admin login issue
This commit is contained in:
36
src/app/api/admin/clients/[id]/owner-password/route.ts
Normal file
36
src/app/api/admin/clients/[id]/owner-password/route.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/admin/clients/{id}/owner-password — reset an owner's password.
|
||||
*
|
||||
* The support case: the owner has locked themselves out and there is nobody
|
||||
* above them in the company to reset it. The new password is GENERATED, never
|
||||
* chosen, and every session that owner held is revoked.
|
||||
*
|
||||
* `email` picks the owner when the company has more than one; with exactly one
|
||||
* it may be omitted, and the UI omits it first. With several owners and no
|
||||
* address the platform answers 400 listing them — that message travels through
|
||||
* `failResponse` intact, which is what lets the dialog ask "which owner?"
|
||||
* without this console needing its own endpoint to enumerate them.
|
||||
*
|
||||
* ── The response body is a credential ────────────────────────────────────
|
||||
* It is shown once and cannot be fetched again. Nothing on this path may cache
|
||||
* it: `proxyUpstream` sets `cache-control: no-store` on every response it
|
||||
* writes, which is what keeps it out of a CDN, a browser disk cache and the
|
||||
* back button. It is never logged here, and never reaches a URL — it travels in
|
||||
* a POST response body and nowhere else.
|
||||
*/
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, body) => {
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : '';
|
||||
return adminApi.resetOwnerPassword(token, id, email || undefined);
|
||||
});
|
||||
}
|
||||
77
src/app/api/admin/clients/[id]/route.ts
Normal file
77
src/app/api/admin/clients/[id]/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream} from '@/shared/services/bff';
|
||||
import {toCompany} from '@/features/admin/services/mapCompany';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/clients/{id} — suspend or reinstate a company.
|
||||
*
|
||||
* Suspension is complete the moment this returns: the company's users cannot
|
||||
* sign in, every session they hold is revoked in the same transaction, and
|
||||
* visits from its shop PCs are dropped at ingest. Reinstating does not restore
|
||||
* sessions — people sign in again.
|
||||
*
|
||||
* The count of revoked sessions is surfaced rather than swallowed. "Suspended"
|
||||
* alone leaves an operator wondering whether somebody is still signed in on a
|
||||
* shop PC; "suspended, 3 sessions ended" answers it.
|
||||
*
|
||||
* `active` is read strictly as a boolean. A missing or non-boolean value is
|
||||
* forwarded as-is so the platform's own 400 (`"active" is required: true to
|
||||
* reinstate, false to suspend`) is what the operator reads, rather than a
|
||||
* second, differently-worded validation invented here.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => adminApi.setClientActive(token, id, body.active as boolean),
|
||||
{
|
||||
map: (res) => ({
|
||||
company: toCompany(res.client),
|
||||
sessionsRevoked: res.sessions_revoked,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/clients/{id} — permanent, and the data is biometric.
|
||||
*
|
||||
* Two conditions, both the PLATFORM's and neither enforced here: the company
|
||||
* must already be suspended (`409 still_active` otherwise) and the body must
|
||||
* repeat its slug. The dialog mirrors them so nobody is surprised, but this
|
||||
* route forwards whatever it is given — an active company is sent and the real
|
||||
* 409 comes back. A console that pre-empted the check would eventually disagree
|
||||
* with the server about what is deletable, and the disagreement would surface
|
||||
* as a delete that "worked" in the UI and did not happen.
|
||||
*
|
||||
* Upstream order matters if this fails: face images go from object storage
|
||||
* first (`502 storage_error` leaves everything else untouched), then the shop
|
||||
* PCs' broker logins, then every row by cascade.
|
||||
*
|
||||
* ── Why `confirm` arrives as a query parameter ───────────────────────────
|
||||
* The platform wants it in the body, and this route puts it there. It cannot
|
||||
* arrive that way, though: `proxyUpstream` does not read a body on DELETE (a
|
||||
* DELETE legitimately has none), and the browser-side `deleteJson` cannot send
|
||||
* one either. So it travels as a query parameter on THIS origin's request and
|
||||
* is moved into the body on the way upstream.
|
||||
*
|
||||
* Safe to put in a URL, unlike anything else on this screen: a slug is the
|
||||
* company's public identifier, already visible in the list and in every broker
|
||||
* topic. It is a confirmation, not a credential — it proves the operator typed
|
||||
* the right name, and it protects nothing on its own.
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{params}: {params: Promise<{id: string}>},
|
||||
) {
|
||||
const {id} = await params;
|
||||
return proxyUpstream(req, (token, _body, query) =>
|
||||
adminApi.deleteClient(token, id, query.get('confirm') ?? ''),
|
||||
);
|
||||
}
|
||||
62
src/app/api/admin/clients/route.ts
Normal file
62
src/app/api/admin/clients/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {NextRequest} from 'next/server';
|
||||
import {adminApi} from '@/services/api/adminApi';
|
||||
import {proxyUpstream, serveUpstream} from '@/shared/services/bff';
|
||||
import {toCompany} from '@/features/admin/services/mapCompany';
|
||||
import type {ApiNewClientInput} from '@/services/api/types';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* The companies on this platform.
|
||||
*
|
||||
* ── Why this proxy exists at all ─────────────────────────────────────────
|
||||
* The browser could not call `mcp.loyaly.ai/api/admin/clients` directly even if
|
||||
* we wanted it to: the platform sends no CORS headers, so a cross-origin fetch
|
||||
* from this console is blocked before it leaves. Routing through the BFF is not
|
||||
* a workaround for that — it is the reason the platform can afford to send no
|
||||
* CORS headers. The access token stays in an httpOnly cookie this page's
|
||||
* JavaScript cannot read, so an XSS on this origin cannot steal a platform
|
||||
* session.
|
||||
*
|
||||
* Authorisation is NOT re-implemented here. `withUpstream` attaches whatever
|
||||
* token the session holds and the platform decides: `adminOnly` answers 404 to
|
||||
* anyone who is not a platform operator. A merchant who reached this route
|
||||
* would get that 404 translated into `not_found`, not a list of tenants.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
return serveUpstream(
|
||||
req,
|
||||
(token) => adminApi.listClients(token),
|
||||
(rows) => rows.map(toCompany),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/clients — create a company and its owner, in one transaction.
|
||||
*
|
||||
* Answers **201**, and the body carries the owner's generated password. That is
|
||||
* the only time it exists in readable form: it is bcrypt-hashed on the way in
|
||||
* and cannot be fetched again.
|
||||
*
|
||||
* `password` is never forwarded from the client, even if one were sent. Empty
|
||||
* means "generate one", which is the better default — an operator typing a
|
||||
* password for somebody else invents a weak one and then sends it over chat.
|
||||
* `slug` is forwarded only when non-empty; the platform derives it from the
|
||||
* name otherwise, and it can never be changed afterwards.
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
return proxyUpstream(
|
||||
req,
|
||||
(token, body) => {
|
||||
const slug = typeof body.slug === 'string' ? body.slug.trim() : '';
|
||||
const input: ApiNewClientInput = {
|
||||
company_name: String(body.company_name ?? '').trim(),
|
||||
owner_email: String(body.owner_email ?? '').trim(),
|
||||
owner_name: String(body.owner_name ?? '').trim(),
|
||||
};
|
||||
if (slug) input.slug = slug;
|
||||
return adminApi.createClient(token, input);
|
||||
},
|
||||
{status: 201},
|
||||
);
|
||||
}
|
||||
@@ -7,17 +7,25 @@ import {
|
||||
LOGIN_ERROR_PARAM,
|
||||
type LoginErrorCode,
|
||||
} from '@/features/auth/services/loginErrorCodes';
|
||||
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
|
||||
import {resolveRedirectTargetFor} from '@/features/auth/services/redirectTarget';
|
||||
import {
|
||||
REMEMBERED_MAX_AGE_SECONDS,
|
||||
SESSION_COOKIE,
|
||||
SESSION_MAX_AGE_SECONDS,
|
||||
createSessionToken,
|
||||
sessionCookieOptions,
|
||||
} from '@/features/auth/services/sessionToken';
|
||||
import {
|
||||
TAB_POINTER_COOKIE,
|
||||
sessionCookieFor,
|
||||
tabPointerOptions,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
import {
|
||||
newTabId,
|
||||
resolveTabId,
|
||||
} from '@/features/auth/services/tabScopeRequest';
|
||||
import {storeTokens} from '@/features/auth/services/upstreamSession';
|
||||
import {isPlatformAdmin, toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import {destinationForRole} from '@/features/auth/services/roleDestination';
|
||||
import {toAuthUser} from '@/features/auth/services/userMapper';
|
||||
import {destinationForUser} from '@/features/auth/services/roleDestination';
|
||||
import type {AuthSession} from '@/features/auth/types/auth';
|
||||
import type {ApiSuccess} from '@/shared/types/api';
|
||||
|
||||
@@ -171,50 +179,40 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A platform admin authenticates correctly and still gets no session HERE.
|
||||
* A platform admin gets a session here, exactly like a merchant does.
|
||||
*
|
||||
* `isPlatformAdmin` is role AND empty client_id together, which is the
|
||||
* pairing the platform documents — checking the role alone would misread a
|
||||
* tenant-scoped account that happens to carry an admin-shaped role.
|
||||
* ── What this used to do, and why it no longer does ──────────────────────
|
||||
* This route used to detect a platform admin, revoke the upstream session it
|
||||
* had just created, and answer 403 `platform_account`. The reasoning was
|
||||
* sound at the time: every screen in this console was tenant-scoped, an admin
|
||||
* has no tenant, and a cookie would have bought that person a dashboard of
|
||||
* 500s. Refusing the session was the honest answer.
|
||||
*
|
||||
* Every endpoint behind this console is tenant-scoped, and an admin has no
|
||||
* tenant. Measured on the live local platform with a real admin token:
|
||||
* /api/sites 500, /api/visits 500, /api/visitors 500, /api/team 403 "This
|
||||
* account does not belong to a company." Minting a cookie here would buy
|
||||
* that person nothing but a dashboard of server errors, so the session is
|
||||
* refused at the only place that can refuse it — before the cookie is set.
|
||||
* There is now somewhere for them to go — /admin, reading the platform's own
|
||||
* `/api/admin/*` surface, which is the one part of the platform that is NOT
|
||||
* tenant-scoped. So the refusal is gone, and the ONLY thing that differs for
|
||||
* an admin is the destination. Nothing about how the session is minted
|
||||
* changes: same `storeTokens`, same `createSessionToken`, same cookies, same
|
||||
* lifetimes. There is no second authentication path in this app.
|
||||
*
|
||||
* This is not a client-side authorisation check standing in for a server
|
||||
* one. It runs on the server, it mirrors the platform's own rule rather
|
||||
* than inventing a second one, and the platform still enforces its own on
|
||||
* every request regardless of what this route decides.
|
||||
* ── What is emphatically NOT delegated to the client ─────────────────────
|
||||
* `isPlatformAdmin` is role AND empty `client_id` together — the pairing the
|
||||
* platform documents. Checking the role alone would promote a tenant-scoped
|
||||
* account that happens to carry an admin-shaped role, and that account is an
|
||||
* ordinary merchant user. The answer is computed here, from a field the
|
||||
* browser never receives, and signed into the cookie (see userMapper and
|
||||
* sessionToken), so the client cannot assert it.
|
||||
*
|
||||
* The upstream session created moments ago by `authApi.login` is revoked
|
||||
* rather than abandoned: it is a live refresh token nobody will ever use,
|
||||
* and leaving it to expire on its own is a credential left lying around.
|
||||
* Best-effort — a failure to revoke must not turn into a 500 on a sign-in
|
||||
* that this console was going to decline anyway.
|
||||
* And it decides ROUTING, never authority. Every admin read this console
|
||||
* makes is authorised by the platform's own `adminOnly`, which answers 404 to
|
||||
* anyone who is not a platform operator regardless of what this cookie says.
|
||||
*
|
||||
* ── Note what is absent: no `authApi.logout` call ────────────────────────
|
||||
* Revoking was correct while no session followed — an unused refresh token is
|
||||
* a credential left lying around. Now the session DOES follow, and that same
|
||||
* token is what `storeTokens` seals for every subsequent request. Revoking it
|
||||
* here would sign the admin straight back out.
|
||||
*/
|
||||
if (isPlatformAdmin(bundle.user)) {
|
||||
try {
|
||||
await authApi.logout(bundle.access_token);
|
||||
} catch {
|
||||
/* deliberately ignored — see above */
|
||||
}
|
||||
|
||||
const code: LoginErrorCode = 'platform_account';
|
||||
if (isForm) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?${LOGIN_ERROR_PARAM}=${code}`, req.url),
|
||||
303,
|
||||
);
|
||||
}
|
||||
return failJson(
|
||||
code,
|
||||
'This console is for merchant accounts. Platform administrators sign in on the Loyaly platform console.',
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Minting the local session, which is where AUTH_SECRET is first read.
|
||||
@@ -259,9 +257,20 @@ export async function POST(req: NextRequest) {
|
||||
: SESSION_MAX_AGE_SECONDS;
|
||||
const cookieMaxAge = rememberMe ? REMEMBERED_MAX_AGE_SECONDS : undefined;
|
||||
|
||||
/**
|
||||
* Which tab this session belongs to.
|
||||
*
|
||||
* The tab sends its own id in `X-Tab-Id`; signing in again in the same tab
|
||||
* REPLACES that tab's session and leaves every other tab alone. When there is
|
||||
* no id — the no-JavaScript form POST, which cannot set a header — one is
|
||||
* minted here and handed back in the pointer cookie, so that path ends up
|
||||
* with a properly scoped session too rather than a special unscoped one.
|
||||
*/
|
||||
const tabId = (await resolveTabId()) ?? newTabId();
|
||||
|
||||
let sessionCookie: string;
|
||||
try {
|
||||
await storeTokens(bundle, cookieMaxAge);
|
||||
await storeTokens(bundle, cookieMaxAge, tabId);
|
||||
sessionCookie = createSessionToken(
|
||||
{
|
||||
sub: user.id,
|
||||
@@ -269,6 +278,12 @@ export async function POST(req: NextRequest) {
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
organisation: user.organisation,
|
||||
// Signed into the cookie so `proxy.ts` can decide which console to
|
||||
// serve without a round trip, and so the browser cannot edit the
|
||||
// answer: a tampered payload fails verifySessionToken and reads as no
|
||||
// session at all. Still routing, never authority — the platform
|
||||
// re-checks on every /api/admin/* call.
|
||||
isPlatformAdmin: user.isPlatformAdmin,
|
||||
},
|
||||
tokenLifetime,
|
||||
);
|
||||
@@ -299,12 +314,15 @@ export async function POST(req: NextRequest) {
|
||||
const session: AuthSession = {user, expiresAt: bundle.expires_at};
|
||||
|
||||
// The no-JavaScript path lands in the SAME place the hydrated one does: an
|
||||
// explicit `next` wins, otherwise the role the platform just returned decides.
|
||||
// Both paths read one map, so a browser with JS disabled cannot end up
|
||||
// somewhere else.
|
||||
const landing = next
|
||||
? resolveRedirectTarget(next)
|
||||
: destinationForRole(user.role);
|
||||
// explicit `next` wins, otherwise what the platform just returned decides —
|
||||
// /admin for a platform operator, the role's route for a merchant. Both paths
|
||||
// read one function, so a browser with JS disabled cannot end up somewhere
|
||||
// else, and neither can walk into the wrong console.
|
||||
const landing = resolveRedirectTargetFor(
|
||||
next,
|
||||
user.isPlatformAdmin,
|
||||
destinationForUser(user),
|
||||
);
|
||||
|
||||
const res = isForm
|
||||
? NextResponse.redirect(new URL(landing, req.url), 303)
|
||||
@@ -313,6 +331,14 @@ export async function POST(req: NextRequest) {
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
|
||||
res.cookies.set(SESSION_COOKIE, sessionCookie, sessionCookieOptions(cookieMaxAge));
|
||||
res.cookies.set(
|
||||
sessionCookieFor(tabId),
|
||||
sessionCookie,
|
||||
sessionCookieOptions(cookieMaxAge),
|
||||
);
|
||||
// Points server rendering and the proxy at the tab that just signed in. The
|
||||
// tab rewrites this on focus, so it follows whichever tab is in use; it is a
|
||||
// hint for the first paint, never the authority on who anyone is.
|
||||
res.cookies.set(TAB_POINTER_COOKIE, tabId, tabPointerOptions());
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
import {NextResponse} from 'next/server';
|
||||
import {authApi} from '@/services/api/authApi';
|
||||
import {SESSION_COOKIE, sessionCookieOptions} from '@/features/auth/services/sessionToken';
|
||||
import {TOKEN_COOKIE} from '@/features/auth/services/tokenStore';
|
||||
import {sessionCookieOptions} from '@/features/auth/services/sessionToken';
|
||||
import {
|
||||
sessionCookieFor,
|
||||
tokenCookieFor,
|
||||
} from '@/features/auth/services/tabScope';
|
||||
import {resolveTabId} from '@/features/auth/services/tabScopeRequest';
|
||||
import {peekAccessToken} from '@/features/auth/services/upstreamSession';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* POST /api/auth/logout — sign THIS TAB out.
|
||||
*
|
||||
* Revokes the session upstream first, then clears both cookies. The order is
|
||||
* deliberate, and so is the fact that an upstream failure does NOT abort the
|
||||
* local clear: if the platform is unreachable, the least bad outcome is that
|
||||
* this browser is signed out immediately and the server-side session lapses on
|
||||
* its own expiry. Leaving the user apparently signed in because a revoke call
|
||||
* failed is the one outcome nobody expects from pressing Sign out.
|
||||
* Revokes the session upstream first, then clears that tab's two cookies. The
|
||||
* order is deliberate, and so is the fact that an upstream failure does NOT
|
||||
* abort the local clear: if the platform is unreachable, the least bad outcome
|
||||
* is that this tab is signed out immediately and the server-side session lapses
|
||||
* on its own expiry. Leaving somebody apparently signed in because a revoke
|
||||
* call failed is the one outcome nobody expects from pressing Sign out.
|
||||
*
|
||||
* No refresh attempt: the token is about to be thrown away, so spending a
|
||||
* refresh token to revoke it is pure waste.
|
||||
*
|
||||
* ── Only this tab, and the upstream revoke is still real ─────────────────
|
||||
* `peekAccessToken` resolves through the tab scope, so the token revoked
|
||||
* upstream is THIS tab's session and no other. Signing out of the manager tab
|
||||
* ends the manager's platform session — genuinely, server-side, as before — and
|
||||
* leaves the admin and staff tabs holding their own untouched sessions in their
|
||||
* own cookies. Nothing here weakens server-side invalidation; it narrows what
|
||||
* gets invalidated to what the person actually asked to sign out of.
|
||||
*
|
||||
* A request with no resolvable tab clears nothing and still answers 200. There
|
||||
* is no session to end, and guessing at one would sign out a tab that never
|
||||
* asked.
|
||||
*/
|
||||
export async function POST() {
|
||||
const accessToken = await peekAccessToken();
|
||||
@@ -27,7 +43,7 @@ export async function POST() {
|
||||
await authApi.logout(accessToken);
|
||||
} catch {
|
||||
// Already-expired, revoked, or unreachable — all fine. The cookies below
|
||||
// are what actually ends this browser's session.
|
||||
// are what actually ends this tab's session.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +52,23 @@ export async function POST() {
|
||||
{headers: {'cache-control': 'no-store'}},
|
||||
);
|
||||
|
||||
// Overwrite with an expired cookie rather than only deleting: a delete that
|
||||
// misses on `path` leaves a live session behind.
|
||||
res.cookies.set(SESSION_COOKIE, '', sessionCookieOptions(0));
|
||||
res.cookies.set(TOKEN_COOKIE, '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
const tabId = await resolveTabId();
|
||||
if (tabId) {
|
||||
// Overwrite with an expired cookie rather than only deleting: a delete that
|
||||
// misses on `path` leaves a live session behind.
|
||||
res.cookies.set(sessionCookieFor(tabId), '', sessionCookieOptions(0));
|
||||
res.cookies.set(tokenCookieFor(tabId), '', {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
path: '/',
|
||||
maxAge: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// The pointer is deliberately left alone. It names a tab, not a session, and
|
||||
// the signed-out tab rewrites it on its next load anyway — clearing it here
|
||||
// would only blank the server-rendered first paint of whichever OTHER tab the
|
||||
// person switches to next.
|
||||
return res;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user