Phase 20: Admin Member Editor & Form Declutter #25
@@ -11,8 +11,9 @@
|
|||||||
* - No console.log of request bodies or passwords in any handler (T-10-10).
|
* - No console.log of request bodies or passwords in any handler (T-10-10).
|
||||||
*
|
*
|
||||||
* Routes:
|
* Routes:
|
||||||
* GET /api/admin/members → list members + credential status (UI-SPEC Surface 2)
|
* 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)
|
* 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/members/:id/password → admin reset local member password (AUTH-LOCAL-08)
|
||||||
* POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01)
|
* POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01)
|
||||||
* GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5)
|
* GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5)
|
||||||
@@ -105,6 +106,7 @@ adminRouter.get('/members', async (c) => {
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
color: users.color,
|
color: users.color,
|
||||||
|
isAdmin: users.isAdmin, // Plan 20-01: feeds editor admin toggle initial state (D-02)
|
||||||
credentialId: memberCredentials.id,
|
credentialId: memberCredentials.id,
|
||||||
localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row
|
localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row
|
||||||
})
|
})
|
||||||
@@ -116,6 +118,7 @@ adminRouter.get('/members', async (c) => {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
color: row.color,
|
color: row.color,
|
||||||
|
isAdmin: row.isAdmin, // Plan 20-01
|
||||||
hasCredential: row.credentialId !== null,
|
hasCredential: row.credentialId !== null,
|
||||||
hasLocalCredential: row.localCredId !== null, // AUTH-LOCAL-17
|
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<number>`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
|
// POST /api/admin/members/:id/password
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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 () => {
|
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 adminId1 = await seedUser('admin-demote-1', true);
|
||||||
const adminId2 = await seedUser('admin-demote-2', true);
|
const adminId2 = await seedUser('admin-demote-2', true);
|
||||||
|
// Log in as adminId1 to perform the self-demotion
|
||||||
currentDevUserId = adminId1;
|
currentDevUserId = adminId1;
|
||||||
const app = await getApp();
|
const app = await getApp();
|
||||||
|
|
||||||
@@ -1159,7 +1160,9 @@ describe('PATCH /api/admin/members/:id', () => {
|
|||||||
);
|
);
|
||||||
expect(res.status).toBe(200);
|
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'));
|
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
|
||||||
expect(getRes.status).toBe(200);
|
expect(getRes.status).toBe(200);
|
||||||
const getBody = (await getRes.json()) as {
|
const getBody = (await getRes.json()) as {
|
||||||
|
|||||||
Reference in New Issue
Block a user