- Complete ProCanvas redesign with retro sports game aesthetic - Card-game style photo frame with tilt, shimmer, corner star accents - Daily Missions card with weekly challenge + individual quest progress bars - Log Action card (Door Knocked, Lead Gained, Appointment Set, Client Meeting) - Each log action opens themed modal with relevant input fields - Challenges modal with 6 active challenges and progress tracking - Achievements modal with all badges, unlock status, descriptions - Nav bar tabs (Leaderboard, Challenges, Achievements) wired to modals - Rewards & Checkpoints with named stages (Daily Grind to Legend Run) - Smoother Hot Streak pulse animation (2.5s float + 3s pulse rings) - Hit The Map button with smooth pulsing glow animation - Grid pattern overlay in Leaderboard card - Light mode support via dark: Tailwind variants throughout - Top 3 badges displayed on profile card - Fixed dropdown option visibility in dark mode
14 KiB
LLD — I1 Auth Integration
Scope: Replacing mock AuthContext with real JWT flow — state machine, session restore, login/logout sequences, ProtectedRoute guard
Companion doc: docs/integration/01_auth_integration.md
Diagram 1 — Before vs After: AuthContext Architecture
A side-by-side view of what changes and what stays the same.
flowchart LR
subgraph Before["BEFORE — Mock AuthContext"]
direction TB
B_MOCK["mockStore.users\n(17 in-memory users)"]
B_LOGIN["login(id, pass, type)\nSynchronous\nusers.find() in JS"]
B_STATE["useState:\nuser = null\nisAuthenticated = false\n(no isLoading)\n(no session restore)"]
B_LOGOUT["logout()\nSynchronous\nsetUser(null)"]
B_EXPORT["useAuth() exports:\nuser, isAuthenticated\nlogin, logout"]
B_MOCK --> B_LOGIN
B_LOGIN --> B_STATE
B_STATE --> B_LOGOUT
B_LOGOUT --> B_EXPORT
end
subgraph After["AFTER — Real JWT AuthContext"]
direction TB
A_API["src/api/auth.js\nloginApi()\ngetMeApi()\nlogoutApi()"]
A_LOGIN["login(id, pass, type)\nAsync\nPOST /auth/login"]
A_STATE["useState:\nuser = null\nisAuthenticated = false\nisLoading = true ← NEW"]
A_MOUNT["useEffect (mount)\nGET /auth/me\n→ session restore"]
A_FORCE["useEffect\nwindow 'auth:logout-required'\n← from interceptors.js"]
A_LOGOUT["logout()\nAsync\nPOST /auth/logout\nthen setUser(null)"]
A_EXPORT["useAuth() exports:\nuser, isAuthenticated\nisLoading ← NEW\nlogin, logout"]
A_API --> A_LOGIN
A_LOGIN --> A_STATE
A_STATE --> A_MOUNT
A_STATE --> A_FORCE
A_STATE --> A_LOGOUT
A_LOGOUT --> A_EXPORT
end
Before -->|"I1 migration"| After
subgraph Unchanged["Components — NO changes needed"]
U1["All pages reading\nuseAuth().user\nuseAuth().isAuthenticated"]
U2["fillDemo() buttons\nin Login.jsx"]
U3["ROLES constant\n(same 7 values)"]
end
Diagram 2 — AuthContext State Machine
The three states AuthProvider can be in, and the events that cause transitions.
stateDiagram-v2
[*] --> Loading : AuthProvider mounts\nisLoading = true
Loading --> Authenticated : GET /auth/me → 200\nOR refresh succeeded\nsetUser(data)\nsetIsAuthenticated(true)\nsetIsLoading(false)
Loading --> Unauthenticated : GET /auth/me → 401\nAND refresh failed\nOR no cookie present\nsetUser(null)\nsetIsAuthenticated(false)\nsetIsLoading(false)
Unauthenticated --> Authenticated : login() called\nPOST /auth/login → 200\nsetUser(data.user)\nsetIsAuthenticated(true)
Authenticated --> Unauthenticated : logout() called\nPOST /auth/logout\nsetUser(null)\nsetIsAuthenticated(false)
Authenticated --> Unauthenticated : 'auth:logout-required' event\ndispatched by interceptors.js\nwhen refresh token expired/invalid\nsetUser(null)\nsetIsAuthenticated(false)
note right of Loading
ProtectedRoute renders
a spinner while in
this state — never
redirects to /login
until Loading exits.
end note
note right of Authenticated
user object available.
Cookie present in browser.
All API calls succeed.
end note
note right of Unauthenticated
user = null.
No cookies.
ProtectedRoute redirects
to /login.
end note
Diagram 3 — Session Restore on Mount
The exact sequence when the app loads and finds an existing session cookie.
sequenceDiagram
participant Browser as Browser
participant App as App.jsx (React mount)
participant Auth as AuthProvider (useEffect)
participant API as GET /auth/me
participant Inter as interceptors.js
participant BE as FastAPI
Browser->>App: Page load / refresh
App->>Auth: AuthProvider mounts\nisLoading = true
Auth->>API: getMeApi()\napiClient.get('/auth/me')
Note over API: withCredentials=true\nbrowser attaches access_token cookie
API->>BE: GET /api/v1/auth/me\nCookie: access_token=<jwt>
alt Access token valid
BE-->>API: 200 UserPublic { id, role, full_name, ... }
API-->>Auth: data = UserPublic
Auth->>Auth: setUser(data)\nsetIsAuthenticated(true)\nsetIsLoading(false)
Note over App: ProtectedRoute:\nisLoading=false, isAuthenticated=true\n→ render page
else Access token expired (401)
BE-->>API: 401
API-->>Inter: Response interceptor triggered
Inter->>BE: POST /auth/refresh\nCookie: refresh_token=<jwt>
alt Refresh succeeds
BE-->>Inter: 200 new access_token cookie set
Inter->>BE: Retry GET /auth/me (with new cookie)
BE-->>Inter: 200 UserPublic
Inter-->>Auth: data = UserPublic
Auth->>Auth: setUser(data)\nsetIsAuthenticated(true)\nsetIsLoading(false)
else Refresh also fails
BE-->>Inter: 401
Inter->>Inter: dispatch('auth:logout-required')
Inter-->>Auth: Promise rejected
Auth->>Auth: catch block:\nsetUser(null)\nsetIsAuthenticated(false)\nsetIsLoading(false)
Note over App: ProtectedRoute:\nisLoading=false, isAuthenticated=false\n→ Navigate /login
end
else No cookie at all (first visit / after logout)
BE-->>API: 401 (no cookie)
Note over Inter: Interceptor checks _retry flag\nalready retried or no refresh cookie\n→ propagates error
API-->>Auth: catch block:\nsetUser(null)\nsetIsAuthenticated(false)\nsetIsLoading(false)
Note over App: ProtectedRoute → Navigate /login
end
Diagram 4 — Login Flow (Async)
The new async login sequence from form submit to page redirect.
sequenceDiagram
actor User as 👤 User
participant Form as Login.jsx form
participant AuthCtx as AuthContext.login()
participant AuthAPI as src/api/auth.js loginApi()
participant Client as apiClient
participant BE as POST /auth/login
User->>Form: Fill identifier + password\nselect loginType tab\nclick Sign In
Form->>Form: setIsSubmitting(true)\ndisable button
Form->>AuthCtx: await login(identifier, password, loginType)
AuthCtx->>AuthAPI: loginApi(identifier, password, type)
AuthAPI->>Client: apiClient.post('/auth/login',\n{ identifier, password, type })
Client->>BE: POST /api/v1/auth/login\n{ identifier, password, type }
alt Credentials valid
BE-->>Client: 200 { user: UserPublic, message: "Login successful" }\nSet-Cookie: access_token=<jwt>; httpOnly\nSet-Cookie: refresh_token=<jwt>; httpOnly; path=/api/v1/auth
Client-->>AuthAPI: { data: { user, message } }
AuthAPI-->>AuthCtx: data
AuthCtx->>AuthCtx: setUser(data.user)\nsetIsAuthenticated(true)
AuthCtx->>AuthCtx: toast.success('Welcome back, full_name!')
AuthCtx-->>Form: { success: true, role: 'FIELD_AGENT' }
Form->>Form: setIsSubmitting(false)
Form->>Form: switch(result.role)\nnavigate('/emp/fa/dashboard')
else Invalid credentials
BE-->>Client: 401 { detail: "Invalid credentials" }
Client-->>AuthAPI: AxiosError (401)
AuthAPI-->>AuthCtx: throws
AuthCtx->>AuthCtx: message = error.response.data.detail\ntost.error('Invalid credentials')
AuthCtx-->>Form: { success: false, message: "Invalid credentials" }
Form->>Form: setIsSubmitting(false)\nsetError(result.message)
Form-->>User: Error banner shown
end
Diagram 5 — Role Redirect Decision Tree (Login.jsx)
What happens after a successful login response based on result.role.
flowchart TD
SUCCESS["login() returns\n{ success: true, role }"]
SUCCESS --> SW{result.role}
SW -->|CUSTOMER| R1["navigate('/portal/profile')\n← FIXED from '/' (bug)"]
SW -->|OWNER| R2["navigate('/owner/snapshot')"]
SW -->|ADMIN| R3["navigate('/admin/dashboard')\n← FIXED from default (bug)"]
SW -->|CONTRACTOR| R4["navigate('/contractor/dashboard')"]
SW -->|VENDOR| R5["navigate('/vendor/dashboard')"]
SW -->|SUBCONTRACTOR| R6["navigate('/subcontractor/dashboard')"]
SW -->|default FIELD_AGENT| R7["navigate('/emp/fa/dashboard')"]
subgraph Bugs["Two redirect bugs fixed by I1"]
B1["CUSTOMER: '/' → Landing page\nShould be '/portal/profile'"]
B2["ADMIN: fell to default → /emp/fa/dashboard\nShould be '/admin/dashboard'"]
end
subgraph FillDemo["fillDemo() — unchanged"]
FD["fillDemo('customer') sets loginType + identifier + password\nForm submits normally → handleLogin runs\nSame redirect logic applies"]
end
Diagram 6 — Logout Flow
The new async logout — API call first, state clear regardless of outcome.
sequenceDiagram
actor User as 👤 User
participant UI as Any component calling logout()
participant AuthCtx as AuthContext.logout()
participant AuthAPI as src/api/auth.js logoutApi()
participant BE as POST /auth/logout
User->>UI: Click logout button
UI->>AuthCtx: await logout()
AuthCtx->>AuthAPI: logoutApi()
AuthAPI->>BE: POST /api/v1/auth/logout\nCookie: access_token=<jwt>
alt Logout API succeeds
BE-->>AuthAPI: 200 { message: "Logged out successfully" }\nSet-Cookie: access_token=; max_age=0 (cleared)\nSet-Cookie: refresh_token=; max_age=0 (cleared)
Note over BE: Also sets users.refresh_token_hash = NULL in DB
AuthAPI-->>AuthCtx: success
else Logout API fails (token already expired)
BE-->>AuthAPI: 401
AuthAPI-->>AuthCtx: error caught — logged as warning
Note over AuthCtx: Still clears local state below
end
AuthCtx->>AuthCtx: finally block:\nsetUser(null)\nsetIsAuthenticated(false)\ntoast.info('Logged out successfully')
AuthCtx-->>UI: resolved
Note over UI: ProtectedRoute detects\nisAuthenticated=false\n→ Navigate to /login
Diagram 7 — Forced Logout Event Flow
When the refresh token expires or a reuse attack is detected, the interceptor forces a logout without any user action.
sequenceDiagram
participant Comp as Any React Component
participant Client as apiClient
participant Inter as interceptors.js
participant BE_API as Any API endpoint
participant BE_REF as POST /auth/refresh
participant Auth as AuthContext
Comp->>Client: apiClient.get('/properties')
Client->>BE_API: GET /api/v1/properties\n(access_token expired)
BE_API-->>Client: 401
Client->>Inter: Response interceptor triggered
Note over Inter: _retry = false → attempt refresh
Inter->>BE_REF: POST /auth/refresh\nCookie: refresh_token=<jwt>
alt Refresh token also expired
BE_REF-->>Inter: 401 "Invalid or expired refresh token"
else Reuse attack detected
BE_REF-->>Inter: 401 "Refresh token reuse detected"
Note over BE_REF: DB: users.refresh_token_hash = NULL\nAll sessions revoked
end
Inter->>Inter: processQueue(error)\nfailure on all queued requests
Inter->>Inter: window.dispatchEvent(\n new CustomEvent('auth:logout-required')\n)
Inter-->>Comp: Promise.reject(refreshError)
Note over Auth: 'auth:logout-required' event listener\n(registered in AuthProvider useEffect)
Auth->>Auth: handleForceLogout()\nsetUser(null)\nsetIsAuthenticated(false)\ntoast.error('Session expired')
Note over Comp: ProtectedRoute detects\nisAuthenticated=false\n→ Navigate /login
Diagram 8 — ProtectedRoute Decision Tree (With isLoading)
The updated guard logic in App.jsx. The isLoading check is the only change.
flowchart TD
MOUNT["ProtectedRoute renders\n(on every navigation + page refresh)"]
MOUNT --> LOADING{isLoading?}
LOADING -->|true| SPINNER["Render full-screen spinner\n(session check in progress)\nDo NOT redirect yet"]
LOADING -->|false| AUTH{isAuthenticated?}
AUTH -->|false| REDIRECT_LOGIN["<Navigate to='/login'\nstate={{ from: location }}\nreplace />"]
AUTH -->|true| ROLES{allowedRoles\nprovided?}
ROLES -->|No — public-ish protected route| RENDER["Render children"]
ROLES -->|Yes| MATCH{user.role\nin allowedRoles?}
MATCH -->|Yes| RENDER2["Render children"]
MATCH -->|No — wrong role| REDIRECT_HOME["<Navigate to='/' replace />\n(redirects to Landing page)"]
subgraph WhyIsLoadingMatters["Why isLoading matters"]
W1["Without isLoading guard:\nPage refresh → isAuthenticated=false (initial state)\n→ ProtectedRoute immediately redirects to /login\n→ Session restore completes 200ms later\n→ User sees login flash before their page loads"]
W2["With isLoading guard:\nPage refresh → spinner shown\n→ Session restore completes\n→ isLoading=false, isAuthenticated=true\n→ Protected page renders normally"]
end
Diagram 9 — User Object Shape Transformation
How the mock user fields map to real API fields, and which components are affected.
flowchart LR
subgraph MockShape["Mock Store User Shape (camelCase)"]
M1["id: 'e1' (string, not UUID)"]
M2["name: 'Alice Johnson'"]
M3["role: 'CUSTOMER'"]
M4["empId: 'FA001'"]
M5["type: 'customer'"]
M6["xp: 3400"]
M7["streak: 7"]
M8["company: 'ABC Corp'"]
M9["password: 'password' ← in memory!"]
end
subgraph RealShape["Real API UserPublic Shape (snake_case)"]
R1["id: '3f8a1c2d-...' (UUID string)"]
R2["full_name: 'Alice Johnson'"]
R3["role: 'CUSTOMER'"]
R4["emp_id: null (CUSTOMER has no emp_id)"]
R5["legacy_id: 'cus_001'"]
R6["xp: 3400"]
R7["streak_days: 7"]
R8["company_name: 'ABC Corp'"]
R9["(password never returned)"]
end
subgraph I1Fixes["Fixed in I1"]
F1["AuthContext toast:\nuser.name → user.full_name"]
F2["Layout.jsx sidebar:\nuser.name → user.full_name"]
F3["CustomerProfile.jsx header:\nuser.name → user.full_name"]
F4["Chatbot.jsx greeting:\nuser.name → user.full_name"]
end
subgraph LaterFixes["Fixed in later modules"]
L1["user.empId → user.emp_id\n(I4 — Users integration)"]
L2["user.streak → user.streak_days\n(I4 — Users integration)"]
L3["user.id from 'e1' → UUID\n(via legacy_id bridge — I2)"]
end
MockShape -->|"I1 replaces"| RealShape
RealShape --> I1Fixes
RealShape --> LaterFixes