login issue
This commit is contained in:
20
src/app/api/auth/sessions/[id]/route.ts
Normal file
20
src/app/api/auth/sessions/[id]/route.ts
Normal 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));
|
||||
}
|
||||
16
src/app/api/auth/sessions/revoke-others/route.ts
Normal file
16
src/app/api/auth/sessions/revoke-others/route.ts
Normal 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));
|
||||
}
|
||||
20
src/app/api/auth/sessions/route.ts
Normal file
20
src/app/api/auth/sessions/route.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
34
src/app/api/cameras/[id]/check/route.ts
Normal file
34
src/app/api/cameras/[id]/check/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
36
src/app/api/cameras/[id]/route.ts
Normal file
36
src/app/api/cameras/[id]/route.ts
Normal 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));
|
||||
}
|
||||
39
src/app/api/cameras/route.ts
Normal file
39
src/app/api/cameras/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
67
src/app/api/images/route.ts
Normal file
67
src/app/api/images/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
30
src/app/api/sites/[site]/enrolment-code/route.ts
Normal file
30
src/app/api/sites/[site]/enrolment-code/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
32
src/app/api/team/[id]/password/route.ts
Normal file
32
src/app/api/team/[id]/password/route.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
34
src/app/api/team/[id]/route.ts
Normal file
34
src/app/api/team/[id]/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
19
src/app/api/team/invitations/[id]/route.ts
Normal file
19
src/app/api/team/invitations/[id]/route.ts
Normal 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));
|
||||
}
|
||||
39
src/app/api/team/invitations/route.ts
Normal file
39
src/app/api/team/invitations/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
34
src/app/api/team/members/route.ts
Normal file
34
src/app/api/team/members/route.ts
Normal 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},
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user