admin login issue

This commit is contained in:
2026-09-21 16:38:15 +05:30
parent 8456c5e852
commit b60b8baef6
40 changed files with 2254 additions and 260 deletions

View File

@@ -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`.)
---

View File

@@ -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 (
<VStack gap={6} width="100%">
<VStack gap={1}>
<Text type="display-3" weight="medium">
Platform admin
</Text>
<Text size="sm" color="secondary">
Create, suspend and remove the merchant companies on this platform.
</Text>
</VStack>
<CompaniesPanel />
</VStack>
);
}

View File

@@ -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 <AdminLayout>{children}</AdminLayout>;
}

View File

@@ -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);
});
}

View File

@@ -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') ?? ''),
);
}

View File

@@ -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},
);
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -107,15 +107,16 @@ export default async function RootLayout({
>
<body>
{/*
FIRST child of <body>, 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 <body>, 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.
*/}
<script
dangerouslySetInnerHTML={{__html: tabSessionScript(session !== null)}}
/>
<script dangerouslySetInnerHTML={{__html: tabSessionScript()}} />
<Providers initialSession={session} initialThemeMode={themeMode}>
{children}
</Providers>

View File

@@ -0,0 +1,82 @@
'use client';
import {useRouter} from 'next/navigation';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {Badge} from '@astryxdesign/core/Badge';
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
import {BrandMark} from '@/shared/components/brand/BrandLogo';
import {useSession} from '@/features/auth/providers/SessionProvider';
/**
* The platform console's frame.
*
* ── Same guard, different shell ──────────────────────────────────────────
* `AuthGuard` is the one the workspace uses, reading the session minted by the
* same login route. What is deliberately absent is `WorkspaceShell`: no site
* switcher, no tenant navigation, no Loyaly AI rail. Every one of those is
* scoped to a company, and a platform operator has none — a store picker with
* nothing in it is worse than no store picker.
*
* ── Why it says "Platform" so loudly ─────────────────────────────────────
* This console can suspend a company and delete one irreversibly. Somebody who
* has both consoles open needs to know which tab they are typing into before
* they click, not after. The badge is the cheapest possible version of that.
*/
export function AdminLayout({children}: {children: React.ReactNode}) {
return (
<AuthGuard>
<VStack gap={0} width="100%" minHeight="100vh">
<AdminTopBar />
<VStack gap={0} width="100%" paddingInline={8} paddingBlock={8}>
{children}
</VStack>
</VStack>
</AuthGuard>
);
}
function AdminTopBar() {
const {user, logout} = useSession();
const router = useRouter();
async function signOut() {
await logout();
router.replace('/login');
}
return (
<HStack
gap={4}
width="100%"
vAlign="center"
hAlign="between"
paddingInline={8}
paddingBlock={4}
className="border-b border-border bg-surface"
>
<HStack gap={3} vAlign="center">
<BrandMark size={24} />
<Text size="sm" weight="medium">
Platform
</Text>
<Badge variant="neutral" label="Admin" />
</HStack>
<HStack gap={3} vAlign="center">
{/* The address, not the display name: on a console that can delete a
company, which ACCOUNT is signed in matters more than whose it is. */}
<Text size="xsm" color="secondary">
{user?.email}
</Text>
<Button
size="sm"
variant="secondary"
onClick={() => void signOut()}
label="Sign out"
/>
</HStack>
</HStack>
);
}

View File

@@ -0,0 +1,278 @@
'use client';
import {useState} from 'react';
import {proportional} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Button} from '@astryxdesign/core/Button';
import {TextInput} from '@astryxdesign/core/TextInput';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
import {PanelCard} from '@/shared/components/patterns/PanelCard';
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
import {
useCompanies,
useCompanyFilters,
} from '@/features/admin/hooks/useCompanies';
import {CreateCompanyDialog} from './CreateCompanyDialog';
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
import type {Company, CompanyFilter} from '@/features/admin/types/company';
/**
* Every company on the platform, and the four things an operator can do to one.
*
* ── The columns are exactly what the API returns ─────────────────────────
* Name, slug, shops, accounts, status, created. `GET /api/admin/clients`
* returns those seven fields and nothing else, so there is no owner column, no
* plan, no revenue and no health. A column this list cannot fill would have to
* be faked, and a faked column on the console that can delete a company is the
* worst place in the product to put one.
*
* ── Why suspended rows are not hidden ────────────────────────────────────
* A suspended company is the one an operator is most likely to be looking for
* — it is the one somebody is on the phone about. It stays in the default list
* with its status shown, and the filter is there to narrow deliberately.
*/
interface CompanyRow extends Record<string, unknown> {
id: string;
name: string;
slug: string;
sites: number;
users: number;
status: string;
created: string;
}
function toRow(c: Company): CompanyRow {
return {
id: c.id,
name: c.name,
slug: c.slug,
sites: c.sites,
users: c.users,
status: c.isActive ? 'Active' : 'Suspended',
// Date only. A platform operator cares which week a tenant was onboarded,
// never which minute.
created: new Date(c.createdAt).toLocaleDateString(),
};
}
const COLUMNS: TableColumn<CompanyRow>[] = [
{
key: 'name',
header: 'Company',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<Text size="sm" weight="medium">
{row.name}
</Text>
{/* The slug is shown on every row because it is what the delete
confirmation asks for, and hunting for it afterwards is how
somebody ends up copying the wrong one. */}
<Text size="xsm" color="secondary">
{row.slug}
</Text>
</VStack>
),
},
{key: 'sites', header: 'Shops', width: proportional(1), align: 'end'},
{key: 'users', header: 'Accounts', width: proportional(1), align: 'end'},
{
key: 'status',
header: 'Status',
width: proportional(1),
renderCell: (row) => (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={row.status === 'Active' ? 'success' : 'error'}
label={row.status}
/>
<Text size="sm" color="secondary">
{row.status}
</Text>
</HStack>
),
},
{key: 'created', header: 'Created', width: proportional(1), align: 'end'},
];
type Action =
| {kind: 'create'}
| {kind: 'suspend'; company: Company}
| {kind: 'reset'; company: Company}
| {kind: 'delete'; company: Company}
| null;
export function CompaniesPanel() {
const companies = useCompanies();
const {query, setQuery, filter, setFilter, visible} = useCompanyFilters(
companies.data,
);
const [action, setAction] = useState<Action>(null);
/**
* Nothing optimistic. Every mutation refetches and the table redraws from
* what the server says — a row that claimed to be suspended because a PATCH
* was sent, while the platform refused it, is exactly the lie this console
* cannot afford.
*/
function refresh() {
companies.refetch();
}
return (
<VStack gap={6} width="100%">
<PanelCard
title="Companies"
subtitle="Every merchant company on this platform."
resource={companies}
loading={<SkeletonRows count={5} />}
empty={
<EmptyPanel
icon="stores"
title="No companies yet"
description="Create your first merchant company to get started."
actions={
<Button
size="sm"
onClick={() => setAction({kind: 'create'})}
label="Create company"
/>
}
/>
}
actions={
<Button
size="sm"
onClick={() => setAction({kind: 'create'})}
label="Create company"
/>
}
>
{(all) => (
<VStack gap={4} width="100%">
<HStack gap={3} vAlign="center" hAlign="between">
<TextInput
label="Search companies"
isLabelHidden
value={query}
onChange={setQuery}
placeholder="Search by name or slug…"
/>
<SegmentedControl
value={filter}
onChange={(v) => setFilter(v as CompanyFilter)}
label="Filter by status"
>
<SegmentedControlItem value="all" label="All" />
<SegmentedControlItem value="active" label="Active" />
<SegmentedControlItem value="suspended" label="Suspended" />
</SegmentedControl>
</HStack>
{visible.length === 0 ? (
/* A search that matches nothing is NOT the same as a platform
with no companies — saying "no companies yet" here would tell
an operator their tenants had vanished. */
<Text size="sm" color="secondary">
No companies match that search. {all.length}{' '}
{all.length === 1 ? 'company' : 'companies'} in total.
</Text>
) : (
<ResponsiveTable
data={visible.map(toRow)}
idKey="id"
primaryKey="name"
summaryKeys={['status', 'sites']}
columns={COLUMNS}
/>
)}
<VStack gap={2} width="100%">
{visible.map((c) => (
<HStack
key={c.id}
gap={2}
vAlign="center"
hAlign="between"
width="100%"
>
<Text size="xsm" color="secondary">
{c.name}
</Text>
<HStack gap={2}>
<Button
size="sm"
variant="secondary"
onClick={() => setAction({kind: 'reset', company: c})}
label="Reset owner password"
/>
<Button
size="sm"
variant="secondary"
onClick={() => setAction({kind: 'suspend', company: c})}
label={c.isActive ? 'Suspend' : 'Reinstate'}
/>
{/* Delete is offered only on a suspended company, because
the platform refuses it otherwise. The dialog still
sends an active one and shows the real 409 if this is
ever reached another way — the guard is a courtesy,
never the rule. */}
{c.isActive ? null : (
<Button
size="sm"
variant="destructive"
onClick={() => setAction({kind: 'delete', company: c})}
label="Delete"
/>
)}
</HStack>
</HStack>
))}
</VStack>
</VStack>
)}
</PanelCard>
{action?.kind === 'create' ? (
<CreateCompanyDialog
onClose={() => setAction(null)}
onCreated={refresh}
/>
) : null}
{action?.kind === 'suspend' ? (
<SuspendCompanyDialog
company={action.company}
onClose={() => setAction(null)}
onDone={refresh}
/>
) : null}
{action?.kind === 'reset' ? (
<ResetOwnerPasswordDialog
company={action.company}
onClose={() => setAction(null)}
/>
) : null}
{action?.kind === 'delete' ? (
<DeleteCompanyDialog
company={action.company}
onClose={() => setAction(null)}
onDeleted={refresh}
/>
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,176 @@
'use client';
import {useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
import {companyRepository} from '@/features/admin/repositories/companyRepository';
/**
* Create a company and its owner, in one transaction.
*
* ── Why the owner is not optional ────────────────────────────────────────
* The platform creates both together on purpose: a company with no owner is a
* tenant nobody can sign into, and it looks entirely normal in the list. The
* operator finds out weeks later, when the customer says their login does not
* work. So this form asks for the owner up front rather than offering a
* "add the owner later" path that upstream would not honour anyway.
*
* ── The password is not ours to choose ───────────────────────────────────
* There is no password field, and adding one would be a mistake even though
* the API accepts it. An operator inventing a password for somebody else
* invents a weak one and then sends it over chat. The server generates it and
* returns it exactly once — it is bcrypt-hashed on the way in and cannot be
* fetched again — so the reveal below is the only chance to copy it.
*
* ── Slug ─────────────────────────────────────────────────────────────────
* Optional, and left blank by default. The platform derives it from the name,
* strips the characters that would break a broker topic, and then it can NEVER
* be changed. A field that permanent deserves to be a deliberate act, not a
* box somebody fills in because it is there.
*/
export function CreateCompanyDialog({
onClose,
onCreated,
}: {
onClose: () => void;
/** Fired once the list should refetch — on success, and on close after one. */
onCreated: () => void;
}) {
const [companyName, setCompanyName] = useState('');
const [slug, setSlug] = useState('');
const [ownerName, setOwnerName] = useState('');
const [ownerEmail, setOwnerEmail] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [created, setCreated] = useState<{
email: string;
password: string;
} | null>(null);
const canSubmit =
companyName.trim() !== '' && ownerEmail.trim() !== '' && !busy;
async function submit() {
setBusy(true);
setError(null);
const res = await companyRepository.create({
company_name: companyName.trim(),
owner_email: ownerEmail.trim(),
owner_name: ownerName.trim(),
...(slug.trim() ? {slug: slug.trim()} : {}),
});
setBusy(false);
if (!res.ok || !res.data) {
// The platform's own wording. It names the rule that was hit — a
// duplicate address, a slug already taken — and "Something went wrong"
// would throw that away.
setError(res.message ?? 'Could not create that company.');
return;
}
setCreated({email: res.data.ownerEmail, password: res.data.password});
// The company exists now, whether or not they close this dialog politely.
onCreated();
}
/**
* Closing is what discards the password.
*
* It lives in this component's state and nowhere else: not in the URL, not in
* localStorage, not in a toast that outlives the dialog, and never in a log.
* Unmounting drops it, which is the strongest guarantee available on a page
* — and the reason the warning says to copy it first.
*/
function close() {
setCreated(null);
onClose();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : close())}
purpose="info"
width={460}
aria-label="Create company"
>
<VStack gap={4} width="100%">
<DialogHeader
title={created ? 'Company created' : 'Create company'}
onOpenChange={(open) => (open ? undefined : close())}
/>
{created ? (
<VStack gap={4} width="100%">
<Banner
status="success"
title={companyName.trim()}
description={`Owner account created for ${created.email}.`}
/>
<SecretOnce
value={created.password}
label={`Password for ${created.email}`}
note="Send it to them now. It cannot be shown again, and closing this dialog discards it."
/>
<HStack gap={2} hAlign="end">
<Button onClick={close} label="Done" />
</HStack>
</VStack>
) : (
<VStack gap={4} width="100%">
{error ? <Banner status="error" title={error} /> : null}
<TextInput
label="Company name"
value={companyName}
onChange={setCompanyName}
placeholder="TeNext Retail"
/>
<TextInput
label="Slug (optional)"
value={slug}
onChange={setSlug}
placeholder="Derived from the name"
description="Permanent once set — it becomes part of the company's broker topic."
/>
<TextInput
label="Owner email"
type="email"
value={ownerEmail}
onChange={setOwnerEmail}
placeholder="owner@company.com"
/>
<TextInput
label="Owner name"
value={ownerName}
onChange={setOwnerName}
placeholder="Their full name"
/>
<Text size="xsm" color="secondary">
The owner&apos;s password is generated by the server and shown
once, here, immediately after this succeeds.
</Text>
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={close} label="Cancel" />
<Button
onClick={() => void submit()}
isDisabled={!canSubmit}
isLoading={busy}
label="Create company"
/>
</HStack>
</VStack>
)}
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import {useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {companyRepository} from '@/features/admin/repositories/companyRepository';
import type {Company} from '@/features/admin/types/company';
/**
* Delete a company. Permanently.
*
* ── Why the typed slug ───────────────────────────────────────────────────
* The platform requires the body to repeat the company's slug, and this dialog
* asks for it rather than filling it in. That is the whole safeguard: a
* confirm button can be clicked by muscle memory on the wrong row, and typing
* "tenext-retail" cannot. The button stays disabled until it matches exactly —
* no trimming of case, because the slug is lowercase upstream and a console
* that quietly "fixes" what was typed is not confirming anything.
*
* ── Why the suspended check is NOT enforced here ─────────────────────────
* The platform refuses to delete an active company (`409 still_active`), and
* this dialog does not pre-empt that. It says so plainly, and the button is
* still live: an active company is sent, and the platform's real 409 is what
* appears. A console that blocked the request itself would eventually disagree
* with the server about what is deletable, and that disagreement surfaces as a
* delete that looked like it worked and did not happen.
*
* ── What is actually destroyed ───────────────────────────────────────────
* Stored face images go from object storage first — a failure there is
* `502 storage_error` and nothing else is touched — then the shop PCs' broker
* logins, then every row by cascade: face templates, visits, users, sessions,
* cameras. The data is biometric. There is no undo and no backup to ask for.
*/
export function DeleteCompanyDialog({
company,
onClose,
onDeleted,
}: {
company: Company;
onClose: () => void;
onDeleted: () => void;
}) {
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const matches = confirm === company.slug;
async function submit() {
setBusy(true);
setError(null);
const res = await companyRepository.remove(company.id, confirm);
setBusy(false);
if (!res.ok) {
// `still_active` and `storage_error` both name a condition the operator
// can act on — suspend it first, or retry once storage is healthy.
setError(res.message ?? 'Could not delete that company.');
return;
}
onDeleted();
onClose();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="required"
width={460}
aria-label="Delete company"
>
<VStack gap={4} width="100%">
<DialogHeader
title="Delete company?"
onOpenChange={(open) => (open ? undefined : onClose())}
/>
{error ? <Banner status="error" title={error} /> : null}
<Banner
status="error"
title="This cannot be undone"
description={`Deletes ${company.name}, its ${company.sites} ${
company.sites === 1 ? 'shop' : 'shops'
}, its ${company.users} ${
company.users === 1 ? 'account' : 'accounts'
}, and every stored face image, visit and camera. The data is biometric and there is no backup to restore from.`}
/>
{company.isActive ? (
<Banner
status="warning"
title="Still active"
description="The platform will refuse this until the company is suspended. Suspend it first, then come back."
/>
) : null}
<TextInput
label="Type the company slug to confirm"
value={confirm}
onChange={setConfirm}
placeholder={company.slug}
description={`Exactly: ${company.slug}`}
/>
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button
variant="destructive"
onClick={() => void submit()}
isDisabled={!matches || busy}
isLoading={busy}
label="Delete company"
/>
</HStack>
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,149 @@
'use client';
import {useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
import {companyRepository} from '@/features/admin/repositories/companyRepository';
import type {Company} from '@/features/admin/types/company';
/**
* Generate a new password for a company's owner.
*
* ── The support case this exists for ─────────────────────────────────────
* The owner has locked themselves out and there is nobody above them inside
* the company to reset it. Every other account is reset by their own owner;
* this is the one that cannot be.
*
* ── Why the email box starts empty and hidden ────────────────────────────
* With exactly one owner the platform does not need to be told which — and
* that is the common case, so asking would be a field nobody can answer
* usefully. With several owners it answers 400 and NAMES them. That message is
* shown verbatim and the box appears, so the operator picks from the
* platform's own list. This console never enumerates owners itself: there is
* no endpoint for it, and inventing one would mean guessing.
*
* ── The password ─────────────────────────────────────────────────────────
* Generated, never chosen. Shown once — it is bcrypt-hashed upstream and
* cannot be fetched again. It lives in this component's state and nowhere
* else, and closing the dialog discards it. Resetting also revokes every
* session that owner held, so they are signed out wherever they were.
*/
export function ResetOwnerPasswordDialog({
company,
onClose,
}: {
company: Company;
onClose: () => void;
}) {
const [email, setEmail] = useState('');
const [needsEmail, setNeedsEmail] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<{
email: string;
password: string;
} | null>(null);
async function submit() {
setBusy(true);
setError(null);
const res = await companyRepository.resetOwnerPassword(
company.id,
email.trim() || undefined,
);
setBusy(false);
if (!res.ok || !res.data) {
// A 400 here is the platform saying "which owner?" and listing them.
// Surface it word for word and reveal the box — a generic failure would
// hide the one piece of information needed to succeed on the retry.
if (res.status === 400) setNeedsEmail(true);
setError(res.message ?? 'Could not reset that password.');
return;
}
setResult({email: res.data.email, password: res.data.password});
}
function close() {
setResult(null);
onClose();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : close())}
purpose="info"
width={460}
aria-label="Reset owner password"
>
<VStack gap={4} width="100%">
<DialogHeader
title={result ? 'Password reset' : 'Reset owner password?'}
onOpenChange={(open) => (open ? undefined : close())}
/>
{result ? (
<VStack gap={4} width="100%">
<SecretOnce
value={result.password}
label={`New password for ${result.email}`}
note="Send it to them now. It cannot be shown again, and closing this dialog discards it."
/>
<Text size="xsm" color="secondary">
Every session that owner held has been revoked. They are signed
out everywhere and will need this password to get back in.
</Text>
<HStack gap={2} hAlign="end">
<Button onClick={close} label="Done" />
</HStack>
</VStack>
) : (
<VStack gap={4} width="100%">
{error ? <Banner status="error" title={error} /> : null}
<VStack gap={2} width="100%">
<Text size="sm" weight="medium">
{company.name}
</Text>
<Text size="sm" color="secondary">
A new password is generated for the owner and shown once. Their
existing sessions are revoked, so they are signed out
immediately.
</Text>
</VStack>
{needsEmail ? (
<TextInput
label="Owner email"
type="email"
value={email}
onChange={setEmail}
placeholder="owner@company.com"
description="This company has more than one owner — name the one to reset."
/>
) : null}
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={close} label="Cancel" />
<Button
variant="destructive"
onClick={() => void submit()}
isLoading={busy}
isDisabled={busy || (needsEmail && email.trim() === '')}
label="Reset password"
/>
</HStack>
</VStack>
)}
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import {useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {companyRepository} from '@/features/admin/repositories/companyRepository';
import type {Company} from '@/features/admin/types/company';
/**
* Suspend or reinstate a company.
*
* ── Why suspension is confirmed and reinstatement is too ─────────────────
* Suspension is not a flag that takes effect at next sign-in: the platform
* revokes every session the company holds in the same transaction, so a
* manager halfway through a shift on a shop PC is signed out mid-task, and
* visits from its shop PCs are dropped at ingest from that moment. That is
* worth a sentence and a second click.
*
* Reinstatement is confirmed as well, for a quieter reason: it does NOT restore
* the sessions it ended. Everybody signs in again. An operator who expects
* "undo" should be told that before they click, not after the support call.
*
* ── The count is reported, not swallowed ─────────────────────────────────
* "Suspended" on its own leaves the operator wondering whether somebody is
* still signed in somewhere. `sessions_revoked` answers it, and it comes from
* the platform's own transaction — this console does not count anything itself
* and must not: a local tally would be a guess dressed as a fact.
*/
export function SuspendCompanyDialog({
company,
onClose,
onDone,
}: {
company: Company;
onClose: () => void;
onDone: () => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const suspending = company.isActive;
async function submit() {
setBusy(true);
setError(null);
const res = await companyRepository.setActive(company.id, !company.isActive);
setBusy(false);
if (!res.ok || !res.data) {
setError(
res.message ??
(suspending
? 'Could not suspend that company.'
: 'Could not reinstate that company.'),
);
return;
}
onDone();
onClose();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose={suspending ? 'required' : 'info'}
width={440}
aria-label={suspending ? 'Suspend company' : 'Reinstate company'}
>
<VStack gap={4} width="100%">
<DialogHeader
title={suspending ? 'Suspend company?' : 'Reinstate company?'}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
{error ? <Banner status="error" title={error} /> : null}
<VStack gap={2} width="100%">
<Text size="sm" weight="medium">
{company.name}
</Text>
<Text size="sm" color="secondary">
{suspending
? `Nobody at ${company.name} will be able to sign in. Every session they hold is ended immediately — including anyone signed in on a shop PC right now — and visits from their shops stop being recorded.`
: `${company.name} will be able to sign in again. Sessions ended by the suspension are not restored, so everyone signs in fresh.`}
</Text>
{suspending && company.users > 0 ? (
<Text size="xsm" color="secondary">
{company.users} {company.users === 1 ? 'person' : 'people'} and{' '}
{company.sites} {company.sites === 1 ? 'shop' : 'shops'} are
affected.
</Text>
) : null}
</VStack>
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button
variant={suspending ? 'destructive' : 'primary'}
onClick={() => void submit()}
isLoading={busy}
label={suspending ? 'Suspend company' : 'Reinstate company'}
/>
</HStack>
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import {useMemo, useState} from 'react';
import {useResource} from '@/shared/hooks/useResource';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {companyRepository} from '@/features/admin/repositories/companyRepository';
import type {Company, CompanyFilter} from '@/features/admin/types/company';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The company list, and the search and filter applied over it.
*
* ── Why the gate is on `isPlatformAdmin` and not just a session ──────────
* `GET /api/admin/clients` answers 404 to a merchant — that is the platform's
* rule, and it is the right one. But firing the request anyway would put a
* guaranteed 404 in the console of anyone whose session is not an operator's,
* for a question whose answer is already known here. Passing `null` holds the
* request entirely; `useResource` returns early and nothing is sent.
*
* This is not the authorisation. The route answers 404 regardless of what this
* hook believes, and the proxy refuses the page before it renders.
*
* ── Why search and filter are client-side ────────────────────────────────
* The list endpoint takes no query parameters. Inventing `?q=` would mean
* sending something the platform ignores and then filtering locally anyway,
* while implying a server-side search that does not exist.
*/
export function useCompanies(): Resource<Company[]> {
const {isAuthenticated, user} = useSession();
const isOperator = isAuthenticated && user?.isPlatformAdmin === true;
return useResource(isOperator ? companyRepository.list() : null);
}
export function useCompanyFilters(companies: Company[] | undefined) {
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<CompanyFilter>('all');
const visible = useMemo(() => {
const rows = companies ?? [];
const q = query.trim().toLowerCase();
return rows.filter((c) => {
if (filter === 'active' && !c.isActive) return false;
if (filter === 'suspended' && c.isActive) return false;
if (!q) return true;
// Name and slug only — they are the two identifiers the list returns.
// Matching on anything else would mean claiming a field the API does
// not send.
return (
c.name.toLowerCase().includes(q) || c.slug.toLowerCase().includes(q)
);
});
}, [companies, query, filter]);
return {query, setQuery, filter, setFilter, visible};
}

View File

@@ -0,0 +1,79 @@
import {
deleteJson,
patchJson,
postJson,
type Endpoint,
} from '@/shared/services/httpClient';
import type {
Company,
CompanyActiveResult,
NewCompany,
OwnerPassword,
} from '@/features/admin/types/company';
/**
* The companies on this platform.
*
* Every path here is a BFF route on this origin, never `mcp.loyaly.ai`. The
* browser holds no platform token and could not reach that host anyway — it
* sends no CORS headers. The token lives in an httpOnly cookie the BFF reads
* server-side.
*
* Unscoped: platform administration has no site or date scope. There is no
* `?site=` on any of these, and adding one would be meaningless — a platform
* operator has no tenant for a scope to select within.
*/
export const companyRepository = {
list: (): Endpoint<Company[]> => ({path: '/api/admin/clients', params: {}}),
/**
* Creates the company and its owner together, and hands back the owner's
* generated password ONCE.
*
* `password` is deliberately absent from the body: the server generates it.
* `slug` is optional — omitted, the platform derives it from the name, and
* once set it can never be changed.
*/
create: (body: {
company_name: string;
owner_email: string;
owner_name: string;
slug?: string;
}) => postJson<NewCompany>('/api/admin/clients', body),
/**
* Suspend (`false`) or reinstate (`true`).
*
* The result carries `sessionsRevoked`, which the UI reports. Suspension
* ends the company's live sessions upstream; reinstating does not bring them
* back, so that number is 0 on the way in.
*/
setActive: (id: string, active: boolean) =>
patchJson<CompanyActiveResult>(
`/api/admin/clients/${encodeURIComponent(id)}`,
{active},
),
/**
* Generate a new password for the company's owner. Shown once.
*
* `email` is omitted when the company has one owner. With several and no
* address the platform answers 400 and names them — that message is what the
* dialog shows, so this console never needs to enumerate owners itself.
*/
resetOwnerPassword: (id: string, email?: string) =>
postJson<OwnerPassword>(
`/api/admin/clients/${encodeURIComponent(id)}/owner-password`,
email ? {email} : {},
),
/**
* Permanent. The company must already be suspended and `confirm` must repeat
* its slug — both enforced by the platform, which answers 409 `still_active`
* or 400 rather than letting this console decide.
*/
remove: (id: string, confirm: string) =>
deleteJson<{deleted: string; images_deleted: number}>(
`/api/admin/clients/${encodeURIComponent(id)}?confirm=${encodeURIComponent(confirm)}`,
),
};

View File

@@ -0,0 +1,27 @@
import type {ApiClientRow} from '@/services/api/types';
import type {Company} from '@/features/admin/types/company';
/**
* Platform `ClientRow` → the shape the admin screens consume.
*
* One translation, in one place — the same rule `toAuthUser` and `toSite`
* follow. Renaming `created_at` at each call site is how two panels end up
* disagreeing about what an empty value means.
*
* `active` is carried through as `isActive` rather than inverted into
* `isSuspended`: the platform's field is the positive one, and a console that
* negates a boolean on the way in eventually negates it twice somewhere.
*/
export function toCompany(c: ApiClientRow): Company {
return {
id: c.id,
slug: c.slug,
// Falls back to the slug rather than rendering an empty cell. A company
// with no name is a data fault worth seeing, not worth hiding.
name: c.name || c.slug,
isActive: c.active,
sites: c.sites,
users: c.users,
createdAt: c.created_at,
};
}

View File

@@ -0,0 +1,59 @@
/**
* A company on the platform, as the admin console consumes it.
*
* Mapped from the platform's `ClientRow` in one place (services/mapCompany) so
* the upstream field names — `created_at`, `sites`, `users` — do not leak into
* five components that would each rename them slightly differently.
*
* Nothing is invented here. Every field comes back from
* `GET /api/admin/clients`; the admin surface upstream is company
* administration and nothing else, so there is no owner name, no contact, no
* revenue and no health. A column this list cannot fill is a column that would
* have to be faked.
*/
export interface Company {
id: string;
/**
* Immutable upstream — it becomes part of the company's message-broker
* topic and can never be changed. Safe to key on, to put in a URL, and to
* ask somebody to type back as a delete confirmation.
*/
slug: string;
name: string;
/** false = suspended: nobody in the company can sign in, and every session
* they held was revoked at the moment it was switched off. */
isActive: boolean;
sites: number;
users: number;
/** ISO-8601, from the server's clock. */
createdAt: string;
}
/** What a suspend/reinstate hands back — the row, and what it cost. */
export interface CompanyActiveResult {
company: Company;
/** Sessions the platform revoked in the same transaction. 0 when
* reinstating: suspension ends sessions, reinstatement does not restore
* them. */
sessionsRevoked: number;
}
/**
* The two shapes that carry a password. Both are shown once and never stored:
* the platform bcrypt-hashes on the way in and cannot show them again.
*/
export interface NewCompany {
clientId: string;
slug: string;
ownerEmail: string;
password: string;
}
export interface OwnerPassword {
email: string;
password: string;
}
/** The status filter above the table. Client-side — the list endpoint takes
* no query parameters, so filtering a fetched array is the honest option. */
export type CompanyFilter = 'all' | 'active' | 'suspended';

View File

@@ -6,7 +6,7 @@ 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 {destinationForRole} from '@/features/auth/services/roleDestination';
import {destinationForUser} from '@/features/auth/services/roleDestination';
/**
* The mirror of AuthGuard: keeps a signed-in user OFF the sign-in screen.
@@ -42,7 +42,11 @@ export function GuestGuard({children}: {children: React.ReactNode}) {
*/
const next = searchParams.get('next');
router.replace(
next ? resolveRedirectTarget(next) : destinationForRole(user?.role),
// `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]);

