diff --git a/src/app/(workspace)/settings/security/page.tsx b/src/app/(workspace)/settings/security/page.tsx
index e1eaf9e..a567727 100644
--- a/src/app/(workspace)/settings/security/page.tsx
+++ b/src/app/(workspace)/settings/security/page.tsx
@@ -1,4 +1,5 @@
import {SettingsPage} from '@/features/settings/components/SettingsPage';
+import {AccountCard} from '@/features/settings/components/AccountCard';
import {SecurityManager} from '@/features/settings/components/SecurityManager';
export default function SecuritySettingsPage() {
@@ -7,6 +8,9 @@ export default function SecuritySettingsPage() {
title="Security & Audit Logs"
description="Two-Factor authentication, password management, active login sessions and security audit history."
>
+ {/* Who you are, then the devices signed in as you — both read from the
+ platform. The panels below them are still local-only. */}
+
);
diff --git a/src/app/(workspace)/stores/page.tsx b/src/app/(workspace)/stores/page.tsx
index 9a99fd2..c14c3d6 100644
--- a/src/app/(workspace)/stores/page.tsx
+++ b/src/app/(workspace)/stores/page.tsx
@@ -1,17 +1,12 @@
'use client';
-import {VStack, HStack} from '@astryxdesign/core/Layout';
-import {Grid} from '@astryxdesign/core/Grid';
-import {Card} from '@astryxdesign/core/Card';
-import {Text, Heading} from '@astryxdesign/core/Text';
-import {StatusDot} from '@astryxdesign/core/StatusDot';
+import {VStack} from '@astryxdesign/core/Layout';
import {PageHeader} from '@/shared/components/primitives/PageHeader';
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState';
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
-import {StatPair, StatRow} from '@/shared/components/patterns/StatPair';
import {useSites} from '@/features/stores/hooks/useSites';
-import {formatPct} from '@/shared/utils/format';
+import {ShopSection} from '@/features/stores/components/ShopSection';
/**
* The estate, from GET /api/sites.
@@ -28,7 +23,7 @@ export default function StoresPage() {
{(rows) => (
-
+
{rows.map((site) => (
-
-
-
- {site.name}
- {site.isOnline === null ? null : (
-
-
-
- {site.isOnline ? 'Online' : 'Offline'}
-
-
- )}
-
-
-
- {site.id}
-
-
-
-
-
-
-
-
+
))}
-
+
)}
diff --git a/src/app/api/auth/sessions/[id]/route.ts b/src/app/api/auth/sessions/[id]/route.ts
new file mode 100644
index 0000000..087f2c7
--- /dev/null
+++ b/src/app/api/auth/sessions/[id]/route.ts
@@ -0,0 +1,20 @@
+import type {NextRequest} from 'next/server';
+import {authApi} from '@/services/api/authApi';
+import {proxyUpstream} from '@/shared/services/bff';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * DELETE /api/auth/sessions/{id} — sign one device out.
+ *
+ * Revoking the CURRENT session is allowed and signs this browser out — which is
+ * a legitimate thing to want and a surprising thing to do by accident, so the
+ * screen warns before calling it rather than this route refusing.
+ */
+export async function DELETE(
+ req: NextRequest,
+ {params}: {params: Promise<{id: string}>},
+) {
+ 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
new file mode 100644
index 0000000..9b572a4
--- /dev/null
+++ b/src/app/api/auth/sessions/revoke-others/route.ts
@@ -0,0 +1,16 @@
+import type {NextRequest} from 'next/server';
+import {authApi} from '@/services/api/authApi';
+import {proxyUpstream} from '@/shared/services/bff';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/auth/sessions/revoke-others — sign out everywhere else.
+ *
+ * Keeps the caller's own session alive by design, so somebody who suspects a
+ * leak can clear every other device without locking themselves out of the
+ * screen they are doing it from.
+ */
+export async function POST(req: NextRequest) {
+ 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
new file mode 100644
index 0000000..c72ed38
--- /dev/null
+++ b/src/app/api/auth/sessions/route.ts
@@ -0,0 +1,20 @@
+import type {NextRequest} from 'next/server';
+import {authApi} from '@/services/api/authApi';
+import {serveUpstream} from '@/shared/services/bff';
+import {toDeviceSession} from '@/features/settings/services/mapSession';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * GET /api/auth/sessions — every device currently signed in as this person.
+ *
+ * `current: true` marks the one making this request. It is the reason this list
+ * is worth showing at all: a session the user does not recognise is how they
+ * find out a password has leaked, and they need to be able to tell it apart
+ * from the browser they are reading the page in.
+ */
+export async function GET(req: NextRequest) {
+ return serveUpstream(req, (token) => authApi.sessions(token), (list) =>
+ list.map(toDeviceSession),
+ );
+}
diff --git a/src/app/api/cameras/[id]/check/route.ts b/src/app/api/cameras/[id]/check/route.ts
new file mode 100644
index 0000000..fe6f468
--- /dev/null
+++ b/src/app/api/cameras/[id]/check/route.ts
@@ -0,0 +1,34 @@
+import type {NextRequest} from 'next/server';
+import {sitesApi} from '@/services/api/sitesApi';
+import {proxyUpstream} from '@/shared/services/bff';
+import {toCamera} from '@/features/stores/services/mapCamera';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/cameras/{id}/check — ask the shop PC to prove this camera works.
+ *
+ * Two kinds: `connection` (can it be reached at all) and `placement` (is the
+ * view usable for recognition). Anything else the platform rejects, so the
+ * union is narrowed here rather than passed through as a free string.
+ *
+ * The platform answers 202 and the camera it returns still carries the PREVIOUS
+ * check — the edge has not run the new one yet. The caller re-reads; it must
+ * not render this response as the verdict.
+ */
+export async function POST(
+ req: NextRequest,
+ {params}: {params: Promise<{id: string}>},
+) {
+ const {id} = await params;
+ return proxyUpstream(
+ req,
+ (token, body) =>
+ sitesApi.checkCamera(
+ token,
+ id,
+ body.kind === 'placement' ? 'placement' : 'connection',
+ ),
+ {map: toCamera, status: 202},
+ );
+}
diff --git a/src/app/api/cameras/[id]/route.ts b/src/app/api/cameras/[id]/route.ts
new file mode 100644
index 0000000..8f592f4
--- /dev/null
+++ b/src/app/api/cameras/[id]/route.ts
@@ -0,0 +1,36 @@
+import type {NextRequest} from 'next/server';
+import {sitesApi} from '@/services/api/sitesApi';
+import {proxyUpstream} from '@/shared/services/bff';
+import {toCamera} from '@/features/stores/services/mapCamera';
+import type {ApiCameraInput} from '@/services/api/types';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * PATCH /api/cameras/{id} — edit one camera.
+ * DELETE /api/cameras/{id} — remove it.
+ *
+ * PATCH rather than PUT, matching the platform: a form that leaves the password
+ * blank means "keep the stored one", and a PUT would read that as "clear it".
+ */
+export async function PATCH(
+ req: NextRequest,
+ {params}: {params: Promise<{id: string}>},
+) {
+ const {id} = await params;
+ return proxyUpstream(
+ req,
+ (token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput),
+ {map: toCamera},
+ );
+}
+
+export async function DELETE(
+ req: NextRequest,
+ {params}: {params: Promise<{id: string}>},
+) {
+ const {id} = await params;
+ // The platform answers 204 with no body; proxyUpstream sends `data: null`
+ // rather than an empty object, so the client can tell "done" from "malformed".
+ return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id));
+}
diff --git a/src/app/api/cameras/route.ts b/src/app/api/cameras/route.ts
new file mode 100644
index 0000000..c11e846
--- /dev/null
+++ b/src/app/api/cameras/route.ts
@@ -0,0 +1,39 @@
+import type {NextRequest} from 'next/server';
+import {sitesApi} from '@/services/api/sitesApi';
+import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
+import {toCamera} from '@/features/stores/services/mapCamera';
+import type {ApiCameraInput} from '@/services/api/types';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * GET /api/cameras?site= — the cameras on one shop, or all of them.
+ * POST /api/cameras?site= — add one to that shop.
+ *
+ * The POST carries the shop in the QUERY rather than the path because the
+ * platform creates under /api/sites/{site}/cameras while it reads from
+ * /api/cameras — two different shapes for one resource. Collapsing them here
+ * keeps that asymmetry out of every component.
+ */
+export async function GET(req: NextRequest) {
+ const site = req.nextUrl.searchParams.get('site') ?? undefined;
+ return serveUpstream(req, (token) => sitesApi.cameras(token, site), (cams) =>
+ cams.map(toCamera),
+ );
+}
+
+export async function POST(req: NextRequest) {
+ const site = req.nextUrl.searchParams.get('site') ?? '';
+ if (!site) {
+ return Response.json(
+ {error: {code: 'bad_request', message: 'Which shop is this camera in?'}},
+ {status: 400},
+ );
+ }
+
+ return proxyUpstream(
+ req,
+ (token, body) => sitesApi.addCamera(token, site, body as ApiCameraInput),
+ {map: toCamera, status: 201},
+ );
+}
diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts
new file mode 100644
index 0000000..ea95ebc
--- /dev/null
+++ b/src/app/api/images/route.ts
@@ -0,0 +1,67 @@
+import type {NextRequest} from 'next/server';
+import {upstreamRaw} from '@/services/api/apiClient';
+import {withUpstream} from '@/features/auth/services/upstreamSession';
+import {failResponse} from '@/shared/services/bff';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * GET /api/images?src= — any authenticated picture, proxied.
+ *
+ * The same hop as /api/faces and for the same reason: a browser `` cannot
+ * send an Authorization header, and every platform image URL requires one.
+ *
+ * This exists alongside /api/faces rather than replacing it. That route accepts
+ * exactly one namespace, which was right while faces were the only pictures in
+ * the product; camera snapshots are not under /api/faces/, so they could not be
+ * displayed through it at all. /api/faces is left untouched so nothing that
+ * works today changes, and new callers use this.
+ *
+ * ── Why an allowlist of shapes, not a prefix test ────────────────────────
+ * An unchecked pass-through is an open proxy that attaches the merchant's
+ * bearer token to whatever URL an attacker can get into a page. Each pattern
+ * below is anchored at both ends and permits no slash inside the id segment, so
+ * `/api/faces/../../admin/clients` cannot masquerade as a face. The `..` test is
+ * belt and braces on top of that.
+ *
+ * Adding a fourth kind of image means adding a line here, deliberately.
+ */
+const ALLOWED = [
+ /^\/api\/faces\/[^/?]+$/,
+ /^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/,
+ /^\/api\/visitors\/[^/?]+\/image$/,
+];
+
+/** Exported so a caller can decide whether to render an at all. */
+export function isProxyableImage(src: string): boolean {
+ return !src.includes('..') && ALLOWED.some((re) => re.test(src.split('?')[0]));
+}
+
+export async function GET(req: NextRequest) {
+ const src = req.nextUrl.searchParams.get('src') ?? '';
+
+ if (!isProxyableImage(src)) {
+ return Response.json(
+ {error: {code: 'bad_request', message: 'Not a valid image reference.'}},
+ {status: 400},
+ );
+ }
+
+ try {
+ const upstream = await withUpstream((token) =>
+ upstreamRaw({path: src, accessToken: token}),
+ );
+
+ return new Response(upstream.body, {
+ status: 200,
+ headers: {
+ 'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
+ // Private: this is one merchant's shop floor, and a shared cache
+ // holding it would serve it across tenants.
+ 'cache-control': 'private, max-age=300',
+ },
+ });
+ } catch (err) {
+ return failResponse(err);
+ }
+}
diff --git a/src/app/api/sites/[site]/enrolment-code/route.ts b/src/app/api/sites/[site]/enrolment-code/route.ts
new file mode 100644
index 0000000..031a500
--- /dev/null
+++ b/src/app/api/sites/[site]/enrolment-code/route.ts
@@ -0,0 +1,30 @@
+import type {NextRequest} from 'next/server';
+import {sitesApi} from '@/services/api/sitesApi';
+import {proxyUpstream} from '@/shared/services/bff';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/sites/{site}/enrolment-code — a one-time code that enrols a shop PC.
+ *
+ * The code comes back ONCE and is not recoverable: the platform stores a hash,
+ * exactly as it does for a team invitation. So this is a POST even though it
+ * reads like a fetch — asking twice mints two codes rather than showing the
+ * same one, and a GET would invite a browser or a prefetch to do that silently.
+ */
+export async function POST(
+ req: NextRequest,
+ {params}: {params: Promise<{site: string}>},
+) {
+ const {site} = await params;
+ return proxyUpstream(
+ req,
+ (token, body) =>
+ sitesApi.enrolmentCode(
+ token,
+ site,
+ typeof body.label === 'string' ? body.label : undefined,
+ ),
+ {status: 201},
+ );
+}
diff --git a/src/app/api/team/[id]/password/route.ts b/src/app/api/team/[id]/password/route.ts
new file mode 100644
index 0000000..01828b7
--- /dev/null
+++ b/src/app/api/team/[id]/password/route.ts
@@ -0,0 +1,32 @@
+import type {NextRequest} from 'next/server';
+import {teamApi} from '@/services/api/teamApi';
+import {proxyUpstream} from '@/shared/services/bff';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/team/{id}/password — set a new password for somebody.
+ *
+ * The response carries the password ONCE. It is bcrypt-hashed on the way in and
+ * is not recoverable afterwards, so the screen must show it immediately and
+ * must not stash it anywhere it could be read back.
+ *
+ * Omitting `password` has the platform generate a strong one, which is the
+ * better default — a password an operator invents for somebody else is weak and
+ * ends up in a chat message.
+ */
+export async function POST(
+ req: NextRequest,
+ {params}: {params: Promise<{id: string}>},
+) {
+ const {id} = await params;
+ return proxyUpstream(req, (token, body) =>
+ teamApi.resetPassword(
+ token,
+ id,
+ typeof body.password === 'string' && body.password !== ''
+ ? body.password
+ : undefined,
+ ),
+ );
+}
diff --git a/src/app/api/team/[id]/route.ts b/src/app/api/team/[id]/route.ts
new file mode 100644
index 0000000..a663b5d
--- /dev/null
+++ b/src/app/api/team/[id]/route.ts
@@ -0,0 +1,34 @@
+import type {NextRequest} from 'next/server';
+import {teamApi} from '@/services/api/teamApi';
+import {proxyUpstream} from '@/shared/services/bff';
+import {toMember} from '@/features/team/services/mapTeam';
+import type {ApiRole} from '@/services/api/types';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * PATCH /api/team/{id} — change somebody's role, or switch their access off.
+ *
+ * Deactivating revokes every session that person holds IMMEDIATELY; it is not a
+ * soft flag that takes effect at next sign-in. The UI is expected to confirm
+ * before calling this.
+ *
+ * The platform answers 409 `last_owner` when this would leave the company with
+ * no active owner. That travels through `failResponse` with its reason intact,
+ * so the screen can say which rule was hit rather than "something went wrong".
+ */
+export async function PATCH(
+ req: NextRequest,
+ {params}: {params: Promise<{id: string}>},
+) {
+ const {id} = await params;
+ return proxyUpstream(
+ req,
+ (token, body) =>
+ teamApi.update(token, id, {
+ role: typeof body.role === 'string' ? (body.role as ApiRole) : undefined,
+ active: typeof body.active === 'boolean' ? body.active : undefined,
+ }),
+ {map: toMember},
+ );
+}
diff --git a/src/app/api/team/invitations/[id]/route.ts b/src/app/api/team/invitations/[id]/route.ts
new file mode 100644
index 0000000..a73277d
--- /dev/null
+++ b/src/app/api/team/invitations/[id]/route.ts
@@ -0,0 +1,19 @@
+import type {NextRequest} from 'next/server';
+import {teamApi} from '@/services/api/teamApi';
+import {proxyUpstream} from '@/shared/services/bff';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * DELETE /api/team/invitations/{id} — withdraw an invitation.
+ *
+ * The code stops working immediately. There is no way to un-withdraw it; a
+ * change of mind means minting a new one.
+ */
+export async function DELETE(
+ req: NextRequest,
+ {params}: {params: Promise<{id: string}>},
+) {
+ const {id} = await params;
+ return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id));
+}
diff --git a/src/app/api/team/invitations/route.ts b/src/app/api/team/invitations/route.ts
new file mode 100644
index 0000000..b48f402
--- /dev/null
+++ b/src/app/api/team/invitations/route.ts
@@ -0,0 +1,39 @@
+import type {NextRequest} from 'next/server';
+import {teamApi} from '@/services/api/teamApi';
+import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
+import {toInvitation} from '@/features/team/services/mapTeam';
+import type {ApiRole} from '@/services/api/types';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * GET /api/team/invitations — who has been invited and not yet joined.
+ * POST /api/team/invitations — invite somebody.
+ *
+ * The invitation is the PREFERRED way to add a person: they redeem the code and
+ * choose their own password, so the merchant never handles it. The code comes
+ * back once on the POST and never again.
+ */
+export async function GET(req: NextRequest) {
+ return serveUpstream(req, (token) => teamApi.invitations(token), (list) =>
+ list.map(toInvitation),
+ );
+}
+
+export async function POST(req: NextRequest) {
+ return proxyUpstream(
+ req,
+ (token, body) =>
+ teamApi.invite(token, {
+ email: String(body.email ?? '').trim(),
+ full_name:
+ typeof body.full_name === 'string' ? body.full_name : undefined,
+ role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
+ expires_in_days:
+ typeof body.expires_in_days === 'number'
+ ? body.expires_in_days
+ : undefined,
+ }),
+ {map: toInvitation, status: 201},
+ );
+}
diff --git a/src/app/api/team/members/route.ts b/src/app/api/team/members/route.ts
new file mode 100644
index 0000000..9acab26
--- /dev/null
+++ b/src/app/api/team/members/route.ts
@@ -0,0 +1,34 @@
+import type {NextRequest} from 'next/server';
+import {teamApi} from '@/services/api/teamApi';
+import {proxyUpstream} from '@/shared/services/bff';
+import type {ApiRole} from '@/services/api/types';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * POST /api/team/members — create a login directly and hand the password over.
+ *
+ * The other way in is an invitation, where the person chooses their own
+ * password and the merchant never sees it. That is the better path and the UI
+ * offers it first; this exists for somebody standing at the counter with no
+ * phone to redeem a code on.
+ *
+ * Answers 201 with the member AND the generated password, shown once.
+ */
+export async function POST(req: NextRequest) {
+ return proxyUpstream(
+ req,
+ (token, body) =>
+ teamApi.createMember(token, {
+ email: String(body.email ?? '').trim(),
+ full_name:
+ typeof body.full_name === 'string' ? body.full_name : undefined,
+ role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
+ password:
+ typeof body.password === 'string' && body.password !== ''
+ ? body.password
+ : undefined,
+ }),
+ {status: 201},
+ );
+}
diff --git a/src/app/api/team/route.ts b/src/app/api/team/route.ts
index 85bf070..90c8728 100644
--- a/src/app/api/team/route.ts
+++ b/src/app/api/team/route.ts
@@ -1,9 +1,7 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {serveUpstream} from '@/shared/services/bff';
-import type {ApiTeamMember} from '@/services/api/types';
-import type {UserRole} from '@/features/auth/types/auth';
-import type {TeamMember} from '@/features/team/types/team';
+import {toMember} from '@/features/team/services/mapTeam';
export const dynamic = 'force-dynamic';
@@ -20,22 +18,6 @@ export const dynamic = 'force-dynamic';
* row, while `active`, `last_login_at` and `created_at` were discarded. The
* one field the team screen needs — who still has access — never arrived.
*/
-function toMember(m: ApiTeamMember): TeamMember {
- return {
- id: m.id,
- // Falls back to the address rather than rendering a blank cell: somebody
- // invited but not yet named still has to be identifiable.
- name: m.full_name || m.email,
- email: m.email,
- role: m.role as UserRole,
- active: m.active,
- // Null rather than '' — "has never signed in" and "signed in at an unknown
- // time" are different facts, and the screen says so.
- lastLoginAt: m.last_login_at || null,
- createdAt: m.created_at,
- };
-}
-
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => teamApi.list(token), (members) =>
members.map(toMember),
diff --git a/src/features/settings/components/AccountCard.tsx b/src/features/settings/components/AccountCard.tsx
new file mode 100644
index 0000000..d5c95f0
--- /dev/null
+++ b/src/features/settings/components/AccountCard.tsx
@@ -0,0 +1,58 @@
+'use client';
+
+import {Card} from '@astryxdesign/core/Card';
+import {VStack, HStack} from '@astryxdesign/core/Layout';
+import {Text} from '@astryxdesign/core/Text';
+import {Badge} from '@astryxdesign/core/Badge';
+import {Avatar} from '@astryxdesign/core/Avatar';
+import {SectionHeader} from '@/shared/components/patterns/SectionHeader';
+import {useSession} from '@/features/auth/providers/SessionProvider';
+
+/**
+ * Who you are signed in as.
+ *
+ * Read from the SESSION rather than fetched: it is the same object the proxy
+ * verified and the server rendered with, so this cannot disagree with what the
+ * rest of the console believes about the current user, and it costs no request.
+ *
+ * A plain Card rather than PanelCard because there is nothing async here —
+ * PanelCard exists to wrap a Resource, and passing it a fake one to reuse the
+ * header would be the tail wagging the dog.
+ *
+ * There is no edit: a merchant cannot change their own role, and the platform
+ * serves no self-service profile endpoint.
+ */
+export function AccountCard() {
+ const {user} = useSession();
+
+ if (!user) return null;
+
+ return (
+
+
+
+
+
+
+
+ {user.name}
+
+
+ {user.email}
+
+
+
+ {user.organisation ? (
+
+ {user.organisation}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/features/settings/components/SecurityManager.tsx b/src/features/settings/components/SecurityManager.tsx
index 2e9d13d..57cf01c 100644
--- a/src/features/settings/components/SecurityManager.tsx
+++ b/src/features/settings/components/SecurityManager.tsx
@@ -11,7 +11,12 @@ import {Button} from '@astryxdesign/core/Button';
import {Switch} from '@astryxdesign/core/Switch';
import {TextInput} from '@astryxdesign/core/TextInput';
import {useToast} from '@astryxdesign/core/Toast';
-import {StaticPanel} from '@/shared/components/patterns/PanelCard';
+import {PanelCard, StaticPanel} from '@/shared/components/patterns/PanelCard';
+import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
+import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
+import {useResource} from '@/shared/hooks/useResource';
+import {securityRepository} from '@/features/settings/repositories/securityRepository';
+import type {DeviceSession} from '@/features/settings/services/mapSession';
export interface ActiveSessionItem extends Record {
id: string;
@@ -31,33 +36,6 @@ export interface LoginAuditItem extends Record {
timestamp: string;
}
-const INITIAL_SESSIONS: ActiveSessionItem[] = [
- {
- id: 'sess-1',
- device: 'macOS Chrome 126',
- location: 'Bengaluru, India',
- ip: '103.15.24.81',
- lastActive: 'Active Now',
- isCurrent: true,
- },
- {
- id: 'sess-2',
- device: 'iOS Safari 17',
- location: 'Bengaluru, India',
- ip: '49.207.210.12',
- lastActive: '2 hours ago',
- isCurrent: false,
- },
- {
- id: 'sess-3',
- device: 'Windows Edge 125',
- location: 'Bengaluru, India',
- ip: '106.51.72.19',
- lastActive: 'Yesterday, 18:40',
- isCurrent: false,
- },
-];
-
const AUDIT_LOGS: LoginAuditItem[] = [
{
id: 'aud-1',
@@ -88,7 +66,19 @@ const AUDIT_LOGS: LoginAuditItem[] = [
export function SecurityManager() {
const toast = useToast();
const [twoFactor, setTwoFactor] = useState(true);
- const [sessions, setSessions] = useState(INITIAL_SESSIONS);
+ /**
+ * Real devices, from GET /api/auth/sessions.
+ *
+ * This was three invented rows in useState, with a `location` and an `ip` the
+ * platform does not return at all — a merchant checking for an unfamiliar
+ * sign-in was reading fiction. Revoking one spliced the local array and the
+ * row came back on refresh.
+ *
+ * Everything else in this file — the 2FA switch, the password form and the
+ * audit trail — is still local-only. Those have no endpoint on the platform
+ * and are deliberately left as they were rather than deleted.
+ */
+ const sessions = useResource(securityRepository.sessions());
const [currentPass, setCurrentPass] = useState('');
const [newPass, setNewPass] = useState('');
const [confirmPass, setConfirmPass] = useState('');
@@ -113,42 +103,51 @@ export function SecurityManager() {
}, 400);
};
- const handleRevokeSession = (id: string) => {
- setSessions((prev) => prev.filter((s) => s.id !== id));
- toast({body: 'Session revoked successfully'});
+ const handleRevokeSession = async (id: string) => {
+ const res = await securityRepository.revoke(id);
+ if (!res.ok) {
+ toast({type: 'error', body: res.message ?? 'Could not sign that device out.'});
+ return;
+ }
+ // Re-read rather than splice: the platform decides what is still live, and
+ // a locally filtered array is a guess that survives until the next refresh.
+ sessions.refetch();
+ toast({body: 'That device has been signed out.'});
};
- const handleRevokeAllOther = () => {
- setSessions((prev) => prev.filter((s) => s.isCurrent));
- toast({body: 'All other active sessions have been terminated'});
+ const handleRevokeAllOther = async () => {
+ const res = await securityRepository.revokeOthers();
+ if (!res.ok) {
+ toast({type: 'error', body: res.message ?? 'Could not sign the other devices out.'});
+ return;
+ }
+ sessions.refetch();
+ toast({body: 'Every other device has been signed out.'});
};
- const sessionColumns: TableColumn[] = [
+ const sessionColumns: TableColumn>[] = [
{
key: 'device',
- header: 'Device / Browser',
+ header: 'Device',
width: proportional(2),
renderCell: (row) => (
-
-
-
- {row.device}
-
- {row.isCurrent ? : null}
-
-
- {row.location} • {row.ip}
+
+
+ {row.device}
-
+ {row.isCurrent ? (
+
+ ) : null}
+
),
},
{
- key: 'lastActive',
- header: 'Last Active',
+ key: 'lastUsedAt',
+ header: 'Last used',
width: proportional(1.2),
renderCell: (row) => (
- {row.lastActive}
+ {new Date(row.lastUsedAt).toLocaleString()}
),
},
@@ -157,14 +156,17 @@ export function SecurityManager() {
header: 'Action',
align: 'center',
width: pixel(120),
+ // The current session CAN be revoked upstream, but doing it from this
+ // table signs you out mid-page with no warning. Signing out is what the
+ // account menu is for.
renderCell: (row) =>
row.isCurrent ? null : (
),
@@ -275,24 +277,36 @@ export function SecurityManager() {
- }
+ empty={
+
+ }
actions={