diff --git a/src/app/api/activities/route.ts b/src/app/api/activities/route.ts index af1f338..0dbd23e 100644 --- a/src/app/api/activities/route.ts +++ b/src/app/api/activities/route.ts @@ -1,4 +1,5 @@ import type {NextRequest} from 'next/server'; +import {UpstreamError} from '@/services/api/apiClient'; import {engagementApi} from '@/services/api/engagementApi'; import {toReportWindow, toSiteParam} from '@/services/api/range'; import {serveUpstream} from '@/shared/services/bff'; @@ -17,10 +18,17 @@ export async function GET(req: NextRequest) { return serveUpstream(req, async (token, query) => { const window = toReportWindow(query.range, new Date(query.nowMs)); const site = toSiteParam(query.storeId); - const [activities, impact] = await Promise.all([ - engagementApi.activities(token, window, site), - engagementApi.impact(token, window, site), - ]); - return toActivityRows(activities, impact); + try { + const [activities, impact] = await Promise.all([ + engagementApi.activities(token, window, site), + engagementApi.impact(token, window, site), + ]); + return toActivityRows(activities, impact); + } catch (err) { + // The deployed platform predates this route: an empty panel, not a red + // error. Any other failure, a real 404 included, still surfaces. + if (err instanceof UpstreamError && err.isRouteMissing) return []; + throw err; + } }); } diff --git a/src/app/api/admin/clients/[id]/route.ts b/src/app/api/admin/clients/[id]/route.ts index de99c1f..434be86 100644 --- a/src/app/api/admin/clients/[id]/route.ts +++ b/src/app/api/admin/clients/[id]/route.ts @@ -1,10 +1,24 @@ 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'; +import {proxyUpstream, serveUpstream} from '@/shared/services/bff'; +import {toCompany, toCompanyDetail} from '@/features/admin/services/mapCompany'; export const dynamic = 'force-dynamic'; +/** + * GET /api/admin/clients/{id} — one company, with its owner. + * + * Suspended companies still resolve: suspension is exactly when an operator + * opens the page. A non-uuid or unknown id is the platform's 404. + */ +export async function GET( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return serveUpstream(req, (token) => adminApi.getClient(token, id), toCompanyDetail); +} + /** * PATCH /api/admin/clients/{id} — suspend or reinstate a company. * diff --git a/src/app/api/admin/clients/[id]/sites/[site]/cameras/[camera]/route.ts b/src/app/api/admin/clients/[id]/sites/[site]/cameras/[camera]/route.ts new file mode 100644 index 0000000..c0a8756 --- /dev/null +++ b/src/app/api/admin/clients/[id]/sites/[site]/cameras/[camera]/route.ts @@ -0,0 +1,22 @@ +import type {NextRequest} from 'next/server'; +import {adminApi} from '@/services/api/adminApi'; +import {serveUpstream} from '@/shared/services/bff'; +import {toAdminCamera} from '@/features/admin/services/mapMonitoring'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/admin/clients/{id}/sites/{site}/cameras/{camera} — one camera. The + * platform checks company → shop → camera in one query. + */ +export async function GET( + req: NextRequest, + {params}: {params: Promise<{id: string; site: string; camera: string}>}, +) { + const {id, site, camera} = await params; + return serveUpstream( + req, + (token) => adminApi.getSiteCamera(token, id, site, camera), + toAdminCamera, + ); +} diff --git a/src/app/api/admin/clients/[id]/sites/[site]/cameras/route.ts b/src/app/api/admin/clients/[id]/sites/[site]/cameras/route.ts new file mode 100644 index 0000000..241f80d --- /dev/null +++ b/src/app/api/admin/clients/[id]/sites/[site]/cameras/route.ts @@ -0,0 +1,22 @@ +import type {NextRequest} from 'next/server'; +import {adminApi} from '@/services/api/adminApi'; +import {serveUpstream} from '@/shared/services/bff'; +import {toAdminCamera} from '@/features/admin/services/mapMonitoring'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/admin/clients/{id}/sites/{site}/cameras — the shop's cameras, + * redacted upstream: no host, port, path or credentials reach this console. + */ +export async function GET( + req: NextRequest, + {params}: {params: Promise<{id: string; site: string}>}, +) { + const {id, site} = await params; + return serveUpstream( + req, + (token) => adminApi.listSiteCameras(token, id, site), + (rows) => rows.map(toAdminCamera), + ); +} diff --git a/src/app/api/admin/clients/[id]/sites/[site]/route.ts b/src/app/api/admin/clients/[id]/sites/[site]/route.ts new file mode 100644 index 0000000..60cb8af --- /dev/null +++ b/src/app/api/admin/clients/[id]/sites/[site]/route.ts @@ -0,0 +1,24 @@ +import type {NextRequest} from 'next/server'; +import {adminApi} from '@/services/api/adminApi'; +import {serveUpstream} from '@/shared/services/bff'; +import {toAdminStore} from '@/features/admin/services/mapMonitoring'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/admin/clients/{id}/sites/{site} — one shop of one company. + * + * The platform checks the pair: a shop that belongs to another company is a + * 404, never that company's row. + */ +export async function GET( + req: NextRequest, + {params}: {params: Promise<{id: string; site: string}>}, +) { + const {id, site} = await params; + return serveUpstream( + req, + (token) => adminApi.getClientSite(token, id, site), + toAdminStore, + ); +} diff --git a/src/app/api/admin/clients/[id]/sites/route.ts b/src/app/api/admin/clients/[id]/sites/route.ts new file mode 100644 index 0000000..8d5f819 --- /dev/null +++ b/src/app/api/admin/clients/[id]/sites/route.ts @@ -0,0 +1,22 @@ +import type {NextRequest} from 'next/server'; +import {adminApi} from '@/services/api/adminApi'; +import {serveUpstream} from '@/shared/services/bff'; +import {toAdminStore} from '@/features/admin/services/mapMonitoring'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/admin/clients/{id}/sites — the company's active shops, with the + * shop PC's liveness and camera counts. Read-only. + */ +export async function GET( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return serveUpstream( + req, + (token) => adminApi.listClientSites(token, id), + (rows) => rows.map(toAdminStore), + ); +} diff --git a/src/app/api/admin/monitoring/summary/route.ts b/src/app/api/admin/monitoring/summary/route.ts new file mode 100644 index 0000000..0b46562 --- /dev/null +++ b/src/app/api/admin/monitoring/summary/route.ts @@ -0,0 +1,15 @@ +import type {NextRequest} from 'next/server'; +import {adminApi} from '@/services/api/adminApi'; +import {serveUpstream} from '@/shared/services/bff'; +import {toPlatformMonitoring} from '@/features/admin/services/mapMonitoring'; + +export const dynamic = 'force-dynamic'; + +/** GET /api/admin/monitoring/summary — estate-wide counts for the Overview. */ +export async function GET(req: NextRequest) { + return serveUpstream( + req, + (token) => adminApi.monitoringSummary(token), + toPlatformMonitoring, + ); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index fb069de..cb00740 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -5,6 +5,7 @@ import {UpstreamError} from '@/services/api/apiClient'; import {ConfigError} from '@/shared/errors/configError'; import { LOGIN_ERROR_PARAM, + NOT_PLATFORM_ADMIN_MESSAGE, type LoginErrorCode, } from '@/features/auth/services/loginErrorCodes'; import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget'; @@ -234,6 +235,35 @@ export async function POST(req: NextRequest) { * a session it is unable to verify on the next request. */ const user = toAuthUser(bundle.user); + + /** + * This is the Platform Admin console: it admits platform admins and nobody else. + * + * Correct merchant or staff credentials are still not Platform Admin + * credentials. Refused HERE, before `storeTokens` and before any cookie, so + * no session of any kind exists on this domain for them — not a merchant + * session that the proxy then has to route away from. The platform session + * `authApi.login` just minted is revoked for the same reason the + * misconfigured branch below revokes it: an unused refresh token is a + * credential left lying around. No redirect to the merchant app either; the + * form stays put and says why. + */ + if (!user.isPlatformAdmin) { + try { + await authApi.logout(bundle.access_token); + } catch { + /* best-effort: the refusal must not depend on the revoke succeeding */ + } + const code: LoginErrorCode = 'not_platform_admin'; + if (isForm) { + return NextResponse.redirect( + new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url), + 303, + ); + } + return failJson(code, NOT_PLATFORM_ADMIN_MESSAGE, 403); + } + /** * Two different lifetimes, and conflating them was the bug. * diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts index 7cf416a..c4c5f33 100644 --- a/src/app/api/auth/session/route.ts +++ b/src/app/api/auth/session/route.ts @@ -34,6 +34,13 @@ export async function GET() { try { const user = await withUpstream((token) => authApi.me(token)); + // This console knows only platform admins: a merchant or staff session is + // answered as nobody signed in, and its cookies are cleared, exactly like a + // session the platform rejected. + if (!toAuthUser(user).isPlatformAdmin) { + return anonymous(); + } + const session: AuthSession = { user: toAuthUser(user), // The cookie's own expiry is the browser-side lifetime; the platform's @@ -64,41 +71,46 @@ export async function GET() { ); } - const res = NextResponse.json( - {data: null, meta: {generatedAt: new Date().toISOString()}}, - {headers: {'cache-control': 'no-store'}}, - ); - - /** - * Clear THIS TAB's cookies, by their tab-scoped names. - * - * This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped - * names from before sessions were per-tab. Those cookies do not exist any - * more, so the clear silently did nothing and a confirmed 401 left the - * tab's real `loyaly_session_` in place. The result was the - * half-authenticated state upstreamSession warns about, with a twist: the - * client set itself unauthenticated and went to /login, the proxy saw a - * still-valid session cookie and sent it straight back, and the two flapped. - * - * Same two helpers the logout route uses, so there is one naming scheme and - * the two paths cannot drift. Scoped to the resolved tab and no other: a - * dead session in one tab says nothing about the others, and clearing more - * than asked would sign out a tab that is working fine. - * - * A request with no resolvable tab clears nothing. There is no cookie to - * name, and guessing would reach into somebody else's session. - */ - const tabId = await resolveTabId(); - if (tabId) { - res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0)); - res.cookies.set(tokenCookieFor(tabId), '', { - httpOnly: true, - sameSite: 'lax', - secure: process.env.NODE_ENV === 'production', - path: '/', - maxAge: 0, - }); - } - return res; + return anonymous(); } } + +/** Nobody signed in: `data: null`, and this tab's session cookies cleared. */ +async function anonymous() { + const res = NextResponse.json( + {data: null, meta: {generatedAt: new Date().toISOString()}}, + {headers: {'cache-control': 'no-store'}}, + ); + + /** + * Clear THIS TAB's cookies, by their tab-scoped names. + * + * This used to clear `loyaly_session` and `loyaly_tokens` — the unscoped + * names from before sessions were per-tab. Those cookies do not exist any + * more, so the clear silently did nothing and a confirmed 401 left the + * tab's real `loyaly_session_` in place. The result was the + * half-authenticated state upstreamSession warns about, with a twist: the + * client set itself unauthenticated and went to /login, the proxy saw a + * still-valid session cookie and sent it straight back, and the two flapped. + * + * Same two helpers the logout route uses, so there is one naming scheme and + * the two paths cannot drift. Scoped to the resolved tab and no other: a + * dead session in one tab says nothing about the others, and clearing more + * than asked would sign out a tab that is working fine. + * + * A request with no resolvable tab clears nothing. There is no cookie to + * name, and guessing would reach into somebody else's session. + */ + const tabId = await resolveTabId(); + if (tabId) { + res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0)); + res.cookies.set(tokenCookieFor(tabId), '', { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: 0, + }); + } + return res; +} diff --git a/src/app/api/auth/sessions/[id]/route.ts b/src/app/api/auth/sessions/[id]/route.ts index 087f2c7..3f880ac 100644 --- a/src/app/api/auth/sessions/[id]/route.ts +++ b/src/app/api/auth/sessions/[id]/route.ts @@ -1,5 +1,6 @@ import type {NextRequest} from 'next/server'; import {authApi} from '@/services/api/authApi'; +import {refuseOffConsole} from '@/features/auth/services/serverSession'; import {proxyUpstream} from '@/shared/services/bff'; export const dynamic = 'force-dynamic'; @@ -15,6 +16,8 @@ export async function DELETE( req: NextRequest, {params}: {params: Promise<{id: string}>}, ) { + const refused = await refuseOffConsole(); + if (refused) return refused; const {id} = await params; return proxyUpstream(req, (token) => authApi.revokeSession(token, id)); } diff --git a/src/app/api/auth/sessions/revoke-others/route.ts b/src/app/api/auth/sessions/revoke-others/route.ts index 9b572a4..a2c92c1 100644 --- a/src/app/api/auth/sessions/revoke-others/route.ts +++ b/src/app/api/auth/sessions/revoke-others/route.ts @@ -1,5 +1,6 @@ import type {NextRequest} from 'next/server'; import {authApi} from '@/services/api/authApi'; +import {refuseOffConsole} from '@/features/auth/services/serverSession'; import {proxyUpstream} from '@/shared/services/bff'; export const dynamic = 'force-dynamic'; @@ -12,5 +13,7 @@ export const dynamic = 'force-dynamic'; * screen they are doing it from. */ export async function POST(req: NextRequest) { + const refused = await refuseOffConsole(); + if (refused) return refused; return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token)); } diff --git a/src/app/api/auth/sessions/route.ts b/src/app/api/auth/sessions/route.ts index c72ed38..fe7796c 100644 --- a/src/app/api/auth/sessions/route.ts +++ b/src/app/api/auth/sessions/route.ts @@ -1,5 +1,6 @@ import type {NextRequest} from 'next/server'; import {authApi} from '@/services/api/authApi'; +import {refuseOffConsole} from '@/features/auth/services/serverSession'; import {serveUpstream} from '@/shared/services/bff'; import {toDeviceSession} from '@/features/settings/services/mapSession'; @@ -14,6 +15,8 @@ export const dynamic = 'force-dynamic'; * from the browser they are reading the page in. */ export async function GET(req: NextRequest) { + const refused = await refuseOffConsole(); + if (refused) return refused; return serveUpstream(req, (token) => authApi.sessions(token), (list) => list.map(toDeviceSession), ); diff --git a/src/app/api/campaigns/route.ts b/src/app/api/campaigns/route.ts index 549d0dd..543ed7e 100644 --- a/src/app/api/campaigns/route.ts +++ b/src/app/api/campaigns/route.ts @@ -1,4 +1,5 @@ import type {NextRequest} from 'next/server'; +import {UpstreamError} from '@/services/api/apiClient'; import {engagementApi} from '@/services/api/engagementApi'; import {toReportWindow, toSiteParam} from '@/services/api/range'; import {serveUpstream} from '@/shared/services/bff'; @@ -10,7 +11,14 @@ export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest) { return serveUpstream(req, async (token, query) => { const window = toReportWindow(query.range, new Date(query.nowMs)); - const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId)); - return (list ?? []).map(toCampaign); + try { + const list = await engagementApi.campaigns(token, window, toSiteParam(query.storeId)); + return (list ?? []).map(toCampaign); + } catch (err) { + // The deployed platform predates this route: an empty panel, not a red + // error. Any other failure, a real 404 included, still surfaces. + if (err instanceof UpstreamError && err.isRouteMissing) return []; + throw err; + } }); } diff --git a/src/app/api/floor/visits/route.ts b/src/app/api/floor/visits/route.ts index 0eac254..37cd39c 100644 --- a/src/app/api/floor/visits/route.ts +++ b/src/app/api/floor/visits/route.ts @@ -49,7 +49,7 @@ export async function GET(req: NextRequest) { // If the upstream platform has not deployed /api/floor/visits yet, // answer with an empty list so the floor screen renders its clean empty state // rather than failing with 404. - if (err instanceof UpstreamError && err.status === 404) { + if (err instanceof UpstreamError && err.isRouteMissing) { return {items: []}; } throw err; diff --git a/src/app/api/reports/journey/route.ts b/src/app/api/reports/journey/route.ts index d168c7f..a4cb645 100644 --- a/src/app/api/reports/journey/route.ts +++ b/src/app/api/reports/journey/route.ts @@ -1,4 +1,5 @@ import type {NextRequest} from 'next/server'; +import {UpstreamError} from '@/services/api/apiClient'; import {engagementApi} from '@/services/api/engagementApi'; import {toReportWindow, toSiteParam} from '@/services/api/range'; import {serveUpstream} from '@/shared/services/bff'; @@ -14,8 +15,17 @@ export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest) { return serveUpstream(req, async (token, query) => { const window = toReportWindow(query.range, new Date(query.nowMs)); - return toJourney( - await engagementApi.journey(token, window, toSiteParam(query.storeId)), - ); + try { + return toJourney( + await engagementApi.journey(token, window, toSiteParam(query.storeId)), + ); + } catch (err) { + // The deployed platform predates this route: an empty panel, not a red + // error. Any other failure, a real 404 included, still surfaces. + if (err instanceof UpstreamError && err.isRouteMissing) { + return {stages: [], attribution: 'observed'}; + } + throw err; + } }); } diff --git a/src/app/api/sales/route.ts b/src/app/api/sales/route.ts index 26bc7c3..61dd91e 100644 --- a/src/app/api/sales/route.ts +++ b/src/app/api/sales/route.ts @@ -57,8 +57,9 @@ export async function GET(req: NextRequest) { } catch (err) { // If the upstream platform has not deployed /api/sales yet, // answer with an empty list so the sales screen renders cleanly - // rather than failing with 404. - if (err instanceof UpstreamError && err.status === 404) { + // rather than failing. Only a missing ROUTE — a 404 for a shop that + // does not exist is a real answer and still surfaces. + if (err instanceof UpstreamError && err.isRouteMissing) { return {items: []}; } throw err; @@ -87,6 +88,7 @@ export async function GET(req: NextRequest) { export async function POST(req: NextRequest) { let body: { idempotencyKey?: unknown; + clientCreatedAt?: unknown; visitId?: unknown; visitorId?: unknown; invoiceNo?: unknown; @@ -125,7 +127,7 @@ export async function POST(req: NextRequest) { site: typeof body.site === 'string' ? body.site : undefined, visit_id: typeof body.visitId === 'string' ? body.visitId : undefined, visitor_id: typeof body.visitorId === 'string' ? body.visitorId : undefined, - client_created_at: new Date().toISOString(), + client_created_at: draftTime(body.clientCreatedAt), lines, }), ); @@ -139,10 +141,16 @@ export async function POST(req: NextRequest) { sale: result.sale ? toSale(result.sale) : null, }, }, - {status: 201, headers: {'cache-control': 'no-store'}}, + // The platform's own status: 201 for a new sale, 200 for a replay. + { + status: result.status === 'already_processed' ? 200 : 201, + headers: {'cache-control': 'no-store'}, + }, ); } catch (err) { - if (err instanceof UpstreamError && err.status === 404) { + // 404 "No such endpoint." or 405 — the deployed platform has no sale + // writer yet. Any other 404 (a visit that is not this shop's) is real. + if (err instanceof UpstreamError && err.isRouteMissing) { return Response.json( { error: { @@ -161,3 +169,17 @@ export async function POST(req: NextRequest) { ); } } + +/** + * When the sale was drafted on the device, as the dialog stamped it. Falls + * back to now for a missing or unparseable value, and for one in the future — + * a device clock ahead of the server must not date a sale tomorrow. + */ +function draftTime(value: unknown): string { + const now = Date.now(); + if (typeof value !== 'string') return new Date(now).toISOString(); + const t = Date.parse(value); + return Number.isFinite(t) && t <= now + ? new Date(t).toISOString() + : new Date(now).toISOString(); +} diff --git a/src/app/api/settings/profile/route.ts b/src/app/api/settings/profile/route.ts new file mode 100644 index 0000000..ad35b77 --- /dev/null +++ b/src/app/api/settings/profile/route.ts @@ -0,0 +1,23 @@ +import {fail} from '@/shared/services/apiRoute'; + +export const dynamic = 'force-dynamic'; + +/** + * GET / PATCH /api/settings/profile — the merchant's business profile. + * + * The platform has no profile endpoint yet (docs/API-STATUS.md), so there is + * nothing to forward to. Without this file Next.js answered its own HTML 404 + * page, which the settings screen could only report as "Unexpected response". + * This says what is actually true, in the envelope the screen reads. Once the + * platform ships the route, replace both handlers with `serveUpstream` / + * `proxyUpstream` calls against it. + */ +const NOT_YET = 'Business profile is not available on the platform yet.'; + +export function GET() { + return fail('not_deployed', NOT_YET, 501); +} + +export function PATCH() { + return fail('not_deployed', NOT_YET, 501); +} diff --git a/src/features/admin/components/AdminAccount.tsx b/src/features/admin/components/AdminAccount.tsx index ac611ab..b544ae3 100644 --- a/src/features/admin/components/AdminAccount.tsx +++ b/src/features/admin/components/AdminAccount.tsx @@ -15,7 +15,14 @@ import {StaticPanel} from '@/shared/components/patterns/PanelCard'; import {useThemeMode} from '@/shared/providers/ThemeModeProvider'; import type {ThemeMode} from '@/shared/theme/themeMode'; import {useSession} from '@/features/auth/providers/SessionProvider'; -import {ActiveSessionsPanel} from '@/features/settings/components/ActiveSessionsPanel'; +import {StatusDot} from '@astryxdesign/core/StatusDot'; +import {Icon} from '@astryxdesign/core/Icon'; +import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary'; +import {SkeletonRows} from '@/shared/components/patterns/LoadingState'; +import {ICONS} from '@/shared/utils/icons'; +import {useCompanies} from '@/features/admin/hooks/useCompanies'; +import {summarise} from '@/features/admin/hooks/useMonitoring'; +import {adminHref} from './MonitoringTables'; import {AdminPageHeader} from './common/AdminPageHeader'; import {PendingIntegration} from './common/PendingIntegration'; @@ -27,8 +34,8 @@ import {PendingIntegration} from './common/PendingIntegration'; * editable, because the only profile write (`PATCH /api/settings/profile`) is * tenant-scoped and an admin has no tenant. * - * Settings has two live parts: appearance (a cookie this app owns) and active - * sessions (`/api/auth/sessions`, scoped to the person, not a tenant). Password + * Settings has two live parts: appearance (a cookie this app owns) and the + * merchant activity status list (`GET /api/admin/clients`). Password * change and two-factor have no platform endpoint, so they say so rather than * render a form that saves nowhere — the merchant Security page's password * form and 2FA switch are exactly that, and are not reused here. @@ -106,7 +113,7 @@ export function AdminSettings() { Profile} /> @@ -125,7 +132,7 @@ export function AdminSettings() { - + ); } + +/** + * Every merchant and whether it is active or suspended. + * + * Only what `GET /api/admin/clients` returns: `isActive`, `sites`, `users`, + * `createdAt`. The platform has no last-seen or last-activity field, so none + * is shown. Suspended merchants sort first — they are the rows an admin opens + * this list to find. + */ +function MerchantActivityStatus() { + const companies = useCompanies(); + + return ( + }> + {(rows) => { + const s = summarise(rows); + const sorted = [...rows].sort( + (a, b) => + Number(a.isActive) - Number(b.isActive) || + a.name.localeCompare(b.name), + ); + return ( + View all} + > + + {sorted.map((c) => ( + } + endContent={ + + + + {c.isActive ? 'Active' : 'Suspended'} + + + + } + /> + ))} + + + ); + }} + + ); +} + +function formatDate(iso: string): string { + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? iso + : d.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); +} diff --git a/src/features/admin/components/AdminLayout.tsx b/src/features/admin/components/AdminLayout.tsx index 9192542..89db789 100644 --- a/src/features/admin/components/AdminLayout.tsx +++ b/src/features/admin/components/AdminLayout.tsx @@ -1,11 +1,11 @@ 'use client'; -import {useRouter} from 'next/navigation'; -import {HStack, VStack} from '@astryxdesign/core/Layout'; -import {EmptyState} from '@astryxdesign/core/EmptyState'; -import {Button} from '@astryxdesign/core/Button'; +import {useEffect} from 'react'; +import {Center} from '@astryxdesign/core/Center'; +import {Spinner} from '@astryxdesign/core/Spinner'; import {AuthGuard} from '@/features/auth/guards/AuthGuard'; import {useSession} from '@/features/auth/providers/SessionProvider'; +import {destinationForUser} from '@/features/auth/services/roleDestination'; import {AdminShell} from './shell/AdminShell'; /** @@ -29,51 +29,40 @@ export function AdminLayout({children}: {children: React.ReactNode}) { } /** - * What a merchant session sees if it reaches the platform console. + * Platform admins only. A merchant or staff session never sees this console. * - * The proxy normally redirects a merchant away before this renders, but it - * resolves the session from the tab POINTER cookie, while this tab's client - * session comes from its own tab id (see tabSession.ts). With an operator - * signed in in one tab and a merchant in another, the server can render this - * shell for the operator's pointer while the tab itself holds the merchant. + * The proxy redirects them before this renders, but it resolves a DOCUMENT + * request from the tab POINTER cookie, while this tab's client session comes + * from its own tab id (see tabSession.ts). With an operator signed in in one + * tab and a merchant in another, the server can render /admin for the + * operator's pointer while this tab holds the merchant. * - * An automatic redirect to /dashboard was tried and does not work: the proxy, - * reading the same operator pointer, sends /dashboard straight back to /admin, - * and the tab settles on a blank page. So this says what happened and offers - * the two ways out, instead of guessing. + * This used to show an explanation with a "Go to dashboard" link. Now it sends + * the tab straight to its own home — /dashboard for a merchant, /floor for + * staff — and renders nothing of the admin console in between. * - * Routing only, like the proxy. Nothing leaks either way: every admin read - * carries this tab's id and the platform answers 404 to a merchant. + * A full navigation rather than `router.replace`: the tab session script claims + * the pointer on `beforeunload`, so the next document request is gated as THIS + * tab. A client-side RSC fetch could be bounced back to /admin by the + * operator's pointer — the loop that made the old version give up and explain. + * + * Routing only, like the proxy. Every admin read carries this tab's id, and the + * proxy and platform answer 404 to a non-admin either way. */ function OperatorsOnly({children}: {children: React.ReactNode}) { - const {isAuthenticated, user, logout} = useSession(); - const router = useRouter(); + const {user} = useSession(); + const isOperator = user?.isPlatformAdmin === true; + const home = user && !isOperator ? destinationForUser(user) : null; - if (user?.isPlatformAdmin === true) return <>{children}; - if (!isAuthenticated) return null; + useEffect(() => { + if (home) window.location.replace(home); + }, [home]); - async function signOut() { - await logout(); - router.replace('/login?next=%2Fadmin'); - } + if (isOperator) return <>{children}; return ( - - -