From 697b0d9ef29a6be53fee6d1551a7da32d7a438fc Mon Sep 17 00:00:00 2001 From: Aravind Date: Wed, 16 Sep 2026 15:45:17 +0530 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_0161AMotQ8FxGPZ9gFGb5wiK --- src/app/api/auth/login/route.ts | 59 +++++++++++++++++- .../auth/components/LoginHeroPanel.tsx | 17 +++--- src/features/auth/guards/GuestGuard.tsx | 25 ++++++-- src/features/auth/hooks/useLoginForm.ts | 10 ++- src/features/auth/services/loginErrorCodes.ts | 15 +++++ src/features/auth/services/roleDestination.ts | 61 +++++++++++++++++++ 6 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 src/features/auth/services/roleDestination.ts diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index cf552bf..792369f 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -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>( {data: session, meta: {generatedAt: new Date().toISOString()}}, {headers: {'cache-control': 'no-store'}}, diff --git a/src/features/auth/components/LoginHeroPanel.tsx b/src/features/auth/components/LoginHeroPanel.tsx index 8513d6d..5b6502b 100644 --- a/src/features/auth/components/LoginHeroPanel.tsx +++ b/src/features/auth/components/LoginHeroPanel.tsx @@ -25,12 +25,13 @@ import Image from 'next/image'; * plinth all survive. If this panel is ever made materially wider or shorter * than the artwork, that stops being true and the crop comes back. * - * The crop is anchored to the BOTTOM, not centred. Both posters carry brand - * lettering (the bag's wordmark, the booth's plinth) just above their middle, - * and centring drops that straight into the headline band — white type fighting - * a competing wordmark. Anchoring low keeps the clean sweep of floor behind the - * caption and lifts the lettering clear of it, and the few percent that comes - * off is sky at the top of the frame. + * Slide 1 (mascot with bag) anchors to the BOTTOM so the white bag is lifted + * clear of the white caption headline at the base of the card, while the plain + * yellow background at the top absorbs any vertical crop. + * + * Slide 2 (selfie booth) anchors to the TOP so the booth's curved arch, logo + * and "Loyaly.ai" title have comfortable breathing room and are never cropped + * by the container's top edge or rounded corners. * * The caption sits over the lower band on an explicit-stop scrim rather than * Tailwind's from/via/to, which puts `via` at the midpoint and left the @@ -49,10 +50,12 @@ const SLIDES = [ { src: '/brand/login-hero-1.jpeg', alt: 'The Loyaly.ai mascot holding a Loyaly.ai shopping bag', + position: 'object-bottom', }, { src: '/brand/login-hero-2.jpeg', alt: 'The Loyaly.ai selfie booth activation, with SNAP, SMILE and SHARE rewards', + position: 'object-top', }, ] as const; @@ -99,7 +102,7 @@ export function LoginHeroPanel() { // fetched, so this only ever describes the desktop box: half the // viewport, which at 2x picks the full 1122px source. sizes="50vw" - className={`object-cover object-bottom transition-opacity duration-1000 ease-in-out ${ + className={`object-cover ${slide.position} transition-opacity duration-1000 ease-in-out ${ index === active ? 'opacity-100' : 'opacity-0' }`} aria-hidden={index !== active} diff --git a/src/features/auth/guards/GuestGuard.tsx b/src/features/auth/guards/GuestGuard.tsx index 5f03670..2e98e85 100644 --- a/src/features/auth/guards/GuestGuard.tsx +++ b/src/features/auth/guards/GuestGuard.tsx @@ -6,6 +6,7 @@ import {Center} from '@astryxdesign/core/Center'; import {Spinner} from '@astryxdesign/core/Spinner'; import {useSession} from '@/features/auth/providers/SessionProvider'; import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget'; +import {destinationForRole} from '@/features/auth/services/roleDestination'; /** * The mirror of AuthGuard: keeps a signed-in user OFF the sign-in screen. @@ -21,17 +22,29 @@ import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget'; * into a form that is about to disappear. */ export function GuestGuard({children}: {children: React.ReactNode}) { - const {status} = useSession(); + const {status, user} = useSession(); const router = useRouter(); const searchParams = useSearchParams(); useEffect(() => { if (status !== 'authenticated') return; - // The SAME destination the login form resolves. These two redirects race - // on a successful sign-in, and a hardcoded /dashboard here silently threw - // away the deep link the proxy had preserved in `?next=`. - router.replace(resolveRedirectTarget(searchParams.get('next'))); - }, [status, router, searchParams]); + /** + * The SAME destination the login form resolves. These two redirects race + * on a successful sign-in, and a hardcoded /dashboard here silently threw + * away the deep link the proxy had preserved in `?next=`. + * + * The role arm is the same hazard a second time. Once sign-in started + * sending people to a destination chosen by their role, a plain + * `resolveRedirectTarget` here meant a staff member landed on /floor or on + * /dashboard depending purely on which effect fired first — a race with a + * different answer each way, which is worse than either answer. Both sides + * now read the same two functions in the same order. + */ + const next = searchParams.get('next'); + router.replace( + next ? resolveRedirectTarget(next) : destinationForRole(user?.role), + ); + }, [status, router, searchParams, user]); if (status !== 'unauthenticated') { return ( diff --git a/src/features/auth/hooks/useLoginForm.ts b/src/features/auth/hooks/useLoginForm.ts index 6123c3c..d3b3ddb 100644 --- a/src/features/auth/hooks/useLoginForm.ts +++ b/src/features/auth/hooks/useLoginForm.ts @@ -9,6 +9,7 @@ import { loginErrorFromCode, } from '@/features/auth/services/loginErrorCodes'; import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget'; +import {destinationForRole} from '@/features/auth/services/roleDestination'; import type {LoginError} from '@/features/auth/types/auth'; /** @@ -102,7 +103,13 @@ export function useLoginForm() { // Deliberately NOT clearing isSubmitting on success: the button stays in // its loading state until the navigation commits, so the form cannot be // submitted twice while the route transition is in flight. - router.replace(destination); + // An explicit `next` wins: somebody sent here from a deep link gets the + // page they asked for. Otherwise the destination comes from the role the + // BACKEND just returned — never from which of the three login pages they + // happened to open. + router.replace( + next ? destination : destinationForRole(result.session.user.role), + ); // The session cookie changed, so any server-rendered layout above this // route is stale. Without this, the workspace can paint its signed-out // seed until something else happens to refetch. @@ -116,6 +123,7 @@ export function useLoginForm() { rememberMe, toast, router, + next, destination, ], ); diff --git a/src/features/auth/services/loginErrorCodes.ts b/src/features/auth/services/loginErrorCodes.ts index 00e52b7..6755560 100644 --- a/src/features/auth/services/loginErrorCodes.ts +++ b/src/features/auth/services/loginErrorCodes.ts @@ -37,6 +37,21 @@ const LOGIN_ERRORS: Record = { field: 'form', message: 'Could not reach Loyaly. Check your connection and try again.', }, + /** + * Correct credentials for a PLATFORM ADMIN — an account with no company. + * Every surface in this console is tenant-scoped, so there is nothing here + * for one to open; the BFF declines to create a session rather than letting + * them walk into a dashboard of 500s. See the login route and roleDestination. + * + * Safe to say plainly, unlike the codes above: it is only ever reached by + * somebody who has just proved they own the account, so it tells an + * unauthenticated stranger nothing. + */ + platform_account: { + field: 'form', + message: + 'This console is for merchant accounts. Platform administrators sign in on the Loyaly platform console.', + }, }; export type LoginErrorCode = keyof typeof LOGIN_ERRORS; diff --git a/src/features/auth/services/roleDestination.ts b/src/features/auth/services/roleDestination.ts new file mode 100644 index 0000000..25063d5 --- /dev/null +++ b/src/features/auth/services/roleDestination.ts @@ -0,0 +1,61 @@ +import {DEFAULT_DESTINATION} from './redirectTarget'; +import type {UserRole} from '@/features/auth/types/auth'; + +/** + * Where a signed-in user lands, decided by the role the BACKEND returned. + * + * ── Never from the page or the URL ─────────────────────────────────────── + * There is one sign-in page. `POST /api/auth/login` takes an address, a + * password and a device label, and nothing else — no role, no user_type, no + * per-audience endpoint — so nothing about who somebody is can be known until + * the platform has said so. This map is read only AFTER that answer arrives. + * + * That also preserves the form's no-enumeration property: a destination chosen + * before authentication would tell an unauthenticated stranger which role an + * address holds, the same leak the identical wrong-password / no-such-account + * answer exists to close. + * + * One map, read by BOTH sign-in paths — the hydrated fetch, the native form + * POST — and by GuestGuard, which redirects on the same event and would + * otherwise race them to a different answer. + * + * ── Why `admin` is not here ────────────────────────────────────────────── + * It is not an oversight and it must not be "fixed" by adding a fourth line. + * + * The platform's own words, in server/internal/auth/auth.go: "ClientID empty + * means a platform admin, who is the only kind of user not scoped to one + * tenant." Every surface in this console is tenant-scoped, so a platform admin + * has no company for any of it to read. Measured against a real admin token on + * the live local platform: /api/sites 500, /api/visits 500, /api/visitors 500, + * /api/team 403 "This account does not belong to a company." /dashboard is not + * a thin experience for an admin, it is a screenful of server errors. + * + * So an admin is not given a console session at all — the BFF refuses it at + * `POST /api/auth/login` (see that route, and isPlatformAdmin). No admin can + * reach this function, because no admin can hold a session here. Their surface + * is the Companies screen in the platform's own web app, which has its own + * login; this console does not redirect there and does not hand it a token, + * because no session handoff exists between the two and inventing one would + * mean putting a credential in a URL. + * + * The fallback below is for a role this console has not heard of yet, not for + * `admin`. + */ +export const ROLE_DESTINATIONS: Record, string> = { + /** + * The shop floor: who is in the shop now, and who is serving them. The only + * surface in this console aimed at somebody standing behind a counter. + */ + staff: '/floor', + + manager: DEFAULT_DESTINATION, + owner: DEFAULT_DESTINATION, +}; + +/** Falls back to the default for a role this console does not know. */ +export function destinationForRole(role: string | undefined): string { + if (!role) return DEFAULT_DESTINATION; + return ( + ROLE_DESTINATIONS[role as Exclude] ?? DEFAULT_DESTINATION + ); +}