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