From 760279fac180243f6a8a04e898d5ab24148e4b92 Mon Sep 17 00:00:00 2001 From: Humza Butt Date: Fri, 3 Jul 2026 13:27:25 +0100 Subject: [PATCH 1/9] Implement JWT authentication and API key management features - Added support for JWT authentication, allowing users to exchange API keys for JWTs via the new `/auth/token` endpoint. - Introduced API key management with routes for listing and creating API keys, enhancing security and user control. - Updated the OpenAPI specification to reflect new authentication methods and security schemes. - Enhanced the principal resolution logic to support JWTs and API keys, improving authorization handling across the application. - Added tests to ensure proper functionality of the new authentication features and their integration with existing components. --- .cursor/rules/110-docs.mdc | 1 + .cursor/rules/34-auth.mdc | 37 ++++++ .env.example | 1 + apps/api/src/__tests__/jwt.test.ts | 24 ++++ apps/api/src/__tests__/principal.test.ts | 16 ++- apps/api/src/app.ts | 2 + apps/api/src/env.ts | 2 + apps/api/src/lib/jwt.ts | 58 +++++++++ apps/api/src/openapi/spec.ts | 22 +++- apps/api/src/principal.ts | 55 +++++--- apps/api/src/routes/auth.ts | 74 +++++++++++ apps/api/src/routes/media.ts | 3 +- apps/api/src/routes/sessions.ts | 2 +- apps/api/src/services/api-keys.ts | 46 +++++++ apps/web/src/App.tsx | 7 +- apps/web/src/components/OwnerSwitcher.tsx | 24 ++++ apps/web/src/components/ui/form-fields.tsx | 21 +++- apps/web/src/content/field-tooltips.ts | 4 + apps/web/src/lib/api.ts | 24 +++- apps/web/src/lib/auth-store.ts | 55 ++++++++ apps/web/src/lib/auth.tsx | 85 +++++++++++++ apps/web/src/lib/upload-media.ts | 6 +- apps/web/src/main.tsx | 14 ++- apps/web/src/routes/ApiKeys.tsx | 139 +++++++++++++++++++++ docs/AUTH.md | 43 +++++++ docs/README.md | 1 + docs/ROADMAP.md | 16 +-- docs/checklists/04-enhancements.md | 12 +- docs/examples/neon-jwt-rls.sql | 19 +++ packages/db/migrations/0001_api_keys.sql | 17 +++ packages/db/migrations/meta/_journal.json | 7 ++ packages/db/migrations/zzzz_force_rls.sql | 1 + packages/db/src/api-keys.ts | 29 +++++ packages/db/src/index.ts | 1 + packages/db/src/schema.ts | 36 +++++- packages/shared/src/auth.ts | 51 ++++++++ packages/shared/src/index.ts | 1 + scripts/rls-check.mjs | 2 +- scripts/wrangler-dev.mjs | 3 + 39 files changed, 913 insertions(+), 48 deletions(-) create mode 100644 .cursor/rules/34-auth.mdc create mode 100644 apps/api/src/__tests__/jwt.test.ts create mode 100644 apps/api/src/lib/jwt.ts create mode 100644 apps/api/src/routes/auth.ts create mode 100644 apps/api/src/services/api-keys.ts create mode 100644 apps/web/src/components/OwnerSwitcher.tsx create mode 100644 apps/web/src/lib/auth-store.ts create mode 100644 apps/web/src/lib/auth.tsx create mode 100644 apps/web/src/routes/ApiKeys.tsx create mode 100644 docs/AUTH.md create mode 100644 docs/examples/neon-jwt-rls.sql create mode 100644 packages/db/migrations/0001_api_keys.sql create mode 100644 packages/db/src/api-keys.ts create mode 100644 packages/shared/src/auth.ts diff --git a/.cursor/rules/110-docs.mdc b/.cursor/rules/110-docs.mdc index c11937b..e6f9403 100644 --- a/.cursor/rules/110-docs.mdc +++ b/.cursor/rules/110-docs.mdc @@ -7,3 +7,4 @@ globs: "**/*.md" - Reviewer-facing docs (`SUBMISSION.md`, checklists, spec mapping): see **111-submission-docs** — sync with [docs/HOMEWORK-SPEC.md](../../docs/HOMEWORK-SPEC.md) when rubric-relevant work ships; professional yet personable tone for 501. - Game mode “how to play” copy: see **32-game-guides** — single source `apps/web/src/content/game-guides.ts` + [docs/GAME-GUIDES.md](../../docs/GAME-GUIDES.md). - Form field tooltips: see **33-field-tooltips** — `field-tooltips.ts` + [docs/FIELD-TOOLTIPS.md](../../docs/FIELD-TOOLTIPS.md). +- Auth / JWT / API keys: see **34-auth** — [docs/AUTH.md](../../docs/AUTH.md). diff --git a/.cursor/rules/34-auth.mdc b/.cursor/rules/34-auth.mdc new file mode 100644 index 0000000..6304371 --- /dev/null +++ b/.cursor/rules/34-auth.mdc @@ -0,0 +1,37 @@ +--- +description: JWT auth, API keys, owner switcher, RLS upgrade path +globs: "apps/{api,web}/src/**/*.{ts,tsx}" +--- +# Auth & multi-tenant (Tier 4) + +## Request principal + +Resolve order in `resolvePrincipal()`: + +1. `Authorization: Bearer` JWT (or `?token=` for WebSocket) +2. `x-oche-owner` / `?key=` API key (demo, env JSON, or `api_keys` table) + +Every DB call still uses `withPrincipal(db, ownerId, fn)` until Neon JWT RLS is enabled — see docs/AUTH.md. + +## Secrets + +- `OCHE_JWT_SECRET` — Wrangler secret per env; never in the SPA bundle. +- API keys stored **hashed** (SHA-256) in `api_keys`; plaintext returned once on create. +- Demo keys (`demo-key-a/b`) remain for RLS isolation demos. + +## SPA + +- Use `AuthProvider` + `useAuth()`; never hardcode `demo-key-a` in `api.ts`. +- Owner switcher invalidates TanStack Query cache on venue change. +- API key UI at `/settings/keys`. + +## Adding auth to a new route + +```typescript +const ownerId = await resolvePrincipal(c, getDb(c.env)); +if (!ownerId) return c.json({ error: 'Unauthorised' }, 401); +``` + +## Neon upgrade + +Document only in take-home — do not drop GUC policies without migration plan. Reference: `docs/examples/neon-jwt-rls.sql`. diff --git a/.env.example b/.env.example index 9bf772f..1f32024 100644 --- a/.env.example +++ b/.env.example @@ -25,3 +25,4 @@ VITE_API_BASE_STAGING="https://oche-api-staging.humza-butt.space" VITE_API_BASE_PROD="https://oche-api.humza-butt.space" APP_ORIGIN="http://localhost:5173" MEDIA_SIGNING_SECRET="dev-media-signing-secret-change-in-prod" +OCHE_JWT_SECRET="dev-jwt-secret-change-in-prod" diff --git a/apps/api/src/__tests__/jwt.test.ts b/apps/api/src/__tests__/jwt.test.ts new file mode 100644 index 0000000..53f6980 --- /dev/null +++ b/apps/api/src/__tests__/jwt.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { signJwt, verifyJwt } from '../lib/jwt.js'; + +describe('jwt', () => { + const secret = 'test-jwt-secret'; + + it('signs and verifies a token', async () => { + const sub = '11111111-1111-1111-1111-111111111111'; + const token = await signJwt(sub, secret, 60); + const payload = await verifyJwt(token, secret); + expect(payload).toEqual({ sub }); + }); + + it('rejects tampered tokens', async () => { + const token = await signJwt('11111111-1111-1111-1111-111111111111', secret, 60); + const bad = `${token}x`; + expect(await verifyJwt(bad, secret)).toBeNull(); + }); + + it('rejects expired tokens', async () => { + const token = await signJwt('11111111-1111-1111-1111-111111111111', secret, -1); + expect(await verifyJwt(token, secret)).toBeNull(); + }); +}); diff --git a/apps/api/src/__tests__/principal.test.ts b/apps/api/src/__tests__/principal.test.ts index 75edef2..eb4c0e8 100644 --- a/apps/api/src/__tests__/principal.test.ts +++ b/apps/api/src/__tests__/principal.test.ts @@ -1,17 +1,18 @@ import { describe, expect, it } from 'vitest'; import { Hono } from 'hono'; import type { Env } from '../env.js'; +import { signJwt } from '../lib/jwt.js'; import { resolvePrincipal } from '../principal.js'; function appWithEnv(env: Partial) { const app = new Hono<{ Bindings: Env }>(); - app.get('/', (c) => c.json({ ownerId: resolvePrincipal(c) })); + app.get('/', async (c) => c.json({ ownerId: await resolvePrincipal(c) })); return { app, env: env as Env }; } describe('resolvePrincipal', () => { it('resolves built-in demo keys', async () => { - const { app, env } = appWithEnv({} as Env); + const { app, env } = appWithEnv({}); const res = await app.request('/', { headers: { 'x-oche-owner': 'demo-key-a' } }, env); expect(await res.json()).toEqual({ ownerId: '11111111-1111-1111-1111-111111111111' }); }); @@ -27,8 +28,17 @@ describe('resolvePrincipal', () => { }); it('returns null for unknown keys', async () => { - const { app, env } = appWithEnv({} as Env); + const { app, env } = appWithEnv({}); const res = await app.request('/', { headers: { 'x-oche-owner': 'invalid' } }, env); expect(await res.json()).toEqual({ ownerId: null }); }); + + it('resolves Bearer JWT', async () => { + const secret = 'jwt-test-secret'; + const ownerId = '11111111-1111-1111-1111-111111111111'; + const token = await signJwt(ownerId, secret, 60); + const { app, env } = appWithEnv({ OCHE_JWT_SECRET: secret } as Env); + const res = await app.request('/', { headers: { Authorization: `Bearer ${token}` } }, env); + expect(await res.json()).toEqual({ ownerId }); + }); }); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9a2652a..f06336a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,6 +3,7 @@ import { cors } from 'hono/cors'; import type { Env } from './env.js'; import { bodySizeLimit, rateLimit, securityHeaders } from './middleware.js'; import { openApiDocument } from './openapi/spec.js'; +import { auth } from './routes/auth.js'; import { media } from './routes/media.js'; import { sessions } from './routes/sessions.js'; @@ -16,6 +17,7 @@ export function createApp() { app.get('/health', (c) => c.json({ ok: true, env: c.env.ENVIRONMENT })); app.get('/openapi.json', (c) => c.json(openApiDocument)); + app.route('/auth', auth); app.route('/media', media); app.use('/sessions/*', bodySizeLimit()); app.route('/sessions', sessions); diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index b43d1d7..81ad4ac 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -11,6 +11,8 @@ export interface Env { MEDIA_SIGNING_SECRET?: string; /** JSON map of api-key → owner uuid (local dev; from .oche-keys.json). */ OCHE_API_KEYS?: string; + /** HS256 secret for short-lived JWTs (Wrangler secret in staging/prod). */ + OCHE_JWT_SECRET?: string; APP_ORIGIN: string; ENVIRONMENT: 'development' | 'staging' | 'production'; } diff --git a/apps/api/src/lib/jwt.ts b/apps/api/src/lib/jwt.ts new file mode 100644 index 0000000..8b3a168 --- /dev/null +++ b/apps/api/src/lib/jwt.ts @@ -0,0 +1,58 @@ +const encoder = new TextEncoder(); + +function base64UrlEncode(bytes: Uint8Array): string { + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function base64UrlDecode(input: string): Uint8Array { + const padded = input + .replace(/-/g, '+') + .replace(/_/g, '/') + .padEnd(Math.ceil(input.length / 4) * 4, '='); + const binary = atob(padded); + const out = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i); + return out; +} + +async function importHmacKey(secret: string): Promise { + return crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [ + 'sign', + 'verify', + ]); +} + +export async function signJwt(sub: string, secret: string, ttlSec = 3600): Promise { + const now = Math.floor(Date.now() / 1000); + const header = base64UrlEncode(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))); + const payload = base64UrlEncode(encoder.encode(JSON.stringify({ sub, iat: now, exp: now + ttlSec }))); + const data = `${header}.${payload}`; + const key = await importHmacKey(secret); + const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(data)); + return `${data}.${base64UrlEncode(new Uint8Array(sig))}`; +} + +export async function verifyJwt(token: string, secret: string): Promise<{ sub: string } | null> { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [header, payload, sig] = parts as [string, string, string]; + const data = `${header}.${payload}`; + const key = await importHmacKey(secret); + const ok = await crypto.subtle.verify('HMAC', key, base64UrlDecode(sig), encoder.encode(data)); + if (!ok) return null; + + try { + const json = JSON.parse(new TextDecoder().decode(base64UrlDecode(payload))) as { + sub?: string; + exp?: number; + }; + if (!json.sub || typeof json.exp !== 'number' || json.exp < Math.floor(Date.now() / 1000)) { + return null; + } + return { sub: json.sub }; + } catch { + return null; + } +} diff --git a/apps/api/src/openapi/spec.ts b/apps/api/src/openapi/spec.ts index 71eca22..b917b87 100644 --- a/apps/api/src/openapi/spec.ts +++ b/apps/api/src/openapi/spec.ts @@ -5,12 +5,13 @@ export const openApiDocument = { title: 'Oche API', version: '1.0.0', description: - 'Game Session Dashboard API. All /sessions routes require header `x-oche-owner` (take-home stand-in for JWT auth).', + 'Game Session Dashboard API. Auth: Bearer JWT (preferred) or `x-oche-owner` API key. See /auth/token.', }, servers: [{ url: 'http://localhost:8787', description: 'Local wrangler dev' }], - tags: [{ name: 'sessions' }, { name: 'media' }], + tags: [{ name: 'auth' }, { name: 'sessions' }, { name: 'media' }], components: { securitySchemes: { + BearerJwt: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, OcheOwner: { type: 'apiKey', in: 'header', name: 'x-oche-owner' }, }, schemas: { @@ -152,8 +153,23 @@ export const openApiDocument = { }, }, }, - security: [{ OcheOwner: [] }], + security: [{ BearerJwt: [] }, { OcheOwner: [] }], paths: { + '/auth/token': { + post: { + tags: ['auth'], + security: [{ OcheOwner: [] }], + summary: 'Exchange API key for JWT', + responses: { + '200': { description: 'JWT issued' }, + '401': { description: 'Unauthorised' }, + }, + }, + }, + '/auth/keys': { + get: { tags: ['auth'], summary: 'List API keys for current owner' }, + post: { tags: ['auth'], summary: 'Create API key (plaintext returned once)' }, + }, '/health': { get: { security: [], diff --git a/apps/api/src/principal.ts b/apps/api/src/principal.ts index 95bdc9a..4524c32 100644 --- a/apps/api/src/principal.ts +++ b/apps/api/src/principal.ts @@ -1,17 +1,8 @@ import type { Context } from 'hono'; +import { DEMO_OWNERS } from '@oche/shared'; +import { resolveApiKeyOwner, type Db } from '@oche/db'; import type { Env } from './env.js'; - -/** - * Resolve the request principal (owner id). - * - * TAKE-HOME STAND-IN: a signed `x-oche-owner` header / API key maps to an owner uuid. - * PRODUCTION: verify a JWT (e.g. Neon Auth / OIDC) and read the subject; feed it to RLS - * via `auth.user_id()` instead of the GUC. See docs/RLS.md. - */ -const DEMO_OWNERS: Record = { - 'demo-key-a': '11111111-1111-1111-1111-111111111111', - 'demo-key-b': '22222222-2222-2222-2222-222222222222', -}; +import { verifyJwt } from './lib/jwt.js'; function extraOwners(env: Env): Record { if (!env.OCHE_API_KEYS) return {}; @@ -28,9 +19,43 @@ function extraOwners(env: Env): Record { } } -export function resolvePrincipal(c: Context): string | null { +async function ownerFromJwt(c: Context): Promise { + const secret = (c.env as Env).OCHE_JWT_SECRET; + if (!secret) return null; + + const auth = c.req.header('Authorization'); + const bearer = auth?.startsWith('Bearer ') ? auth.slice(7) : null; + const queryToken = c.req.query('token'); + const token = bearer ?? queryToken; + if (!token) return null; + + const payload = await verifyJwt(token, secret); + return payload?.sub ?? null; +} + +async function ownerFromApiKey(c: Context, db: Db | undefined, key: string): Promise { + const owners: Record = { ...DEMO_OWNERS, ...extraOwners(c.env as Env) }; + if (owners[key]) return owners[key]; + if (!db) return null; + return resolveApiKeyOwner(db, key); +} + +/** + * Resolve the request principal (owner id). + * + * Order: Bearer JWT (or `?token=` for WebSockets) → `x-oche-owner` / `?key=` API key. + * API keys may be built-in demo keys, env JSON map, or hashed rows in `api_keys`. + * + * PRODUCTION upgrade: Neon JWT + `auth.user_id()` RLS — see docs/AUTH.md. + */ +export async function resolvePrincipal( + c: Context, + db?: Db, +): Promise { + const fromJwt = await ownerFromJwt(c); + if (fromJwt) return fromJwt; + const key = c.req.header('x-oche-owner') ?? c.req.query('key'); if (!key) return null; - const owners = { ...DEMO_OWNERS, ...extraOwners(c.env) }; - return owners[key] ?? null; + return ownerFromApiKey(c, db, key); } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..bd13f15 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,74 @@ +import { CreateApiKeyInput } from '@oche/shared'; +import { Hono } from 'hono'; +import type { Env } from '../env.js'; +import { readJsonBody, validationError } from '../lib/errors.js'; +import { getDb } from '../lib/db.js'; +import { signJwt } from '../lib/jwt.js'; +import { resolvePrincipal } from '../principal.js'; +import { createApiKey, listApiKeys, revokeApiKey } from '../services/api-keys.js'; + +export type AuthVariables = { ownerId: string }; + +const auth = new Hono<{ Bindings: Env; Variables: AuthVariables }>(); + +/** Exchange an API key for a short-lived JWT (Bearer auth for REST + WebSocket). */ +auth.post('/token', async (c) => { + const db = getDb(c.env); + const ownerId = await resolvePrincipal(c, db); + if (!ownerId) return c.json({ error: 'Unauthorised' }, 401); + + const secret = c.env.OCHE_JWT_SECRET; + if (!secret) return c.json({ error: 'JWT auth is not configured' }, 503); + + const expiresIn = 3600; + const token = await signJwt(ownerId, secret, expiresIn); + return c.json({ token, expiresIn, ownerId }); +}); + +auth.use('*', async (c, next) => { + const db = getDb(c.env); + const ownerId = await resolvePrincipal(c, db); + if (!ownerId) return c.json({ error: 'Unauthorised' }, 401); + c.set('ownerId', ownerId); + await next(); +}); + +auth.get('/me', (c) => c.json({ ownerId: c.get('ownerId') })); + +auth.get('/keys', async (c) => { + const rows = await listApiKeys(getDb(c.env), c.get('ownerId')); + return c.json({ + data: rows.map((r) => ({ + id: r.id, + label: r.label, + keyPrefix: r.keyPrefix, + createdAt: r.createdAt.toISOString(), + revokedAt: r.revokedAt?.toISOString() ?? null, + })), + }); +}); + +auth.post('/keys', async (c) => { + const parsed = CreateApiKeyInput.safeParse(await readJsonBody(c)); + if (!parsed.success) return validationError(c, parsed.error); + + const created = await createApiKey(getDb(c.env), c.get('ownerId'), parsed.data); + return c.json( + { + id: created.id, + label: created.label, + key: created.key, + keyPrefix: created.keyPrefix, + createdAt: created.createdAt.toISOString(), + }, + 201, + ); +}); + +auth.delete('/keys/:id', async (c) => { + const ok = await revokeApiKey(getDb(c.env), c.get('ownerId'), c.req.param('id')); + if (!ok) return c.json({ error: 'API key not found' }, 404); + return c.json({ ok: true as const }); +}); + +export { auth }; diff --git a/apps/api/src/routes/media.ts b/apps/api/src/routes/media.ts index 84afa39..54f0862 100644 --- a/apps/api/src/routes/media.ts +++ b/apps/api/src/routes/media.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { buildMediaKey } from '../lib/media-keys.js'; import { parseRangeHeader, validateUpload } from '../lib/media-policy.js'; import { buildSignedMediaUrl, verifyMediaKey } from '../lib/signed-url.js'; +import { getDb } from '../lib/db.js'; import { resolvePrincipal } from '../principal.js'; import type { Env } from '../env.js'; @@ -9,7 +10,7 @@ const media = new Hono<{ Bindings: Env }>(); /** Upload photo/video to R2 (immutable hashed key). Requires principal header. */ media.post('/upload', async (c) => { - const ownerId = resolvePrincipal(c); + const ownerId = await resolvePrincipal(c, getDb(c.env)); if (!ownerId) return c.json({ error: 'Unauthorised' }, 401); const mime = c.req.header('content-type')?.split(';')[0]?.trim() ?? ''; diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts index 46f4fd1..b92e201 100644 --- a/apps/api/src/routes/sessions.ts +++ b/apps/api/src/routes/sessions.ts @@ -19,7 +19,7 @@ export type ApiVariables = { ownerId: string }; const sessions = new Hono<{ Bindings: Env; Variables: ApiVariables }>(); sessions.use('*', async (c, next) => { - const ownerId = resolvePrincipal(c); + const ownerId = await resolvePrincipal(c, getDb(c.env)); if (!ownerId) return c.json({ error: 'Unauthorised' }, 401); c.set('ownerId', ownerId); await next(); diff --git a/apps/api/src/services/api-keys.ts b/apps/api/src/services/api-keys.ts new file mode 100644 index 0000000..4b76834 --- /dev/null +++ b/apps/api/src/services/api-keys.ts @@ -0,0 +1,46 @@ +import { apiKeys, generateApiKeyPlaintext, hashApiKey, withPrincipal, type Db } from '@oche/db'; +import { eq, desc } from 'drizzle-orm'; +import type { CreateApiKeyInput } from '@oche/shared'; + +export async function listApiKeys(db: Db, ownerId: string) { + return withPrincipal(db, ownerId, async (tx) => + tx + .select({ + id: apiKeys.id, + label: apiKeys.label, + keyPrefix: apiKeys.keyPrefix, + createdAt: apiKeys.createdAt, + revokedAt: apiKeys.revokedAt, + }) + .from(apiKeys) + .orderBy(desc(apiKeys.createdAt)), + ); +} + +export async function createApiKey(db: Db, ownerId: string, input: CreateApiKeyInput) { + const plaintext = generateApiKeyPlaintext(); + const keyHash = await hashApiKey(plaintext); + const keyPrefix = plaintext.slice(0, 12); + + const [row] = await withPrincipal(db, ownerId, async (tx) => + tx.insert(apiKeys).values({ ownerId, label: input.label, keyHash, keyPrefix }).returning({ + id: apiKeys.id, + label: apiKeys.label, + keyPrefix: apiKeys.keyPrefix, + createdAt: apiKeys.createdAt, + }), + ); + + return { ...row!, key: plaintext }; +} + +export async function revokeApiKey(db: Db, ownerId: string, keyId: string): Promise { + const updated = await withPrincipal(db, ownerId, async (tx) => + tx + .update(apiKeys) + .set({ revokedAt: new Date() }) + .where(eq(apiKeys.id, keyId)) + .returning({ id: apiKeys.id }), + ); + return updated.length > 0; +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0ef4c19..1f09b09 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,7 +1,8 @@ -import { useLocation } from 'react-router-dom'; +import { useLocation, Link } from 'react-router-dom'; import { AnimatedOutlet } from '@/components/motion/AnimatedOutlet'; import { TabStrip } from '@/components/motion/TabStrip'; import { ThemeToggle } from '@/components/ThemeToggle'; +import { OwnerSwitcher } from '@/components/OwnerSwitcher'; import { Button } from '@/components/ui/button'; import { useNavActiveId } from '@/lib/motion'; @@ -28,6 +29,10 @@ export default function App() {
+ +
diff --git a/apps/web/src/components/OwnerSwitcher.tsx b/apps/web/src/components/OwnerSwitcher.tsx new file mode 100644 index 0000000..d3043ee --- /dev/null +++ b/apps/web/src/components/OwnerSwitcher.tsx @@ -0,0 +1,24 @@ +import { DEMO_VENUES, useAuth } from '@/lib/auth'; +import { SelectField } from '@/components/ui/form-fields'; +import { FIELD_TOOLTIPS } from '@/content/field-tooltips'; + +export function OwnerSwitcher() { + const { apiKey, switchVenue, ready } = useAuth(); + + return ( + void switchVenue(e.target.value)} + className="w-[9.5rem] text-sm" + > + {DEMO_VENUES.map((v) => ( + + ))} + + ); +} diff --git a/apps/web/src/components/ui/form-fields.tsx b/apps/web/src/components/ui/form-fields.tsx index 7215acd..94b2099 100644 --- a/apps/web/src/components/ui/form-fields.tsx +++ b/apps/web/src/components/ui/form-fields.tsx @@ -75,19 +75,36 @@ export type SelectFieldProps = Omit - + {hideLabel ? ( + + + + ) : ( + + )} ` dropdowns | -| `FileField` | Visible file inputs | +| `FileField` | Visible file inputs — hidden `` + outline **Choose file** button (same affordance as `MediaUpload`) | | `MediaUpload` | Hidden file input + upload UX | | `FieldTooltipIcon` | Custom labels (only if wrapping a control manually) | @@ -46,6 +46,7 @@ import { FIELD_TOOLTIPS } from '@/content/field-tooltips'; ## Don't - Use placeholder text as the only help (placeholders ≠ tooltips). +- Ship a bare native file input in the UI — always use the hidden input + visible button pattern. - Ship `title=""` or omit `aria-describedby`. - Add `@radix-ui/react-tooltip` unless explicitly requested — use shared `form-fields` pattern. diff --git a/apps/web/src/components/ui/form-fields.tsx b/apps/web/src/components/ui/form-fields.tsx index 94b2099..ce0be8f 100644 --- a/apps/web/src/components/ui/form-fields.tsx +++ b/apps/web/src/components/ui/form-fields.tsx @@ -1,4 +1,5 @@ -import { useId } from 'react'; +import { useId, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; const inputClass = @@ -120,20 +121,55 @@ export type FileFieldProps = Omit, ' tooltip: string; }; -export function FileField({ label, tooltip, id, className, ...props }: FileFieldProps) { +export function FileField({ label, tooltip, id, className, onChange, disabled, ...props }: FileFieldProps) { const uid = useId(); const inputId = id ?? uid; const tipId = `${inputId}-tip`; + const inputRef = useRef(null); + const [fileName, setFileName] = useState(null); + + function handleChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + setFileName(file?.name ?? null); + onChange?.(e); + } return ( -
+
+
+ + + {fileName ?? 'No file chosen'} + +