frontend phase 1: auth — phone-OTP login, role routing & session refresh
Replace the username/password stub with Balinyaar's real credential (phone-OTP) and add the role router every authenticated screen sits behind. - services/auth rewritten for OTP over the b2 contract: types/keys/constants, clientApi+mockApi behind a config'd seam, hooks (useRequestOtp/useVerifyOtp/ useMe/useRefresh/useLogout/useSelectRole/useSessionRoleSync). Stub removed. - A1/A2 customer login + B1/B2 nurse switch as one OTP flow at /login (PhoneStep/OtpStep: auto-verify, resend countdown, wrong/expired/lockout states). - Role router: pure resolveRoleDestination + RoleRouter -> family / nurse / admin / select-role, with a splash while /me loads (no wrong-shell flash). - SelectRole first-use screen at /select-role. - Widened AuthState (roles via SessionUser), hydrated from /me by useSessionRoleSync. - Fetch-layer silent token refresh (single-flight + one retry) + shared persistAuthTokens/clearAuthTokens; useRefresh as the on-demand path. - auth i18n namespace in both locales; tests for routing branches, countdown, OtpStep state machine, RoleRouter branches; fixed jest @/ -> src alias. - Docs: client/CLAUDE.md, frontend STATUS/report, for-backend REQ-002..004. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,16 +1,63 @@
|
||||
import { clientFetch } from '@/lib/api/client';
|
||||
import type { AuthTokens, LoginDto, User } from '../types';
|
||||
import { unwrap, type ApiEnvelope } from '@/lib/api/types';
|
||||
import { AUTH_API_BASE } from '../constants';
|
||||
import type {
|
||||
AuthApi,
|
||||
AuthTokens,
|
||||
LogoutRequest,
|
||||
Me,
|
||||
OtpRequest,
|
||||
OtpVerify,
|
||||
RefreshRequest,
|
||||
RequestOtpResult,
|
||||
SelectRoleDto,
|
||||
} from '../types';
|
||||
|
||||
export const AuthClientApi = {
|
||||
login: (dto: LoginDto) =>
|
||||
clientFetch<AuthTokens>('/auth/login', {
|
||||
/**
|
||||
* Real HTTP implementation of the AuthApi seam. `clientFetch` returns the raw server
|
||||
* envelope, so each call reads its payload via `unwrap`. Routes are the exact snake_case
|
||||
* paths from the published swagger (`[controller]/[action]` transform).
|
||||
*/
|
||||
export const authClientApi: AuthApi = {
|
||||
requestOtp: async (body: OtpRequest) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<RequestOtpResult>>(`${AUTH_API_BASE}/auth/request_otp`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
|
||||
verifyOtp: async (body: OtpVerify) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<AuthTokens>>(`${AUTH_API_BASE}/auth/verify_otp`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
|
||||
refresh: async (body: RefreshRequest) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<AuthTokens>>(`${AUTH_API_BASE}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
|
||||
// Success is an empty envelope (no `data`) — don't unwrap, just await the revocation.
|
||||
logout: async (body?: LogoutRequest) => {
|
||||
await clientFetch<ApiEnvelope<void>>(`${AUTH_API_BASE}/auth/logout`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(dto),
|
||||
}),
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
},
|
||||
|
||||
logout: () =>
|
||||
clientFetch<void>('/auth/logout', { method: 'POST' }),
|
||||
getMe: async () => unwrap(await clientFetch<ApiEnvelope<Me>>(`${AUTH_API_BASE}/me`)),
|
||||
|
||||
getCurrentUser: () =>
|
||||
clientFetch<User>('/auth/me'),
|
||||
selectRole: async (body: SelectRoleDto) =>
|
||||
unwrap(
|
||||
await clientFetch<ApiEnvelope<Me>>(`${AUTH_API_BASE}/me/select_role`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { USE_AUTH_MOCK } from '../constants';
|
||||
import type { AuthApi } from '../types';
|
||||
import { authClientApi } from './clientApi';
|
||||
import { authMockApi } from './mockApi';
|
||||
|
||||
/**
|
||||
* The selected AuthApi implementation — the single seam every auth hook imports. Selection is
|
||||
* by config (USE_AUTH_MOCK), never by scattered `if (mock)` checks.
|
||||
*/
|
||||
export const authApi: AuthApi = USE_AUTH_MOCK ? authMockApi : authClientApi;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { sleep } from '@/utils';
|
||||
import { ApiError } from '@/lib/api/errors';
|
||||
import { OTP_LOCKED_CODE } from '../constants';
|
||||
import type { AuthApi, AuthTokens, Me, RoleCode } from '../types';
|
||||
|
||||
const MOCK_LATENCY_MS = 400;
|
||||
const DEV_OTP_CODE = '123456';
|
||||
const MAX_WRONG_ATTEMPTS = 3;
|
||||
|
||||
/**
|
||||
* Which `Me` the mock returns, so a developer can exercise all three role-router branches
|
||||
* without a backend. Flip this to `nurse_unverified` or `no_role` while USE_AUTH_MOCK is true.
|
||||
*/
|
||||
type MockScenario = 'customer' | 'nurse_unverified' | 'no_role';
|
||||
const MOCK_SCENARIO: MockScenario = 'customer';
|
||||
|
||||
const SCENARIO_ROLES: Record<MockScenario, RoleCode[]> = {
|
||||
customer: ['customer'],
|
||||
nurse_unverified: ['nurse'],
|
||||
no_role: [],
|
||||
};
|
||||
|
||||
// Mutable so `selectRole` can grant a role to the fresh (`no_role`) user mid-session.
|
||||
let grantedRoles: RoleCode[] = [...SCENARIO_ROLES[MOCK_SCENARIO]];
|
||||
let wrongAttempts = 0;
|
||||
|
||||
function fakeTokens(): AuthTokens {
|
||||
return {
|
||||
// A structurally-valid unsigned JWT with a far-future `exp` so `isTokenAlive`
|
||||
// (middleware / server-seed) accepts it. Payload: { exp: 4102444800 }.
|
||||
accessToken:
|
||||
'eyJhbGciOiJub25lIn0.eyJleHAiOjQxMDI0NDQ4MDB9.',
|
||||
refreshToken: 'mock-refresh-token',
|
||||
accessExpiresAt: '2100-01-01T00:00:00Z',
|
||||
refreshExpiresAt: '2100-01-01T00:00:00Z',
|
||||
isNewUser: grantedRoles.length === 0,
|
||||
roles: grantedRoles,
|
||||
};
|
||||
}
|
||||
|
||||
function currentMe(): Me {
|
||||
return {
|
||||
id: 1,
|
||||
phone: '0912*****00',
|
||||
firstName: null,
|
||||
lastName: null,
|
||||
gender: null,
|
||||
isActive: true,
|
||||
roles: grantedRoles,
|
||||
hasCustomerProfile: false,
|
||||
hasNurseProfile: false,
|
||||
nurseVerificationStatus: 'not_started',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory mock behind the AuthApi seam — the offline fallback for the login flow.
|
||||
* `verifyOtp` accepts the fixed dev code `123456` and locks after MAX_WRONG_ATTEMPTS wrong
|
||||
* tries (reset by requesting a new code), mirroring the real domain 4xx states.
|
||||
*/
|
||||
export const authMockApi: AuthApi = {
|
||||
requestOtp: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
wrongAttempts = 0;
|
||||
return { otpSent: true, resendAvailableInSeconds: 120 };
|
||||
},
|
||||
|
||||
verifyOtp: async ({ code }) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (wrongAttempts >= MAX_WRONG_ATTEMPTS) {
|
||||
throw new ApiError(400, 'Too many attempts. Request a new code.', OTP_LOCKED_CODE);
|
||||
}
|
||||
if (code !== DEV_OTP_CODE) {
|
||||
wrongAttempts += 1;
|
||||
if (wrongAttempts >= MAX_WRONG_ATTEMPTS) {
|
||||
throw new ApiError(400, 'Too many attempts. Request a new code.', OTP_LOCKED_CODE);
|
||||
}
|
||||
throw new ApiError(400, 'The code is incorrect or has expired.');
|
||||
}
|
||||
wrongAttempts = 0;
|
||||
return fakeTokens();
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return fakeTokens();
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
grantedRoles = [...SCENARIO_ROLES[MOCK_SCENARIO]];
|
||||
},
|
||||
|
||||
getMe: async () => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
return currentMe();
|
||||
},
|
||||
|
||||
selectRole: async ({ role }) => {
|
||||
await sleep(MOCK_LATENCY_MS);
|
||||
if (!grantedRoles.includes(role)) grantedRoles = [...grantedRoles, role];
|
||||
return currentMe();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Auth domain configuration. The b2 endpoints are live (swagger snapshot published), so
|
||||
* the real client is selected by default; flip USE_AUTH_MOCK to develop the login flow
|
||||
* offline behind the AuthApi seam (see dev/shared-working-context/reports/mocks-registry.md).
|
||||
*/
|
||||
export const USE_AUTH_MOCK = false;
|
||||
|
||||
/** Versioned API base — every auth route is `api/v1/...` per the published swagger. */
|
||||
export const AUTH_API_BASE = '/api/v1';
|
||||
|
||||
/** `/me` is stable within a session; revisiting a screen shouldn't refetch it. */
|
||||
export const AUTH_ME_STALE_TIME = 60_000;
|
||||
|
||||
/**
|
||||
* OTP length. The contract's RequestOtpResult exposes no `code_length`, and the live verify
|
||||
* example uses a 6-digit code, so we default to 6 (a request to surface the length is filed in
|
||||
* frontend/requests/for-backend.md). The wireframe's 4 boxes predate the real 6-digit code.
|
||||
*/
|
||||
export const OTP_CODE_LENGTH = 6;
|
||||
|
||||
/** Fallback resend cooldown (seconds) when a request hasn't yet returned the server value. */
|
||||
export const OTP_RESEND_FALLBACK_SECONDS = 120;
|
||||
|
||||
/**
|
||||
* `ApiError.code` the mock returns after too many wrong codes so the OTP screen can show the
|
||||
* lockout state. The live server sends a 400 with a safe message but no machine-readable code
|
||||
* yet (also filed in for-backend.md) — the screen degrades to the generic invalid-code state.
|
||||
*/
|
||||
export const OTP_LOCKED_CODE = 'otp_locked';
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { AuthClientApi } from '../apis/clientApi';
|
||||
import { authKeys } from '../keys';
|
||||
|
||||
export function useCurrentUser() {
|
||||
return useQuery({
|
||||
queryKey: authKeys.currentUser(),
|
||||
queryFn: () => AuthClientApi.getCurrentUser(),
|
||||
});
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { AUTH_ACCESS_COOKIE_OPTIONS, AUTH_REFRESH_COOKIE_OPTIONS, COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { setClientCookie } from '@/lib/cookies/client';
|
||||
import { dispatchToast } from '@/lib/toast/dispatchToast';
|
||||
import type { ApiError } from '@/lib/api/errors';
|
||||
import { useAuth } from '@/context/auth';
|
||||
|
||||
import { AuthClientApi } from '../apis/clientApi';
|
||||
import type { LoginDto } from '../types';
|
||||
|
||||
export function useLogin() {
|
||||
const [, dispatch] = useAuth();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (dto: LoginDto) => AuthClientApi.login(dto),
|
||||
onSuccess: (data) => {
|
||||
setClientCookie(COOKIE_NAMES.ACCESS_TOKEN, data.accessToken, AUTH_ACCESS_COOKIE_OPTIONS);
|
||||
setClientCookie(COOKIE_NAMES.REFRESH_TOKEN, data.refreshToken, AUTH_REFRESH_COOKIE_OPTIONS);
|
||||
// Sync AuthContext after a client-side login so the UI reflects the new
|
||||
// session immediately, without waiting for the next full server render.
|
||||
dispatch({ type: 'LOG_IN' });
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
dispatchToast(error.message, 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,30 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useLocale } from 'next-intl';
|
||||
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { deleteClientCookie } from '@/lib/cookies/client';
|
||||
import { clearAuthTokens } from '@/lib/auth/session';
|
||||
import { ROUTES } from '@/constants';
|
||||
import { useAuth } from '@/context/auth';
|
||||
|
||||
import { AuthClientApi } from '../apis/clientApi';
|
||||
import { authApi } from '../apis';
|
||||
import { authKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* The single logout path: revoke the server session, then (regardless of the call's outcome) clear
|
||||
* both token cookies, reset AuthContext, drop the cached `/me`, and return to login.
|
||||
*/
|
||||
export function useLogout() {
|
||||
const [, dispatch] = useAuth();
|
||||
const router = useRouter();
|
||||
const locale = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => AuthClientApi.logout(),
|
||||
mutationFn: () => authApi.logout({}),
|
||||
onSettled: () => {
|
||||
deleteClientCookie(COOKIE_NAMES.ACCESS_TOKEN);
|
||||
deleteClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
clearAuthTokens();
|
||||
dispatch({ type: 'LOG_OUT' });
|
||||
queryClient.removeQueries({ queryKey: authKeys.all });
|
||||
router.replace(`/${locale}${ROUTES.LOGIN}`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { useIsAuthenticated } from '@/hooks';
|
||||
|
||||
import { authApi } from '../apis';
|
||||
import { AUTH_ME_STALE_TIME } from '../constants';
|
||||
import { authKeys } from '../keys';
|
||||
|
||||
/**
|
||||
* The signed-in user's identity, roles, and onboarding state — the role router's data source and
|
||||
* the shells' role source. Only runs once authenticated (no `/me` call for a logged-out visitor).
|
||||
*/
|
||||
export function useMe() {
|
||||
const isAuthenticated = useIsAuthenticated();
|
||||
return useQuery({
|
||||
queryKey: authKeys.me(),
|
||||
queryFn: () => authApi.getMe(),
|
||||
enabled: isAuthenticated,
|
||||
staleTime: AUTH_ME_STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { persistAuthTokens } from '@/lib/auth/session';
|
||||
|
||||
import { authApi } from '../apis';
|
||||
|
||||
/**
|
||||
* Explicitly rotate the token pair (e.g. after selecting a role, whose claim lives inside the
|
||||
* access token). The **automatic** silent refresh on an expired access token is owned by the
|
||||
* fetch layer (`@/lib/api/refresh`); this hook is the on-demand path and shares the same
|
||||
* `persistAuthTokens` writer so token storage never diverges.
|
||||
*/
|
||||
export function useRefresh() {
|
||||
return useMutation({
|
||||
mutationFn: (refreshToken: string) => authApi.refresh({ refreshToken }),
|
||||
onSuccess: (tokens) => persistAuthTokens(tokens),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
import { authApi } from '../apis';
|
||||
import type { OtpRequest } from '../types';
|
||||
|
||||
/**
|
||||
* Request an OTP for a phone (A1/B1 → A2/B2). Domain 4xx (400 invalid phone, 429 rate-limit)
|
||||
* surface via `mutation.error` for the caller to render inline — 401/403/5xx are already
|
||||
* toasted by the fetch layer, so this hook does not toast.
|
||||
*/
|
||||
export function useRequestOtp() {
|
||||
return useMutation({
|
||||
mutationFn: (body: OtpRequest) => authApi.requestOtp(body),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { COOKIE_NAMES } from '@/lib/cookies';
|
||||
import { getClientCookie } from '@/lib/cookies/client';
|
||||
import { persistAuthTokens } from '@/lib/auth/session';
|
||||
import { useAuth } from '@/context/auth';
|
||||
|
||||
import { authApi } from '../apis';
|
||||
import { authKeys } from '../keys';
|
||||
import { toAppRoles } from '../routing';
|
||||
import type { PublicRole } from '../types';
|
||||
|
||||
/**
|
||||
* Self-assign a public actor role for a brand-new user. Because role claims live inside the access
|
||||
* token, we rotate the token pair after selecting so subsequent role-gated calls carry the new
|
||||
* claim (contract note). AuthContext and the cached `/me` are then updated so the router re-runs
|
||||
* against the new role.
|
||||
*/
|
||||
export function useSelectRole() {
|
||||
const queryClient = useQueryClient();
|
||||
const [, dispatch] = useAuth();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (role: PublicRole) => authApi.selectRole({ role }),
|
||||
onSuccess: async (me) => {
|
||||
const refreshToken = getClientCookie(COOKIE_NAMES.REFRESH_TOKEN);
|
||||
if (refreshToken) {
|
||||
try {
|
||||
persistAuthTokens(await authApi.refresh({ refreshToken }));
|
||||
} catch {
|
||||
// A failed rotation surfaces on the next `/me`/gated call via the fetch layer; the
|
||||
// role is already persisted server-side, so don't block the flow on it.
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'LOG_IN', user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles) } });
|
||||
queryClient.setQueryData(authKeys.me(), me);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useAuth } from '@/context/auth';
|
||||
|
||||
import { useMe } from './useMe';
|
||||
import { toAppRoles } from '../routing';
|
||||
|
||||
/**
|
||||
* Hydrate AuthContext with the real roles from `/me` for an already-authenticated session (a
|
||||
* returning user whose server-seeded state only knows `isAuthenticated`). Runs once per `/me`
|
||||
* result — the shells read `currentUser.roles` to pick chrome, so this keeps that single source
|
||||
* of truth fresh without a second role store. Mount it once, high in the authenticated tree.
|
||||
*/
|
||||
export function useSessionRoleSync(): void {
|
||||
const [, dispatch] = useAuth();
|
||||
const { data: me } = useMe();
|
||||
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
dispatch({ type: 'LOG_IN', user: { id: me.id, phone: me.phone, roles: toAppRoles(me.roles) } });
|
||||
}, [me, dispatch]);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { persistAuthTokens } from '@/lib/auth/session';
|
||||
import { useAuth } from '@/context/auth';
|
||||
|
||||
import { authApi } from '../apis';
|
||||
import { authKeys } from '../keys';
|
||||
import { toAppRoles } from '../routing';
|
||||
import type { OtpVerify } from '../types';
|
||||
|
||||
/**
|
||||
* Verify the OTP, then establish the session: persist the token pair, sync AuthContext with the
|
||||
* roles the server returned (so the shells pick chrome immediately), and invalidate `/me` — the
|
||||
* role router's input. The router (rendered on success) then routes to the right app.
|
||||
*
|
||||
* Wrong/expired/lockout codes come back as domain 400s via `mutation.error`; the OTP screen maps
|
||||
* them to inline states. The fetch layer owns 401/403/5xx toasts.
|
||||
*/
|
||||
export function useVerifyOtp() {
|
||||
const [, dispatch] = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (body: OtpVerify) => authApi.verifyOtp(body),
|
||||
onSuccess: (tokens, variables) => {
|
||||
persistAuthTokens(tokens);
|
||||
dispatch({
|
||||
type: 'LOG_IN',
|
||||
user: { phone: variables.phone, roles: toAppRoles(tokens.roles) },
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: authKeys.me() });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
export { useLogin } from './hooks/useLogin';
|
||||
export { useRequestOtp } from './hooks/useRequestOtp';
|
||||
export { useVerifyOtp } from './hooks/useVerifyOtp';
|
||||
export { useMe } from './hooks/useMe';
|
||||
export { useRefresh } from './hooks/useRefresh';
|
||||
export { useLogout } from './hooks/useLogout';
|
||||
export { useCurrentUser } from './hooks/useCurrentUser';
|
||||
export { useSelectRole } from './hooks/useSelectRole';
|
||||
export { useSessionRoleSync } from './hooks/useSessionRoleSync';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const authKeys = {
|
||||
all: ['auth'] as const,
|
||||
currentUser: () => [...authKeys.all, 'me'] as const,
|
||||
me: () => [...authKeys.all, 'me'] as const,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ROUTES } from '@/constants';
|
||||
import { isAdminRole, resolveRoleDestination, toAppRoles } from './routing';
|
||||
import type { RoleCode } from './types';
|
||||
|
||||
describe('toAppRoles', () => {
|
||||
it('collapses all admin sub-roles to the single admin actor', () => {
|
||||
expect(toAppRoles(['support'])).toEqual(['admin']);
|
||||
expect(toAppRoles(['super_admin', 'finance'])).toEqual(['admin']);
|
||||
});
|
||||
|
||||
it('keeps customer and nurse and de-duplicates', () => {
|
||||
expect(toAppRoles(['customer', 'nurse'])).toEqual(['customer', 'nurse']);
|
||||
expect(toAppRoles(['nurse', 'nurse'])).toEqual(['nurse']);
|
||||
});
|
||||
|
||||
it('drops unknown/empty codes', () => {
|
||||
expect(toAppRoles([])).toEqual([]);
|
||||
expect(toAppRoles(['unknown' as RoleCode])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAdminRole', () => {
|
||||
it('flags every internal admin sub-role', () => {
|
||||
(['admin', 'support', 'finance', 'moderation', 'super_admin'] as RoleCode[]).forEach((role) =>
|
||||
expect(isAdminRole(role)).toBe(true),
|
||||
);
|
||||
});
|
||||
it('does not flag public roles', () => {
|
||||
expect(isAdminRole('customer')).toBe(false);
|
||||
expect(isAdminRole('nurse')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveRoleDestination (role-router branches)', () => {
|
||||
it('sends a customer to the family app home', () => {
|
||||
expect(resolveRoleDestination({ roles: ['customer'] })).toBe(ROUTES.HOME);
|
||||
});
|
||||
|
||||
it('sends a nurse to the nurse app', () => {
|
||||
expect(resolveRoleDestination({ roles: ['nurse'] })).toBe(ROUTES.NURSE);
|
||||
});
|
||||
|
||||
it('sends a role-less (brand-new) user to role selection', () => {
|
||||
expect(resolveRoleDestination({ roles: [] })).toBe(ROUTES.SELECT_ROLE);
|
||||
});
|
||||
|
||||
it('sends any admin-family role to the admin console', () => {
|
||||
expect(resolveRoleDestination({ roles: ['support'] })).toBe(ROUTES.ADMIN);
|
||||
expect(resolveRoleDestination({ roles: ['customer', 'super_admin'] })).toBe(ROUTES.ADMIN);
|
||||
});
|
||||
|
||||
it('uses the login intent to disambiguate a dual customer+nurse user', () => {
|
||||
expect(resolveRoleDestination({ roles: ['customer', 'nurse'] }, 'nurse')).toBe(ROUTES.NURSE);
|
||||
expect(resolveRoleDestination({ roles: ['customer', 'nurse'] }, 'customer')).toBe(ROUTES.HOME);
|
||||
// Defaults to the family app when no intent is carried.
|
||||
expect(resolveRoleDestination({ roles: ['customer', 'nurse'] })).toBe(ROUTES.HOME);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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<AppRole>();
|
||||
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<Me, 'roles'>, 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;
|
||||
}
|
||||
@@ -1,19 +1,104 @@
|
||||
export interface LoginDto {
|
||||
username: string;
|
||||
password: string;
|
||||
/**
|
||||
* Auth domain — phone-OTP login, revocable refresh-token sessions, `/me`, and public
|
||||
* role selection. Shapes mirror the b2 contract
|
||||
* (`dev/contracts/domains/identity-auth.md` + `dev/contracts/openapi/swagger.v1.json`)
|
||||
* exactly, including the **camelCase** wire casing confirmed against the live envelope.
|
||||
*
|
||||
* Enums cross the wire as stable string codes (money-and-types.md) and are mirrored here
|
||||
* as string-literal unions — never hardcode a display label off a code (labels are i18n keys).
|
||||
*/
|
||||
|
||||
/** Publicly self-selectable roles (customer and nurse may coexist). */
|
||||
export type PublicRole = 'customer' | 'nurse';
|
||||
|
||||
/** Internal-only admin sub-roles — never self-assignable (`me/select_role` → 403). */
|
||||
export type AdminRole = 'admin' | 'support' | 'finance' | 'moderation' | 'super_admin';
|
||||
|
||||
/** Any role code the server may report on a session. */
|
||||
export type RoleCode = PublicRole | AdminRole;
|
||||
|
||||
/** Load-bearing for same-gender matching; null until the profile flow (b3) sets it. */
|
||||
export type Gender = 'male' | 'female';
|
||||
|
||||
/** Nurse verification stage. `not_started` until the b6 pipeline exists. */
|
||||
export type NurseVerificationStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'pending_review'
|
||||
| 'verified'
|
||||
| 'rejected';
|
||||
|
||||
/** `POST auth/request_otp` body. The server accepts +98/0098/Persian digits and normalizes. */
|
||||
export interface OtpRequest {
|
||||
phone: string;
|
||||
}
|
||||
|
||||
/** `POST auth/request_otp` payload. Inside the resend window `otpSent` is false with the remaining seconds. */
|
||||
export interface RequestOtpResult {
|
||||
otpSent: boolean;
|
||||
resendAvailableInSeconds: number;
|
||||
}
|
||||
|
||||
/** `POST auth/verify_otp` body. */
|
||||
export interface OtpVerify {
|
||||
phone: string;
|
||||
code: string;
|
||||
deviceInfo?: string;
|
||||
}
|
||||
|
||||
/** `POST auth/refresh` body — the refresh token is itself the credential. */
|
||||
export interface RefreshRequest {
|
||||
refreshToken: string;
|
||||
deviceInfo?: string;
|
||||
}
|
||||
|
||||
/** `POST auth/logout` body. Omitting `refreshToken` (or `everywhere: true`) revokes every session. */
|
||||
export interface LogoutRequest {
|
||||
refreshToken?: string;
|
||||
everywhere?: boolean;
|
||||
}
|
||||
|
||||
/** `AuthTokensResult` — returned by verify_otp and refresh. */
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
accessExpiresAt: string;
|
||||
refreshExpiresAt: string;
|
||||
/** `true` only on the first successful verify for a phone ⇒ send to role selection. */
|
||||
isNewUser: boolean;
|
||||
/** Active roles; empty ⇒ route the user to role selection. */
|
||||
roles: RoleCode[];
|
||||
}
|
||||
|
||||
import type { AppRole } from '@/constants';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
// Populated by the server in f1-b2. Optional until then; the shell defaults to
|
||||
// the customer experience when roles are absent (see useActorRole).
|
||||
roles?: AppRole[];
|
||||
/** `POST me/select_role` body. Only public roles are accepted. */
|
||||
export interface SelectRoleDto {
|
||||
role: PublicRole;
|
||||
}
|
||||
|
||||
/** `MeResult` — the role router's input and the shell's role source. */
|
||||
export interface Me {
|
||||
id: number;
|
||||
/** Always masked by the server (`0912*****33`); the full number is never returned. */
|
||||
phone: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
gender: Gender | null;
|
||||
isActive: boolean;
|
||||
roles: RoleCode[];
|
||||
hasCustomerProfile: boolean;
|
||||
hasNurseProfile: boolean;
|
||||
nurseVerificationStatus: NurseVerificationStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth domain's API seam. The real HTTP client and the in-memory mock both implement
|
||||
* this interface; selection is by config (`USE_AUTH_MOCK`), never scattered `if (mock)` checks.
|
||||
*/
|
||||
export interface AuthApi {
|
||||
requestOtp(body: OtpRequest): Promise<RequestOtpResult>;
|
||||
verifyOtp(body: OtpVerify): Promise<AuthTokens>;
|
||||
refresh(body: RefreshRequest): Promise<AuthTokens>;
|
||||
logout(body?: LogoutRequest): Promise<void>;
|
||||
getMe(): Promise<Me>;
|
||||
selectRole(body: SelectRoleDto): Promise<Me>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user