diff --git a/src/app/(public)/login/page.tsx b/src/app/(public)/login/page.tsx index d6fafce..1cf20af 100644 --- a/src/app/(public)/login/page.tsx +++ b/src/app/(public)/login/page.tsx @@ -6,12 +6,13 @@ export const metadata: Metadata = { }; /** - * Sits in the (public) group on purpose: no shell, no nav, no store scope, and - * a GuestGuard instead of an AuthGuard — see PublicLayout. + * Sits in the (public) group on purpose: no shell, no nav, no store scope and + * no auth guard — see PublicLayout. * - * Reaching this page with a live session is already impossible via a fresh - * request (src/proxy.ts redirects it to /dashboard); the guard covers the - * client-side navigation the proxy never sees. + * This page renders whenever it is asked for, with or without a live session in + * this browser. Sessions are per tab, so "somebody is signed in here" is not a + * reason to refuse the sign-in form to a tab that has no session of its own — + * and a page that always renders cannot take part in a redirect cycle. */ export default function LoginPage() { return ; diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 961287b..7cf416a 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -1,8 +1,12 @@ import {NextResponse} from 'next/server'; import {authApi} from '@/services/api/authApi'; import {UpstreamError} from '@/services/api/apiClient'; -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 {NoSessionError, withUpstream} from '@/features/auth/services/upstreamSession'; import {toAuthUser} from '@/features/auth/services/userMapper'; import type {AuthSession} from '@/features/auth/types/auth'; @@ -64,14 +68,37 @@ export async function GET() { {data: null, meta: {generatedAt: new Date().toISOString()}}, {headers: {'cache-control': 'no-store'}}, ); - 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, - }); + + /** + * Clear THIS TAB's cookies, by their tab-scoped names. + * + * This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped + * names from before sessions were per-tab. Those cookies do not exist any + * more, so the clear silently did nothing and a confirmed 401 left the + * tab's real `loyaly_session_` in place. The result was the + * half-authenticated state upstreamSession warns about, with a twist: the + * client set itself unauthenticated and went to /login, the proxy saw a + * still-valid session cookie and sent it straight back, and the two flapped. + * + * Same two helpers the logout route uses, so there is one naming scheme and + * the two paths cannot drift. Scoped to the resolved tab and no other: a + * dead session in one tab says nothing about the others, and clearing more + * than asked would sign out a tab that is working fine. + * + * A request with no resolvable tab clears nothing. There is no cookie to + * name, and guessing would reach into somebody else's session. + */ + const tabId = await resolveTabId(); + if (tabId) { + 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, + }); + } return res; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2d148d0..dd5c42b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -3,6 +3,7 @@ import {cookies} from 'next/headers'; import {Sora, Inter} from 'next/font/google'; import './globals.css'; import {getServerSession} from '@/features/auth/services/serverSession'; +import {resolveTabId} from '@/features/auth/services/tabScopeRequest'; import {tabSessionScript} from '@/features/auth/services/tabSession'; import { htmlThemeAttr, @@ -88,6 +89,22 @@ export default async function RootLayout({ * already answered in this very request. */ const session = await getServerSession(); + /** + * WHICH tab that session was resolved from, handed to the client alongside it. + * + * A document navigation carries no `X-Tab-Id`, so `getServerSession` resolves + * through the `loyaly_tab` pointer — the tab that was last FOCUSED, which for + * a newly opened tab is somebody else. The seed is still worth sending (it is + * what saves a guard spinner on every load), but the client has to be able to + * tell whether the seed is its own. Without this it could not: it received an + * identity with nothing attached saying who it belonged to, so a second tab + * rendered the first tab's user in the shell and only found out when a data + * call answered 401. + * + * Not a credential and not a decision — just the label that lets the client + * recognise a seed that is not about it. See SessionProvider. + */ + const tabId = await resolveTabId(); const themeMode = await readThemeMode(); return ( @@ -117,7 +134,11 @@ export default async function RootLayout({ services/tabSession.ts for what it replaced and why. */}