- 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
16 KiB
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 twohttpOnlycookies - Call
GET /api/v1/auth/meon 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.jswhen 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
useMockStoreimport fromAuthContext.jsxupdateProfile()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.
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:
- Remove
useMockStoreimport - Add
isLoadingstate (startstrue, setfalseafter mount check) - Add
useEffectfor session restore — callsgetMeApi()on mount - Add
useEffectfor forced-logout event frominterceptors.js login()becomesasync, callsloginApi()logout()becomesasync, callslogoutApi()isLoadingis now exported in context value
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
// 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:
<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.
// 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 displaysrc/pages/CustomerProfile.jsx— profile headersrc/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/meif 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 independenciessrc/api/auth.jscreated withloginApi,getMeApi,logoutApisrc/api/interceptors.jsandsrc/api/client.jsexist (from I0)import './api/interceptors'present at top ofsrc/main.jsxAuthContext.jsxno longer imports frommockStoreisLoadingexported fromAuthContext.ProvidervalueProtectedRouteinApp.jsxhas theisLoadingspinner guardLogin.jsxhandleLoginisasyncandawaitslogin()Login.jsxCUSTOMER redirects to/portal/profile(not/)Login.jsxADMIN redirects to/admin/dashboard(not default)- Toast on login shows
data.user.full_name(notfoundUser.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 calllogin()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.