Files
LynkedUpPro_CRM/docs/integration/01_auth_integration.md
T
Satyam 7694788387 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
2026-02-26 01:36:36 +05:30

16 KiB
Raw Blame History

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 booleantrue 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 PatchhandleLogin → async, correct ADMIN redirect, add submit loading state
src/App.jsx PatchProtectedRoute 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:

  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
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.jsxProtectedRoute 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 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 awaits 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 (I3I8) is integrated.