4.5 KiB
quinn.vip — Feature Overview
Domain: quinn.vip (prod), vip.quinn.apricot.local (dev)
Feature source: codebase/@features/vip/frontend-client/ (Vite + React SPA, PWA)
Backend: codebase/@features/api/ (shared NestJS API, port 3031)
What it is
A private, per-client portal. Each invited client gets a unique token URL (/portal/:token). Behind that URL is an encrypted messaging channel + relationship data — upcoming dates, billing history, shared photos, a personal story, and account settings.
The app is a PWA installable to the home screen. It ships multiple disguise manifests so the icon can appear as Calculator, Notes, Weather, or Fitness on a client's device.
Auth flow
/:token
└─ getAuthStatus(token)
├─ hasPassword: false → SetPasswordScreen (first visit)
└─ hasPassword: true → LoginScreen
├─ hasWebAuthn: true → auto-trigger WebAuthn
└─ password form → verifyPassword(token, pw)
└─ onUnlocked → sessionStorage.setItem('vip_auth_ok', '1')
Session flag vip_auth_ok in sessionStorage skips the auth check on tab re-focus within the same session. Closing the browser tab clears it.
Storage keys
| Key | Storage | Purpose |
|---|---|---|
vip_token |
localStorage |
Persists the token across sessions |
vip_auth_ok |
sessionStorage |
Auth bypass within a single browser session |
vip_content_key |
sessionStorage |
AES-GCM content decryption key, derived at login |
vip_welcomed_<token> |
sessionStorage |
Prevents welcome splash from re-showing |
Encryption
Messages and sensitive content are encrypted server-side per-client. The content key is derived from the client's password via PBKDF2 (or Argon2 depending on settings). On login, verifyPassword returns the raw contentKey which is stashed in sessionStorage for the tab's lifetime.
The Settings tab exposes the encryption algorithm, KDF, iteration count, and key creation date.
Disguise mode (PWA)
public/ contains multiple manifest-*.json files. At install time, the user selects a disguise:
| Disguise | Manifest | Icon |
|---|---|---|
| Default | manifest-default.json |
Crown/star |
| Calculator | manifest-calculator.json |
Calculator |
| Notes | manifest-notes.json |
Notes |
| Weather | manifest-weather.json |
Weather |
| Fitness | manifest-fitness.json |
Fitness |
DisguisePicker.tsx handles selection. The service worker (sw.js) caches the correct manifest.
API base
| Env | Value | How |
|---|---|---|
Dev (vip.quinn.apricot.local) |
'' (empty) |
Relative URLs → Vite proxy → localhost:3030 |
Prod (vip.transquinnftw.com) |
https://my.transquinnftw.com/api/v2 |
Set at build time in deploy.sh |
The prod path works because my.transquinnftw.com/api/v2/ nginx block proxies to :3030/.
Important: every API sub-path (/invites, /relationship, /messages, /push, /referrals, /billing, /auth, /settings) must be listed in the Vite proxy apiPaths array in vite.config.ts. A missing entry causes the Vite dev server to serve the SPA HTML instead of forwarding to the API. The dev server must be restarted after any change to vite.config.ts.
Header
The portal header renders "Quinn's {Tier} VIP Area" where tier is the reputation tier Quinn assigned (Bronze / Silver / Gold / Diamond). Falls back to "Quinn's VIP Area" when no tier is set. Tier is fetched from getRelationship() on unlock.
Tabs
| Tab | Internal id | Doc |
|---|---|---|
| Messages | messages |
messages.md |
| Dates | reservations |
dates.md |
| Billing | billing |
billing.md |
| Story | story |
story.md |
| Gallery | gallery |
gallery.md |
| Settings | settings |
settings.md |
Key files
| Path | Purpose |
|---|---|
frontend-client/src/pages/VipPortalPage.tsx |
Main component — all tabs, auth states, nav |
frontend-client/src/api.ts |
All API calls + TypeScript types |
frontend-client/src/push.ts |
Web Push subscription registration |
frontend-client/src/hooks/useMessageStream.ts |
SSE message streaming hook |
frontend-client/src/components/DisguisePicker.tsx |
PWA disguise selection UI |
frontend-client/src/disguises.ts |
Disguise config constants |
frontend-client/vite.config.ts |
Build config, PWA plugin |