admin login issue for timeout issue

This commit is contained in:
2026-09-22 00:23:34 +05:30
parent b60b8baef6
commit 91e4db8217
13 changed files with 343 additions and 141 deletions

View File

@@ -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 <LoginSplit />;

View File

@@ -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_<tabId>` 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;
}
}

View File

@@ -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.
*/}
<script dangerouslySetInnerHTML={{__html: tabSessionScript()}} />
<Providers initialSession={session} initialThemeMode={themeMode}>
<Providers
initialSession={session}
initialTabId={tabId}
initialThemeMode={themeMode}
>
{children}
</Providers>
</body>

View File

@@ -32,11 +32,14 @@ import {LoyalyAiProvider} from '@/features/loyaly-ai/providers/LoyalyAiProvider'
export function Providers({
children,
initialSession,
initialTabId,
initialThemeMode,
}: {
children: React.ReactNode;
/** Resolved from the signed cookie in the root layout — see SessionProvider. */
initialSession: AuthSession | null;
/** Which tab that session was read from — see SessionProvider. */
initialTabId: string | null;
/** Resolved from the theme-mode cookie in the root layout. */
initialThemeMode: ThemeMode;
}) {
@@ -44,7 +47,10 @@ export function Providers({
// Outside <Theme>, because <Theme> takes the mode as a prop — a context
// rendered inside it could never reach it.
<ThemeModeProvider initialMode={initialThemeMode}>
<ThemedProviders initialSession={initialSession}>
<ThemedProviders
initialSession={initialSession}
initialTabId={initialTabId}
>
{children}
</ThemedProviders>
</ThemeModeProvider>
@@ -54,9 +60,11 @@ export function Providers({
function ThemedProviders({
children,
initialSession,
initialTabId,
}: {
children: React.ReactNode;
initialSession: AuthSession | null;
initialTabId: string | null;
}) {
const {mode} = useThemeMode();
@@ -67,7 +75,10 @@ function ThemedProviders({
<MotionConfig reducedMotion="user">
{/* Above the route tree AND outside (workspace), because /login
establishes the session that the workspace then reads. */}
<SessionProvider initialSession={initialSession}>
<SessionProvider
initialSession={initialSession}
initialTabId={initialTabId}
>
<WorkspaceProvider>
<LoyalyAiProvider>{children}</LoyalyAiProvider>
</WorkspaceProvider>

View File

@@ -1,62 +0,0 @@
'use client';
import {useEffect} from 'react';
import {useRouter, useSearchParams} from 'next/navigation';
import {Center} from '@astryxdesign/core/Center';
import {Spinner} from '@astryxdesign/core/Spinner';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
import {destinationForUser} from '@/features/auth/services/roleDestination';
/**
* The mirror of AuthGuard: keeps a signed-in user OFF the sign-in screen.
*
* The proxy already redirects /login → /dashboard for a request carrying a
* valid cookie. This covers the case the proxy cannot see: a client-side
* navigation back to /login after logging in within the same page session.
* Offering the form again to someone who is already authenticated reads as the
* login having failed.
*
* While `loading`, the form is NOT rendered. Painting it and then yanking it
* away is worse than a beat of spinner, and it invites someone to start typing
* into a form that is about to disappear.
*/
export function GuestGuard({children}: {children: React.ReactNode}) {
const {status, user} = useSession();
const router = useRouter();
const searchParams = useSearchParams();
useEffect(() => {
if (status !== 'authenticated') return;
/**
* The SAME destination the login form resolves. These two redirects race
* on a successful sign-in, and a hardcoded /dashboard here silently threw
* away the deep link the proxy had preserved in `?next=`.
*
* The role arm is the same hazard a second time. Once sign-in started
* sending people to a destination chosen by their role, a plain
* `resolveRedirectTarget` here meant a staff member landed on /floor or on
* /dashboard depending purely on which effect fired first — a race with a
* different answer each way, which is worse than either answer. Both sides
* now read the same two functions in the same order.
*/
const next = searchParams.get('next');
router.replace(
// `user` is non-null in this branch (status === 'authenticated'), but the
// fallback keeps the default rather than asserting it away.
next || !user
? resolveRedirectTarget(next)
: destinationForUser(user),
);
}, [status, router, searchParams, user]);
if (status !== 'unauthenticated') {
return (
<Center height="100vh" role="status" aria-label="Checking your session">
<Spinner size="lg" />
</Center>
);
}
return <>{children}</>;
}

View File

@@ -73,8 +73,9 @@ export function useLoginForm() {
const next = searchParams.get('next');
// Consumed at submit time through resolveRedirectTargetFor, which is shared
// with GuestGuard so the two cannot race to different answers — and which
// drops a `next` belonging to the other console. See redirectTarget.ts.
// with the no-JavaScript path in the login route so the two cannot land in
// different places — and which drops a `next` belonging to the other console.
// See redirectTarget.ts.
const submit = useCallback(
async (event: React.FormEvent) => {

View File

@@ -10,6 +10,7 @@ import {
} from 'react';
import {useRouter} from 'next/navigation';
import {authService} from '@/features/auth/services/authService';
import {TAB_ID_STORAGE_KEY} from '@/features/auth/services/tabScope';
import {SIDENAV_COLLAPSED_KEY} from '@/shared/layouts/workspace/sidebarStorage';
import type {
AuthSession,
@@ -68,15 +69,65 @@ export function SessionProvider({
* already had in the same request.
*/
initialSession,
/**
* The tab the seed above was resolved from, or null when the server could
* not tell. Compared against this tab's own id — see the effect below.
*/
initialTabId,
}: {
children: React.ReactNode;
initialSession: AuthSession | null;
initialTabId: string | null;
}) {
const router = useRouter();
const [session, setSession] = useState<AuthSession | null>(initialSession);
const [status, setStatus] = useState<SessionStatus>(
initialSession ? 'authenticated' : 'unauthenticated',
);
/**
* `seedIsOurs` decides whether the server's answer is about THIS tab.
*
* Read during the first render rather than in an effect so the first paint is
* already right: a tab whose seed belongs to somebody else starts in
* 'loading' and shows a guard spinner, instead of painting another person's
* name into the shell and swapping it a moment later.
*
* ── Why a seed can belong to another tab ─────────────────────────────────
* A document navigation cannot carry `X-Tab-Id`, so the server resolves the
* session through the `loyaly_tab` pointer cookie, which names whichever tab
* was last focused. A newly opened tab has not written it yet when its first
* document is rendered, so it is handed the previous tab's identity. That is
* a HINT (see tabScopeRequest.ts), and it was being treated as an answer:
* measured, a second tab opened on /admin rendered the first tab's operator
* in the chrome and then failed every data call with 401, because the cookies
* those calls need are keyed to a tab id it does not have.
*
* ── Why comparing ids is enough ──────────────────────────────────────────
* The inline script in the root layout writes this tab's id into
* `sessionStorage` before any of this parses, so by the time React renders,
* the id is there. If it matches the one the seed came from, the seed IS this
* tab's and nothing needs re-asking — which is the common case, and it keeps
* the seed doing the job it exists for. If it differs, the only honest state
* is 'loading' until `GET /api/auth/session` answers with the header, which
* addresses the right cookie.
*
* Storage that throws (private mode) reads as `null` and the seed is trusted,
* which is the same fail-open the script and `tabHeaders()` take: a browser
* with no `sessionStorage` has one shared session, and that is the documented
* degraded mode, not a reason to refuse to render.
*/
const [seedIsOurs] = useState(() => {
if (typeof window === 'undefined') return true;
let own: string | null = null;
try {
own = window.sessionStorage.getItem(TAB_ID_STORAGE_KEY);
} catch {
return true;
}
return own === null || own === initialTabId;
});
const [status, setStatus] = useState<SessionStatus>(() => {
if (!seedIsOurs) return 'loading';
return initialSession ? 'authenticated' : 'unauthenticated';
});
const refresh = useCallback(async () => {
const next = await authService.currentSession();
@@ -84,6 +135,39 @@ export function SessionProvider({
setStatus(next ? 'authenticated' : 'unauthenticated');
}, []);
/**
* Ask for real when the seed was not ours.
*
* Runs once, only in the tab that detected the mismatch, and it is the whole
* reason `seedIsOurs` exists: `refresh()` goes through `httpClient`, which
* attaches `X-Tab-Id`, so the server reads THIS tab's cookies and answers
* about this tab. A tab with no session of its own lands on 'unauthenticated'
* here, and AuthGuard sends it to /login — which now always renders, so that
* is where it stops.
*
* `seedIsOurs` is fixed for the life of the mount, so this cannot re-fire —
* there is no state it writes that could feed back into its own condition.
* Every later re-check is the visibilitychange listener below.
*
* The request is spelled out rather than delegated to `refresh()` so the
* state updates sit in the async continuation, where they can be dropped if
* the provider unmounts first. A late answer landing on a torn-down tree is
* the one way a one-shot fetch like this can misbehave.
*/
useEffect(() => {
if (seedIsOurs) return;
let cancelled = false;
void authService.currentSession().then((next) => {
if (cancelled) return;
setSession(next);
setStatus(next ? 'authenticated' : 'unauthenticated');
});
return () => {
cancelled = true;
};
}, [seedIsOurs]);
/**
* Re-check on focus.
*

View File

@@ -2,13 +2,16 @@
* Where a signed-in user should land, from the proxy's `?next=` hint.
*
* ── Why this is shared rather than inlined ───────────────────────────────
* Two things redirect after a successful sign-in and they RACE: the login form
* (which knows the attempt just succeeded) and GuestGuard (which reacts to the
* session becoming authenticated). Measured: signing in from
* `/login?next=/settings/billing` landed on /dashboard, because the guard's
* effect fired first with a hardcoded destination and the form's redirect was
* replaced. Both now resolve through this function, so whichever wins, the
* user arrives at the same place.
* Two paths redirect after a successful sign-in — the hydrated form and the
* native form POST handled in the login route — and they must agree. Measured
* back when they did not: signing in from `/login?next=/settings/billing`
* landed on /dashboard because one side carried a hardcoded destination. Both
* now resolve through `resolveRedirectTargetFor` below, so the answer does not
* depend on which path the browser took.
*
* (A third redirecting authority used to exist — `GuestGuard`, which reacted to
* the session becoming authenticated — and racing it was the original reason
* this module is shared. It is gone: see shared/layouts/PublicLayout.tsx.)
*
* ── Why the validation matters ───────────────────────────────────────────
* `next` is attacker-controllable — it is a query parameter on a public page.
@@ -23,13 +26,6 @@
export const DEFAULT_DESTINATION = '/dashboard';
export function resolveRedirectTarget(next: string | null): string {
if (!next || !next.startsWith('/') || next.startsWith('//')) {
return DEFAULT_DESTINATION;
}
return next;
}
/**
* `next`, but only when it belongs to the person who just signed in.
*

View File

@@ -16,8 +16,7 @@ import type {AuthUser, UserRole} from '@/features/auth/types/auth';
* answer exists to close.
*
* One module, read by BOTH sign-in paths — the hydrated fetch and the native
* form POST — and by GuestGuard, which redirects on the same event and would
* otherwise race them to a different answer.
* form POST — so a browser with JavaScript disabled cannot land somewhere else.
*/
/**

View File

@@ -52,12 +52,22 @@ if(!id||!/^[a-z0-9]{8,32}$/.test(id)){
id=(Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)).slice(0,16);
s.setItem(K,id);
}
var point=function(){document.cookie=P+'='+id+';path=/;samesite=lax'+(location.protocol==='https:'?';secure':'');};
// Only a VISIBLE tab claims the pointer, and that condition is load-bearing.
// A tab opened in the background (a middle-click, "open link in new tab")
// starts hidden, and pointing from there would aim the pointer at a tab with
// no session while the person is still working in the one that has it — so the
// focused tab's very next navigation would be gated against the wrong cookie
// and bounced to /login. A hidden tab simply waits: the visibilitychange
// listener below points the moment it is actually looked at.
var point=function(){
if(document.visibilityState!=='visible')return;
document.cookie=P+'='+id+';path=/;samesite=lax'+(location.protocol==='https:'?';secure':'');
};
point();
// Re-point on focus: only one tab is focused at a time, so this keeps the
// pointer aimed at the tab the person is actually using, which is the tab whose
// next navigation the server has to render.
window.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')point();});
window.addEventListener('visibilitychange',point);
window.addEventListener('pageshow',point);
}catch(e){}})();`;
}