View File

@@ -8,8 +8,8 @@ import {
LOGIN_ERROR_PARAM,
loginErrorFromCode,
} from '@/features/auth/services/loginErrorCodes';
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
import {destinationForRole} from '@/features/auth/services/roleDestination';
import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget';
import {destinationForUser} from '@/features/auth/services/roleDestination';
import type {LoginError} from '@/features/auth/types/auth';
/**
@@ -72,9 +72,9 @@ export function useLoginForm() {
// which is the only side that can be trusted to.
const next = searchParams.get('next');
// Shared with GuestGuard, which redirects on the same event — see
// resolveRedirectTarget for why that matters and how `next` is validated.
const destination = resolveRedirectTarget(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.
const submit = useCallback(
async (event: React.FormEvent) => {
@@ -104,11 +104,17 @@ export function useLoginForm() {
// its loading state until the navigation commits, so the form cannot be
// submitted twice while the route transition is in flight.
// An explicit `next` wins: somebody sent here from a deep link gets the
// page they asked for. Otherwise the destination comes from the role the
// page they asked for. Otherwise the destination comes from what the
// BACKEND just returned — never from which of the three login pages they
// happened to open.
// happened to open. `destinationForUser` reads `isPlatformAdmin` before
// the role, so an operator lands on /admin and a tenant-scoped `admin`
// still lands where merchants do.
router.replace(
next ? destination : destinationForRole(result.session.user.role),
resolveRedirectTargetFor(
next,
result.session.user.isPlatformAdmin,
destinationForUser(result.session.user),
),
);
// The session cookie changed, so any server-rendered layout above this
// route is stale. Without this, the workspace can paint its signed-out
@@ -124,7 +130,6 @@ export function useLoginForm() {
toast,
router,
next,
destination,
],
);

View File

@@ -10,7 +10,7 @@ import {
} from 'react';
import {useRouter} from 'next/navigation';
import {authService} from '@/features/auth/services/authService';
import {TAB_SESSION_KEY} from '@/features/auth/services/tabSession';
import {SIDENAV_COLLAPSED_KEY} from '@/shared/layouts/workspace/sidebarStorage';
import type {
AuthSession,
AuthUser,
@@ -109,27 +109,6 @@ export function SessionProvider({
async (credentials: LoginCredentials): Promise<LoginResult> => {
const result = await authService.login(credentials);
if (result.ok) {
/**
* Re-claim the tab for the session just created.
*
* Normally the inline script in the root layout has already done this
* while /login rendered, so this is a no-op. It is load-bearing for one
* path: `logout` below CLEARS sessionStorage, and the /login it then
* navigates to is a client transition with no new document, so no
* script runs to put the marker back. Without this line, signing out
* and straight back in inside the same tab left it unmarked — and the
* next full reload of the workspace read that as an inherited session
* and signed the user out again.
*
* Wrapped for the same reason the clear in `logout` is: storage throws
* in private mode, and a failed write must not fail a good sign-in.
*/
try {
window.sessionStorage.setItem(TAB_SESSION_KEY, '1');
} catch {
/* ignore — the guard script fails open for the same reason */
}
setSession(result.session);
setStatus('authenticated');
}
@@ -144,18 +123,23 @@ export function SessionProvider({
setStatus('unauthenticated');
/**
* Belt and braces on the client side of the door.
* Clear this tab's preferences — and ONLY this tab's.
*
* The cookie is already gone server-side, which is what actually ends the
* session. This clears the *other* things a signed-in user leaves behind —
* a stored sidebar preference, a cached scope — so a shared machine does
* not hand the next person a workspace shaped like the last one. Wrapped
* because storage throws in private mode, and a failed cleanup must not
* strand someone in a session they asked to leave.
* This used to be `localStorage.clear()` + `sessionStorage.clear()`.
* `localStorage` is shared by the whole browser, so signing out of one tab
* wiped the theme and sidebar state of every other signed-in tab; and
* clearing `sessionStorage` wholesale would now also throw away this tab's
* id, orphaning the cookie the logout route still has to find.
*
* Named keys instead. The session itself is already gone server-side, which
* is what actually ends it — this is only about not handing the next person
* at a shared machine a workspace shaped like the last one.
*
* Wrapped because storage throws in private mode, and a failed cleanup must
* not strand someone in a session they asked to leave.
*/
try {
window.localStorage.clear();
window.sessionStorage.clear();
window.localStorage.removeItem(SIDENAV_COLLAPSED_KEY);
} catch {
/* ignore */
}

View File

@@ -51,21 +51,16 @@ const LOGIN_ERRORS: Record<string, LoginError> = {
field: 'form',
message: 'Sign-in is unavailable right now. Please contact support.',
},
/**
* Correct credentials for a PLATFORM ADMIN — an account with no company.
* Every surface in this console is tenant-scoped, so there is nothing here
* for one to open; the BFF declines to create a session rather than letting
* them walk into a dashboard of 500s. See the login route and roleDestination.
*
* Safe to say plainly, unlike the codes above: it is only ever reached by
* somebody who has just proved they own the account, so it tells an
* unauthenticated stranger nothing.
/*
* `platform_account` used to live here: correct credentials for a platform
* admin, refused because every surface in this console was tenant-scoped.
* It is gone because the login route no longer produces it — a platform
* operator now gets an ordinary session and lands on /admin. A code the
* server cannot emit is worse than absent: it is a message somebody will
* eventually try to trigger, and `?error=` accepts anything, so leaving it
* meant a URL could still put a stale "sign in elsewhere" instruction on the
* sign-in screen. See the login route and roleDestination.
*/
platform_account: {
field: 'form',
message:
'This console is for merchant accounts. Platform administrators sign in on the Loyaly platform console.',
},
};
export type LoginErrorCode = keyof typeof LOGIN_ERRORS;

View File

@@ -29,3 +29,40 @@ export function resolveRedirectTarget(next: string | null): string {
}
return next;
}
/**
* `next`, but only when it belongs to the person who just signed in.
*
* ── Why the plain version is not enough after sign-in ────────────────────
* `next` is written by the proxy when a request for a protected path has no
* session — someone's cookie lapsed while they were working. Resuming there is
* the point, and that still happens.
*
* What must NOT happen is a stale `next` outliving the identity it was written
* for. The sign-in page is shared: a staff member's session lapses on /floor,
* the proxy writes `?next=/floor`, and the next person to use that screen is
* handed a path chosen by the last one. Across consoles that is worse than
* untidy — a platform operator would be dropped into a tenant screen that
* answers 500 to every call, and a merchant into /admin, which the platform
* answers 404 to. Neither is a page either of them can use.
*
* So the rule is: honour `next` only when it is on the same side of the
* admin/merchant line as the user the platform just returned. Anything else
* falls back to that user's own home, which is always somewhere they can work.
*
* Deliberately NOT a per-role allowlist. /floor is a legitimate destination for
* a manager as well as for staff, and refusing it would break the ordinary
* resume-where-you-were case to chase a tidier-looking rule.
*/
export function resolveRedirectTargetFor(
next: string | null,
isPlatformAdmin: boolean,
fallback: string,
): string {
if (!next || !next.startsWith('/') || next.startsWith('//')) return fallback;
const wantsAdmin = next === '/admin' || next.startsWith('/admin/');
if (wantsAdmin !== isPlatformAdmin) return fallback;
return next;
}

