login issue

This commit is contained in:
2026-09-19 13:12:19 +05:30
parent dccb1beda5
commit ea3dbbeaf3
41 changed files with 2342 additions and 163 deletions

View File

@@ -0,0 +1,20 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* DELETE /api/auth/sessions/{id} — sign one device out.
*
* Revoking the CURRENT session is allowed and signs this browser out — which is
* a legitimate thing to want and a surprising thing to do by accident, so the
* screen warns before calling it rather than this route refusing.
*/
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token) => authApi.revokeSession(token, id));
}

View File

@@ -0,0 +1,16 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* POST /api/auth/sessions/revoke-others — sign out everywhere else.
*
* Keeps the caller's own session alive by design, so somebody who suspects a
* leak can clear every other device without locking themselves out of the
* screen they are doing it from.
*/
export async function POST(req: NextRequest) {
return proxyUpstream(req, (token) => authApi.revokeOtherSessions(token));
}

View File

@@ -0,0 +1,20 @@
import type {NextRequest} from 'next/server';
import {authApi} from '@/services/api/authApi';
import {serveUpstream} from '@/shared/services/bff';
import {toDeviceSession} from '@/features/settings/services/mapSession';
export const dynamic = 'force-dynamic';
/**
* GET /api/auth/sessions — every device currently signed in as this person.
*
* `current: true` marks the one making this request. It is the reason this list
* is worth showing at all: a session the user does not recognise is how they
* find out a password has leaked, and they need to be able to tell it apart
* from the browser they are reading the page in.
*/
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => authApi.sessions(token), (list) =>
list.map(toDeviceSession),
);
}

View File

@@ -0,0 +1,34 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {proxyUpstream} from '@/shared/services/bff';
import {toCamera} from '@/features/stores/services/mapCamera';
export const dynamic = 'force-dynamic';
/**
* POST /api/cameras/{id}/check — ask the shop PC to prove this camera works.
*
* Two kinds: `connection` (can it be reached at all) and `placement` (is the
* view usable for recognition). Anything else the platform rejects, so the
* union is narrowed here rather than passed through as a free string.
*
* The platform answers 202 and the camera it returns still carries the PREVIOUS
* check — the edge has not run the new one yet. The caller re-reads; it must
* not render this response as the verdict.
*/
export async function POST(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(
req,
(token, body) =>
sitesApi.checkCamera(
token,
id,
body.kind === 'placement' ? 'placement' : 'connection',
),
{map: toCamera, status: 202},
);
}

View File

@@ -0,0 +1,36 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {proxyUpstream} from '@/shared/services/bff';
import {toCamera} from '@/features/stores/services/mapCamera';
import type {ApiCameraInput} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* PATCH /api/cameras/{id} — edit one camera.
* DELETE /api/cameras/{id} — remove it.
*
* PATCH rather than PUT, matching the platform: a form that leaves the password
* blank means "keep the stored one", and a PUT would read that as "clear it".
*/
export async function PATCH(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(
req,
(token, body) => sitesApi.updateCamera(token, id, body as ApiCameraInput),
{map: toCamera},
);
}
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
// The platform answers 204 with no body; proxyUpstream sends `data: null`
// rather than an empty object, so the client can tell "done" from "malformed".
return proxyUpstream(req, (token) => sitesApi.deleteCamera(token, id));
}

View File

