# I1 — Auth Integration **Module:** I1 — Replace Mock AuthContext with Real JWT Flow **Companion backend doc:** `docs/backend/01_authentication_module.md` (B1) **Depends on:** I0 (API client + interceptors must be set up first) **Modifies:** `src/context/AuthContext.jsx`, `src/pages/Login.jsx`, `src/App.jsx` **Creates:** `src/api/auth.js` --- ## 1. Overview The current `AuthContext.jsx` authenticates users by searching `mockStore.users` in memory. There is no session persistence — a page refresh loses the logged-in user entirely. After I1, authentication will: - Call `POST /api/v1/auth/login` → backend validates credentials, sets two `httpOnly` cookies - Call `GET /api/v1/auth/me` on app mount → restores the session from the cookie without any extra login prompt - Call `POST /api/v1/auth/logout` → clears cookies and invalidates the refresh token in the DB - Handle forced logout events dispatched by `interceptors.js` when the refresh token is also expired ### What does NOT change The public API of `useAuth()` is preserved so that no other component needs to be touched: | Value / function | Before | After | Notes | |-----------------|--------|-------|-------| | `user` | User object from mock store | User object from `GET /auth/me` | Field names differ — see Section 7 | | `isAuthenticated` | `boolean` | `boolean` | Identical | | `login(id, pass, type)` | Synchronous, returns `{success, role}` | **Async**, returns `{success, role}` | `Login.jsx` must `await` it | | `logout()` | Synchronous, clears state | **Async**, calls API then clears state | No changes needed in callers | | `isLoading` | Does not exist | `boolean` — `true` until mount check resolves | `ProtectedRoute` must guard on this | ### What is removed - `useMockStore` import from `AuthContext.jsx` - `updateProfile()` stub (it was non-functional; will be re-added properly in I4) --- ## 2. Files Summary | File | Action | |------|--------| | `src/api/auth.js` | **Create** — thin API wrapper for all auth endpoints | | `src/context/AuthContext.jsx` | **Replace** — remove mock logic, add real API calls + session restore | | `src/pages/Login.jsx` | **Patch** — `handleLogin` → async, correct ADMIN redirect, add submit loading state | | `src/App.jsx` | **Patch** — `ProtectedRoute` must handle `isLoading` before redirecting | --- ## 3. `src/api/auth.js` — New File This is the thin API wrapper. No state, no side effects — just HTTP calls. ```js import apiClient from './client'; /** * POST /auth/login * Returns: { user: UserPublic, message: string } * Sets: access_token + refresh_token httpOnly cookies */ export async function loginApi(identifier, password, type) { const { data } = await apiClient.post('/auth/login', { identifier, password, type }); return data; } /** * GET /auth/me * Returns: UserPublic * Used on mount to restore session from cookie */ export async function getMeApi() { const { data } = await apiClient.get('/auth/me'); return data; } /** * POST /auth/logout * Returns: { message: string } * Clears cookies + invalidates refresh_token_hash in DB */ export async function logoutApi() { const { data } = await apiClient.post('/auth/logout'); return data; } ``` --- ## 4. `src/context/AuthContext.jsx` — Full Replacement Replace the entire file with the implementation below. Key changes: 1. Remove `useMockStore` import 2. Add `isLoading` state (starts `true`, set `false` after mount check) 3. Add `useEffect` for session restore — calls `getMeApi()` on mount 4. Add `useEffect` for forced-logout event from `interceptors.js` 5. `login()` becomes `async`, calls `loginApi()` 6. `logout()` becomes `async`, calls `logoutApi()` 7. `isLoading` is now exported in context value ```jsx import React, { createContext, useContext, useState, useEffect } from 'react'; import { logger } from '../utils/logger'; import { toast } from 'sonner'; import { loginApi, getMeApi, logoutApi } from '../api/auth'; const AuthContext = createContext(); export const ROLES = { OWNER: 'OWNER', ADMIN: 'ADMIN', CONTRACTOR: 'CONTRACTOR', SUBCONTRACTOR: 'SUBCONTRACTOR', VENDOR: 'VENDOR', FIELD_AGENT: 'FIELD_AGENT', CUSTOMER: 'CUSTOMER', }; export const AuthProvider = ({ children }) => { const [user, setUser] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); // true until mount check resolves // ── Session Restore on Mount ────────────────────────────────────────────── // Calls GET /auth/me. If the access_token cookie is valid, restores the user. // If expired, the interceptor attempts /auth/refresh automatically. // If refresh also fails, this catch block runs and isLoading is set false. useEffect(() => { let cancelled = false; async function restoreSession() { try { const userData = await getMeApi(); if (!cancelled) { setUser(userData); setIsAuthenticated(true); logger.info('Session restored', { userId: userData.id, role: userData.role }); } } catch { // No valid session — that's fine, user will see login page if (!cancelled) { setUser(null); setIsAuthenticated(false); } } finally { if (!cancelled) setIsLoading(false); } } restoreSession(); return () => { cancelled = true; }; }, []); // ── Forced Logout Listener ──────────────────────────────────────────────── // Triggered by interceptors.js when the refresh token is also expired/invalid. useEffect(() => { const handleForceLogout = () => { setUser(null); setIsAuthenticated(false); toast.error('Session expired', { description: 'Please sign in again.', }); logger.info('Forced logout — refresh token invalid'); }; window.addEventListener('auth:logout-required', handleForceLogout); return () => window.removeEventListener('auth:logout-required', handleForceLogout); }, []); // ── Login ───────────────────────────────────────────────────────────────── // Returns { success: true, role } or { success: false, message } // Callers must await this function. const login = async (identifier, password, type) => { try { const data = await loginApi(identifier, password, type); setUser(data.user); setIsAuthenticated(true); logger.info('User logged in', { userId: data.user.id, role: data.user.role }); toast.success(`Welcome back, ${data.user.full_name}!`); return { success: true, role: data.user.role }; } catch (error) { const message = error.response?.data?.detail ?? 'Invalid credentials'; logger.warn('Failed login attempt', { identifier, type }); toast.error('Invalid credentials', { description: 'Please check your details and try again.', }); return { success: false, message }; } }; // ── Logout ──────────────────────────────────────────────────────────────── // Calls API first, then clears local state regardless of API result. const logout = async () => { try { await logoutApi(); } catch (error) { // API may fail if token already expired — still clear local state logger.warn('Logout API error (clearing state anyway)', error); } finally { setUser(null); setIsAuthenticated(false); logger.info('User logged out', { userId: user?.id }); toast.info('Logged out successfully'); } }; return ( {children} ); }; export const useAuth = () => useContext(AuthContext); ``` --- ## 5. `src/pages/Login.jsx` — Minimal Patches Only three changes needed. The UI, tabs, form, and `fillDemo()` function are **untouched**. ### Change 1 — `handleLogin` becomes async ```jsx // BEFORE const handleLogin = (e) => { e.preventDefault(); setError(''); if (!identifier || !password) { setError('Please fill in all fields'); return; } const result = login(identifier, password, loginType); if (result.success) { /* switch */ } else { setError(result.message); } }; // AFTER const [isSubmitting, setIsSubmitting] = useState(false); // add this state const handleLogin = async (e) => { e.preventDefault(); setError(''); if (!identifier || !password) { setError('Please fill in all fields'); return; } setIsSubmitting(true); const result = await login(identifier, password, loginType); setIsSubmitting(false); if (result.success) { switch (result.role) { case 'CUSTOMER': navigate('/portal/profile'); break; // was '/' (bug fix) case 'OWNER': navigate('/owner/snapshot'); break; case 'ADMIN': navigate('/admin/dashboard'); break; // was missing (fell to default) case 'CONTRACTOR': navigate('/contractor/dashboard'); break; case 'VENDOR': navigate('/vendor/dashboard'); break; case 'SUBCONTRACTOR': navigate('/subcontractor/dashboard'); break; default: navigate('/emp/fa/dashboard'); break; // FIELD_AGENT } } else { setError(result.message); } }; ``` ### Change 2 — Disable submit button while submitting Pass `isSubmitting` to the `RainbowButton` so it can't be double-clicked: ```jsx {isSubmitting ? Signing in… : <>Sign In} ``` ### Two Redirect Bugs Fixed by I1 | Role | Old redirect (mock) | Correct redirect (real API) | |------|--------------------|-----------------------------| | `CUSTOMER` | `/` (Landing page) | `/portal/profile` | | `ADMIN` | `/emp/fa/dashboard` (fell to default) | `/admin/dashboard` | These were harmless with mock data (ADMIN can access both routes) but must be corrected for the real system. --- ## 6. `src/App.jsx` — `ProtectedRoute` Patch Add the `isLoading` guard. Without it, every page refresh causes a flash-to-login before the session check completes. ```jsx // BEFORE const ProtectedRoute = ({ children, allowedRoles }) => { const { user, isAuthenticated } = useAuth(); const location = useLocation(); if (!isAuthenticated) { return ; } if (allowedRoles && !allowedRoles.includes(user.role)) { return ; } return children; }; // AFTER — add isLoading check const ProtectedRoute = ({ children, allowedRoles }) => { const { user, isAuthenticated, isLoading } = useAuth(); const location = useLocation(); // Wait for session restore before making any auth decisions if (isLoading) { return (
); } if (!isAuthenticated) { return ; } if (allowedRoles && !allowedRoles.includes(user.role)) { return ; } return children; }; ``` No other changes to `App.jsx`. --- ## 7. User Object Field Mapping The mock store user shape uses camelCase. The real API (`UserPublic` schema from B1) uses snake_case. Components that read from `user` directly may need updates. | Field purpose | Mock store field | Real API field (`UserPublic`) | |--------------|-----------------|-------------------------------| | Display name | `user.name` | `user.full_name` | | User UUID | `user.id` | `user.id` (same — UUID string) | | Role string | `user.role` | `user.role` (same) | | Employee ID | `user.empId` | `user.emp_id` | | Legacy mock ID | `user.legacyId` / `user.id` (e.g., `'e1'`) | `user.legacy_id` | | XP points | `user.xp` | `user.xp` (same) | | Streak | `user.streak` | `user.streak_days` | | Achievements | `user.achievements` | `user.achievements` (array of strings) | | Company | `user.company` | `user.company_name` | ### Where `user.name` is used in the codebase Search these files for `user.name` and change to `user.full_name` as part of I1: - `src/components/Layout.jsx` — sidebar user display - `src/pages/CustomerProfile.jsx` — profile header - `src/components/Chatbot.jsx` — user greeting > Other field renames (`empId`, `streak`) are only used in their respective feature pages and will be fixed by the module that integrates those pages (I4, I5). --- ## 8. Session Restore Behaviour ### On every app load / page refresh ``` App mounts → AuthProvider mounts → isLoading = true → GET /auth/me (cookie sent automatically by browser) → 200: setUser(data), setIsAuthenticated(true), setIsLoading(false) → 401 (expired): interceptors.js fires POST /auth/refresh → refresh 200: GET /auth/me retried → user restored → refresh 401: catch block runs → setIsAuthenticated(false), setIsLoading(false) → user sees login page ``` ### First visit / after logout ``` App mounts → GET /auth/me → 401 (no cookie) → interceptors.js does NOT attempt refresh (401 from /auth/me with no cookie) → catch block: setUser(null), setIsAuthenticated(false), setIsLoading(false) → ProtectedRoute: isLoading=false, isAuthenticated=false → Navigate /login ``` > The interceptor will only retry `/auth/me` if it gets a 401 with a cookie present (access token expired). With no cookie at all, the 401 propagates to the catch block immediately. --- ## 9. I1 Verification Checklist Before marking I1 complete: - [ ] `npm install` — axios is in `dependencies` - [ ] `src/api/auth.js` created with `loginApi`, `getMeApi`, `logoutApi` - [ ] `src/api/interceptors.js` and `src/api/client.js` exist (from I0) - [ ] `import './api/interceptors'` present at top of `src/main.jsx` - [ ] `AuthContext.jsx` no longer imports from `mockStore` - [ ] `isLoading` exported from `AuthContext.Provider` value - [ ] `ProtectedRoute` in `App.jsx` has the `isLoading` spinner guard - [ ] `Login.jsx` `handleLogin` is `async` and `await`s `login()` - [ ] `Login.jsx` CUSTOMER redirects to `/portal/profile` (not `/`) - [ ] `Login.jsx` ADMIN redirects to `/admin/dashboard` (not default) - [ ] Toast on login shows `data.user.full_name` (not `foundUser.name`) - [ ] Page refresh on a protected route restores session without flashing to `/login` - [ ] Logout clears cookies (verify in DevTools → Application → Cookies) - [ ] `fillDemo()` buttons still work (they call `login()` via the form — no changes needed) --- *Next: Read `02_data_layer_migration.md` (I2) — wrapping `MockStoreProvider` in a feature-flagged `ApiProvider` before any feature module (I3–I8) is integrated.*