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:
@@ -7,7 +7,6 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { authClient } from '@/lib/auth/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -15,9 +14,10 @@ import { Label } from '@/components/ui/label';
|
||||
import { BrandedAuthShell } from '@/components/shared/branded-auth-shell';
|
||||
|
||||
// `identifier` accepts either an email address or a username (3–30 lowercase
|
||||
// letters / digits / dot / underscore / hyphen). The page resolves usernames
|
||||
// to the canonical Better-Auth email via /api/auth/resolve-identifier before
|
||||
// the actual sign-in call.
|
||||
// letters / digits / dot / underscore / hyphen). The server endpoint
|
||||
// /api/auth/sign-in-by-identifier resolves the username server-side and
|
||||
// forwards to better-auth in one round-trip — the canonical email is never
|
||||
// returned to the browser, which closes the username-enumeration vector.
|
||||
const loginSchema = z.object({
|
||||
identifier: z.string().min(1, 'Email or username is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
@@ -40,29 +40,20 @@ export default function LoginPage() {
|
||||
async function onSubmit(data: LoginFormData) {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Resolve username → email when the input isn't already an email.
|
||||
// The endpoint always returns SOMETHING (the input itself on miss)
|
||||
// so the auth call below fails uniformly with "invalid credentials"
|
||||
// either way — no username enumeration.
|
||||
const identifier = data.identifier.trim();
|
||||
let email = identifier;
|
||||
if (!identifier.includes('@')) {
|
||||
const res = await fetch('/api/auth/resolve-identifier', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identifier }),
|
||||
});
|
||||
const payload = (await res.json().catch(() => ({}))) as { email?: string };
|
||||
email = payload.email?.trim() || identifier;
|
||||
}
|
||||
|
||||
const result = await authClient.signIn.email({
|
||||
email,
|
||||
password: data.password,
|
||||
const res = await fetch('/api/auth/sign-in-by-identifier', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
identifier: data.identifier.trim(),
|
||||
password: data.password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
toast.error(result.error.message ?? 'Invalid credentials');
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => ({}))) as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
toast.error(payload.error?.message ?? 'Invalid credentials');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,22 +30,6 @@ const FIELDS: SettingFieldDef[] = [
|
||||
placeholder: 'sales@example.com',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_signature_html',
|
||||
label: 'Default signature (HTML)',
|
||||
description: 'Appended to the bottom of system-generated emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p>-<br>The Port Nimara team</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'email_footer_html',
|
||||
label: 'Email footer (HTML)',
|
||||
description: 'Legal/contact footer rendered at the very bottom of all emails.',
|
||||
type: 'html',
|
||||
placeholder: '<p style="font-size:11px;color:#888;">© Port Nimara · ul. ...</p>',
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
key: 'smtp_host_override',
|
||||
label: 'SMTP host override',
|
||||
@@ -83,17 +67,17 @@ export default function EmailSettingsPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Email Settings"
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank."
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="From address & signature"
|
||||
description="Identity headers and shared HTML used by system-generated emails."
|
||||
fields={FIELDS.slice(0, 5)}
|
||||
title="From address"
|
||||
description="Identity headers used by system-generated emails."
|
||||
fields={FIELDS.slice(0, 3)}
|
||||
/>
|
||||
<SettingsFormCard
|
||||
title="SMTP transport overrides"
|
||||
description="Optional per-port SMTP credentials. Leave blank to use the global env defaults."
|
||||
fields={FIELDS.slice(5)}
|
||||
fields={FIELDS.slice(3)}
|
||||
/>
|
||||
<SalesEmailConfigCard />
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { EXPENSE_CATEGORIES } from '@/lib/constants';
|
||||
import { EXPENSE_CATEGORIES, formatEnum } from '@/lib/constants';
|
||||
|
||||
interface ScanResult {
|
||||
establishment: string | null;
|
||||
@@ -345,7 +345,7 @@ export default function ScanReceiptPage() {
|
||||
<SelectContent>
|
||||
{EXPENSE_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
{formatEnum(cat)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
102
src/app/api/auth/sign-in-by-identifier/route.ts
Normal file
102
src/app/api/auth/sign-in-by-identifier/route.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user