backend-only-rework #1

Open
Suriya wants to merge 2 commits from backend-only-rework into main
57 changed files with 1585 additions and 1630 deletions
Showing only changes of commit 0b61077e2f - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 gap={8}>
{rows.map((site, i) => (
<VStack key={site.uuid} gap={6}>
{i > 0 && <Divider />}
<ShopSection site={site} canManage={canManage} />
</VStack>
</Card>
))}
</Grid>
</VStack>
)}
</AsyncBoundary>
</VStack>

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

@@ -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;
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();
}
if (newPass !== confirmPass) {
toast({type: 'error', body: 'New passwords do not match'});
return;
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();
}
setIsChangingPass(true);
setTimeout(() => {
setIsChangingPass(false);
setCurrentPass('');
setNewPass('');
setConfirmPass('');
toast({body: 'Password updated successfully'});
}, 400);
};
const handleRevokeSession = (id: string) => {
setSessions((prev) => prev.filter((s) => s.id !== id));
toast({body: 'Session revoked successfully'});
};
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'}`});
}}
/>
</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}
/>
{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>
</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>
{!s.current && <Button size="sm" variant="ghost" label="Sign out" onClick={() => void revoke(s)} />}
</HStack>
))}
</VStack>
)}
</PanelCard>
);
}

View File

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

View File

@@ -1,5 +1,6 @@
'use client';
import {useState} from 'react';
import {proportional} from '@astryxdesign/core/Table';
import type {TableColumn} from '@astryxdesign/core/Table';
import {VStack, HStack} from '@astryxdesign/core/Layout';
@@ -7,67 +8,74 @@ 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',
};
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();
}
const COLUMNS: TableColumn<TeamRow>[] = [
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',
@@ -76,76 +84,124 @@ const COLUMNS: TableColumn<TeamRow>[] = [
<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>
<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: '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>
<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), align: 'end'},
{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)},
]}
/>
);
},
}]
: []),
];
export function TeamManagement() {
const team = useTeam();
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 (
<VStack gap={5}>
<PanelCard
title="Console access"
subtitle="Everybody who can sign in to this company's dashboard."
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="No team members yet"
description="People appear here once they accept an invitation."
/>
}
empty={<EmptyPanel icon="staff" title="Nobody yet" description="Add the first member." />}
>
{(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. */}
<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">
New members are added by invitation, and choose their own password
when they redeem it. Roles and access are managed on the platform.
{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>
)}
{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>
);
}

View File

@@ -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. */

View 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', {}),
};

View File

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

View File

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

View File

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

View File

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

View 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'};
}

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

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

View 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.'};
}

View 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: ''},
];

View 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;
}

View File

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

View 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;
}

View File

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

View 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,
},
};
}

View 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;
}

View File

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

View File

@@ -0,0 +1,112 @@
'use client';
import {useState} from 'react';
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
import {VStack, HStack} from '@astryxdesign/core/Layout';
import {TextInput} from '@astryxdesign/core/TextInput';
import {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 '';
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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',
},
],
},

View File

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

View File

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

View File

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