View File

@@ -1,45 +1,43 @@
import {DEFAULT_DESTINATION} from './redirectTarget';
import type {UserRole} from '@/features/auth/types/auth';
import type {AuthUser, UserRole} from '@/features/auth/types/auth';
/**
* Where a signed-in user lands, decided by the role the BACKEND returned.
* Where a signed-in user lands, decided by what the BACKEND returned.
*
* ── Never from the page or the URL ───────────────────────────────────────
* There is one sign-in page. `POST /api/auth/login` takes an address, a
* password and a device label, and nothing else — no role, no user_type, no
* per-audience endpoint — so nothing about who somebody is can be known until
* the platform has said so. This map is read only AFTER that answer arrives.
* the platform has said so. This is read only AFTER that answer arrives.
*
* That also preserves the form's no-enumeration property: a destination chosen
* before authentication would tell an unauthenticated stranger which role an
* address holds, the same leak the identical wrong-password / no-such-account
* answer exists to close.
*
* One map, read by BOTH sign-in paths — the hydrated fetch, the native form
* POST — and by GuestGuard, which redirects on the same event and would
* 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.
*/
/**
* The platform console. A platform operator has no tenant, so every screen
* under /dashboard would answer 500 for one; this is the only route that can
* serve them, and it reads the platform's own `/api/admin/*` surface.
*/
export const ADMIN_DESTINATION = '/admin';
/**
* Tenant roles only, and `admin` is deliberately absent.
*
* ── Why `admin` is not here ──────────────────────────────────────────────
* It is not an oversight and it must not be "fixed" by adding a fourth line.
* That is not an oversight: `admin` is a role a TENANT can hold too — an
* account with `role === 'admin'` and a non-empty `client_id` is an ordinary
* merchant user who belongs on /dashboard. Keying the platform console off the
* role alone would send that person to a console with nothing in it for them,
* which is exactly the mistake `isPlatformAdmin` exists to prevent.
*
* The platform's own words, in server/internal/auth/auth.go: "ClientID empty
* means a platform admin, who is the only kind of user not scoped to one
* tenant." Every surface in this console is tenant-scoped, so a platform admin
* has no company for any of it to read. Measured against a real admin token on
* the live local platform: /api/sites 500, /api/visits 500, /api/visitors 500,
* /api/team 403 "This account does not belong to a company." /dashboard is not
* a thin experience for an admin, it is a screenful of server errors.
*
* So an admin is not given a console session at all — the BFF refuses it at
* `POST /api/auth/login` (see that route, and isPlatformAdmin). No admin can
* reach this function, because no admin can hold a session here. Their surface
* is the Companies screen in the platform's own web app, which has its own
* login; this console does not redirect there and does not hand it a token,
* because no session handoff exists between the two and inventing one would
* mean putting a credential in a URL.
*
* The fallback below is for a role this console has not heard of yet, not for
* `admin`.
* The distinction is decided server-side from the role AND an empty
* `client_id` together — see userMapper.
*/
export const ROLE_DESTINATIONS: Record<Exclude<UserRole, 'admin'>, string> = {
/**
@@ -52,6 +50,19 @@ export const ROLE_DESTINATIONS: Record<Exclude<UserRole, 'admin'>, string> = {
owner: DEFAULT_DESTINATION,
};
/**
* The landing route for a user.
*
* `isPlatformAdmin` is checked FIRST and wins over every role mapping, because
* it is the only fact that decides which of the two consoles this browser is
* about to render. A tenant-scoped `admin` falls through to the map and keeps
* the merchant destination it has always had.
*/
export function destinationForUser(user: AuthUser): string {
if (user.isPlatformAdmin) return ADMIN_DESTINATION;
return destinationForRole(user.role);
}
/** Falls back to the default for a role this console does not know. */
export function destinationForRole(role: string | undefined): string {
if (!role) return DEFAULT_DESTINATION;

View File

@@ -1,9 +1,8 @@
import 'server-only';
import {cookies} from 'next/headers';
import {
SESSION_COOKIE,
verifySessionToken,
} from '@/features/auth/services/sessionToken';
import {verifySessionToken} from '@/features/auth/services/sessionToken';
import {sessionCookieFor} from '@/features/auth/services/tabScope';
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
import type {AuthSession, UserRole} from '@/features/auth/types/auth';
/**
@@ -27,9 +26,22 @@ import type {AuthSession, UserRole} from '@/features/auth/types/auth';
* There is no local directory any more — the platform is the directory — so
* the check moved to where the platform actually answers.
*/
/**
* ── Which tab's session ──────────────────────────────────────────────────
* Resolved through `resolveTabId`, so a server render seeds the shell with the
* identity of the tab making the request rather than whichever tab signed in
* most recently. During a Server Component render there is no `X-Tab-Id`
* header, so this falls back to the `loyaly_tab` pointer cookie — a hint, and
* deliberately only a hint. `SessionProvider` re-resolves on the client with
* the header, so a stale pointer costs one corrected fetch and never renders
* one tab's data inside another.
*/
export async function getServerSession(): Promise<AuthSession | null> {
const tabId = await resolveTabId();
if (!tabId) return null;
const store = await cookies();
const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value);
const payload = verifySessionToken(store.get(sessionCookieFor(tabId))?.value);
if (!payload) return null;
return {
@@ -39,6 +51,9 @@ export async function getServerSession(): Promise<AuthSession | null> {
name: payload.name,
role: payload.role as UserRole,
organisation: payload.organisation,
// `=== true` rather than a cast: a cookie minted before this field
// existed has it `undefined`, and a merchant must read as false.
isPlatformAdmin: payload.isPlatformAdmin === true,
},
expiresAt: new Date(payload.exp * 1000).toISOString(),
};

View File

@@ -50,6 +50,18 @@ export interface SessionPayload {
name: string;
role: string;
organisation: string;
/**
* A platform operator, not a merchant. Computed SERVER-SIDE from
* `role === 'admin' && client_id === ''` (userMapper.isPlatformAdmin) and
* signed into this payload, because the browser never sees `client_id` and
* must not infer the answer from `organisation` — `client_name` is
* COALESCEd to '' upstream, so a tenant whose company name is blank would
* read as an operator.
*
* Routing only. Authorisation stays upstream: the platform answers 404 to
* `/api/admin/*` for anyone who is not one of these, whatever this says.
*/
isPlatformAdmin: boolean;
/** Issued-at and expiry, epoch seconds. */
iat: number;
exp: number;

View File

@@ -0,0 +1,88 @@
/**
* Which browser TAB a session belongs to — the naming rules, and nothing else.
*
* ── The problem ──────────────────────────────────────────────────────────
* A cookie jar belongs to the browser profile, not the tab. One pair of cookies
* for the whole origin meant one identity for the whole browser: signing in as
* a manager in a second tab replaced the admin in the first, and merely
* switching back to the first tab repainted it as the manager, because the
* session provider refetches on `visibilitychange`. No cookie attribute scopes
* a cookie to a tab; this is not something React state can fix.
*
* ── The fix ──────────────────────────────────────────────────────────────
* Every tab mints a random id into `sessionStorage` — the only per-tab lifetime
* browsers give us — and each tab's session lives in its OWN pair of cookies:
*
* loyaly_session_<tabId> signed identity
* loyaly_tokens_<tabId> AES-sealed access + refresh
*
* Nothing about the security model changes. Both cookies are still httpOnly, so
* JavaScript still cannot read a token. The id is NOT a credential: it names
* which cookie to open, and a forged one selects a cookie the attacker's own
* browser already had — or, far more likely, none at all.
*
* ── Why this file is pure ────────────────────────────────────────────────
* `src/proxy.ts` needs `isSessionCookieName` and `sessionCookieFor`, and the
* proxy cannot import `server-only` — that package throws on import outside a
* react-server condition, the same trap documented in platformApi.ts. So the
* request-context half (`resolveTabId`, which reads headers and cookies) lives
* in tabScopeRequest.ts, and everything here is a pure string function.
*/
/** Names the tab; carries no authority of its own. Not httpOnly — the tab's
* own script writes it, and it is not a credential. */
export const TAB_POINTER_COOKIE = 'loyaly_tab';
export const TAB_ID_HEADER = 'x-tab-id';
/** Where each tab keeps its id. `sessionStorage`, so it is empty in a new tab,
* survives that tab's reloads, and dies with it. */
export const TAB_ID_STORAGE_KEY = 'loyaly.tab-id';
const SESSION_PREFIX = 'loyaly_session_';
const TOKEN_PREFIX = 'loyaly_tokens_';
/**
* Strict, and this is the load-bearing line in the file.
*
* The id becomes part of a COOKIE NAME and it arrives from the client. Anything
* looser than a fixed alphabet lets a crafted value inject cookie syntax — a
* `;`, a space, an `=` — and name a cookie it was never meant to reach.
* Lowercase alphanumerics only, bounded length, no exceptions.
*/
const TAB_ID = /^[a-z0-9]{8,32}$/;
export function isValidTabId(value: string | undefined | null): value is string {
return typeof value === 'string' && TAB_ID.test(value);
}
export function sessionCookieFor(tabId: string): string {
return `${SESSION_PREFIX}${tabId}`;
}
export function tokenCookieFor(tabId: string): string {
return `${TOKEN_PREFIX}${tabId}`;
}
/** Every session cookie in the jar — one per signed-in tab. */
export function isSessionCookieName(name: string): boolean {
return (
name.startsWith(SESSION_PREFIX) &&
isValidTabId(name.slice(SESSION_PREFIX.length))
);
}
/**
* The pointer is readable by script on purpose — the tab writes it on load and
* on focus so the next DOCUMENT navigation, which cannot carry a header, is
* server-rendered as the right user. `lax` keeps it off cross-site requests,
* and it holds no authority regardless.
*/
export function tabPointerOptions() {
return {
httpOnly: false,
sameSite: 'lax' as const,
secure: process.env.NODE_ENV === 'production',
path: '/',
};
}

View File

@@ -0,0 +1,53 @@
import 'server-only';
import {cookies, headers} from 'next/headers';
import {randomBytes} from 'node:crypto';
import {TAB_ID_HEADER, TAB_POINTER_COOKIE, isValidTabId} from './tabScope';
/**
* Resolving which tab the CURRENT request came from.
*
* Split from tabScope.ts because this half touches `next/headers` and is
* `server-only`; the proxy imports the pure half and could not load this one.
*
* ── Two channels, in priority order ──────────────────────────────────────
* Neither works everywhere, which is why there are two:
*
* 1. `X-Tab-Id` on fetches from the app. AUTHORITATIVE — it comes from the
* tab making the request, so a background tab polling in the same browser
* reads its own session rather than the focused tab's.
*
* 2. The `loyaly_tab` POINTER cookie, for DOCUMENT navigations, which cannot
* carry a custom header. The tab writes it on load and on focus, so it
* tracks the tab actually in use — only one tab is focused at a time,
* which is what makes a single pointer sufficient.
*
* The pointer is a hint for server rendering and for the proxy's role routing,
* never the authority. The client always re-resolves with the header, so a
* stale pointer costs one corrected fetch and can never render one tab's data
* inside another.
*/
/**
* Null when neither channel yields a valid id — a request from outside the app,
* or a tab that has not claimed one yet.
*
* Callers must treat null as "no session", never as "any session". Picking an
* arbitrary cookie out of the jar would be precisely the cross-tab leak this
* module exists to close.
*/
export async function resolveTabId(): Promise<string | null> {
const fromHeader = (await headers()).get(TAB_ID_HEADER);
if (isValidTabId(fromHeader)) return fromHeader;
const pointer = (await cookies()).get(TAB_POINTER_COOKIE)?.value;
return isValidTabId(pointer) ? pointer : null;
}
/**
* 16 hex characters from a CSPRNG. Not a secret — just unlikely to collide with
* another tab in the same browser. Used only by the login route, for the
* no-JavaScript path that cannot send a header.
*/
export function newTabId(): string {
return randomBytes(8).toString('hex');
}

View File

@@ -1,84 +1,63 @@
/**
* Tab-scoped session lifetime.
*
* ── The problem cookies cannot solve on their own ─────────────────────────
* `loyaly_session` and `loyaly_tokens` are browser-session cookies: no Max-Age,
* so the browser drops them when it exits. That is per BROWSER, not per TAB —
* one cookie jar is shared by every tab of the profile, so closing the tab a
* merchant signed in on leaves the jar untouched and the next tab is still
* authenticated. No cookie attribute exists that scopes a cookie to one tab.
*
* `sessionStorage` is the only per-tab lifetime the platform gives us: it is
* empty in a newly opened tab, survives a reload of the tab it belongs to, and
* is discarded when that tab closes. So a tab that holds the session marker is
* a tab that was here when the session was established; a tab without it is a
* tab that inherited somebody else's cookies.
*
* The marker is NOT a credential. It is the string "1". The access and refresh
* tokens stay sealed in the httpOnly cookie where JavaScript cannot reach them,
* and nothing here changes that: a forged marker buys an attacker exactly the
* cookies their browser already had.
*
* ── Why an inline script and not an effect ────────────────────────────────
* A React effect runs after the first paint, so a new tab would show the
* dashboard and then bounce to /login. This runs as the first child of <body>,
* before the app markup below it is parsed, so the authenticated shell is never
* painted at all.
*/
/** Present ⇒ this tab is the one the session belongs to. */
export const TAB_SESSION_KEY = 'loyaly.tab-session';
import {
TAB_ID_STORAGE_KEY,
TAB_POINTER_COOKIE,
} from '@/features/auth/services/tabScope';
/**
* Set for the duration of one sign-out attempt, and the reason this cannot
* loop. `proxy.ts` redirects /login → /dashboard while the cookies verify, so a
* failed `POST /api/auth/logout` would otherwise land straight back on a
* protected page with the marker still missing, and fire again forever. If the
* guard finds its own flag already set it concludes the clear did not take,
* claims the tab and gets out of the way — degrading to the previous behaviour
* rather than locking somebody out of a console it cannot sign them out of.
*/
const RESET_FLAG = 'loyaly.tab-session.reset';
/**
* Claim the tab for the session that is about to exist.
* The inline script that gives each tab its own identity.
*
* Runs on every SIGNED-OUT document, which is what makes the no-JavaScript and
* pre-hydration sign-in paths work: /login itself claims the tab, so the native
* form POST lands on /dashboard with the marker already in place. The reset flag
* is cleared here because arriving signed out is proof the sign-out worked.
*/
const CLAIM_SCRIPT = `(function(){try{var s=window.sessionStorage;s.setItem(${JSON.stringify(
TAB_SESSION_KEY,
)},'1');s.removeItem(${JSON.stringify(RESET_FLAG)});}catch(e){}})();`;
/**
* End the session when the tab holding it is gone.
* ── What this used to do, and why it had to go ───────────────────────────
* This file used to enforce a ONE-TAB-PER-SESSION policy. A document served
* with a session to a tab that had no `sessionStorage` marker — which is every
* newly opened tab — hid the page, called `POST /api/auth/logout`, and bounced
* to /login. That made opening a second tab sign the FIRST one out and revoke
* its platform session, because the cookies were shared by the whole browser
* and there was no way to tell "a tab that inherited someone's cookies" from
* "a second tab of the same person".
*
* Fails OPEN when sessionStorage is unreadable — private mode, blocked site
* data, an embedded webview. A storage API that throws must not be the thing
* that decides a merchant cannot use the console; the worst case is the
* cross-tab behaviour this file exists to change, not a lockout.
* There is a way now: each tab carries an id and opens its OWN cookies (see
* tabScope.ts). A second tab simply has no session of its own and lands on
* /login, which is the correct answer and costs the first tab nothing. So the
* destructive guard is gone — nothing in here signs anybody out any more.
*
* ── What it does instead ─────────────────────────────────────────────────
* 1. Mints this tab's id into `sessionStorage` if it has none. Empty in a new
* tab, survives that tab's reloads, discarded when it closes — exactly the
* lifetime a per-tab session needs.
* 2. Writes the id into the `loyaly_tab` pointer cookie, so the NEXT document
* navigation (which cannot carry a header) is server-rendered as this tab's
* user rather than as whoever signed in last.
*
* ── Why an inline script and not an effect ───────────────────────────────
* A React effect runs after the first paint, so the first document of a tab
* would be rendered against the wrong pointer and visibly re-render. This runs
* as the first child of <body>, before the app markup below it is parsed.
*
* ── Why it fails open ────────────────────────────────────────────────────
* `sessionStorage` throws in private mode, with site data blocked, and in some
* embedded webviews. A storage API that throws must never be the thing that
* decides somebody cannot use the console. With no id the server falls back to
* the pointer cookie, and the browser behaves as it did before this existed:
* one shared session. Degraded, never locked out.
*
* The id is NOT a credential. Tokens stay sealed in httpOnly cookies that
* JavaScript cannot read, and a forged id names a cookie the browser either
* already had or does not have at all.
*/
const GUARD_SCRIPT = `(function(){var K=${JSON.stringify(
TAB_SESSION_KEY,
)},R=${JSON.stringify(RESET_FLAG)},s;
try{s=window.sessionStorage;}catch(e){return;}
try{
if(s.getItem(K))return;
if(s.getItem(R)){s.setItem(K,'1');s.removeItem(R);return;}
s.setItem(R,'1');
}catch(e){return;}
document.documentElement.style.visibility='hidden';
var go=function(){location.replace('/login');};
try{fetch('/api/auth/logout',{method:'POST',credentials:'same-origin'}).then(go,go);}catch(e){go();}
})();`;
/**
* The script the root layout inlines, chosen by whether the SERVER resolved a
* session for this request. Two separate bodies rather than one that branches on
* a serialised flag, so neither path can be reached by tampering with the other.
*/
export function tabSessionScript(hasSession: boolean): string {
return hasSession ? GUARD_SCRIPT : CLAIM_SCRIPT;
export function tabSessionScript(): string {
return `(function(){try{
var K=${JSON.stringify(TAB_ID_STORAGE_KEY)},P=${JSON.stringify(TAB_POINTER_COOKIE)};
var s=window.sessionStorage,id=s.getItem(K);
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':'');};
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('pageshow',point);
}catch(e){}})();`;
}

View File

@@ -4,12 +4,13 @@ import {createHash} from 'node:crypto';
import {UpstreamError, upstreamRequest} from '@/services/api/apiClient';
import type {ApiTokenBundle} from '@/services/api/types';
import {
TOKEN_COOKIE,
openTokens,
sealTokens,
tokenCookieOptions,
type TokenBundle,
} from './tokenStore';
import {tokenCookieFor} from './tabScope';
import {resolveTabId} from './tabScopeRequest';
/**
* Authenticated access to the platform, with the three refresh rules the
@@ -43,9 +44,20 @@ function lockKey(refreshToken: string): string {
return createHash('sha256').update(refreshToken).digest('base64url');
}
/**
* This TAB's tokens, never another tab's.
*
* A null tab id returns null rather than falling back to some other cookie in
* the jar. "I could not tell which tab this is" must read as "no session" — the
* alternative is handing one tab the credentials of whichever tab happened to
* sign in last, which is the bug this scoping exists to fix.
*/
async function readTokens(): Promise<TokenBundle | null> {
const tabId = await resolveTabId();
if (!tabId) return null;
const store = await cookies();
return openTokens(store.get(TOKEN_COOKIE)?.value);
return openTokens(store.get(tokenCookieFor(tabId))?.value);
}
/**
@@ -56,9 +68,20 @@ async function readTokens(): Promise<TokenBundle | null> {
async function persistTokens(
bundle: TokenBundle,
maxAgeSeconds?: number,
forTabId?: string,
): Promise<void> {
// `forTabId` is passed by the login route, which has just minted the id and
// cannot resolve it from a header or a pointer that does not exist yet. Every
// other caller — a token refresh mid-session — resolves it normally.
const tabId = forTabId ?? (await resolveTabId());
if (!tabId) return;
const store = await cookies();
store.set(TOKEN_COOKIE, sealTokens(bundle), tokenCookieOptions(maxAgeSeconds));
store.set(
tokenCookieFor(tabId),
sealTokens(bundle),
tokenCookieOptions(maxAgeSeconds),
);
}
export function toBundle(res: ApiTokenBundle): TokenBundle {
@@ -153,15 +176,20 @@ export async function withUpstream<T>(
export async function storeTokens(
res: ApiTokenBundle,
maxAgeSeconds?: number,
forTabId?: string,
): Promise<TokenBundle> {
const bundle = toBundle(res);
await persistTokens(bundle, maxAgeSeconds);
await persistTokens(bundle, maxAgeSeconds, forTabId);
return bundle;
}
/** Only this tab's. Signing out of one tab must leave the others signed in. */
export async function clearTokens(): Promise<void> {
const tabId = await resolveTabId();
if (!tabId) return;
const store = await cookies();
store.delete(TOKEN_COOKIE);
store.delete(tokenCookieFor(tabId));
}
/** The access token without any refresh attempt — for fire-and-forget calls

View File

@@ -16,6 +16,9 @@ export function toAuthUser(u: ApiUser): AuthUser {
name: u.full_name || u.email,
role: u.role as UserRole,
organisation: u.client_name,
// From the real `client_id`, here, once. The browser never receives that
// field, so this is the only place the question can be answered honestly.
isPlatformAdmin: isPlatformAdmin(u),
};
}

View File

@@ -24,6 +24,13 @@ export interface AuthUser {
role: UserRole;
/** Merchant/tenant this user belongs to. */
organisation: string;
/**
* A platform operator, who has no tenant. Decided by the server from the
* role AND an empty `client_id` together — never from the role alone, and
* never from `organisation`. Drives which console the browser renders; it
* grants nothing, because every admin call is authorised upstream.
*/
isPlatformAdmin: boolean;
}
/** What the client is allowed to know about its own session. */

View File

@@ -30,7 +30,22 @@ import type {Resource} from '@/shared/hooks/useResource';
* SessionProvider's `initialSession`), so this costs a signed-in user nothing —
* there is no 'loading' pass to wait through before the estate is requested.
*/
/**
* ── Why a platform operator is gated out as well ─────────────────────────
* WorkspaceProvider sits above the WHOLE route tree (see app/providers.tsx),
* /admin included, so this fires on the platform console too. A platform
* operator has no tenant, and the platform answers `GET /api/sites` with a 500
* for a token that carries no company — so an ungated call put a guaranteed
* server error in the console on every admin page, for a request whose answer
* could never be anything else.
*
* Gated here rather than in WorkspaceProvider for the same reason the session
* check is: the rule is a property of the ENDPOINT — no tenant, no estate — and
* only a null endpoint can actually hold the request. A guard on the derived
* value would still send it.
*/
export function useSites(): Resource<Site[]> {
const {isAuthenticated} = useSession();
return useResource(isAuthenticated ? siteRepository.list() : null);
const {isAuthenticated, user} = useSession();
const hasTenant = isAuthenticated && user?.isPlatformAdmin !== true;
return useResource(hasTenant ? siteRepository.list() : null);
}

View File

@@ -1,10 +1,13 @@
import {NextResponse} from 'next/server';
import type {NextRequest} from 'next/server';
import {configStatus} from '@/shared/config/configCheck';
import {verifySessionToken} from '@/features/auth/services/sessionToken';
import {
SESSION_COOKIE,
verifySessionToken,
} from '@/features/auth/services/sessionToken';
TAB_POINTER_COOKIE,
isSessionCookieName,
isValidTabId,
sessionCookieFor,
} from '@/features/auth/services/tabScope';
/**
* The authentication gate. `proxy.ts`, not `middleware.ts` — the middleware
@@ -32,6 +35,27 @@ import {
const LOGIN_PATH = '/login';
const HOME_PATH = '/dashboard';
/**
* The platform console, and the rule that keeps the two consoles apart.
*
* Enforced HERE rather than only in a layout or a page, for the same reason
* the session gate is: a client-side guard can be defeated by disabling JS or
* by racing the redirect, and a merchant who reaches /admin before a guard
* fires has already had the page rendered at them. A request that never gets a
* page back cannot be raced.
*
* This is routing, not authorisation. It decides which console a browser is
* shown; it grants nothing. Every admin read still goes to the platform, which
* answers 404 to anyone who is not a platform operator — so a forged cookie
* (which is not possible: the payload is HMAC-signed) would buy an empty
* screen, not data.
*/
const ADMIN_PATH = '/admin';
function isAdminArea(pathname: string): boolean {
return pathname === ADMIN_PATH || pathname.startsWith(`${ADMIN_PATH}/`);
}
/**
* Never gated, by session or by configuration — they are how a container
* reports which of those two states it is in, and a probe that had to
@@ -120,20 +144,57 @@ export function proxy(request: NextRequest): NextResponse {
return misconfiguredResponse(pathname);
}
const session = verifySessionToken(
request.cookies.get(SESSION_COOKIE)?.value,
);
const isAuthenticated = session !== null;
/**
* 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.
*
* 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.
*/
const pointer = request.cookies.get(TAB_POINTER_COOKIE)?.value;
const session = isValidTabId(pointer)
? verifySessionToken(request.cookies.get(sessionCookieFor(pointer))?.value)
: null;
const isAuthenticated =
session !== null ||
request.cookies
.getAll()
.some(
(c) => isSessionCookieName(c.name) && verifySessionToken(c.value) !== null,
);
/**
* Which console this session belongs to. Read from the SIGNED payload, so a
* cookie cannot be edited into the other one — a tampered signature fails
* `verifySessionToken` and reads as no session at all.
*
* `=== true` rather than truthiness: a cookie minted before this field
* existed has it `undefined`, and such a session must read as a merchant.
*/
const isPlatformAdmin = session?.isPlatformAdmin === true;
const home = isPlatformAdmin ? ADMIN_PATH : HOME_PATH;
if (pathname === '/') {
return NextResponse.redirect(
new URL(isAuthenticated ? HOME_PATH : LOGIN_PATH, request.url),
new URL(isAuthenticated ? home : LOGIN_PATH, request.url),
);
}
if (PUBLIC_PATHS.has(pathname)) {
if (isAuthenticated) {
return NextResponse.redirect(new URL(HOME_PATH, request.url));
return NextResponse.redirect(new URL(home, request.url));
}
return NextResponse.next();
}
@@ -165,6 +226,55 @@ export function proxy(request: NextRequest): NextResponse {
return NextResponse.redirect(target);
}
/**
* Signed in, but possibly at the wrong console.
*
* Both directions are redirects rather than 404s, because both are a person
* who IS signed in and does have somewhere to be — sending them there is more
* useful than telling them a page does not exist. The API arm is the
* exception: a fetch cannot follow a redirect to HTML and make sense of it
* (the same reasoning as the anonymous branch above), so it gets a status.
*
* 404, not 403, for a merchant reaching an admin API — matching the
* platform's own choice for `/api/admin/*`. A tenant user has no business
* learning this surface exists.
*/
const wantsAdmin = isAdminArea(pathname) || pathname.startsWith('/api/admin');
if (wantsAdmin && !isPlatformAdmin) {
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{error: {code: 'not_found', message: 'No such page.'}},
{status: 404, headers: {'cache-control': 'no-store'}},
);
}
return NextResponse.redirect(new URL(HOME_PATH, request.url));
}
/**
* A platform operator anywhere in the tenant console.
*
* Every screen there is scoped to a company they do not have, so the platform
* answers 500 or 403 to each of its data calls — a dashboard of server errors
* rather than a page. Sending them to /admin is the only honest answer.
*
* `/api/auth` is already excluded by the matcher, so sign-out is unaffected.
*/
if (isPlatformAdmin && !wantsAdmin) {
if (pathname.startsWith('/api/')) {
return NextResponse.json(
{
error: {
code: 'unauthorized',
message: 'This account has no company. Use the platform console.',
},
},
{status: 403, headers: {'cache-control': 'no-store'}},
);
}
return NextResponse.redirect(new URL(ADMIN_PATH, request.url));
}
return NextResponse.next();
}

