import { APP_ROLES, ROUTES, type AppRole } from '@/constants'; import type { Me, RoleCode } from './types'; const ADMIN_ROLES: RoleCode[] = ['admin', 'support', 'finance', 'moderation', 'super_admin']; export function isAdminRole(role: RoleCode): boolean { return ADMIN_ROLES.includes(role); } /** * Collapse the server's fine-grained role codes to the three actor experiences the shells * render. Admin sub-roles all map to the single ADMIN shell; unknown/non-public codes drop out. */ export function toAppRoles(roles: RoleCode[]): AppRole[] { const actors = new Set(); for (const role of roles) { if (role === 'customer') actors.add(APP_ROLES.CUSTOMER); else if (role === 'nurse') actors.add(APP_ROLES.NURSE); else if (isAdminRole(role)) actors.add(APP_ROLES.ADMIN); } return [...actors]; } /** * The locale-less path a signed-in user should land on after auth — the pure core of the role * router (the component just navigates to it). `intendedRole` is the role carried from the login * switch (A1 vs B1); it only disambiguates a user who holds **both** customer and nurse. * * A nurse always routes to the nurse app here; the B3 "verification in progress" landing for an * unverified nurse is f5's job (this only routes correctly, it doesn't render the banner). */ export function resolveRoleDestination(me: Pick, intendedRole?: AppRole): string { if (me.roles.length === 0) return ROUTES.SELECT_ROLE; if (me.roles.some(isAdminRole)) return ROUTES.ADMIN; const hasNurse = me.roles.includes('nurse'); const hasCustomer = me.roles.includes('customer'); let target: AppRole; if (hasNurse && hasCustomer) { target = intendedRole === APP_ROLES.NURSE ? APP_ROLES.NURSE : APP_ROLES.CUSTOMER; } else { target = hasNurse ? APP_ROLES.NURSE : APP_ROLES.CUSTOMER; } return target === APP_ROLES.NURSE ? ROUTES.NURSE : ROUTES.HOME; } /** True for a same-origin relative path only — rejects a protocol-relative (`//host`) or absolute-URL `next`. */ function isSafeRelativePath(path: string): boolean { return path.startsWith('/') && !path.startsWith('//') && !path.startsWith('/\\'); } /** The app (nurse/admin/customer) whose route tree owns `path`; customer paths carry no prefix. */ function appRoleForPath(path: string): AppRole | null { const pathname = path.split('?')[0].split('#')[0]; if (pathname === ROUTES.NURSE || pathname.startsWith(`${ROUTES.NURSE}/`)) return APP_ROLES.NURSE; if (pathname === ROUTES.ADMIN || pathname.startsWith(`${ROUTES.ADMIN}/`)) return APP_ROLES.ADMIN; // The partner portal isn't an AppRole (it self-gates via useMyPartnerCenter) — never a `next` target. if (pathname === ROUTES.PARTNER || pathname.startsWith(`${ROUTES.PARTNER}/`)) return null; return APP_ROLES.CUSTOMER; } /** * The `?next=` deep-link carried through login (middleware.ts appends it on the redirect-to-login). * Returns `next` only when it is a validated same-origin relative path **and** the resolved session * actually holds the role that owns it (a customer's `next=/nurse/...` falls through) — otherwise * defers to `resolveRoleDestination`, which stays the single "which app" source of truth. Never * returns an absolute URL or a protocol-relative path — this is the one guard against `next` * becoming an open redirect. */ export function resolvePostLoginDestination( me: Pick, intendedRole: AppRole | undefined, next: string | null | undefined, ): string { if (next && isSafeRelativePath(next)) { const owner = appRoleForPath(next); if (owner && toAppRoles(me.roles).includes(owner)) return next; } return resolveRoleDestination(me, intendedRole); }