feat(ProCanvas): retro game overhaul with log actions, challenges & achievements modals
- 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
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
# 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.
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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`.
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user