feat(19-02): admin create-member, reset-password, hasLocalCredential on GET /members
- POST /api/admin/members: atomic tx (users + local_credentials), 409 on dup username
- POST /api/admin/members/:id/password: admin reset (no current-pwd required), 404 if no local cred
- GET /api/admin/members: LEFT JOIN local_credentials, hasLocalCredential in each member row
- noEchoHook on both POST routes (T-19-06); requireAdmin via router.use('*') remains first statement
- Dup-entry detection via error message string match (Drizzle wraps mysql2 ER_DUP_ENTRY)
This commit is contained in:
@@ -11,10 +11,12 @@
|
|||||||
* - 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 (UI-SPEC Surface 2)
|
||||||
* POST /api/admin/credentials → validate+encrypt+store for any member (ADMIN-01)
|
* POST /api/admin/members → create local member: users row + local_credentials (AUTH-LOCAL-07)
|
||||||
* GET /api/admin/calendars → list synced calendars (UI-SPEC Surface 5)
|
* POST /api/admin/members/:id/password → admin reset local member password (AUTH-LOCAL-08)
|
||||||
* PUT /api/admin/calendars/:id/shared → exclusive is_shared designation (ADMIN-02)
|
* 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)
|
* Mounted in index.ts: app.route('/api/admin', adminRouter)
|
||||||
*/
|
*/
|
||||||
@@ -25,13 +27,15 @@ import { zValidator } from '@hono/zod-validator';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq, sql } from 'drizzle-orm';
|
import { eq, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db/client.js';
|
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 { requireAdmin } from '../lib/requireAdmin.js';
|
||||||
import { isValidIanaTimezone, resolveHouseholdTimezone } from '../lib/householdTimezone.js';
|
import { isValidIanaTimezone, resolveHouseholdTimezone } from '../lib/householdTimezone.js';
|
||||||
import {
|
import {
|
||||||
validateEncryptAndStoreCredential,
|
validateEncryptAndStoreCredential,
|
||||||
CredentialValidationError,
|
CredentialValidationError,
|
||||||
} from '../broker/credentialSync.js';
|
} 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')
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
||||||
import '../auth/devBypass.js';
|
import '../auth/devBypass.js';
|
||||||
|
|
||||||
@@ -78,6 +82,7 @@ const noEchoHook = (result: { success: boolean }, c: Context) => {
|
|||||||
//
|
//
|
||||||
// Returns all household members with their credential status.
|
// Returns all household members with their credential status.
|
||||||
// Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance).
|
// Feeds UI-SPEC Surface 2 (member list with rotate-credential affordance).
|
||||||
|
// Includes hasLocalCredential (AUTH-LOCAL-17) alongside existing hasCredential.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
adminRouter.get('/members', async (c) => {
|
adminRouter.get('/members', async (c) => {
|
||||||
@@ -87,20 +92,162 @@ adminRouter.get('/members', async (c) => {
|
|||||||
displayName: users.displayName,
|
displayName: users.displayName,
|
||||||
color: users.color,
|
color: users.color,
|
||||||
credentialId: memberCredentials.id,
|
credentialId: memberCredentials.id,
|
||||||
|
localCredId: localCredentials.id, // LEFT JOIN — null when no local_credentials row
|
||||||
})
|
})
|
||||||
.from(users)
|
.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) => ({
|
const members = rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
displayName: row.displayName,
|
displayName: row.displayName,
|
||||||
color: row.color,
|
color: row.color,
|
||||||
hasCredential: row.credentialId !== null,
|
hasCredential: row.credentialId !== null,
|
||||||
|
hasLocalCredential: row.localCredId !== null, // AUTH-LOCAL-17
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return c.json({ members });
|
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
|
// POST /api/admin/credentials
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user