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,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() });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user