diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 1f25f82..968bdd3 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -11,10 +11,12 @@ * - No console.log of request bodies or passwords in any handler (T-10-10). * * Routes: - * GET /api/admin/members → list members + credential status (UI-SPEC Surface 2) - * POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01) - * GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5) - * PUT /api/admin/calendars/:id/shared → exclusive is_shared designation (ADMIN-02) + * GET /api/admin/members → list members + credential status (UI-SPEC Surface 2) + * POST /api/admin/members → create local member: users row + local_credentials (AUTH-LOCAL-07) + * POST /api/admin/members/:id/password → admin reset local member password (AUTH-LOCAL-08) + * POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01) + * GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5) + * PUT /api/admin/calendars/:id/shared → exclusive is_shared designation (ADMIN-02) * * Mounted in index.ts: app.route('/api/admin', adminRouter) */ @@ -25,13 +27,15 @@ import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; import { eq, sql } from 'drizzle-orm'; import { db } from '../db/client.js'; -import { users, memberCredentials, calendars, appConfig } from '../db/schema.js'; +import { users, memberCredentials, calendars, appConfig, localCredentials } from '../db/schema.js'; import { requireAdmin } from '../lib/requireAdmin.js'; import { isValidIanaTimezone, resolveHouseholdTimezone } from '../lib/householdTimezone.js'; import { validateEncryptAndStoreCredential, CredentialValidationError, } from '../broker/credentialSync.js'; +import { hashPassword } from '../auth/localCredentials.js'; +import { COLOR_PALETTE } from '../auth/user.js'; // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') import '../auth/devBypass.js'; @@ -78,6 +82,7 @@ const noEchoHook = (result: { success: boolean }, c: Context) => { // // Returns all household members with their credential status. // Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance). +// Includes hasLocalCredential (AUTH-LOCAL-17) alongside existing hasCredential. // --------------------------------------------------------------------------- adminRouter.get('/members', async (c) => { @@ -87,20 +92,162 @@ adminRouter.get('/members', async (c) => { displayName: users.displayName, color: users.color, credentialId: memberCredentials.id, + localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row }) .from(users) - .leftJoin(memberCredentials, eq(memberCredentials.userId, users.id)); + .leftJoin(memberCredentials, eq(memberCredentials.userId, users.id)) + .leftJoin(localCredentials, eq(localCredentials.userId, users.id)); // AUTH-LOCAL-17 const members = rows.map((row) => ({ id: row.id, displayName: row.displayName, color: row.color, hasCredential: row.credentialId !== null, + hasLocalCredential: row.localCredId !== null, // AUTH-LOCAL-17 })); return c.json({ members }); }); +// --------------------------------------------------------------------------- +// POST /api/admin/members +// +// Admin creates a new local member: inserts a users row and a local_credentials +// row with a hashed initial password in a single transaction (AUTH-LOCAL-07). +// Security: +// - noEchoHook: never echoes Zod errors containing the submitted password (T-19-06) +// - requireAdmin: already enforced by adminRouter.use('*', requireAdmin) (T-19-05) +// - db.transaction: rolls back both inserts on username conflict (T-19-10) +// --------------------------------------------------------------------------- + +const createMemberSchema = z.object({ + displayName: z.string().min(1).max(256), + username: z.string().min(1).max(128), + initialPassword: z.string().min(8), +}); + +adminRouter.post( + '/members', + zValidator('json', createMemberSchema, noEchoHook), + async (c) => { + const { displayName, username, initialPassword } = c.req.valid('json'); + // T-19-06: NEVER log request body, displayName, username, or initialPassword here + + // Assign the first palette color not already in use (mirrors upsertUser color logic) + const usedRows = await db.select({ color: users.color }).from(users); + const usedColors = new Set(usedRows.map((r) => r.color)); + const color = + COLOR_PALETTE.find((c) => !usedColors.has(c)) ?? + COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; + + try { + let newUserId: number; + + // T-19-10: atomic transaction — both inserts succeed or both roll back + await db.transaction(async (tx) => { + // Insert the new users row (no oidcIss/oidcSub — local-only member) + const [inserted] = await tx + .insert(users) + .values({ + displayName, + color, + isAdmin: false, + claimed: false, // no OIDC identity bound yet + }) + .$returningId(); + newUserId = inserted.id; + + // Insert local_credentials row with hashed initial password + // If username is already in use, the UNIQUE constraint fires here and rolls back + await tx.insert(localCredentials).values({ + userId: newUserId, + username, + passwordHash: hashPassword(initialPassword), + }); + }); + + // Return the new user's id (the PWA uses it to navigate to the member) + return c.json({ id: newUserId! }, 201); + } catch (err) { + // Username uniqueness violation — UNIQUE constraint on local_credentials.username. + // Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY. + const isDup = + (err instanceof Error && err.message.includes('ER_DUP_ENTRY')) || + (err != null && + typeof err === 'object' && + 'code' in err && + (err as { code?: string }).code === 'ER_DUP_ENTRY') || + (err != null && + typeof err === 'object' && + 'cause' in err && + (err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY'); + if (isDup) { + return c.json({ error: 'Username already in use' }, 409); + } + // Unexpected errors — log message only, never the body or password + console.error( + '[admin/POST /members] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } + }, +); + +// --------------------------------------------------------------------------- +// POST /api/admin/members/:id/password +// +// Admin resets a local member's password without knowing the current one (AUTH-LOCAL-08). +// Security: +// - noEchoHook: never echoes Zod errors (T-19-06) +// - requireAdmin: enforced by adminRouter.use('*', requireAdmin) (T-19-05) +// - No current password required — admin-only capability +// --------------------------------------------------------------------------- + +const resetPasswordSchema = z.object({ + newPassword: z.string().min(8), +}); + +adminRouter.post( + '/members/:id/password', + zValidator('json', resetPasswordSchema, noEchoHook), + async (c) => { + const targetId = parseInt(c.req.param('id'), 10); + if (isNaN(targetId)) { + return c.json({ error: 'Invalid member id' }, 400); + } + + const { newPassword } = c.req.valid('json'); + // T-19-06: NEVER log newPassword or the request body + + // Verify the target user has a local_credentials row (404 if not) + const [credRow] = await db + .select({ id: localCredentials.id }) + .from(localCredentials) + .where(eq(localCredentials.userId, targetId)) + .limit(1); + + if (!credRow) { + return c.json({ error: 'Member not found or has no local credential' }, 404); + } + + try { + await db + .update(localCredentials) + .set({ passwordHash: hashPassword(newPassword) }) + .where(eq(localCredentials.userId, targetId)); + + return c.json({ ok: true }, 200); + } catch (err) { + console.error( + '[admin/POST /members/:id/password] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } + }, +); + // --------------------------------------------------------------------------- // POST /api/admin/credentials //