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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user