From b23ea760c8f35c304e6fb2a764b13a5bd260207e Mon Sep 17 00:00:00 2001 From: Aravind Date: Thu, 6 Aug 2026 15:43:46 +0530 Subject: [PATCH] fix chatbox --- src/app/api/auth/login/route.ts | 213 ++++++++++++++---- src/app/globals.css | 25 +- .../auth/components/LoginCredentialsForm.tsx | 33 ++- src/features/auth/hooks/useLoginForm.ts | 34 ++- src/features/auth/services/loginErrorCodes.ts | 37 +++ .../loyaly-ai/components/MessageBubble.tsx | 37 ++- .../components/response/ChartBlock.tsx | 11 +- .../components/response/InsightCardsBlock.tsx | 28 ++- .../components/response/KpiGridBlock.tsx | 59 ++++- .../components/response/ResponseRenderer.tsx | 41 +++- .../components/response/SummaryCard.tsx | 2 +- .../components/response/TableBlock.tsx | 33 ++- .../loyaly-ai/services/ai/templates/stores.ts | 8 +- src/shared/components/charts/ChartFrame.tsx | 9 +- src/shared/components/charts/types.ts | 2 +- 15 files changed, 475 insertions(+), 97 deletions(-) create mode 100644 src/features/auth/services/loginErrorCodes.ts diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 4414af2..262a7a1 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,6 +1,11 @@ -import {cookies} from 'next/headers'; +import {NextResponse} from 'next/server'; import type {NextRequest} from 'next/server'; import {verifyCredentials} from '@/features/auth/mock/users.mock'; +import { + LOGIN_ERROR_PARAM, + type LoginErrorCode, +} from '@/features/auth/services/loginErrorCodes'; +import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget'; import { REMEMBERED_MAX_AGE_SECONDS, SESSION_COOKIE, @@ -25,6 +30,23 @@ export const dynamic = 'force-dynamic'; * • deciding how long the session lasts (rememberMe is a request, not an * instruction — the server sets the cookie lifetime) * • issuing the httpOnly cookie the client can never read or forge + * + * ── Two content types, one endpoint ────────────────────────────────────── + * It answers both `application/json` (the hydrated form, via authRepository) + * and `application/x-www-form-urlencoded` (the browser posting the form + * natively, before React has hydrated or when its bundle never arrived). + * + * That second path is not a nicety, it is the fix for a real leak. The sign-in + * form has named inputs; a
with no method and no action submits GET to + * its own URL, so a submit landing in the pre-hydration window rewrote the + * address bar to `/login?email=…&password=…` — putting the password in the + * URL bar, in browser history, in the referrer and in every access log between + * here and the user. Declaring method="post" action="/api/auth/login" means + * the worst case is now a normal POST with the credentials in the body. + * + * The two paths differ ONLY in how the answer is shaped: JSON gets an + * envelope, a native post gets a 303 redirect, because a browser that just + * submitted a form needs somewhere to land, not a document full of braces. */ interface LoginRequestBody { @@ -33,54 +55,140 @@ interface LoginRequestBody { rememberMe?: unknown; } -function failure(error: LoginError, status: number): Response { - // Shaped as the app's standard envelope so the client's error path is the - // same one every other endpoint uses; `field` rides alongside for the form. - const body: ApiFailure & {field: LoginError['field']} = { - error: {code: status === 401 ? 'unauthorized' : 'bad_request', message: error.message}, - field: error.field, - }; - return Response.json(body, {status, headers: {'cache-control': 'no-store'}}); +/** What the request asked for, normalised across both content types. */ +interface ParsedLogin { + email: string; + password: string; + rememberMe: boolean; + /** Only meaningful on the native path — where to land after success. */ + next: string | null; + /** True when the browser posted the form itself, so answer in redirects. */ + isFormPost: boolean; } -export async function POST(req: NextRequest): Promise { +function asString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +async function parseRequest(req: NextRequest): Promise { + const contentType = req.headers.get('content-type') ?? ''; + + if ( + contentType.includes('application/x-www-form-urlencoded') || + contentType.includes('multipart/form-data') + ) { + const form = await req.formData(); + return { + email: asString(form.get('email')).trim(), + password: asString(form.get('password')), + // An unchecked checkbox is absent from the payload entirely; any present + // value means checked. Never parsed as a boolean-ish string. + rememberMe: form.get('rememberMe') !== null, + next: asString(form.get('next')) || null, + isFormPost: true, + }; + } + let body: LoginRequestBody; try { body = (await req.json()) as LoginRequestBody; } catch { - return failure({field: 'form', message: 'Malformed request body.'}, 400); + return null; + } + return { + email: asString(body.email).trim(), + password: asString(body.password), + rememberMe: body.rememberMe === true, + next: null, + isFormPost: false, + }; +} + +/** + * A failure, in whichever dialect the caller speaks. + * + * The redirect carries a code, never the submitted values — bouncing the email + * back through the URL to repopulate the field would reintroduce exactly the + * leak this endpoint exists to close. + */ +function failure( + req: NextRequest, + parsed: Pick | null, + code: LoginErrorCode, + error: LoginError, + status: number, +): NextResponse { + if (parsed?.isFormPost) { + const target = new URL('/login', req.url); + target.searchParams.set(LOGIN_ERROR_PARAM, code); + // Preserve the deep link so a failed attempt does not cost the user the + // page they were originally trying to reach. Validated, not echoed raw. + const next = resolveRedirectTarget(parsed.next); + if (next !== '/dashboard') target.searchParams.set('next', next); + // 303: the browser must follow with GET, not repeat the POST. + return NextResponse.redirect(target, 303); } - const email = typeof body.email === 'string' ? body.email.trim() : ''; - const password = typeof body.password === 'string' ? body.password : ''; - const rememberMe = body.rememberMe === true; + // Shaped as the app's standard envelope so the client's error path is the + // same one every other endpoint uses; `field` rides alongside for the form. + const body: ApiFailure & {field: LoginError['field']} = { + error: { + code: status === 401 ? 'unauthorized' : 'bad_request', + message: error.message, + }, + field: error.field, + }; + return NextResponse.json(body, { + status, + headers: {'cache-control': 'no-store'}, + }); +} + +export async function POST(req: NextRequest): Promise { + const parsed = await parseRequest(req); + + if (!parsed) { + return failure( + req, + null, + 'malformed', + {field: 'form', message: 'Malformed request body.'}, + 400, + ); + } // Server-side validation, repeated rather than trusted from the client. The // form validates too, for latency; this validates because the form is not a // security boundary and a POST can arrive without it. - if (!email) { - return failure({field: 'email', message: 'Enter your email address.'}, 400); + if (!parsed.email) { + return failure(req, parsed, 'email_required', { + field: 'email', + message: 'Enter your email address.', + }, 400); } - if (!/^\S+@\S+\.\S+$/.test(email)) { - return failure( - {field: 'email', message: 'Enter a valid email address.'}, - 400, - ); + if (!/^\S+@\S+\.\S+$/.test(parsed.email)) { + return failure(req, parsed, 'email_invalid', { + field: 'email', + message: 'Enter a valid email address.', + }, 400); } - if (!password) { - return failure({field: 'password', message: 'Enter your password.'}, 400); + if (!parsed.password) { + return failure(req, parsed, 'password_required', { + field: 'password', + message: 'Enter your password.', + }, 400); } - const check = verifyCredentials(email, password); + const check = verifyCredentials(parsed.email, parsed.password); if (check.outcome === 'unknown_email' || check.outcome === 'wrong_password') { - return failure( - {field: 'form', message: 'Invalid email or password.'}, - 401, - ); + return failure(req, parsed, 'invalid_credentials', { + field: 'form', + message: 'Invalid email or password.', + }, 401); } - const maxAge = rememberMe + const maxAge = parsed.rememberMe ? REMEMBERED_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS; @@ -95,23 +203,42 @@ export async function POST(req: NextRequest): Promise { maxAge, ); - // A browser-session cookie still needs a server-side expiry, or a tab left - // open for a week would hold a valid token indefinitely. - const store = await cookies(); - store.set( - SESSION_COOKIE, - token, - sessionCookieOptions(rememberMe ? maxAge : undefined), - ); - const session: AuthSession = { user: check.user, expiresAt: new Date(Date.now() + maxAge * 1000).toISOString(), }; - const payload: ApiSuccess = { - data: session, - meta: {generatedAt: new Date().toISOString()}, - }; - return Response.json(payload, {headers: {'cache-control': 'no-store'}}); + const response = parsed.isFormPost + ? // 303 so the browser re-issues as GET. Without it, a refresh on the + // landing page would re-POST the credentials. + NextResponse.redirect( + new URL(resolveRedirectTarget(parsed.next), req.url), + 303, + ) + : NextResponse.json( + { + data: session, + meta: {generatedAt: new Date().toISOString()}, + } satisfies ApiSuccess, + {headers: {'cache-control': 'no-store'}}, + ); + + /** + * Set on the response rather than through the `cookies()` store, because + * this response may be a redirect: the header has to ride along with the 303 + * itself or the browser follows it to the dashboard still signed out, gets + * bounced back to /login by the proxy, and the sign-in appears to have + * silently failed. + * + * A browser-session cookie still needs a server-side expiry, or a tab left + * open for a week would hold a valid token indefinitely — hence the token's + * own `exp` regardless of whether maxAge is sent. + */ + response.cookies.set( + SESSION_COOKIE, + token, + sessionCookieOptions(parsed.rememberMe ? maxAge : undefined), + ); + + return response; } diff --git a/src/app/globals.css b/src/app/globals.css index 4f5d0cc..743cc54 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -30,18 +30,33 @@ html { } @layer components { - /* Global Data Table Inset Contract: 24px left lead-in, 32px right inset */ + /* + * Global Data Table Inset Contract: 24px left lead-in, 32px right inset. + * + * `[data-ai-report]` is excluded, and that exclusion is a bug fix rather + * than a preference. These rules were written for full-page dashboard + * tables, but the selector is bare `table` with `!important`, so they also + * hit the report tables rendered inside the Loyaly AI panel — adding 24px + + * 32px of inset to a table that only has ~376px to begin with, on top of + * its own px-4 cells. Roughly a sixth of the panel went to padding the + * report could not opt out of, which is a large part of why its columns + * came out unreadably narrow. + * + * The AI report sets its own cell padding for its own width. Scoping by + * attribute keeps the dashboard contract exactly as it was for every table + * that is genuinely on a full page. + */ .astryx-table-cell:first-child, .astryx-table-header-cell:first-child, - table th:first-child, - table td:first-child { + table:not([data-ai-report]) th:first-child, + table:not([data-ai-report]) td:first-child { padding-inline-start: var(--spacing-6) !important; } .astryx-table-cell:last-child, .astryx-table-header-cell:last-child, - table th:last-child, - table td:last-child { + table:not([data-ai-report]) th:last-child, + table:not([data-ai-report]) td:last-child { padding-inline-end: var(--spacing-8) !important; } diff --git a/src/features/auth/components/LoginCredentialsForm.tsx b/src/features/auth/components/LoginCredentialsForm.tsx index f1f625e..43f5e33 100644 --- a/src/features/auth/components/LoginCredentialsForm.tsx +++ b/src/features/auth/components/LoginCredentialsForm.tsx @@ -1,7 +1,7 @@ 'use client'; import {useState} from 'react'; -import {useLoginForm} from '@/features/auth/hooks/useLoginForm'; +import {LOGIN_ENDPOINT, useLoginForm} from '@/features/auth/hooks/useLoginForm'; import {GoogleIcon, MicrosoftIcon} from './ProviderIcons'; /** @@ -27,6 +27,7 @@ export function LoginCredentialsForm() { rememberMe, errors, isSubmitting, + next, setEmail, setPassword, setRememberMe, @@ -34,7 +35,35 @@ export function LoginCredentialsForm() { } = useLoginForm(); return ( - + /** + * `method="post"` and `action` are the no-JavaScript safety net, and they + * are load-bearing rather than decorative. + * + * `submit` calls preventDefault, so on a hydrated page these attributes + * are never exercised. But hydration is not instantaneous: a form with + * neither attribute submits GET to its own URL, so a Continue pressed + * before React attached — or on a page whose bundle failed outright — + * navigated to `/login?email=…&password=…`, writing the password into the + * address bar, the history and every log along the way. + * + * With these set, that same early submit is an ordinary POST with the + * credentials in the body, and the endpoint answers it with a redirect. + * Sign-in therefore works with JavaScript disabled entirely. + */ + + {/* + The deep link the proxy preserved, carried through the native path — + the hydrated path reads it from the URL instead. Without it, a no-JS + sign-in from /login?next=/settings/billing would land on the dashboard. + */} + {next && } +