From 0b61077e2fab21f3261beaba481d71bfeb934567 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Fri, 18 Sep 2026 12:06:28 +0530 Subject: [PATCH] Stores, Team and Account run against the platform, not a mock Stores: each shop's health in one line (offline, losing visits, camera trouble, faces too poor - in severity order, one verdict), its cameras as pictures with connection and 'proven to recognise a face' as two different claims, add/edit/remove camera with the make picker, test connection and placement checks claimed by the shop PC, and the enrolment code a new shop PC types. Team: create an account (password shown once), invite (code shown once), change role, remove access, reset password, revoke pending invitations - with the rank rules the server enforces mirrored in what the form offers. Account: who you are and every device signed in, with 'sign out' per device and everywhere else. Gone: StoreManagement, SecurityManager and ProfileForm that rendered hard-coded arrays, the Business form with no backend, the /api/stores route nothing served, and the cascading account menu's dead links. Two things found by using the camera form, not by tests: Chrome filled the operator's own email into 'camera username', and the first camera saved with a password and no username because autofill wrote to the input without React seeing it. The dialog sets autocomplete on the real inputs and reads the credential fields from the DOM at submit. Every route was exercised against production before this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj --- src/app/(workspace)/settings/page.tsx | 16 +- src/app/(workspace)/settings/profile/page.tsx | 33 -- .../(workspace)/settings/security/page.tsx | 13 - src/app/(workspace)/settings/stores/page.tsx | 13 - src/app/(workspace)/stores/page.tsx | 93 ++--- src/app/api/auth/sessions/[id]/route.ts | 10 + .../api/auth/sessions/revoke-others/route.ts | 10 + src/app/api/auth/sessions/route.ts | 10 + src/app/api/cameras/[id]/check/route.ts | 17 + src/app/api/cameras/[id]/route.ts | 21 ++ src/app/api/cameras/route.ts | 26 ++ src/app/api/faces/route.ts | 57 --- src/app/api/images/route.ts | 38 ++ .../api/sites/[site]/enrolment-code/route.ts | 19 + src/app/api/sites/route.ts | 33 +- src/app/api/team/[id]/password/route.ts | 13 + src/app/api/team/[id]/route.ts | 18 + src/app/api/team/invitations/[id]/route.ts | 10 + src/app/api/team/invitations/route.ts | 25 ++ src/app/api/team/members/route.ts | 22 ++ src/app/api/visits/route.ts | 2 +- .../settings/components/AccountCard.tsx | 31 ++ .../settings/components/BusinessForm.tsx | 175 --------- .../settings/components/ProfileForm.tsx | 165 --------- .../settings/components/SecurityManager.tsx | 338 +++--------------- .../settings/components/StoreManagement.tsx | 277 -------------- .../settings/components/TeamManagement.tsx | 302 +++++++++------- src/features/settings/config/settingsNav.ts | 32 +- .../repositories/securityRepository.ts | 18 + .../repositories/settingsRepository.ts | 16 - .../repositories/settingsServerRepository.ts | 22 -- .../settings/services/settingsService.ts | 55 --- src/features/settings/types/settings.ts | 26 -- src/features/stores/components/CameraCard.tsx | 132 +++++++ .../stores/components/CameraDialog.tsx | 146 ++++++++ .../stores/components/EnrolmentCodeDialog.tsx | 70 ++++ .../stores/components/ShopSection.tsx | 100 ++++++ src/features/stores/data/cameraMakes.ts | 28 ++ src/features/stores/hooks/useCameras.ts | 23 ++ src/features/stores/hooks/useStores.ts | 28 -- .../stores/repositories/cameraRepository.ts | 33 ++ .../stores/repositories/storeRepository.ts | 11 - src/features/stores/services/mapCamera.ts | 39 ++ src/features/stores/types/camera.ts | 43 +++ src/features/stores/types/site.ts | 36 +- .../team/components/AddMemberDialog.tsx | 112 ++++++ src/features/team/hooks/useTeam.ts | 6 +- .../team/repositories/teamRepository.ts | 23 +- src/features/team/types/team.ts | 35 +- src/services/api/sitesApi.ts | 59 ++- src/services/api/teamApi.ts | 57 +-- src/services/api/types.ts | 184 ++++------ src/shared/components/patterns/SecretOnce.tsx | 34 ++ src/shared/layouts/workspace/account-menu.ts | 20 +- src/shared/layouts/workspace/nav-config.ts | 1 + src/shared/services/bff.ts | 35 ++ src/shared/services/httpClient.ts | 4 + 57 files changed, 1585 insertions(+), 1630 deletions(-) delete mode 100644 src/app/(workspace)/settings/profile/page.tsx delete mode 100644 src/app/(workspace)/settings/security/page.tsx delete mode 100644 src/app/(workspace)/settings/stores/page.tsx create mode 100644 src/app/api/auth/sessions/[id]/route.ts create mode 100644 src/app/api/auth/sessions/revoke-others/route.ts create mode 100644 src/app/api/auth/sessions/route.ts create mode 100644 src/app/api/cameras/[id]/check/route.ts create mode 100644 src/app/api/cameras/[id]/route.ts create mode 100644 src/app/api/cameras/route.ts delete mode 100644 src/app/api/faces/route.ts create mode 100644 src/app/api/images/route.ts create mode 100644 src/app/api/sites/[site]/enrolment-code/route.ts create mode 100644 src/app/api/team/[id]/password/route.ts create mode 100644 src/app/api/team/[id]/route.ts create mode 100644 src/app/api/team/invitations/[id]/route.ts create mode 100644 src/app/api/team/invitations/route.ts create mode 100644 src/app/api/team/members/route.ts create mode 100644 src/features/settings/components/AccountCard.tsx delete mode 100644 src/features/settings/components/BusinessForm.tsx delete mode 100644 src/features/settings/components/ProfileForm.tsx delete mode 100644 src/features/settings/components/StoreManagement.tsx create mode 100644 src/features/settings/repositories/securityRepository.ts delete mode 100644 src/features/settings/repositories/settingsRepository.ts delete mode 100644 src/features/settings/repositories/settingsServerRepository.ts delete mode 100644 src/features/settings/services/settingsService.ts delete mode 100644 src/features/settings/types/settings.ts create mode 100644 src/features/stores/components/CameraCard.tsx create mode 100644 src/features/stores/components/CameraDialog.tsx create mode 100644 src/features/stores/components/EnrolmentCodeDialog.tsx create mode 100644 src/features/stores/components/ShopSection.tsx create mode 100644 src/features/stores/data/cameraMakes.ts create mode 100644 src/features/stores/hooks/useCameras.ts delete mode 100644 src/features/stores/hooks/useStores.ts create mode 100644 src/features/stores/repositories/cameraRepository.ts delete mode 100644 src/features/stores/repositories/storeRepository.ts create mode 100644 src/features/stores/services/mapCamera.ts create mode 100644 src/features/stores/types/camera.ts create mode 100644 src/features/team/components/AddMemberDialog.tsx create mode 100644 src/shared/components/patterns/SecretOnce.tsx diff --git a/src/app/(workspace)/settings/page.tsx b/src/app/(workspace)/settings/page.tsx index c7edf8a..05d795a 100644 --- a/src/app/(workspace)/settings/page.tsx +++ b/src/app/(workspace)/settings/page.tsx @@ -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 ( - - + + + + + ); } diff --git a/src/app/(workspace)/settings/profile/page.tsx b/src/app/(workspace)/settings/profile/page.tsx deleted file mode 100644 index baa414c..0000000 --- a/src/app/(workspace)/settings/profile/page.tsx +++ /dev/null @@ -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 ( - - {profile ? ( - - ) : ( - - )} - - ); -} diff --git a/src/app/(workspace)/settings/security/page.tsx b/src/app/(workspace)/settings/security/page.tsx deleted file mode 100644 index e1eaf9e..0000000 --- a/src/app/(workspace)/settings/security/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {SettingsPage} from '@/features/settings/components/SettingsPage'; -import {SecurityManager} from '@/features/settings/components/SecurityManager'; - -export default function SecuritySettingsPage() { - return ( - - - - ); -} diff --git a/src/app/(workspace)/settings/stores/page.tsx b/src/app/(workspace)/settings/stores/page.tsx deleted file mode 100644 index 1d42aa0..0000000 --- a/src/app/(workspace)/settings/stores/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import {SettingsPage} from '@/features/settings/components/SettingsPage'; -import {StoreManagement} from '@/features/settings/components/StoreManagement'; - -export default function StoreSettingsPage() { - return ( - - - - ); -} diff --git a/src/app/(workspace)/stores/page.tsx b/src/app/(workspace)/stores/page.tsx index 9a99fd2..57a4d8d 100644 --- a/src/app/(workspace)/stores/page.tsx +++ b/src/app/(workspace)/stores/page.tsx @@ -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 ( - - - + + } - empty={ - - } + loading={} + empty={} > {(rows) => ( - - {rows.map((site) => ( - - - - {site.name} - {site.isOnline === null ? null : ( - - - - {site.isOnline ? 'Online' : 'Offline'} - - - )} - - - - {site.id} - - - - - - - - + + {rows.map((site, i) => ( + + {i > 0 && } + + ))} - + )} diff --git a/src/app/api/auth/sessions/[id]/route.ts b/src/app/api/auth/sessions/[id]/route.ts new file mode 100644 index 0000000..538872c --- /dev/null +++ b/src/app/api/auth/sessions/[id]/route.ts @@ -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)); +} diff --git a/src/app/api/auth/sessions/revoke-others/route.ts b/src/app/api/auth/sessions/revoke-others/route.ts new file mode 100644 index 0000000..76698c7 --- /dev/null +++ b/src/app/api/auth/sessions/revoke-others/route.ts @@ -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)); +} diff --git a/src/app/api/auth/sessions/route.ts b/src/app/api/auth/sessions/route.ts new file mode 100644 index 0000000..7fb2784 --- /dev/null +++ b/src/app/api/auth/sessions/route.ts @@ -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)); +} diff --git a/src/app/api/cameras/[id]/check/route.ts b/src/app/api/cameras/[id]/check/route.ts new file mode 100644 index 0000000..e04e1a2 --- /dev/null +++ b/src/app/api/cameras/[id]/check/route.ts @@ -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}, + ); +} diff --git a/src/app/api/cameras/[id]/route.ts b/src/app/api/cameras/[id]/route.ts new file mode 100644 index 0000000..44f1e1b --- /dev/null +++ b/src/app/api/cameras/[id]/route.ts @@ -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)); +} diff --git a/src/app/api/cameras/route.ts b/src/app/api/cameras/route.ts new file mode 100644 index 0000000..b111cc4 --- /dev/null +++ b/src/app/api/cameras/route.ts @@ -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= - 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}, + ); +} diff --git a/src/app/api/faces/route.ts b/src/app/api/faces/route.ts deleted file mode 100644 index 0443c48..0000000 --- a/src/app/api/faces/route.ts +++ /dev/null @@ -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/.jpg — an authenticated photo, proxied. - * - * A browser `` 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); - } -} diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts new file mode 100644 index 0000000..6d0cdf9 --- /dev/null +++ b/src/app/api/images/route.ts @@ -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 , 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); + } +} diff --git a/src/app/api/sites/[site]/enrolment-code/route.ts b/src/app/api/sites/[site]/enrolment-code/route.ts new file mode 100644 index 0000000..a567b99 --- /dev/null +++ b/src/app/api/sites/[site]/enrolment-code/route.ts @@ -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}, + ); +} diff --git a/src/app/api/sites/route.ts b/src/app/api/sites/route.ts index 20289db..d39b5bd 100644 --- a/src/app/api/sites/route.ts +++ b/src/app/api/sites/route.ts @@ -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)); } diff --git a/src/app/api/team/[id]/password/route.ts b/src/app/api/team/[id]/password/route.ts new file mode 100644 index 0000000..58becca --- /dev/null +++ b/src/app/api/team/[id]/password/route.ts @@ -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), + ); +} diff --git a/src/app/api/team/[id]/route.ts b/src/app/api/team/[id]/route.ts new file mode 100644 index 0000000..af1d079 --- /dev/null +++ b/src/app/api/team/[id]/route.ts @@ -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, + }), + ); +} diff --git a/src/app/api/team/invitations/[id]/route.ts b/src/app/api/team/invitations/[id]/route.ts new file mode 100644 index 0000000..017756a --- /dev/null +++ b/src/app/api/team/invitations/[id]/route.ts @@ -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)); +} diff --git a/src/app/api/team/invitations/route.ts b/src/app/api/team/invitations/route.ts new file mode 100644 index 0000000..9e57ed9 --- /dev/null +++ b/src/app/api/team/invitations/route.ts @@ -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}, + ); +} diff --git a/src/app/api/team/members/route.ts b/src/app/api/team/members/route.ts new file mode 100644 index 0000000..e03d4d9 --- /dev/null +++ b/src/app/api/team/members/route.ts @@ -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}, + ); +} diff --git a/src/app/api/visits/route.ts b/src/app/api/visits/route.ts index 8ba282d..4de0af1 100644 --- a/src/app/api/visits/route.ts +++ b/src/app/api/visits/route.ts @@ -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, } diff --git a/src/features/settings/components/AccountCard.tsx b/src/features/settings/components/AccountCard.tsx new file mode 100644 index 0000000..06d4804 --- /dev/null +++ b/src/features/settings/components/AccountCard.tsx @@ -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 ( + + + + + + {user.name} + + + {user.email} + {user.organisation} + + + + ); +} diff --git a/src/features/settings/components/BusinessForm.tsx b/src/features/settings/components/BusinessForm.tsx deleted file mode 100644 index 863317b..0000000 --- a/src/features/settings/components/BusinessForm.tsx +++ /dev/null @@ -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(DEFAULT_BUSINESS); - const [isSaving, setIsSaving] = useState(false); - const [savedData, setSavedData] = useState(DEFAULT_BUSINESS); - - const set = (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 ( - - {isDirty ? ( - - Unsaved changes - - ) : null} -