audit: 33-agent comprehensive audit + critical fixes

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>
This commit is contained in:
2026-05-12 16:52:35 +02:00
parent 660553c074
commit 4b9743a594
31 changed files with 7042 additions and 81 deletions

View File

@@ -850,8 +850,7 @@ export async function changeInterestStage(
? (STAGE_LABELS[oldStage as PipelineStage] ?? oldStage.replace(/_/g, ' '))
: 'unknown';
const toLabel =
STAGE_LABELS[data.pipelineStage as PipelineStage] ??
data.pipelineStage.replace(/_/g, ' ');
STAGE_LABELS[data.pipelineStage as PipelineStage] ?? data.pipelineStage.replace(/_/g, ' ');
await createNotification({
portId,
userId: meta.userId,

View File

@@ -16,6 +16,7 @@ import { db } from '@/lib/db';
import { userPortRoles, roles, type RolePermissions } from '@/lib/db/schema/users';
import { logger } from '@/lib/logger';
import { createNotification } from '@/lib/services/notifications.service';
import { STAGE_LABELS, type PipelineStage } from '@/lib/constants';
export interface BerthReleaseNotificationInput {
portId: string;
@@ -59,8 +60,9 @@ export async function notifyNextInLine(input: BerthReleaseNotificationInput): Pr
// 2. Build a single description listing the next interests so the
// rep can act on it without opening the berth detail page first.
const previewLines = input.nextInLineInterests.slice(0, 5).map((i) => {
const days = i.pipelineStage.replace(/_/g, ' ');
return `${i.clientName ?? '(unknown)'}${days}`;
const stageLabel =
STAGE_LABELS[i.pipelineStage as PipelineStage] ?? i.pipelineStage.replace(/_/g, ' ');
return `${i.clientName ?? '(unknown)'}${stageLabel}`;
});
const more =
input.nextInLineInterests.length > 5

View File

@@ -58,8 +58,12 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
category: 'settings',
keywords: ['preferences', 'configuration', 'config'],
},
// The granular settings cards below redirect to the `/admin/<x>` routes
// that actually exist — the catalog previously listed `/settings/<x>`
// paths that have never had route folders. We keep the keyword aliases
// so the cmd-K search still finds them under the right destination.
{
href: '/:portSlug/settings/email',
href: '/:portSlug/admin/email',
label: 'Email accounts (SMTP / IMAP)',
category: 'settings',
keywords: [
@@ -75,47 +79,47 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/settings/branding',
href: '/:portSlug/admin/branding',
label: 'Branding (per-port logo, colors, copy)',
category: 'settings',
keywords: ['logo', 'theme', 'colors', 'tenant brand', 'white-label'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/settings/templates',
href: '/:portSlug/admin/templates',
label: 'Document templates',
category: 'settings',
keywords: ['eoi', 'documenso', 'pdf templates', 'template merge fields'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/settings/storage',
href: '/:portSlug/admin/storage',
label: 'File storage backend',
category: 'settings',
keywords: ['s3', 'minio', 'filesystem', 'storage'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/settings/recommender',
href: '/:portSlug/admin/settings',
label: 'Berth recommender weights',
category: 'settings',
keywords: ['ranking', 'tier ladder', 'heat', 'fallthrough', 'recommend'],
requires: 'admin.manage_settings',
},
{
href: '/:portSlug/settings/tags',
href: '/:portSlug/admin/tags',
label: 'Tags',
category: 'settings',
keywords: ['labels', 'categories', 'classification'],
},
{
href: '/:portSlug/settings/notifications',
href: '/:portSlug/settings/profile',
label: 'Notification preferences',
category: 'settings',
keywords: ['alerts', 'email digest', 'in-app', 'push'],
keywords: ['alerts', 'email digest', 'in-app', 'push', 'reminders digest'],
},
{
href: '/:portSlug/user-settings',
href: '/:portSlug/settings/profile',
label: 'My profile & preferences',
category: 'settings',
keywords: [
@@ -159,7 +163,7 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
requires: 'admin.manage_users',
},
{
href: '/:portSlug/admin/audit-log',
href: '/:portSlug/admin/audit',
label: 'Audit log',
category: 'admin',
keywords: ['activity', 'history', 'events', 'who did what', 'compliance'],
@@ -172,7 +176,7 @@ export const NAV_CATALOG: NavCatalogEntry[] = [
keywords: ['enquiries', 'leads', 'contact form', 'eoi requests', 'website'],
},
{
href: '/:portSlug/admin/error-events',
href: '/:portSlug/admin/errors',
label: 'Platform errors',
category: 'admin',
keywords: ['errors', 'exceptions', 'incidents', 'failures'],

View File

@@ -1,7 +1,7 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { account, session, user, userProfiles, userPortRoles, roles, ports } from '@/lib/db/schema';
import { auth } from '@/lib/auth';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
@@ -247,6 +247,25 @@ export async function updateUser(
await db.update(user).set(userUpdates).where(eq(user.id, userId));
}
if (wantsEmailChange) {
const newEmailLower = data.email!.toLowerCase();
// Better Auth's credential provider authenticates by
// `account.accountId` (the email captured at sign-up), NOT by
// `user.email`. Without this update the user can't sign in with
// either address — old fails because user.email no longer matches,
// new fails because there's no account.accountId row for it.
await db
.update(account)
.set({ accountId: newEmailLower, updatedAt: new Date() })
.where(and(eq(account.userId, userId), eq(account.providerId, 'credential')));
// Revoke every active session — the admin just changed the identity
// the user authenticates with, so existing sessions are effectively
// orphaned and a security risk if the account is being rotated due
// to compromise. The user re-authenticates with the new address.
await db.delete(session).where(eq(session.userId, userId));
}
if (wantsEmailChange && previousEmail) {
// Best-effort notification — failure to send doesn't roll back the
// change because Better Auth's primary identity has already moved.