login issue

This commit is contained in:
2026-09-19 13:12:19 +05:30
parent dccb1beda5
commit ea3dbbeaf3
41 changed files with 2342 additions and 163 deletions

View File

@@ -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. */}
<AccountCard />
<SecurityManager />
</SettingsPage>
);

View File

@@ -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() {
<VStack gap={5}>
<PageHeader
title="Store"
description="Every shop in the network, with its camera health."
description="Every shop, its cameras, and the shop PC that watches them."
/>
<AsyncBoundary
@@ -43,52 +38,11 @@ export default function StoresPage() {
}
>
{(rows) => (
<Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
<VStack gap={6}>
{rows.map((site) => (
<Card key={site.id}>
<VStack gap={3}>
<HStack gap={2} vAlign="center" hAlign="between">
<Heading level={3}>{site.name}</Heading>
{site.isOnline === null ? null : (
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={site.isOnline ? 'success' : 'error'}
label={site.isOnline ? 'Online' : 'Offline'}
/>
<Text size="xsm" color="secondary">
{site.isOnline ? 'Online' : 'Offline'}
</Text>
</HStack>
)}
</HStack>
<Text size="sm" color="secondary" className="font-mono">
{site.id}
</Text>
<StatRow>
<StatPair
label="Cameras up"
value={
site.camerasUp === null || site.camerasTotal === null
? '—'
: `${site.camerasUp}/${site.camerasTotal}`
}
/>
<StatPair
label="Below gate"
value={
site.fractionBelowGate === null
? '—'
: formatPct(site.fractionBelowGate * 100, 0)
}
align="end"
/>
</StatRow>
</VStack>
</Card>
<ShopSection key={site.id} site={site} />
))}
</Grid>
</VStack>
)}
</AsyncBoundary>
</VStack>

View File

@@ -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));
}

View File

@@ -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));
}

View File

@@ -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),
);
}

View File

@@ -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},
);
}

View File

@@ -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));
}

View File

@@ -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=<slug> — the cameras on one shop, or all of them.
* POST /api/cameras?site=<slug> — 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},
);
}

View File

@@ -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=<platform image path> — any authenticated picture, proxied.
*
* The same hop as /api/faces and for the same reason: a browser `<img>` 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 <img> 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);
}
}

View File

@@ -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},
);
}

View File

@@ -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,
),
);
}

View File

@@ -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},
);
}

View File

@@ -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));
}

View File

@@ -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},
);
}

View File

@@ -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},
);
}

View File

@@ -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),

View File

@@ -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 (
<Card>
<VStack gap={4}>
<SectionHeader
title="Account"
subtitle="The account this browser is signed in as."
level={2}
/>
<HStack gap={3} vAlign="center">
<Avatar name={user.name} size="md" tooltip={false} />
<VStack gap={0}>
<Text size="sm" weight="medium">
{user.name}
</Text>
<Text size="xsm" color="secondary">
{user.email}
</Text>
</VStack>
<Badge variant="neutral" label={user.role} />
{user.organisation ? (
<Text size="xsm" color="secondary">
{user.organisation}
</Text>
) : null}
</HStack>
</VStack>
</Card>
);
}

View File