@@ -0,0 +1,39 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
import {toCamera} from '@/features/stores/services/mapCamera';
import type {ApiCameraInput} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* GET /api/cameras?site=<slug> — the cameras on one shop, or all of them.
* POST /api/cameras?site=<slug> — add one to that shop.
*
* The POST carries the shop in the QUERY rather than the path because the
* platform creates under /api/sites/{site}/cameras while it reads from
* /api/cameras — two different shapes for one resource. Collapsing them here
* keeps that asymmetry out of every component.
*/
export async function GET(req: NextRequest) {
const site = req.nextUrl.searchParams.get('site') ?? undefined;
return serveUpstream(req, (token) => sitesApi.cameras(token, site), (cams) =>
cams.map(toCamera),
);
}
export async function POST(req: NextRequest) {
const site = req.nextUrl.searchParams.get('site') ?? '';
if (!site) {
return Response.json(
{error: {code: 'bad_request', message: 'Which shop is this camera in?'}},
{status: 400},
);
}
return proxyUpstream(
req,
(token, body) => sitesApi.addCamera(token, site, body as ApiCameraInput),
{map: toCamera, status: 201},
);
}

View File

@@ -0,0 +1,67 @@
import type {NextRequest} from 'next/server';
import {upstreamRaw} from '@/services/api/apiClient';
import {withUpstream} from '@/features/auth/services/upstreamSession';
import {failResponse} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* GET /api/images?src=<platform image path> — any authenticated picture, proxied.
*
* The same hop as /api/faces and for the same reason: a browser `<img>` cannot
* send an Authorization header, and every platform image URL requires one.
*
* This exists alongside /api/faces rather than replacing it. That route accepts
* exactly one namespace, which was right while faces were the only pictures in
* the product; camera snapshots are not under /api/faces/, so they could not be
* displayed through it at all. /api/faces is left untouched so nothing that
* works today changes, and new callers use this.
*
* ── Why an allowlist of shapes, not a prefix test ────────────────────────
* An unchecked pass-through is an open proxy that attaches the merchant's
* bearer token to whatever URL an attacker can get into a page. Each pattern
* below is anchored at both ends and permits no slash inside the id segment, so
* `/api/faces/../../admin/clients` cannot masquerade as a face. The `..` test is
* belt and braces on top of that.
*
* Adding a fourth kind of image means adding a line here, deliberately.
*/
const ALLOWED = [
/^\/api\/faces\/[^/?]+$/,
/^\/api\/cameras\/[^/?]+\/snapshot\.jpg$/,
/^\/api\/visitors\/[^/?]+\/image$/,
];
/** Exported so a caller can decide whether to render an <img> at all. */
export function isProxyableImage(src: string): boolean {
return !src.includes('..') && ALLOWED.some((re) => re.test(src.split('?')[0]));
}
export async function GET(req: NextRequest) {
const src = req.nextUrl.searchParams.get('src') ?? '';
if (!isProxyableImage(src)) {
return Response.json(
{error: {code: 'bad_request', message: 'Not a valid image reference.'}},
{status: 400},
);
}
try {
const upstream = await withUpstream((token) =>
upstreamRaw({path: src, accessToken: token}),
);
return new Response(upstream.body, {
status: 200,
headers: {
'content-type': upstream.headers.get('content-type') ?? 'image/jpeg',
// Private: this is one merchant's shop floor, and a shared cache
// holding it would serve it across tenants.
'cache-control': 'private, max-age=300',
},
});
} catch (err) {
return failResponse(err);
}
}

View File

@@ -0,0 +1,30 @@
import type {NextRequest} from 'next/server';
import {sitesApi} from '@/services/api/sitesApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* POST /api/sites/{site}/enrolment-code — a one-time code that enrols a shop PC.
*
* The code comes back ONCE and is not recoverable: the platform stores a hash,
* exactly as it does for a team invitation. So this is a POST even though it
* reads like a fetch — asking twice mints two codes rather than showing the
* same one, and a GET would invite a browser or a prefetch to do that silently.
*/
export async function POST(
req: NextRequest,
{params}: {params: Promise<{site: string}>},
) {
const {site} = await params;
return proxyUpstream(
req,
(token, body) =>
sitesApi.enrolmentCode(
token,
site,
typeof body.label === 'string' ? body.label : undefined,
),
{status: 201},
);
}

View File

