login issue
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
20
src/app/api/auth/sessions/[id]/route.ts
Normal file
20
src/app/api/auth/sessions/[id]/route.ts
Normal 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));
|
||||
}
|
||||
16
src/app/api/auth/sessions/revoke-others/route.ts
Normal file
16
src/app/api/auth/sessions/revoke-others/route.ts
Normal 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));
|
||||
}
|
||||
20
src/app/api/auth/sessions/route.ts
Normal file
20
src/app/api/auth/sessions/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
34
src/app/api/cameras/[id]/check/route.ts
Normal file
34
src/app/api/cameras/[id]/check/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
36
src/app/api/cameras/[id]/route.ts
Normal file
36
src/app/api/cameras/[id]/route.ts
Normal 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));
|
||||
}
|
||||
39
src/app/api/cameras/route.ts
Normal file
39
src/app/api/cameras/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
67
src/app/api/images/route.ts
Normal file
67
src/app/api/images/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/app/api/sites/[site]/enrolment-code/route.ts
Normal file
30
src/app/api/sites/[site]/enrolment-code/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
32
src/app/api/team/[id]/password/route.ts
Normal file
32
src/app/api/team/[id]/password/route.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
34
src/app/api/team/[id]/route.ts
Normal file
34
src/app/api/team/[id]/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
19
src/app/api/team/invitations/[id]/route.ts
Normal file
19
src/app/api/team/invitations/[id]/route.ts
Normal 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));
|
||||
}
|
||||
39
src/app/api/team/invitations/route.ts
Normal file
39
src/app/api/team/invitations/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
34
src/app/api/team/members/route.ts
Normal file
34
src/app/api/team/members/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
58
src/features/settings/components/AccountCard.tsx
Normal file
58
src/features/settings/components/AccountCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
src/features/settings/repositories/securityRepository.ts
Normal file
16
src/features/settings/repositories/securityRepository.ts
Normal 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', {}),
|
||||
};
|
||||
25
src/features/settings/services/mapSession.ts
Normal file
25
src/features/settings/services/mapSession.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
144
src/features/stores/components/CameraCard.tsx
Normal file
144
src/features/stores/components/CameraCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src/features/stores/components/CameraDialog.tsx
Normal file
180
src/features/stores/components/CameraDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/features/stores/components/EnrolmentCodeDialog.tsx
Normal file
112
src/features/stores/components/EnrolmentCodeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
197
src/features/stores/components/ShopSection.tsx
Normal file
197
src/features/stores/components/ShopSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
79
src/features/stores/data/cameraMakes.ts
Normal file
79
src/features/stores/data/cameraMakes.ts
Normal 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];
|
||||
26
src/features/stores/hooks/useCameras.ts
Normal file
26
src/features/stores/hooks/useCameras.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
51
src/features/stores/repositories/cameraRepository.ts
Normal file
51
src/features/stores/repositories/cameraRepository.ts
Normal 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}),
|
||||
};
|
||||
74
src/features/stores/services/mapCamera.ts
Normal file
74
src/features/stores/services/mapCamera.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
53
src/features/stores/types/camera.ts
Normal file
53
src/features/stores/types/camera.ts
Normal 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;
|
||||
}
|
||||
183
src/features/team/components/AddMemberDialog.tsx
Normal file
183
src/features/team/components/AddMemberDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
|
||||
42
src/features/team/services/mapTeam.ts
Normal file
42
src/features/team/services/mapTeam.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
65
src/shared/components/patterns/SecretOnce.tsx
Normal file
65
src/shared/components/patterns/SecretOnce.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user