@@ -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<string, unknown> {
id: string;
@@ -31,33 +36,6 @@ export interface LoginAuditItem extends Record<string, unknown> {
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<ActiveSessionItem[]>(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<ActiveSessionItem>[] = [
const sessionColumns: TableColumn<DeviceSession & Record<string, unknown>>[] = [
{
key: 'device',
header: 'Device / Browser',
header: 'Device',
width: proportional(2),
renderCell: (row) => (
<VStack gap={0}>
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.device}
</Text>
{row.isCurrent ? <Badge variant="success" label="Current Session" /> : null}
{row.isCurrent ? (
<Badge variant="success" label="This browser" />
) : null}
</HStack>
<Text size="sm" color="secondary">
{row.location} • {row.ip}
</Text>
</VStack>
),
},
{
key: 'lastActive',
header: 'Last Active',
key: 'lastUsedAt',
header: 'Last used',
width: proportional(1.2),
renderCell: (row) => (
<Text size="sm" color="secondary">
{row.lastActive}
{new Date(row.lastUsedAt).toLocaleString()}
</Text>
),
},
@@ -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 : (
<HStack hAlign="center">
<Button
size="sm"
variant="ghost"
label="Revoke"
onClick={() => handleRevokeSession(row.id)}
label="Sign out"
onClick={() => void handleRevokeSession(row.id)}
/>
</HStack>
),
@@ -275,24 +277,36 @@ export function SecurityManager() {
</VStack>
</StaticPanel>
<StaticPanel
title="Active Sessions"
subtitle="Devices currently logged into your Loyaly merchant workspace."
<PanelCard
title="Active sessions"
subtitle="Every device signed in as you."
resource={sessions}
loading={<SkeletonRows count={3} />}
empty={
<EmptyPanel
icon="security"
title="No other devices"
description="Only this browser is signed in."
/>
}
actions={
<Button
variant="secondary"
size="sm"
label="Revoke Other Sessions"
onClick={handleRevokeAllOther}
label="Sign out everywhere else"
onClick={() => void handleRevokeAllOther()}
/>
}
>
{(rows) => (
<ResponsiveTable
columns={sessionColumns}
data={sessions}
data={rows as (DeviceSession & Record<string, unknown>)[]}
idKey="id"
primaryKey="device"
/>
</StaticPanel>
)}
</PanelCard>
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
<ResponsiveTable

View File

@@ -1,5 +1,6 @@
'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';
@@ -11,7 +12,12 @@ 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 {useTeam} from '@/features/team/hooks/useTeam';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
import {useInvitations, useTeam} from '@/features/team/hooks/useTeam';
import {teamRepository} from '@/features/team/repositories/teamRepository';
import {AddMemberDialog} from '@/features/team/components/AddMemberDialog';
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
import type {TeamMember} from '@/features/team/types/team';
/**
@@ -26,18 +32,17 @@ import type {TeamMember} from '@/features/team/types/team';
* local state and were lost on refresh. A merchant could not tell any of it
* from the real thing.
*
* ── Why this screen is read-only ─────────────────────────────────────────
* Accounts are created by INVITATION, and that flow already exists in the
* platform's own web app: a manager mints a code, the holder redeems it and
* chooses their own password. Adding a second way to create an account here
* would be a second place a login can be born, on the one surface where that
* matters most. So this lists the team and does not mint anyone.
* ── Why the actions are here now ─────────────────────────────────────────
* This was read-only, because `PATCH /api/team/{id}`, the invitation endpoints
* and `POST /api/team/members` all answered 404 on the deployed platform when
* it was built. They do not any more, so the screen does what it always
* described: lists who has access and changes it.
*
* Role changes and deactivation are supported upstream by
* `PATCH /api/team/{id}` and are NOT wired here — see the report. Deactivating
* revokes every session that person holds immediately, so it wants a
* confirmation step and `409 last_owner` handling, neither of which belongs in
* a change whose purpose is removing invented data.
* Deactivating revokes every session that person holds IMMEDIATELY — it is not
* a flag that takes effect at their next sign-in. The platform also refuses to
* remove the last active owner, answering `409 last_owner`; that message is
* surfaced verbatim rather than flattened into "something went wrong", because
* it names a rule the merchant can act on.
*/
interface TeamRow extends Record<string, unknown> {
@@ -113,6 +118,36 @@ const COLUMNS: TableColumn<TeamRow>[] = [
export function TeamManagement() {
const team = useTeam();
const invitations = useInvitations();
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reset, setReset] = useState<{label: string; value: string} | null>(
null,
);
function refresh() {
team.refetch();
invitations.refetch();
}
async function setActive(id: string, active: boolean) {
setError(null);
const res = await teamRepository.update(id, {active});
// `last_owner` is the one failure worth repeating word for word: it names a
// rule rather than a fault, and the merchant can act on it.
if (!res.ok) setError(res.message ?? 'Could not change that account.');
refresh();
}
async function resetPassword(id: string, email: string) {
setError(null);
const res = await teamRepository.resetPassword(id);
if (!res.ok || !res.data) {
setError(res.message ?? 'Could not reset that password.');
return;
}
setReset({label: `New password for ${email}`, value: res.data.password});
}
return (
<PanelCard
@@ -137,13 +172,100 @@ export function TeamManagement() {
summaryKeys={['role', 'status']}
columns={COLUMNS}
/>
{/* Says where accounts come from, because the button that used to
claim to create one has gone. Without this the screen reads as
missing a feature rather than pointing at the right place. */}
{error ? <Banner status="error" title={error} /> : null}
{reset ? (
<SecretOnce value={reset.value} label={reset.label} />
) : null}
<HStack gap={2} hAlign="between" vAlign="center">
<Text size="xsm" color="secondary">
New members are added by invitation, and choose their own password
when they redeem it. Roles and access are managed on the platform.
An invitation lets somebody choose their own password; a created
login shows one once. Deactivating signs that person out at once.
</Text>
<Button size="sm" onClick={() => setAdding(true)} label="Add someone" />
</HStack>
{members.some((m) => !m.active) ? (
<VStack gap={2}>
<Text size="xsm" color="secondary">
Deactivated
</Text>
{members
.filter((m) => !m.active)
.map((m) => (
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm">{m.name}</Text>
<Button
size="sm"
variant="secondary"
onClick={() => void setActive(m.id, true)}
label="Restore access"
/>
</HStack>
))}
</VStack>
) : null}
<VStack gap={2}>
{members
.filter((m) => m.active)
.map((m) => (
<HStack key={m.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm" color="secondary">
{m.email}
</Text>
<HStack gap={2}>
<Button
size="sm"
variant="secondary"
onClick={() => void resetPassword(m.id, m.email)}
label="Reset password"
/>
<Button
size="sm"
variant="secondary"
onClick={() => void setActive(m.id, false)}
label="Deactivate"
/>
</HStack>
</HStack>
))}
</VStack>
{invitations.data && invitations.data.length > 0 ? (
<VStack gap={2}>
<Text size="xsm" color="secondary">
Invited, not yet joined
</Text>
{invitations.data.map((inv) => (
<HStack key={inv.id} gap={2} vAlign="center" hAlign="between">
<Text size="sm" color="secondary">
{inv.email} · {inv.role}
</Text>
<Button
size="sm"
variant="secondary"
onClick={() => {
void teamRepository
.revokeInvitation(inv.id)
.then(() => invitations.refetch());
}}
label="Withdraw"
/>
</HStack>
))}
</VStack>
) : null}
{adding ? (
<AddMemberDialog
onClose={() => setAdding(false)}
onDone={() => {
setAdding(false);
refresh();
}}
/>
) : null}
</VStack>
)}
</PanelCard>

View File

@@ -0,0 +1,16 @@
import {deleteJson, postJson, type Endpoint} from '@/shared/services/httpClient';
import type {DeviceSession} from '@/features/settings/services/mapSession';
/** Signed-in devices, and the two ways to end them. */
export const securityRepository = {
sessions: (): Endpoint<DeviceSession[]> => ({
path: '/api/auth/sessions',
params: {},
}),
revoke: (id: string) =>
deleteJson<null>(`/api/auth/sessions/${encodeURIComponent(id)}`),
/** Keeps this browser signed in — see the route. */
revokeOthers: () => postJson<null>('/api/auth/sessions/revoke-others', {}),
};

View File

@@ -0,0 +1,25 @@
import type {ApiDeviceSession} from '@/services/api/types';
/** A signed-in device, as the settings screen consumes it. */
export interface DeviceSession {
id: string;
device: string;
createdAt: string;
lastUsedAt: string;
expiresAt: string;
/** The browser reading this page. Warn before signing it out. */
isCurrent: boolean;
}
export function toDeviceSession(s: ApiDeviceSession): DeviceSession {
return {
id: s.id,
// The platform records whatever the client sent at sign-in. An empty label
// is possible, and "Unknown device" is more honest than a blank row.
device: s.device || 'Unknown device',
createdAt: s.created_at,
lastUsedAt: s.last_used_at,
expiresAt: s.expires_at,
isCurrent: s.current,
};
}

View File

@@ -0,0 +1,144 @@
'use client';
import Image from 'next/image';
import {Card} from '@astryxdesign/core/Card';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {StatusDot} from '@astryxdesign/core/StatusDot';
import {Badge} from '@astryxdesign/core/Badge';
import type {Camera, CameraCheck} from '@/features/stores/types/camera';
/**
* One camera: what it sees, whether the shop PC can reach it, and the actions.
*
* ── Three states, not two ────────────────────────────────────────────────
* `connected === null` means no shop PC has reported on this camera yet. That
* is NOT "offline": showing a red dot for a camera nobody has tried yet reads
* as a fault the merchant caused, when the real answer is that the PC is not
* enrolled or has not spoken. It gets its own neutral wording.
*
* A missing snapshot is DATA too — a deployment with no object storage serves
* every camera without one, and `snapshotReason` says so. A broken-image icon
* for a system working as configured is the failure this avoids.
*/
/**
* §8: only `verdict: "good"` is a pass. `marginal` is NOT — it means about half
* the visitors this camera sees are silently discarded, and showing it green
* tells a merchant the camera is fine when their footfall is quietly halved.
*
* A check that has not come back yet is neither: the shop PC may be off, and
* `state` stays `requested` until it claims the job.
*/
function checkTone(c: CameraCheck): 'success' | 'warning' | 'error' | 'neutral' {
if (c.state !== 'done') return 'neutral';
if (c.verdict === 'good') return 'success';
if (c.verdict === 'marginal') return 'warning';
return c.ok ? 'success' : 'error';
}
function checkSummary(c: CameraCheck): string {
if (c.state !== 'done') return 'Check running…';
if (c.verdict === 'good') return 'Camera can recognise faces';
if (c.verdict === 'marginal') return 'Marginal — needs moving';
return c.ok ? 'Check passed' : 'Check failed';
}
function connectionLabel(c: Camera): {variant: 'success' | 'error' | 'neutral'; text: string} {
if (c.connected === null) return {variant: 'neutral', text: 'Not reported yet'};
return c.connected
? {variant: 'success', text: 'Connected'}
: {variant: 'error', text: 'Not reachable'};
}
export function CameraCard({
camera,
onEdit,
onCheck,
onRemove,
busy,
}: {
camera: Camera;
onEdit: () => void;
onCheck: () => void;
onRemove: () => void;
busy?: boolean;
}) {
const status = connectionLabel(camera);
return (
<Card>
<VStack gap={3}>
<HStack gap={2} vAlign="center" hAlign="between">
<VStack gap={0}>
<Text size="sm" weight="medium">
{camera.label}
</Text>
<Text size="xsm" color="secondary">
{camera.host}:{camera.port}
</Text>
</VStack>
{camera.enabled ? null : <Badge variant="neutral" label="Disabled" />}
</HStack>
{camera.snapshotUrl ? (
<Image
src={camera.snapshotUrl}
alt={`Latest view from ${camera.label}`}
width={320}
height={180}
unoptimized
style={{width: '100%', height: 'auto', borderRadius: 8}}
/>
) : (
<Text size="xsm" color="secondary">
{camera.snapshotReason ?? 'No picture yet.'}
</Text>
)}
<HStack gap={1.5} vAlign="center">
<StatusDot variant={status.variant} label={status.text} />
<Text size="xsm" color="secondary">
{status.text}
</Text>
</HStack>
{camera.check ? (
<VStack gap={1.5}>
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={checkTone(camera.check)}
label={checkSummary(camera.check)}
/>
<Text size="xsm" color="secondary">
{camera.check.headline ?? checkSummary(camera.check)}
</Text>
</HStack>
{/*
Verbatim, as §8 asks: the engine writes this for the person
standing next to the camera, and paraphrasing it loses the one
instruction that would fix the shot.
*/}
{camera.check.advice.map((line) => (
<Text key={line} size="xsm" color="secondary">
• {line}
</Text>
))}
</VStack>
) : null}
<HStack gap={2}>
<Button size="sm" variant="secondary" onClick={onEdit} label="Edit" />
<Button
size="sm"
variant="secondary"
onClick={onCheck}
isDisabled={busy}
label={busy ? 'Checking…' : 'Test'}
/>
<Button size="sm" variant="secondary" onClick={onRemove} label="Remove" />
</HStack>
</VStack>
</Card>
);
}

View File

@@ -0,0 +1,180 @@
'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 {Selector} from '@astryxdesign/core/Selector';
import {Switch} from '@astryxdesign/core/Switch';
import {Button} from '@astryxdesign/core/Button';
import {Text} from '@astryxdesign/core/Text';
import {Banner} from '@astryxdesign/core/Banner';
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
import {CAMERA_MAKES, makeById} from '@/features/stores/data/cameraMakes';
import type {Camera} from '@/features/stores/types/camera';
/**
* Add or edit one camera.
*
* ── The password is write-only ───────────────────────────────────────────
* The platform never returns it — only `has_password`. So on an edit the field
* starts EMPTY, and an empty field is omitted from the PATCH entirely, which
* the platform reads as "keep the stored one". Pre-filling it with dots would
* mean either sending those dots as the new password or guessing when to strip
* them; both are worse than saying plainly that one is already set.
*
* ── Why a make picker ────────────────────────────────────────────────────
* A wrong RTSP path is the commonest reason a camera enrols and then produces
* nothing, and it is not something a shopkeeper can reasonably know. Choosing a
* make fills the path in; the field stays editable, because the camera on the
* wall will be the model that is not on the list.
*/
export function CameraDialog({
siteId,
camera,
onClose,
onSaved,
}: {
/** The shop's slug. Used when adding; ignored on edit. */
siteId: string;
/** Absent when adding. */
camera?: Camera;
onClose: () => void;
onSaved: () => void;
}) {
const [label, setLabel] = useState(camera?.label ?? '');
const [host, setHost] = useState(camera?.host ?? '');
const [port, setPort] = useState(String(camera?.port ?? 554));
const [path, setPath] = useState(camera?.path ?? '');
const [username, setUsername] = useState(camera?.username ?? '');
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(camera?.enabled ?? true);
const [make, setMake] = useState('');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const canSave = label.trim() !== '' && host.trim() !== '';
function pickMake(value: string) {
setMake(value);
const found = makeById(value);
// "I know the path" carries an empty path and must not wipe what is there.
if (found.path) setPath(found.path);
}
async function save() {
setSaving(true);
setError(null);
const body: Record<string, unknown> = {
label: label.trim(),
host: host.trim(),
port: Number(port) || 554,
path: path.trim(),
username: username.trim(),
enabled,
};
// Omitted, not blanked — see the note above.
if (password !== '') body.password = password;
const res = camera
? await cameraRepository.update(camera.id, body)
: await cameraRepository.add(siteId, body);
setSaving(false);
if (!res.ok) {
setError(res.message ?? 'Could not save this camera.');
return;
}
onSaved();
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={480}
aria-label={camera ? 'Edit camera' : 'Add camera'}
>
<VStack gap={4} width="100%">
<DialogHeader
title={camera ? 'Edit camera' : 'Add camera'}
onOpenChange={(open) => (open ? undefined : onClose())}
/>
<TextInput
label="Name"
value={label}
onChange={setLabel}
placeholder="Entrance"
description="What the staff call this camera."
/>
<HStack gap={2} width="100%">
<TextInput
label="Address"
value={host}
onChange={setHost}
placeholder="192.168.1.64"
/>
<TextInput label="Port" value={port} onChange={setPort} />
</HStack>
<Selector
label="Make"
options={CAMERA_MAKES.map((m) => ({value: m.id, label: m.label}))}
value={make}
onChange={pickMake}
placeholder="Choose to fill the stream path"
isOptional
// Several notes are load-bearing — the Tapo one in particular saves an
// installer an hour of trying their app login on the camera.
description={make ? makeById(make).note || undefined : undefined}
/>
<TextInput
label="Stream path"
value={path}
onChange={setPath}
placeholder="/Streaming/Channels/101"
/>
<HStack gap={2} width="100%">
<TextInput label="Username" value={username} onChange={setUsername} />
<TextInput
label="Password"
value={password}
onChange={setPassword}
type="password"
placeholder={camera?.hasPassword ? 'Already set' : ''}
/>
</HStack>
{camera?.hasPassword ? (
<Text size="xsm" color="secondary">
A password is stored. Leave this blank to keep it.
</Text>
) : null}
<Switch
label="Enabled"
value={enabled}
onChange={setEnabled}
description="A disabled camera stays configured but is not watched."
/>
{error ? <Banner status="error" title={error} /> : null}
<HStack gap={2} hAlign="end">
<Button variant="secondary" onClick={onClose} label="Cancel" />
<Button
isDisabled={!canSave || saving}
onClick={() => void save()}
label={saving ? 'Saving…' : camera ? 'Save changes' : 'Add camera'}
/>
</HStack>
</VStack>
</Dialog>
);
}

View 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 {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 {postJson} from '@/shared/services/httpClient';
import type {Site} from '@/features/stores/types/site';
interface EnrolmentCode {
code: string;
siteName?: string;
expiresAt?: string;
}
/**
* Mint the one-time code that enrols a shop PC against this shop.
*
* Nothing is requested on open. The code is minted only when the merchant asks,
* because each request produces a NEW code and invalidates nothing — opening a
* dialog to look must not quietly leave a trail of live codes behind it.
*
* The label is optional and exists so a merchant with three tills can tell the
* codes apart later; the platform records it against the enrolment.
*/
export function EnrolmentCodeDialog({
site,
onClose,
}: {
site: Site;
onClose: () => void;
}) {
const [label, setLabel] = useState('');
const [minting, setMinting] = useState(false);
const [issued, setIssued] = useState<EnrolmentCode | null>(null);
const [error, setError] = useState<string | null>(null);
async function mint() {
setMinting(true);
setError(null);
const res = await postJson<EnrolmentCode>(
`/api/sites/${encodeURIComponent(site.id)}/enrolment-code`,
label.trim() ? {label: label.trim()} : {},
);
setMinting(false);
if (!res.ok || !res.data) {
setError(res.message ?? 'Could not create a code.');
return;
}
setIssued(res.data);
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={440}
aria-label="Enrolment code"
>
<VStack gap={4} width="100%">
<DialogHeader
title="Enrol a shop PC"
onOpenChange={(open) => (open ? undefined : onClose())}
/>
{issued ? (
<SecretOnce
value={issued.code}
label={`Enter this on the shop PC at ${site.name}`}
note="This code works once and cannot be shown again."
/>
) : (
<>
<Text size="sm" color="secondary">
The shop PC asks for a code the first time it runs. Each code
works once, and a new one is created every time you tap below.
</Text>
<TextInput
label="Label"
value={label}
onChange={setLabel}
placeholder="Front counter PC"
isOptional
description="Only so you can tell codes apart later."
/>
{error ? <Banner status="error" title={error} /> : null}
</>
)}
<HStack gap={2} hAlign="end">
<Button
variant="secondary"
onClick={onClose}
label={issued ? 'Done' : 'Cancel'}
/>
{issued ? null : (
<Button
isDisabled={minting}
onClick={() => void mint()}
label={minting ? 'Creating…' : 'Create code'}
/>
)}
</HStack>
</VStack>
</Dialog>
);
}

View File

@@ -0,0 +1,197 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Grid} from '@astryxdesign/core/Grid';
import {Text, Heading} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {StatusDot} from '@astryxdesign/core/StatusDot';
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 {formatPct} from '@/shared/utils/format';
import {useCameras} from '@/features/stores/hooks/useCameras';
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
import {CameraCard} from '@/features/stores/components/CameraCard';
import {CameraDialog} from '@/features/stores/components/CameraDialog';
import {EnrolmentCodeDialog} from '@/features/stores/components/EnrolmentCodeDialog';
import type {Camera} from '@/features/stores/types/camera';
import type {Site} from '@/features/stores/types/site';
/**
* One shop and the cameras in it.
*
* Cameras are fetched PER SHOP rather than once for the estate: the list is
* keyed by `?site=`, so useResource refetches on its own when a different shop
* is rendered, and a merchant with one busy shop does not pay for every other.
*
* Every write re-reads rather than patching local state. A camera edit changes
* `revision`, a check changes the verdict, and the platform is the only thing
* that knows what the shop PC has reported since — so the list it returns is
* the truth and a locally spliced copy is a guess.
*/
export function ShopSection({site}: {site: Site}) {
const cameras = useCameras(site.id);
const [editing, setEditing] = useState<Camera | null>(null);
const [adding, setAdding] = useState(false);
const [enrolling, setEnrolling] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
/**
* Ask the shop PC to prove this camera works, then wait for the answer.
*
* §8: the POST answers 202 with `state: "requested"` and the camera still
* carrying its PREVIOUS verdict — the job has been queued, not run. So a
* single re-read would show the old result and look like the button did
* nothing. Poll until `state` turns `done`.
*
* Bounded, because the shop PC may simply be off: §8 says the server releases
* an unclaimed job after five minutes. Giving up after ~30s leaves the row
* showing "Check running…", which is the truth, rather than spinning forever.
*/
async function check(camera: Camera) {
setBusyId(camera.id);
const res = await cameraRepository.check(camera.id, 'connection');
if (!res.ok) {
setBusyId(null);
cameras.refetch();
return;
}
for (let attempt = 0; attempt < 10; attempt++) {
await new Promise((resolve) => setTimeout(resolve, 3000));
const poll = await cameraRepository.one(camera.id);
if (poll.ok && poll.data?.check?.state === 'done') break;
}
setBusyId(null);
cameras.refetch();
}
async function remove(camera: Camera) {
setBusyId(camera.id);
await cameraRepository.remove(camera.id);
setBusyId(null);
cameras.refetch();
}
return (
<VStack gap={4}>
<HStack gap={2} vAlign="center" hAlign="between">
<VStack gap={0}>
<Heading level={3}>{site.name}</Heading>
<HStack gap={1.5} vAlign="center">
<StatusDot
variant={site.isOnline === null ? 'neutral' : site.isOnline ? 'success' : 'error'}
label={
site.isOnline === null
? 'Not reported'
: site.isOnline
? 'Online'
: 'Offline'
}
/>
<Text size="xsm" color="secondary">
{site.isOnline === null
? 'No shop PC has reported yet'
: site.isOnline
? 'Shop PC online'
: 'Shop PC offline'}
</Text>
</HStack>
</VStack>
<HStack gap={2}>
<Button
size="sm"
variant="secondary"
onClick={() => setEnrolling(true)}
label="Enrolment code"
/>
<Button size="sm" onClick={() => setAdding(true)} label="Add camera" />
</HStack>
</HStack>
{/*
Carried over from the card this section replaced. These come from
/api/sites, not from the camera list, and stay null rather than zero
when a deployment does not report them — "0/0 cameras" for "not
reported" makes a working estate look broken.
*/}
<StatRow>
<StatPair
label="Cameras up"
value={
site.camerasUp === null || site.camerasTotal === null
? '—'
: `${site.camerasUp}/${site.camerasTotal}`
}
/>
<StatPair
label="Below gate"
value={
site.fractionBelowGate === null
? '—'
: formatPct(site.fractionBelowGate * 100, 0)
}
align="end"
/>
</StatRow>
<AsyncBoundary
resource={cameras}
loading={<SkeletonCardGrid count={2} height={220} />}
empty={
<EmptyPanel
icon="stores"
title="No cameras yet"
description="Add the first camera in this shop, then enrol the shop PC."
/>
}
>
{(list) => (
<Grid columns={{minWidth: 260, repeat: 'fit'}} gap={4}>
{list.map((camera) => (
<CameraCard
key={camera.id}
camera={camera}
busy={busyId === camera.id}
onEdit={() => setEditing(camera)}
onCheck={() => void check(camera)}
onRemove={() => void remove(camera)}
/>
))}
</Grid>
)}
</AsyncBoundary>
{adding ? (
<CameraDialog
siteId={site.id}
onClose={() => setAdding(false)}
onSaved={() => {
setAdding(false);
cameras.refetch();
}}
/>
) : null}
{editing ? (
<CameraDialog
siteId={site.id}
camera={editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
cameras.refetch();
}}
/>
) : null}
{enrolling ? (
<EnrolmentCodeDialog site={site} onClose={() => setEnrolling(false)} />
) : null}
</VStack>
);
}

View File

@@ -0,0 +1,79 @@
/**
* RTSP stream paths for the common camera makes.
*
* Mirrors `shared/cameraMakes.js` in the platform repository, which API.md §8
* names as the list the console fills this field from. Kept in step with it
* deliberately: "a make that is right in one and stale in the other is worse
* than not offering the list at all — an installer trusts a filled-in field."
*
* This is the single biggest obstacle to setting up a first camera. The address
* and the password are on a label; the RTSP *path* is model-specific and
* written nowhere a shop owner would look, and getting it wrong produces
* "could not open stream", which reads like a password problem and is not.
*
* Picking a make fills it in. The field stays editable, because these are
* conventions rather than guarantees.
*/
export interface CameraMake {
id: string;
label: string;
path: string;
/** Shown under the field once a make is chosen. Several are load-bearing. */
note: string;
}
export const CAMERA_MAKES: CameraMake[] = [
{
id: 'hikvision',
label: 'Hikvision',
path: '/Streaming/Channels/101',
note: 'Channel 1, main stream. Use /Streaming/Channels/102 for the lower-quality sub stream.',
},
{
id: 'dahua',
label: 'Dahua',
path: '/cam/realmonitor?channel=1&subtype=0',
note: 'Channel 1, main stream. subtype=1 is the sub stream.',
},
{
id: 'cpplus',
label: 'CP Plus',
path: '/cam/realmonitor?channel=1&subtype=0',
note: 'CP Plus cameras use the Dahua stream path.',
},
{
id: 'uniview',
label: 'Uniview',
path: '/media/video1',
note: 'Some older Uniview models use /video1 instead.',
},
{
id: 'tplink',
label: 'TP-Link / Tapo',
path: '/stream1',
note: 'Tapo cameras need a separate camera account created in the Tapo app — your Tapo login will not work.',
},
{
id: 'reolink',
label: 'Reolink',
path: '/h264Preview_01_main',
note: 'Use /h264Preview_01_sub for the lower-quality stream.',
},
{
id: 'amcrest',
label: 'Amcrest',
path: '/cam/realmonitor?channel=1&subtype=0',
note: 'Amcrest cameras use the Dahua stream path.',
},
{id: 'axis', label: 'Axis', path: '/axis-media/media.amp', note: ''},
{
id: 'onvif',
label: 'Other (ONVIF)',
path: '/onvif1',
note: 'Many generic cameras answer here. If it does not work, look for “RTSP” in the camera’s own app.',
},
{id: 'manual', label: 'I know the path', path: '', note: ''},
];
export const makeById = (id: string): CameraMake =>
CAMERA_MAKES.find((m) => m.id === id) ?? CAMERA_MAKES[CAMERA_MAKES.length - 1];

View File

@@ -0,0 +1,26 @@
'use client';
import {useResource} from '@/shared/hooks/useResource';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
import type {Camera} from '@/features/stores/types/camera';
import type {Resource} from '@/shared/hooks/useResource';
/**
* The cameras on one shop.
*
* Session-gated for the same reason `useSites` is: every BFF route answers an
* anonymous request with 401 by design, and this provider tree sits above
* /login too, so an ungated call puts a guaranteed 401 in the console of every
* visitor who has not signed in.
*
* Scoped by SITE rather than by the workspace scope selector: this is estate
* configuration, and a merchant setting up shop B should not have the screen
* silently filtered by whatever shop A the switcher last had.
*/
export function useCameras(site: string | null): Resource<Camera[]> {
const {isAuthenticated} = useSession();
return useResource(
isAuthenticated && site ? cameraRepository.list(site) : null,
);
}

View File

@@ -0,0 +1,51 @@
import {
deleteJson,
getJson,
patchJson,
postJson,
type Endpoint,
} from '@/shared/services/httpClient';
import type {Camera} from '@/features/stores/types/camera';
/**
* TRANSPORT ONLY — the cameras of one shop, and the writes against them.
*
* `list` is an Endpoint because useResource keys on the URL, so changing shop
* refetches on its own. The writes are plain calls: they are user-initiated,
* one at a time, and the caller decides when to re-read.
*/
export const cameraRepository = {
list: (site: string): Endpoint<Camera[]> => ({
path: '/api/cameras',
params: {site},
}),
add: (site: string, body: Record<string, unknown>) =>
postJson<Camera>(`/api/cameras?site=${encodeURIComponent(site)}`, body),
update: (id: string, body: Record<string, unknown>) =>
patchJson<Camera>(`/api/cameras/${encodeURIComponent(id)}`, body),
remove: (id: string) =>
deleteJson<null>(`/api/cameras/${encodeURIComponent(id)}`),
/**
* One camera, for polling a check to completion.
*
* Reads the whole list and picks the row out, because the platform serves no
* single-camera GET — /api/cameras/{id} is PATCH and DELETE only. Cheap
* enough at a shop's camera count, and it keeps the poll on the documented
* surface rather than inventing a route the backend does not have.
*/
one: async (id: string) => {
const res = await getJson<Camera[]>('/api/cameras');
return {
ok: res.ok,
data: res.ok ? res.data?.find((c) => c.id === id) : undefined,
message: res.message,
};
},
check: (id: string, kind: 'connection' | 'placement') =>
postJson<Camera>(`/api/cameras/${encodeURIComponent(id)}/check`, {kind}),
};

View File

@@ -0,0 +1,74 @@
import type {ApiCamera, ApiImage} from '@/services/api/types';
import type {Camera, CameraCheck} from '@/features/stores/types/camera';
/**
* Platform camera → the shape the screen consumes.
*
* ── Which URLs need the token, and how we know ───────────────────────────
* The platform describes a picture rather than linking one, and there are two
* kinds of URL. API.md §6 is explicit about telling them apart:
*
* "the `auth` flag is how you tell them apart. Do not infer it from the
* shape of the URL."
*
* `auth: true` is served by the API and needs the caller's bearer token, which
* a browser <img> cannot send — so it goes through /api/images, which adds the
* token server-side. Anything else is a presigned object-storage link carrying
* its own signature, and must NOT be proxied through this origin.
*
* The relative test is kept as a FLOOR, not as the decision: §6 also says
* "treat any relative URL as needing auth whether or not the flag is set,
* there is no public one". So a relative URL is proxied even if `auth` is
* absent — but an ABSOLUTE url with `auth: true` is now proxied too, which
* inferring from the shape got wrong.
*/
function needsToken(image: ApiImage): boolean {
return image.auth === true || !image.url!.startsWith('http');
}
function proxied(image: ApiImage | undefined): string | null {
if (!image?.available || !image.url) return null;
return needsToken(image)
? `/api/images?src=${encodeURIComponent(image.url)}`
: image.url;
}
function toCheck(c: ApiCamera['check']): CameraCheck | null {
// A camera that has never been checked still carries a `check` object with
// nothing in it. Treating that as "no result" keeps an empty verdict off the
// card rather than rendering a green tick for a test nobody ran.
if (!c || (!c.state && !c.headline && !c.finished_at)) return null;
return {
kind: c.kind ?? null,
state: c.state ?? null,
verdict: c.verdict ?? null,
ok: c.ok,
headline: c.headline ?? c.verdict ?? null,
advice: c.advice ?? [],
finishedAt: c.finished_at ?? null,
imageUrl: proxied(c.image),
};
}
export function toCamera(c: ApiCamera): Camera {
return {
id: c.id,
siteId: c.site_id,
cameraId: c.camera_id,
label: c.label,
host: c.host,
port: c.port,
path: c.path,
username: c.username,
hasPassword: c.has_password,
maxWidth: c.max_width,
enabled: c.enabled,
// `?? null` and NOT `?? false` — see the note on Camera.connected.
connected: c.connected ?? null,
lastSeenAt: c.last_seen_at ?? null,
snapshotUrl: proxied(c.snapshot),
snapshotReason: c.snapshot?.available === false ? (c.snapshot.reason ?? null) : null,
snapshotAt: c.snapshot_at ?? null,
check: toCheck(c.check),
};
}

View File

@@ -0,0 +1,53 @@
/**
* A camera, as the console consumes it.
*
* `connected` stays THREE-valued all the way to the screen. null means no shop
* PC has reported on this camera yet, which is not the same as one reporting
* that it cannot reach it — and collapsing the two to `false` shows a red dot
* on a camera nobody has tried, which reads as a fault the merchant caused.
*
* `snapshotUrl` is already proxied through /api/images. The platform's own URL
* needs a bearer token an <img> cannot send, so a component must never build
* that URL itself.
*/
export interface Camera {
/** The uuid. Addresses this camera on every write. */
id: string;
siteId: string;
/** Stable per-site identifier; appears in MQTT topics. Immutable in practice. */
cameraId: string;
label: string;
host: string;
port: number;
path: string;
username: string;
/** Whether a password is stored. The value itself is never returned. */
hasPassword: boolean;
maxWidth: number;
enabled: boolean;
connected: boolean | null;
lastSeenAt: string | null;
snapshotUrl: string | null;
/** Why there is no snapshot, when there is none. Data, not an error. */
snapshotReason: string | null;
snapshotAt: string | null;
check: CameraCheck | null;
}
/** The last time this camera was asked to prove itself, and what happened. */
export interface CameraCheck {
kind: string | null;
/** 'requested' until the shop PC claims it; 'done' when it has answered. */
state: string | null;
/**
* §8: "Only `verdict: \"good\"` is a pass. `marginal` means half the
* visitors are silently discarded, which is not a working camera."
*/
verdict: string | null;
ok: boolean;
headline: string | null;
/** Written for the person standing next to the camera. Shown verbatim. */
advice: string[];
finishedAt: string | null;
imageUrl: string | null;
}

View File

@@ -0,0 +1,183 @@
'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 {Selector} from '@astryxdesign/core/Selector';
import {
SegmentedControl,
SegmentedControlItem,
} from '@astryxdesign/core/SegmentedControl';
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 {teamRepository} from '@/features/team/repositories/teamRepository';
import type {UserRole} from '@/features/auth/types/auth';
/**
* Add somebody to the console, two ways.
*
* ── Invitation first, deliberately ───────────────────────────────────────
* An invitation is the default because the person redeems it and chooses their
* own password, so the merchant never handles a credential belonging to
* somebody else. A password an operator invents for a colleague is weak, gets
* sent over WhatsApp, and is never changed.
*
* Direct creation exists because the invitation flow needs the recipient's
* device to redeem on, and a shop hiring somebody at the counter does not
* always have that. It hands back a generated password, shown once.
*
* `owner` is absent from the role list on purpose: promoting somebody to owner
* is a different decision from adding a person, the platform guards it
* separately, and doing it by accident on a form like this is hard to undo.
*/
const ROLES: {value: UserRole; label: string}[] = [
{value: 'staff', label: 'Staff'},
{value: 'manager', label: 'Manager'},
];
export function AddMemberDialog({
onClose,
onDone,
}: {
onClose: () => void;
onDone: () => void;
}) {
const [mode, setMode] = useState<'invite' | 'create'>('invite');
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState<UserRole>('staff');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [secret, setSecret] = useState<{label: string; value: string} | null>(
null,
);
const canSubmit = email.trim() !== '';
async function submit() {
setBusy(true);
setError(null);
const body = {email: email.trim(), full_name: name.trim(), role};
if (mode === 'invite') {
const res = await teamRepository.invite(body);
setBusy(false);
if (!res.ok || !res.data) {
setError(res.message ?? 'Could not send that invitation.');
return;
}
// No code on the response would mean the platform changed its contract;
// say so rather than rendering an empty box that looks like a bug.
setSecret(
res.data.code
? {label: `Invitation code for ${res.data.email}`, value: res.data.code}
: null,
);
if (!res.data.code) onDone();
return;
}
const res = await teamRepository.create(body);
setBusy(false);
if (!res.ok || !res.data) {
setError(res.message ?? 'Could not create that login.');
return;
}
setSecret({
label: `Password for ${res.data.email}`,
value: res.data.password,
});
}
return (
<Dialog
isOpen
onOpenChange={(open) => (open ? undefined : onClose())}
purpose="info"
width={460}
aria-label="Add team member"
>
<VStack gap={4} width="100%">
<DialogHeader
title="Add someone"
onOpenChange={(open) => (open ? undefined : onClose())}
/>
{secret ? (
<SecretOnce
value={secret.value}
label={secret.label}
note={
mode === 'invite'
? 'Give this to them. It works once and cannot be shown again.'
: 'Give this to them. It cannot be shown again.'
}
/>
) : (
<>
<SegmentedControl
label="How to add them"
value={mode}
onChange={(v) => setMode(v === 'create' ? 'create' : 'invite')}
layout="fill"
>
<SegmentedControlItem value="invite" label="Send an invitation" />
<SegmentedControlItem value="create" label="Create a login" />
</SegmentedControl>
<Text size="xsm" color="secondary">
{mode === 'invite'
? 'They redeem the code and choose their own password. You never see it.'
: 'A password is generated and shown once. Use this when they cannot redeem a code.'}
</Text>
<TextInput
label="Email"
value={email}
onChange={setEmail}
placeholder="name@company.com"
/>
<TextInput
label="Name"
value={name}
onChange={setName}
isOptional
/>
<Selector
label="Role"
options={ROLES.map((r) => ({value: r.value, label: r.label}))}
value={role}
onChange={(v) => setRole(v as UserRole)}
/>
{error ? <Banner status="error" title={error} /> : null}
</>
)}
<HStack gap={2} hAlign="end">
<Button
variant="secondary"
onClick={secret ? onDone : onClose}
label={secret ? 'Done' : 'Cancel'}
/>
{secret ? null : (
<Button
isDisabled={!canSubmit || busy}
onClick={() => void submit()}
label={
busy
? 'Working…'
: mode === 'invite'
? 'Send invitation'
: 'Create login'
}
/>
)}
</HStack>
</VStack>
</Dialog>
);
}

View File

@@ -2,9 +2,21 @@
import {useResource} from '@/shared/hooks/useResource';
import {teamRepository} from '@/features/team/repositories/teamRepository';
import type {TeamMember} from '@/features/team/types/team';
import type {Invitation, TeamMember} from '@/features/team/types/team';
import type {Resource} from '@/shared/hooks/useResource';
export function useTeam(): Resource<TeamMember[]> {
return useResource(teamRepository.list());
}
/**
* Invitations that have been sent and not yet redeemed.
*
* A separate resource from the member list rather than one merged feed: they
* are different things. A member can sign in; an invitation is a promise that
* somebody could. Merging them would put rows in the access list for people who
* have no access, which is the opposite of what that list is for.
*/
export function useInvitations(): Resource<Invitation[]> {
return useResource(teamRepository.invitations());
}

View File

@@ -1,7 +1,44 @@
import type {Endpoint} from '@/shared/services/httpClient';
import type {TeamMember} from '@/features/team/types/team';
import {
deleteJson,
patchJson,
postJson,
type Endpoint,
} from '@/shared/services/httpClient';
import type {Invitation, TeamMember} from '@/features/team/types/team';
import type {UserRole} from '@/features/auth/types/auth';
/** What a direct create hands back — the member, and the password, once. */
export interface NewMember extends TeamMember {
password: string;
}
/** Console accounts. Unscoped — team membership is per company, not per site. */
export const teamRepository = {
list: (): Endpoint<TeamMember[]> => ({path: '/api/team', params: {}}),
invitations: (): Endpoint<Invitation[]> => ({
path: '/api/team/invitations',
params: {},
}),
/** Role change or deactivation. 409 `last_owner` is a real answer here. */
update: (id: string, patch: {role?: UserRole; active?: boolean}) =>
patchJson<TeamMember>(`/api/team/${encodeURIComponent(id)}`, patch),
/** Preferred: they redeem the code and pick their own password. */
invite: (body: {email: string; full_name?: string; role: UserRole}) =>
postJson<Invitation>('/api/team/invitations', body),
revokeInvitation: (id: string) =>
deleteJson<null>(`/api/team/invitations/${encodeURIComponent(id)}`),
/** For somebody with no phone to redeem an invitation on. */
create: (body: {email: string; full_name?: string; role: UserRole}) =>
postJson<NewMember>('/api/team/members', body),
resetPassword: (id: string) =>
postJson<{password: string}>(
`/api/team/${encodeURIComponent(id)}/password`,
{},
),
};

View File

@@ -0,0 +1,42 @@
import type {ApiInvitation, ApiTeamMember} from '@/services/api/types';
import type {Invitation, TeamMember} from '@/features/team/types/team';
import type {UserRole} from '@/features/auth/types/auth';
/**
* Platform shapes → what the team screen consumes.
*
* Extracted from the route file so the list route and the three write routes
* map identically. They did not before: only the list mapped, so a member that
* came back from a PATCH was a different shape from the same member in the
* list, and the screen could not use one to update the other.
*/
export 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 '' — "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 function toInvitation(i: ApiInvitation): Invitation {
return {
id: i.id,
email: i.email,
name: i.full_name || i.email,
role: i.role as UserRole,
invitedBy: i.invited_by ?? null,
expiresAt: i.expires_at,
createdAt: i.created_at,
// Present ONLY on the response that minted it. Every later read omits it,
// because the platform keeps a hash and cannot show it again.
code: i.code ?? null,
};
}

View File

@@ -24,3 +24,22 @@ export interface TeamMember {
lastLoginAt: string | null;
createdAt: string;
}
/**
* A pending invitation — an account that does not exist yet.
*
* `code` is present on exactly one response, the one that created it. The
* platform stores a hash, so a list read can never show it again; a screen that
* misses its one chance to display it has to withdraw the invitation and mint
* another.
*/
export interface Invitation {
id: string;
email: string;
name: string;
role: UserRole;
invitedBy: string | null;
expiresAt: string;
createdAt: string;
code: string | null;
}

View File

@@ -1,6 +1,6 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {ApiCamera, ApiSite} from './types';
import type {ApiCamera, ApiCameraInput, ApiEnrolmentCode, ApiSite} from './types';
/**
* Shops and cameras.
@@ -26,4 +26,63 @@ export const sitesApi = {
query: {site},
accessToken,
}),
/**
* Cameras are created UNDER a site, not at /api/cameras — the shop is what
* scopes them, and the platform derives the MQTT topic from it.
*
* Answers 201 with the created camera, so the caller never has to re-list.
*/
addCamera: (accessToken: string, site: string, body: ApiCameraInput) =>
upstreamRequest<ApiCamera>({
path: `/api/sites/${encodeURIComponent(site)}/cameras`,
method: 'POST',
body,
accessToken,
}),
/** PATCH, not PUT: omitted keys keep their stored value, and `password` is
* the one field that can be written but never read back. */
updateCamera: (accessToken: string, id: string, body: ApiCameraInput) =>
upstreamRequest<ApiCamera>({
path: `/api/cameras/${encodeURIComponent(id)}`,
method: 'PATCH',
body,
accessToken,
}),
deleteCamera: (accessToken: string, id: string) =>
upstreamRequest<void>({
path: `/api/cameras/${encodeURIComponent(id)}`,
method: 'DELETE',
accessToken,
}),
/**
* Ask the shop PC to prove a camera works.
*
* Answers 202, not 200: the check is queued for the edge to run, and the
* camera that comes back still carries the PREVIOUS result until it does.
* The UI must re-read rather than treat this response as the verdict.
*/
checkCamera: (
accessToken: string,
id: string,
kind: 'connection' | 'placement',
) =>
upstreamRequest<ApiCamera>({
path: `/api/cameras/${encodeURIComponent(id)}/check`,
method: 'POST',
body: {kind},
accessToken,
}),
/** One-time code that enrols a shop PC against this site. */
enrolmentCode: (accessToken: string, site: string, label?: string) =>
upstreamRequest<ApiEnrolmentCode>({
path: `/api/sites/${encodeURIComponent(site)}/enrolment-code`,
method: 'POST',
body: label ? {label} : {},
accessToken,
}),
};

View File

@@ -1,6 +1,12 @@
import 'server-only';
import {upstreamRequest} from './apiClient';
import type {ApiRole, ApiTeamMember} from './types';
import type {
ApiInvitation,
ApiNewMember,
ApiPasswordReset,
ApiRole,
ApiTeamMember,
} from './types';
/**
* The people with console accounts.
@@ -35,4 +41,65 @@ export const teamApi = {
body: patch,
accessToken,
}),
/**
* Create a login directly, and get the password back once.
*
* The OTHER way to add somebody is an invitation, where they choose their own
* password and the merchant never sees it. This exists for the case that flow
* cannot cover — somebody standing at the counter with no phone to redeem a
* code on.
*
* `password` is optional: omitted, the platform generates a strong one.
*/
createMember: (
accessToken: string,
body: {email: string; full_name?: string; role: ApiRole; password?: string},
) =>
upstreamRequest<ApiNewMember>({
path: '/api/team/members',
method: 'POST',
body,
accessToken,
}),
/** Omit `password` to have one generated. Returned once, never readable again. */
resetPassword: (accessToken: string, id: string, password?: string) =>
upstreamRequest<ApiPasswordReset>({
path: `/api/team/${encodeURIComponent(id)}/password`,
method: 'POST',
body: password ? {password} : {},
accessToken,
}),
invitations: (accessToken: string) =>
upstreamRequest<ApiInvitation[]>({
path: '/api/team/invitations',
accessToken,
}),
/** The `code` on the response is the only time it is ever legible. */
invite: (
accessToken: string,
body: {
email: string;
full_name?: string;
role: ApiRole;
expires_in_days?: number;
},
) =>
upstreamRequest<ApiInvitation>({
path: '/api/team/invitations',
method: 'POST',
body,
accessToken,
}),
/** Withdraw an unredeemed invitation. The code stops working immediately. */
revokeInvitation: (accessToken: string, id: string) =>
upstreamRequest<void>({
path: `/api/team/invitations/${encodeURIComponent(id)}`,
method: 'DELETE',
accessToken,
}),
};

View File

@@ -77,9 +77,29 @@ export interface ApiDeviceSession {
current: boolean;
}
/**
* What `POST /api/team/members` answers with: the member, plus the password.
*
* The password is returned exactly ONCE and is bcrypt-hashed on the way in, so
* it is not recoverable afterwards — the same show-once contract as an
* invitation code.
*/
export interface ApiNewMember extends ApiTeamMember {
password: string;
}
/** What `POST /api/team/{id}/password` answers with. Shown once, as above. */
export interface ApiPasswordReset {
password: string;
}
export interface ApiInvitation {
id: string;
email: string;
/** Both returned by the platform and both were missing here, so a pending
* row could not say who it was for or who had invited them. */
full_name?: string;
invited_by?: string;
role: Exclude<ApiRole, 'admin'>;
/** Returned exactly ONCE at mint time and never recoverable — only a hash is
* stored. Absent on every subsequent read. */
@@ -113,20 +133,127 @@ export interface ApiSite {
/** Immutable. Safe to persist in a URL or a saved report; the name is not. */
slug: string;
name: string;
timezone?: string;
online?: boolean;
cameras_total?: number;
cameras_up?: number;
/**
* These were optional here and are not optional on the wire.
*
* `SiteHealth` in the platform's api package returns every one of them on
* every row, so marking them `?` only forced `?? 0` at each call site — and a
* `?? 0` is indistinguishable from a site that genuinely reported zero. That
* is the same shape of defect as `site_id` above: nothing throws, the number
* is simply wrong.
*/
timezone: string;
online: boolean;
cameras_up: number;
cameras_total: number;
/** Share of arrivals whose match confidence fell below the identity gate. */
fraction_below_gate?: number;
fraction_below_gate: number;
/** Edge backlog. A rising `queued` with a flat `dropped` is a slow link; a
* rising `dropped` is a shop PC that has given up on frames. */
queued: number;
dropped: number;
/** Absent until a shop PC has reported for the first time. */
last_heartbeat_at?: string;
last_event_at?: string;
recognition_model?: string;
agent_version?: string;
}
/**
* A picture the platform holds, described rather than linked.
*
* `available: false` is a normal answer, not a failure: a deployment with no
* object storage configured serves every visit without images and says so in
* `reason`. Read the flag; never treat a missing `url` as an error.
*
* `auth: true` means the URL needs the caller's token, which a browser <img>
* cannot send — those go through /api/images, which adds it server-side.
*/
export interface ApiImage {
available: boolean;
url?: string;
expires_in?: number;
auth?: boolean;
reason?: string;
}
/** The result of asking a camera to prove itself — connection or placement. */
export interface ApiCameraCheck {
kind?: 'connection' | 'placement' | string;
state?: 'requested' | 'started' | 'finished' | string;
requested_at?: string;
finished_at?: string;
seconds?: number;
ok: boolean;
verdict?: string;
headline?: string;
advice?: string[];
detail?: Record<string, unknown>;
image: ApiImage;
}
/**
* A camera as the platform actually describes it.
*
* What this replaced was wrong in a way nothing reported: it declared `name`,
* `online` and `last_still_url`, and the platform's `Camera` struct has none of
* them — it has `label`, `connected` and `snapshot`. All three deserialised to
* undefined on every row, so a camera list rendered blank labels and an
* unknown state while the request itself succeeded.
*
* `connected` is deliberately three-valued: null means no shop PC has reported
* on this camera yet, which is not the same as reporting that it cannot reach
* it.
*/
export interface ApiCamera {
id: string;
site_id: string;
name?: string;
online?: boolean;
last_still_url?: string | null;
site?: string;
/** Stable identifier within the site; appears in MQTT topics. */
camera_id: string;
label: string;
host: string;
port: number;
path: string;
username: string;
/** The password itself is never returned — only whether one is stored. */
has_password: boolean;
max_width: number;
tuning?: Record<string, unknown>;
enabled: boolean;
/** Incremented by the platform on every accepted edit. */
revision: number;
connected?: boolean | null;
last_seen_at?: string;
snapshot: ApiImage;
snapshot_at?: string;
check: ApiCameraCheck;
}
/** Everything a camera write accepts. Omitted keys are left as they were. */
export interface ApiCameraInput {
camera_id?: string;
label?: string;
host?: string;
port?: number;
path?: string;
username?: string;
password?: string;
max_width?: number;
enabled?: boolean;
}
/**
* A one-time code that enrols a shop PC against a site.
*
* `code` comes back once and is not recoverable — the platform keeps a hash.
*/
export interface ApiEnrolmentCode {
code: string;
site_id: string;
site_name: string;
label?: string;
expires_at: string;
}
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,65 @@
'use client';
import {useState} from 'react';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {Text} from '@astryxdesign/core/Text';
import {Button} from '@astryxdesign/core/Button';
import {Banner} from '@astryxdesign/core/Banner';
/**
* A value the platform hands out exactly once and can never show again.
*
* Enrolment codes and invitation codes are both stored as a hash upstream, so
* "copy it now" is not advice — it is the only chance. This exists so both
* screens say that the same way, and so neither is tempted to stash the value
* somewhere it can be re-read later, which would defeat the hashing.
*
* Deliberately NOT auto-copied to the clipboard on mount: a silent clipboard
* write is both a surprise and, on a shared shop PC, a credential left behind
* for whoever pastes next. The button is one tap and says what it did.
*/
export function SecretOnce({
value,
label,
note,
}: {
value: string;
label: string;
note?: string;
}) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
} catch {
// A denied clipboard permission is not an error worth a banner — the
// value is on screen and can be selected by hand.
}
}
return (
<VStack gap={2} width="100%">
<Banner
status="warning"
title="Shown once"
description={note ?? 'This cannot be shown again. Copy it now.'}
/>
<Text size="xsm" color="secondary">
{label}
</Text>
<HStack gap={2} vAlign="center" hAlign="between">
<Text size="sm" weight="medium">
{value}
</Text>
<Button
size="sm"
variant="secondary"
onClick={() => void copy()}
label={copied ? 'Copied' : 'Copy'}
/>
</HStack>
</VStack>
);
}

View File

@@ -112,6 +112,58 @@ export function failResponse(err: unknown): Response {
* `map` exists so the platform's shape and the UI's shape can differ without a
* component knowing. Where they already agree, omit it.
*/
/**
* Serve one platform WRITE as an enveloped response.
*
* The sibling of `serveUpstream`, which is read-shaped: it parses a query and
* never a body. A mutation needs the opposite, and hand-rolling the body parse,
* the token, the envelope and the error mapping in each route file is how four
* routes end up disagreeing about what a 409 looks like.
*
* The body is parsed leniently and defaults to `{}`: a DELETE legitimately has
* none, and a malformed one is the platform's to reject with its own message
* rather than this layer inventing a 400 of its own.
*
* `status` exists for the routes whose success is a 201.
*/
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 {
/* deliberately ignored — see above */
}
}
try {
const upstream = await withUpstream((token) =>
call(token, body, req.nextUrl.searchParams),
);
const data = opts?.map ? opts.map(upstream) : upstream;
return Response.json(
// `?? null` because several platform writes answer 204 with no body, and
// `{data: undefined}` serialises to `{}` — which reads as a shape error
// on the client rather than as "it worked and said nothing".
{data: data ?? null, meta: {generatedAt: new Date().toISOString()}},
{status: opts?.status ?? 200, headers: {'cache-control': 'no-store'}},
);
} catch (err) {
return failResponse(err);
}
}
export async function serveUpstream<U, T = U>(
req: NextRequest,
fetcher: (accessToken: string, query: Query) => Promise<U>,

View File

@@ -162,3 +162,8 @@ export function patchJson<T>(
body: JSON.stringify(body),
});
}
/** No body, by design: a DELETE that needs one is a POST wearing a costume. */
export function deleteJson<T>(path: string): Promise<HttpResult<T>> {
return request<T>(path, {method: 'DELETE'});
}