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,440 @@
|
||||
# I0 — Integration Overview
|
||||
|
||||
**Module:** I0 — Integration Team Foundation
|
||||
**Companion backend doc:** `docs/backend/00_project_overview.md` (B0)
|
||||
**Status:** Integration Team reads this first, before touching any frontend code.
|
||||
|
||||
---
|
||||
|
||||
## What This Module Covers
|
||||
|
||||
This document establishes the shared foundation every integration module (`I1`–`I8`) will build on:
|
||||
|
||||
1. API client setup — Axios instance, base URL, default headers
|
||||
2. Auth token strategy — httpOnly cookies, no localStorage
|
||||
3. Global 401 interceptor — automatic token refresh + request retry
|
||||
4. Environment variable setup
|
||||
5. Loading and error state conventions
|
||||
6. The mock-to-real migration pattern (how to replace mockStore piece by piece)
|
||||
7. Role-redirect logic (how to stay in sync with `Login.jsx`)
|
||||
|
||||
Read this document fully before working on any `I1`–`I8` module.
|
||||
|
||||
---
|
||||
|
||||
## 1. Environment Variables
|
||||
|
||||
The frontend uses Vite. All client-side environment variables must be prefixed with `VITE_`.
|
||||
|
||||
**Add to `.env.local` (never commit this file):**
|
||||
|
||||
```env
|
||||
# Backend API
|
||||
VITE_API_URL=http://localhost:8000/api/v1
|
||||
|
||||
# External (already present — do not change)
|
||||
VITE_GROQ_API_KEY=your_groq_key_here
|
||||
VITE_OPENWEATHER_API_KEY=your_openweather_key_here
|
||||
```
|
||||
|
||||
**Add to `.env.example` (commit this as a template):**
|
||||
|
||||
```env
|
||||
# Backend API base URL
|
||||
VITE_API_URL=http://localhost:8000/api/v1
|
||||
```
|
||||
|
||||
**Access in code:**
|
||||
|
||||
```js
|
||||
// src/config/env.js (add alongside existing exports)
|
||||
export const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000/api/v1';
|
||||
```
|
||||
|
||||
> The `?? fallback` prevents crashes during local dev if `.env.local` is missing. In production on Vercel, `VITE_API_URL` must be set as an environment variable in the Vercel dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 2. API Client Setup
|
||||
|
||||
Create one shared Axios instance. Every integration module imports from this file — never call `axios.create()` again elsewhere.
|
||||
|
||||
**File to create:** `src/api/client.js`
|
||||
|
||||
```js
|
||||
import axios from 'axios';
|
||||
import { API_URL } from '../config/env';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_URL,
|
||||
withCredentials: true, // sends httpOnly cookies automatically
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
export default apiClient;
|
||||
```
|
||||
|
||||
### Why `withCredentials: true`?
|
||||
|
||||
The backend sets the access token and refresh token as `httpOnly` cookies (see B1). The browser will not include these cookies on cross-origin requests unless `withCredentials: true` is set. This is the only auth mechanism — there is no `Authorization: Bearer` header and no `localStorage`.
|
||||
|
||||
### What NOT to do
|
||||
|
||||
```js
|
||||
// ❌ Never do this
|
||||
localStorage.setItem('access_token', token);
|
||||
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
|
||||
// ✅ Correct — the cookie is set by the backend, browser sends it automatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Global 401 Interceptor — Token Refresh and Retry
|
||||
|
||||
When the access token expires, the backend returns `401`. The client must silently refresh the token and retry the original request. This logic lives in one place: `src/api/interceptors.js`.
|
||||
|
||||
**File to create:** `src/api/interceptors.js`
|
||||
|
||||
```js
|
||||
import apiClient from './client';
|
||||
|
||||
let isRefreshing = false;
|
||||
let failedQueue = []; // requests that arrived while refresh was in-flight
|
||||
|
||||
function processQueue(error, token = null) {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(token);
|
||||
}
|
||||
});
|
||||
failedQueue = [];
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Only handle 401s that haven't already been retried
|
||||
if (error.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// If a refresh is already in progress, queue this request
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject });
|
||||
}).then(() => apiClient(originalRequest));
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
// POST /auth/refresh — backend rotates the cookie automatically
|
||||
await apiClient.post('/auth/refresh');
|
||||
processQueue(null);
|
||||
return apiClient(originalRequest); // retry original request
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError);
|
||||
// Refresh failed → force logout
|
||||
window.dispatchEvent(new CustomEvent('auth:logout-required'));
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
```
|
||||
|
||||
**Register interceptors in your app entry point.** Import this file once at the top of `src/main.jsx` (or `src/App.jsx`):
|
||||
|
||||
```js
|
||||
// src/main.jsx — add this import at the top
|
||||
import './api/interceptors';
|
||||
```
|
||||
|
||||
> The `failedQueue` pattern prevents a thunderstorm of refresh requests when multiple concurrent API calls all get 401 at once. Only one refresh call is made; all others wait in the queue and retry together.
|
||||
|
||||
### Listening for the forced-logout event
|
||||
|
||||
In `AuthContext.jsx`, add a listener for the custom event dispatched when refresh fails:
|
||||
|
||||
```js
|
||||
// Inside AuthProvider useEffect
|
||||
useEffect(() => {
|
||||
const handleForceLogout = () => {
|
||||
logout(); // existing logout function
|
||||
};
|
||||
window.addEventListener('auth:logout-required', handleForceLogout);
|
||||
return () => window.removeEventListener('auth:logout-required', handleForceLogout);
|
||||
}, []);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Module Files
|
||||
|
||||
Each integration module (`I1`–`I8`) will create one `src/api/*.js` file. These are thin wrappers around `apiClient` — no business logic, no state. Example structure:
|
||||
|
||||
```
|
||||
src/api/
|
||||
├── client.js ← Axios instance (shared)
|
||||
├── interceptors.js ← 401 handler + refresh (imported once in main.jsx)
|
||||
├── auth.js ← I1: login, logout, refresh, getMe
|
||||
├── properties.js ← I3: getProperties, getProperty, assignAgent, updateStatus
|
||||
├── users.js ← I4: getUsers, getUser, updateUser
|
||||
├── meetings.js ← I5: getMeetings, createMeeting, updateMeeting, requestChange
|
||||
├── vendors.js ← I6: getVendors, getVendor, uploadComplianceDoc
|
||||
├── invoices.js ← I7: getInvoices, createInvoice, approveInvoice
|
||||
└── chatbot.js ← I8: sendMessage (streaming)
|
||||
```
|
||||
|
||||
**Convention for every API file:**
|
||||
|
||||
```js
|
||||
// src/api/properties.js — example pattern
|
||||
import apiClient from './client';
|
||||
|
||||
export async function getProperties(params = {}) {
|
||||
const { data } = await apiClient.get('/properties', { params });
|
||||
return data; // { data: [...], meta: { total, page, page_size } }
|
||||
}
|
||||
|
||||
export async function getProperty(id) {
|
||||
const { data } = await apiClient.get(`/properties/${id}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function assignAgent(propertyId, agentId) {
|
||||
const { data } = await apiClient.patch(`/properties/${propertyId}`, {
|
||||
assigned_agent_id: agentId,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
Always destructure `{ data }` from the Axios response — the actual payload is in `response.data`, not `response` itself.
|
||||
|
||||
---
|
||||
|
||||
## 5. Loading and Error State Conventions
|
||||
|
||||
Every component that fetches data should follow this pattern. Use React's built-in `useState` + `useEffect` — no new libraries needed at this stage.
|
||||
|
||||
### Standard fetch pattern
|
||||
|
||||
```js
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await getProperties({ status: 'Hot Lead' });
|
||||
if (!cancelled) setData(result.data);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err.response?.data?.detail ?? 'Failed to load data');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
```
|
||||
|
||||
The `cancelled` flag prevents state updates on unmounted components (React strict mode issue).
|
||||
|
||||
### Error shape from the backend
|
||||
|
||||
```js
|
||||
// All backend errors follow this shape (from B0):
|
||||
// { detail: "Human-readable message", code: "MACHINE_CODE" }
|
||||
|
||||
// Access in catch block:
|
||||
const message = err.response?.data?.detail ?? 'An unexpected error occurred';
|
||||
const code = err.response?.data?.code; // e.g. "PERMISSION_DENIED"
|
||||
|
||||
// HTTP status codes:
|
||||
// 400 — Validation error (bad request body)
|
||||
// 401 — Not authenticated / token expired
|
||||
// 403 — Authenticated but forbidden (wrong role)
|
||||
// 404 — Resource not found
|
||||
// 422 — FastAPI schema validation failed
|
||||
// 429 — Rate limited
|
||||
// 500 — Server error
|
||||
```
|
||||
|
||||
### Loading UI
|
||||
|
||||
Use the existing `LoadingSpinner` component (or the role-appropriate skeleton) that is already present in the codebase. Do not add new spinner libraries.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mock-to-Real Migration Pattern
|
||||
|
||||
The frontend currently runs entirely off `src/data/mockStore.jsx` through `MockStoreProvider`. The migration approach is **module by module** — never rip out the whole mock layer at once.
|
||||
|
||||
### The three phases of each module swap
|
||||
|
||||
**Phase 1 — Feature-flagged (default OFF)**
|
||||
|
||||
Each integration module starts with a feature flag so the mock data remains the fallback during development:
|
||||
|
||||
```js
|
||||
// src/config/env.js — add this
|
||||
export const FEATURE_REAL_API = import.meta.env.VITE_FEATURE_REAL_API === 'true';
|
||||
```
|
||||
|
||||
```env
|
||||
# .env.local — set to true when you're ready to test a module
|
||||
VITE_FEATURE_REAL_API=false
|
||||
```
|
||||
|
||||
**Phase 2 — Swap one data source at a time**
|
||||
|
||||
For example, when doing I3 (Properties), update only the properties data hook:
|
||||
|
||||
```js
|
||||
// src/hooks/useProperties.js — new file created by I3
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FEATURE_REAL_API } from '../config/env';
|
||||
import { getProperties } from '../api/properties';
|
||||
import { useMockStore } from '../context/MockStoreContext';
|
||||
|
||||
export function useProperties(filters = {}) {
|
||||
const mockStore = useMockStore();
|
||||
|
||||
if (!FEATURE_REAL_API) {
|
||||
// Return mock data in the same shape as the real API
|
||||
return {
|
||||
data: mockStore.properties.filter(/* apply filters */),
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Real API path (only when flag is true)
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
// ... useEffect fetch ...
|
||||
return { data, loading, error };
|
||||
}
|
||||
```
|
||||
|
||||
> Each integration module's spec (I1–I8) will define the exact hook shape and what filters to support.
|
||||
|
||||
**Phase 3 — Remove the mock branch**
|
||||
|
||||
Once QA confirms the real API works correctly, delete the mock branch and the feature flag check. The `MockStoreProvider` can be removed only when all modules are migrated (I1 → I8 all complete).
|
||||
|
||||
### Shape matching rule
|
||||
|
||||
The real API response shape must always match the mock data shape that components already consume. If the backend returns a field name differently (e.g., `agent_id` vs `agentId`), transform it in the API layer (`src/api/*.js`), not in the component.
|
||||
|
||||
```js
|
||||
// src/api/properties.js — transform snake_case to camelCase if needed
|
||||
export async function getProperties(params = {}) {
|
||||
const { data } = await apiClient.get('/properties', { params });
|
||||
return {
|
||||
...data,
|
||||
data: data.data.map((p) => ({
|
||||
...p,
|
||||
agentId: p.assigned_agent_id, // normalize for components
|
||||
marketValue: p.estimated_market_value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Role → Redirect Map
|
||||
|
||||
The backend returns a `role` field in the `/auth/me` and `/auth/login` responses. The frontend uses this to redirect users to their portal. This map must stay in sync with the switch statement in `src/pages/Login.jsx`.
|
||||
|
||||
| Role | Redirect path |
|
||||
|------|--------------|
|
||||
| `OWNER` | `/owner/snapshot` |
|
||||
| `ADMIN` | `/admin/dashboard` |
|
||||
| `FIELD_AGENT` | `/emp/fa/dashboard` |
|
||||
| `CONTRACTOR` | `/contractor/dashboard` |
|
||||
| `SUBCONTRACTOR` | `/subcontractor/dashboard` |
|
||||
| `VENDOR` | `/vendor/dashboard` |
|
||||
| `CUSTOMER` | `/portal/profile` |
|
||||
|
||||
These exact strings are used in `src/context/AuthContext.jsx` (the `ROLE_ROUTES` map) after I1 replaces the login flow.
|
||||
|
||||
---
|
||||
|
||||
## 8. Dependency Installation
|
||||
|
||||
Only one new package is needed: Axios. Everything else (React Query, SWR, etc.) is explicitly out of scope for the integration phase — keep the state management approach simple and consistent with the existing codebase.
|
||||
|
||||
```bash
|
||||
npm install axios
|
||||
```
|
||||
|
||||
Verify it was added to `package.json` `dependencies` (not `devDependencies`).
|
||||
|
||||
---
|
||||
|
||||
## 9. File Creation Checklist for I0
|
||||
|
||||
Before moving to I1, confirm all of the following are created or updated:
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `.env.local` | Add `VITE_API_URL=http://localhost:8000/api/v1` |
|
||||
| `.env.example` | Add `VITE_API_URL=` (no value) |
|
||||
| `src/config/env.js` | Export `API_URL` and `FEATURE_REAL_API` |
|
||||
| `src/api/client.js` | Create Axios instance with `withCredentials: true` |
|
||||
| `src/api/interceptors.js` | Create 401 interceptor with queue + retry logic |
|
||||
| `src/main.jsx` | Add `import './api/interceptors'` at top |
|
||||
| `src/context/AuthContext.jsx` | Add `auth:logout-required` event listener |
|
||||
| `package.json` | `axios` present in `dependencies` |
|
||||
|
||||
---
|
||||
|
||||
## 10. What Each Integration Module Builds On Top of This
|
||||
|
||||
| Module | What it adds |
|
||||
|--------|-------------|
|
||||
| **I1** — Auth Integration | Replaces `AuthContext` login/logout with real `/auth/login`, `/auth/logout`, `/auth/me` calls |
|
||||
| **I2** — Data Layer Migration | Wraps `MockStoreProvider` in a feature-flagged `ApiProvider`; establishes hook conventions |
|
||||
| **I3** — Properties | Hooks for map + property CRUD; geospatial filter params; lead status updates |
|
||||
| **I4** — Users & People | Hooks for `PeopleDirectory`, `VendorDirectory`; masked field handling |
|
||||
| **I5** — Meetings | Hooks for `AdminSchedule`, meeting status transitions, change request flow |
|
||||
| **I6** — Vendors & Compliance | Hooks for vendor CRUD, COI upload, compliance badge logic |
|
||||
| **I7** — Financial | Hooks for invoice tables, AR/AP views, payout approval flow |
|
||||
| **I8** — Chatbot | Streaming response from `/chatbot/message`; replaces direct Groq SDK calls |
|
||||
|
||||
---
|
||||
|
||||
## 11. Key Constraints — Do Not Violate
|
||||
|
||||
1. **Never store tokens in `localStorage` or `sessionStorage`.** The httpOnly cookie is set by the backend. The frontend never touches it directly.
|
||||
2. **Never call `axios.create()` outside of `src/api/client.js`.** All API calls go through the shared instance so the interceptor works.
|
||||
3. **Never modify `src/data/mockStore.jsx` during integration.** Mock data is the reference — it must remain intact as a fallback.
|
||||
4. **Never remove `MockStoreProvider` from `main.jsx` until I2 is fully complete** and all feature flags have been validated.
|
||||
5. **Money values arrive in cents from the API.** Divide by 100 before display. Never send or store display-formatted dollar values back to the API.
|
||||
6. **All timestamps are ISO 8601 UTC.** Use `new Date(isoString).toLocaleDateString()` or the existing date util for display formatting — do not use raw strings.
|
||||
|
||||
---
|
||||
|
||||
*Document maintained by Integration Team — mark complete in `docs/README.md` when all files in Section 9 checklist are created and verified.*
|
||||
@@ -0,0 +1,422 @@
|
||||
# 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 (
|
||||
<AuthContext.Provider value={{ user, isAuthenticated, isLoading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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
|
||||
<RainbowButton
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="mt-4 text-white py-4 md:py-5 text-base md:text-lg"
|
||||
>
|
||||
{isSubmitting ? <span>Signing in…</span> : <><span>Sign In</span><ArrowRight size={20} /></>}
|
||||
</RainbowButton>
|
||||
```
|
||||
|
||||
### 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 <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
if (allowedRoles && !allowedRoles.includes(user.role)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
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 (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
if (allowedRoles && !allowedRoles.includes(user.role)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
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.*
|
||||
Reference in New Issue
Block a user