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:
34
src/shared/components/patterns/SecretOnce.tsx
Normal file
34
src/shared/components/patterns/SecretOnce.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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},
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user