admin login issue

This commit is contained in:
2026-09-21 16:38:15 +05:30
parent 8456c5e852
commit b60b8baef6
40 changed files with 2254 additions and 260 deletions

View 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);
});
}

View 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') ?? ''),
);
}

View 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},
);
}

View File

@@ -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;
}

View File

@@ -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;
}