View File

@@ -9,8 +9,12 @@ import {
tokenCookieOptions,
type TokenBundle,
} from './tokenStore';
import {tokenCookieFor} from './tabScope';
import {sessionCookieFor, tokenCookieFor} from './tabScope';
import {resolveTabId} from './tabScopeRequest';
import {
REMEMBERED_MAX_AGE_SECONDS,
verifySessionToken,
} from './sessionToken';
/**
* Authenticated access to the platform, with the three refresh rules the
@@ -98,6 +102,50 @@ export function toBundle(res: ApiTokenBundle): TokenBundle {
* The persist happens INSIDE the locked section, before the promise resolves,
* so every waiter observes a bundle that is already durable.
*/
/**
* How long the refreshed token cookie should live — read off the identity
* cookie, which is the only thing that still knows.
*
* ── The bug this closes ──────────────────────────────────────────────────
* `persistTokens(next)` was called with no lifetime, and `tokenCookieOptions`
* omits `maxAge` when it is falsy. So every refresh quietly downgraded
* `loyaly_tokens_<tab>` to a browser-session cookie. For a remembered sign-in
* that meant `loyaly_session_<tab>` kept its 30 days while the sealed tokens
* did not: after a browser restart the identity survived, the tokens were gone,
* the proxy admitted the page on the identity alone, and every data call
* answered 401 — the exact half-authenticated state the note on `storeTokens`
* below describes, arrived at from the other direction.
*
* ── Why the identity cookie is the source of truth ───────────────────────
* A cookie cannot report its own `maxAge`, and by refresh time the `rememberMe`
* checkbox is long gone. But the signed payload carries `iat` and `exp`, and
* their span IS the lifetime chosen at sign-in — 12h for a normal session, 30d
* for a remembered one. So the span identifies which kind this is without
* trusting anything the client sends, and the payload is HMAC-signed, so it
* cannot be edited into a longer one.
*
* Returns the REMAINING time rather than a fresh 30 days: the two cookies must
* expire together, and re-granting the full window on every refresh would let
* the tokens outlive the identity that authorises them.
*
* `undefined` for a normal session, which keeps it a browser-session cookie —
* the unticked box is asking for exactly that, and it must stay that way.
*/
async function rememberedLifetime(): Promise<number | undefined> {
const tabId = await resolveTabId();
if (!tabId) return undefined;
const store = await cookies();
const payload = verifySessionToken(store.get(sessionCookieFor(tabId))?.value);
if (!payload) return undefined;
// Only a remembered sign-in was ever given a persistent cookie.
if (payload.exp - payload.iat < REMEMBERED_MAX_AGE_SECONDS) return undefined;
const remaining = payload.exp - Math.floor(Date.now() / 1000);
return remaining > 0 ? remaining : undefined;
}
async function refresh(current: TokenBundle): Promise<TokenBundle> {
const k = lockKey(current.refreshToken);
const existing = inFlight.get(k);
@@ -110,7 +158,7 @@ async function refresh(current: TokenBundle): Promise<TokenBundle> {
body: {refresh_token: current.refreshToken, device: 'Loyaly Web Console'},
});
const next = toBundle(res);
await persistTokens(next);
await persistTokens(next, await rememberedLifetime());
return next;
})();