View File

@@ -0,0 +1,100 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {
ApiClientActiveResult,
ApiClientDeleteResult,
ApiClientRow,
ApiNewClientInput,
ApiNewClientResult,
ApiOwnerPasswordResult,
} from './types';
/**
* Platform administration — the companies on this platform.
*
* `server-only`, like every module in this directory, and that is the whole
* security posture of the admin console: the browser never holds a platform
* token and never calls `mcp.loyaly.ai`. These run inside the BFF, which reads
* the access token out of an AES-sealed httpOnly cookie the page cannot see.
*
* ── This layer authorises nothing, deliberately ──────────────────────────
* There is no role check in this file and there must not be one. The platform
* gates `/api/admin/*` with `adminOnly` — `role === "admin"` AND no company —
* and answers **404** to everyone else. Re-implementing that rule here would
* create a second copy to drift out of step with the first, and the copy that
* matters is the one on the server holding the data.
*
* What the console does with `isPlatformAdmin` is routing: which screen to
* draw. If that answer were ever wrong, these calls would return 404 and the
* screen would be empty — not populated with somebody else's tenants.
*
* Scope is exactly the five operations the platform documents as the admin
* surface. There is no endpoint to look inside a company, and none is invented
* here: seeing a tenant's data means signing in as that tenant's owner.
*/
export const adminApi = {
listClients: (accessToken: string) =>
upstreamRequest<ApiClientRow[]>({path: '/api/admin/clients', accessToken}),
/**
* Creates the company and its owner together. Answers 201.
*
* `password` is deliberately not sent — the server generates it and returns
* it once. Nothing in this console ever chooses a password for somebody else.
*/
createClient: (accessToken: string, body: ApiNewClientInput) =>
upstreamRequest<ApiNewClientResult>({
path: '/api/admin/clients',
method: 'POST',
body,
accessToken,
}),
/**
* Suspend (`false`) or reinstate (`true`).
*
* Suspension revokes the company's sessions upstream in the same
* transaction. This console must not simulate that — a local "signed out"
* flag would claim an invalidation it cannot perform.
*/
setClientActive: (accessToken: string, id: string, active: boolean) =>
upstreamRequest<ApiClientActiveResult>({
path: `/api/admin/clients/${encodeURIComponent(id)}`,
method: 'PATCH',
body: {active},
accessToken,
}),
/**
* For an owner locked out with nobody above them in the company.
*
* `email` picks which owner when the company has more than one; with exactly
* one it may be omitted, and omitting it is what the UI does first. With
* several owners and no address the platform answers 400 and lists them,
* which is the signal to ask.
*/
resetOwnerPassword: (accessToken: string, id: string, email?: string) =>
upstreamRequest<ApiOwnerPasswordResult>({
path: `/api/admin/clients/${encodeURIComponent(id)}/owner-password`,
method: 'POST',
body: email ? {email} : {},
accessToken,
}),
/**
* Irreversible, and the data is biometric, so the platform makes it a
* two-step decision: the company must already be suspended (409
* `still_active` otherwise) and the body must repeat its slug.
*
* Both conditions are the platform's, not this console's. The dialog mirrors
* them so the person is not surprised, but it never pre-empts them — an
* active company is sent and the real 409 is shown.
*/
deleteClient: (accessToken: string, id: string, confirm: string) =>
upstreamRequest<ApiClientDeleteResult>({
path: `/api/admin/clients/${encodeURIComponent(id)}`,
method: 'DELETE',
body: {confirm},
accessToken,
}),
};

