From ea3dbbeaf32d9a8f0dd096ed50c62d59f5079665 Mon Sep 17 00:00:00 2001 From: Aravind Date: Sat, 19 Sep 2026 13:12:19 +0530 Subject: [PATCH] login issue --- .../(workspace)/settings/security/page.tsx | 4 + src/app/(workspace)/stores/page.tsx | 58 +----- src/app/api/auth/sessions/[id]/route.ts | 20 ++ .../api/auth/sessions/revoke-others/route.ts | 16 ++ src/app/api/auth/sessions/route.ts | 20 ++ src/app/api/cameras/[id]/check/route.ts | 34 +++ src/app/api/cameras/[id]/route.ts | 36 ++++ src/app/api/cameras/route.ts | 39 ++++ src/app/api/images/route.ts | 67 ++++++ .../api/sites/[site]/enrolment-code/route.ts | 30 +++ src/app/api/team/[id]/password/route.ts | 32 +++ src/app/api/team/[id]/route.ts | 34 +++ src/app/api/team/invitations/[id]/route.ts | 19 ++ src/app/api/team/invitations/route.ts | 39 ++++ src/app/api/team/members/route.ts | 34 +++ src/app/api/team/route.ts | 20 +- .../settings/components/AccountCard.tsx | 58 ++++++ .../settings/components/SecurityManager.tsx | 134 ++++++------ .../settings/components/TeamManagement.tsx | 160 ++++++++++++-- .../repositories/securityRepository.ts | 16 ++ src/features/settings/services/mapSession.ts | 25 +++ src/features/stores/components/CameraCard.tsx | 144 +++++++++++++ .../stores/components/CameraDialog.tsx | 180 ++++++++++++++++ .../stores/components/EnrolmentCodeDialog.tsx | 112 ++++++++++ .../stores/components/ShopSection.tsx | 197 ++++++++++++++++++ src/features/stores/data/cameraMakes.ts | 79 +++++++ src/features/stores/hooks/useCameras.ts | 26 +++ .../stores/repositories/cameraRepository.ts | 51 +++++ src/features/stores/services/mapCamera.ts | 74 +++++++ src/features/stores/types/camera.ts | 53 +++++ .../team/components/AddMemberDialog.tsx | 183 ++++++++++++++++ src/features/team/hooks/useTeam.ts | 14 +- .../team/repositories/teamRepository.ts | 41 +++- src/features/team/services/mapTeam.ts | 42 ++++ src/features/team/types/team.ts | 19 ++ src/services/api/sitesApi.ts | 61 +++++- src/services/api/teamApi.ts | 69 +++++- src/services/api/types.ts | 143 ++++++++++++- src/shared/components/patterns/SecretOnce.tsx | 65 ++++++ src/shared/services/bff.ts | 52 +++++ src/shared/services/httpClient.ts | 5 + 41 files changed, 2342 insertions(+), 163 deletions(-) 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 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 create mode 100644 src/features/settings/repositories/securityRepository.ts create mode 100644 src/features/settings/services/mapSession.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 create mode 100644 src/features/stores/repositories/cameraRepository.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/features/team/services/mapTeam.ts create mode 100644 src/shared/components/patterns/SecretOnce.tsx diff --git a/src/app/(workspace)/settings/security/page.tsx b/src/app/(workspace)/settings/security/page.tsx index e1eaf9e..a567727 100644 --- a/src/app/(workspace)/settings/security/page.tsx +++ b/src/app/(workspace)/settings/security/page.tsx @@ -1,4 +1,5 @@ import {SettingsPage} from '@/features/settings/components/SettingsPage'; +import {AccountCard} from '@/features/settings/components/AccountCard'; import {SecurityManager} from '@/features/settings/components/SecurityManager'; export default function SecuritySettingsPage() { @@ -7,6 +8,9 @@ export default function SecuritySettingsPage() { title="Security & Audit Logs" description="Two-Factor authentication, password management, active login sessions and security audit history." > + {/* Who you are, then the devices signed in as you — both read from the + platform. The panels below them are still local-only. */} + ); diff --git a/src/app/(workspace)/stores/page.tsx b/src/app/(workspace)/stores/page.tsx index 9a99fd2..c14c3d6 100644 --- a/src/app/(workspace)/stores/page.tsx +++ b/src/app/(workspace)/stores/page.tsx @@ -1,17 +1,12 @@ 'use client'; -import {VStack, HStack} from '@astryxdesign/core/Layout'; -import {Grid} from '@astryxdesign/core/Grid'; -import {Card} from '@astryxdesign/core/Card'; -import {Text, Heading} from '@astryxdesign/core/Text'; -import {StatusDot} from '@astryxdesign/core/StatusDot'; +import {VStack} from '@astryxdesign/core/Layout'; import {PageHeader} from '@/shared/components/primitives/PageHeader'; import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary'; import {SkeletonCardGrid} from '@/shared/components/patterns/LoadingState'; import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; -import {StatPair, StatRow} from '@/shared/components/patterns/StatPair'; import {useSites} from '@/features/stores/hooks/useSites'; -import {formatPct} from '@/shared/utils/format'; +import {ShopSection} from '@/features/stores/components/ShopSection'; /** * The estate, from GET /api/sites. @@ -28,7 +23,7 @@ export default function StoresPage() { {(rows) => ( - + {rows.map((site) => ( - - - - {site.name} - {site.isOnline === null ? null : ( - - - - {site.isOnline ? 'Online' : 'Offline'} - - - )} - - - - {site.id} - - - - - - - - + ))} - + )} 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..087f2c7 --- /dev/null +++ b/src/app/api/auth/sessions/[id]/route.ts @@ -0,0 +1,20 @@ +import type {NextRequest} from 'next/server'; +import {authApi} from '@/services/api/authApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** + * DELETE /api/auth/sessions/{id} — sign one device out. + * + * Revoking the CURRENT session is allowed and signs this browser out — which is + * a legitimate thing to want and a surprising thing to do by accident, so the + * screen warns before calling it rather than this route refusing. + */ +export async function DELETE( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream(req, (token) => authApi.revokeSession(token, id)); +} 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..9b572a4 --- /dev/null +++ b/src/app/api/auth/sessions/revoke-others/route.ts @@ -0,0 +1,16 @@ +import type {NextRequest} from 'next/server'; +import {authApi} from '@/services/api/authApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/auth/sessions/revoke-others — sign out everywhere else. + * + * Keeps the caller's own session alive by design, so somebody who suspects a + * leak can clear every other device without locking themselves out of the + * screen they are doing it from. + */ +export async function POST(req: NextRequest) { + return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token)); +} diff --git a/src/app/api/auth/sessions/route.ts b/src/app/api/auth/sessions/route.ts new file mode 100644 index 0000000..c72ed38 --- /dev/null +++ b/src/app/api/auth/sessions/route.ts @@ -0,0 +1,20 @@ +import type {NextRequest} from 'next/server'; +import {authApi} from '@/services/api/authApi'; +import {serveUpstream} from '@/shared/services/bff'; +import {toDeviceSession} from '@/features/settings/services/mapSession'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/auth/sessions — every device currently signed in as this person. + * + * `current: true` marks the one making this request. It is the reason this list + * is worth showing at all: a session the user does not recognise is how they + * find out a password has leaked, and they need to be able to tell it apart + * from the browser they are reading the page in. + */ +export async function GET(req: NextRequest) { + return serveUpstream(req, (token) => authApi.sessions(token), (list) => + list.map(toDeviceSession), + ); +} 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..fe6f468 --- /dev/null +++ b/src/app/api/cameras/[id]/check/route.ts @@ -0,0 +1,34 @@ +import type {NextRequest} from 'next/server'; +import {sitesApi} from '@/services/api/sitesApi'; +import {proxyUpstream} from '@/shared/services/bff'; +import {toCamera} from '@/features/stores/services/mapCamera'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/cameras/{id}/check — ask the shop PC to prove this camera works. + * + * Two kinds: `connection` (can it be reached at all) and `placement` (is the + * view usable for recognition). Anything else the platform rejects, so the + * union is narrowed here rather than passed through as a free string. + * + * The platform answers 202 and the camera it returns still carries the PREVIOUS + * check — the edge has not run the new one yet. The caller re-reads; it must + * not render this response as the verdict. + */ +export async function POST( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream( + req, + (token, body) => + sitesApi.checkCamera( + token, + id, + body.kind === 'placement' ? 'placement' : 'connection', + ), + {map: toCamera, status: 202}, + ); +} diff --git a/src/app/api/cameras/[id]/route.ts b/src/app/api/cameras/[id]/route.ts new file mode 100644 index 0000000..8f592f4 --- /dev/null +++ b/src/app/api/cameras/[id]/route.ts @@ -0,0 +1,36 @@ +import type {NextRequest} from 'next/server'; +import {sitesApi} from '@/services/api/sitesApi'; +import {proxyUpstream} from '@/shared/services/bff'; +import {toCamera} from '@/features/stores/services/mapCamera'; +import type {ApiCameraInput} from '@/services/api/types'; + +export const dynamic = 'force-dynamic'; + +/** + * PATCH /api/cameras/{id} — edit one camera. + * DELETE /api/cameras/{id} — remove it. + * + * PATCH rather than PUT, matching the platform: a form that leaves the password + * blank means "keep the stored one", and a PUT would read that as "clear it". + */ +export async function PATCH( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream( + req, + (token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput), + {map: toCamera}, + ); +} + +export async function DELETE( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + // The platform answers 204 with no body; proxyUpstream sends `data: null` + // rather than an empty object, so the client can tell "done" from "malformed". + return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id)); +} diff --git a/src/app/api/cameras/route.ts b/src/app/api/cameras/route.ts new file mode 100644 index 0000000..c11e846 --- /dev/null +++ b/src/app/api/cameras/route.ts @@ -0,0 +1,39 @@ +import type {NextRequest} from 'next/server'; +import {sitesApi} from '@/services/api/sitesApi'; +import {proxyUpstream, serveUpstream} from '@/shared/services/bff'; +import {toCamera} from '@/features/stores/services/mapCamera'; +import type {ApiCameraInput} from '@/services/api/types'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/cameras?site= — the cameras on one shop, or all of them. + * POST /api/cameras?site= — add one to that shop. + * + * The POST carries the shop in the QUERY rather than the path because the + * platform creates under /api/sites/{site}/cameras while it reads from + * /api/cameras — two different shapes for one resource. Collapsing them here + * keeps that asymmetry out of every component. + */ +export async function GET(req: NextRequest) { + const site = req.nextUrl.searchParams.get('site') ?? undefined; + return serveUpstream(req, (token) => sitesApi.cameras(token, site), (cams) => + cams.map(toCamera), + ); +} + +export async function POST(req: NextRequest) { + const site = req.nextUrl.searchParams.get('site') ?? ''; + if (!site) { + return Response.json( + {error: {code: 'bad_request', message: 'Which shop is this camera in?'}}, + {status: 400}, + ); + } + + return proxyUpstream( + req, + (token, body) => sitesApi.addCamera(token, site, body as ApiCameraInput), + {map: toCamera, status: 201}, + ); +} diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts new file mode 100644 index 0000000..ea95ebc --- /dev/null +++ b/src/app/api/images/route.ts @@ -0,0 +1,67 @@ +import type {NextRequest} from 'next/server'; +import {upstreamRaw} from '@/services/api/apiClient'; +import {withUpstream} from '@/features/auth/services/upstreamSession'; +import {failResponse} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/images?src= — any authenticated picture, proxied. + * + * The same hop as /api/faces and for the same reason: a browser `` cannot + * send an Authorization header, and every platform image URL requires one. + * + * This exists alongside /api/faces rather than replacing it. That route accepts + * exactly one namespace, which was right while faces were the only pictures in + * the product; camera snapshots are not under /api/faces/, so they could not be + * displayed through it at all. /api/faces is left untouched so nothing that + * works today changes, and new callers use this. + * + * ── Why an allowlist of shapes, not a prefix test ──────────────────────── + * An unchecked pass-through is an open proxy that attaches the merchant's + * bearer token to whatever URL an attacker can get into a page. Each pattern + * below is anchored at both ends and permits no slash inside the id segment, so + * `/api/faces/../../admin/clients` cannot masquerade as a face. The `..` test is + * belt and braces on top of that. + * + * Adding a fourth kind of image means adding a line here, deliberately. + */ +const ALLOWED = [ + /^\/api\/faces\/[^/?]+$/, + /^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/, + /^\/api\/visitors\/[^/?]+\/image$/, +]; + +/** Exported so a caller can decide whether to render an at all. */ +export function isProxyableImage(src: string): boolean { + return !src.includes('..') && ALLOWED.some((re) => re.test(src.split('?')[0])); +} + +export async function GET(req: NextRequest) { + const src = req.nextUrl.searchParams.get('src') ?? ''; + + if (!isProxyableImage(src)) { + return Response.json( + {error: {code: 'bad_request', message: 'Not a valid image reference.'}}, + {status: 400}, + ); + } + + try { + const upstream = await withUpstream((token) => + upstreamRaw({path: src, accessToken: token}), + ); + + return new Response(upstream.body, { + status: 200, + headers: { + 'content-type': upstream.headers.get('content-type') ?? 'image/jpeg', + // Private: this is one merchant's shop floor, and a shared cache + // holding it would serve it across tenants. + 'cache-control': 'private, max-age=300', + }, + }); + } catch (err) { + return failResponse(err); + } +} 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..031a500 --- /dev/null +++ b/src/app/api/sites/[site]/enrolment-code/route.ts @@ -0,0 +1,30 @@ +import type {NextRequest} from 'next/server'; +import {sitesApi} from '@/services/api/sitesApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/sites/{site}/enrolment-code — a one-time code that enrols a shop PC. + * + * The code comes back ONCE and is not recoverable: the platform stores a hash, + * exactly as it does for a team invitation. So this is a POST even though it + * reads like a fetch — asking twice mints two codes rather than showing the + * same one, and a GET would invite a browser or a prefetch to do that silently. + */ +export async function POST( + req: NextRequest, + {params}: {params: Promise<{site: string}>}, +) { + const {site} = await params; + return proxyUpstream( + req, + (token, body) => + sitesApi.enrolmentCode( + token, + site, + typeof body.label === 'string' ? body.label : undefined, + ), + {status: 201}, + ); +} 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..01828b7 --- /dev/null +++ b/src/app/api/team/[id]/password/route.ts @@ -0,0 +1,32 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/team/{id}/password — set a new password for somebody. + * + * The response carries the password ONCE. It is bcrypt-hashed on the way in and + * is not recoverable afterwards, so the screen must show it immediately and + * must not stash it anywhere it could be read back. + * + * Omitting `password` has the platform generate a strong one, which is the + * better default — a password an operator invents for somebody else is weak and + * ends up in a chat message. + */ +export async function POST( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream(req, (token, body) => + teamApi.resetPassword( + token, + id, + typeof body.password === 'string' && body.password !== '' + ? body.password + : undefined, + ), + ); +} diff --git a/src/app/api/team/[id]/route.ts b/src/app/api/team/[id]/route.ts new file mode 100644 index 0000000..a663b5d --- /dev/null +++ b/src/app/api/team/[id]/route.ts @@ -0,0 +1,34 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import {proxyUpstream} from '@/shared/services/bff'; +import {toMember} from '@/features/team/services/mapTeam'; +import type {ApiRole} from '@/services/api/types'; + +export const dynamic = 'force-dynamic'; + +/** + * PATCH /api/team/{id} — change somebody's role, or switch their access off. + * + * Deactivating revokes every session that person holds IMMEDIATELY; it is not a + * soft flag that takes effect at next sign-in. The UI is expected to confirm + * before calling this. + * + * The platform answers 409 `last_owner` when this would leave the company with + * no active owner. That travels through `failResponse` with its reason intact, + * so the screen can say which rule was hit rather than "something went wrong". + */ +export async function PATCH( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream( + req, + (token, body) => + teamApi.update(token, id, { + role: typeof body.role === 'string' ? (body.role as ApiRole) : undefined, + active: typeof body.active === 'boolean' ? body.active : undefined, + }), + {map: toMember}, + ); +} 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..a73277d --- /dev/null +++ b/src/app/api/team/invitations/[id]/route.ts @@ -0,0 +1,19 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import {proxyUpstream} from '@/shared/services/bff'; + +export const dynamic = 'force-dynamic'; + +/** + * DELETE /api/team/invitations/{id} — withdraw an invitation. + * + * The code stops working immediately. There is no way to un-withdraw it; a + * change of mind means minting a new one. + */ +export async function DELETE( + req: NextRequest, + {params}: {params: Promise<{id: string}>}, +) { + const {id} = await params; + return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id)); +} diff --git a/src/app/api/team/invitations/route.ts b/src/app/api/team/invitations/route.ts new file mode 100644 index 0000000..b48f402 --- /dev/null +++ b/src/app/api/team/invitations/route.ts @@ -0,0 +1,39 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import {proxyUpstream, serveUpstream} from '@/shared/services/bff'; +import {toInvitation} from '@/features/team/services/mapTeam'; +import type {ApiRole} from '@/services/api/types'; + +export const dynamic = 'force-dynamic'; + +/** + * GET /api/team/invitations — who has been invited and not yet joined. + * POST /api/team/invitations — invite somebody. + * + * The invitation is the PREFERRED way to add a person: they redeem the code and + * choose their own password, so the merchant never handles it. The code comes + * back once on the POST and never again. + */ +export async function GET(req: NextRequest) { + return serveUpstream(req, (token) => teamApi.invitations(token), (list) => + list.map(toInvitation), + ); +} + +export async function POST(req: NextRequest) { + return proxyUpstream( + req, + (token, body) => + teamApi.invite(token, { + email: String(body.email ?? '').trim(), + full_name: + typeof body.full_name === 'string' ? body.full_name : undefined, + role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole, + expires_in_days: + typeof body.expires_in_days === 'number' + ? body.expires_in_days + : undefined, + }), + {map: toInvitation, status: 201}, + ); +} diff --git a/src/app/api/team/members/route.ts b/src/app/api/team/members/route.ts new file mode 100644 index 0000000..9acab26 --- /dev/null +++ b/src/app/api/team/members/route.ts @@ -0,0 +1,34 @@ +import type {NextRequest} from 'next/server'; +import {teamApi} from '@/services/api/teamApi'; +import {proxyUpstream} from '@/shared/services/bff'; +import type {ApiRole} from '@/services/api/types'; + +export const dynamic = 'force-dynamic'; + +/** + * POST /api/team/members — create a login directly and hand the password over. + * + * The other way in is an invitation, where the person chooses their own + * password and the merchant never sees it. That is the better path and the UI + * offers it first; this exists for somebody standing at the counter with no + * phone to redeem a code on. + * + * Answers 201 with the member AND the generated password, shown once. + */ +export async function POST(req: NextRequest) { + return proxyUpstream( + req, + (token, body) => + teamApi.createMember(token, { + email: String(body.email ?? '').trim(), + full_name: + typeof body.full_name === 'string' ? body.full_name : undefined, + role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole, + password: + typeof body.password === 'string' && body.password !== '' + ? body.password + : undefined, + }), + {status: 201}, + ); +} diff --git a/src/app/api/team/route.ts b/src/app/api/team/route.ts index 85bf070..90c8728 100644 --- a/src/app/api/team/route.ts +++ b/src/app/api/team/route.ts @@ -1,9 +1,7 @@ import type {NextRequest} from 'next/server'; import {teamApi} from '@/services/api/teamApi'; import {serveUpstream} from '@/shared/services/bff'; -import type {ApiTeamMember} from '@/services/api/types'; -import type {UserRole} from '@/features/auth/types/auth'; -import type {TeamMember} from '@/features/team/types/team'; +import {toMember} from '@/features/team/services/mapTeam'; export const dynamic = 'force-dynamic'; @@ -20,22 +18,6 @@ export const dynamic = 'force-dynamic'; * row, while `active`, `last_login_at` and `created_at` were discarded. The * one field the team screen needs — who still has access — never arrived. */ -function toMember(m: ApiTeamMember): TeamMember { - return { - id: m.id, - // Falls back to the address rather than rendering a blank cell: somebody - // invited but not yet named still has to be identifiable. - name: m.full_name || m.email, - email: m.email, - role: m.role as UserRole, - active: m.active, - // Null rather than '' — "has never signed in" and "signed in at an unknown - // time" are different facts, and the screen says so. - lastLoginAt: m.last_login_at || null, - createdAt: m.created_at, - }; -} - export async function GET(req: NextRequest) { return serveUpstream(req, (token) => teamApi.list(token), (members) => members.map(toMember), diff --git a/src/features/settings/components/AccountCard.tsx b/src/features/settings/components/AccountCard.tsx new file mode 100644 index 0000000..d5c95f0 --- /dev/null +++ b/src/features/settings/components/AccountCard.tsx @@ -0,0 +1,58 @@ +'use client'; + +import {Card} from '@astryxdesign/core/Card'; +import {VStack, HStack} from '@astryxdesign/core/Layout'; +import {Text} from '@astryxdesign/core/Text'; +import {Badge} from '@astryxdesign/core/Badge'; +import {Avatar} from '@astryxdesign/core/Avatar'; +import {SectionHeader} from '@/shared/components/patterns/SectionHeader'; +import {useSession} from '@/features/auth/providers/SessionProvider'; + +/** + * Who you are signed in as. + * + * Read from the SESSION rather than fetched: it is the same object the proxy + * verified and the server rendered with, so this cannot disagree with what the + * rest of the console believes about the current user, and it costs no request. + * + * A plain Card rather than PanelCard because there is nothing async here — + * PanelCard exists to wrap a Resource, and passing it a fake one to reuse the + * header would be the tail wagging the dog. + * + * There is no edit: a merchant cannot change their own role, and the platform + * serves no self-service profile endpoint. + */ +export function AccountCard() { + const {user} = useSession(); + + if (!user) return null; + + return ( + + + + + + + + {user.name} + + + {user.email} + + + + {user.organisation ? ( + + {user.organisation} + + ) : null} + + + + ); +} diff --git a/src/features/settings/components/SecurityManager.tsx b/src/features/settings/components/SecurityManager.tsx index 2e9d13d..57cf01c 100644 --- a/src/features/settings/components/SecurityManager.tsx +++ b/src/features/settings/components/SecurityManager.tsx @@ -11,7 +11,12 @@ import {Button} from '@astryxdesign/core/Button'; import {Switch} from '@astryxdesign/core/Switch'; import {TextInput} from '@astryxdesign/core/TextInput'; import {useToast} from '@astryxdesign/core/Toast'; -import {StaticPanel} from '@/shared/components/patterns/PanelCard'; +import {PanelCard, StaticPanel} from '@/shared/components/patterns/PanelCard'; +import {SkeletonRows} from '@/shared/components/patterns/LoadingState'; +import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel'; +import {useResource} from '@/shared/hooks/useResource'; +import {securityRepository} from '@/features/settings/repositories/securityRepository'; +import type {DeviceSession} from '@/features/settings/services/mapSession'; export interface ActiveSessionItem extends Record { id: string; @@ -31,33 +36,6 @@ export interface LoginAuditItem extends Record { timestamp: string; } -const INITIAL_SESSIONS: ActiveSessionItem[] = [ - { - id: 'sess-1', - device: 'macOS Chrome 126', - location: 'Bengaluru, India', - ip: '103.15.24.81', - lastActive: 'Active Now', - isCurrent: true, - }, - { - id: 'sess-2', - device: 'iOS Safari 17', - location: 'Bengaluru, India', - ip: '49.207.210.12', - lastActive: '2 hours ago', - isCurrent: false, - }, - { - id: 'sess-3', - device: 'Windows Edge 125', - location: 'Bengaluru, India', - ip: '106.51.72.19', - lastActive: 'Yesterday, 18:40', - isCurrent: false, - }, -]; - const AUDIT_LOGS: LoginAuditItem[] = [ { id: 'aud-1', @@ -88,7 +66,19 @@ const AUDIT_LOGS: LoginAuditItem[] = [ export function SecurityManager() { const toast = useToast(); const [twoFactor, setTwoFactor] = useState(true); - const [sessions, setSessions] = useState(INITIAL_SESSIONS); + /** + * Real devices, from GET /api/auth/sessions. + * + * This was three invented rows in useState, with a `location` and an `ip` the + * platform does not return at all — a merchant checking for an unfamiliar + * sign-in was reading fiction. Revoking one spliced the local array and the + * row came back on refresh. + * + * Everything else in this file — the 2FA switch, the password form and the + * audit trail — is still local-only. Those have no endpoint on the platform + * and are deliberately left as they were rather than deleted. + */ + const sessions = useResource(securityRepository.sessions()); const [currentPass, setCurrentPass] = useState(''); const [newPass, setNewPass] = useState(''); const [confirmPass, setConfirmPass] = useState(''); @@ -113,42 +103,51 @@ export function SecurityManager() { }, 400); }; - const handleRevokeSession = (id: string) => { - setSessions((prev) => prev.filter((s) => s.id !== id)); - toast({body: 'Session revoked successfully'}); + const handleRevokeSession = async (id: string) => { + const res = await securityRepository.revoke(id); + if (!res.ok) { + toast({type: 'error', body: res.message ?? 'Could not sign that device out.'}); + return; + } + // Re-read rather than splice: the platform decides what is still live, and + // a locally filtered array is a guess that survives until the next refresh. + sessions.refetch(); + toast({body: 'That device has been signed out.'}); }; - const handleRevokeAllOther = () => { - setSessions((prev) => prev.filter((s) => s.isCurrent)); - toast({body: 'All other active sessions have been terminated'}); + const handleRevokeAllOther = async () => { + const res = await securityRepository.revokeOthers(); + if (!res.ok) { + toast({type: 'error', body: res.message ?? 'Could not sign the other devices out.'}); + return; + } + sessions.refetch(); + toast({body: 'Every other device has been signed out.'}); }; - const sessionColumns: TableColumn[] = [ + const sessionColumns: TableColumn>[] = [ { key: 'device', - header: 'Device / Browser', + header: 'Device', width: proportional(2), renderCell: (row) => ( - - - - {row.device} - - {row.isCurrent ? : null} - - - {row.location} • {row.ip} + + + {row.device} - + {row.isCurrent ? ( + + ) : null} + ), }, { - key: 'lastActive', - header: 'Last Active', + key: 'lastUsedAt', + header: 'Last used', width: proportional(1.2), renderCell: (row) => ( - {row.lastActive} + {new Date(row.lastUsedAt).toLocaleString()} ), }, @@ -157,14 +156,17 @@ export function SecurityManager() { header: 'Action', align: 'center', width: pixel(120), + // The current session CAN be revoked upstream, but doing it from this + // table signs you out mid-page with no warning. Signing out is what the + // account menu is for. renderCell: (row) => row.isCurrent ? null : (