From b60b8baef6211cb55068231ab41c88cccd37df2f Mon Sep 17 00:00:00 2001 From: Aravind Date: Mon, 21 Sep 2026 16:38:15 +0530 Subject: [PATCH] admin login issue --- docs/BEHAVISION-GAP-ANALYSIS.md | 5 +- src/app/(admin)/admin/page.tsx | 35 +++ src/app/(admin)/layout.tsx | 23 ++ .../clients/[id]/owner-password/route.ts | 36 +++ src/app/api/admin/clients/[id]/route.ts | 77 +++++ src/app/api/admin/clients/route.ts | 62 ++++ src/app/api/auth/login/route.ts | 128 ++++---- src/app/api/auth/logout/route.ts | 64 ++-- src/app/layout.tsx | 15 +- src/features/admin/components/AdminLayout.tsx | 82 ++++++ .../admin/components/CompaniesPanel.tsx | 278 ++++++++++++++++++ .../admin/components/CreateCompanyDialog.tsx | 176 +++++++++++ .../admin/components/DeleteCompanyDialog.tsx | 125 ++++++++ .../components/ResetOwnerPasswordDialog.tsx | 149 ++++++++++ .../admin/components/SuspendCompanyDialog.tsx | 112 +++++++ src/features/admin/hooks/useCompanies.ts | 56 ++++ .../admin/repositories/companyRepository.ts | 79 +++++ src/features/admin/services/mapCompany.ts | 27 ++ src/features/admin/types/company.ts | 59 ++++ src/features/auth/guards/GuestGuard.tsx | 8 +- src/features/auth/hooks/useLoginForm.ts | 23 +- .../auth/providers/SessionProvider.tsx | 46 +-- src/features/auth/services/loginErrorCodes.ts | 23 +- src/features/auth/services/redirectTarget.ts | 37 +++ src/features/auth/services/roleDestination.ts | 61 ++-- src/features/auth/services/serverSession.ts | 25 +- src/features/auth/services/sessionToken.ts | 12 + src/features/auth/services/tabScope.ts | 88 ++++++ src/features/auth/services/tabScopeRequest.ts | 53 ++++ src/features/auth/services/tabSession.ts | 135 ++++----- src/features/auth/services/upstreamSession.ts | 38 ++- src/features/auth/services/userMapper.ts | 3 + src/features/auth/types/auth.ts | 7 + src/features/stores/hooks/useSites.ts | 19 +- src/proxy.ts | 128 +++++++- src/services/api/adminApi.ts | 100 +++++++ src/services/api/types.ts | 71 +++++ .../layouts/workspace/SidebarProvider.tsx | 2 +- .../layouts/workspace/sidebarStorage.ts | 15 + src/shared/services/httpClient.ts | 32 ++ 40 files changed, 2254 insertions(+), 260 deletions(-) create mode 100644 src/app/(admin)/admin/page.tsx create mode 100644 src/app/(admin)/layout.tsx create mode 100644 src/app/api/admin/clients/[id]/owner-password/route.ts create mode 100644 src/app/api/admin/clients/[id]/route.ts create mode 100644 src/app/api/admin/clients/route.ts create mode 100644 src/features/admin/components/AdminLayout.tsx create mode 100644 src/features/admin/components/CompaniesPanel.tsx create mode 100644 src/features/admin/components/CreateCompanyDialog.tsx create mode 100644 src/features/admin/components/DeleteCompanyDialog.tsx create mode 100644 src/features/admin/components/ResetOwnerPasswordDialog.tsx create mode 100644 src/features/admin/components/SuspendCompanyDialog.tsx create mode 100644 src/features/admin/hooks/useCompanies.ts create mode 100644 src/features/admin/repositories/companyRepository.ts create mode 100644 src/features/admin/services/mapCompany.ts create mode 100644 src/features/admin/types/company.ts create mode 100644 src/features/auth/services/tabScope.ts create mode 100644 src/features/auth/services/tabScopeRequest.ts create mode 100644 src/services/api/adminApi.ts create mode 100644 src/shared/layouts/workspace/sidebarStorage.ts diff --git a/docs/BEHAVISION-GAP-ANALYSIS.md b/docs/BEHAVISION-GAP-ANALYSIS.md index 1ab74f5..5f55519 100644 --- a/docs/BEHAVISION-GAP-ANALYSIS.md +++ b/docs/BEHAVISION-GAP-ANALYSIS.md @@ -1,6 +1,9 @@ # Behavision API ↔ Loyaly Merchant OS — gap analysis -Audit date: 2026-09-09 · API: `https://platform.loyaly.ai` · Frontend: this repo +Audit date: 2026-09-09 · API: `https://mcp.loyaly.ai` · Frontend: this repo + +(Host corrected 2026-09-21: this line read `https://platform.loyaly.ai`, which +serves this console, not the API. See `src/shared/config/platformApi.ts:76-80`.) --- diff --git a/src/app/(admin)/admin/page.tsx b/src/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..97b1d22 --- /dev/null +++ b/src/app/(admin)/admin/page.tsx @@ -0,0 +1,35 @@ +import type {Metadata} from 'next'; +import {VStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {CompaniesPanel} from '@/features/admin/components/CompaniesPanel'; + +export const metadata: Metadata = { + title: 'Platform admin · Loyaly', +}; + +/** + * The platform console. + * + * Company administration and nothing else — that is the whole of the platform's + * admin surface upstream (`/api/admin/*`), and this page deliberately does not + * grow past it. There is no endpoint to browse a tenant's visitors, cameras, + * reports or shops, and no impersonation: seeing a company's data means signing + * in as that company's owner, which is a different decision with a different + * audit trail. + */ +export default function AdminPage() { + return ( + + + + Platform admin + + + Create, suspend and remove the merchant companies on this platform. + + + + + + ); +} diff --git a/src/app/(admin)/layout.tsx b/src/app/(admin)/layout.tsx new file mode 100644 index 0000000..a2bb5d1 --- /dev/null +++ b/src/app/(admin)/layout.tsx @@ -0,0 +1,23 @@ +import {AdminLayout} from '@/features/admin/components/AdminLayout'; + +/** + * The route-group boundary for the platform console. + * + * Deliberately NOT `(workspace)`. That group wraps everything in + * `ProtectedLayout` → `WorkspaceShell`: a site switcher, tenant navigation and + * the Loyaly AI rail, every one of which is scoped to a company. A platform + * operator has no company, so that shell would render a store picker with + * nothing in it above a page about somebody else's stores. + * + * What the two groups DO share is the session: `AdminLayout` uses the same + * `AuthGuard` as the workspace, reading the same cookie minted by the same + * login route. There is one authentication system in this app, and this is not + * a second one. + */ +export default function AdminRouteLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/api/admin/clients/[id]/owner-password/route.ts b/src/app/api/admin/clients/[id]/owner-password/route.ts new file mode 100644 index 0000000..83d7547 --- /dev/null +++ b/src/app/api/admin/clients/[id]/owner-password/route.ts @@ -0,0 +1,36 @@ +import type {NextRequest} from 'next/server'; +import {adminApi} from '@/services/api/adminApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/admin/clients/{id}/owner-password — reset an owner's password. + * + * The support case: the owner has locked themselves out and there is nobody + * above them in the company to reset it. The new password is GENERATED, never + * chosen, and every session that owner held is revoked. + * + * `email` picks the owner when the company has more than one; with exactly one + * it may be omitted, and the UI omits it first. With several owners and no + * address the platform answers 400 listing them — that message travels through + * `failResponse` intact, which is what lets the dialog ask "which owner?" + * without this console needing its own endpoint to enumerate them. + * + * ── The response body is a credential ──────────────────────────────────── + * It is shown once and cannot be fetched again. Nothing on this path may cache + * it: `proxyUpstream` sets `cache-control: no-store` on every response it + * writes, which is what keeps it out of a CDN, a browser disk cache and the + * back button. It is never logged here, and never reaches a URL — it travels in + * a POST response body and nowhere else. + */ +export async function POST( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream(req, (token, body) => { + const email = typeof body.email === 'string' ? body.email.trim() : ''; + return adminApi.resetOwnerPassword(token, id, email || undefined); + }); +} diff --git a/src/app/api/admin/clients/[id]/route.ts b/src/app/api/admin/clients/[id]/route.ts new file mode 100644 index 0000000..de99c1f --- /dev/null +++ b/src/app/api/admin/clients/[id]/route.ts @@ -0,0 +1,77 @@ +import type {NextRequest} from 'next/server'; +import {adminApi} from '@/services/api/adminApi'; +import {proxyUpstream} from '@/shared/services/bff'; +import {toCompany} from '@/features/admin/services/mapCompany'; + +export const dynamic = 'force-dynamic'; + +/** + * PATCH /api/admin/clients/{id} — suspend or reinstate a company. + * + * Suspension is complete the moment this returns: the company's users cannot + * sign in, every session they hold is revoked in the same transaction, and + * visits from its shop PCs are dropped at ingest. Reinstating does not restore + * sessions — people sign in again. + * + * The count of revoked sessions is surfaced rather than swallowed. "Suspended" + * alone leaves an operator wondering whether somebody is still signed in on a + * shop PC; "suspended, 3 sessions ended" answers it. + * + * `active` is read strictly as a boolean. A missing or non-boolean value is + * forwarded as-is so the platform's own 400 (`"active" is required: true to + * reinstate, false to suspend`) is what the operator reads, rather than a + * second, differently-worded validation invented here. + */ +export async function PATCH( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream( + req, + (token, body) => adminApi.setClientActive(token, id, body.active as boolean), + { + map: (res) => ({ + company: toCompany(res.client), + sessionsRevoked: res.sessions_revoked, + }), + }, + ); +} + +/** + * DELETE /api/admin/clients/{id} — permanent, and the data is biometric. + * + * Two conditions, both the PLATFORM's and neither enforced here: the company + * must already be suspended (`409 still_active` otherwise) and the body must + * repeat its slug. The dialog mirrors them so nobody is surprised, but this + * route forwards whatever it is given — an active company is sent and the real + * 409 comes back. A console that pre-empted the check would eventually disagree + * with the server about what is deletable, and the disagreement would surface + * as a delete that "worked" in the UI and did not happen. + * + * Upstream order matters if this fails: face images go from object storage + * first (`502 storage_error` leaves everything else untouched), then the shop + * PCs' broker logins, then every row by cascade. + * + * ── Why `confirm` arrives as a query parameter ─────────────────────────── + * The platform wants it in the body, and this route puts it there. It cannot + * arrive that way, though: `proxyUpstream` does not read a body on DELETE (a + * DELETE legitimately has none), and the browser-side `deleteJson` cannot send + * one either. So it travels as a query parameter on THIS origin's request and + * is moved into the body on the way upstream. + * + * Safe to put in a URL, unlike anything else on this screen: a slug is the + * company's public identifier, already visible in the list and in every broker + * topic. It is a confirmation, not a credential — it proves the operator typed + * the right name, and it protects nothing on its own. + */ +export async function DELETE( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream(req, (token, _body, query) => + adminApi.deleteClient(token, id, query.get('confirm') ?? ''), + ); +} diff --git a/src/app/api/admin/clients/route.ts b/src/app/api/admin/clients/route.ts new file mode 100644 index 0000000..253725e --- /dev/null +++ b/src/app/api/admin/clients/route.ts @@ -0,0 +1,62 @@ +import type {NextRequest} from 'next/server'; +import {adminApi} from '@/services/api/adminApi'; +import {proxyUpstream, serveUpstream} from '@/shared/services/bff'; +import {toCompany} from '@/features/admin/services/mapCompany'; +import type {ApiNewClientInput} from '@/services/api/types'; + +export const dynamic = 'force-dynamic'; + +/** + * The companies on this platform. + * + * ── Why this proxy exists at all ───────────────────────────────────────── + * The browser could not call `mcp.loyaly.ai/api/admin/clients` directly even if + * we wanted it to: the platform sends no CORS headers, so a cross-origin fetch + * from this console is blocked before it leaves. Routing through the BFF is not + * a workaround for that — it is the reason the platform can afford to send no + * CORS headers. The access token stays in an httpOnly cookie this page's + * JavaScript cannot read, so an XSS on this origin cannot steal a platform + * session. + * + * Authorisation is NOT re-implemented here. `withUpstream` attaches whatever + * token the session holds and the platform decides: `adminOnly` answers 404 to + * anyone who is not a platform operator. A merchant who reached this route + * would get that 404 translated into `not_found`, not a list of tenants. + */ +export async function GET(req: NextRequest) { + return serveUpstream( + req, + (token) => adminApi.listClients(token), + (rows) => rows.map(toCompany), + ); +} + +/** + * POST /api/admin/clients — create a company and its owner, in one transaction. + * + * Answers **201**, and the body carries the owner's generated password. That is + * the only time it exists in readable form: it is bcrypt-hashed on the way in + * and cannot be fetched again. + * + * `password` is never forwarded from the client, even if one were sent. Empty + * means "generate one", which is the better default — an operator typing a + * password for somebody else invents a weak one and then sends it over chat. + * `slug` is forwarded only when non-empty; the platform derives it from the + * name otherwise, and it can never be changed afterwards. + */ +export async function POST(req: NextRequest) { + return proxyUpstream( + req, + (token, body) => { + const slug = typeof body.slug === 'string' ? body.slug.trim() : ''; + const input: ApiNewClientInput = { + company_name: String(body.company_name ?? '').trim(), + owner_email: String(body.owner_email ?? '').trim(), + owner_name: String(body.owner_name ?? '').trim(), + }; + if (slug) input.slug = slug; + return adminApi.createClient(token, input); + }, + {status: 201}, + ); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 369328d..fb069de 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -7,17 +7,25 @@ import { LOGIN_ERROR_PARAM, type LoginErrorCode, } from '@/features/auth/services/loginErrorCodes'; -import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget'; +import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget'; import { REMEMBERED_MAX_AGE_SECONDS, - SESSION_COOKIE, SESSION_MAX_AGE_SECONDS, createSessionToken, sessionCookieOptions, } from '@/features/auth/services/sessionToken'; +import { + TAB_POINTER_COOKIE, + sessionCookieFor, + tabPointerOptions, +} from '@/features/auth/services/tabScope'; +import { + newTabId, + resolveTabId, +} from '@/features/auth/services/tabScopeRequest'; import {storeTokens} from '@/features/auth/services/upstreamSession'; -import {isPlatformAdmin, toAuthUser} from '@/features/auth/services/userMapper'; -import {destinationForRole} from '@/features/auth/services/roleDestination'; +import {toAuthUser} from '@/features/auth/services/userMapper'; +import {destinationForUser} from '@/features/auth/services/roleDestination'; import type {AuthSession} from '@/features/auth/types/auth'; import type {ApiSuccess} from '@/shared/types/api'; @@ -171,50 +179,40 @@ export async function POST(req: NextRequest) { } /** - * A platform admin authenticates correctly and still gets no session HERE. + * A platform admin gets a session here, exactly like a merchant does. * - * `isPlatformAdmin` is role AND empty client_id together, which is the - * pairing the platform documents — checking the role alone would misread a - * tenant-scoped account that happens to carry an admin-shaped role. + * ── What this used to do, and why it no longer does ────────────────────── + * This route used to detect a platform admin, revoke the upstream session it + * had just created, and answer 403 `platform_account`. The reasoning was + * sound at the time: every screen in this console was tenant-scoped, an admin + * has no tenant, and a cookie would have bought that person a dashboard of + * 500s. Refusing the session was the honest answer. * - * Every endpoint behind this console is tenant-scoped, and an admin has no - * tenant. Measured on the live local platform with a real admin token: - * /api/sites 500, /api/visits 500, /api/visitors 500, /api/team 403 "This - * account does not belong to a company." Minting a cookie here would buy - * that person nothing but a dashboard of server errors, so the session is - * refused at the only place that can refuse it — before the cookie is set. + * There is now somewhere for them to go — /admin, reading the platform's own + * `/api/admin/*` surface, which is the one part of the platform that is NOT + * tenant-scoped. So the refusal is gone, and the ONLY thing that differs for + * an admin is the destination. Nothing about how the session is minted + * changes: same `storeTokens`, same `createSessionToken`, same cookies, same + * lifetimes. There is no second authentication path in this app. * - * This is not a client-side authorisation check standing in for a server - * one. It runs on the server, it mirrors the platform's own rule rather - * than inventing a second one, and the platform still enforces its own on - * every request regardless of what this route decides. + * ── What is emphatically NOT delegated to the client ───────────────────── + * `isPlatformAdmin` is role AND empty `client_id` together — the pairing the + * platform documents. Checking the role alone would promote a tenant-scoped + * account that happens to carry an admin-shaped role, and that account is an + * ordinary merchant user. The answer is computed here, from a field the + * browser never receives, and signed into the cookie (see userMapper and + * sessionToken), so the client cannot assert it. * - * The upstream session created moments ago by `authApi.login` is revoked - * rather than abandoned: it is a live refresh token nobody will ever use, - * and leaving it to expire on its own is a credential left lying around. - * Best-effort — a failure to revoke must not turn into a 500 on a sign-in - * that this console was going to decline anyway. + * And it decides ROUTING, never authority. Every admin read this console + * makes is authorised by the platform's own `adminOnly`, which answers 404 to + * anyone who is not a platform operator regardless of what this cookie says. + * + * ── Note what is absent: no `authApi.logout` call ──────────────────────── + * Revoking was correct while no session followed — an unused refresh token is + * a credential left lying around. Now the session DOES follow, and that same + * token is what `storeTokens` seals for every subsequent request. Revoking it + * here would sign the admin straight back out. */ - if (isPlatformAdmin(bundle.user)) { - try { - await authApi.logout(bundle.access_token); - } catch { - /* deliberately ignored — see above */ - } - - const code: LoginErrorCode = 'platform_account'; - if (isForm) { - return NextResponse.redirect( - new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url), - 303, - ); - } - return failJson( - code, - 'This console is for merchant accounts. Platform administrators sign in on the Loyaly platform console.', - 403, - ); - } /** * Minting the local session, which is where AUTH_SECRET is first read. @@ -259,9 +257,20 @@ export async function POST(req: NextRequest) { : SESSION_MAX_AGE_SECONDS; const cookieMaxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : undefined; + /** + * Which tab this session belongs to. + * + * The tab sends its own id in `X-Tab-Id`; signing in again in the same tab + * REPLACES that tab's session and leaves every other tab alone. When there is + * no id — the no-JavaScript form POST, which cannot set a header — one is + * minted here and handed back in the pointer cookie, so that path ends up + * with a properly scoped session too rather than a special unscoped one. + */ + const tabId = (await resolveTabId()) ?? newTabId(); + let sessionCookie: string; try { - await storeTokens(bundle, cookieMaxAge); + await storeTokens(bundle, cookieMaxAge, tabId); sessionCookie = createSessionToken( { sub: user.id, @@ -269,6 +278,12 @@ export async function POST(req: NextRequest) { name: user.name, role: user.role, organisation: user.organisation, + // Signed into the cookie so `proxy.ts` can decide which console to + // serve without a round trip, and so the browser cannot edit the + // answer: a tampered payload fails verifySessionToken and reads as no + // session at all. Still routing, never authority — the platform + // re-checks on every /api/admin/* call. + isPlatformAdmin: user.isPlatformAdmin, }, tokenLifetime, ); @@ -299,12 +314,15 @@ export async function POST(req: NextRequest) { const session: AuthSession = {user, expiresAt: bundle.expires_at}; // The no-JavaScript path lands in the SAME place the hydrated one does: an - // explicit `next` wins, otherwise the role the platform just returned decides. - // Both paths read one map, so a browser with JS disabled cannot end up - // somewhere else. - const landing = next - ? resolveRedirectTarget(next) - : destinationForRole(user.role); + // explicit `next` wins, otherwise what the platform just returned decides — + // /admin for a platform operator, the role's route for a merchant. Both paths + // read one function, so a browser with JS disabled cannot end up somewhere + // else, and neither can walk into the wrong console. + const landing = resolveRedirectTargetFor( + next, + user.isPlatformAdmin, + destinationForUser(user), + ); const res = isForm ? NextResponse.redirect(new URL(landing, req.url), 303) @@ -313,6 +331,14 @@ export async function POST(req: NextRequest) { {headers: {'cache-control': 'no-store'}}, ); - res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(cookieMaxAge)); + res.cookies.set( + sessionCookieFor(tabId), + sessionCookie, + sessionCookieOptions(cookieMaxAge), + ); + // Points server rendering and the proxy at the tab that just signed in. The + // tab rewrites this on focus, so it follows whichever tab is in use; it is a + // hint for the first paint, never the authority on who anyone is. + res.cookies.set(TAB_POINTER_COOKIE, tabId, tabPointerOptions()); return res; } diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 4abbd74..5bfe881 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,23 +1,39 @@ import {NextResponse} from 'next/server'; import {authApi} from '@/services/api/authApi'; -import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken'; -import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore'; +import {sessionCookieOptions} from '@/features/auth/services/sessionToken'; +import { + sessionCookieFor, + tokenCookieFor, +} from '@/features/auth/services/tabScope'; +import {resolveTabId} from '@/features/auth/services/tabScopeRequest'; import {peekAccessToken} from '@/features/auth/services/upstreamSession'; export const dynamic = 'force-dynamic'; /** - * POST /api/auth/logout + * POST /api/auth/logout — sign THIS TAB out. * - * Revokes the session upstream first, then clears both cookies. The order is - * deliberate, and so is the fact that an upstream failure does NOT abort the - * local clear: if the platform is unreachable, the least bad outcome is that - * this browser is signed out immediately and the server-side session lapses on - * its own expiry. Leaving the user apparently signed in because a revoke call - * failed is the one outcome nobody expects from pressing Sign out. + * Revokes the session upstream first, then clears that tab's two cookies. The + * order is deliberate, and so is the fact that an upstream failure does NOT + * abort the local clear: if the platform is unreachable, the least bad outcome + * is that this tab is signed out immediately and the server-side session lapses + * on its own expiry. Leaving somebody apparently signed in because a revoke + * call failed is the one outcome nobody expects from pressing Sign out. * * No refresh attempt: the token is about to be thrown away, so spending a * refresh token to revoke it is pure waste. + * + * ── Only this tab, and the upstream revoke is still real ───────────────── + * `peekAccessToken` resolves through the tab scope, so the token revoked + * upstream is THIS tab's session and no other. Signing out of the manager tab + * ends the manager's platform session — genuinely, server-side, as before — and + * leaves the admin and staff tabs holding their own untouched sessions in their + * own cookies. Nothing here weakens server-side invalidation; it narrows what + * gets invalidated to what the person actually asked to sign out of. + * + * A request with no resolvable tab clears nothing and still answers 200. There + * is no session to end, and guessing at one would sign out a tab that never + * asked. */ export async function POST() { const accessToken = await peekAccessToken(); @@ -27,7 +43,7 @@ export async function POST() { await authApi.logout(accessToken); } catch { // Already-expired, revoked, or unreachable — all fine. The cookies below - // are what actually ends this browser's session. + // are what actually ends this tab's session. } } @@ -36,15 +52,23 @@ export async function POST() { {headers: {'cache-control': 'no-store'}}, ); - // Overwrite with an expired cookie rather than only deleting: a delete that - // misses on `path` leaves a live session behind. - res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0)); - res.cookies.set(TOKEN_COOKIE, '', { - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 0, - }); + const tabId = await resolveTabId(); + if (tabId) { + // Overwrite with an expired cookie rather than only deleting: a delete that + // misses on `path` leaves a live session behind. + res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0)); + res.cookies.set(tokenCookieFor(tabId), '', { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: 0, + }); + } + + // The pointer is deliberately left alone. It names a tab, not a session, and + // the signed-out tab rewrites it on its next load anyway — clearing it here + // would only blank the server-rendered first paint of whichever OTHER tab the + // person switches to next. return res; } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a35b3fc..2d148d0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -107,15 +107,16 @@ export default async function RootLayout({ > {/* - FIRST child of , and that position is the whole point: it runs - before the markup below it is parsed, so a tab that inherited the - cookies without owning the session never paints the workspace. An + FIRST child of , and that position is the whole point: it gives + this tab its id and points the cookie at it before the markup below is + parsed, so the very first navigation is rendered as the right user. An effect inside Providers would run after the first paint instead. - See services/tabSession.ts for what it does and why it fails open. + + It no longer takes the session: it does not decide anything about + being signed in, and nothing in it signs anybody out. See + services/tabSession.ts for what it replaced and why. */} -