View File

@@ -567,3 +567,74 @@ export interface ApiSaleResult {
sale_id: string;
sale?: ApiSale;
}
/* ── §11 Platform administration — /api/admin/* ──────────────────────────
*
* The one surface upstream that is NOT tenant-scoped. Every shape below is
* taken from the server's own types (internal/api/types.go, handlers_admin.go,
* handlers_admin_clients.go), not from the prose in API.md — where the two
* disagree the server is right, and API.md says so itself.
*
* Reachable only by an account with `role === 'admin'` AND an empty
* `client_id`. Everything else gets 404, not 403: a tenant user has no
* business learning this surface exists.
*/
/** One tenant on the platform-admin list. `ClientRow` upstream. */
export interface ApiClientRow {
id: string;
/** Immutable — it becomes part of the company's broker topic. Safe to key on
* and to repeat back as the delete confirmation. */
slug: string;
name: string;
active: boolean;
sites: number;
users: number;
created_at: string;
}
/**
* Creating a company also creates its owner, in one transaction — a company
* with no owner is a tenant nobody can sign into and it looks normal in every
* list.
*
* `password` is left EMPTY on purpose: the server generates one. An operator
* inventing a password for somebody else invents a weak one and sends it over
* chat. `slug` is optional and derived from the name when omitted.
*/
export interface ApiNewClientInput {
company_name: string;
slug?: string;
owner_email: string;
owner_name: string;
password?: string;
}
/** The only moment the owner's password exists in readable form. */
export interface ApiNewClientResult {
client_id: string;
slug: string;
owner_email: string;
/** Bcrypt-hashed on the way in and not recoverable. Shown once, never stored. */
password: string;
}
/** `PATCH /api/admin/clients/{id}` — suspend or reinstate. */
export interface ApiClientActiveResult {
client: ApiClientRow;
/** Suspension revokes every session the company holds, in the same
* transaction — a live access token stops working now, not at expiry. */
sessions_revoked: number;
}
/** `POST /api/admin/clients/{id}/owner-password`. Shown once. */
export interface ApiOwnerPasswordResult {
email: string;
password: string;
}
/** `DELETE /api/admin/clients/{id}`. Irreversible; the data is biometric. */
export interface ApiClientDeleteResult {
deleted: string;
images_deleted: number;
}

