admin login issue
This commit is contained in:
@@ -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`.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
35
src/app/(admin)/admin/page.tsx
Normal file
35
src/app/(admin)/admin/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type {Metadata} from 'next';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {CompaniesPanel} from '@/features/admin/components/CompaniesPanel';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Platform admin · Loyaly',
|
||||
};
|
||||
|
||||
/**
|
||||
* The platform console.
|
||||
*
|
||||
* Company administration and nothing else — that is the whole of the platform's
|
||||
* admin surface upstream (`/api/admin/*`), and this page deliberately does not
|
||||
* grow past it. There is no endpoint to browse a tenant's visitors, cameras,
|
||||
* reports or shops, and no impersonation: seeing a company's data means signing
|
||||
* in as that company's owner, which is a different decision with a different
|
||||
* audit trail.
|
||||
*/
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<VStack gap={1}>
|
||||
<Text type="display-3" weight="medium">
|
||||
Platform admin
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
Create, suspend and remove the merchant companies on this platform.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
<CompaniesPanel />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
23
src/app/(admin)/layout.tsx
Normal file
23
src/app/(admin)/layout.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import {AdminLayout} from '@/features/admin/components/AdminLayout';
|
||||
|
||||
/**
|
||||
* The route-group boundary for the platform console.
|
||||
*
|
||||
* Deliberately NOT `(workspace)`. That group wraps everything in
|
||||
* `ProtectedLayout` → `WorkspaceShell`: a site switcher, tenant navigation and
|
||||
* the Loyaly AI rail, every one of which is scoped to a company. A platform
|
||||
* operator has no company, so that shell would render a store picker with
|
||||
* nothing in it above a page about somebody else's stores.
|
||||
*
|
||||
* What the two groups DO share is the session: `AdminLayout` uses the same
|
||||
* `AuthGuard` as the workspace, reading the same cookie minted by the same
|
||||
* login route. There is one authentication system in this app, and this is not
|
||||
* a second one.
|
||||
*/
|
||||
export default function AdminRouteLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <AdminLayout>{children}</AdminLayout>;
|
||||
}
|
||||
36
src/app/api/admin/clients/[id]/owner-password/route.ts
Normal file
36
src/app/api/admin/clients/[id]/owner-password/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/admin/clients/{id}/owner-password — reset an owner's password.
|
||||
*
|
||||
* The support case: the owner has locked themselves out and there is nobody
|
||||
* above them in the company to reset it. The new password is GENERATED, never
|
||||
* chosen, and every session that owner held is revoked.
|
||||
*
|
||||
* `email` picks the owner when the company has more than one; with exactly one
|
||||
* it may be omitted, and the UI omits it first. With several owners and no
|
||||
* address the platform answers 400 listing them — that message travels through
|
||||
* `failResponse` intact, which is what lets the dialog ask "which owner?"
|
||||
* without this console needing its own endpoint to enumerate them.
|
||||
*
|
||||
* ── The response body is a credential ────────────────────────────────────
|
||||
* It is shown once and cannot be fetched again. Nothing on this path may cache
|
||||
* it: `proxyUpstream` sets `cache-control: no-store` on every response it
|
||||
* writes, which is what keeps it out of a CDN, a browser disk cache and the
|
||||
* back button. It is never logged here, and never reaches a URL — it travels in
|
||||
* a POST response body and nowhere else.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, body) => {
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
||||
return adminApi.resetOwnerPassword(token, id, email || undefined);
|
||||
});
|
||||
}
|
||||
77
src/app/api/admin/clients/[id]/route.ts
Normal file
77
src/app/api/admin/clients/[id]/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toCompany} from '@/features/admin/services/mapCompany';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/clients/{id} — suspend or reinstate a company.
|
||||
*
|
||||
* Suspension is complete the moment this returns: the company's users cannot
|
||||
* sign in, every session they hold is revoked in the same transaction, and
|
||||
* visits from its shop PCs are dropped at ingest. Reinstating does not restore
|
||||
* sessions — people sign in again.
|
||||
*
|
||||
* The count of revoked sessions is surfaced rather than swallowed. "Suspended"
|
||||
* alone leaves an operator wondering whether somebody is still signed in on a
|
||||
* shop PC; "suspended, 3 sessions ended" answers it.
|
||||
*
|
||||
* `active` is read strictly as a boolean. A missing or non-boolean value is
|
||||
* forwarded as-is so the platform's own 400 (`"active" is required: true to
|
||||
* reinstate, false to suspend`) is what the operator reads, rather than a
|
||||
* second, differently-worded validation invented here.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => adminApi.setClientActive(token, id, body.active as boolean),
|
||||
{
|
||||
map: (res) => ({
|
||||
company: toCompany(res.client),
|
||||
sessionsRevoked: res.sessions_revoked,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/clients/{id} — permanent, and the data is biometric.
|
||||
*
|
||||
* Two conditions, both the PLATFORM's and neither enforced here: the company
|
||||
* must already be suspended (`409 still_active` otherwise) and the body must
|
||||
* repeat its slug. The dialog mirrors them so nobody is surprised, but this
|
||||
* route forwards whatever it is given — an active company is sent and the real
|
||||
* 409 comes back. A console that pre-empted the check would eventually disagree
|
||||
* with the server about what is deletable, and the disagreement would surface
|
||||
* as a delete that "worked" in the UI and did not happen.
|
||||
*
|
||||
* Upstream order matters if this fails: face images go from object storage
|
||||
* first (`502 storage_error` leaves everything else untouched), then the shop
|
||||
* PCs' broker logins, then every row by cascade.
|
||||
*
|
||||
* ── Why `confirm` arrives as a query parameter ───────────────────────────
|
||||
* The platform wants it in the body, and this route puts it there. It cannot
|
||||
* arrive that way, though: `proxyUpstream` does not read a body on DELETE (a
|
||||
* DELETE legitimately has none), and the browser-side `deleteJson` cannot send
|
||||
* one either. So it travels as a query parameter on THIS origin's request and
|
||||
* is moved into the body on the way upstream.
|
||||
*
|
||||
* Safe to put in a URL, unlike anything else on this screen: a slug is the
|
||||
* company's public identifier, already visible in the list and in every broker
|
||||
* topic. It is a confirmation, not a credential — it proves the operator typed
|
||||
* the right name, and it protects nothing on its own.
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, _body, query) =>
|
||||
adminApi.deleteClient(token, id, query.get('confirm') ?? ''),
|
||||
);
|
||||
}
|
||||
62
src/app/api/admin/clients/route.ts
Normal file
62
src/app/api/admin/clients/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
|
||||
import {toCompany} from '@/features/admin/services/mapCompany';
|
||||
import type {ApiNewClientInput} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* The companies on this platform.
|
||||
*
|
||||
* ── Why this proxy exists at all ─────────────────────────────────────────
|
||||
* The browser could not call `mcp.loyaly.ai/api/admin/clients` directly even if
|
||||
* we wanted it to: the platform sends no CORS headers, so a cross-origin fetch
|
||||
* from this console is blocked before it leaves. Routing through the BFF is not
|
||||
* a workaround for that — it is the reason the platform can afford to send no
|
||||
* CORS headers. The access token stays in an httpOnly cookie this page's
|
||||
* JavaScript cannot read, so an XSS on this origin cannot steal a platform
|
||||
* session.
|
||||
*
|
||||
* Authorisation is NOT re-implemented here. `withUpstream` attaches whatever
|
||||
* token the session holds and the platform decides: `adminOnly` answers 404 to
|
||||
* anyone who is not a platform operator. A merchant who reached this route
|
||||
* would get that 404 translated into `not_found`, not a list of tenants.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => adminApi.listClients(token),
|
||||
(rows) => rows.map(toCompany),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/clients — create a company and its owner, in one transaction.
|
||||
*
|
||||
* Answers **201**, and the body carries the owner's generated password. That is
|
||||
* the only time it exists in readable form: it is bcrypt-hashed on the way in
|
||||
* and cannot be fetched again.
|
||||
*
|
||||
* `password` is never forwarded from the client, even if one were sent. Empty
|
||||
* means "generate one", which is the better default — an operator typing a
|
||||
* password for somebody else invents a weak one and then sends it over chat.
|
||||
* `slug` is forwarded only when non-empty; the platform derives it from the
|
||||
* name otherwise, and it can never be changed afterwards.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => {
|
||||
const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
|
||||
const input: ApiNewClientInput = {
|
||||
company_name: String(body.company_name ?? '').trim(),
|
||||
owner_email: String(body.owner_email ?? '').trim(),
|
||||
owner_name: String(body.owner_name ?? '').trim(),
|
||||
};
|
||||
if (slug) input.slug = slug;
|
||||
return adminApi.createClient(token, input);
|
||||
},
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -107,15 +107,16 @@ export default async function RootLayout({
|
||||
>
|
||||
<body>
|
||||
{/*
|
||||
FIRST child of <body>, and that position is the whole point: it runs
|
||||
before the markup below it is parsed, so a tab that inherited the
|
||||
cookies without owning the session never paints the workspace. An
|
||||
FIRST child of <body>, and that position is the whole point: it gives
|
||||
this tab its id and points the cookie at it before the markup below is
|
||||
parsed, so the very first navigation is rendered as the right user. An
|
||||
effect inside Providers would run after the first paint instead.
|
||||
See services/tabSession.ts for what it does and why it fails open.
|
||||
|
||||
It no longer takes the session: it does not decide anything about
|
||||
being signed in, and nothing in it signs anybody out. See
|
||||
services/tabSession.ts for what it replaced and why.
|
||||
*/}
|
||||
<script
|
||||
dangerouslySetInnerHTML={{__html: tabSessionScript(session !== null)}}
|
||||
/>
|
||||
<script dangerouslySetInnerHTML={{__html: tabSessionScript()}} />
|
||||
<Providers initialSession={session} initialThemeMode={themeMode}>
|
||||
{children}
|
||||
</Providers>
|
||||
|
||||
82
src/features/admin/components/AdminLayout.tsx
Normal file
82
src/features/admin/components/AdminLayout.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client';
|
||||
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {AuthGuard} from '@/features/auth/guards/AuthGuard';
|
||||
import {BrandMark} from '@/shared/components/brand/BrandLogo';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
|
||||
/**
|
||||
* The platform console's frame.
|
||||
*
|
||||
* ── Same guard, different shell ──────────────────────────────────────────
|
||||
* `AuthGuard` is the one the workspace uses, reading the session minted by the
|
||||
* same login route. What is deliberately absent is `WorkspaceShell`: no site
|
||||
* switcher, no tenant navigation, no Loyaly AI rail. Every one of those is
|
||||
* scoped to a company, and a platform operator has none — a store picker with
|
||||
* nothing in it is worse than no store picker.
|
||||
*
|
||||
* ── Why it says "Platform" so loudly ─────────────────────────────────────
|
||||
* This console can suspend a company and delete one irreversibly. Somebody who
|
||||
* has both consoles open needs to know which tab they are typing into before
|
||||
* they click, not after. The badge is the cheapest possible version of that.
|
||||
*/
|
||||
export function AdminLayout({children}: {children: React.ReactNode}) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<VStack gap={0} width="100%" minHeight="100vh">
|
||||
<AdminTopBar />
|
||||
<VStack gap={0} width="100%" paddingInline={8} paddingBlock={8}>
|
||||
{children}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminTopBar() {
|
||||
const {user, logout} = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
router.replace('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<HStack
|
||||
gap={4}
|
||||
width="100%"
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
paddingInline={8}
|
||||
paddingBlock={4}
|
||||
className="border-b border-border bg-surface"
|
||||
>
|
||||
<HStack gap={3} vAlign="center">
|
||||
<BrandMark size={24} />
|
||||
<Text size="sm" weight="medium">
|
||||
Platform
|
||||
</Text>
|
||||
<Badge variant="neutral" label="Admin" />
|
||||
</HStack>
|
||||
|
||||
<HStack gap={3} vAlign="center">
|
||||
{/* The address, not the display name: on a console that can delete a
|
||||
company, which ACCOUNT is signed in matters more than whose it is. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
{user?.email}
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void signOut()}
|
||||
label="Sign out"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
278
src/features/admin/components/CompaniesPanel.tsx
Normal file
278
src/features/admin/components/CompaniesPanel.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
} from '@astryxdesign/core/SegmentedControl';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {
|
||||
useCompanies,
|
||||
useCompanyFilters,
|
||||
} from '@/features/admin/hooks/useCompanies';
|
||||
import {CreateCompanyDialog} from './CreateCompanyDialog';
|
||||
import {SuspendCompanyDialog} from './SuspendCompanyDialog';
|
||||
import {ResetOwnerPasswordDialog} from './ResetOwnerPasswordDialog';
|
||||
import {DeleteCompanyDialog} from './DeleteCompanyDialog';
|
||||
import type {Company, CompanyFilter} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Every company on the platform, and the four things an operator can do to one.
|
||||
*
|
||||
* ── The columns are exactly what the API returns ─────────────────────────
|
||||
* Name, slug, shops, accounts, status, created. `GET /api/admin/clients`
|
||||
* returns those seven fields and nothing else, so there is no owner column, no
|
||||
* plan, no revenue and no health. A column this list cannot fill would have to
|
||||
* be faked, and a faked column on the console that can delete a company is the
|
||||
* worst place in the product to put one.
|
||||
*
|
||||
* ── Why suspended rows are not hidden ────────────────────────────────────
|
||||
* A suspended company is the one an operator is most likely to be looking for
|
||||
* — it is the one somebody is on the phone about. It stays in the default list
|
||||
* with its status shown, and the filter is there to narrow deliberately.
|
||||
*/
|
||||
|
||||
interface CompanyRow extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
sites: number;
|
||||
users: number;
|
||||
status: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
function toRow(c: Company): CompanyRow {
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
slug: c.slug,
|
||||
sites: c.sites,
|
||||
users: c.users,
|
||||
status: c.isActive ? 'Active' : 'Suspended',
|
||||
// Date only. A platform operator cares which week a tenant was onboarded,
|
||||
// never which minute.
|
||||
created: new Date(c.createdAt).toLocaleDateString(),
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<CompanyRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Company',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
{/* The slug is shown on every row because it is what the delete
|
||||
confirmation asks for, and hunting for it afterwards is how
|
||||
somebody ends up copying the wrong one. */}
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.slug}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{key: 'sites', header: 'Shops', width: proportional(1), align: 'end'},
|
||||
{key: 'users', header: 'Accounts', width: proportional(1), align: 'end'},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={row.status === 'Active' ? 'success' : 'error'}
|
||||
label={row.status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.status}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{key: 'created', header: 'Created', width: proportional(1), align: 'end'},
|
||||
];
|
||||
|
||||
type Action =
|
||||
| {kind: 'create'}
|
||||
| {kind: 'suspend'; company: Company}
|
||||
| {kind: 'reset'; company: Company}
|
||||
| {kind: 'delete'; company: Company}
|
||||
| null;
|
||||
|
||||
export function CompaniesPanel() {
|
||||
const companies = useCompanies();
|
||||
const {query, setQuery, filter, setFilter, visible} = useCompanyFilters(
|
||||
companies.data,
|
||||
);
|
||||
const [action, setAction] = useState<Action>(null);
|
||||
|
||||
/**
|
||||
* Nothing optimistic. Every mutation refetches and the table redraws from
|
||||
* what the server says — a row that claimed to be suspended because a PATCH
|
||||
* was sent, while the platform refused it, is exactly the lie this console
|
||||
* cannot afford.
|
||||
*/
|
||||
function refresh() {
|
||||
companies.refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack gap={6} width="100%">
|
||||
<PanelCard
|
||||
title="Companies"
|
||||
subtitle="Every merchant company on this platform."
|
||||
resource={companies}
|
||||
loading={<SkeletonRows count={5} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No companies yet"
|
||||
description="Create your first merchant company to get started."
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAction({kind: 'create'})}
|
||||
label="Create company"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
actions={
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setAction({kind: 'create'})}
|
||||
label="Create company"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(all) => (
|
||||
<VStack gap={4} width="100%">
|
||||
<HStack gap={3} vAlign="center" hAlign="between">
|
||||
<TextInput
|
||||
label="Search companies"
|
||||
isLabelHidden
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder="Search by name or slug…"
|
||||
/>
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(v) => setFilter(v as CompanyFilter)}
|
||||
label="Filter by status"
|
||||
>
|
||||
<SegmentedControlItem value="all" label="All" />
|
||||
<SegmentedControlItem value="active" label="Active" />
|
||||
<SegmentedControlItem value="suspended" label="Suspended" />
|
||||
</SegmentedControl>
|
||||
</HStack>
|
||||
|
||||
{visible.length === 0 ? (
|
||||
/* A search that matches nothing is NOT the same as a platform
|
||||
with no companies — saying "no companies yet" here would tell
|
||||
an operator their tenants had vanished. */
|
||||
<Text size="sm" color="secondary">
|
||||
No companies match that search. {all.length}{' '}
|
||||
{all.length === 1 ? 'company' : 'companies'} in total.
|
||||
</Text>
|
||||
) : (
|
||||
<ResponsiveTable
|
||||
data={visible.map(toRow)}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
summaryKeys={['status', 'sites']}
|
||||
columns={COLUMNS}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
{visible.map((c) => (
|
||||
<HStack
|
||||
key={c.id}
|
||||
gap={2}
|
||||
vAlign="center"
|
||||
hAlign="between"
|
||||
width="100%"
|
||||
>
|
||||
<Text size="xsm" color="secondary">
|
||||
{c.name}
|
||||
</Text>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAction({kind: 'reset', company: c})}
|
||||
label="Reset owner password"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setAction({kind: 'suspend', company: c})}
|
||||
label={c.isActive ? 'Suspend' : 'Reinstate'}
|
||||
/>
|
||||
{/* Delete is offered only on a suspended company, because
|
||||
the platform refuses it otherwise. The dialog still
|
||||
sends an active one and shows the real 409 if this is
|
||||
ever reached another way — the guard is a courtesy,
|
||||
never the rule. */}
|
||||
{c.isActive ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => setAction({kind: 'delete', company: c})}
|
||||
label="Delete"
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{action?.kind === 'create' ? (
|
||||
<CreateCompanyDialog
|
||||
onClose={() => setAction(null)}
|
||||
onCreated={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'suspend' ? (
|
||||
<SuspendCompanyDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
onDone={refresh}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'reset' ? (
|
||||
<ResetOwnerPasswordDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{action?.kind === 'delete' ? (
|
||||
<DeleteCompanyDialog
|
||||
company={action.company}
|
||||
onClose={() => setAction(null)}
|
||||
onDeleted={refresh}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
176
src/features/admin/components/CreateCompanyDialog.tsx
Normal file
176
src/features/admin/components/CreateCompanyDialog.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
|
||||
/**
|
||||
* Create a company and its owner, in one transaction.
|
||||
*
|
||||
* ── Why the owner is not optional ────────────────────────────────────────
|
||||
* The platform creates both together on purpose: a company with no owner is a
|
||||
* tenant nobody can sign into, and it looks entirely normal in the list. The
|
||||
* operator finds out weeks later, when the customer says their login does not
|
||||
* work. So this form asks for the owner up front rather than offering a
|
||||
* "add the owner later" path that upstream would not honour anyway.
|
||||
*
|
||||
* ── The password is not ours to choose ───────────────────────────────────
|
||||
* There is no password field, and adding one would be a mistake even though
|
||||
* the API accepts it. An operator inventing a password for somebody else
|
||||
* invents a weak one and then sends it over chat. The server generates it and
|
||||
* returns it exactly once — it is bcrypt-hashed on the way in and cannot be
|
||||
* fetched again — so the reveal below is the only chance to copy it.
|
||||
*
|
||||
* ── Slug ─────────────────────────────────────────────────────────────────
|
||||
* Optional, and left blank by default. The platform derives it from the name,
|
||||
* strips the characters that would break a broker topic, and then it can NEVER
|
||||
* be changed. A field that permanent deserves to be a deliberate act, not a
|
||||
* box somebody fills in because it is there.
|
||||
*/
|
||||
export function CreateCompanyDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
/** Fired once the list should refetch — on success, and on close after one. */
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [companyName, setCompanyName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
const [ownerName, setOwnerName] = useState('');
|
||||
const [ownerEmail, setOwnerEmail] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [created, setCreated] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
const canSubmit =
|
||||
companyName.trim() !== '' && ownerEmail.trim() !== '' && !busy;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.create({
|
||||
company_name: companyName.trim(),
|
||||
owner_email: ownerEmail.trim(),
|
||||
owner_name: ownerName.trim(),
|
||||
...(slug.trim() ? {slug: slug.trim()} : {}),
|
||||
});
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
// The platform's own wording. It names the rule that was hit — a
|
||||
// duplicate address, a slug already taken — and "Something went wrong"
|
||||
// would throw that away.
|
||||
setError(res.message ?? 'Could not create that company.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreated({email: res.data.ownerEmail, password: res.data.password});
|
||||
// The company exists now, whether or not they close this dialog politely.
|
||||
onCreated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closing is what discards the password.
|
||||
*
|
||||
* It lives in this component's state and nowhere else: not in the URL, not in
|
||||
* localStorage, not in a toast that outlives the dialog, and never in a log.
|
||||
* Unmounting drops it, which is the strongest guarantee available on a page
|
||||
* — and the reason the warning says to copy it first.
|
||||
*/
|
||||
function close() {
|
||||
setCreated(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Create company"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={created ? 'Company created' : 'Create company'}
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
/>
|
||||
|
||||
{created ? (
|
||||
<VStack gap={4} width="100%">
|
||||
<Banner
|
||||
status="success"
|
||||
title={companyName.trim()}
|
||||
description={`Owner account created for ${created.email}.`}
|
||||
/>
|
||||
<SecretOnce
|
||||
value={created.password}
|
||||
label={`Password for ${created.email}`}
|
||||
note="Send it to them now. It cannot be shown again, and closing this dialog discards it."
|
||||
/>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button onClick={close} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack gap={4} width="100%">
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<TextInput
|
||||
label="Company name"
|
||||
value={companyName}
|
||||
onChange={setCompanyName}
|
||||
placeholder="TeNext Retail"
|
||||
/>
|
||||
<TextInput
|
||||
label="Slug (optional)"
|
||||
value={slug}
|
||||
onChange={setSlug}
|
||||
placeholder="Derived from the name"
|
||||
description="Permanent once set — it becomes part of the company's broker topic."
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner email"
|
||||
type="email"
|
||||
value={ownerEmail}
|
||||
onChange={setOwnerEmail}
|
||||
placeholder="owner@company.com"
|
||||
/>
|
||||
<TextInput
|
||||
label="Owner name"
|
||||
value={ownerName}
|
||||
onChange={setOwnerName}
|
||||
placeholder="Their full name"
|
||||
/>
|
||||
|
||||
<Text size="xsm" color="secondary">
|
||||
The owner's password is generated by the server and shown
|
||||
once, here, immediately after this succeeds.
|
||||
</Text>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={close} label="Cancel" />
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!canSubmit}
|
||||
isLoading={busy}
|
||||
label="Create company"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
125
src/features/admin/components/DeleteCompanyDialog.tsx
Normal file
125
src/features/admin/components/DeleteCompanyDialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Delete a company. Permanently.
|
||||
*
|
||||
* ── Why the typed slug ───────────────────────────────────────────────────
|
||||
* The platform requires the body to repeat the company's slug, and this dialog
|
||||
* asks for it rather than filling it in. That is the whole safeguard: a
|
||||
* confirm button can be clicked by muscle memory on the wrong row, and typing
|
||||
* "tenext-retail" cannot. The button stays disabled until it matches exactly —
|
||||
* no trimming of case, because the slug is lowercase upstream and a console
|
||||
* that quietly "fixes" what was typed is not confirming anything.
|
||||
*
|
||||
* ── Why the suspended check is NOT enforced here ─────────────────────────
|
||||
* The platform refuses to delete an active company (`409 still_active`), and
|
||||
* this dialog does not pre-empt that. It says so plainly, and the button is
|
||||
* still live: an active company is sent, and the platform's real 409 is what
|
||||
* appears. A console that blocked the request itself would eventually disagree
|
||||
* with the server about what is deletable, and that disagreement surfaces as a
|
||||
* delete that looked like it worked and did not happen.
|
||||
*
|
||||
* ── What is actually destroyed ───────────────────────────────────────────
|
||||
* Stored face images go from object storage first — a failure there is
|
||||
* `502 storage_error` and nothing else is touched — then the shop PCs' broker
|
||||
* logins, then every row by cascade: face templates, visits, users, sessions,
|
||||
* cameras. The data is biometric. There is no undo and no backup to ask for.
|
||||
*/
|
||||
export function DeleteCompanyDialog({
|
||||
company,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const matches = confirm === company.slug;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.remove(company.id, confirm);
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok) {
|
||||
// `still_active` and `storage_error` both name a condition the operator
|
||||
// can act on — suspend it first, or retry once storage is healthy.
|
||||
setError(res.message ?? 'Could not delete that company.');
|
||||
return;
|
||||
}
|
||||
|
||||
onDeleted();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={460}
|
||||
aria-label="Delete company"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title="Delete company?"
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<Banner
|
||||
status="error"
|
||||
title="This cannot be undone"
|
||||
description={`Deletes ${company.name}, its ${company.sites} ${
|
||||
company.sites === 1 ? 'shop' : 'shops'
|
||||
}, its ${company.users} ${
|
||||
company.users === 1 ? 'account' : 'accounts'
|
||||
}, and every stored face image, visit and camera. The data is biometric and there is no backup to restore from.`}
|
||||
/>
|
||||
|
||||
{company.isActive ? (
|
||||
<Banner
|
||||
status="warning"
|
||||
title="Still active"
|
||||
description="The platform will refuse this until the company is suspended. Suspend it first, then come back."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TextInput
|
||||
label="Type the company slug to confirm"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder={company.slug}
|
||||
description={`Exactly: ${company.slug}`}
|
||||
/>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isDisabled={!matches || busy}
|
||||
isLoading={busy}
|
||||
label="Delete company"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
149
src/features/admin/components/ResetOwnerPasswordDialog.tsx
Normal file
149
src/features/admin/components/ResetOwnerPasswordDialog.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Generate a new password for a company's owner.
|
||||
*
|
||||
* ── The support case this exists for ─────────────────────────────────────
|
||||
* The owner has locked themselves out and there is nobody above them inside
|
||||
* the company to reset it. Every other account is reset by their own owner;
|
||||
* this is the one that cannot be.
|
||||
*
|
||||
* ── Why the email box starts empty and hidden ────────────────────────────
|
||||
* With exactly one owner the platform does not need to be told which — and
|
||||
* that is the common case, so asking would be a field nobody can answer
|
||||
* usefully. With several owners it answers 400 and NAMES them. That message is
|
||||
* shown verbatim and the box appears, so the operator picks from the
|
||||
* platform's own list. This console never enumerates owners itself: there is
|
||||
* no endpoint for it, and inventing one would mean guessing.
|
||||
*
|
||||
* ── The password ─────────────────────────────────────────────────────────
|
||||
* Generated, never chosen. Shown once — it is bcrypt-hashed upstream and
|
||||
* cannot be fetched again. It lives in this component's state and nowhere
|
||||
* else, and closing the dialog discards it. Resetting also revokes every
|
||||
* session that owner held, so they are signed out wherever they were.
|
||||
*/
|
||||
export function ResetOwnerPasswordDialog({
|
||||
company,
|
||||
onClose,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [needsEmail, setNeedsEmail] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{
|
||||
email: string;
|
||||
password: string;
|
||||
} | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.resetOwnerPassword(
|
||||
company.id,
|
||||
email.trim() || undefined,
|
||||
);
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
// A 400 here is the platform saying "which owner?" and listing them.
|
||||
// Surface it word for word and reveal the box — a generic failure would
|
||||
// hide the one piece of information needed to succeed on the retry.
|
||||
if (res.status === 400) setNeedsEmail(true);
|
||||
setError(res.message ?? 'Could not reset that password.');
|
||||
return;
|
||||
}
|
||||
|
||||
setResult({email: res.data.email, password: res.data.password});
|
||||
}
|
||||
|
||||
function close() {
|
||||
setResult(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
purpose="info"
|
||||
width={460}
|
||||
aria-label="Reset owner password"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={result ? 'Password reset' : 'Reset owner password?'}
|
||||
onOpenChange={(open) => (open ? undefined : close())}
|
||||
/>
|
||||
|
||||
{result ? (
|
||||
<VStack gap={4} width="100%">
|
||||
<SecretOnce
|
||||
value={result.password}
|
||||
label={`New password for ${result.email}`}
|
||||
note="Send it to them now. It cannot be shown again, and closing this dialog discards it."
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
Every session that owner held has been revoked. They are signed
|
||||
out everywhere and will need this password to get back in.
|
||||
</Text>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button onClick={close} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : (
|
||||
<VStack gap={4} width="100%">
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<Text size="sm" weight="medium">
|
||||
{company.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
A new password is generated for the owner and shown once. Their
|
||||
existing sessions are revoked, so they are signed out
|
||||
immediately.
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{needsEmail ? (
|
||||
<TextInput
|
||||
label="Owner email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
placeholder="owner@company.com"
|
||||
description="This company has more than one owner — name the one to reset."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={close} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isLoading={busy}
|
||||
isDisabled={busy || (needsEmail && email.trim() === '')}
|
||||
label="Reset password"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
112
src/features/admin/components/SuspendCompanyDialog.tsx
Normal file
112
src/features/admin/components/SuspendCompanyDialog.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Suspend or reinstate a company.
|
||||
*
|
||||
* ── Why suspension is confirmed and reinstatement is too ─────────────────
|
||||
* Suspension is not a flag that takes effect at next sign-in: the platform
|
||||
* revokes every session the company holds in the same transaction, so a
|
||||
* manager halfway through a shift on a shop PC is signed out mid-task, and
|
||||
* visits from its shop PCs are dropped at ingest from that moment. That is
|
||||
* worth a sentence and a second click.
|
||||
*
|
||||
* Reinstatement is confirmed as well, for a quieter reason: it does NOT restore
|
||||
* the sessions it ended. Everybody signs in again. An operator who expects
|
||||
* "undo" should be told that before they click, not after the support call.
|
||||
*
|
||||
* ── The count is reported, not swallowed ─────────────────────────────────
|
||||
* "Suspended" on its own leaves the operator wondering whether somebody is
|
||||
* still signed in somewhere. `sessions_revoked` answers it, and it comes from
|
||||
* the platform's own transaction — this console does not count anything itself
|
||||
* and must not: a local tally would be a guess dressed as a fact.
|
||||
*/
|
||||
export function SuspendCompanyDialog({
|
||||
company,
|
||||
onClose,
|
||||
onDone,
|
||||
}: {
|
||||
company: Company;
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const suspending = company.isActive;
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
const res = await companyRepository.setActive(company.id, !company.isActive);
|
||||
setBusy(false);
|
||||
|
||||
if (!res.ok || !res.data) {
|
||||
setError(
|
||||
res.message ??
|
||||
(suspending
|
||||
? 'Could not suspend that company.'
|
||||
: 'Could not reinstate that company.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onDone();
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose={suspending ? 'required' : 'info'}
|
||||
width={440}
|
||||
aria-label={suspending ? 'Suspend company' : 'Reinstate company'}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={suspending ? 'Suspend company?' : 'Reinstate company?'}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
|
||||
<VStack gap={2} width="100%">
|
||||
<Text size="sm" weight="medium">
|
||||
{company.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{suspending
|
||||
? `Nobody at ${company.name} will be able to sign in. Every session they hold is ended immediately — including anyone signed in on a shop PC right now — and visits from their shops stop being recorded.`
|
||||
: `${company.name} will be able to sign in again. Sessions ended by the suspension are not restored, so everyone signs in fresh.`}
|
||||
</Text>
|
||||
{suspending && company.users > 0 ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{company.users} {company.users === 1 ? 'person' : 'people'} and{' '}
|
||||
{company.sites} {company.sites === 1 ? 'shop' : 'shops'} are
|
||||
affected.
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant={suspending ? 'destructive' : 'primary'}
|
||||
onClick={() => void submit()}
|
||||
isLoading={busy}
|
||||
label={suspending ? 'Suspend company' : 'Reinstate company'}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
56
src/features/admin/hooks/useCompanies.ts
Normal file
56
src/features/admin/hooks/useCompanies.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {companyRepository} from '@/features/admin/repositories/companyRepository';
|
||||
import type {Company, CompanyFilter} from '@/features/admin/types/company';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
/**
|
||||
* The company list, and the search and filter applied over it.
|
||||
*
|
||||
* ── Why the gate is on `isPlatformAdmin` and not just a session ──────────
|
||||
* `GET /api/admin/clients` answers 404 to a merchant — that is the platform's
|
||||
* rule, and it is the right one. But firing the request anyway would put a
|
||||
* guaranteed 404 in the console of anyone whose session is not an operator's,
|
||||
* for a question whose answer is already known here. Passing `null` holds the
|
||||
* request entirely; `useResource` returns early and nothing is sent.
|
||||
*
|
||||
* This is not the authorisation. The route answers 404 regardless of what this
|
||||
* hook believes, and the proxy refuses the page before it renders.
|
||||
*
|
||||
* ── Why search and filter are client-side ────────────────────────────────
|
||||
* The list endpoint takes no query parameters. Inventing `?q=` would mean
|
||||
* sending something the platform ignores and then filtering locally anyway,
|
||||
* while implying a server-side search that does not exist.
|
||||
*/
|
||||
export function useCompanies(): Resource<Company[]> {
|
||||
const {isAuthenticated, user} = useSession();
|
||||
const isOperator = isAuthenticated && user?.isPlatformAdmin === true;
|
||||
return useResource(isOperator ? companyRepository.list() : null);
|
||||
}
|
||||
|
||||
export function useCompanyFilters(companies: Company[] | undefined) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<CompanyFilter>('all');
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const rows = companies ?? [];
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
return rows.filter((c) => {
|
||||
if (filter === 'active' && !c.isActive) return false;
|
||||
if (filter === 'suspended' && c.isActive) return false;
|
||||
if (!q) return true;
|
||||
// Name and slug only — they are the two identifiers the list returns.
|
||||
// Matching on anything else would mean claiming a field the API does
|
||||
// not send.
|
||||
return (
|
||||
c.name.toLowerCase().includes(q) || c.slug.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [companies, query, filter]);
|
||||
|
||||
return {query, setQuery, filter, setFilter, visible};
|
||||
}
|
||||
79
src/features/admin/repositories/companyRepository.ts
Normal file
79
src/features/admin/repositories/companyRepository.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
deleteJson,
|
||||
patchJson,
|
||||
postJson,
|
||||
type Endpoint,
|
||||
} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
Company,
|
||||
CompanyActiveResult,
|
||||
NewCompany,
|
||||
OwnerPassword,
|
||||
} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* The companies on this platform.
|
||||
*
|
||||
* Every path here is a BFF route on this origin, never `mcp.loyaly.ai`. The
|
||||
* browser holds no platform token and could not reach that host anyway — it
|
||||
* sends no CORS headers. The token lives in an httpOnly cookie the BFF reads
|
||||
* server-side.
|
||||
*
|
||||
* Unscoped: platform administration has no site or date scope. There is no
|
||||
* `?site=` on any of these, and adding one would be meaningless — a platform
|
||||
* operator has no tenant for a scope to select within.
|
||||
*/
|
||||
export const companyRepository = {
|
||||
list: (): Endpoint<Company[]> => ({path: '/api/admin/clients', params: {}}),
|
||||
|
||||
/**
|
||||
* Creates the company and its owner together, and hands back the owner's
|
||||
* generated password ONCE.
|
||||
*
|
||||
* `password` is deliberately absent from the body: the server generates it.
|
||||
* `slug` is optional — omitted, the platform derives it from the name, and
|
||||
* once set it can never be changed.
|
||||
*/
|
||||
create: (body: {
|
||||
company_name: string;
|
||||
owner_email: string;
|
||||
owner_name: string;
|
||||
slug?: string;
|
||||
}) => postJson<NewCompany>('/api/admin/clients', body),
|
||||
|
||||
/**
|
||||
* Suspend (`false`) or reinstate (`true`).
|
||||
*
|
||||
* The result carries `sessionsRevoked`, which the UI reports. Suspension
|
||||
* ends the company's live sessions upstream; reinstating does not bring them
|
||||
* back, so that number is 0 on the way in.
|
||||
*/
|
||||
setActive: (id: string, active: boolean) =>
|
||||
patchJson<CompanyActiveResult>(
|
||||
`/api/admin/clients/${encodeURIComponent(id)}`,
|
||||
{active},
|
||||
),
|
||||
|
||||
/**
|
||||
* Generate a new password for the company's owner. Shown once.
|
||||
*
|
||||
* `email` is omitted when the company has one owner. With several and no
|
||||
* address the platform answers 400 and names them — that message is what the
|
||||
* dialog shows, so this console never needs to enumerate owners itself.
|
||||
*/
|
||||
resetOwnerPassword: (id: string, email?: string) =>
|
||||
postJson<OwnerPassword>(
|
||||
`/api/admin/clients/${encodeURIComponent(id)}/owner-password`,
|
||||
email ? {email} : {},
|
||||
),
|
||||
|
||||
/**
|
||||
* Permanent. The company must already be suspended and `confirm` must repeat
|
||||
* its slug — both enforced by the platform, which answers 409 `still_active`
|
||||
* or 400 rather than letting this console decide.
|
||||
*/
|
||||
remove: (id: string, confirm: string) =>
|
||||
deleteJson<{deleted: string; images_deleted: number}>(
|
||||
`/api/admin/clients/${encodeURIComponent(id)}?confirm=${encodeURIComponent(confirm)}`,
|
||||
),
|
||||
};
|
||||
27
src/features/admin/services/mapCompany.ts
Normal file
27
src/features/admin/services/mapCompany.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type {ApiClientRow} from '@/services/api/types';
|
||||
import type {Company} from '@/features/admin/types/company';
|
||||
|
||||
/**
|
||||
* Platform `ClientRow` → the shape the admin screens consume.
|
||||
*
|
||||
* One translation, in one place — the same rule `toAuthUser` and `toSite`
|
||||
* follow. Renaming `created_at` at each call site is how two panels end up
|
||||
* disagreeing about what an empty value means.
|
||||
*
|
||||
* `active` is carried through as `isActive` rather than inverted into
|
||||
* `isSuspended`: the platform's field is the positive one, and a console that
|
||||
* negates a boolean on the way in eventually negates it twice somewhere.
|
||||
*/
|
||||
export function toCompany(c: ApiClientRow): Company {
|
||||
return {
|
||||
id: c.id,
|
||||
slug: c.slug,
|
||||
// Falls back to the slug rather than rendering an empty cell. A company
|
||||
// with no name is a data fault worth seeing, not worth hiding.
|
||||
name: c.name || c.slug,
|
||||
isActive: c.active,
|
||||
sites: c.sites,
|
||||
users: c.users,
|
||||
createdAt: c.created_at,
|
||||
};
|
||||
}
|
||||
59
src/features/admin/types/company.ts
Normal file
59
src/features/admin/types/company.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* A company on the platform, as the admin console consumes it.
|
||||
*
|
||||
* Mapped from the platform's `ClientRow` in one place (services/mapCompany) so
|
||||
* the upstream field names — `created_at`, `sites`, `users` — do not leak into
|
||||
* five components that would each rename them slightly differently.
|
||||
*
|
||||
* Nothing is invented here. Every field comes back from
|
||||
* `GET /api/admin/clients`; the admin surface upstream is company
|
||||
* administration and nothing else, so there is no owner name, no contact, no
|
||||
* revenue and no health. A column this list cannot fill is a column that would
|
||||
* have to be faked.
|
||||
*/
|
||||
export interface Company {
|
||||
id: string;
|
||||
/**
|
||||
* Immutable upstream — it becomes part of the company's message-broker
|
||||
* topic and can never be changed. Safe to key on, to put in a URL, and to
|
||||
* ask somebody to type back as a delete confirmation.
|
||||
*/
|
||||
slug: string;
|
||||
name: string;
|
||||
/** false = suspended: nobody in the company can sign in, and every session
|
||||
* they held was revoked at the moment it was switched off. */
|
||||
isActive: boolean;
|
||||
sites: number;
|
||||
users: number;
|
||||
/** ISO-8601, from the server's clock. */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** What a suspend/reinstate hands back — the row, and what it cost. */
|
||||
export interface CompanyActiveResult {
|
||||
company: Company;
|
||||
/** Sessions the platform revoked in the same transaction. 0 when
|
||||
* reinstating: suspension ends sessions, reinstatement does not restore
|
||||
* them. */
|
||||
sessionsRevoked: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two shapes that carry a password. Both are shown once and never stored:
|
||||
* the platform bcrypt-hashes on the way in and cannot show them again.
|
||||
*/
|
||||
export interface NewCompany {
|
||||
clientId: string;
|
||||
slug: string;
|
||||
ownerEmail: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface OwnerPassword {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** The status filter above the table. Client-side — the list endpoint takes
|
||||
* no query parameters, so filtering a fetched array is the honest option. */
|
||||
export type CompanyFilter = 'all' | 'active' | 'suspended';
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'react';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {authService} from '@/features/auth/services/authService';
|
||||
import {TAB_SESSION_KEY} from '@/features/auth/services/tabSession';
|
||||
import {SIDENAV_COLLAPSED_KEY} from '@/shared/layouts/workspace/sidebarStorage';
|
||||
import type {
|
||||
AuthSession,
|
||||
AuthUser,
|
||||
@@ -109,27 +109,6 @@ export function SessionProvider({
|
||||
async (credentials: LoginCredentials): Promise<LoginResult> => {
|
||||
const result = await authService.login(credentials);
|
||||
if (result.ok) {
|
||||
/**
|
||||
* Re-claim the tab for the session just created.
|
||||
*
|
||||
* Normally the inline script in the root layout has already done this
|
||||
* while /login rendered, so this is a no-op. It is load-bearing for one
|
||||
* path: `logout` below CLEARS sessionStorage, and the /login it then
|
||||
* navigates to is a client transition with no new document, so no
|
||||
* script runs to put the marker back. Without this line, signing out
|
||||
* and straight back in inside the same tab left it unmarked — and the
|
||||
* next full reload of the workspace read that as an inherited session
|
||||
* and signed the user out again.
|
||||
*
|
||||
* Wrapped for the same reason the clear in `logout` is: storage throws
|
||||
* in private mode, and a failed write must not fail a good sign-in.
|
||||
*/
|
||||
try {
|
||||
window.sessionStorage.setItem(TAB_SESSION_KEY, '1');
|
||||
} catch {
|
||||
/* ignore — the guard script fails open for the same reason */
|
||||
}
|
||||
|
||||
setSession(result.session);
|
||||
setStatus('authenticated');
|
||||
}
|
||||
@@ -144,18 +123,23 @@ export function SessionProvider({
|
||||
setStatus('unauthenticated');
|
||||
|
||||
/**
|
||||
* Belt and braces on the client side of the door.
|
||||
* Clear this tab's preferences — and ONLY this tab's.
|
||||
*
|
||||
* The cookie is already gone server-side, which is what actually ends the
|
||||
* session. This clears the *other* things a signed-in user leaves behind —
|
||||
* a stored sidebar preference, a cached scope — so a shared machine does
|
||||
* not hand the next person a workspace shaped like the last one. Wrapped
|
||||
* because storage throws in private mode, and a failed cleanup must not
|
||||
* strand someone in a session they asked to leave.
|
||||
* This used to be `localStorage.clear()` + `sessionStorage.clear()`.
|
||||
* `localStorage` is shared by the whole browser, so signing out of one tab
|
||||
* wiped the theme and sidebar state of every other signed-in tab; and
|
||||
* clearing `sessionStorage` wholesale would now also throw away this tab's
|
||||
* id, orphaning the cookie the logout route still has to find.
|
||||
*
|
||||
* Named keys instead. The session itself is already gone server-side, which
|
||||
* is what actually ends it — this is only about not handing the next person
|
||||
* at a shared machine a workspace shaped like the last one.
|
||||
*
|
||||
* Wrapped because storage throws in private mode, and a failed cleanup must
|
||||
* not strand someone in a session they asked to leave.
|
||||
*/
|
||||
try {
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.removeItem(SIDENAV_COLLAPSED_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
@@ -51,21 +51,16 @@ const LOGIN_ERRORS: Record<string, LoginError> = {
|
||||
field: 'form',
|
||||
message: 'Sign-in is unavailable right now. Please contact support.',
|
||||
},
|
||||
/**
|
||||
* Correct credentials for a PLATFORM ADMIN — an account with no company.
|
||||
* Every surface in this console is tenant-scoped, so there is nothing here
|
||||
* for one to open; the BFF declines to create a session rather than letting
|
||||
* them walk into a dashboard of 500s. See the login route and roleDestination.
|
||||
*
|
||||
* Safe to say plainly, unlike the codes above: it is only ever reached by
|
||||
* somebody who has just proved they own the account, so it tells an
|
||||
* unauthenticated stranger nothing.
|
||||
/*
|
||||
* `platform_account` used to live here: correct credentials for a platform
|
||||
* admin, refused because every surface in this console was tenant-scoped.
|
||||
* It is gone because the login route no longer produces it — a platform
|
||||
* operator now gets an ordinary session and lands on /admin. A code the
|
||||
* server cannot emit is worse than absent: it is a message somebody will
|
||||
* eventually try to trigger, and `?error=` accepts anything, so leaving it
|
||||
* meant a URL could still put a stale "sign in elsewhere" instruction on the
|
||||
* sign-in screen. See the login route and roleDestination.
|
||||
*/
|
||||
platform_account: {
|
||||
field: 'form',
|
||||
message:
|
||||
'This console is for merchant accounts. Platform administrators sign in on the Loyaly platform console.',
|
||||
},
|
||||
};
|
||||
|
||||
export type LoginErrorCode = keyof typeof LOGIN_ERRORS;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
import {DEFAULT_DESTINATION} from './redirectTarget';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
import type {AuthUser, UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Where a signed-in user lands, decided by the role the BACKEND returned.
|
||||
* Where a signed-in user lands, decided by what the BACKEND returned.
|
||||
*
|
||||
* ── Never from the page or the URL ───────────────────────────────────────
|
||||
* There is one sign-in page. `POST /api/auth/login` takes an address, a
|
||||
* password and a device label, and nothing else — no role, no user_type, no
|
||||
* per-audience endpoint — so nothing about who somebody is can be known until
|
||||
* the platform has said so. This map is read only AFTER that answer arrives.
|
||||
* the platform has said so. This is read only AFTER that answer arrives.
|
||||
*
|
||||
* That also preserves the form's no-enumeration property: a destination chosen
|
||||
* before authentication would tell an unauthenticated stranger which role an
|
||||
* address holds, the same leak the identical wrong-password / no-such-account
|
||||
* answer exists to close.
|
||||
*
|
||||
* One map, read by BOTH sign-in paths — the hydrated fetch, the native form
|
||||
* POST — and by GuestGuard, which redirects on the same event and would
|
||||
* One module, read by BOTH sign-in paths — the hydrated fetch and the native
|
||||
* form POST — and by GuestGuard, which redirects on the same event and would
|
||||
* otherwise race them to a different answer.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The platform console. A platform operator has no tenant, so every screen
|
||||
* under /dashboard would answer 500 for one; this is the only route that can
|
||||
* serve them, and it reads the platform's own `/api/admin/*` surface.
|
||||
*/
|
||||
export const ADMIN_DESTINATION = '/admin';
|
||||
|
||||
/**
|
||||
* Tenant roles only, and `admin` is deliberately absent.
|
||||
*
|
||||
* ── Why `admin` is not here ──────────────────────────────────────────────
|
||||
* It is not an oversight and it must not be "fixed" by adding a fourth line.
|
||||
* That is not an oversight: `admin` is a role a TENANT can hold too — an
|
||||
* account with `role === 'admin'` and a non-empty `client_id` is an ordinary
|
||||
* merchant user who belongs on /dashboard. Keying the platform console off the
|
||||
* role alone would send that person to a console with nothing in it for them,
|
||||
* which is exactly the mistake `isPlatformAdmin` exists to prevent.
|
||||
*
|
||||
* The platform's own words, in server/internal/auth/auth.go: "ClientID empty
|
||||
* means a platform admin, who is the only kind of user not scoped to one
|
||||
* tenant." Every surface in this console is tenant-scoped, so a platform admin
|
||||
* has no company for any of it to read. Measured against a real admin token on
|
||||
* the live local platform: /api/sites 500, /api/visits 500, /api/visitors 500,
|
||||
* /api/team 403 "This account does not belong to a company." /dashboard is not
|
||||
* a thin experience for an admin, it is a screenful of server errors.
|
||||
*
|
||||
* So an admin is not given a console session at all — the BFF refuses it at
|
||||
* `POST /api/auth/login` (see that route, and isPlatformAdmin). No admin can
|
||||
* reach this function, because no admin can hold a session here. Their surface
|
||||
* is the Companies screen in the platform's own web app, which has its own
|
||||
* login; this console does not redirect there and does not hand it a token,
|
||||
* because no session handoff exists between the two and inventing one would
|
||||
* mean putting a credential in a URL.
|
||||
*
|
||||
* The fallback below is for a role this console has not heard of yet, not for
|
||||
* `admin`.
|
||||
* The distinction is decided server-side from the role AND an empty
|
||||
* `client_id` together — see userMapper.
|
||||
*/
|
||||
export const ROLE_DESTINATIONS: Record<Exclude<UserRole, 'admin'>, string> = {
|
||||
/**
|
||||
@@ -52,6 +50,19 @@ export const ROLE_DESTINATIONS: Record<Exclude<UserRole, 'admin'>, string> = {
|
||||
owner: DEFAULT_DESTINATION,
|
||||
};
|
||||
|
||||
/**
|
||||
* The landing route for a user.
|
||||
*
|
||||
* `isPlatformAdmin` is checked FIRST and wins over every role mapping, because
|
||||
* it is the only fact that decides which of the two consoles this browser is
|
||||
* about to render. A tenant-scoped `admin` falls through to the map and keeps
|
||||
* the merchant destination it has always had.
|
||||
*/
|
||||
export function destinationForUser(user: AuthUser): string {
|
||||
if (user.isPlatformAdmin) return ADMIN_DESTINATION;
|
||||
return destinationForRole(user.role);
|
||||
}
|
||||
|
||||
/** Falls back to the default for a role this console does not know. */
|
||||
export function destinationForRole(role: string | undefined): string {
|
||||
if (!role) return DEFAULT_DESTINATION;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'server-only';
|
||||
import {cookies} from 'next/headers';
|
||||
import {
|
||||
SESSION_COOKIE,
|
||||
verifySessionToken,
|
||||
} from '@/features/auth/services/sessionToken';
|
||||
import {verifySessionToken} from '@/features/auth/services/sessionToken';
|
||||
import {sessionCookieFor} from '@/features/auth/services/tabScope';
|
||||
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import type {AuthSession, UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
@@ -27,9 +26,22 @@ import type {AuthSession, UserRole} from '@/features/auth/types/auth';
|
||||
* There is no local directory any more — the platform is the directory — so
|
||||
* the check moved to where the platform actually answers.
|
||||
*/
|
||||
/**
|
||||
* ── Which tab's session ──────────────────────────────────────────────────
|
||||
* Resolved through `resolveTabId`, so a server render seeds the shell with the
|
||||
* identity of the tab making the request rather than whichever tab signed in
|
||||
* most recently. During a Server Component render there is no `X-Tab-Id`
|
||||
* header, so this falls back to the `loyaly_tab` pointer cookie — a hint, and
|
||||
* deliberately only a hint. `SessionProvider` re-resolves on the client with
|
||||
* the header, so a stale pointer costs one corrected fetch and never renders
|
||||
* one tab's data inside another.
|
||||
*/
|
||||
export async function getServerSession(): Promise<AuthSession | null> {
|
||||
const tabId = await resolveTabId();
|
||||
if (!tabId) return null;
|
||||
|
||||
const store = await cookies();
|
||||
const payload = verifySessionToken(store.get(SESSION_COOKIE)?.value);
|
||||
const payload = verifySessionToken(store.get(sessionCookieFor(tabId))?.value);
|
||||
if (!payload) return null;
|
||||
|
||||
return {
|
||||
@@ -39,6 +51,9 @@ export async function getServerSession(): Promise<AuthSession | null> {
|
||||
name: payload.name,
|
||||
role: payload.role as UserRole,
|
||||
organisation: payload.organisation,
|
||||
// `=== true` rather than a cast: a cookie minted before this field
|
||||
// existed has it `undefined`, and a merchant must read as false.
|
||||
isPlatformAdmin: payload.isPlatformAdmin === true,
|
||||
},
|
||||
expiresAt: new Date(payload.exp * 1000).toISOString(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
88
src/features/auth/services/tabScope.ts
Normal file
88
src/features/auth/services/tabScope.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Which browser TAB a session belongs to — the naming rules, and nothing else.
|
||||
*
|
||||
* ── The problem ──────────────────────────────────────────────────────────
|
||||
* A cookie jar belongs to the browser profile, not the tab. One pair of cookies
|
||||
* for the whole origin meant one identity for the whole browser: signing in as
|
||||
* a manager in a second tab replaced the admin in the first, and merely
|
||||
* switching back to the first tab repainted it as the manager, because the
|
||||
* session provider refetches on `visibilitychange`. No cookie attribute scopes
|
||||
* a cookie to a tab; this is not something React state can fix.
|
||||
*
|
||||
* ── The fix ──────────────────────────────────────────────────────────────
|
||||
* Every tab mints a random id into `sessionStorage` — the only per-tab lifetime
|
||||
* browsers give us — and each tab's session lives in its OWN pair of cookies:
|
||||
*
|
||||
* loyaly_session_<tabId> signed identity
|
||||
* loyaly_tokens_<tabId> AES-sealed access + refresh
|
||||
*
|
||||
* Nothing about the security model changes. Both cookies are still httpOnly, so
|
||||
* JavaScript still cannot read a token. The id is NOT a credential: it names
|
||||
* which cookie to open, and a forged one selects a cookie the attacker's own
|
||||
* browser already had — or, far more likely, none at all.
|
||||
*
|
||||
* ── Why this file is pure ────────────────────────────────────────────────
|
||||
* `src/proxy.ts` needs `isSessionCookieName` and `sessionCookieFor`, and the
|
||||
* proxy cannot import `server-only` — that package throws on import outside a
|
||||
* react-server condition, the same trap documented in platformApi.ts. So the
|
||||
* request-context half (`resolveTabId`, which reads headers and cookies) lives
|
||||
* in tabScopeRequest.ts, and everything here is a pure string function.
|
||||
*/
|
||||
|
||||
/** Names the tab; carries no authority of its own. Not httpOnly — the tab's
|
||||
* own script writes it, and it is not a credential. */
|
||||
export const TAB_POINTER_COOKIE = 'loyaly_tab';
|
||||
|
||||
export const TAB_ID_HEADER = 'x-tab-id';
|
||||
|
||||
/** Where each tab keeps its id. `sessionStorage`, so it is empty in a new tab,
|
||||
* survives that tab's reloads, and dies with it. */
|
||||
export const TAB_ID_STORAGE_KEY = 'loyaly.tab-id';
|
||||
|
||||
const SESSION_PREFIX = 'loyaly_session_';
|
||||
const TOKEN_PREFIX = 'loyaly_tokens_';
|
||||
|
||||
/**
|
||||
* Strict, and this is the load-bearing line in the file.
|
||||
*
|
||||
* The id becomes part of a COOKIE NAME and it arrives from the client. Anything
|
||||
* looser than a fixed alphabet lets a crafted value inject cookie syntax — a
|
||||
* `;`, a space, an `=` — and name a cookie it was never meant to reach.
|
||||
* Lowercase alphanumerics only, bounded length, no exceptions.
|
||||
*/
|
||||
const TAB_ID = /^[a-z0-9]{8,32}$/;
|
||||
|
||||
export function isValidTabId(value: string | undefined | null): value is string {
|
||||
return typeof value === 'string' && TAB_ID.test(value);
|
||||
}
|
||||
|
||||
export function sessionCookieFor(tabId: string): string {
|
||||
return `${SESSION_PREFIX}${tabId}`;
|
||||
}
|
||||
|
||||
export function tokenCookieFor(tabId: string): string {
|
||||
return `${TOKEN_PREFIX}${tabId}`;
|
||||
}
|
||||
|
||||
/** Every session cookie in the jar — one per signed-in tab. */
|
||||
export function isSessionCookieName(name: string): boolean {
|
||||
return (
|
||||
name.startsWith(SESSION_PREFIX) &&
|
||||
isValidTabId(name.slice(SESSION_PREFIX.length))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pointer is readable by script on purpose — the tab writes it on load and
|
||||
* on focus so the next DOCUMENT navigation, which cannot carry a header, is
|
||||
* server-rendered as the right user. `lax` keeps it off cross-site requests,
|
||||
* and it holds no authority regardless.
|
||||
*/
|
||||
export function tabPointerOptions() {
|
||||
return {
|
||||
httpOnly: false,
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
};
|
||||
}
|
||||
53
src/features/auth/services/tabScopeRequest.ts
Normal file
53
src/features/auth/services/tabScopeRequest.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'server-only';
|
||||
import {cookies, headers} from 'next/headers';
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import {TAB_ID_HEADER, TAB_POINTER_COOKIE, isValidTabId} from './tabScope';
|
||||
|
||||
/**
|
||||
* Resolving which tab the CURRENT request came from.
|
||||
*
|
||||
* Split from tabScope.ts because this half touches `next/headers` and is
|
||||
* `server-only`; the proxy imports the pure half and could not load this one.
|
||||
*
|
||||
* ── Two channels, in priority order ──────────────────────────────────────
|
||||
* Neither works everywhere, which is why there are two:
|
||||
*
|
||||
* 1. `X-Tab-Id` on fetches from the app. AUTHORITATIVE — it comes from the
|
||||
* tab making the request, so a background tab polling in the same browser
|
||||
* reads its own session rather than the focused tab's.
|
||||
*
|
||||
* 2. The `loyaly_tab` POINTER cookie, for DOCUMENT navigations, which cannot
|
||||
* carry a custom header. The tab writes it on load and on focus, so it
|
||||
* tracks the tab actually in use — only one tab is focused at a time,
|
||||
* which is what makes a single pointer sufficient.
|
||||
*
|
||||
* The pointer is a hint for server rendering and for the proxy's role routing,
|
||||
* never the authority. The client always re-resolves with the header, so a
|
||||
* stale pointer costs one corrected fetch and can never render one tab's data
|
||||
* inside another.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Null when neither channel yields a valid id — a request from outside the app,
|
||||
* or a tab that has not claimed one yet.
|
||||
*
|
||||
* Callers must treat null as "no session", never as "any session". Picking an
|
||||
* arbitrary cookie out of the jar would be precisely the cross-tab leak this
|
||||
* module exists to close.
|
||||
*/
|
||||
export async function resolveTabId(): Promise<string | null> {
|
||||
const fromHeader = (await headers()).get(TAB_ID_HEADER);
|
||||
if (isValidTabId(fromHeader)) return fromHeader;
|
||||
|
||||
const pointer = (await cookies()).get(TAB_POINTER_COOKIE)?.value;
|
||||
return isValidTabId(pointer) ? pointer : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 16 hex characters from a CSPRNG. Not a secret — just unlikely to collide with
|
||||
* another tab in the same browser. Used only by the login route, for the
|
||||
* no-JavaScript path that cannot send a header.
|
||||
*/
|
||||
export function newTabId(): string {
|
||||
return randomBytes(8).toString('hex');
|
||||
}
|
||||
@@ -1,84 +1,63 @@
|
||||
/**
|
||||
* Tab-scoped session lifetime.
|
||||
*
|
||||
* ── The problem cookies cannot solve on their own ─────────────────────────
|
||||
* `loyaly_session` and `loyaly_tokens` are browser-session cookies: no Max-Age,
|
||||
* so the browser drops them when it exits. That is per BROWSER, not per TAB —
|
||||
* one cookie jar is shared by every tab of the profile, so closing the tab a
|
||||
* merchant signed in on leaves the jar untouched and the next tab is still
|
||||
* authenticated. No cookie attribute exists that scopes a cookie to one tab.
|
||||
*
|
||||
* `sessionStorage` is the only per-tab lifetime the platform gives us: it is
|
||||
* empty in a newly opened tab, survives a reload of the tab it belongs to, and
|
||||
* is discarded when that tab closes. So a tab that holds the session marker is
|
||||
* a tab that was here when the session was established; a tab without it is a
|
||||
* tab that inherited somebody else's cookies.
|
||||
*
|
||||
* The marker is NOT a credential. It is the string "1". The access and refresh
|
||||
* tokens stay sealed in the httpOnly cookie where JavaScript cannot reach them,
|
||||
* and nothing here changes that: a forged marker buys an attacker exactly the
|
||||
* cookies their browser already had.
|
||||
*
|
||||
* ── Why an inline script and not an effect ────────────────────────────────
|
||||
* A React effect runs after the first paint, so a new tab would show the
|
||||
* dashboard and then bounce to /login. This runs as the first child of <body>,
|
||||
* before the app markup below it is parsed, so the authenticated shell is never
|
||||
* painted at all.
|
||||
*/
|
||||
|
||||
/** Present ⇒ this tab is the one the session belongs to. */
|
||||
export const TAB_SESSION_KEY = 'loyaly.tab-session';
|
||||
import {
|
||||
TAB_ID_STORAGE_KEY,
|
||||
TAB_POINTER_COOKIE,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
|
||||
/**
|
||||
* Set for the duration of one sign-out attempt, and the reason this cannot
|
||||
* loop. `proxy.ts` redirects /login → /dashboard while the cookies verify, so a
|
||||
* failed `POST /api/auth/logout` would otherwise land straight back on a
|
||||
* protected page with the marker still missing, and fire again forever. If the
|
||||
* guard finds its own flag already set it concludes the clear did not take,
|
||||
* claims the tab and gets out of the way — degrading to the previous behaviour
|
||||
* rather than locking somebody out of a console it cannot sign them out of.
|
||||
*/
|
||||
const RESET_FLAG = 'loyaly.tab-session.reset';
|
||||
|
||||
/**
|
||||
* Claim the tab for the session that is about to exist.
|
||||
* The inline script that gives each tab its own identity.
|
||||
*
|
||||
* Runs on every SIGNED-OUT document, which is what makes the no-JavaScript and
|
||||
* pre-hydration sign-in paths work: /login itself claims the tab, so the native
|
||||
* form POST lands on /dashboard with the marker already in place. The reset flag
|
||||
* is cleared here because arriving signed out is proof the sign-out worked.
|
||||
*/
|
||||
const CLAIM_SCRIPT = `(function(){try{var s=window.sessionStorage;s.setItem(${JSON.stringify(
|
||||
TAB_SESSION_KEY,
|
||||
)},'1');s.removeItem(${JSON.stringify(RESET_FLAG)});}catch(e){}})();`;
|
||||
|
||||
/**
|
||||
* End the session when the tab holding it is gone.
|
||||
* ── What this used to do, and why it had to go ───────────────────────────
|
||||
* This file used to enforce a ONE-TAB-PER-SESSION policy. A document served
|
||||
* with a session to a tab that had no `sessionStorage` marker — which is every
|
||||
* newly opened tab — hid the page, called `POST /api/auth/logout`, and bounced
|
||||
* to /login. That made opening a second tab sign the FIRST one out and revoke
|
||||
* its platform session, because the cookies were shared by the whole browser
|
||||
* and there was no way to tell "a tab that inherited someone's cookies" from
|
||||
* "a second tab of the same person".
|
||||
*
|
||||
* Fails OPEN when sessionStorage is unreadable — private mode, blocked site
|
||||
* data, an embedded webview. A storage API that throws must not be the thing
|
||||
* that decides a merchant cannot use the console; the worst case is the
|
||||
* cross-tab behaviour this file exists to change, not a lockout.
|
||||
* There is a way now: each tab carries an id and opens its OWN cookies (see
|
||||
* tabScope.ts). A second tab simply has no session of its own and lands on
|
||||
* /login, which is the correct answer and costs the first tab nothing. So the
|
||||
* destructive guard is gone — nothing in here signs anybody out any more.
|
||||
*
|
||||
* ── What it does instead ─────────────────────────────────────────────────
|
||||
* 1. Mints this tab's id into `sessionStorage` if it has none. Empty in a new
|
||||
* tab, survives that tab's reloads, discarded when it closes — exactly the
|
||||
* lifetime a per-tab session needs.
|
||||
* 2. Writes the id into the `loyaly_tab` pointer cookie, so the NEXT document
|
||||
* navigation (which cannot carry a header) is server-rendered as this tab's
|
||||
* user rather than as whoever signed in last.
|
||||
*
|
||||
* ── Why an inline script and not an effect ───────────────────────────────
|
||||
* A React effect runs after the first paint, so the first document of a tab
|
||||
* would be rendered against the wrong pointer and visibly re-render. This runs
|
||||
* as the first child of <body>, before the app markup below it is parsed.
|
||||
*
|
||||
* ── Why it fails open ────────────────────────────────────────────────────
|
||||
* `sessionStorage` throws in private mode, with site data blocked, and in some
|
||||
* embedded webviews. A storage API that throws must never be the thing that
|
||||
* decides somebody cannot use the console. With no id the server falls back to
|
||||
* the pointer cookie, and the browser behaves as it did before this existed:
|
||||
* one shared session. Degraded, never locked out.
|
||||
*
|
||||
* The id is NOT a credential. Tokens stay sealed in httpOnly cookies that
|
||||
* JavaScript cannot read, and a forged id names a cookie the browser either
|
||||
* already had or does not have at all.
|
||||
*/
|
||||
const GUARD_SCRIPT = `(function(){var K=${JSON.stringify(
|
||||
TAB_SESSION_KEY,
|
||||
)},R=${JSON.stringify(RESET_FLAG)},s;
|
||||
try{s=window.sessionStorage;}catch(e){return;}
|
||||
try{
|
||||
if(s.getItem(K))return;
|
||||
if(s.getItem(R)){s.setItem(K,'1');s.removeItem(R);return;}
|
||||
s.setItem(R,'1');
|
||||
}catch(e){return;}
|
||||
document.documentElement.style.visibility='hidden';
|
||||
var go=function(){location.replace('/login');};
|
||||
try{fetch('/api/auth/logout',{method:'POST',credentials:'same-origin'}).then(go,go);}catch(e){go();}
|
||||
})();`;
|
||||
|
||||
/**
|
||||
* The script the root layout inlines, chosen by whether the SERVER resolved a
|
||||
* session for this request. Two separate bodies rather than one that branches on
|
||||
* a serialised flag, so neither path can be reached by tampering with the other.
|
||||
*/
|
||||
export function tabSessionScript(hasSession: boolean): string {
|
||||
return hasSession ? GUARD_SCRIPT : CLAIM_SCRIPT;
|
||||
export function tabSessionScript(): string {
|
||||
return `(function(){try{
|
||||
var K=${JSON.stringify(TAB_ID_STORAGE_KEY)},P=${JSON.stringify(TAB_POINTER_COOKIE)};
|
||||
var s=window.sessionStorage,id=s.getItem(K);
|
||||
if(!id||!/^[a-z0-9]{8,32}$/.test(id)){
|
||||
id=(Math.random().toString(36).slice(2)+Math.random().toString(36).slice(2)).slice(0,16);
|
||||
s.setItem(K,id);
|
||||
}
|
||||
var point=function(){document.cookie=P+'='+id+';path=/;samesite=lax'+(location.protocol==='https:'?';secure':'');};
|
||||
point();
|
||||
// Re-point on focus: only one tab is focused at a time, so this keeps the
|
||||
// pointer aimed at the tab the person is actually using, which is the tab whose
|
||||
// next navigation the server has to render.
|
||||
window.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible')point();});
|
||||
window.addEventListener('pageshow',point);
|
||||
}catch(e){}})();`;
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ import {createHash} from 'node:crypto';
|
||||
import {UpstreamError, upstreamRequest} from '@/services/api/apiClient';
|
||||
import type {ApiTokenBundle} from '@/services/api/types';
|
||||
import {
|
||||
TOKEN_COOKIE,
|
||||
openTokens,
|
||||
sealTokens,
|
||||
tokenCookieOptions,
|
||||
type TokenBundle,
|
||||
} from './tokenStore';
|
||||
import {tokenCookieFor} from './tabScope';
|
||||
import {resolveTabId} from './tabScopeRequest';
|
||||
|
||||
/**
|
||||
* Authenticated access to the platform, with the three refresh rules the
|
||||
@@ -43,9 +44,20 @@ function lockKey(refreshToken: string): string {
|
||||
return createHash('sha256').update(refreshToken).digest('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* This TAB's tokens, never another tab's.
|
||||
*
|
||||
* A null tab id returns null rather than falling back to some other cookie in
|
||||
* the jar. "I could not tell which tab this is" must read as "no session" — the
|
||||
* alternative is handing one tab the credentials of whichever tab happened to
|
||||
* sign in last, which is the bug this scoping exists to fix.
|
||||
*/
|
||||
async function readTokens(): Promise<TokenBundle | null> {
|
||||
const tabId = await resolveTabId();
|
||||
if (!tabId) return null;
|
||||
|
||||
const store = await cookies();
|
||||
return openTokens(store.get(TOKEN_COOKIE)?.value);
|
||||
return openTokens(store.get(tokenCookieFor(tabId))?.value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,9 +68,20 @@ async function readTokens(): Promise<TokenBundle | null> {
|
||||
async function persistTokens(
|
||||
bundle: TokenBundle,
|
||||
maxAgeSeconds?: number,
|
||||
forTabId?: string,
|
||||
): Promise<void> {
|
||||
// `forTabId` is passed by the login route, which has just minted the id and
|
||||
// cannot resolve it from a header or a pointer that does not exist yet. Every
|
||||
// other caller — a token refresh mid-session — resolves it normally.
|
||||
const tabId = forTabId ?? (await resolveTabId());
|
||||
if (!tabId) return;
|
||||
|
||||
const store = await cookies();
|
||||
store.set(TOKEN_COOKIE, sealTokens(bundle), tokenCookieOptions(maxAgeSeconds));
|
||||
store.set(
|
||||
tokenCookieFor(tabId),
|
||||
sealTokens(bundle),
|
||||
tokenCookieOptions(maxAgeSeconds),
|
||||
);
|
||||
}
|
||||
|
||||
export function toBundle(res: ApiTokenBundle): TokenBundle {
|
||||
@@ -153,15 +176,20 @@ export async function withUpstream<T>(
|
||||
export async function storeTokens(
|
||||
res: ApiTokenBundle,
|
||||
maxAgeSeconds?: number,
|
||||
forTabId?: string,
|
||||
): Promise<TokenBundle> {
|
||||
const bundle = toBundle(res);
|
||||
await persistTokens(bundle, maxAgeSeconds);
|
||||
await persistTokens(bundle, maxAgeSeconds, forTabId);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/** Only this tab's. Signing out of one tab must leave the others signed in. */
|
||||
export async function clearTokens(): Promise<void> {
|
||||
const tabId = await resolveTabId();
|
||||
if (!tabId) return;
|
||||
|
||||
const store = await cookies();
|
||||
store.delete(TOKEN_COOKIE);
|
||||
store.delete(tokenCookieFor(tabId));
|
||||
}
|
||||
|
||||
/** The access token without any refresh attempt — for fire-and-forget calls
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -30,7 +30,22 @@ import type {Resource} from '@/shared/hooks/useResource';
|
||||
* SessionProvider's `initialSession`), so this costs a signed-in user nothing —
|
||||
* there is no 'loading' pass to wait through before the estate is requested.
|
||||
*/
|
||||
/**
|
||||
* ── Why a platform operator is gated out as well ─────────────────────────
|
||||
* WorkspaceProvider sits above the WHOLE route tree (see app/providers.tsx),
|
||||
* /admin included, so this fires on the platform console too. A platform
|
||||
* operator has no tenant, and the platform answers `GET /api/sites` with a 500
|
||||
* for a token that carries no company — so an ungated call put a guaranteed
|
||||
* server error in the console on every admin page, for a request whose answer
|
||||
* could never be anything else.
|
||||
*
|
||||
* Gated here rather than in WorkspaceProvider for the same reason the session
|
||||
* check is: the rule is a property of the ENDPOINT — no tenant, no estate — and
|
||||
* only a null endpoint can actually hold the request. A guard on the derived
|
||||
* value would still send it.
|
||||
*/
|
||||
export function useSites(): Resource<Site[]> {
|
||||
const {isAuthenticated} = useSession();
|
||||
return useResource(isAuthenticated ? siteRepository.list() : null);
|
||||
const {isAuthenticated, user} = useSession();
|
||||
const hasTenant = isAuthenticated && user?.isPlatformAdmin !== true;
|
||||
return useResource(hasTenant ? siteRepository.list() : null);
|
||||
}
|
||||
|
||||
128
src/proxy.ts
128
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();
|
||||
}
|
||||
|
||||
|
||||
100
src/services/api/adminApi.ts
Normal file
100
src/services/api/adminApi.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'server-only';
|
||||
import {upstreamRequest} from './apiClient';
|
||||
import type {
|
||||
ApiClientActiveResult,
|
||||
ApiClientDeleteResult,
|
||||
ApiClientRow,
|
||||
ApiNewClientInput,
|
||||
ApiNewClientResult,
|
||||
ApiOwnerPasswordResult,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Platform administration — the companies on this platform.
|
||||
*
|
||||
* `server-only`, like every module in this directory, and that is the whole
|
||||
* security posture of the admin console: the browser never holds a platform
|
||||
* token and never calls `mcp.loyaly.ai`. These run inside the BFF, which reads
|
||||
* the access token out of an AES-sealed httpOnly cookie the page cannot see.
|
||||
*
|
||||
* ── This layer authorises nothing, deliberately ──────────────────────────
|
||||
* There is no role check in this file and there must not be one. The platform
|
||||
* gates `/api/admin/*` with `adminOnly` — `role === "admin"` AND no company —
|
||||
* and answers **404** to everyone else. Re-implementing that rule here would
|
||||
* create a second copy to drift out of step with the first, and the copy that
|
||||
* matters is the one on the server holding the data.
|
||||
*
|
||||
* What the console does with `isPlatformAdmin` is routing: which screen to
|
||||
* draw. If that answer were ever wrong, these calls would return 404 and the
|
||||
* screen would be empty — not populated with somebody else's tenants.
|
||||
*
|
||||
* Scope is exactly the five operations the platform documents as the admin
|
||||
* surface. There is no endpoint to look inside a company, and none is invented
|
||||
* here: seeing a tenant's data means signing in as that tenant's owner.
|
||||
*/
|
||||
export const adminApi = {
|
||||
listClients: (accessToken: string) =>
|
||||
upstreamRequest<ApiClientRow[]>({path: '/api/admin/clients', accessToken}),
|
||||
|
||||
/**
|
||||
* Creates the company and its owner together. Answers 201.
|
||||
*
|
||||
* `password` is deliberately not sent — the server generates it and returns
|
||||
* it once. Nothing in this console ever chooses a password for somebody else.
|
||||
*/
|
||||
createClient: (accessToken: string, body: ApiNewClientInput) =>
|
||||
upstreamRequest<ApiNewClientResult>({
|
||||
path: '/api/admin/clients',
|
||||
method: 'POST',
|
||||
body,
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Suspend (`false`) or reinstate (`true`).
|
||||
*
|
||||
* Suspension revokes the company's sessions upstream in the same
|
||||
* transaction. This console must not simulate that — a local "signed out"
|
||||
* flag would claim an invalidation it cannot perform.
|
||||
*/
|
||||
setClientActive: (accessToken: string, id: string, active: boolean) =>
|
||||
upstreamRequest<ApiClientActiveResult>({
|
||||
path: `/api/admin/clients/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: {active},
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
/**
|
||||
* For an owner locked out with nobody above them in the company.
|
||||
*
|
||||
* `email` picks which owner when the company has more than one; with exactly
|
||||
* one it may be omitted, and omitting it is what the UI does first. With
|
||||
* several owners and no address the platform answers 400 and lists them,
|
||||
* which is the signal to ask.
|
||||
*/
|
||||
resetOwnerPassword: (accessToken: string, id: string, email?: string) =>
|
||||
upstreamRequest<ApiOwnerPasswordResult>({
|
||||
path: `/api/admin/clients/${encodeURIComponent(id)}/owner-password`,
|
||||
method: 'POST',
|
||||
body: email ? {email} : {},
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
/**
|
||||
* Irreversible, and the data is biometric, so the platform makes it a
|
||||
* two-step decision: the company must already be suspended (409
|
||||
* `still_active` otherwise) and the body must repeat its slug.
|
||||
*
|
||||
* Both conditions are the platform's, not this console's. The dialog mirrors
|
||||
* them so the person is not surprised, but it never pre-empts them — an
|
||||
* active company is sent and the real 409 is shown.
|
||||
*/
|
||||
deleteClient: (accessToken: string, id: string, confirm: string) =>
|
||||
upstreamRequest<ApiClientDeleteResult>({
|
||||
path: `/api/admin/clients/${encodeURIComponent(id)}`,
|
||||
method: 'DELETE',
|
||||
body: {confirm},
|
||||
accessToken,
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
15
src/shared/layouts/workspace/sidebarStorage.ts
Normal file
15
src/shared/layouts/workspace/sidebarStorage.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* The sidebar's persistence key, in a module of its own.
|
||||
*
|
||||
* It is needed in two unrelated places — `SidebarProvider`, which reads and
|
||||
* writes it, and the sign-out path, which clears it — and importing a client
|
||||
* provider into the session provider just to reach a string would couple two
|
||||
* layers that have no other business with each other.
|
||||
*
|
||||
* `localStorage`, deliberately: a collapsed rail is a per-DEVICE preference,
|
||||
* not a per-tab one. Someone who collapses it in one tab expects it collapsed
|
||||
* in the next. That is also why sign-out clears this key BY NAME rather than
|
||||
* calling `localStorage.clear()` — the clear wiped every other signed-in tab's
|
||||
* preferences along with it.
|
||||
*/
|
||||
export const SIDENAV_COLLAPSED_KEY = 'loyaly.sidenav.collapsed';
|
||||
@@ -1,5 +1,9 @@
|
||||
import type {ApiResponse, RangeKey} from '@/shared/types/api';
|
||||
import {isFailure} from '@/shared/types/api';
|
||||
import {
|
||||
TAB_ID_HEADER,
|
||||
TAB_ID_STORAGE_KEY,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
|
||||
/**
|
||||
* The backend-swap seam. Nothing above this file knows a URL.
|
||||
@@ -60,6 +64,9 @@ export async function fetchEndpoint<T>(
|
||||
signal,
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
// Names the tab, so a background tab reads its own session rather than the
|
||||
// focused tab's. See tabScope.ts.
|
||||
headers: tabHeaders(),
|
||||
});
|
||||
// A non-JSON body (proxy error page, 502 HTML) must surface as a typed
|
||||
// failure rather than throwing a SyntaxError deep inside the hook.
|
||||
@@ -92,6 +99,30 @@ export interface HttpResult<T> {
|
||||
field?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which tab is asking.
|
||||
*
|
||||
* The BFF keeps one session per tab, in a cookie named after this id (see
|
||||
* tabScope.ts), so every request has to say which one it wants. Without it the
|
||||
* server falls back to the `loyaly_tab` pointer — correct for the focused tab,
|
||||
* wrong for a background tab polling in the same browser, which is exactly the
|
||||
* case this header exists to get right.
|
||||
*
|
||||
* Read fresh per request rather than cached at module scope: the inline script
|
||||
* in the root layout writes it before any of this loads, but a tab whose
|
||||
* storage was cleared mid-session would otherwise keep sending a dead id.
|
||||
* Returns nothing when storage throws (private mode), and the server degrades
|
||||
* to the pointer — the same fail-open the script takes.
|
||||
*/
|
||||
function tabHeaders(): Record<string, string> {
|
||||
try {
|
||||
const id = window.sessionStorage.getItem(TAB_ID_STORAGE_KEY);
|
||||
return id ? {[TAB_ID_HEADER]: id} : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
@@ -102,6 +133,7 @@ async function request<T>(
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: {...tabHeaders(), ...(init?.headers as Record<string, string>)},
|
||||
});
|
||||
} catch (err) {
|
||||
// Offline, DNS, CORS — there is no status to report, so 0 stands for
|
||||
|
||||
Reference in New Issue
Block a user