A portfolio that is also a working product. Live at theppratan.dev — not a static résumé page, but a running full-stack application with real authentication, a real database, a protected file vault, and a game you can play against a live global leaderboard.
Built by Theppratan Junpanya — Computer Engineering & Business Administration graduate. Bangkok / Nonthaburi, remote-ready.
🇹🇭 สรุปสั้น ๆ: เว็บพอร์ตโฟลิโอที่ไม่ใช่หน้าเว็บนิ่ง ๆ แต่เป็นระบบจริงที่รันอยู่บน production มีระบบสมาชิก ฐานข้อมูลจริง พื้นที่ไฟล์ที่ป้องกันด้วยการยืนยันตัวตน และมินิเกมที่บันทึกคะแนนลงกระดานอันดับจริง เขียนขึ้นเองทั้งหมด ไม่ใช้เทมเพลต — เอกสารภายในฉบับละเอียดอยู่ที่
PROGRESS.md
Most junior portfolios are a list of claims. This one is built so the claims can be verified in the browser, in under two minutes:
| The claim | Where you can check it yourself |
|---|---|
| "I can build secure authentication" | Sign up on the site. The session cookie is HttpOnly — open DevTools and confirm JavaScript cannot read it. |
| "I can protect resources server-side" | GET /api/files/resume returns 401 without a session. The file lives outside public/, so there is no URL to guess. |
| "I understand rate limiting" | Enter a wrong password 5 times. The 5th attempt returns 429, not another 401. |
| "I write real-time interactive code" | Play the mini-game — a requestAnimationFrame loop with fixed-timestep physics, written from scratch on <canvas>. |
| "I care about data integrity" | Try POST /api/leaderboard with score: 999999999. The server rejects it. |
| "I ship to real users" | The Chan Ni-Yom project in the vault is a live restaurant platform: channiyom.com |
| Layer | Choice | Why this one |
|---|---|---|
| Framework | Next.js 16 (App Router, Turbopack) | One codebase for UI and API. Route Handlers keep secrets on the server. |
| Language | TypeScript 5 (strict: true) |
Type errors fail the build — the production build is type-checked, not skipped. |
| UI | React 19 + Tailwind CSS v3 | Utility classes bound to CSS custom properties, so theming is one attribute swap. |
| Database | PostgreSQL (Supabase) | Relational data with real foreign keys and cascade rules. |
| ORM | Prisma 5 | Typed queries; the schema is the single source of truth. |
| Auth | Session tokens in DB + HttpOnly cookies |
Revocable server-side — see the design note below. |
| OAuth | Supabase Auth (Google / GitHub) | Federated login without storing third-party credentials. |
| Hosting | Vercel (sin1, Singapore) |
Edge caching close to the audience. |
No UI kit, no template, no create-*-app starter beyond the initial scaffold. Every component in src/components/ was written for this project.
flowchart TD
subgraph Browser
UI["React client components<br/>Theme + Language context"]
EV{{"window CustomEvent bus<br/>authStateChanged · triggerVaultLogout"}}
UI <--> EV
end
subgraph Vercel["Next.js 16 on Vercel"]
RH["Route Handlers<br/>/api/*"]
SESS["lib/session.ts<br/>single auth checkpoint"]
SEC["lib/security.ts<br/>rate limit · validation"]
RH --> SESS
RH --> SEC
end
subgraph Data
PG[("PostgreSQL / Supabase<br/>users · sessions<br/>audit_logs · leaderboards")]
FILE["private/resume.pdf<br/>outside the public folder"]
end
SB(["Supabase Auth<br/>Google · GitHub"])
UI -- "fetch + HttpOnly cookie" --> RH
UI -- "OAuth redirect" --> SB
SB -- "access token verified server-side" --> RH
SESS --> PG
SEC --> PG
RH -- "only with a valid session" --> FILE
One rule holds the whole thing together: the client never decides who you are. isUnlocked in React only controls what is drawn. Every protected resource re-validates the cookie against the sessions table on the server, on every request.
Two front doors, one lock:
Email + password ──► bcrypt verify ──┐
├──► session row in DB ──► HttpOnly cookie
Google / GitHub ──► Supabase ────────┘ (2h absolute)
(token verified server-side)
JWTs are self-contained, which means you cannot revoke one before it expires. For a site with a "log out everywhere" requirement and an idle-timeout demo, that is the wrong trade. Sessions live in Postgres, so:
- logging out deletes the row — the token dies instantly, not in 15 minutes
- changing your password revokes every other device in the same transaction
- deleting your account cascades sessions away via a database constraint, not application code
The cost is one indexed lookup per request. For this workload that is the right price to pay.
| Layer | Duration | Mechanism |
|---|---|---|
| Absolute timeout | 2 hours | Cookie expiry and sessions.expires_at — both must be valid |
| Sliding renewal | +2 hours | Refreshed whenever GET /api/user/settings is called |
| Idle timeout | configurable | Client-side warning + countdown, then a forced master logout |
| Tab close | immediate | sessionStorage flag disappears; next load forces a full logout |
The idle timer is deliberately careful about one thing: it checks whether a <video> is playing before it fires. Kicking someone out mid-demo-video is a bug, not a security feature.
Concrete measures, not a checklist:
| Area | Implementation |
|---|---|
| Password storage | bcrypt, cost 10 (cost 12 for admin credentials) |
| Session cookie | HttpOnly + Secure + SameSite=Strict |
| Brute force | Failed attempts counted from audit_logs over a 15-minute window → 429. Counting from the database rather than memory is deliberate: serverless instances do not share memory, so an in-process counter would not actually limit anything. |
| User enumeration | Unknown account and wrong password return the same message and status |
| Privilege escalation | Registration hard-codes role: GUEST; the admin role can only be granted by a CLI script run by the operator |
| OAuth spoofing | The access token is verified against Supabase server-side; the email in the request body is ignored in favour of the verified one |
| Input validation | Email format, password length, and score plausibility are all enforced server-side, never trusted from the client |
| Protected files | Stored in private/, streamed only after session validation, Cache-Control: no-store |
| Audit trail | 15+ event types recorded with actor, detail and IP address |
| Response headers | nosniff, SAMEORIGIN, Referrer-Policy, Permissions-Policy; X-Powered-By removed |
| Error handling | Stack traces are logged server-side and never returned to the client |
User ─┬─1:N─ Session (sessionToken unique, expiresAt) [cascade delete]
├─1:N─ AuditLog (action, details, ipAddress) [set null on delete]
└─1:1─ Leaderboard (playerName, score) [cascade delete]
Cascade behaviour is defined in the schema, not in application code — deleting a user cannot leave orphaned sessions behind, even if a future code path forgets to clean up.
Indexes exist for the queries that actually run: audit_logs(action, created_at) powers the rate limiter, sessions(user_id) powers bulk revocation.
| Endpoint | Method | Auth | Purpose |
|---|---|---|---|
/api/auth/register |
POST | — | Create account (validated, rate limited) |
/api/auth/login |
POST | — | Verify password, issue session |
/api/auth/sync |
POST | OAuth token | Exchange a verified Supabase session for ours |
/api/auth/logout |
POST | cookie | Delete session row, force-clear cookie |
/api/user/settings |
GET | cookie | Profile + sliding session renewal |
/api/user/settings |
PATCH | cookie | Update display name |
/api/user/settings |
PUT | cookie | Change password, revoke other sessions |
/api/user/settings |
DELETE | cookie | Delete account (password-confirmed) |
/api/leaderboard |
GET | optional | Top 10, flags the caller's own row |
/api/leaderboard |
POST | cookie | Submit score (validated, keeps personal best) |
/api/leaderboard |
DELETE | cookie | Remove own record |
/api/files/resume |
GET | cookie | Stream protected PDF + audit log |
/api/system-status |
POST | token | Health check with a real database ping |
Signing in unlocks four case studies:
| Project | Stack | Status | |
|---|---|---|---|
| v01 | AI content & YouTube channels | Generative AI · video production | Live |
| v02 | Witch 2.5D RPG | Unity · C# · WebGL | In development |
| v03 | VR motorcycle assembly | Unity · Meta Quest · web dashboard | Delivered |
| v04 | Chan Ni-Yom restaurant platform | .NET 9 Blazor Server · PostgreSQL | Live in production |
v04 is the one to look at. channiyom.com is a working ordering platform for a real Thai restaurant on Chan Road, Rama 3 — bilingual storefront, member accounts, cart, and a separate staff console on admin.channiyom.com where the kitchen manages menu items and prices without a code deploy. It ships with cookie consent and a written privacy policy because it handles real customer data.
It is the web tier of a four-repository system: the shipped web application, a core services backend, a mobile client, and an AR feature — the latter three still to be built, in that order.
npm installCreate .env in the project root:
DATABASE_URL="postgresql://..." # Supabase pooled connection
DIRECT_URL="postgresql://..." # direct connection, for migrations
NEXT_PUBLIC_SUPABASE_URL="https://....supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="..."
SYSTEM_STATUS_TOKEN="..." # optional, overrides the demo tokennpx prisma generate
npx prisma db push # first-time schema sync
npm run dev # http://localhost:3000| Script | Does |
|---|---|
npm run dev |
Development server (Turbopack) |
npm run build |
prisma generate + type-checked production build |
npm run lint |
ESLint (flat config) |
npm run typecheck |
tsc --noEmit |
Creating an admin account — never hard-coded, always explicit:
[email protected] ADMIN_PASSWORD='a-long-random-passphrase' node --env-file=.env scripts/manage-admin.mjssrc/
├── app/
│ ├── layout.tsx Theme + language providers, SEO metadata
│ ├── page.tsx Single-page composition of every section
│ ├── opengraph-image.tsx Social share card, generated at build time
│ ├── robots.ts · sitemap.ts Generated, not hand-maintained
│ └── api/ Route Handlers (auth, leaderboard, files, health)
├── components/
│ ├── Play.tsx Canvas game loop + live leaderboard
│ ├── Vault.tsx Auth flows, protected project viewer, idle timeout
│ ├── Console.tsx Interactive terminal that calls the real health API
│ └── vault-projects/ One component per case study, code-split on open
├── context/ Language (TH/EN) and theme (light/dark) providers
└── lib/
├── prisma.ts Client singleton
├── session.ts The single authentication checkpoint
└── security.ts Rate limiting, IP extraction, input validation
Global event bus instead of a state library. Six components need to react to "the user logged out." Redux or Zustand would work, but this app has exactly one piece of cross-cutting state. window.dispatchEvent(new Event("triggerVaultLogout")) handles it with zero dependencies, and logout logic exists in exactly one function — no duplicated cleanup drifting out of sync.
Design tokens as CSS custom properties. Theme switching sets one data-theme attribute on <html>. No re-render, no flash, no theme provider re-computing class names — and the <canvas> game reads the same variables, so the game recolours with the site.
Everything the client sends is treated as hostile. Score is validated for plausibility. The OAuth email is discarded in favour of the token-verified one. role is never read from a request body. The client is a rendering surface, not an authority.
Bilingual from the first commit. Every string ships as { en, th }. Retrofitting i18n is expensive; doing it from the start costs almost nothing.
Theppratan Junpanya ✉ [email protected] · 💼 LinkedIn · 💻 GitHub
Résumé is available on the site after signing in — that gate is itself part of the demo.