View File

@@ -3,8 +3,8 @@ import type {NextRequest} from 'next/server';
import {configStatus} from '@/shared/config/configCheck';
import {verifySessionToken} from '@/features/auth/services/sessionToken';
import {
TAB_ID_HEADER,
TAB_POINTER_COOKIE,
isSessionCookieName,
isValidTabId,
sessionCookieFor,
} from '@/features/auth/services/tabScope';
@@ -20,12 +20,21 @@ import {
*
* Three rules, in order:
*
* 1. `/` → /dashboard when signed in, else /login
* 2. a protected path → /login?next=… when there is no valid session
* 3. /login while signed in → /dashboard
* 1. `/` → /dashboard when THIS TAB is signed in, else /login
* 2. a protected path → /login?next=… when this tab has no valid session
* 3. /login → always served, signed in or not
*
* Rule 3 is what makes /login a GuestGuard-ed route: coming back to it with a
* live session should not offer to sign you in again.
* ── Rule 3 used to redirect a signed-in browser to /dashboard ────────────
* It no longer does, and the reason is that "signed in" is not a property of a
* BROWSER in this app — sessions are per tab (see tabScope.ts). A second tab
* asking for /login has no session of its own; bouncing it to the dashboard
* showed it a console it could not use, because every data call in that tab is
* authorised by a cookie it does not have. Explicitly opening /login is now
* always answered with the sign-in page, which is also the only answer that
* cannot disagree with the client guard and ping-pong against it.
*
* Nothing about protection changes: /login is the one PUBLIC path, and every
* other route still needs a verified session for THIS tab.
*
* The session is verified, not merely detected. A cookie whose signature fails
* or whose `exp` has passed is treated as absent, so a tampered or stale
@@ -147,33 +156,60 @@ export function proxy(request: NextRequest): NextResponse {
/**
* Which tab, and therefore which session cookie.
*
* A document navigation cannot carry `X-Tab-Id`, so the `loyaly_tab` pointer
* is all there is here. When it resolves, this is the focused tab's real
* session and every rule below applies to it.
* The SAME two channels in the SAME order as `resolveTabId` — the header
* first, the pointer cookie second — because this gate and the route handlers
* behind it must answer about one identical session. They used to differ: a
* fetch carrying `X-Tab-Id` was admitted here on the strength of the pointer
* cookie (some other tab) and then answered 401 by the handler, which read
* the header. Reading both here removes that disagreement at the source.
*
* When it does NOT resolve — a stale pointer, a tab that has not claimed an
* id yet, a bookmark opened cold — the gate falls back to "is ANY tab in this
* browser signed in". That is enough to decide whether to show the sign-in
* page, and it is deliberately NOT enough to decide role routing: sending a
* manager to /admin because another tab holds an operator session would be
* exactly the cross-tab confusion this scoping removes. So `session` stays
* null in that case and the client re-resolves with the header.
*
* No data rides on this. Every fetch behind the page carries the tab id and
* is authorised per-session by the platform.
* `tabScopeRequest.ts` cannot be imported — it is `server-only` and reads
* `next/headers` — so the two channels are re-read from the request here. The
* pure half (`isValidTabId`, `sessionCookieFor`) is shared, so the NAMING can
* never drift; only the plumbing to reach a header is duplicated.
*/
const fromHeader = request.headers.get(TAB_ID_HEADER);
const pointer = request.cookies.get(TAB_POINTER_COOKIE)?.value;
const session = isValidTabId(pointer)
? verifySessionToken(request.cookies.get(sessionCookieFor(pointer))?.value)
const tabId = isValidTabId(fromHeader)
? fromHeader
: isValidTabId(pointer)
? pointer
: null;
const session = tabId
? verifySessionToken(request.cookies.get(sessionCookieFor(tabId))?.value)
: null;
const isAuthenticated =
session !== null ||
request.cookies
.getAll()
.some(
(c) => isSessionCookieName(c.name) && verifySessionToken(c.value) !== null,
);
/**
* THIS TAB's session, and no other. This is the line that was wrong.
*
* ── What it used to say ──────────────────────────────────────────────────
* It fell back to "is ANY tab in this browser signed in" by scanning every
* `loyaly_session_*` cookie in the jar. The intent was charitable — decide
* whether to show the sign-in page even when the pointer is stale — but a
* cookie jar is shared by the whole browser while a SESSION here is not, so
* the answer it gave was about a different tab than the one asking.
*
* ── What that cost ───────────────────────────────────────────────────────
* Everything else in the request path is already tab-scoped: `getServerSession`
* seeds the shell from this tab's cookie, `withUpstream` opens this tab's
* sealed tokens. So a tab with no session of its own was admitted to
* /dashboard by this gate, rendered a shell, was told `GET /api/auth/session`
* → null, and had AuthGuard send it to /login — which this same gate then
* redirected back to /dashboard, because another tab's cookie was still in
* the jar. That is the login loop, and it was unbounded: measured as
* /dashboard → /api/auth/session (null) → /login → /dashboard → … while
* `GET /api/sites` answered 401 throughout.
*
* Reading only this tab's cookie makes the gate agree with the two layers
* beneath it. A tab that cannot be identified has no session, which is the
* same rule `resolveTabId`'s callers follow and for the same reason: guessing
* would hand one tab another tab's identity.
*
* This is a TIGHTENING, not a relaxation. No request that was refused before
* is admitted now.
*/
const isAuthenticated = session !== null;
/**
* Which console this session belongs to. Read from the SIGNED payload, so a
@@ -192,10 +228,16 @@ export function proxy(request: NextRequest): NextResponse {
);
}
/**
* The sign-in page, always served — see rule 3 at the top of this file.
*
* Deliberately BEFORE the `!isAuthenticated` branch below, so this is the one
* path whose answer does not depend on session state at all. A page that
* always renders cannot take part in a redirect cycle, which is what makes
* /login the terminal state of every failed-auth path rather than one more
* hop in it.
*/
if (PUBLIC_PATHS.has(pathname)) {
if (isAuthenticated) {
return NextResponse.redirect(new URL(home, request.url));
}
return NextResponse.next();
}

View File

@@ -1,6 +1,4 @@
'use client';
import {GuestGuard} from '@/features/auth/guards/GuestGuard';
import type {ReactNode} from 'react';
/**
* The unauthenticated surface: sign-in today, password reset and invitation
@@ -10,7 +8,33 @@ import {GuestGuard} from '@/features/auth/guards/GuestGuard';
* means anything without an account, and mounting the providers behind it
* would start fetching workspace data for a visitor who has not signed in.
* The page owns its own full-bleed layout.
*
* ── Why there is no guard here any more ──────────────────────────────────
* This used to wrap its children in `GuestGuard`, which redirected anyone the
* session provider called `authenticated` away to their home route. Two things
* made that wrong once sessions became per-tab (see auth/services/tabScope.ts):
*
* 1. The provider is SEEDED from `getServerSession()`, which resolves through
* the `loyaly_tab` pointer cookie. A newly opened tab has not written that
* cookie yet when its first document is rendered, so the seed is whichever
* tab was last focused — and the guard redirected the new tab on the
* strength of a DIFFERENT tab's session, to a console it has no cookies
* for. That is issue 1: opening /login in a second tab never showed the
* sign-in page.
* 2. Even correctly seeded, it fought src/proxy.ts over the same decision on
* two different clocks. Two redirecting authorities pointing opposite ways
* is what a redirect loop is made of.
*
* So /login now simply renders. Nothing is weakened by that: it is the one
* public route, it holds no data, and the workspace behind it is still gated by
* `AuthGuard` on the client and by the proxy on the server. Signing in from it
* replaces THIS tab's session and leaves every other tab alone, which is what
* per-tab sessions already meant everywhere else.
*
* It is a plain server component now — there was nothing client-side left in it
* once the guard went, and a needless `'use client'` here would pull the whole
* public tree into the client bundle.
*/
export function PublicLayout({children}: {children: React.ReactNode}) {
return <GuestGuard>{children}</GuestGuard>;
export function PublicLayout({children}: {children: ReactNode}) {
return <>{children}</>;
}