@@ -0,0 +1,32 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* POST /api/team/{id}/password — set a new password for somebody.
*
* The response carries the password ONCE. It is bcrypt-hashed on the way in and
* is not recoverable afterwards, so the screen must show it immediately and
* must not stash it anywhere it could be read back.
*
* Omitting `password` has the platform generate a strong one, which is the
* better default — a password an operator invents for somebody else is weak and
* ends up in a chat message.
*/
export async function POST(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token, body) =>
teamApi.resetPassword(
token,
id,
typeof body.password === 'string' && body.password !== ''
? body.password
: undefined,
),
);
}

View File

@@ -0,0 +1,34 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {proxyUpstream} from '@/shared/services/bff';
import {toMember} from '@/features/team/services/mapTeam';
import type {ApiRole} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* PATCH /api/team/{id} — change somebody's role, or switch their access off.
*
* Deactivating revokes every session that person holds IMMEDIATELY; it is not a
* soft flag that takes effect at next sign-in. The UI is expected to confirm
* before calling this.
*
* The platform answers 409 `last_owner` when this would leave the company with
* no active owner. That travels through `failResponse` with its reason intact,
* so the screen can say which rule was hit rather than "something went wrong".
*/
export async function PATCH(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(
req,
(token, body) =>
teamApi.update(token, id, {
role: typeof body.role === 'string' ? (body.role as ApiRole) : undefined,
active: typeof body.active === 'boolean' ? body.active : undefined,
}),
{map: toMember},
);
}

View File

@@ -0,0 +1,19 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {proxyUpstream} from '@/shared/services/bff';
export const dynamic = 'force-dynamic';
/**
* DELETE /api/team/invitations/{id} — withdraw an invitation.
*
* The code stops working immediately. There is no way to un-withdraw it; a
* change of mind means minting a new one.
*/
export async function DELETE(
req: NextRequest,
{params}: {params: Promise<{id: string}>},
) {
const {id} = await params;
return proxyUpstream(req, (token) => teamApi.revokeInvitation(token, id));
}

View File

@@ -0,0 +1,39 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
import {toInvitation} from '@/features/team/services/mapTeam';
import type {ApiRole} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* GET /api/team/invitations — who has been invited and not yet joined.
* POST /api/team/invitations — invite somebody.
*
* The invitation is the PREFERRED way to add a person: they redeem the code and
* choose their own password, so the merchant never handles it. The code comes
* back once on the POST and never again.
*/
export async function GET(req: NextRequest) {
return serveUpstream(req, (token) => teamApi.invitations(token), (list) =>
list.map(toInvitation),
);
}
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) =>
teamApi.invite(token, {
email: String(body.email ?? '').trim(),
full_name:
typeof body.full_name === 'string' ? body.full_name : undefined,
role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
expires_in_days:
typeof body.expires_in_days === 'number'
? body.expires_in_days
: undefined,
}),
{map: toInvitation, status: 201},
);
}

View File

@@ -0,0 +1,34 @@
import type {NextRequest} from 'next/server';
import {teamApi} from '@/services/api/teamApi';
import {proxyUpstream} from '@/shared/services/bff';
import type {ApiRole} from '@/services/api/types';
export const dynamic = 'force-dynamic';
/**
* POST /api/team/members — create a login directly and hand the password over.
*
* The other way in is an invitation, where the person chooses their own
* password and the merchant never sees it. That is the better path and the UI
* offers it first; this exists for somebody standing at the counter with no
* phone to redeem a code on.
*
* Answers 201 with the member AND the generated password, shown once.
*/
export async function POST(req: NextRequest) {
return proxyUpstream(
req,
(token, body) =>
teamApi.createMember(token, {
email: String(body.email ?? '').trim(),
full_name:
typeof body.full_name === 'string' ? body.full_name : undefined,
role: (typeof body.role === 'string' ? body.role : 'staff') as ApiRole,
password:
typeof body.password === 'string' && body.password !== ''
? body.password
: undefined,
}),
{status: 201},
);
}

View File

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