fix(audit-wave-9): standardize on Sheet for previews; doctrine in CLAUDE.md

Swap the one outlier (client-interests-tab.tsx) from Vaul Drawer to
Sheet side=right so every detail-preview surface uses the same
primitive. Document the doctrine: Sheet for side panels on both desktop
and mobile; Vaul Drawer reserved for mobile-only bottom-sheet UX
(currently just MoreSheet).

Closes ui/ux M11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 11:50:07 +02:00
parent b2588ecdd8
commit 4233aa3ac3
94 changed files with 1674 additions and 895 deletions

View File

@@ -1,90 +0,0 @@
/**
* Resolves an email-or-username sign-in identifier to a canonical email
* that Better Auth's email/password flow accepts.
*
* Public endpoint by design — the login form calls it BEFORE the user is
* authenticated, so it can't sit behind `withAuth`.
*
* **Anti-enumeration:** the response shape is identical for hit and
* miss. On a miss we return a synthetic `@auth.invalid` email derived
* from the input so Better Auth's `signIn.email` call fails uniformly
* with "invalid credentials" — an attacker can't tell whether the
* username exists from this endpoint's response. (Previously a miss
* returned the bare input string, which lacked an `@` and was visibly
* different from a hit's real email.)
*
* **Rate limiting:** shares the `auth` bucket (5/15min/ip), so an
* attacker can't iterate a wordlist faster than they could brute-force
* passwords directly.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles } from '@/lib/db/schema/users';
import { eq } from 'drizzle-orm';
import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit';
const EMAIL_HINT = /@/;
/** Synthetic, definitively-invalid email used for the miss path. The
* `.invalid` TLD is reserved by RFC 2606 — no real domain can use it,
* so a downstream signIn call always fails as "invalid credentials"
* without ever leaking the lookup outcome. */
function syntheticEmail(raw: string): string {
const slug = raw.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown';
return `${slug}@auth.invalid`;
}
function clientIp(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown'
);
}
export async function POST(req: NextRequest) {
try {
// Rate-limit on IP — same 5/15min bucket the actual sign-in uses.
// Without this an attacker can wordlist usernames at full HTTP
// bandwidth and only funnel the validated emails into the slower
// signIn flow.
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
return NextResponse.json({ email: '' }, { status: 429, headers: rateLimitHeaders(rl) });
}
const body = (await req.json().catch(() => ({}))) as { identifier?: string };
const raw = (body.identifier ?? '').trim();
if (!raw) return NextResponse.json({ email: syntheticEmail('empty') });
// Looks like an email → already canonical. Hand it straight back.
if (EMAIL_HINT.test(raw)) {
return NextResponse.json({ email: raw });
}
// Otherwise treat the input as a username and look up the linked
// Better Auth email. Case-insensitive match against the
// `LOWER(username)` unique index.
const normalized = raw.toLowerCase();
const rows = await db
.select({ email: user.email })
.from(userProfiles)
.innerJoin(user, eq(userProfiles.userId, user.id))
.where(sql`LOWER(${userProfiles.username}) = ${normalized}`)
.limit(1);
if (rows.length === 0) {
// Synthetic `.invalid` email — indistinguishable from a hit in
// shape (has `@`, has a tld), guaranteed to fail downstream auth.
return NextResponse.json({ email: syntheticEmail(normalized) });
}
return NextResponse.json({ email: rows[0]!.email });
} catch {
// Defensive — never expose internals from a public endpoint.
return NextResponse.json({ email: syntheticEmail('error') }, { status: 200 });
}
}

View File

