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