Stores, Team and Account run against the platform, not a mock

Stores: each shop's health in one line (offline, losing visits, camera
trouble, faces too poor - in severity order, one verdict), its cameras
as pictures with connection and 'proven to recognise a face' as two
different claims, add/edit/remove camera with the make picker, test
connection and placement checks claimed by the shop PC, and the
enrolment code a new shop PC types. Team: create an account (password
shown once), invite (code shown once), change role, remove access, reset
password, revoke pending invitations - with the rank rules the server
enforces mirrored in what the form offers. Account: who you are and
every device signed in, with 'sign out' per device and everywhere else.

Gone: StoreManagement, SecurityManager and ProfileForm that rendered
hard-coded arrays, the Business form with no backend, the /api/stores
route nothing served, and the cascading account menu's dead links.

Two things found by using the camera form, not by tests: Chrome filled
the operator's own email into 'camera username', and the first camera
saved with a password and no username because autofill wrote to the
input without React seeing it. The dialog sets autocomplete on the real
inputs and reads the credential fields from the DOM at submit.

Every route was exercised against production before this commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-18 12:06:28 +05:30
parent 30d10921c2
commit 0b61077e2f
57 changed files with 1585 additions and 1630 deletions

View File

@@ -0,0 +1,34 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {Button} from '@astryxdesign/core/Button';
/**
* A password or a code the server will never show again. The copy button
* exists because retyping a generated secret is how it ends up wrong, and the
* warning exists because the thing is going to be pasted into a chat.
*/
export function SecretOnce({label, value, hint, onDone}: {label: string; value: string; hint: string; onDone: () => void}) {
const [copied, setCopied] = useState(false);
return (
<VStack gap={4}>
<Text size="sm" color="secondary">{label}</Text>
<Heading level={2} className="font-mono" style={{letterSpacing: '0.04em', wordBreak: 'break-all'}}>{value}</Heading>
<Banner status="warning" title="Shown once." description={hint} />
<HStack gap={2} hAlign="end">
<Button
variant="secondary"
label={copied ? 'Copied' : 'Copy'}
onClick={() => {
void navigator.clipboard?.writeText(value);
setCopied(true);
}}
/>
<Button onClick={onDone} label="Done" />
</HStack>
</VStack>
);
}

View File

@@ -59,11 +59,12 @@ export function isBranch(row: AccountRowSpec): row is AccountBranch {
* it is grouped by what it is rather than stacked by what fit.
*/
export const ACCOUNT_ROOT_GROUPS: AccountRowSpec[][] = [
// Flat: every row is a destination that exists. The cascading panels below
// are unreachable from here and kept only until the menu is simplified.
[
{label: 'Settings', icon: ICONS.settings, panel: 'settings'},
{label: 'Profile', icon: ICONS.profile, panel: 'profile'},
{label: 'Language', icon: ICONS.language, panel: 'language'},
{label: 'Help', icon: ICONS.help, panel: 'help'},
{label: 'Account & devices', icon: ICONS.profile, href: '/settings'},
{label: 'Team', icon: ICONS.staff, href: '/settings/team'},
{label: 'Keyboard shortcuts', icon: ICONS.keyboard, action: 'shortcuts'},
],
[
{
@@ -121,14 +122,7 @@ export const ACCOUNT_PANELS: Record<AccountPanelId, AccountPanel> = {
profile: {
title: 'Profile',
rows: [
{label: 'Business Details', icon: ICONS.business, href: '/settings'},
{label: 'Subscription', icon: ICONS.billing, href: '/settings/billing'},
{label: 'Activity', icon: ICONS.analytics, href: '/activity'},
// Devices and Sessions are two views of the same Security screen today.
// They stay two rows because they are two questions a merchant asks;
// when Security splits into tabs, only the href moves.
{label: 'Devices', icon: ICONS.security, href: '/settings/security'},
{label: 'Sessions', icon: ICONS.sessions, href: '/settings/security'},
{label: 'Account & devices', icon: ICONS.profile, href: '/settings'},
],
},
language: {
@@ -137,7 +131,7 @@ export const ACCOUNT_PANELS: Record<AccountPanelId, AccountPanel> = {
{
label: 'Language settings',
icon: ICONS.preferences,
href: '/settings/preferences',
href: '/settings',
},
],
},

View File

@@ -14,6 +14,7 @@ export interface NavEntry {
*/
export const PRIMARY_NAV: NavEntry[] = [
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
{label: 'Live', href: '/activity', icon: ICONS.visitors},
{label: 'Stores', href: '/stores', icon: ICONS.stores},
];

View File

@@ -127,3 +127,38 @@ export async function serveUpstream<U, T = U>(
return failResponse(err);
}
}
/**
* A write (or a read with no scope) proxied to the platform.
*
* The body is parsed once and handed to the caller; the platform's own
* validation answers a bad one, and its message is shown as-is. `status` is
* for a 201 on create. An upstream 204 comes back as `{data: null}` so the
* client sees one envelope shape everywhere.
*/
export async function proxyUpstream<U, T = U>(
req: NextRequest,
call: (accessToken: string, body: Record<string, unknown>, params: URLSearchParams) => Promise<U>,
opts?: {map?: (upstream: U) => T; status?: number},
): Promise<Response> {
let body: Record<string, unknown> = {};
if (req.method !== 'GET' && req.method !== 'DELETE') {
try {
const parsed: unknown = await req.json();
if (parsed && typeof parsed === 'object') body = parsed as Record<string, unknown>;
} catch {
// An empty body is legitimate for several endpoints (reset a password,
// mint a code). The platform rejects a body that is actually required.
}
}
try {
const upstream = await withUpstream((token) => call(token, body, req.nextUrl.searchParams));
const data = opts?.map ? opts.map(upstream) : upstream;
return Response.json(
{data: data ?? null, meta: {generatedAt: new Date().toISOString()}},
{status: opts?.status ?? 200, headers: {'cache-control': 'no-store'}},
);
} catch (err) {
return failResponse(err);
}
}

View File

@@ -162,3 +162,7 @@ export function patchJson<T>(
body: JSON.stringify(body),
});
}
export function deleteJson<T>(path: string): Promise<HttpResult<T>> {
return request<T>(path, {method: 'DELETE'});
}