@@ -0,0 +1,102 @@
/**
* Server-side sign-in endpoint that accepts an email-or-username
* `identifier`. The username → email resolution happens entirely server-
* side, so the canonical email is never disclosed to the browser. This
* closes the username-enumeration vector that the old
* `/api/auth/resolve-identifier` endpoint left open (it echoed the real
* email on a hit; a synthetic `@auth.invalid` email on a miss was
* trivially distinguishable from a real one by domain).
*
* The endpoint POSTs to better-auth's `/api/auth/sign-in/email`
* downstream so the response shape (cookies + JSON body) matches what
* the existing client expects.
*/
import { NextResponse, type NextRequest } from 'next/server';
import { sql, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { user, userProfiles } from '@/lib/db/schema/users';
import { checkRateLimit, rateLimitHeaders, rateLimiters } from '@/lib/rate-limit';
function clientIp(req: NextRequest): string {
return (
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown'
);
}
async function resolveToEmail(identifier: string): Promise<string | null> {
const raw = identifier.trim();
if (!raw) return null;
if (raw.includes('@')) return raw;
const normalized = raw.toLowerCase();
const rows = await db
.select({ email: user.email })
.from(userProfiles)
.innerJoin(user, eq(userProfiles.userId, user.id))
.where(sql`LOWER(${userProfiles.username}) = ${normalized}`)
.limit(1);
return rows[0]?.email ?? null;
}
export async function POST(req: NextRequest) {
// Rate-limit on IP — same 5/15min bucket the sign-in endpoint uses.
const ip = clientIp(req);
const rl = await checkRateLimit(ip, rateLimiters.auth);
if (!rl.allowed) {
return NextResponse.json(
{ error: { message: 'Too many attempts. Try again later.' } },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const body = (await req.json().catch(() => ({}))) as {
identifier?: string;
password?: string;
rememberMe?: boolean;
callbackURL?: string;
};
const identifier = (body.identifier ?? '').trim();
const password = body.password ?? '';
if (!identifier || !password) {
// Match better-auth's invalid-credentials shape so the client can
// surface a uniform error without distinguishing the failure mode.
return NextResponse.json(
{ error: { message: 'Invalid credentials', code: 'INVALID_EMAIL_OR_PASSWORD' } },
{ status: 401 },
);
}
const email = await resolveToEmail(identifier);
// On a username miss we still call better-auth with a guaranteed-fail
// email so the timing and response shape match the hit-with-wrong-
// password path. The `.invalid` TLD is reserved by RFC 2606 so no real
// user could ever match it.
const effectiveEmail =
email ?? `${identifier.replace(/[^a-z0-9._-]/gi, '').slice(0, 30) || 'unknown'}@auth.invalid`;
// Forward to better-auth's existing sign-in endpoint. We construct a
// fresh Request because Next.js's NextRequest is read-only.
const url = new URL('/api/auth/sign-in/email', req.url);
const forwardBody = JSON.stringify({
email: effectiveEmail,
password,
rememberMe: body.rememberMe,
callbackURL: body.callbackURL,
});
const forwardReq = new Request(url.toString(), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Preserve client metadata for audit / rate limiting downstream.
'x-forwarded-for': req.headers.get('x-forwarded-for') ?? ip,
'user-agent': req.headers.get('user-agent') ?? '',
cookie: req.headers.get('cookie') ?? '',
},
body: forwardBody,
});
const { POST: signInHandler } = await import('@/app/api/auth/[...all]/route');
return signInHandler(forwardReq as NextRequest);
}

View File

@@ -17,6 +17,7 @@ import { logger } from '@/lib/logger';
import { createAuditLog } from '@/lib/audit';
import { checkRateLimit, rateLimiters } from '@/lib/rate-limit';
import { captureErrorEvent } from '@/lib/services/error-events.service';
import { withPublicContext } from '@/lib/api/helpers';
// BR-024: Dedup via signatureHash unique index on documentEvents
// Always return 200 from webhook (webhook best practice)
@@ -83,7 +84,7 @@ type DocumensoWebhookBody = {
};
};
export async function POST(req: NextRequest): Promise<NextResponse> {
async function handleDocumensoWebhook(req: NextRequest): Promise<NextResponse> {
let rawBody: string;
try {
@@ -296,3 +297,9 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
return NextResponse.json({ ok: true }, { status: 200 });
}
// Wrap with withPublicContext so the handler runs inside a
// runWithRequestContext ALS frame — without it the inline
// `captureErrorEvent` call in the catch block silently no-ops because
// getRequestContext() returns null for unauthenticated routes.
export const POST = withPublicContext(handleDocumensoWebhook);