diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 4ea0f43..369328d 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -236,11 +236,32 @@ export async function POST(req: NextRequest) { * a session it is unable to verify on the next request. */ const user = toAuthUser(bundle.user); - const maxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : SESSION_MAX_AGE_SECONDS; + /** + * Two different lifetimes, and conflating them was the bug. + * + * `tokenLifetime` is how long the SIGNED PAYLOAD stays valid — it becomes the + * `exp` claim, and it must always be a real duration. A cookie with no expiry + * whose token also never expires is a credential that works forever once + * captured. + * + * `cookieMaxAge` is how long the BROWSER keeps the cookie, and it is + * `undefined` when "remember me" is off. That is what makes it a + * browser-session cookie: the browser drops it on close, which is what the + * unticked box is asking for. It used to be given 12 hours regardless, so an + * unticked "remember me" still left somebody signed in on a shared machine + * after they had closed the browser. + * + * The SAME value goes to both cookies, so the identity can never outlive the + * sealed tokens it claims to stand for. + */ + const tokenLifetime = rememberMe + ? REMEMBERED_MAX_AGE_SECONDS + : SESSION_MAX_AGE_SECONDS; + const cookieMaxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : undefined; let sessionCookie: string; try { - await storeTokens(bundle); + await storeTokens(bundle, cookieMaxAge); sessionCookie = createSessionToken( { sub: user.id, @@ -249,7 +270,7 @@ export async function POST(req: NextRequest) { role: user.role, organisation: user.organisation, }, - maxAge, + tokenLifetime, ); } catch (err) { if (!(err instanceof ConfigError)) throw err; @@ -292,6 +313,6 @@ export async function POST(req: NextRequest) { {headers: {'cache-control': 'no-store'}}, ); - res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(maxAge)); + res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(cookieMaxAge)); return res; } diff --git a/src/app/api/floor/visits/route.ts b/src/app/api/floor/visits/route.ts index 06341ca..0eac254 100644 --- a/src/app/api/floor/visits/route.ts +++ b/src/app/api/floor/visits/route.ts @@ -2,6 +2,7 @@ import type {NextRequest} from 'next/server'; import {floorApi} from '@/services/api/floorApi'; import {toSiteParam} from '@/services/api/range'; import {serveUpstream} from '@/shared/services/bff'; +import {UpstreamError} from '@/services/api/apiClient'; import type {ApiFloorVisit} from '@/services/api/types'; import type {FloorVisit} from '@/features/floor/types/floor'; @@ -41,7 +42,20 @@ export function toFloorVisit(v: ApiFloorVisit): FloorVisit { export async function GET(req: NextRequest) { return serveUpstream( req, - (token, query) => floorApi.list(token, {site: toSiteParam(query.storeId)}), + async (token, query) => { + try { + return await floorApi.list(token, {site: toSiteParam(query.storeId)}); + } catch (err) { + // If the upstream platform has not deployed /api/floor/visits yet, + // answer with an empty list so the floor screen renders its clean empty state + // rather than failing with 404. + if (err instanceof UpstreamError && err.status === 404) { + return {items: []}; + } + throw err; + } + }, (page) => (page.items ?? []).map(toFloorVisit), ); } + diff --git a/src/app/api/sales/route.ts b/src/app/api/sales/route.ts index 814537d..26bc7c3 100644 --- a/src/app/api/sales/route.ts +++ b/src/app/api/sales/route.ts @@ -1,8 +1,9 @@ import type {NextRequest} from 'next/server'; import {salesApi} from '@/services/api/salesApi'; -import {toSiteParam} from '@/services/api/range'; +import {toReportWindow, toSiteParam} from '@/services/api/range'; import {serveUpstream, failureFrom} from '@/shared/services/bff'; import {withUpstream} from '@/features/auth/services/upstreamSession'; +import {UpstreamError} from '@/services/api/apiClient'; import type {ApiSale} from '@/services/api/types'; import type {Sale} from '@/features/commerce/types/sale'; @@ -44,11 +45,25 @@ export function toSale(s: ApiSale): Sale { export async function GET(req: NextRequest) { return serveUpstream( req, - (token, query) => - salesApi.list(token, { - site: toSiteParam(query.storeId), - limit: 50, - }), + async (token, query) => { + const window = toReportWindow(query.range, new Date(query.nowMs)); + try { + return await salesApi.list(token, { + site: toSiteParam(query.storeId), + from: window.from, + to: window.to, + limit: 50, + }); + } catch (err) { + // If the upstream platform has not deployed /api/sales yet, + // answer with an empty list so the sales screen renders cleanly + // rather than failing with 404. + if (err instanceof UpstreamError && err.status === 404) { + return {items: []}; + } + throw err; + } + }, (page) => (page.items ?? []).map(toSale), ); } @@ -127,6 +142,18 @@ export async function POST(req: NextRequest) { {status: 201, headers: {'cache-control': 'no-store'}}, ); } catch (err) { + if (err instanceof UpstreamError && err.status === 404) { + return Response.json( + { + error: { + code: 'bad_request', + message: 'Sale recording is not available on this server version yet.', + }, + reason: 'not_implemented', + }, + {status: 501, headers: {'cache-control': 'no-store'}}, + ); + } const f = failureFrom(err); return Response.json( {error: {code: f.code, message: f.message}, reason: f.reason}, diff --git a/src/features/auth/services/upstreamSession.ts b/src/features/auth/services/upstreamSession.ts index d992995..e9b2f45 100644 --- a/src/features/auth/services/upstreamSession.ts +++ b/src/features/auth/services/upstreamSession.ts @@ -53,9 +53,12 @@ async function readTokens(): Promise { * a Server Component's cookie store is read-only, which is exactly why every * platform call goes through a route rather than being made during render. */ -async function persistTokens(bundle: TokenBundle): Promise { +async function persistTokens( + bundle: TokenBundle, + maxAgeSeconds?: number, +): Promise { const store = await cookies(); - store.set(TOKEN_COOKIE, sealTokens(bundle), tokenCookieOptions()); + store.set(TOKEN_COOKIE, sealTokens(bundle), tokenCookieOptions(maxAgeSeconds)); } export function toBundle(res: ApiTokenBundle): TokenBundle { @@ -136,10 +139,23 @@ export async function withUpstream( } } -/** Persist a bundle at sign-in / registration. Route handlers only. */ -export async function storeTokens(res: ApiTokenBundle): Promise { +/** + * Persist a bundle at sign-in / registration. Route handlers only. + * + * `maxAgeSeconds` MUST match whatever the identity cookie is given, and is + * omitted for a browser-session cookie. The two used to disagree: this one was + * always session-scoped while `loyaly_session` was always persistent, so after + * a browser restart the identity cookie survived and the sealed tokens did not. + * The proxy then admitted the page on the identity alone, the shell rendered + * looking signed in, and every data call answered 401 — a half-authenticated + * state that reads as a broken dashboard rather than as a finished session. + */ +export async function storeTokens( + res: ApiTokenBundle, + maxAgeSeconds?: number, +): Promise { const bundle = toBundle(res); - await persistTokens(bundle); + await persistTokens(bundle, maxAgeSeconds); return bundle; } diff --git a/src/shared/components/patterns/DownloadDropdown.tsx b/src/shared/components/patterns/DownloadDropdown.tsx index f6730d3..422bc2d 100644 --- a/src/shared/components/patterns/DownloadDropdown.tsx +++ b/src/shared/components/patterns/DownloadDropdown.tsx @@ -90,7 +90,6 @@ export function DownloadDropdown({ }; const handleSelectFormat = async (fmt: ExportFormat) => { - console.log('[DownloadDropdown] Selected format:', fmt); setIsOpen(false); setLoadingFormat(fmt); @@ -105,7 +104,6 @@ export function DownloadDropdown({ }); const label = fmt === 'pdf' ? 'PDF' : fmt === 'excel' ? 'Excel' : 'CSV'; - console.log(`[DownloadDropdown] Success toast for ${label}`); toast({ body: `✓ ${label} downloaded successfully`, }); diff --git a/src/shared/utils/export/exportManager.ts b/src/shared/utils/export/exportManager.ts index 167869d..90200be 100644 --- a/src/shared/utils/export/exportManager.ts +++ b/src/shared/utils/export/exportManager.ts @@ -19,20 +19,12 @@ export interface ExportDataParams { } function triggerBrowserDownload(blob: Blob, filename: string): void { - console.log('[exportManager] Blob generated successfully:', { - size: blob.size, - type: blob.type, - isBlob: blob instanceof Blob, - }); - console.log('[exportManager] Triggering download link for:', filename); - const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); - console.log('[exportManager] Executing link.click()...'); link.click(); document.body.removeChild(link); @@ -47,8 +39,6 @@ export async function exportData({ data, format, }: ExportDataParams): Promise { - console.log('[exportData] Starting generation for format:', format, {filename, title}); - // Artificial generation delay for smooth UX transition await new Promise((res) => setTimeout(res, 450));