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}
</HStack>
<Text size="sm" color="secondary">
{row.location} • {row.ip}
<HStack gap={2} vAlign="center">
<Text size="sm" weight="medium">
{row.device}
</Text>
</VStack>
{row.isCurrent ? (
<Badge variant="success" label="This browser" />
) : null}
</HStack>
),
},
{
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()}
/>
}
>
<ResponsiveTable
{(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. */}
<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.
</Text>
{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">
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'});
}