Stores, Team and Account run against the platform, not a mock
Stores: each shop's health in one line (offline, losing visits, camera trouble, faces too poor - in severity order, one verdict), its cameras as pictures with connection and 'proven to recognise a face' as two different claims, add/edit/remove camera with the make picker, test connection and placement checks claimed by the shop PC, and the enrolment code a new shop PC types. Team: create an account (password shown once), invite (code shown once), change role, remove access, reset password, revoke pending invitations - with the rank rules the server enforces mirrored in what the form offers. Account: who you are and every device signed in, with 'sign out' per device and everywhere else. Gone: StoreManagement, SecurityManager and ProfileForm that rendered hard-coded arrays, the Business form with no backend, the /api/stores route nothing served, and the cascading account menu's dead links. Two things found by using the camera form, not by tests: Chrome filled the operator's own email into 'camera username', and the first camera saved with a password and no username because autofill wrote to the input without React seeing it. The dialog sets autocomplete on the real inputs and reads the credential fields from the DOM at submit. Every route was exercised against production before this commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||
import {BusinessForm} from '@/features/settings/components/BusinessForm';
|
||||
import {AccountCard} from '@/features/settings/components/AccountCard';
|
||||
import {SecurityManager} from '@/features/settings/components/SecurityManager';
|
||||
import {VStack} from '@astryxdesign/core/Layout';
|
||||
|
||||
export default function BusinessSettingsPage() {
|
||||
export default function AccountSettingsPage() {
|
||||
return (
|
||||
<SettingsPage
|
||||
title="Business Settings"
|
||||
description="Company profile, GSTIN registration, registered address and LYT earn defaults."
|
||||
>
|
||||
<BusinessForm />
|
||||
<SettingsPage title="Account" description="Who you are signed in as, and where.">
|
||||
<VStack gap={5}>
|
||||
<AccountCard />
|
||||
<SecurityManager />
|
||||
</VStack>
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||
import {ProfileForm} from '@/features/settings/components/ProfileForm';
|
||||
import {FeatureUnavailable} from '@/shared/components/patterns/FeatureUnavailable';
|
||||
import {settingsServerRepository} from '@/features/settings/repositories/settingsServerRepository';
|
||||
|
||||
/**
|
||||
* Server Component: the record is read on the server and handed to the form as
|
||||
* its initial state, so the inputs paint filled rather than flashing empty.
|
||||
* The form then saves through the client repository over HTTP.
|
||||
*
|
||||
* The read goes through a repository rather than the fixture module the page
|
||||
* used to import — a page that knows the shape of a mock is a page that breaks
|
||||
* the day the mock is deleted.
|
||||
*/
|
||||
export default async function MerchantProfilePage() {
|
||||
const profile = await settingsServerRepository.getProfile();
|
||||
|
||||
return (
|
||||
<SettingsPage
|
||||
title="Personal Profile"
|
||||
description="Your user credentials, contact details, account email and timezone preference."
|
||||
>
|
||||
{profile ? (
|
||||
<ProfileForm initialData={profile} />
|
||||
) : (
|
||||
<FeatureUnavailable
|
||||
title="Business profile"
|
||||
description="Your sign-in details are shown above. The wider company record — business name, GSTIN, registered address, timezone and currency — is not editable here yet, so this section is left out rather than offering a form that would not save."
|
||||
/>
|
||||
)}
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||
import {SecurityManager} from '@/features/settings/components/SecurityManager';
|
||||
|
||||
export default function SecuritySettingsPage() {
|
||||
return (
|
||||
<SettingsPage
|
||||
title="Security & Audit Logs"
|
||||
description="Two-Factor authentication, password management, active login sessions and security audit history."
|
||||
>
|
||||
<SecurityManager />
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {SettingsPage} from '@/features/settings/components/SettingsPage';
|
||||
import {StoreManagement} from '@/features/settings/components/StoreManagement';
|
||||
|
||||
export default function StoreSettingsPage() {
|
||||
return (
|
||||
<SettingsPage
|
||||
title="Store Locations"
|
||||
description="Branch operations, outlet directory, operating schedules and manager assignments."
|
||||
>
|
||||
<StoreManagement />
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
@@ -1,94 +1,43 @@
|
||||
'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 {Divider} from '@astryxdesign/core/Divider';
|
||||
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 {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {ShopSection} from '@/features/stores/components/ShopSection';
|
||||
|
||||
/**
|
||||
* The estate, from GET /api/sites.
|
||||
*
|
||||
* Health fields are nullable and rendered as "—" when the platform does not
|
||||
* report them. A deployment that sends no camera health is not a deployment
|
||||
* with zero cameras up, and printing "0/0" for "not reported" makes a working
|
||||
* estate look broken.
|
||||
* The screen somebody opens to find out whether their shops are WORKING.
|
||||
* Each shop: its health in one line, then its cameras as pictures, with the
|
||||
* actions that make a shop real - add a camera, set up the PC, prove the
|
||||
* camera can see a face.
|
||||
*/
|
||||
export default function StoresPage() {
|
||||
const sites = useSites();
|
||||
const {user} = useSession();
|
||||
const canManage = user?.role === 'owner' || user?.role === 'manager';
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<PageHeader
|
||||
title="Store"
|
||||
description="Every shop in the network, with its camera health."
|
||||
/>
|
||||
|
||||
<VStack gap={6}>
|
||||
<PageHeader title="Stores" description="Every shop, whether it is working, and its cameras." />
|
||||
<AsyncBoundary
|
||||
resource={sites}
|
||||
loading={<SkeletonCardGrid count={4} height={170} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No stores yet"
|
||||
description="Shops appear here once they are registered on the platform."
|
||||
/>
|
||||
}
|
||||
loading={<SkeletonCardGrid count={2} height={170} />}
|
||||
empty={<EmptyPanel icon="stores" title="No stores yet" description="Loyaly registers your shops. Contact support to add one." />}
|
||||
>
|
||||
{(rows) => (
|
||||
<Grid columns={{minWidth: 280, repeat: 'fit'}} gap={4}>
|
||||
{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>
|
||||
<VStack gap={8}>
|
||||
{rows.map((site, i) => (
|
||||
<VStack key={site.uuid} gap={6}>
|
||||
{i > 0 && <Divider />}
|
||||
<ShopSection site={site} canManage={canManage} />
|
||||
</VStack>
|
||||
))}
|
||||
</Grid>
|
||||
</VStack>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
</VStack>
|
||||
|
||||
10
src/app/api/auth/sessions/[id]/route.ts
Normal file
10
src/app/api/auth/sessions/[id]/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
|
||||
const {id} = await ctx.params;
|
||||
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
|
||||
}
|
||||
10
src/app/api/auth/sessions/revoke-others/route.ts
Normal file
10
src/app/api/auth/sessions/revoke-others/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** Keeps the calling device signed in; everything else is out immediately. */
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
|
||||
}
|
||||
10
src/app/api/auth/sessions/route.ts
Normal file
10
src/app/api/auth/sessions/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET /api/auth/sessions - every device signed in as this person. */
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyUpstream(req, (token) => authApi.sessions(token));
|
||||
}
|
||||
17
src/app/api/cameras/[id]/check/route.ts
Normal file
17
src/app/api/cameras/[id]/check/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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 {kind: connection|placement}. The shop PC
|
||||
* claims the job on its next sync; poll the camera list for the result. */
|
||||
export async function POST(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
|
||||
const {id} = await ctx.params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => sitesApi.checkCamera(token, id, body.kind === 'placement' ? 'placement' : 'connection'),
|
||||
{map: toCamera, status: 202},
|
||||
);
|
||||
}
|
||||
21
src/app/api/cameras/[id]/route.ts
Normal file
21
src/app/api/cameras/[id]/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import type {ApiCameraInput} from '@/services/api/types';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toCamera} from '@/features/stores/services/mapCamera';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
type Ctx = {params: Promise<{id: string}>};
|
||||
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
const {id} = await ctx.params;
|
||||
return proxyUpstream(req, (token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput), {
|
||||
map: toCamera,
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
const {id} = await ctx.params;
|
||||
return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id));
|
||||
}
|
||||
26
src/app/api/cameras/route.ts
Normal file
26
src/app/api/cameras/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {sitesApi} from '@/services/api/sitesApi';
|
||||
import type {ApiCameraInput} from '@/services/api/types';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toCamera} from '@/features/stores/services/mapCamera';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** GET /api/cameras?site=<slug> - every camera, or one shop's. */
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyUpstream(req, (token, _body, params) => sitesApi.cameras(token, params.get('site') || undefined), {
|
||||
map: (cams) => cams.map(toCamera),
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /api/cameras {site, ...camera} - add a camera to a shop. */
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => {
|
||||
const {site, ...input} = body as {site?: string} & ApiCameraInput;
|
||||
return sitesApi.addCamera(token, String(site ?? ''), input);
|
||||
},
|
||||
{map: toCamera, status: 201},
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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/faces?src=/api/faces/<uuid>.jpg — an authenticated photo, proxied.
|
||||
*
|
||||
* A browser `<img>` cannot send an Authorization header, and the platform's
|
||||
* own image URLs require one. The alternatives were fetch + createObjectURL +
|
||||
* revoke-on-unmount at every avatar — which leaks hundreds of copies of one
|
||||
* photograph on a screen left open all afternoon — or this: one hop through
|
||||
* the origin that already holds the token.
|
||||
*
|
||||
* ── Why `src` is validated rather than trusted ───────────────────────────
|
||||
* An unchecked pass-through would be an open proxy that attaches the
|
||||
* merchant's bearer token to any URL an attacker can get into a page. Only
|
||||
* same-origin platform paths under /api/faces/ are forwarded.
|
||||
*
|
||||
* Every hand-out of a photo is written to the platform's audit log, so this
|
||||
* must be requested once per screen rather than once per component: two
|
||||
* components asking for the same face puts two rows in "who looked at my
|
||||
* customers" for one glance at one person.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const src = new URL(req.url).searchParams.get('src') ?? '';
|
||||
|
||||
// Relative, no traversal, and inside the faces namespace. Anything else is
|
||||
// refused rather than sanitised — a "cleaned" attacker-supplied URL is still
|
||||
// attacker-supplied.
|
||||
if (!src.startsWith('/api/faces/') || src.includes('..')) {
|
||||
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 customer, and a shared cache holding
|
||||
// it would serve it across tenants.
|
||||
'cache-control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
38
src/app/api/images/route.ts
Normal file
38
src/app/api/images/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* The browser cannot put a bearer on an <img>, so pictures the platform
|
||||
* serves with the session - faces, camera snapshots - come through here.
|
||||
* Presigned bucket links are absolute and load directly; they never come here.
|
||||
*/
|
||||
const ALLOWED = [/^\/api\/faces\/[^/?]+$/, /^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/, /^\/api\/visitors\/[^/?]+\/image$/];
|
||||
|
||||
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: one merchant's customer or shop floor. A shared cache
|
||||
// holding it would serve it across tenants.
|
||||
'cache-control': 'private, max-age=60',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
19
src/app/api/sites/[site]/enrolment-code/route.ts
Normal file
19
src/app/api/sites/[site]/enrolment-code/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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 - the code a new shop PC types. */
|
||||
export async function POST(req: NextRequest, ctx: {params: Promise<{site: string}>}) {
|
||||
const {site} = await ctx.params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
sitesApi.enrolmentCode(token, site, {
|
||||
label: typeof body.label === 'string' ? body.label : undefined,
|
||||
ttl_hours: typeof body.ttl_hours === 'number' ? body.ttl_hours : undefined,
|
||||
}),
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
@@ -6,33 +6,24 @@ import type {Site} from '@/features/stores/types/site';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* GET /api/sites — the estate.
|
||||
*
|
||||
* This is the most load-bearing read in the console: the site switcher scopes
|
||||
* every other request in the app, so a hardcoded list here meant every screen
|
||||
* was filtered by a store that might not exist.
|
||||
*
|
||||
* `slug` is carried through as the identifier the UI keys on because it is
|
||||
* IMMUTABLE upstream and safe to persist in a URL or a saved report, while the
|
||||
* display name is expected to change.
|
||||
*/
|
||||
function toSite(s: ApiSite): Site {
|
||||
return {
|
||||
// Slug first: it is immutable and is what every scoped request sends as
|
||||
// `?site=`. `site_id` is the uuid — the server does not send a bare `id`.
|
||||
id: s.slug || s.site_id,
|
||||
id: s.slug,
|
||||
uuid: s.site_id,
|
||||
name: s.name,
|
||||
isOnline: s.online ?? null,
|
||||
camerasTotal: s.cameras_total ?? null,
|
||||
camerasUp: s.cameras_up ?? null,
|
||||
fractionBelowGate: s.fraction_below_gate ?? null,
|
||||
timezone: s.timezone,
|
||||
isOnline: s.online,
|
||||
lastHeartbeatAt: s.last_heartbeat_at ?? null,
|
||||
lastEventAt: s.last_event_at ?? null,
|
||||
recognitionModel: s.recognition_model ?? null,
|
||||
camerasTotal: s.cameras_total,
|
||||
camerasUp: s.cameras_up,
|
||||
fractionBelowGate: s.fraction_below_gate,
|
||||
queued: s.queued,
|
||||
dropped: s.dropped,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(req, (token) => sitesApi.list(token), (sites) =>
|
||||
sites.map(toSite),
|
||||
);
|
||||
return serveUpstream(req, (token) => sitesApi.list(token), (sites) => sites.map(toSite));
|
||||
}
|
||||
|
||||
13
src/app/api/team/[id]/password/route.ts
Normal file
13
src/app/api/team/[id]/password/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
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 - new password shown ONCE; signs them out everywhere. */
|
||||
export async function POST(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
|
||||
const {id} = await ctx.params;
|
||||
return proxyUpstream(req, (token, body) =>
|
||||
teamApi.resetPassword(token, id, typeof body.password === 'string' && body.password ? body.password : undefined),
|
||||
);
|
||||
}
|
||||
18
src/app/api/team/[id]/route.ts
Normal file
18
src/app/api/team/[id]/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import type {ApiRole} from '@/services/api/types';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** PATCH /api/team/{id} {role?, active?}. Deactivating revokes every session
|
||||
* that person holds, in the same transaction, server-side. */
|
||||
export async function PATCH(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
|
||||
const {id} = await ctx.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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
10
src/app/api/team/invitations/[id]/route.ts
Normal file
10
src/app/api/team/invitations/[id]/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: {params: Promise<{id: string}>}) {
|
||||
const {id} = await ctx.params;
|
||||
return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id));
|
||||
}
|
||||
25
src/app/api/team/invitations/route.ts
Normal file
25
src/app/api/team/invitations/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import type {ApiRole} from '@/services/api/types';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return proxyUpstream(req, (token) => teamApi.invitations(token));
|
||||
}
|
||||
|
||||
/** POST /api/team/invitations - the code is in the response ONCE. */
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
teamApi.invite(token, {
|
||||
email: String(body.email ?? ''),
|
||||
full_name: typeof body.full_name === 'string' ? body.full_name : undefined,
|
||||
role: String(body.role ?? 'staff') as ApiRole,
|
||||
ttl_hours: typeof body.ttl_hours === 'number' ? body.ttl_hours : undefined,
|
||||
}),
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
22
src/app/api/team/members/route.ts
Normal file
22
src/app/api/team/members/route.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {teamApi} from '@/services/api/teamApi';
|
||||
import type {ApiRole} from '@/services/api/types';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/** POST /api/team/members - create an account. The password is in the
|
||||
* response ONCE and nowhere else. */
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) =>
|
||||
teamApi.createMember(token, {
|
||||
email: String(body.email ?? ''),
|
||||
full_name: String(body.full_name ?? ''),
|
||||
role: String(body.role ?? 'staff') as ApiRole,
|
||||
password: typeof body.password === 'string' && body.password ? body.password : undefined,
|
||||
}),
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ function toArrival(a: ApiArrival): Arrival {
|
||||
url: a.image.url
|
||||
? a.image.url.startsWith('http')
|
||||
? a.image.url
|
||||
: `/api/faces?src=${encodeURIComponent(a.image.url)}`
|
||||
: `/api/images?src=${encodeURIComponent(a.image.url)}`
|
||||
: null,
|
||||
reason: a.image.reason ?? null,
|
||||
}
|
||||
|
||||
31
src/features/settings/components/AccountCard.tsx
Normal file
31
src/features/settings/components/AccountCard.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {roleLabel} from '@/features/team/components/AddMemberDialog';
|
||||
|
||||
/** Who you are signed in as. Read-only: the platform has no profile editor
|
||||
* yet, and a form that does not save is worse than none. */
|
||||
export function AccountCard() {
|
||||
const {user} = useSession();
|
||||
if (!user) return null;
|
||||
return (
|
||||
<Card>
|
||||
<HStack gap={3} vAlign="center">
|
||||
<Avatar name={user.name} size="lg" tooltip={false} />
|
||||
<VStack gap={0.5}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="lg" weight="medium">{user.name}</Text>
|
||||
<Badge variant="neutral" label={roleLabel(user.role)} />
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">{user.email}</Text>
|
||||
<Text size="sm" color="secondary">{user.organisation}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {FormLayout} from '@astryxdesign/core/FormLayout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {NumberInput} from '@astryxdesign/core/NumberInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
|
||||
export interface BusinessData {
|
||||
businessName: string;
|
||||
legalEntity: string;
|
||||
category: string;
|
||||
gstin: string;
|
||||
pan: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
city: string;
|
||||
pincode: string;
|
||||
lytsPerHundred: number;
|
||||
}
|
||||
|
||||
const DEFAULT_BUSINESS: BusinessData = {
|
||||
businessName: 'Loyaly Retail Pvt Ltd',
|
||||
legalEntity: 'Private Limited Company',
|
||||
category: 'Retail & Quick Service Restaurant',
|
||||
gstin: '29AABCL1234M1Z7',
|
||||
pan: 'AABCL1234M',
|
||||
phone: '+91 98450 12345',
|
||||
email: 'aravind@nearle.in',
|
||||
address: '100 Feet Road, Indiranagar',
|
||||
city: 'Bengaluru, Karnataka',
|
||||
pincode: '560038',
|
||||
lytsPerHundred: 5,
|
||||
};
|
||||
|
||||
export function BusinessForm() {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState<BusinessData>(DEFAULT_BUSINESS);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [savedData, setSavedData] = useState<BusinessData>(DEFAULT_BUSINESS);
|
||||
|
||||
const set = <K extends keyof BusinessData>(key: K, val: BusinessData[K]) =>
|
||||
setForm((f) => ({...f, [key]: val}));
|
||||
|
||||
const isDirty = JSON.stringify(form) !== JSON.stringify(savedData);
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
setTimeout(() => {
|
||||
setSavedData(form);
|
||||
setIsSaving(false);
|
||||
toast({body: 'Business details updated successfully'});
|
||||
}, 400);
|
||||
};
|
||||
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Business Information"
|
||||
subtitle="Official company details, tax registration and operating address."
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{isDirty ? (
|
||||
<Text size="sm" color="secondary">
|
||||
Unsaved changes
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Reset"
|
||||
isDisabled={!isDirty || isSaving}
|
||||
onClick={() => setForm(savedData)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save changes"
|
||||
isDisabled={!isDirty}
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={5}>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Business name"
|
||||
value={form.businessName}
|
||||
onChange={(v) => set('businessName', v)}
|
||||
isRequired
|
||||
/>
|
||||
<Selector
|
||||
label="Legal entity structure"
|
||||
value={form.legalEntity}
|
||||
onChange={(v) => set('legalEntity', v)}
|
||||
options={[
|
||||
'Private Limited Company',
|
||||
'Sole Proprietorship',
|
||||
'Partnership Firm',
|
||||
'Limited Liability Partnership (LLP)',
|
||||
]}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="GSTIN"
|
||||
value={form.gstin}
|
||||
onChange={(v) => set('gstin', v.toUpperCase())}
|
||||
description="15-digit Tax Registration Number"
|
||||
/>
|
||||
<TextInput
|
||||
label="PAN"
|
||||
value={form.pan}
|
||||
onChange={(v) => set('pan', v.toUpperCase())}
|
||||
description="10-digit Permanent Account Number"
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Support email"
|
||||
value={form.email}
|
||||
onChange={(v) => set('email', v)}
|
||||
isRequired
|
||||
/>
|
||||
<TextInput
|
||||
label="Support phone"
|
||||
value={form.phone}
|
||||
onChange={(v) => set('phone', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Registered address"
|
||||
value={form.address}
|
||||
onChange={(v) => set('address', v)}
|
||||
/>
|
||||
<TextInput
|
||||
label="City & State"
|
||||
value={form.city}
|
||||
onChange={(v) => set('city', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Pincode"
|
||||
value={form.pincode}
|
||||
onChange={(v) => set('pincode', v)}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Default LYTs per ₹100 spent"
|
||||
value={form.lytsPerHundred}
|
||||
onChange={(v) => set('lytsPerHundred', v ?? 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
description="1 LYT = ₹1. Basket ₹1,000 earns 50 LYTs (5%)."
|
||||
/>
|
||||
</FormLayout>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {FormLayout} from '@astryxdesign/core/FormLayout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {NumberInput} from '@astryxdesign/core/NumberInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
import {settingsService} from '@/features/settings/services/settingsService';
|
||||
import type {ProfileFieldErrors} from '@/features/settings/services/settingsService';
|
||||
import type {MerchantProfile} from '@/features/settings/types/settings';
|
||||
|
||||
/**
|
||||
* Merchant profile.
|
||||
*
|
||||
* `initialData` arrives from a Server Component that read the record
|
||||
* DIRECTLY — no HTTP round trip — so the form paints filled on first render
|
||||
* rather than flashing empty inputs. This is the hybrid path the data layer
|
||||
* was designed for: dashboards fetch on the client because their scope
|
||||
* changes constantly, but a settings form has exactly one server-known state
|
||||
* at page load and should use it.
|
||||
*
|
||||
* Status is only ever set on submit, never at initial paint — Astryx's
|
||||
* FieldStatus breaks SSR hydration when it is server-rendered with a status
|
||||
* already applied (see AGENTS.md).
|
||||
*/
|
||||
export function ProfileForm({initialData}: {initialData: MerchantProfile}) {
|
||||
const toast = useToast();
|
||||
const [form, setForm] = useState(initialData);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [errors, setErrors] = useState<ProfileFieldErrors>({});
|
||||
|
||||
const set = <K extends keyof MerchantProfile>(
|
||||
key: K,
|
||||
value: MerchantProfile[K],
|
||||
) => setForm((f) => ({...f, [key]: value}));
|
||||
|
||||
const isDirty = JSON.stringify(form) !== JSON.stringify(initialData);
|
||||
|
||||
async function save() {
|
||||
// Validation and transport both live in settingsService — this component
|
||||
// decides what to render, never what a valid profile is or where it goes.
|
||||
// The raw fetch that used to sit here was the last one in the codebase.
|
||||
setIsSaving(true);
|
||||
const result = await settingsService.saveProfile(form);
|
||||
setIsSaving(false);
|
||||
|
||||
if (result.ok) {
|
||||
setErrors({});
|
||||
toast({body: 'Profile saved'});
|
||||
return;
|
||||
}
|
||||
setErrors(result.errors);
|
||||
if (result.message) {
|
||||
toast({type: 'error', body: result.message});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StaticPanel
|
||||
title="Business profile"
|
||||
subtitle="Shown on receipts and used for LYT settlement."
|
||||
actions={
|
||||
<HStack gap={2} vAlign="center">
|
||||
{isDirty ? (
|
||||
<Text size="sm" color="secondary">
|
||||
Unsaved changes
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Reset"
|
||||
isDisabled={!isDirty || isSaving}
|
||||
onClick={() => {
|
||||
setForm(initialData);
|
||||
setErrors({});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Save changes"
|
||||
isDisabled={!isDirty}
|
||||
isLoading={isSaving}
|
||||
onClick={save}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack gap={5}>
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Business name"
|
||||
value={form.businessName}
|
||||
onChange={(v) => set('businessName', v)}
|
||||
isRequired
|
||||
status={
|
||||
errors.businessName
|
||||
? {type: 'error', message: errors.businessName}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Contact name"
|
||||
value={form.contactName}
|
||||
onChange={(v) => set('contactName', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(v) => set('email', v)}
|
||||
isRequired
|
||||
status={
|
||||
errors.email ? {type: 'error', message: errors.email} : undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Phone"
|
||||
value={form.phone}
|
||||
onChange={(v) => set('phone', v)}
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<Divider />
|
||||
|
||||
<FormLayout direction="horizontal">
|
||||
<TextInput
|
||||
label="GSTIN"
|
||||
value={form.gstin}
|
||||
onChange={(v) => set('gstin', v.toUpperCase())}
|
||||
description="15-character GST registration number"
|
||||
status={
|
||||
errors.gstin ? {type: 'error', message: errors.gstin} : undefined
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Timezone"
|
||||
value={form.timezone}
|
||||
onChange={(v) => set('timezone', v)}
|
||||
description="All reports are rendered in this zone"
|
||||
/>
|
||||
</FormLayout>
|
||||
|
||||
<NumberInput
|
||||
label="LYTs per ₹100 spent"
|
||||
value={form.lytsPerHundred}
|
||||
onChange={(v) => set('lytsPerHundred', v ?? 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
description="1 LYT = ₹1. At 5, a ₹1,000 basket earns 50 LYTs — ₹50 of future liability."
|
||||
width={320}
|
||||
/>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
);
|
||||
}
|
||||
@@ -1,306 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Switch} from '@astryxdesign/core/Switch';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
|
||||
export interface ActiveSessionItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
device: string;
|
||||
location: string;
|
||||
ip: string;
|
||||
lastActive: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
export interface LoginAuditItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
user: string;
|
||||
ip: string;
|
||||
device: string;
|
||||
status: 'success' | 'failed';
|
||||
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',
|
||||
user: 'aravind@nearle.in',
|
||||
ip: '103.15.24.81',
|
||||
device: 'macOS Chrome',
|
||||
status: 'success',
|
||||
timestamp: 'Today, 14:02',
|
||||
},
|
||||
{
|
||||
id: 'aud-2',
|
||||
user: 'vikram@nearle.in',
|
||||
ip: '106.51.72.19',
|
||||
device: 'Windows Chrome',
|
||||
status: 'success',
|
||||
timestamp: 'Today, 11:15',
|
||||
},
|
||||
{
|
||||
id: 'aud-3',
|
||||
user: 'aravind@nearle.in',
|
||||
ip: '185.220.101.5',
|
||||
device: 'Unknown Linux Device',
|
||||
status: 'failed',
|
||||
timestamp: 'Yesterday, 22:30',
|
||||
},
|
||||
];
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {securityRepository} from '@/features/settings/repositories/securityRepository';
|
||||
import type {DeviceSession} from '@/features/settings/repositories/securityRepository';
|
||||
|
||||
/**
|
||||
* Every device signed in as this person, and the button that ends one.
|
||||
* This is the benefit of session tokens over JWTs, finally collected: staff
|
||||
* phones and shop PCs get lost, resold and shared, so "sign that one out,
|
||||
* now" has to actually work - and it does, immediately, server-side.
|
||||
*/
|
||||
export function SecurityManager() {
|
||||
const sessions = useResource(securityRepository.sessions());
|
||||
const toast = useToast();
|
||||
const [twoFactor, setTwoFactor] = useState(true);
|
||||
const [sessions, setSessions] = useState<ActiveSessionItem[]>(INITIAL_SESSIONS);
|
||||
const [currentPass, setCurrentPass] = useState('');
|
||||
const [newPass, setNewPass] = useState('');
|
||||
const [confirmPass, setConfirmPass] = useState('');
|
||||
const [isChangingPass, setIsChangingPass] = useState(false);
|
||||
|
||||
const handleChangePassword = () => {
|
||||
if (!currentPass || !newPass) {
|
||||
toast({type: 'error', body: 'Please fill in all password fields'});
|
||||
return;
|
||||
}
|
||||
if (newPass !== confirmPass) {
|
||||
toast({type: 'error', body: 'New passwords do not match'});
|
||||
return;
|
||||
}
|
||||
setIsChangingPass(true);
|
||||
setTimeout(() => {
|
||||
setIsChangingPass(false);
|
||||
setCurrentPass('');
|
||||
setNewPass('');
|
||||
setConfirmPass('');
|
||||
toast({body: 'Password updated successfully'});
|
||||
}, 400);
|
||||
};
|
||||
async function revoke(s: DeviceSession) {
|
||||
const res = await securityRepository.revoke(s.id);
|
||||
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not sign that device out.'});
|
||||
sessions.refetch();
|
||||
}
|
||||
|
||||
const handleRevokeSession = (id: string) => {
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
toast({body: 'Session revoked successfully'});
|
||||
};
|
||||
async function revokeOthers() {
|
||||
if (!window.confirm('Sign out every other device? This one stays signed in.')) return;
|
||||
const res = await securityRepository.revokeOthers();
|
||||
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not sign the other devices out.'});
|
||||
else toast({body: 'Every other device has been signed out.'});
|
||||
sessions.refetch();
|
||||
}
|
||||
|
||||
const handleRevokeAllOther = () => {
|
||||
setSessions((prev) => prev.filter((s) => s.isCurrent));
|
||||
toast({body: 'All other active sessions have been terminated'});
|
||||
};
|
||||
|
||||
const sessionColumns: TableColumn<ActiveSessionItem>[] = [
|
||||
{
|
||||
key: 'device',
|
||||
header: 'Device / Browser',
|
||||
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}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastActive',
|
||||
header: 'Last Active',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.lastActive}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Action',
|
||||
align: 'center',
|
||||
width: pixel(120),
|
||||
renderCell: (row) =>
|
||||
row.isCurrent ? null : (
|
||||
<HStack hAlign="center">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Revoke"
|
||||
onClick={() => handleRevokeSession(row.id)}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const auditColumns: TableColumn<LoginAuditItem>[] = [
|
||||
{
|
||||
key: 'user',
|
||||
header: 'Account User',
|
||||
width: proportional(1.5),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.user}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'device',
|
||||
header: 'Device & IP',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm">{row.device}</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.ip}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Result',
|
||||
width: pixel(110),
|
||||
renderCell: (row) => (
|
||||
<Badge
|
||||
variant={row.status === 'success' ? 'success' : 'error'}
|
||||
label={row.status === 'success' ? 'Success' : 'Failed'}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'timestamp',
|
||||
header: 'Timestamp',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.timestamp}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
const others = (sessions.data ?? []).filter((s) => !s.current).length;
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Two-Factor Authentication (2FA)"
|
||||
subtitle="Require an authenticator app code (TOTP) or SMS OTP during sign-in."
|
||||
actions={<Badge variant={twoFactor ? 'success' : 'warning'} label={twoFactor ? '2FA Enabled' : '2FA Disabled'} />}
|
||||
>
|
||||
<PanelCard
|
||||
title="Signed-in devices"
|
||||
subtitle="Where your account is currently signed in."
|
||||
actions={others > 0 ? <Button size="sm" variant="secondary" label="Sign out everywhere else" onClick={() => void revokeOthers()} /> : undefined}
|
||||
resource={sessions}
|
||||
loading={<SkeletonRows count={3} />}
|
||||
>
|
||||
{(rows) => (
|
||||
<VStack gap={3}>
|
||||
<Switch
|
||||
label="Enable Two-Factor Authentication"
|
||||
description="Protect your merchant account with an extra verification step on new devices."
|
||||
value={twoFactor}
|
||||
onChange={(v) => {
|
||||
setTwoFactor(v);
|
||||
toast({body: `2FA ${v ? 'enabled' : 'disabled'}`});
|
||||
}}
|
||||
/>
|
||||
{rows.map((s) => (
|
||||
<HStack key={s.id} gap={3} hAlign="between" vAlign="center">
|
||||
<VStack gap={0}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text size="sm" weight="medium">{s.device || 'Unknown device'}</Text>
|
||||
{s.current && <Badge variant="success" label="This device" />}
|
||||
</HStack>
|
||||
<Text size="xsm" color="secondary">
|
||||
Signed in <Timestamp value={s.created_at} format="relative" />
|
||||
{s.last_used_at && <> · last used <Timestamp value={s.last_used_at} format="relative" /></>}
|
||||
</Text>
|
||||
</VStack>
|
||||
{!s.current && <Button size="sm" variant="ghost" label="Sign out" onClick={() => void revoke(s)} />}
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel
|
||||
title="Change Password"
|
||||
subtitle="Ensure your password is at least 12 characters long with mixed case and numbers."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Update Password"
|
||||
isLoading={isChangingPass}
|
||||
onClick={handleChangePassword}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
<TextInput
|
||||
label="Current Password"
|
||||
type="password"
|
||||
value={currentPass}
|
||||
onChange={setCurrentPass}
|
||||
/>
|
||||
<HStack gap={4} wrap="wrap">
|
||||
<TextInput
|
||||
label="New Password"
|
||||
type="password"
|
||||
value={newPass}
|
||||
onChange={setNewPass}
|
||||
/>
|
||||
<TextInput
|
||||
label="Confirm New Password"
|
||||
type="password"
|
||||
value={confirmPass}
|
||||
onChange={setConfirmPass}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel
|
||||
title="Active Sessions"
|
||||
subtitle="Devices currently logged into your Loyaly merchant workspace."
|
||||
actions={
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
label="Revoke Other Sessions"
|
||||
onClick={handleRevokeAllOther}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ResponsiveTable
|
||||
columns={sessionColumns}
|
||||
data={sessions}
|
||||
primaryKey="device"
|
||||
/>
|
||||
</StaticPanel>
|
||||
|
||||
<StaticPanel title="Login Audit Trail" subtitle="Recent account authentication events and security attempts.">
|
||||
<ResponsiveTable
|
||||
columns={auditColumns}
|
||||
data={AUDIT_LOGS}
|
||||
primaryKey="user"
|
||||
/>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {StaticPanel} from '@/shared/components/patterns/PanelCard';
|
||||
|
||||
export interface StoreItem extends Record<string, unknown> {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
manager: string;
|
||||
staffCount: number;
|
||||
hours: string;
|
||||
status: 'active' | 'maintenance' | 'disabled';
|
||||
}
|
||||
|
||||
const INITIAL_STORES: StoreItem[] = [
|
||||
{
|
||||
id: 'blr-indiranagar',
|
||||
name: 'Indiranagar Flagship',
|
||||
location: '100 Feet Road, Indiranagar, Bengaluru',
|
||||
manager: 'Vikram Seth',
|
||||
staffCount: 14,
|
||||
hours: '08:00 – 23:00',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'blr-koramangala',
|
||||
name: 'Koramangala 80ft',
|
||||
location: '80 Feet Road, Koramangala 4th Block, Bengaluru',
|
||||
manager: 'Priya Sharma',
|
||||
staffCount: 9,
|
||||
hours: '09:00 – 22:30',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'blr-whitefield',
|
||||
name: 'Whitefield Main',
|
||||
location: 'ITPL Main Road, Whitefield, Bengaluru',
|
||||
manager: 'Deepa Nair',
|
||||
staffCount: 6,
|
||||
hours: '10:00 – 22:00',
|
||||
status: 'maintenance',
|
||||
},
|
||||
{
|
||||
id: 'blr-jayanagar',
|
||||
name: 'Jayanagar 4th Block',
|
||||
location: '11th Main Rd, Jayanagar, Bengaluru',
|
||||
manager: 'Suresh T',
|
||||
staffCount: 8,
|
||||
hours: '09:00 – 21:30',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'blr-mgroad',
|
||||
name: 'MG Road Express',
|
||||
location: 'Church Street, Off MG Road, Bengaluru',
|
||||
manager: 'Unassigned',
|
||||
staffCount: 0,
|
||||
hours: '10:00 – 21:00',
|
||||
status: 'disabled',
|
||||
},
|
||||
];
|
||||
|
||||
export function StoreManagement() {
|
||||
const toast = useToast();
|
||||
const [stores, setStores] = useState<StoreItem[]>(INITIAL_STORES);
|
||||
const [isAdding, setIsAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [manager, setManager] = useState('Vikram Seth');
|
||||
const [hours, setHours] = useState('09:00 – 22:00');
|
||||
|
||||
const handleAddStore = () => {
|
||||
if (!name.trim()) {
|
||||
toast({type: 'error', body: 'Store name is required'});
|
||||
return;
|
||||
}
|
||||
const newStore: StoreItem = {
|
||||
id: `blr-${name.toLowerCase().replace(/\s+/g, '')}`,
|
||||
name,
|
||||
location: location || 'Bengaluru, Karnataka',
|
||||
manager,
|
||||
staffCount: 1,
|
||||
hours,
|
||||
status: 'active',
|
||||
};
|
||||
setStores((prev) => [newStore, ...prev]);
|
||||
setName('');
|
||||
setLocation('');
|
||||
setIsAdding(false);
|
||||
toast({body: `Store "${name}" added successfully`});
|
||||
};
|
||||
|
||||
const handleToggleStatus = (id: string) => {
|
||||
setStores((prev) =>
|
||||
prev.map((s) => {
|
||||
if (s.id !== id) return s;
|
||||
const next = s.status === 'active' ? 'disabled' : 'active';
|
||||
toast({body: `${s.name} status updated to ${next}`});
|
||||
return {...s, status: next};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemove = (id: string, storeName: string) => {
|
||||
setStores((prev) => prev.filter((s) => s.id !== id));
|
||||
toast({body: `Store "${storeName}" removed`});
|
||||
};
|
||||
|
||||
const columns: TableColumn<StoreItem>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Store Location',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.location}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'manager',
|
||||
header: 'Store Manager',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => <Text size="sm">{row.manager}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'hours',
|
||||
header: 'Operating Hours',
|
||||
width: proportional(1.2),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" color="secondary">
|
||||
{row.hours}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staffCount',
|
||||
header: 'Staff Count',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.staffCount} staff
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: pixel(120),
|
||||
renderCell: (row) => {
|
||||
const variant =
|
||||
row.status === 'active'
|
||||
? 'success'
|
||||
: row.status === 'maintenance'
|
||||
? 'warning'
|
||||
: 'error';
|
||||
return <Badge variant={variant} label={row.status} />;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: 'Actions',
|
||||
align: 'center',
|
||||
width: pixel(160),
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="center">
|
||||
<DropdownMenu
|
||||
button={{
|
||||
variant: 'secondary',
|
||||
size: 'sm',
|
||||
label: 'Manage',
|
||||
icon: <Icon icon="moreHorizontal" size="sm" />,
|
||||
}}
|
||||
menuWidth={180}
|
||||
items={[
|
||||
{
|
||||
label: row.status === 'active' ? 'Disable store' : 'Enable store',
|
||||
onClick: () => handleToggleStatus(row.id),
|
||||
},
|
||||
{type: 'divider'},
|
||||
{
|
||||
label: 'Remove store',
|
||||
onClick: () => handleRemove(row.id, row.name),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={5}>
|
||||
<StaticPanel
|
||||
title="Store Locations & Outlets"
|
||||
subtitle="Manage active branches, operating hours, assigned managers and status."
|
||||
actions={
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label={isAdding ? 'Cancel' : 'Add Store Location'}
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<VStack gap={4}>
|
||||
{isAdding ? (
|
||||
<VStack gap={3} padding={4}>
|
||||
<Heading level={3}>Add New Branch Location</Heading>
|
||||
<HStack gap={3} vAlign="end" wrap="wrap">
|
||||
<TextInput
|
||||
label="Store Name"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
placeholder="e.g. HSR Layout 27th Main"
|
||||
/>
|
||||
<TextInput
|
||||
label="Address / Landmark"
|
||||
value={location}
|
||||
onChange={setLocation}
|
||||
placeholder="HSR Layout, Bengaluru"
|
||||
/>
|
||||
<Selector
|
||||
label="Store Manager"
|
||||
value={manager}
|
||||
onChange={(v) => setManager(v)}
|
||||
options={[
|
||||
'Vikram Seth',
|
||||
'Priya Sharma',
|
||||
'Deepa Nair',
|
||||
'Unassigned',
|
||||
]}
|
||||
/>
|
||||
<TextInput
|
||||
label="Hours"
|
||||
value={hours}
|
||||
onChange={setHours}
|
||||
placeholder="09:00 – 22:00"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
label="Create Store"
|
||||
onClick={handleAddStore}
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
) : null}
|
||||
|
||||
<ResponsiveTable
|
||||
columns={columns}
|
||||
data={stores}
|
||||
primaryKey="name"
|
||||
/>
|
||||
</VStack>
|
||||
</StaticPanel>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -7,145 +8,200 @@ import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Avatar} from '@astryxdesign/core/Avatar';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
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 type {TeamMember} from '@/features/team/types/team';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {useTeam, useInvitations} from '@/features/team/hooks/useTeam';
|
||||
import {teamRepository} from '@/features/team/repositories/teamRepository';
|
||||
import {AddMemberDialog, roleLabel} from '@/features/team/components/AddMemberDialog';
|
||||
import type {TeamMember, Invitation} from '@/features/team/types/team';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* Who has access to this console.
|
||||
*
|
||||
* ── What this replaced ───────────────────────────────────────────────────
|
||||
* Five invented people — `aravind@nearle.in`, `Vikram Seth`, `Priya Sharma` —
|
||||
* held in `useState`, with store names (`Indiranagar Flagship`,
|
||||
* `Koramangala 80ft`) that no endpoint supplies, roles (`Cashier`,
|
||||
* `Marketing`, `Support`) that do not exist upstream, and four controls that
|
||||
* changed nothing: Add Staff, Suspend, Reset password and Remove all mutated
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
interface TeamRow extends Record<string, unknown> {
|
||||
interface Row extends Record<string, unknown> {
|
||||
id: string;
|
||||
member: TeamMember;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
status: string;
|
||||
lastLogin: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read off the payload, never inferred. The platform sends a boolean; there is
|
||||
* no "invited" or "suspended" state on this endpoint, so the screen does not
|
||||
* invent one.
|
||||
*/
|
||||
function toRow(m: TeamMember): TeamRow {
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
email: m.email,
|
||||
role: m.role,
|
||||
status: m.active ? 'Active' : 'Inactive',
|
||||
// "Never" is a fact worth showing: an account created and never used is
|
||||
// exactly the row a manager wants to notice.
|
||||
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMNS: TableColumn<TeamRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Member',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Avatar name={row.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.name}
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{row.email}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
header: 'Role',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Badge variant="neutral" label={row.role} />,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Access',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot
|
||||
variant={row.status === 'Active' ? 'success' : 'error'}
|
||||
label={row.status}
|
||||
/>
|
||||
<Text size="sm" color="secondary">
|
||||
{row.status}
|
||||
</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1), align: 'end'},
|
||||
];
|
||||
|
||||
export function TeamManagement() {
|
||||
const {user} = useSession();
|
||||
const team = useTeam();
|
||||
const invitations = useInvitations();
|
||||
const toast = useToast();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [secret, setSecret] = useState<{title: string; label: string; value: string; hint: string} | null>(null);
|
||||
|
||||
const me = user?.role ?? 'staff';
|
||||
const canManage = me === 'owner' || me === 'manager';
|
||||
|
||||
async function setRole(m: TeamMember, role: UserRole) {
|
||||
const res = await teamRepository.update(m.id, {role});
|
||||
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not change the role.'});
|
||||
team.refetch();
|
||||
}
|
||||
|
||||
async function setActive(m: TeamMember, active: boolean) {
|
||||
if (!active && !window.confirm(`Remove ${m.name}'s access now? They are signed out of every device immediately.`)) return;
|
||||
const res = await teamRepository.update(m.id, {active});
|
||||
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not change access.'});
|
||||
team.refetch();
|
||||
}
|
||||
|
||||
async function resetPassword(m: TeamMember) {
|
||||
if (!window.confirm(`Reset ${m.name}'s password? Their current one stops working and they are signed out everywhere.`)) return;
|
||||
const res = await teamRepository.resetPassword(m.id);
|
||||
if (!res.ok || !res.data) return toast({type: 'error', body: res.message ?? 'Could not reset the password.'});
|
||||
setSecret({
|
||||
title: `New password for ${m.name}`,
|
||||
label: `${m.email} signs in with this from now on:`,
|
||||
value: res.data.password,
|
||||
hint: 'Give it to them directly. They can change it from their own Security settings.',
|
||||
});
|
||||
}
|
||||
|
||||
async function revoke(inv: Invitation) {
|
||||
const res = await teamRepository.revokeInvitation(inv.id);
|
||||
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not revoke the invitation.'});
|
||||
invitations.refetch();
|
||||
}
|
||||
|
||||
const columns: TableColumn<Row>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Member',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Avatar name={row.name} size="sm" tooltip={false} />
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">{row.name}{row.member.id === user?.id ? ' (you)' : ''}</Text>
|
||||
<Text size="xsm" color="secondary">{row.member.email}</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{key: 'role', header: 'Role', width: proportional(1), renderCell: (row) => <Badge variant="neutral" label={row.role} />},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Access',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => (
|
||||
<HStack gap={1.5} vAlign="center">
|
||||
<StatusDot variant={row.member.active ? 'success' : 'error'} label={row.status} />
|
||||
<Text size="sm" color="secondary">{row.status}</Text>
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
{key: 'lastLogin', header: 'Last sign-in', width: proportional(1)},
|
||||
...(canManage
|
||||
? [{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
width: proportional(1),
|
||||
align: 'end' as const,
|
||||
renderCell: (row: Row) => {
|
||||
const m = row.member;
|
||||
const self = m.id === user?.id;
|
||||
// An owner outranks a manager; a manager may not touch an owner,
|
||||
// and nobody edits themselves here - that is what Security is for.
|
||||
const allowed = !self && (me === 'owner' || m.role !== 'owner');
|
||||
if (!allowed) return null;
|
||||
const roleItems = (me === 'owner' ? (['staff', 'manager', 'owner'] as UserRole[]) : (['staff', 'manager'] as UserRole[]))
|
||||
.filter((r) => r !== m.role)
|
||||
.map((r) => ({label: `Make ${roleLabel(r).toLowerCase()}`, onClick: () => void setRole(m, r)}));
|
||||
return (
|
||||
<DropdownMenu
|
||||
button={{label: 'Manage', variant: 'ghost', size: 'sm'}}
|
||||
items={[
|
||||
...roleItems,
|
||||
{type: 'divider'},
|
||||
{label: 'Reset password', onClick: () => void resetPassword(m)},
|
||||
m.active
|
||||
? {label: 'Remove access', onClick: () => void setActive(m, false)}
|
||||
: {label: 'Restore access', onClick: () => void setActive(m, true)},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}]
|
||||
: []),
|
||||
];
|
||||
|
||||
const toRow = (m: TeamMember): Row => ({
|
||||
id: m.id,
|
||||
member: m,
|
||||
name: m.name,
|
||||
role: roleLabel(m.role),
|
||||
status: m.active ? 'Active' : 'Removed',
|
||||
lastLogin: m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString() : 'Never',
|
||||
});
|
||||
|
||||
const pending = invitations.data ?? [];
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Console access"
|
||||
subtitle="Everybody who can sign in to this company's dashboard."
|
||||
resource={team}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="staff"
|
||||
title="No team members yet"
|
||||
description="People appear here once they accept an invitation."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(members) => (
|
||||
<VStack gap={4}>
|
||||
<ResponsiveTable
|
||||
data={members.map(toRow)}
|
||||
idKey="id"
|
||||
primaryKey="name"
|
||||
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>
|
||||
</VStack>
|
||||
<VStack gap={5}>
|
||||
<PanelCard
|
||||
title="Team"
|
||||
subtitle="Everybody who can sign in to this company - here, on the shop PC, and in the app."
|
||||
actions={canManage ? <Button size="sm" label="Add member" onClick={() => setAdding(true)} /> : undefined}
|
||||
resource={team}
|
||||
loading={<SkeletonRows count={4} />}
|
||||
empty={<EmptyPanel icon="staff" title="Nobody yet" description="Add the first member." />}
|
||||
>
|
||||
{(members) => (
|
||||
<ResponsiveTable data={members.map(toRow)} idKey="id" primaryKey="name" summaryKeys={['role', 'status']} columns={columns} />
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{canManage && pending.length > 0 && (
|
||||
<PanelCard title="Pending invitations" subtitle="Codes sent but not yet used." resource={invitations} loading={<SkeletonRows count={2} />}>
|
||||
{(rows) => (
|
||||
<VStack gap={2}>
|
||||
{rows.map((inv) => (
|
||||
<HStack key={inv.id} gap={3} hAlign="between" vAlign="center">
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">{inv.full_name || inv.email}</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
{inv.email} · {roleLabel(inv.role)} · expires {new Date(inv.expires_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</VStack>
|
||||
<Button size="sm" variant="ghost" label="Revoke" onClick={() => void revoke(inv)} />
|
||||
</HStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</PanelCard>
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{adding && (
|
||||
<AddMemberDialog
|
||||
callerRole={me}
|
||||
onClose={() => setAdding(false)}
|
||||
onDone={() => {
|
||||
setAdding(false);
|
||||
team.refetch();
|
||||
invitations.refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{secret && (
|
||||
<Dialog isOpen onOpenChange={(o) => (o ? undefined : setSecret(null))} purpose="info" width={480} aria-label={secret.title}>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader title={secret.title} onOpenChange={(o) => (o ? undefined : setSecret(null))} />
|
||||
<SecretOnce label={secret.label} value={secret.value} hint={secret.hint} onDone={() => setSecret(null)} />
|
||||
</VStack>
|
||||
</Dialog>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,36 +16,8 @@ export interface SettingsSection {
|
||||
* layout changing.
|
||||
*/
|
||||
export const SETTINGS_NAV: SettingsSection[] = [
|
||||
{
|
||||
label: 'Business',
|
||||
href: '/settings',
|
||||
icon: ICONS.business,
|
||||
description: 'Company details, tax IDs, registered address and support.',
|
||||
},
|
||||
{
|
||||
label: 'Profile',
|
||||
href: '/settings/profile',
|
||||
icon: ICONS.profile,
|
||||
description: 'Merchant contact details, timezone and personal info.',
|
||||
},
|
||||
{
|
||||
label: 'Team & Staff',
|
||||
href: '/settings/team',
|
||||
icon: ICONS.staff,
|
||||
description: 'Staff directory, store assignments, roles and status.',
|
||||
},
|
||||
{
|
||||
label: 'Stores',
|
||||
href: '/settings/stores',
|
||||
icon: ICONS.stores,
|
||||
description: 'Manage store locations, operating hours and managers.',
|
||||
},
|
||||
{
|
||||
label: 'Security',
|
||||
href: '/settings/security',
|
||||
icon: ICONS.security,
|
||||
description: '2FA authentication, active sessions and audit history.',
|
||||
},
|
||||
{label: 'Account', href: '/settings', icon: ICONS.profile, description: 'Who you are signed in as, and your devices.'},
|
||||
{label: 'Team', href: '/settings/team', icon: ICONS.staff, description: 'Who can sign in, and as what.'},
|
||||
];
|
||||
|
||||
/** Exact match only — /settings must not stay active on /settings/team. */
|
||||
|
||||
18
src/features/settings/repositories/securityRepository.ts
Normal file
18
src/features/settings/repositories/securityRepository.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {postJson, deleteJson} from '@/shared/services/httpClient';
|
||||
import type {Endpoint} from '@/shared/services/httpClient';
|
||||
|
||||
export interface DeviceSession {
|
||||
id: string;
|
||||
device: string;
|
||||
created_at: string;
|
||||
last_used_at?: string;
|
||||
expires_at: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
export const securityRepository = {
|
||||
sessions: (): Endpoint<DeviceSession[]> => ({path: '/api/auth/sessions', params: {}}),
|
||||
revoke: (id: string) => deleteJson<null>(`/api/auth/sessions/${encodeURIComponent(id)}`),
|
||||
/** Keeps this device signed in. */
|
||||
revokeOthers: () => postJson<null>('/api/auth/sessions/revoke-others', {}),
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import {getJson, patchJson} from '@/shared/services/httpClient';
|
||||
import type {MerchantProfile} from '@/features/settings/types/settings';
|
||||
|
||||
/**
|
||||
* Settings endpoints. See dashboardRepository for the layer's contract.
|
||||
*
|
||||
* Imperative rather than Endpoint-shaped: a settings record has one state at
|
||||
* page load and changes only when the user saves it, so there is nothing for
|
||||
* useResource's scope-keyed refetching to do.
|
||||
*/
|
||||
export const settingsRepository = {
|
||||
profile: () => getJson<MerchantProfile>('/api/settings/profile'),
|
||||
|
||||
updateProfile: (patch: Partial<MerchantProfile>) =>
|
||||
patchJson<MerchantProfile>('/api/settings/profile', patch),
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import 'server-only';
|
||||
import type {MerchantProfile} from '@/features/settings/types/settings';
|
||||
|
||||
/**
|
||||
* The merchant profile.
|
||||
*
|
||||
* There is no business-profile resource in the platform contract — GET
|
||||
* /api/auth/me returns the signed-in USER (name, email, role, tenant), not the
|
||||
* company's GSTIN, timezone, currency or LYT accrual rate. The fixture that
|
||||
* used to answer this has been removed, so this returns null and the form
|
||||
* renders its unavailable state.
|
||||
*
|
||||
* When the resource ships, this function is the only thing that changes.
|
||||
*
|
||||
* Required: GET/PATCH /api/settings/profile — business name, GSTIN, timezone,
|
||||
* currency, notification preferences.
|
||||
*/
|
||||
export const settingsServerRepository = {
|
||||
async getProfile(): Promise<MerchantProfile | null> {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import {settingsRepository} from '@/features/settings/repositories/settingsRepository';
|
||||
import type {MerchantProfile} from '@/features/settings/types/settings';
|
||||
|
||||
/**
|
||||
* Domain rules for the settings module.
|
||||
*
|
||||
* The validation lives here rather than in the form for the same reason the
|
||||
* login rules live in authService: a rule that only exists inside a component
|
||||
* cannot be reused by a second screen, cannot be unit-tested without mounting
|
||||
* React, and quietly disappears the day the screen is redesigned.
|
||||
*/
|
||||
|
||||
export type ProfileFieldErrors = Partial<Record<keyof MerchantProfile, string>>;
|
||||
|
||||
export function validateProfile(profile: MerchantProfile): ProfileFieldErrors {
|
||||
const errors: ProfileFieldErrors = {};
|
||||
if (!profile.businessName.trim()) {
|
||||
errors.businessName = 'Business name is required';
|
||||
}
|
||||
if (!/^\S+@\S+\.\S+$/.test(profile.email)) {
|
||||
errors.email = 'Enter a valid email address';
|
||||
}
|
||||
// Optional, but a partial GSTIN is worse than none — it will fail at
|
||||
// settlement time rather than here.
|
||||
if (profile.gstin && profile.gstin.length !== 15) {
|
||||
errors.gstin = 'GSTIN is 15 characters';
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export const settingsService = {
|
||||
validateProfile,
|
||||
|
||||
/**
|
||||
* Validate, then save. Returns the field errors rather than throwing —
|
||||
* invalid input is an expected outcome of a form submission, not an
|
||||
* exception, and the caller has to render them either way.
|
||||
*/
|
||||
async saveProfile(
|
||||
profile: MerchantProfile,
|
||||
): Promise<{ok: true} | {ok: false; errors: ProfileFieldErrors; message?: string}> {
|
||||
const errors = validateProfile(profile);
|
||||
if (Object.keys(errors).length > 0) return {ok: false, errors};
|
||||
|
||||
const res = await settingsRepository.updateProfile(profile);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: {},
|
||||
message: res.message ?? 'Could not save profile',
|
||||
};
|
||||
}
|
||||
return {ok: true};
|
||||
},
|
||||
};
|
||||
@@ -1,26 +0,0 @@
|
||||
/**
|
||||
* Merchant settings contracts.
|
||||
*
|
||||
* The wire shape for this feature. Imported by BOTH its route handlers and its
|
||||
* components, so a server/client drift is a type error rather than a runtime
|
||||
* surprise. When a real backend arrives, this file is the negotiation artifact.
|
||||
*/
|
||||
|
||||
export interface MerchantProfile {
|
||||
businessName: string;
|
||||
contactName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
gstin: string;
|
||||
/** IANA zone — all reporting timestamps are rendered in it. */
|
||||
timezone: string;
|
||||
currency: 'INR';
|
||||
/** Default LYTs granted per ₹100 spent. */
|
||||
lytsPerHundred: number;
|
||||
notifications: {
|
||||
expiryAlerts: boolean;
|
||||
dailySummary: boolean;
|
||||
staffAbsence: boolean;
|
||||
lowConversion: boolean;
|
||||
};
|
||||
}
|
||||
132
src/features/stores/components/CameraCard.tsx
Normal file
132
src/features/stores/components/CameraCard.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Card} from '@astryxdesign/core/Card';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {DropdownMenu} from '@astryxdesign/core/DropdownMenu';
|
||||
import {Icon} from '@astryxdesign/core/Icon';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {useToast} from '@astryxdesign/core/Toast';
|
||||
import {ICONS} from '@/shared/utils/icons';
|
||||
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
|
||||
import type {Camera} from '@/features/stores/types/camera';
|
||||
|
||||
/**
|
||||
* A camera is a thing you look at, so the picture is the card. One line under
|
||||
* it answers the question a site gets signed off without: has anyone proved
|
||||
* this camera can recognise a face - which is different from "connected".
|
||||
*/
|
||||
export function CameraCard({camera, onEdit, onChanged}: {camera: Camera; onEdit: () => void; onChanged: () => void}) {
|
||||
const toast = useToast();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
async function check(kind: 'connection' | 'placement') {
|
||||
setBusy(kind);
|
||||
const res = await cameraRepository.check(camera.id, kind);
|
||||
setBusy(null);
|
||||
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not ask the shop PC.'});
|
||||
else toast({body: kind === 'placement' ? 'Placement check requested - walk through the frame for the next 25 seconds.' : 'Connection test requested.'});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!window.confirm(`Remove ${camera.label}? The shop PC stops watching it on its next sync.`)) return;
|
||||
setBusy('remove');
|
||||
const res = await cameraRepository.remove(camera.id);
|
||||
setBusy(null);
|
||||
if (!res.ok) toast({type: 'error', body: res.message ?? 'Could not remove this camera.'});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
const conn = connection(camera);
|
||||
const verified = verification(camera);
|
||||
const checking = camera.check.state === 'requested' || camera.check.state === 'started';
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<VStack gap={3}>
|
||||
<div style={{position: 'relative', aspectRatio: '16 / 9', borderRadius: 8, overflow: 'hidden', background: 'var(--color-surface-sunken, #111)'}}>
|
||||
{camera.snapshot.url ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={camera.snapshot.url} alt={camera.label} style={{width: '100%', height: '100%', objectFit: 'cover', display: 'block'}} />
|
||||
) : (
|
||||
<div style={{position: 'absolute', inset: 0, display: 'grid', placeItems: 'center'}}>
|
||||
<VStack gap={1} hAlign="center">
|
||||
<Icon icon={ICONS.stores} size="lg" color="secondary" />
|
||||
<Text size="xsm" color="secondary">{camera.snapshot.reason ?? 'No picture yet'}</Text>
|
||||
</VStack>
|
||||
</div>
|
||||
)}
|
||||
<div style={{position: 'absolute', top: 8, right: 8}}>
|
||||
<Badge variant={conn.variant} label={conn.label} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HStack gap={2} hAlign="between" vAlign="center">
|
||||
<VStack gap={0}>
|
||||
<Heading level={4}>{camera.label}</Heading>
|
||||
<Text size="xsm" color="secondary" className="font-mono">{camera.cameraId} · {camera.host}</Text>
|
||||
</VStack>
|
||||
<DropdownMenu
|
||||
button={{label: 'Actions', variant: 'secondary', size: 'sm'}}
|
||||
items={[
|
||||
{label: 'Test connection', onClick: () => void check('connection'), isDisabled: Boolean(busy) || checking},
|
||||
{label: 'Check placement (25 s walk-past)', onClick: () => void check('placement'), isDisabled: Boolean(busy) || checking},
|
||||
{type: 'divider'},
|
||||
{label: 'Edit', onClick: onEdit},
|
||||
{label: 'Remove', onClick: () => void remove(), isDisabled: Boolean(busy)},
|
||||
]}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Badge variant={verified.variant} label={verified.label} />
|
||||
<Text size="xsm" color="secondary">{verified.detail}</Text>
|
||||
</HStack>
|
||||
{camera.check.advice.length > 0 && !camera.check.ok && camera.check.state === 'finished' && (
|
||||
<VStack gap={0.5}>
|
||||
{camera.check.advice.slice(0, 2).map((a) => (
|
||||
<Text key={a} size="xsm" color="secondary">· {a}</Text>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
{camera.snapshotAt && (
|
||||
<Text size="xsm" color="secondary">
|
||||
Picture <Timestamp value={camera.snapshotAt} format="relative" />
|
||||
</Text>
|
||||
)}
|
||||
{!camera.enabled && <Button size="sm" variant="secondary" label="Disabled" isDisabled />}
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** Three states, not two. null is "no shop PC has reported on this yet" and
|
||||
* must not read as "broken" - it sends an installer to check the cabling on
|
||||
* a camera nobody has tried to reach. */
|
||||
function connection(c: Camera): {variant: 'success' | 'warning' | 'error' | 'neutral'; label: string} {
|
||||
if (c.connected === true) return {variant: 'success', label: 'Connected'};
|
||||
if (c.connected === false) return {variant: 'error', label: 'Not connecting'};
|
||||
return {variant: 'neutral', label: 'Waiting for the shop PC'};
|
||||
}
|
||||
|
||||
function verification(c: Camera): {variant: 'success' | 'warning' | 'error' | 'neutral'; label: string; detail: string} {
|
||||
const k = c.check;
|
||||
if (k.state === 'requested') return {variant: 'neutral', label: 'Checking…', detail: 'waiting for the shop PC to pick it up'};
|
||||
if (k.state === 'started') return {variant: 'neutral', label: 'Checking…', detail: k.kind === 'placement' ? 'walk through the frame now' : 'testing the connection'};
|
||||
if (k.state === 'finished') {
|
||||
if (k.kind === 'placement') {
|
||||
// Only `good` is a pass. `marginal` means half the visitors are silently
|
||||
// discarded, which is not a working camera.
|
||||
if (k.verdict === 'good') return {variant: 'success', label: 'Proven', detail: k.headline ?? 'faces recognised on a walk-past'};
|
||||
return {variant: k.verdict === 'marginal' ? 'warning' : 'error', label: 'Not proven', detail: k.headline ?? k.verdict ?? ''};
|
||||
}
|
||||
return k.ok
|
||||
? {variant: 'success', label: 'Connection OK', detail: `${k.headline ?? 'stream opens'} · run a placement check to prove recognition`}
|
||||
: {variant: 'error', label: 'Connection failed', detail: k.headline ?? ''};
|
||||
}
|
||||
return {variant: 'neutral', label: 'Not yet checked', detail: 'added, not proven - run a placement check'};
|
||||
}
|
||||
146
src/features/stores/components/CameraDialog.tsx
Normal file
146
src/features/stores/components/CameraDialog.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useRef, 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 {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {MAKES} from '@/features/stores/data/cameraMakes';
|
||||
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
|
||||
import type {Camera, CameraDraft} from '@/features/stores/types/camera';
|
||||
|
||||
interface Props {
|
||||
site: string;
|
||||
/** Editing when set; adding otherwise. */
|
||||
camera?: Camera;
|
||||
onClose: () => void;
|
||||
onSaved: (camera: Camera) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAKE = 'hikvision';
|
||||
const EMPTY: CameraDraft = {
|
||||
cameraId: '', label: '', host: '', port: 554,
|
||||
path: MAKES.find((m) => m.id === DEFAULT_MAKE)?.path ?? '',
|
||||
username: '', password: '',
|
||||
};
|
||||
|
||||
export function CameraDialog({site, camera, onClose, onSaved}: Props) {
|
||||
const editing = Boolean(camera);
|
||||
const [draft, setDraft] = useState<CameraDraft>(
|
||||
camera
|
||||
? {cameraId: camera.cameraId, label: camera.label, host: camera.host, port: camera.port, path: camera.path, username: camera.username, password: ''}
|
||||
: EMPTY,
|
||||
);
|
||||
const [make, setMake] = useState(editing ? 'manual' : DEFAULT_MAKE);
|
||||
// TextInput exposes no autocomplete prop, so the attribute is set on the
|
||||
// real inputs after mount. Measured without it: Chrome filled the
|
||||
// operator's own Loyaly email into "Camera username" - a text input next to
|
||||
// a password input is a sign-in form as far as a browser is concerned.
|
||||
const userRef = useRef<HTMLInputElement>(null);
|
||||
const passRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
userRef.current?.setAttribute('autocomplete', 'off');
|
||||
userRef.current?.setAttribute('data-lpignore', 'true');
|
||||
passRef.current?.setAttribute('autocomplete', 'new-password');
|
||||
}, []);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const set = <K extends keyof CameraDraft>(k: K, v: CameraDraft[K]) => setDraft((d) => ({...d, [k]: v}));
|
||||
|
||||
const chosen = MAKES.find((m) => m.id === make);
|
||||
|
||||
function pickMake(id: string) {
|
||||
setMake(id);
|
||||
const m = MAKES.find((x) => x.id === id);
|
||||
if (m && m.path) set('path', m.path);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
// Read the credential fields from the DOM, not only from state: a browser
|
||||
// autofill can write into an input without React ever seeing a change
|
||||
// event, and the first camera added through this form arrived with its
|
||||
// password stored and its username empty.
|
||||
const final: CameraDraft = {
|
||||
...draft,
|
||||
username: userRef.current?.value ?? draft.username,
|
||||
password: passRef.current?.value ?? draft.password,
|
||||
};
|
||||
const res = editing && camera
|
||||
? await cameraRepository.update(camera.id, {label: final.label, host: final.host, port: final.port, path: final.path, username: final.username, password: final.password})
|
||||
: await cameraRepository.add(site, final);
|
||||
setSaving(false);
|
||||
if (!res.ok || !res.data) {
|
||||
setError(res.message ?? 'Could not save this camera.');
|
||||
return;
|
||||
}
|
||||
onSaved(res.data);
|
||||
}
|
||||
|
||||
const close = (open: boolean) => (open ? undefined : onClose());
|
||||
const canSave = draft.host.trim() && draft.path.trim() && (editing || draft.cameraId.trim());
|
||||
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={close} purpose="form" width={520} aria-label={editing ? 'Edit camera' : 'Add a camera'}>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={editing ? `Edit ${camera?.label}` : 'Add a camera'}
|
||||
subtitle={editing ? undefined : 'The shop PC connects to it on its next sync.'}
|
||||
onOpenChange={close}
|
||||
/>
|
||||
{error && <Banner status="error" title={error} />}
|
||||
|
||||
{!editing && (
|
||||
<TextInput
|
||||
label="Camera id"
|
||||
description="Short, no spaces - it names this camera in every report and cannot be changed later."
|
||||
value={draft.cameraId}
|
||||
onChange={(v) => set('cameraId', v.replace(/[^A-Za-z0-9_-]/g, ''))}
|
||||
placeholder="entrance"
|
||||
isRequired
|
||||
/>
|
||||
)}
|
||||
<TextInput label="Name" value={draft.label} onChange={(v) => set('label', v)} placeholder="Front door" isOptional />
|
||||
|
||||
<HStack gap={3}>
|
||||
<TextInput label="Address" value={draft.host} onChange={(v) => set('host', v)} placeholder="192.168.1.20" isRequired width="70%" />
|
||||
<TextInput label="Port" value={String(draft.port)} onChange={(v) => set('port', Number(v.replace(/\D/g, '')) || 0)} width="30%" />
|
||||
</HStack>
|
||||
|
||||
<Selector label="Camera make" value={make} onChange={pickMake} options={MAKES.map((m) => ({value: m.id, label: m.label}))} />
|
||||
<TextInput
|
||||
label="Stream path"
|
||||
description={chosen?.note || 'Filled in from the make. Change it if the camera\'s own app says otherwise.'}
|
||||
value={draft.path}
|
||||
onChange={(v) => set('path', v)}
|
||||
placeholder="/Streaming/Channels/101"
|
||||
isRequired
|
||||
/>
|
||||
|
||||
<HStack gap={3}>
|
||||
<TextInput ref={userRef} label="Camera username" htmlName="rtsp-account" value={draft.username} onChange={(v) => set('username', v)} placeholder="admin" isOptional />
|
||||
<TextInput
|
||||
label="Camera password"
|
||||
ref={passRef}
|
||||
type="password"
|
||||
htmlName="rtsp-secret"
|
||||
value={draft.password}
|
||||
onChange={(v) => set('password', v)}
|
||||
placeholder={editing && camera?.hasPassword ? '(unchanged)' : ''}
|
||||
isOptional
|
||||
/>
|
||||
</HStack>
|
||||
{editing && <Text size="xsm" color="secondary">Leave the password blank to keep the one already stored.</Text>}
|
||||
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button isDisabled={!canSave || saving} isLoading={saving} onClick={() => void save()} label={editing ? 'Save' : 'Add camera'} />
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
70
src/features/stores/components/EnrolmentCodeDialog.tsx
Normal file
70
src/features/stores/components/EnrolmentCodeDialog.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
|
||||
import type {EnrolmentCode} from '@/features/stores/types/site';
|
||||
|
||||
/**
|
||||
* The code a new shop PC types to become THIS shop. Minted when the dialog
|
||||
* opens, shown once, and it is a credential: it is redeemed for the shop's
|
||||
* broker login, so it should travel to the shop by a channel of its own.
|
||||
*/
|
||||
export function EnrolmentCodeDialog({site, siteName, onClose}: {site: string; siteName: string; onClose: () => void}) {
|
||||
const [code, setCode] = useState<EnrolmentCode | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void cameraRepository.enrolmentCode(site).then((res) => {
|
||||
if (!live) return;
|
||||
if (res.ok && res.data) setCode(res.data);
|
||||
else setError(res.message ?? 'Could not create a code.');
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [site]);
|
||||
|
||||
const close = (open: boolean) => (open ? undefined : onClose());
|
||||
const expires = code ? new Date(code.expires_at).toLocaleString() : '';
|
||||
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={close} purpose="info" width={520} aria-label="Set up a shop PC">
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader title="Set up a shop PC" subtitle={siteName} onOpenChange={close} />
|
||||
{error && <Banner status="error" title={error} />}
|
||||
{!code && !error && <Text size="sm" color="secondary">Creating a code…</Text>}
|
||||
{code && (
|
||||
<>
|
||||
<Text size="sm" color="secondary">
|
||||
Install Behavision on the PC in this shop and type this code on its first screen. It works once and expires {expires}.
|
||||
</Text>
|
||||
<Heading level={2} className="font-mono" style={{letterSpacing: '0.08em'}}>{code.code}</Heading>
|
||||
<Banner
|
||||
status="warning"
|
||||
title="Treat it like a password."
|
||||
description="Whoever has this code can connect a PC as this shop. Send it to the installer directly, not in a group chat."
|
||||
/>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
label={copied ? 'Copied' : 'Copy code'}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(code.code);
|
||||
setCopied(true);
|
||||
}}
|
||||
/>
|
||||
<Button onClick={onClose} label="Done" />
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
100
src/features/stores/components/ShopSection.tsx
Normal file
100
src/features/stores/components/ShopSection.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
'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 {StatusDot} from '@astryxdesign/core/StatusDot';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
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 {CameraCard} from './CameraCard';
|
||||
import {CameraDialog} from './CameraDialog';
|
||||
import {EnrolmentCodeDialog} from './EnrolmentCodeDialog';
|
||||
import type {Site} from '@/features/stores/types/site';
|
||||
import type {Camera} from '@/features/stores/types/camera';
|
||||
|
||||
/** One shop: its health in a line, then its cameras as pictures. */
|
||||
export function ShopSection({site, canManage}: {site: Site; canManage: boolean}) {
|
||||
const cameras = useCameras(site.id);
|
||||
const [dialog, setDialog] = useState<null | {kind: 'add'} | {kind: 'edit'; camera: Camera} | {kind: 'code'}>(null);
|
||||
|
||||
const health = verdictFor(site);
|
||||
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
<HStack gap={3} hAlign="between" vAlign="start" wrap="wrap">
|
||||
<VStack gap={1}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Heading level={2}>{site.name}</Heading>
|
||||
<StatusDot variant={health.tone} label={health.label} />
|
||||
<Text size="sm" color="secondary">{health.label}</Text>
|
||||
</HStack>
|
||||
<Text size="sm" color="secondary">{health.line}</Text>
|
||||
<StatRow>
|
||||
<StatPair label="Cameras up" value={`${site.camerasUp}/${site.camerasTotal}`} />
|
||||
<StatPair label="Faces too poor to use" value={site.camerasTotal ? formatPct(site.fractionBelowGate * 100, 0) : '—'} />
|
||||
<StatPair label="Last heartbeat" value={site.lastHeartbeatAt ? <Timestamp value={site.lastHeartbeatAt} format="relative" /> : 'never'} />
|
||||
{site.dropped > 0 && <StatPair label="Visits lost" value={String(site.dropped)} />}
|
||||
</StatRow>
|
||||
</VStack>
|
||||
{canManage && (
|
||||
<HStack gap={2}>
|
||||
<Button variant="secondary" label="Set up a shop PC" onClick={() => setDialog({kind: 'code'})} />
|
||||
<Button label="Add camera" onClick={() => setDialog({kind: 'add'})} />
|
||||
</HStack>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<AsyncBoundary
|
||||
resource={cameras}
|
||||
loading={<SkeletonCardGrid count={2} height={260} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="stores"
|
||||
title="No cameras yet"
|
||||
description={canManage ? 'Add the camera by its address; the shop PC connects to it on its next sync.' : 'A manager can add cameras to this shop.'}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<Grid columns={{minWidth: 320, repeat: 'fit'}} gap={4}>
|
||||
{rows.map((cam) => (
|
||||
<CameraCard key={cam.id} camera={cam} onEdit={() => setDialog({kind: 'edit', camera: cam})} onChanged={cameras.refetch} />
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
|
||||
{dialog?.kind === 'add' && (
|
||||
<CameraDialog site={site.id} onClose={() => setDialog(null)} onSaved={() => { setDialog(null); cameras.refetch(); }} />
|
||||
)}
|
||||
{dialog?.kind === 'edit' && (
|
||||
<CameraDialog site={site.id} camera={dialog.camera} onClose={() => setDialog(null)} onSaved={() => { setDialog(null); cameras.refetch(); }} />
|
||||
)}
|
||||
{dialog?.kind === 'code' && <EnrolmentCodeDialog site={site.id} siteName={site.name} onClose={() => setDialog(null)} />}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One function decides a shop's health, and everything on the section reads
|
||||
* from it - the dot, the label and the sentence. Severity order matters and
|
||||
* only the first applies: a shop that is offline AND has a bad camera needs
|
||||
* its PC turned on first.
|
||||
*/
|
||||
function verdictFor(s: Site): {tone: 'success' | 'warning' | 'error' | 'neutral'; label: string; line: string} {
|
||||
if (s.camerasTotal === 0) return {tone: 'neutral', label: 'Not set up', line: 'No cameras yet. Add one, then set up the shop PC.'};
|
||||
if (!s.isOnline) return {tone: 'error', label: 'Offline', line: s.lastHeartbeatAt ? 'The shop PC has stopped reporting. Check it is on and connected.' : 'No shop PC has connected yet. Use "Set up a shop PC".'};
|
||||
if (s.dropped > 0) return {tone: 'error', label: 'Losing visits', line: `${s.dropped} visits were lost while this PC was offline too long.`};
|
||||
if (s.camerasUp === 0) return {tone: 'error', label: 'No camera connected', line: 'The PC is on but cannot reach any camera.'};
|
||||
if (s.camerasUp < s.camerasTotal) return {tone: 'warning', label: 'Camera trouble', line: `${s.camerasTotal - s.camerasUp} of ${s.camerasTotal} cameras not connecting.`};
|
||||
if (s.fractionBelowGate > 0.5) return {tone: 'error', label: 'Faces too poor', line: 'Most faces this shop sees are too poor to recognise - the camera needs moving to head height, facing the approach.'};
|
||||
if (s.fractionBelowGate > 0.2) return {tone: 'warning', label: 'Marginal', line: 'A fair share of faces are too poor to recognise. Consider re-aiming the camera.'};
|
||||
return {tone: 'success', label: 'Working', line: 'Online, cameras connected, recognition running.'};
|
||||
}
|
||||
28
src/features/stores/data/cameraMakes.ts
Normal file
28
src/features/stores/data/cameraMakes.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* RTSP paths by make. The address is on a label and the password is in the
|
||||
* installer's notes; the 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. Conventions, not
|
||||
* guarantees - the field stays editable.
|
||||
*
|
||||
* Mirrors Behavision's shared/cameraMakes.js. Keep the two in step.
|
||||
*/
|
||||
export interface CameraMake {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export const MAKES: CameraMake[] = [
|
||||
{id: 'hikvision', label: 'Hikvision', path: '/Streaming/Channels/101', note: 'Channel 1, main stream. /Streaming/Channels/102 is 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 - the Tapo login will not work.'},
|
||||
{id: 'reolink', label: 'Reolink', path: '/h264Preview_01_main', note: '/h264Preview_01_sub is 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 not, look for "RTSP" in the camera\'s own app.'},
|
||||
{id: 'manual', label: 'I know the path', path: '', note: ''},
|
||||
];
|
||||
23
src/features/stores/hooks/useCameras.ts
Normal file
23
src/features/stores/hooks/useCameras.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import {cameraRepository} from '@/features/stores/repositories/cameraRepository';
|
||||
import type {Camera} from '@/features/stores/types/camera';
|
||||
|
||||
/**
|
||||
* One shop's cameras, re-read every few seconds while the screen is open.
|
||||
*
|
||||
* Polling rather than on-demand because the interesting changes happen on the
|
||||
* shop PC, not here: a check the operator asked for finishes there, a camera
|
||||
* reconnects there, and the only way this screen learns is by asking again.
|
||||
*/
|
||||
export function useCameras(site: string | null, intervalMs = 8000): Resource<Camera[]> {
|
||||
const res = useResource(site ? cameraRepository.list(site) : null);
|
||||
const {refetch, status} = res;
|
||||
useEffect(() => {
|
||||
if (!site || status === 'loading') return;
|
||||
const id = setInterval(refetch, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [site, status, refetch, intervalMs]);
|
||||
return res;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {storeRepository} from '@/features/stores/repositories/storeRepository';
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useScope} from '@/shared/hooks/useScope';
|
||||
|
||||
/**
|
||||
* Store data access. See features/dashboard/hooks/useDashboard for the pattern.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The directory always asks for ALL stores regardless of the workspace filter:
|
||||
* a page whose job is to compare locations cannot be filtered down to one, or
|
||||
* it has nothing to compare. The period still applies.
|
||||
*/
|
||||
export function useStoreList() {
|
||||
const {range} = useScope();
|
||||
return useResource(storeRepository.list({range, storeId: 'all'}));
|
||||
}
|
||||
|
||||
export function useStore(storeId: string) {
|
||||
const {range} = useScope();
|
||||
return useResource(storeRepository.byId({range, storeId}, storeId), {
|
||||
// A single object is never "empty" — without this, useResource's default
|
||||
// array check would call a populated store empty.
|
||||
isEmpty: () => false,
|
||||
});
|
||||
}
|
||||
33
src/features/stores/repositories/cameraRepository.ts
Normal file
33
src/features/stores/repositories/cameraRepository.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {postJson, patchJson, deleteJson} from '@/shared/services/httpClient';
|
||||
import type {Endpoint} from '@/shared/services/httpClient';
|
||||
import type {Camera, CameraDraft} from '@/features/stores/types/camera';
|
||||
import type {EnrolmentCode} from '@/features/stores/types/site';
|
||||
|
||||
export const cameraRepository = {
|
||||
list: (site: string): Endpoint<Camera[]> => ({path: '/api/cameras', params: {site}}),
|
||||
|
||||
add: (site: string, draft: CameraDraft) => postJson<Camera>('/api/cameras', {site, ...toInput(draft)}),
|
||||
|
||||
/** Only what changed. A blank password is never sent: blank means "keep". */
|
||||
update: (id: string, draft: Partial<CameraDraft>) => patchJson<Camera>(`/api/cameras/${encodeURIComponent(id)}`, toInput(draft)),
|
||||
|
||||
remove: (id: string) => deleteJson<null>(`/api/cameras/${encodeURIComponent(id)}`),
|
||||
|
||||
check: (id: string, kind: 'connection' | 'placement') =>
|
||||
postJson<Camera>(`/api/cameras/${encodeURIComponent(id)}/check`, {kind}),
|
||||
|
||||
enrolmentCode: (site: string, label?: string) =>
|
||||
postJson<EnrolmentCode>(`/api/sites/${encodeURIComponent(site)}/enrolment-code`, label ? {label} : {}),
|
||||
};
|
||||
|
||||
function toInput(d: Partial<CameraDraft>) {
|
||||
const out: Record<string, string | number> = {};
|
||||
if (d.cameraId !== undefined && d.cameraId !== '') out.camera_id = d.cameraId.trim();
|
||||
if (d.label !== undefined) out.label = d.label.trim();
|
||||
if (d.host !== undefined && d.host !== '') out.host = d.host.trim();
|
||||
if (d.port !== undefined && d.port > 0) out.port = d.port;
|
||||
if (d.path !== undefined && d.path !== '') out.path = d.path.trim();
|
||||
if (d.username !== undefined) out.username = d.username.trim();
|
||||
if (d.password) out.password = d.password;
|
||||
return out;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import {scopedEndpoint} from '@/shared/services/httpClient';
|
||||
import type {Endpoint, Scope} from '@/shared/services/httpClient';
|
||||
import type {Store} from '@/features/stores/types/store';
|
||||
|
||||
/** Store endpoints. See dashboardRepository for the layer's contract. */
|
||||
export const storeRepository = {
|
||||
list: (scope: Scope): Endpoint<Store[]> => scopedEndpoint('/api/stores', scope),
|
||||
|
||||
byId: (scope: Scope, storeId: string): Endpoint<Store> =>
|
||||
scopedEndpoint(`/api/stores/${storeId}`, scope),
|
||||
};
|
||||
39
src/features/stores/services/mapCamera.ts
Normal file
39
src/features/stores/services/mapCamera.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type {ApiCamera, ApiImage} from '@/services/api/types';
|
||||
import type {Camera, Picture} from '@/features/stores/types/camera';
|
||||
|
||||
/** A picture URL the browser can load: presigned links as they are,
|
||||
* session-authenticated ones through this origin's image proxy. */
|
||||
export function toPicture(img: ApiImage | undefined): Picture {
|
||||
if (!img?.available || !img.url) return {url: null, reason: img?.reason ?? null};
|
||||
const needsSession = img.auth || img.url.startsWith('/');
|
||||
return {url: needsSession ? `/api/images?src=${encodeURIComponent(img.url)}` : img.url, reason: null};
|
||||
}
|
||||
|
||||
export function toCamera(c: ApiCamera): Camera {
|
||||
return {
|
||||
id: c.id,
|
||||
site: c.site ?? c.site_id,
|
||||
cameraId: c.camera_id,
|
||||
label: c.label || c.camera_id,
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
path: c.path,
|
||||
username: c.username,
|
||||
hasPassword: c.has_password,
|
||||
enabled: c.enabled,
|
||||
// Three states, not two: null is "no shop PC has reported on this yet".
|
||||
connected: c.connected ?? null,
|
||||
lastSeenAt: c.last_seen_at ?? null,
|
||||
snapshot: toPicture(c.snapshot),
|
||||
snapshotAt: c.snapshot_at ?? null,
|
||||
check: {
|
||||
kind: c.check?.kind ?? null,
|
||||
state: c.check?.state ?? null,
|
||||
ok: c.check?.ok ?? false,
|
||||
verdict: c.check?.verdict ?? null,
|
||||
headline: c.check?.headline ?? null,
|
||||
advice: c.check?.advice ?? [],
|
||||
finishedAt: c.check?.finished_at ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
43
src/features/stores/types/camera.ts
Normal file
43
src/features/stores/types/camera.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface Picture {
|
||||
url: string | null;
|
||||
/** Why there is no picture, in the platform's words. */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
export interface CameraCheck {
|
||||
kind: string | null;
|
||||
state: string | null;
|
||||
ok: boolean;
|
||||
verdict: string | null;
|
||||
headline: string | null;
|
||||
advice: string[];
|
||||
finishedAt: string | null;
|
||||
}
|
||||
|
||||
export interface Camera {
|
||||
id: string;
|
||||
site: string;
|
||||
cameraId: string;
|
||||
label: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
username: string;
|
||||
hasPassword: boolean;
|
||||
enabled: boolean;
|
||||
connected: boolean | null;
|
||||
lastSeenAt: string | null;
|
||||
snapshot: Picture;
|
||||
snapshotAt: string | null;
|
||||
check: CameraCheck;
|
||||
}
|
||||
|
||||
export interface CameraDraft {
|
||||
cameraId: string;
|
||||
label: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
@@ -1,22 +1,24 @@
|
||||
/**
|
||||
* A shop, as the console consumes it.
|
||||
*
|
||||
* `id` is the platform's SLUG, not its uuid, and that is deliberate: the slug
|
||||
* is immutable, readable, and is what every scoped request sends as `?site=`.
|
||||
* The uuid is kept alongside for routes that prefer an opaque key, but nothing
|
||||
* should key on `name` — display names are expected to change.
|
||||
*
|
||||
* Health fields are nullable rather than zero-defaulted. A deployment that
|
||||
* does not report camera health is not a deployment with zero cameras up, and
|
||||
* rendering "0/0 cameras" for "not reported" is how a working estate looks
|
||||
* broken.
|
||||
*/
|
||||
export interface Site {
|
||||
/** The slug: immutable, what every scoped request sends as `site=`. */
|
||||
id: string;
|
||||
uuid: string;
|
||||
name: string;
|
||||
isOnline: boolean | null;
|
||||
camerasTotal: number | null;
|
||||
camerasUp: number | null;
|
||||
fractionBelowGate: number | null;
|
||||
timezone: string;
|
||||
isOnline: boolean;
|
||||
lastHeartbeatAt: string | null;
|
||||
lastEventAt: string | null;
|
||||
recognitionModel: string | null;
|
||||
camerasTotal: number;
|
||||
camerasUp: number;
|
||||
fractionBelowGate: number;
|
||||
queued: number;
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
export interface EnrolmentCode {
|
||||
code: string;
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
label?: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
112
src/features/team/components/AddMemberDialog.tsx
Normal file
112
src/features/team/components/AddMemberDialog.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 {Selector} from '@astryxdesign/core/Selector';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {SecretOnce} from '@/shared/components/patterns/SecretOnce';
|
||||
import {teamRepository} from '@/features/team/repositories/teamRepository';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
type Mode = 'create' | 'invite';
|
||||
|
||||
/**
|
||||
* Two ways to give somebody an account, and the difference is who handles
|
||||
* the password. "Create" makes the account now and hands the manager a
|
||||
* generated password to pass on - right when the person is standing there.
|
||||
* "Invite" hands them a code and they choose their own password - better
|
||||
* when they have their phone, because the manager never sees it.
|
||||
*/
|
||||
export function AddMemberDialog({callerRole, onClose, onDone}: {callerRole: UserRole; onClose: () => void; onDone: () => void}) {
|
||||
const [mode, setMode] = useState<Mode>('create');
|
||||
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; hint: string} | null>(null);
|
||||
|
||||
// A manager cannot mint an owner: promoting somebody past yourself is an
|
||||
// escalation. The server enforces it; the form simply does not offer it.
|
||||
const roles: UserRole[] = callerRole === 'owner' ? ['staff', 'manager', 'owner'] : ['staff', 'manager'];
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
if (mode === 'create') {
|
||||
const res = await teamRepository.addMember({email: email.trim(), full_name: name.trim(), role});
|
||||
setBusy(false);
|
||||
if (!res.ok || !res.data) return setError(res.message ?? 'Could not create the account.');
|
||||
setSecret({
|
||||
label: `${res.data.email} can sign in now with this password:`,
|
||||
value: res.data.password,
|
||||
hint: 'Give it to them directly. They can change it from their own Security settings.',
|
||||
});
|
||||
} else {
|
||||
const res = await teamRepository.invite({email: email.trim(), full_name: name.trim() || undefined, role});
|
||||
setBusy(false);
|
||||
if (!res.ok || !res.data?.code) return setError(res.message ?? 'Could not create the invitation.');
|
||||
setSecret({
|
||||
label: `Send ${res.data.email} this code. They open the app, enter it, and choose their own password.`,
|
||||
value: res.data.code,
|
||||
hint: `Whoever has the code gets a ${role} account for this company. It expires ${new Date(res.data.expires_at).toLocaleString()}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const close = (open: boolean) => (open ? undefined : onClose());
|
||||
const valid = /.+@.+\..+/.test(email) && (mode === 'invite' || name.trim().length > 0);
|
||||
|
||||
return (
|
||||
<Dialog isOpen onOpenChange={close} purpose="form" width={480} aria-label="Add a team member">
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader title="Add a team member" onOpenChange={close} />
|
||||
{secret ? (
|
||||
<SecretOnce {...secret} onDone={onDone} />
|
||||
) : (
|
||||
<>
|
||||
{error && <Banner status="error" title={error} />}
|
||||
<Selector
|
||||
label="How"
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as Mode)}
|
||||
options={[
|
||||
{value: 'create', label: 'Create the account now - I will hand them the password'},
|
||||
{value: 'invite', label: 'Send an invitation - they choose their own password'},
|
||||
]}
|
||||
/>
|
||||
<TextInput label="Email" type="email" value={email} onChange={setEmail} placeholder="name@shop.in" isRequired />
|
||||
<TextInput label="Full name" value={name} onChange={setName} placeholder="Priya R" isRequired={mode === 'create'} isOptional={mode === 'invite'} />
|
||||
<Selector label="Role" value={role} onChange={(v) => setRole(v as UserRole)} options={roles.map((r) => ({value: r, label: roleLabel(r)}))} />
|
||||
<Text size="xsm" color="secondary">{roleHelp(role)}</Text>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button isDisabled={!valid || busy} isLoading={busy} onClick={() => void submit()} label={mode === 'create' ? 'Create account' : 'Create invitation'} />
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function roleLabel(r: UserRole): string {
|
||||
return {staff: 'Staff', manager: 'Manager', owner: 'Owner', admin: 'Admin'}[r];
|
||||
}
|
||||
|
||||
function roleHelp(r: UserRole): string {
|
||||
switch (r) {
|
||||
case 'staff':
|
||||
return 'Sees arrivals and customers on the shop floor. Cannot change cameras or the team.';
|
||||
case 'manager':
|
||||
return 'Everything staff can, plus cameras, shop PCs and the team.';
|
||||
case 'owner':
|
||||
return 'Full control of the company, including other owners.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {teamRepository} from '@/features/team/repositories/teamRepository';
|
||||
import type {TeamMember} from '@/features/team/types/team';
|
||||
import type {TeamMember, Invitation} from '@/features/team/types/team';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
|
||||
export function useTeam(): Resource<TeamMember[]> {
|
||||
return useResource(teamRepository.list());
|
||||
}
|
||||
|
||||
export function useInvitations(): Resource<Invitation[]> {
|
||||
return useResource(teamRepository.invitations());
|
||||
}
|
||||
|
||||
@@ -1,7 +1,26 @@
|
||||
import {postJson, patchJson, deleteJson} from '@/shared/services/httpClient';
|
||||
import type {Endpoint} from '@/shared/services/httpClient';
|
||||
import type {TeamMember} from '@/features/team/types/team';
|
||||
import type {TeamMember, Invitation, NewMember} from '@/features/team/types/team';
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/** Console accounts. Unscoped — team membership is per company, not per site. */
|
||||
export const teamRepository = {
|
||||
list: (): Endpoint<TeamMember[]> => ({path: '/api/team', params: {}}),
|
||||
|
||||
/** Creates the account now. The password is in the response ONCE. */
|
||||
addMember: (body: {email: string; full_name: string; role: UserRole; password?: string}) =>
|
||||
postJson<NewMember>('/api/team/members', body),
|
||||
|
||||
update: (id: string, patch: {role?: UserRole; active?: boolean}) =>
|
||||
patchJson<TeamMember>(`/api/team/${encodeURIComponent(id)}`, patch),
|
||||
|
||||
/** New password shown ONCE; that person is signed out of every device. */
|
||||
resetPassword: (id: string) => postJson<{password: string}>(`/api/team/${encodeURIComponent(id)}/password`, {}),
|
||||
|
||||
invitations: (): Endpoint<Invitation[]> => ({path: '/api/team/invitations', params: {}}),
|
||||
|
||||
/** The code is in the response ONCE. */
|
||||
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)}`),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import type {UserRole} from '@/features/auth/types/auth';
|
||||
|
||||
/**
|
||||
* A person with console access.
|
||||
*
|
||||
* Not a shop-floor employee. `active: false` means signed out immediately and
|
||||
* unable to sign back in; reactivating restores the account but not their old
|
||||
* sessions.
|
||||
*
|
||||
* `organisation` was removed: it mapped from `client_name`, which
|
||||
* `GET /api/team` does not send, so it was `undefined` on every row. Every
|
||||
* member of this list belongs to the caller's own company by definition — the
|
||||
* backend scopes the query by the session's tenant — so the field said nothing
|
||||
* even when it was populated.
|
||||
*/
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: UserRole;
|
||||
/** false = signed out immediately and unable to sign back in. */
|
||||
active: boolean;
|
||||
/** Absent for somebody who has never signed in — that is data, not a gap. */
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** The server's raw shape; the password field exists only on creation. */
|
||||
export interface NewMember {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: UserRole;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name?: string;
|
||||
role: UserRole;
|
||||
invited_by?: string;
|
||||
code?: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,54 @@
|
||||
import 'server-only';
|
||||
import {upstreamRequest} from './apiClient';
|
||||
import type {ApiCamera, ApiSite} from './types';
|
||||
import {upstreamRaw, upstreamRequest} from './apiClient';
|
||||
import type {ApiCamera, ApiCameraInput, ApiEnrolmentCode, ApiSite} from './types';
|
||||
|
||||
/**
|
||||
* Shops and cameras.
|
||||
*
|
||||
* `{id}` accepts a uuid or the site's slug, and the slug is IMMUTABLE — safe
|
||||
* to store in a saved URL, a config file or a scheduled report. The display
|
||||
* name is free to change and should be, so nothing keys on it.
|
||||
*/
|
||||
/** Shops and their cameras. A shop is addressed by slug everywhere. */
|
||||
export const sitesApi = {
|
||||
list: (accessToken: string) =>
|
||||
upstreamRequest<ApiSite[]>({path: '/api/sites', accessToken}),
|
||||
list: (accessToken: string) => upstreamRequest<ApiSite[]>({path: '/api/sites', accessToken}),
|
||||
|
||||
/** Five-step smoke test for one shop. */
|
||||
check: (accessToken: string, site: string) =>
|
||||
upstreamRequest<unknown>({
|
||||
path: `/api/sites/${encodeURIComponent(site)}/check`,
|
||||
upstreamRequest<unknown>({path: `/api/sites/${encodeURIComponent(site)}/check`, accessToken}),
|
||||
|
||||
/** The one-shot code a shop PC types to claim itself. Manager and above. */
|
||||
enrolmentCode: (accessToken: string, site: string, body: {label?: string; ttl_hours?: number}) =>
|
||||
upstreamRequest<ApiEnrolmentCode>({
|
||||
path: `/api/sites/${encodeURIComponent(site)}/enrolment-code`,
|
||||
method: 'POST',
|
||||
body,
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
cameras: (accessToken: string, site?: string) =>
|
||||
upstreamRequest<ApiCamera[]>({
|
||||
path: '/api/cameras',
|
||||
query: {site},
|
||||
upstreamRequest<ApiCamera[]>({path: '/api/cameras', query: {site}, accessToken}),
|
||||
|
||||
addCamera: (accessToken: string, site: string, body: ApiCameraInput) =>
|
||||
upstreamRequest<ApiCamera>({
|
||||
path: `/api/sites/${encodeURIComponent(site)}/cameras`,
|
||||
method: 'POST',
|
||||
body,
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
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 test the connection or run a placement check. */
|
||||
checkCamera: (accessToken: string, id: string, kind: 'connection' | 'placement') =>
|
||||
upstreamRequest<ApiCamera>({
|
||||
path: `/api/cameras/${encodeURIComponent(id)}/check`,
|
||||
method: 'POST',
|
||||
body: {kind},
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
/** Raw bytes of an authenticated image (snapshot or face). */
|
||||
image: (accessToken: string, path: string) => upstreamRaw({path, accessToken}),
|
||||
};
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
import 'server-only';
|
||||
import {upstreamRequest} from './apiClient';
|
||||
import type {ApiRole, ApiTeamMember} from './types';
|
||||
import type {ApiInvitation, ApiRole, ApiTeamMember} from './types';
|
||||
|
||||
/**
|
||||
* The people with console accounts.
|
||||
*
|
||||
* NOT shop-floor rostering. This is who can sign in, at what privilege — there
|
||||
* is no attendance, shift or performance data upstream, which is why the
|
||||
* Leaderboard's attendance metrics are recorded as a backend gap rather than
|
||||
* being derived from this.
|
||||
*
|
||||
* Deactivating signs that person out immediately and stops them signing back
|
||||
* in; reactivating restores the account but not their old sessions. A change
|
||||
* that would leave the company with no active owner is refused with 409
|
||||
* `last_owner`.
|
||||
*/
|
||||
export const teamApi = {
|
||||
list: (accessToken: string) =>
|
||||
upstreamRequest<ApiTeamMember[]>({path: '/api/team', accessToken}),
|
||||
list: (accessToken: string) => upstreamRequest<ApiTeamMember[]>({path: '/api/team', accessToken}),
|
||||
|
||||
update: (
|
||||
accessToken: string,
|
||||
id: string,
|
||||
patch: {role?: ApiRole; active?: boolean},
|
||||
) =>
|
||||
// Returns the UPDATED member, not an ApiUser — the handler writes back the
|
||||
// same TeamMember shape `list` returns. Mis-typed as ApiUser, which has no
|
||||
// `active`, so a caller reading the result could not see the change it had
|
||||
// just made. Nothing calls this yet; correcting it now keeps the file
|
||||
// honest for whoever wires the role/deactivate controls.
|
||||
update: (accessToken: string, id: string, patch: {role?: ApiRole; active?: boolean}) =>
|
||||
upstreamRequest<ApiTeamMember>({
|
||||
path: `/api/team/${encodeURIComponent(id)}`,
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
/** Create an account directly. The password comes back ONCE. */
|
||||
createMember: (accessToken: string, body: {email: string; full_name: string; role: ApiRole; password?: string}) =>
|
||||
upstreamRequest<ApiTeamMember & {password: string}>({
|
||||
path: '/api/team/members',
|
||||
method: 'POST',
|
||||
body,
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
/** Reset a password and sign that person out everywhere, in one step. */
|
||||
resetPassword: (accessToken: string, id: string, password?: string) =>
|
||||
upstreamRequest<{password: string}>({
|
||||
path: `/api/team/${encodeURIComponent(id)}/password`,
|
||||
method: 'POST',
|
||||
body: password ? {password} : {},
|
||||
accessToken,
|
||||
}),
|
||||
|
||||
invitations: (accessToken: string) =>
|
||||
upstreamRequest<ApiInvitation[]>({path: '/api/team/invitations', accessToken}),
|
||||
|
||||
/** Invite somebody to choose their own password. The code comes back ONCE. */
|
||||
invite: (accessToken: string, body: {email: string; full_name?: string; role: ApiRole; ttl_hours?: number}) =>
|
||||
upstreamRequest<ApiInvitation>({path: '/api/team/invitations', method: 'POST', body, accessToken}),
|
||||
|
||||
revokeInvitation: (accessToken: string, id: string) =>
|
||||
upstreamRequest<void>({path: `/api/team/invitations/${encodeURIComponent(id)}`, method: 'DELETE', accessToken}),
|
||||
};
|
||||
|
||||
@@ -80,6 +80,8 @@ export interface ApiDeviceSession {
|
||||
export interface ApiInvitation {
|
||||
id: string;
|
||||
email: string;
|
||||
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. */
|
||||
@@ -101,32 +103,89 @@ export interface ApiInvitationPreview {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ApiSite {
|
||||
/**
|
||||
* The uuid, named `site_id` on the wire — NOT `id`.
|
||||
*
|
||||
* This was declared as `id`, so it deserialised to undefined, `uuid` on the
|
||||
* mapped Site was undefined, and every store card rendered with
|
||||
* `key={undefined}`. React's duplicate-key warning was the only symptom;
|
||||
* nothing threw, because an absent field is not an error on either side.
|
||||
*/
|
||||
site_id: string;
|
||||
/** 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;
|
||||
/** Share of arrivals whose match confidence fell below the identity gate. */
|
||||
fraction_below_gate?: number;
|
||||
timezone: string;
|
||||
online: boolean;
|
||||
last_heartbeat_at?: string;
|
||||
last_event_at?: string;
|
||||
recognition_model?: string;
|
||||
agent_version?: string;
|
||||
cameras_up: number;
|
||||
cameras_total: number;
|
||||
fraction_below_gate: number;
|
||||
queued: number;
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
/** A photo, or the reason there is none. `auth` means the URL needs our
|
||||
* session's bearer - it is one of ours, not a presigned bucket link. */
|
||||
export interface ApiImage {
|
||||
available: boolean;
|
||||
url?: string;
|
||||
expires_in?: number;
|
||||
auth?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ApiCamera {
|
||||
id: string;
|
||||
site_id: string;
|
||||
name?: string;
|
||||
online?: boolean;
|
||||
last_still_url?: string | null;
|
||||
site?: string;
|
||||
camera_id: string;
|
||||
label: string;
|
||||
host: string;
|
||||
port: number;
|
||||
path: string;
|
||||
username: string;
|
||||
has_password: boolean;
|
||||
max_width: number;
|
||||
tuning?: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
revision: number;
|
||||
/** null: no shop PC has reported yet. false: not connecting. */
|
||||
connected?: boolean | null;
|
||||
last_seen_at?: string;
|
||||
snapshot: ApiImage;
|
||||
snapshot_at?: string;
|
||||
check: ApiCameraCheck;
|
||||
}
|
||||
|
||||
/** Every field optional: a PATCH sends only what changed, and a blank
|
||||
* password means "leave it alone", never "clear it". */
|
||||
export interface ApiCameraInput {
|
||||
camera_id?: string;
|
||||
label?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
path?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
max_width?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiEnrolmentCode {
|
||||
code: string;
|
||||
site_id: string;
|
||||
site_name: string;
|
||||
label?: string;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -347,96 +406,7 @@ export interface ApiAssistantAnswer {
|
||||
// Sales (the merchant application's domain — LOYALY.md)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One line of a sale. `intent` is the load-bearing field: an enquiry keeps the
|
||||
* price it was quoted at and contributes NOTHING to the bill, which is why
|
||||
* `billable_paise` ships separately rather than being re-derived per client.
|
||||
*/
|
||||
export interface ApiSaleLine {
|
||||
product_id?: string;
|
||||
product_name: string;
|
||||
price_paise: number;
|
||||
intent: 'purchased' | 'enquired';
|
||||
billable_paise: number;
|
||||
}
|
||||
|
||||
/** Money is integer PAISE on this path, never a float. */
|
||||
export interface ApiSale {
|
||||
id: string;
|
||||
invoice_no?: string;
|
||||
site_id: string;
|
||||
site_slug?: string;
|
||||
visit_id?: string;
|
||||
visitor_id?: string;
|
||||
visitor_ref?: string;
|
||||
customer_label?: string;
|
||||
staff_id?: string;
|
||||
staff_name?: string;
|
||||
terminal_id?: string;
|
||||
total_paise: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
lines?: ApiSaleLine[];
|
||||
/** Counts, so the history list need not carry every line of every sale. */
|
||||
purchased_lines?: number;
|
||||
enquiry_lines?: number;
|
||||
client_created_at?: string;
|
||||
server_created_at: string;
|
||||
}
|
||||
|
||||
export interface ApiDashboardSummary {
|
||||
date: string;
|
||||
from: string;
|
||||
to: string;
|
||||
timezone: string;
|
||||
total_revenue_paise: number;
|
||||
currency: string;
|
||||
sales_count: number;
|
||||
enquiries_count: number;
|
||||
active_customer_count: number;
|
||||
visitors_count: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The shop floor (LOYALY.md §6/§7/§20)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* One person the shop is dealing with right now.
|
||||
*
|
||||
* `visitor_id` ABSENT is the load-bearing case: the cameras saw a face and
|
||||
* matched nothing, and that is exactly when the app offers to name them.
|
||||
* It must stay distinguishable from a known customer who has no name yet.
|
||||
*/
|
||||
export interface ApiFloorVisit {
|
||||
visit_id: string;
|
||||
site_id: string;
|
||||
site_slug?: string;
|
||||
detected_at: string;
|
||||
status: 'waiting' | 'attending' | 'completed' | 'cancelled';
|
||||
visitor_id?: string;
|
||||
visitor_ref?: string;
|
||||
label?: string;
|
||||
phone?: string;
|
||||
previous_visits: number;
|
||||
attended_by?: string;
|
||||
attended_by_name?: string;
|
||||
attended_by_me: boolean;
|
||||
attended_at?: string;
|
||||
image?: {available: boolean; url?: string | null; reason?: string};
|
||||
}
|
||||
|
||||
export interface ApiCustomerCreated {
|
||||
id: string;
|
||||
ref: string;
|
||||
label: string;
|
||||
full_name?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
/** §16 — "processed" for a new sale, "already_processed" for a replay. */
|
||||
export interface ApiSaleResult {
|
||||
status: 'processed' | 'already_processed';
|
||||
sale_id: string;
|
||||
sale?: ApiSale;
|
||||
}
|
||||
|
||||
34
src/shared/components/patterns/SecretOnce.tsx
Normal file
34
src/shared/components/patterns/SecretOnce.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
|
||||
/**
|
||||
* A password or a code the server will never show again. The copy button
|
||||
* exists because retyping a generated secret is how it ends up wrong, and the
|
||||
* warning exists because the thing is going to be pasted into a chat.
|
||||
*/
|
||||
export function SecretOnce({label, value, hint, onDone}: {label: string; value: string; hint: string; onDone: () => void}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
<Text size="sm" color="secondary">{label}</Text>
|
||||
<Heading level={2} className="font-mono" style={{letterSpacing: '0.04em', wordBreak: 'break-all'}}>{value}</Heading>
|
||||
<Banner status="warning" title="Shown once." description={hint} />
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
label={copied ? 'Copied' : 'Copy'}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(value);
|
||||
setCopied(true);
|
||||
}}
|
||||
/>
|
||||
<Button onClick={onDone} label="Done" />
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -59,11 +59,12 @@ export function isBranch(row: AccountRowSpec): row is AccountBranch {
|
||||
* it is grouped by what it is rather than stacked by what fit.
|
||||
*/
|
||||
export const ACCOUNT_ROOT_GROUPS: AccountRowSpec[][] = [
|
||||
// Flat: every row is a destination that exists. The cascading panels below
|
||||
// are unreachable from here and kept only until the menu is simplified.
|
||||
[
|
||||
{label: 'Settings', icon: ICONS.settings, panel: 'settings'},
|
||||
{label: 'Profile', icon: ICONS.profile, panel: 'profile'},
|
||||
{label: 'Language', icon: ICONS.language, panel: 'language'},
|
||||
{label: 'Help', icon: ICONS.help, panel: 'help'},
|
||||
{label: 'Account & devices', icon: ICONS.profile, href: '/settings'},
|
||||
{label: 'Team', icon: ICONS.staff, href: '/settings/team'},
|
||||
{label: 'Keyboard shortcuts', icon: ICONS.keyboard, action: 'shortcuts'},
|
||||
],
|
||||
[
|
||||
{
|
||||
@@ -121,14 +122,7 @@ export const ACCOUNT_PANELS: Record<AccountPanelId, AccountPanel> = {
|
||||
profile: {
|
||||
title: 'Profile',
|
||||
rows: [
|
||||
{label: 'Business Details', icon: ICONS.business, href: '/settings'},
|
||||
{label: 'Subscription', icon: ICONS.billing, href: '/settings/billing'},
|
||||
{label: 'Activity', icon: ICONS.analytics, href: '/activity'},
|
||||
// Devices and Sessions are two views of the same Security screen today.
|
||||
// They stay two rows because they are two questions a merchant asks;
|
||||
// when Security splits into tabs, only the href moves.
|
||||
{label: 'Devices', icon: ICONS.security, href: '/settings/security'},
|
||||
{label: 'Sessions', icon: ICONS.sessions, href: '/settings/security'},
|
||||
{label: 'Account & devices', icon: ICONS.profile, href: '/settings'},
|
||||
],
|
||||
},
|
||||
language: {
|
||||
@@ -137,7 +131,7 @@ export const ACCOUNT_PANELS: Record<AccountPanelId, AccountPanel> = {
|
||||
{
|
||||
label: 'Language settings',
|
||||
icon: ICONS.preferences,
|
||||
href: '/settings/preferences',
|
||||
href: '/settings',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface NavEntry {
|
||||
*/
|
||||
export const PRIMARY_NAV: NavEntry[] = [
|
||||
{label: 'Dashboard', href: '/dashboard', icon: ICONS.dashboard},
|
||||
{label: 'Live', href: '/activity', icon: ICONS.visitors},
|
||||
{label: 'Stores', href: '/stores', icon: ICONS.stores},
|
||||
];
|
||||
|
||||
|
||||
@@ -127,3 +127,38 @@ export async function serveUpstream<U, T = U>(
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A write (or a read with no scope) proxied to the platform.
|
||||
*
|
||||
* The body is parsed once and handed to the caller; the platform's own
|
||||
* validation answers a bad one, and its message is shown as-is. `status` is
|
||||
* for a 201 on create. An upstream 204 comes back as `{data: null}` so the
|
||||
* client sees one envelope shape everywhere.
|
||||
*/
|
||||
export async function proxyUpstream<U, T = U>(
|
||||
req: NextRequest,
|
||||
call: (accessToken: string, body: Record<string, unknown>, params: URLSearchParams) => Promise<U>,
|
||||
opts?: {map?: (upstream: U) => T; status?: number},
|
||||
): Promise<Response> {
|
||||
let body: Record<string, unknown> = {};
|
||||
if (req.method !== 'GET' && req.method !== 'DELETE') {
|
||||
try {
|
||||
const parsed: unknown = await req.json();
|
||||
if (parsed && typeof parsed === 'object') body = parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
// An empty body is legitimate for several endpoints (reset a password,
|
||||
// mint a code). The platform rejects a body that is actually required.
|
||||
}
|
||||
}
|
||||
try {
|
||||
const upstream = await withUpstream((token) => call(token, body, req.nextUrl.searchParams));
|
||||
const data = opts?.map ? opts.map(upstream) : upstream;
|
||||
return Response.json(
|
||||
{data: data ?? null, meta: {generatedAt: new Date().toISOString()}},
|
||||
{status: opts?.status ?? 200, headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
} catch (err) {
|
||||
return failResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,3 +162,7 @@ export function patchJson<T>(
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteJson<T>(path: string): Promise<HttpResult<T>> {
|
||||
return request<T>(path, {method: 'DELETE'});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user