Full team audit run, all reports verbatim in docs/AUDIT-2026-05-12.md (5900+ lines, 30+ critical findings). Already-fixed this commit: - permission-overrides PUT: self-target block + RolePermissions allow-list + cross-tenant guard - /api/auth/resolve-identifier: rate-limit + synthetic miss-email kill enumeration - admin email-change: rotates account.accountId + revokes sessions - middleware: token-gated email confirm/cancel routes whitelisted - NAV_CATALOG: 10 dead-link sweeps to existing /admin/<x> targets Feature work landing same commit: optional username sign-in (migration 0054), per-user permission overrides (0055) with three-state matrix tabbed inside UserForm, user disable button, role + outcome + stage label normalisation across the platform, admin email-change with auto-notification template. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
192 lines
7.1 KiB
TypeScript
192 lines
7.1 KiB
TypeScript
import { NextResponse } from 'next/server';
|
||
import { eq } from 'drizzle-orm';
|
||
|
||
import { withAuth, type AuthContext } from '@/lib/api/helpers';
|
||
import { parseBody } from '@/lib/api/route-helpers';
|
||
import { db } from '@/lib/db';
|
||
import { userProfiles } from '@/lib/db/schema';
|
||
import { ConflictError, errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||
import { isReservedUsername, USERNAME_REGEX } from '@/lib/validators/username';
|
||
import { sql } from 'drizzle-orm';
|
||
import { z } from 'zod';
|
||
|
||
const updateProfileSchema = z.object({
|
||
firstName: z.string().min(1).max(120).nullable().optional(),
|
||
lastName: z.string().min(1).max(120).nullable().optional(),
|
||
displayName: z.string().min(1).max(200).optional(),
|
||
/**
|
||
* Optional sign-in alias. `null` clears the existing value; a string
|
||
* must match the 2–30 lowercase shape pinned by USERNAME_REGEX (also
|
||
* enforced by `chk_user_profiles_username_shape` in migration 0054).
|
||
* Uniqueness is checked below before the UPDATE — collisions surface
|
||
* as a 409 with a friendly message.
|
||
*/
|
||
username: z
|
||
.union([
|
||
z.string().transform((s) => s.trim().toLowerCase()),
|
||
z.null(),
|
||
])
|
||
.optional(),
|
||
phone: z.string().nullable().optional(),
|
||
// Refuse `javascript:` / `data:` schemes — z.string().url() lets them
|
||
// through and `<a href={avatarUrl}>` would otherwise be a stored-XSS
|
||
// vector if any future renderer treated the value as a link.
|
||
avatarUrl: z
|
||
.string()
|
||
.url()
|
||
.refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL')
|
||
.nullable()
|
||
.optional(),
|
||
// Strict allow-list — no `.passthrough()` here. The previous schema let
|
||
// arbitrary client-supplied keys survive validation and persist into
|
||
// `userProfiles.preferences` JSONB unbounded; auditor-E3 §28 caught this.
|
||
// Add new keys here as the UI surfaces them rather than letting the
|
||
// client mint them at will.
|
||
preferences: z
|
||
.object({
|
||
dark_mode: z.boolean().optional(),
|
||
locale: z.string().optional(),
|
||
timezone: z.string().optional(),
|
||
// Per-table column visibility. Keyed by entity type — entries
|
||
// with an empty `hiddenColumns` mean "all visible". The validator
|
||
// caps total entries / IDs so a malicious client can't bloat the
|
||
// 8 KB preferences blob; see merge step below for the byte cap.
|
||
tablePreferences: z
|
||
.record(
|
||
z.string().min(1).max(64),
|
||
z
|
||
.object({
|
||
hiddenColumns: z.array(z.string().min(1).max(64)).max(50).optional(),
|
||
})
|
||
.strict(),
|
||
)
|
||
.optional(),
|
||
})
|
||
.strict()
|
||
.optional(),
|
||
});
|
||
|
||
export const GET = withAuth(async (_req, ctx: AuthContext) => {
|
||
// Hydrate preferences from user_profiles so the client can read its
|
||
// saved table-column visibility (and other prefs) without a second
|
||
// round-trip on app boot.
|
||
const profile = await db.query.userProfiles.findFirst({
|
||
where: eq(userProfiles.userId, ctx.userId),
|
||
columns: {
|
||
preferences: true,
|
||
avatarFileId: true,
|
||
avatarUrl: true,
|
||
firstName: true,
|
||
lastName: true,
|
||
displayName: true,
|
||
username: true,
|
||
},
|
||
});
|
||
|
||
return NextResponse.json({
|
||
data: {
|
||
userId: ctx.userId,
|
||
portId: ctx.portId,
|
||
portSlug: ctx.portSlug,
|
||
permissions: ctx.permissions,
|
||
isSuperAdmin: ctx.isSuperAdmin,
|
||
user: ctx.user,
|
||
preferences: profile?.preferences ?? {},
|
||
profile: {
|
||
avatarFileId: profile?.avatarFileId ?? null,
|
||
avatarUrl: profile?.avatarUrl ?? null,
|
||
firstName: profile?.firstName ?? null,
|
||
lastName: profile?.lastName ?? null,
|
||
displayName: profile?.displayName ?? null,
|
||
username: profile?.username ?? null,
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
export const PATCH = withAuth(async (req, ctx: AuthContext) => {
|
||
try {
|
||
const body = await parseBody(req, updateProfileSchema);
|
||
|
||
const profile = await db.query.userProfiles.findFirst({
|
||
where: eq(userProfiles.userId, ctx.userId),
|
||
});
|
||
if (!profile) throw new NotFoundError('profile');
|
||
|
||
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
||
if (body.firstName !== undefined) updates.firstName = body.firstName;
|
||
if (body.lastName !== undefined) updates.lastName = body.lastName;
|
||
if (body.displayName !== undefined) updates.displayName = body.displayName;
|
||
if (body.phone !== undefined) updates.phone = body.phone;
|
||
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
|
||
if (body.username !== undefined) {
|
||
// null clears; non-null must match the shape + not be reserved +
|
||
// be unique (case-insensitive) across all users in the install.
|
||
if (body.username === null || body.username === '') {
|
||
updates.username = null;
|
||
} else {
|
||
const candidate = body.username;
|
||
if (!USERNAME_REGEX.test(candidate)) {
|
||
throw new ValidationError(
|
||
'Username must be 2–30 lowercase letters, digits, dot, underscore, or hyphen.',
|
||
);
|
||
}
|
||
if (isReservedUsername(candidate)) {
|
||
throw new ValidationError('That username is reserved. Please pick another.');
|
||
}
|
||
const taken = await db
|
||
.select({ userId: userProfiles.userId })
|
||
.from(userProfiles)
|
||
.where(sql`LOWER(${userProfiles.username}) = ${candidate}`)
|
||
.limit(1);
|
||
if (taken.length > 0 && taken[0]!.userId !== ctx.userId) {
|
||
throw new ConflictError('That username is already taken.');
|
||
}
|
||
updates.username = candidate;
|
||
}
|
||
}
|
||
if (body.preferences !== undefined) {
|
||
// Allow-list — only retain keys defined in the strict schema. Pre-
|
||
// strict rows may carry extra keys from when the schema was
|
||
// .passthrough(); the merge prunes them so legacy bloat doesn't
|
||
// accumulate forever, and a future schema regression that tries
|
||
// to ship arbitrary keys still gets dropped here at write time.
|
||
const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone', 'tablePreferences']);
|
||
const existing = (profile.preferences as Record<string, unknown>) ?? {};
|
||
const merged = Object.fromEntries(
|
||
Object.entries({ ...existing, ...body.preferences }).filter(([k]) =>
|
||
ALLOWED_PREF_KEYS.has(k),
|
||
),
|
||
);
|
||
// Hard cap on the merged JSONB — defense in depth against any
|
||
// future schema growth that might re-introduce free-form keys.
|
||
const serialized = JSON.stringify(merged);
|
||
if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) {
|
||
throw new ValidationError('preferences exceeds 8KB');
|
||
}
|
||
updates.preferences = merged;
|
||
}
|
||
|
||
const [updated] = await db
|
||
.update(userProfiles)
|
||
.set(updates)
|
||
.where(eq(userProfiles.userId, ctx.userId))
|
||
.returning();
|
||
|
||
return NextResponse.json({
|
||
data: {
|
||
userId: updated!.userId,
|
||
firstName: updated!.firstName,
|
||
lastName: updated!.lastName,
|
||
displayName: updated!.displayName,
|
||
username: updated!.username,
|
||
phone: updated!.phone,
|
||
avatarUrl: updated!.avatarUrl,
|
||
preferences: updated!.preferences,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
return errorResponse(error);
|
||
}
|
||
});
|