feat(auth): unify login flow and update login visuals

One sign-in page for everyone. /login is the only entry point; the
short-lived /admin/login and /staff/login routes are gone, along with the
per-route branding that came with them. Admin, owner, manager and staff see
the same form, post the same {email, password} to the same
POST /api/auth/login, and are never asked to say who they are.

Where somebody lands is decided by the role the BACKEND returns, never by
the URL they arrived at:

  owner, manager -> /dashboard
  staff          -> /floor

roleDestination is the single map, read by all three redirect paths - the
hydrated form, the no-JavaScript form POST, and GuestGuard. GuestGuard
raced the form to a hardcoded /dashboard, so a staff member landed in a
different place depending on which effect fired first; it now resolves
through the same map.

A platform admin authenticates correctly and still gets no session here.
Every surface in this console is tenant-scoped and an admin has no tenant
(auth.go: "ClientID empty means a platform admin"). Measured against a real
admin token: /api/sites 500, /api/visits 500, /api/visitors 500, /api/team
403 "This account does not belong to a company." So the BFF declines to set
the cookie rather than handing out a dashboard of server errors, revokes the
upstream session it will not use, and says so on /login through the existing
fixed-code table. Their surface is Companies in the platform's own web app,
which this console does not link to and does not hand a token - no session
handoff exists between the two, and inventing one would mean putting a
credential in a URL.

No enumeration is given up: a wrong password for an admin is answered
exactly like every other wrong password, so the "wrong console" message only
ever reaches somebody who has already proved they own the account.

Login visuals: the hero carousel now anchors each slide independently -
slide 1 (mascot with bag) to the bottom so the white bag clears the white
caption, slide 2 (selfie booth) to the top so the arch and wordmark are not
cropped by the rounded corner.

Unchanged: the BFF, the sealed httpOnly token cookie, the signed session
cookie, refresh, logout, route protection and the open-redirect guard on
?next=.

Verified against the live local platform with real accounts for all four
roles, plus wrong-password, unknown-email, empty-field, invalid-format and
inactive-user cases, session persistence, a forced token refresh, logout,
and two-tenant isolation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK
This commit is contained in:
2026-09-16 15:45:17 +05:30
parent d2e090214a
commit 697b0d9ef2
6 changed files with 171 additions and 16 deletions

View File

@@ -15,7 +15,8 @@ import {
sessionCookieOptions,
} from '@/features/auth/services/sessionToken';
import {storeTokens} from '@/features/auth/services/upstreamSession';
import {toAuthUser} from '@/features/auth/services/userMapper';
import {isPlatformAdmin, toAuthUser} from '@/features/auth/services/userMapper';
import {destinationForRole} from '@/features/auth/services/roleDestination';
import type {AuthSession} from '@/features/auth/types/auth';
import type {ApiSuccess} from '@/shared/types/api';
@@ -129,6 +130,52 @@ export async function POST(req: NextRequest) {
return failJson(code, message, status);
}
/**
* A platform admin authenticates correctly and still gets no session HERE.
*
* `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.
*
* 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.
*
* 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.
*
* 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.
*/
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,
);
}
await storeTokens(bundle);
const user = toAuthUser(bundle.user);
@@ -146,8 +193,16 @@ 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);
const res = isForm
? NextResponse.redirect(new URL(resolveRedirectTarget(next), req.url), 303)
? NextResponse.redirect(new URL(landing, req.url), 303)
: NextResponse.json<ApiSuccess<AuthSession>>(
{data: session, meta: {generatedAt: new Date().toISOString()}},
{headers: {'cache-control': 'no-store'}},