View File

@@ -10,6 +10,7 @@ import {
} from 'react';
import {useBreakpoint, isSideNavInline} from '@/shared/hooks/useBreakpoint';
import {usePersistentTriState} from '@/shared/hooks/usePersistentFlag';
import {SIDENAV_COLLAPSED_KEY as COLLAPSE_KEY} from './sidebarStorage';
/**
* One source of truth for "where is the navigation right now".
@@ -40,7 +41,6 @@ export const NAV_DRAWER_ID = 'workspace-nav-drawer';
/** The inline rail's DOM id, for the same reason above the breakpoint. */
export const NAV_RAIL_ID = 'workspace-nav-rail';
const COLLAPSE_KEY = 'loyaly.sidenav.collapsed';
interface SidebarValue {
/** True below 640px, where the nav is an overlay rather than a column. */

View File

@@ -0,0 +1,15 @@
/**
* The sidebar's persistence key, in a module of its own.
*
* It is needed in two unrelated places — `SidebarProvider`, which reads and
* writes it, and the sign-out path, which clears it — and importing a client
* provider into the session provider just to reach a string would couple two
* layers that have no other business with each other.
*
* `localStorage`, deliberately: a collapsed rail is a per-DEVICE preference,
* not a per-tab one. Someone who collapses it in one tab expects it collapsed
* in the next. That is also why sign-out clears this key BY NAME rather than
* calling `localStorage.clear()` — the clear wiped every other signed-in tab's
* preferences along with it.
*/
export const SIDENAV_COLLAPSED_KEY = 'loyaly.sidenav.collapsed';

View File

@@ -1,5 +1,9 @@
import type {ApiResponse, RangeKey} from '@/shared/types/api';
import {isFailure} from '@/shared/types/api';
import {
TAB_ID_HEADER,
TAB_ID_STORAGE_KEY,
} from '@/features/auth/services/tabScope';
/**
* The backend-swap seam. Nothing above this file knows a URL.
@@ -60,6 +64,9 @@ export async function fetchEndpoint<T>(
signal,
cache: 'no-store',
credentials: 'same-origin',
// Names the tab, so a background tab reads its own session rather than the
// focused tab's. See tabScope.ts.
headers: tabHeaders(),
});
// A non-JSON body (proxy error page, 502 HTML) must surface as a typed
// failure rather than throwing a SyntaxError deep inside the hook.
@@ -92,6 +99,30 @@ export interface HttpResult<T> {
field?: string;
}
/**
* Which tab is asking.
*
* The BFF keeps one session per tab, in a cookie named after this id (see
* tabScope.ts), so every request has to say which one it wants. Without it the
* server falls back to the `loyaly_tab` pointer — correct for the focused tab,
* wrong for a background tab polling in the same browser, which is exactly the
* case this header exists to get right.
*
* Read fresh per request rather than cached at module scope: the inline script
* in the root layout writes it before any of this loads, but a tab whose
* storage was cleared mid-session would otherwise keep sending a dead id.
* Returns nothing when storage throws (private mode), and the server degrades
* to the pointer — the same fail-open the script takes.
*/
function tabHeaders(): Record<string, string> {
try {
const id = window.sessionStorage.getItem(TAB_ID_STORAGE_KEY);
return id ? {[TAB_ID_HEADER]: id} : {};
} catch {
return {};
}
}
async function request<T>(
path: string,
init?: RequestInit,
@@ -102,6 +133,7 @@ async function request<T>(
cache: 'no-store',
credentials: 'same-origin',
...init,
headers: {...tabHeaders(), ...(init?.headers as Record<string, string>)},
});
} catch (err) {
// Offline, DNS, CORS — there is no status to report, so 0 stands for