diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 3f62d77..1ffd207 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -11,12 +11,13 @@ * - 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/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) + * GET /api/admin/members → list members + credential status + isAdmin (UI-SPEC Surface 2) + * POST /api/admin/members → create local member: users row + local_credentials (AUTH-LOCAL-07) + * PATCH /api/admin/members/:id → update member profile: displayName and/or isAdmin (Plan 20-01) + * 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) */ @@ -105,6 +106,7 @@ adminRouter.get('/members', async (c) => { id: users.id, displayName: users.displayName, color: users.color, + isAdmin: users.isAdmin, // Plan 20-01: feeds editor admin toggle initial state (D-02) credentialId: memberCredentials.id, localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row }) @@ -116,6 +118,7 @@ adminRouter.get('/members', async (c) => { id: row.id, displayName: row.displayName, color: row.color, + isAdmin: row.isAdmin, // Plan 20-01 hasCredential: row.credentialId !== null, hasLocalCredential: row.localCredId !== null, // AUTH-LOCAL-17 })); @@ -208,6 +211,74 @@ adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), } }); +// --------------------------------------------------------------------------- +// PATCH /api/admin/members/:id +// +// Updates a member's displayName and/or isAdmin flag (Plan 20-01, D-02, D-03). +// Security: +// - requireAdmin: inherited from adminRouter.use('*', requireAdmin) (D-02 — no second guard) +// - noEchoHook: applied for consistency with other admin write routes (T-20-04) +// - D-03 last-admin guard: rejects isAdmin=false when target is the sole remaining admin (T-20-02) +// - parsePositiveIntParam: rejects malformed ids (T-20-03) +// --------------------------------------------------------------------------- + +const updateMemberSchema = z.object({ + displayName: z.string().min(1).max(256).optional(), + isAdmin: z.boolean().optional(), +}); + +adminRouter.patch( + '/members/:id', + zValidator('json', updateMemberSchema, noEchoHook), + async (c) => { + const targetId = parsePositiveIntParam(c.req.param('id')); + if (targetId === null) { + return c.json({ error: 'Invalid member id' }, 400); + } + + const { displayName, isAdmin } = c.req.valid('json'); + // T-20-04: NEVER log request body + + // Verify the target user exists (404 if not) + const [target] = await db + .select({ id: users.id, isAdmin: users.isAdmin }) + .from(users) + .where(eq(users.id, targetId)) + .limit(1); + + if (!target) { + return c.json({ error: 'Member not found' }, 404); + } + + // D-03 last-admin guard: reject demotion of the only remaining admin (T-20-02) + if (isAdmin === false && target.isAdmin) { + const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) + .from(users) + .where(eq(users.isAdmin, true)); + if (Number(count) <= 1) { + return c.json({ error: 'Cannot remove the last admin' }, 409); + } + } + + // Build a partial set() from whichever fields are present + const updates: { displayName?: string; isAdmin?: boolean } = {}; + if (displayName !== undefined) updates.displayName = displayName; + if (isAdmin !== undefined) updates.isAdmin = isAdmin; + + try { + await db.update(users).set(updates).where(eq(users.id, targetId)); + return c.json({ ok: true }, 200); + } catch (err) { + console.error( + '[admin/PATCH /members/:id] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } + }, +); + // --------------------------------------------------------------------------- // POST /api/admin/members/:id/password // diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index 9bd1062..54012de 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -1151,6 +1151,7 @@ describe('PATCH /api/admin/members/:id', () => { it('Test D (self-demotion allowed): with two admins, PATCH { isAdmin: false } returns 200; one admin remains', async () => { const adminId1 = await seedUser('admin-demote-1', true); const adminId2 = await seedUser('admin-demote-2', true); + // Log in as adminId1 to perform the self-demotion currentDevUserId = adminId1; const app = await getApp(); @@ -1159,7 +1160,9 @@ describe('PATCH /api/admin/members/:id', () => { ); expect(res.status).toBe(200); - // Only adminId2 should remain as admin + // Switch to adminId2 to verify the outcome — adminId1 is now non-admin + // and can no longer call GET /members (would 403). + currentDevUserId = adminId2; const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); expect(getRes.status).toBe(200); const getBody = (await getRes.json()) as {