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

@@ -5,7 +5,13 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { portRoleOverrides, ports, userPortRoles, userProfiles } from '@/lib/db/schema';
import {
portRoleOverrides,
ports,
userPermissionOverrides,
userPortRoles,
userProfiles,
} from '@/lib/db/schema';
import { type RolePermissions } from '@/lib/db/schema/users';
import { createAuditLog } from '@/lib/audit';
import { errorResponse, ForbiddenError } from '@/lib/errors';
@@ -213,6 +219,23 @@ export function withAuth<TParams extends RouteParams = Record<string, string>>(
},
};
}
// Per-user permission overrides. Final layer in the chain so
// they win over role + port-role-override. Most users will
// never have a row here; admins flip individual leaves from
// the user-edit drawer's Permissions tab.
const userOverride = await db.query.userPermissionOverrides.findFirst({
where: and(
eq(userPermissionOverrides.userId, profile.userId),
eq(userPermissionOverrides.portId, portId),
),
});
if (userOverride?.permissionOverrides && permissions) {
permissions = deepMerge(
permissions as unknown as Record<string, unknown>,
userOverride.permissionOverrides as Record<string, unknown>,
) as RolePermissions;
}
} else if (profile.isSuperAdmin && portId) {
const port = await db.query.ports.findFirst({
where: eq(ports.id, portId),

View File

@@ -268,6 +268,33 @@ export function formatRole(role: string | null | undefined): string {
.join(' ');
}
// ─── Interest outcomes ───────────────────────────────────────────────────────
// Mirrors INTEREST_OUTCOMES in src/lib/validators/interests.ts. Lives here
// so render sites can format outcome strings without pulling in the
// validator (which would drag zod into RSC bundles). Validator → enforces
// the set; here → labels for humans.
export const OUTCOME_LABELS: Record<string, string> = {
won: 'Won',
lost_other_marina: 'Lost — chose another marina',
lost_unqualified: 'Lost — not qualified',
lost_no_response: 'Lost — no response',
lost_other: 'Lost — other',
cancelled: 'Cancelled',
};
/** Returns the human label for a stored outcome value. Falls back to a
* pretty Title-Case rendering for any new values added at the validator
* before this map catches up. */
export function formatOutcome(outcome: string | null | undefined): string | null {
if (!outcome) return null;
if (outcome in OUTCOME_LABELS) return OUTCOME_LABELS[outcome]!;
return outcome
.split('_')
.map((part) => (part ? part[0]!.toUpperCase() + part.slice(1) : part))
.join(' ');
}
// ─── Document Types ──────────────────────────────────────────────────────────
export const DOCUMENT_TYPES = ['eoi', 'contract', 'nda', 'reservation_agreement', 'other'] as const;

View File

@@ -0,0 +1,31 @@
-- 0054_user_profiles_username.sql
-- ----------------------------------------------------------------------------
-- Optional username as a sign-in alternative to email. Stored alongside the
-- canonical first/last name on user_profiles so the rest of the auth/profile
-- code keeps a single place to look. The Better-Auth `user` table stays the
-- source of truth for email + password; the username is a thin alias the
-- login form looks up to resolve to the matching email before delegating
-- to better-auth's email/password flow.
--
-- Constraints (enforced application-side AND in SQL):
-- - 2..30 characters
-- - lowercase letters, digits, dot, underscore, hyphen
-- - case-insensitive uniqueness per install (no per-port scoping —
-- reps move between ports and a global username keeps URLs stable)
--
-- The column is nullable; existing users keep email-only sign-in until they
-- pick one.
ALTER TABLE user_profiles
ADD COLUMN IF NOT EXISTS username TEXT;
-- Shape check at the DB level catches anything that slipped past the API
-- (raw SQL inserts in tests, scripts, etc.).
ALTER TABLE user_profiles
ADD CONSTRAINT chk_user_profiles_username_shape
CHECK (username IS NULL OR username ~ '^[a-z0-9._-]{2,30}$');
-- Case-insensitive uniqueness. Partial so multiple NULLs are allowed.
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_profiles_username_unique
ON user_profiles (LOWER(username))
WHERE username IS NOT NULL;

View File

@@ -0,0 +1,26 @@
-- 0055_user_permission_overrides.sql
-- ----------------------------------------------------------------------------
-- Per-user permission overrides layered on top of the role's baseline.
-- Effective permission = role[resource][action]
-- |> apply port_role_overrides for that port
-- |> apply user_permission_overrides for (user, port)
--
-- A user override entry is OPTIONAL — most users will never have one.
-- When present, the JSONB blob is a Partial<RolePermissions> map where any
-- explicitly-set leaf wins over the inherited value (true forces grant,
-- false forces deny, missing → inherit).
CREATE TABLE IF NOT EXISTS user_permission_overrides (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
user_id TEXT NOT NULL,
port_id TEXT NOT NULL REFERENCES ports(id) ON DELETE CASCADE,
permission_overrides JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_perm_overrides_user_port
ON user_permission_overrides (user_id, port_id);
CREATE INDEX IF NOT EXISTS idx_user_perm_overrides_user
ON user_permission_overrides (user_id);

View File

@@ -244,6 +244,14 @@ export const userProfiles = pgTable(
firstName: text('first_name'),
lastName: text('last_name'),
displayName: text('display_name').notNull(),
/**
* Optional sign-in alias. Lowercase a-z0-9 plus dot/underscore/hyphen,
* 330 chars (shape pinned by `chk_user_profiles_username_shape`).
* Case-insensitive uniqueness is enforced by a partial unique index on
* LOWER(username); NULL allows the column to coexist with users who
* still sign in by email. See migration 0054.
*/
username: text('username'),
avatarUrl: text('avatar_url'),
/** FK into the polymorphic `files` table — the avatar is stored
* via getStorageBackend() so an S3↔filesystem swap carries it
@@ -278,6 +286,42 @@ export const roles = pgTable('roles', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
/**
* Per-user permission overrides layered on top of the role's baseline for
* a specific port. Each row carries a `Partial<RolePermissions>` map; any
* explicitly-set leaf wins over the role + port-role-override chain. Most
* users will never have a row here — it exists for the rare "give Alice
* the same role as her team but let her run permanent deletes" case.
*
* Effective permission resolution lives in `getEffectivePermissions` in
* src/lib/services/permissions.service.ts.
*/
export const userPermissionOverrides = pgTable(
'user_permission_overrides',
{
id: text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
userId: text('user_id').notNull(),
portId: text('port_id')
.notNull()
.references(() => ports.id, { onDelete: 'cascade' }),
permissionOverrides: jsonb('permission_overrides')
.$type<Partial<RolePermissions>>()
.notNull()
.default({}),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => [
uniqueIndex('idx_user_perm_overrides_user_port').on(table.userId, table.portId),
index('idx_user_perm_overrides_user').on(table.userId),
],
);
export type UserPermissionOverride = typeof userPermissionOverrides.$inferSelect;
export type NewUserPermissionOverride = typeof userPermissionOverrides.$inferInsert;
export const portRoleOverrides = pgTable(
'port_role_overrides',
{

View File

@@ -1,6 +1,7 @@
import type { Template } from '@pdfme/common';
import type { PipelineData } from '@/lib/services/report-generators';
import { stageLabel } from '@/lib/constants';
export const pipelineReportTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
@@ -69,8 +70,7 @@ export function buildPipelineInputs(
const summaryLines = stageOrder
.filter((stage) => (data.stageCounts[stage] ?? 0) > 0)
.map((stage) => {
const label = stage.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
return `${label}: ${data.stageCounts[stage] ?? 0} interest(s)`;
return `${stageLabel(stage)}: ${data.stageCounts[stage] ?? 0} interest(s)`;
});
// Include stages not in standard order

View File

@@ -1,6 +1,7 @@
import type { Template } from '@pdfme/common';
import type { RevenueData } from '@/lib/services/report-generators';
import { stageLabel } from '@/lib/constants';
export const revenueReportTemplate: Template = {
basePdf: 'BLANK_PDF' as unknown as string,
@@ -74,12 +75,11 @@ export function buildRevenueInputs(data: RevenueData, portName?: string): Record
breakdownLines.push('No revenue data available.');
} else {
for (const stage of orderedStages) {
const label = stage.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const amount = Number(data.stageRevenue[stage] ?? 0).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
breakdownLines.push(`${label}: ${amount}`);
breakdownLines.push(`${stageLabel(stage)}: ${amount}`);
}
}

View File

@@ -5,6 +5,7 @@ import type { ConnectionOptions } from 'bullmq';
import { logger } from '@/lib/logger';
import { attachWorkerAudit } from '@/lib/queue/audit-helpers';
import { QUEUE_CONFIGS } from '@/lib/queue';
import { stageLabel } from '@/lib/constants';
// ─── Email draft generation ───────────────────────────────────────────────────
@@ -280,7 +281,7 @@ function buildTemplateDraft(opts: {
},
stage_update: {
subject: `Update on your application ${clientName}`,
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${pipelineStage.replace(/_/g, ' ')}" stage.\n\nWe will be in touch shortly with the next steps.\n\nKind regards,\nPort Nimara Team`,
body: `Dear ${clientName},\n\nWe are pleased to inform you that your application for ${berthText} has progressed to the "${stageLabel(pipelineStage)}" stage.\n\nWe will be in touch shortly with the next steps.\n\nKind regards,\nPort Nimara Team`,
},
general: {
subject: `Message from Port Nimara ${clientName}`,

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.

View File

@@ -0,0 +1,52 @@
import { z } from 'zod';
/**
* Canonical username shape.
*
* - 2..30 characters (yes, 2 — initials like "dm" are real and the
* director uses them)
* - lowercase letters, digits, `.`, `_`, `-`
* - case-insensitive uniqueness is enforced by a partial unique index on
* LOWER(username) in migration 0054.
*
* The same regex lives on the DB CHECK constraint, so any insert that
* slips past the API still gets rejected at the DB layer.
*/
export const USERNAME_REGEX = /^[a-z0-9._-]{2,30}$/;
export const usernameSchema = z
.string()
.transform((s) => s.trim().toLowerCase())
.refine(
(s) => USERNAME_REGEX.test(s),
'Use 230 lowercase letters, digits, dot, underscore, or hyphen.',
);
/** Reserved names that the API rejects even if the regex would accept them.
* Keeps obvious confusables out of customer-facing URLs / mentions. */
export const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
'root',
'system',
'support',
'noreply',
'no-reply',
'help',
'security',
'me',
'self',
'api',
'auth',
'login',
'logout',
'signin',
'signup',
'register',
'undefined',
'null',
]);
export function isReservedUsername(username: string): boolean {
return RESERVED_USERNAMES.has(username.trim().toLowerCase());
}