fix(audit-tier-2-routes): manual NextResponse.json error sweep + admin form banners
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>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { activateAccount } from '@/lib/services/portal-auth.service';
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { activateAccount } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
@@ -15,22 +15,19 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||
if (limited) return limited;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new ValidationError('Invalid request body');
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
|
||||
try {
|
||||
await activateAccount(parsed.data.token, parsed.data.password);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { requestPasswordReset } from '@/lib/services/portal-auth.service';
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
|
||||
const bodySchema = z.object({ email: z.string().email() });
|
||||
|
||||
@@ -14,24 +15,26 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const limited = await enforcePublicRateLimit(req, 'portalForgot');
|
||||
if (limited) return limited;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new ValidationError('Invalid request body');
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
|
||||
}
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) throw new ValidationError('Invalid email address');
|
||||
|
||||
// Always return 200 to prevent account-enumeration. Errors are logged
|
||||
// server-side, never surfaced to the client.
|
||||
try {
|
||||
await requestPasswordReset(parsed.data.email);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Portal forgot-password failed (swallowed)');
|
||||
// Always return 200 to prevent account-enumeration. Errors are logged
|
||||
// server-side, never surfaced to the client.
|
||||
try {
|
||||
await requestPasswordReset(parsed.data.email);
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'Portal forgot-password failed (swallowed)');
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return errorResponse(error);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { resetPassword } from '@/lib/services/portal-auth.service';
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { resetPassword } from '@/lib/services/portal-auth.service';
|
||||
|
||||
const bodySchema = z.object({
|
||||
token: z.string().min(1),
|
||||
@@ -15,22 +15,19 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const limited = await enforcePublicRateLimit(req, 'portalToken');
|
||||
if (limited) return limited;
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new ValidationError('Invalid request body');
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.errors[0]?.message ?? 'Invalid input' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
throw new ValidationError(parsed.error.errors[0]?.message ?? 'Invalid input');
|
||||
}
|
||||
|
||||
try {
|
||||
await resetPassword(parsed.data.token, parsed.data.password);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
import { errorResponse, ValidationError } from '@/lib/errors';
|
||||
import { PORTAL_COOKIE } from '@/lib/portal/auth';
|
||||
import { signIn } from '@/lib/services/portal-auth.service';
|
||||
import { enforcePublicRateLimit } from '@/lib/api/route-helpers';
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
@@ -18,12 +18,12 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
|
||||
return errorResponse(new ValidationError('Email format is invalid'));
|
||||
}
|
||||
|
||||
const parsed = bodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: 'Email format is invalid' }, { status: 400 });
|
||||
return errorResponse(new ValidationError('Email format is invalid'));
|
||||
}
|
||||
|
||||
// Per-(ip,email) bucket: 5 attempts / 15min. Keyed on email-lowercase so
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { errorResponse, NotFoundError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||
import { getPortalDashboard } from '@/lib/services/portal.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const GET = withPortalAuth(async (_req, session) => {
|
||||
try {
|
||||
const dashboard = await getPortalDashboard(session.clientId, session.portId);
|
||||
|
||||
if (!dashboard) {
|
||||
return NextResponse.json({ error: 'Client not found' }, { status: 404 });
|
||||
}
|
||||
if (!dashboard) throw new NotFoundError('client');
|
||||
|
||||
return NextResponse.json({ data: dashboard });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Portal dashboard fetch failed');
|
||||
return NextResponse.json({ error: 'Failed to load dashboard' }, { status: 500 });
|
||||
logger.error({ err: error }, 'Portal dashboard fetch failed');
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,26 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||
import { getDocumentDownloadUrl } from '@/lib/services/portal.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const GET = withPortalAuth(async (_req, session, params) => {
|
||||
try {
|
||||
const documentId = params.documentId;
|
||||
|
||||
if (!documentId) {
|
||||
return NextResponse.json({ error: 'Document ID required' }, { status: 400 });
|
||||
}
|
||||
if (!documentId) throw new ValidationError('documentId is required');
|
||||
|
||||
const url = await getDocumentDownloadUrl(session.clientId, documentId, session.portId);
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: 'Document not found' }, { status: 404 });
|
||||
}
|
||||
if (!url) throw new NotFoundError('document');
|
||||
|
||||
return NextResponse.json({ url });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Portal document download URL fetch failed');
|
||||
return NextResponse.json({ error: 'Failed to generate download URL' }, { status: 500 });
|
||||
logger.error({ err: error }, 'Portal document download URL fetch failed');
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||
import { getClientDocuments } from '@/lib/services/portal.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const GET = withPortalAuth(async (_req, session) => {
|
||||
try {
|
||||
const data = await getClientDocuments(session.clientId, session.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Portal documents fetch failed');
|
||||
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
|
||||
logger.error({ err: error }, 'Portal documents fetch failed');
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||
import { getClientInterests } from '@/lib/services/portal.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const GET = withPortalAuth(async (_req, session) => {
|
||||
try {
|
||||
const data = await getClientInterests(session.clientId, session.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Portal interests fetch failed');
|
||||
return NextResponse.json({ error: 'Failed to load interests' }, { status: 500 });
|
||||
logger.error({ err: error }, 'Portal interests fetch failed');
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { withPortalAuth } from '@/lib/portal/helpers';
|
||||
import { getClientInvoices } from '@/lib/services/portal.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export const GET = withPortalAuth(async (_req, session) => {
|
||||
try {
|
||||
const data = await getClientInvoices(session.clientId, session.portId);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
logger.error({ error }, 'Portal invoices fetch failed');
|
||||
return NextResponse.json({ error: 'Failed to load invoices' }, { status: 500 });
|
||||
logger.error({ err: error }, 'Portal invoices fetch failed');
|
||||
return errorResponse(error);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user