Two final waves of error-surface hygiene closing the audit's MED §12 +
HIGH §15 + HIGH §17 findings:
* 50 route files swept (61 sites): manual NextResponse.json({error,
status: 4xx|5xx}) early-returns replaced by typed throws +
errorResponse(err) at the catch.
- Super-admin gates (13 sites) use new requireSuperAdmin(ctx, action)
helper from src/lib/api/helpers.ts so denials hit the audit log.
- Path-param + body validation 400s become ValidationError throws.
- 404s become NotFoundError or CodedError('NOT_FOUND') for AI
feature-flag paths.
- 11 manual 5xx returns now re-throw so error_events captures the
request-id (the admin error inspector becomes usable from real
incidents).
- website-analytics 200-with-error anti-pattern flipped to 409 +
UMAMI_NOT_CONFIGURED. 502 upstream paths use UMAMI_UPSTREAM_ERROR.
- 11 sites intentionally preserved: storage/[token] anti-enumeration
token-failure paths, webhook-secret 401, "Unknown port" 400 in
public intake.
* 7 admin forms (roles, users, ports, webhooks, custom-fields,
document-templates, tags) gain a formatErrorBanner() helper from
src/lib/api/toast-error.ts that builds a multi-line "Error code / Reference ID"
banner — the rep can copy the request id when reporting a failed
save. Banners get whitespace-pre-line so newlines render.
Test status: 1168/1168 vitest, tsc clean.
Refs: docs/audit-comprehensive-2026-05-05.md MED §12 (auditor-F Issue 1)
+ HIGH §15 (auditor-F Issue 2) + HIGH §17 (auditor-H Issue 2).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
72 lines
2.6 KiB
TypeScript
72 lines
2.6 KiB
TypeScript
'use client';
|
|
|
|
import { toast } from 'sonner';
|
|
|
|
import { ApiError } from '@/lib/api/client';
|
|
|
|
/**
|
|
* Build a multi-line string suitable for an inline form banner — the
|
|
* primary message followed by `Error code:` / `Reference ID:` lines when
|
|
* the error is an ApiError. Use from admin forms that want to keep
|
|
* their inline error UX instead of switching to a toast.
|
|
*
|
|
* Example output:
|
|
* "Couldn't save the role.
|
|
* Error code: ROLES_DUPLICATE_NAME
|
|
* Reference ID: 1ab2c3d4-…"
|
|
*/
|
|
export function formatErrorBanner(err: unknown, fallback = 'Something went wrong.'): string {
|
|
if (err instanceof ApiError) {
|
|
const parts = [err.message || fallback];
|
|
if (err.code) parts.push(`Error code: ${err.code}`);
|
|
if (err.requestId) parts.push(`Reference ID: ${err.requestId}`);
|
|
return parts.join('\n');
|
|
}
|
|
if (err instanceof Error) return err.message || fallback;
|
|
return fallback;
|
|
}
|
|
|
|
/**
|
|
* Render an API error as a toast in the consistent platform format:
|
|
*
|
|
* ┌─────────────────────────────────────────────┐
|
|
* │ {plain-text message} │
|
|
* │ │
|
|
* │ Error code: EXPENSES_RECEIPT_REQUIRED │
|
|
* │ Reference ID: ab12-cd34-… [Copy] │
|
|
* └─────────────────────────────────────────────┘
|
|
*
|
|
* Use this anywhere a `useMutation({ onError })` would otherwise just
|
|
* call `toast.error(err.message)`. Falls back gracefully when the error
|
|
* isn't an ApiError (network errors, programmer errors, etc.).
|
|
*/
|
|
export function toastError(err: unknown, fallback = 'Something went wrong.'): void {
|
|
if (err instanceof ApiError) {
|
|
const lines: string[] = [];
|
|
if (err.code) lines.push(`Error code: ${err.code}`);
|
|
if (err.requestId) lines.push(`Reference ID: ${err.requestId}`);
|
|
toast.error(err.message, {
|
|
description: lines.length > 0 ? lines.join('\n') : undefined,
|
|
// Long enough to read the message + grab the reference id.
|
|
duration: 8_000,
|
|
action: err.requestId
|
|
? {
|
|
label: 'Copy ID',
|
|
onClick: () => {
|
|
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
void navigator.clipboard.writeText(err.requestId!);
|
|
toast.success('Reference ID copied');
|
|
}
|
|
},
|
|
}
|
|
: undefined,
|
|
});
|
|
return;
|
|
}
|
|
if (err instanceof Error) {
|
|
toast.error(err.message || fallback);
|
|
return;
|
|
}
|
|
toast.error(fallback);
|
|
}
|