From 31fadc1b11cc08d786709d0bed31a90df9c9af10 Mon Sep 17 00:00:00 2001 From: Yamamoto Date: Sat, 3 Jan 2026 09:33:55 -0300 Subject: [PATCH] feat(auth): migrate sessionStorage to localStorage and add refreshSession() - Replace sessionStorage with localStorage for user data persistence - Add refreshSession() function to restore session from HTTPOnly cookie via /users/me - Update tests to use localStorage mocks - Add 3 new tests for refreshSession() functionality - Update superadmin credentials in README.md and DEVOPS.md --- README.md | 18 +++++--- docs/DEVOPS.md | 8 +++- frontend/src/lib/__tests__/auth.test.ts | 60 ++++++++++++++++++++----- frontend/src/lib/auth.ts | 60 ++++++++++++++++++++++--- 4 files changed, 122 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 9a14c45..5e3f1a7 100644 --- a/README.md +++ b/README.md @@ -236,12 +236,20 @@ cd seeder-api && npm install && npm run seed ## 🔑 Credenciais de Teste -| Tipo | Login | Senha | Acesso | +> [!NOTE] +> O SuperAdmin foi atualizado via migration `032_update_superadmin_lol.sql`. +> No primeiro login será necessário trocar a senha (status `force_change_password`). + +| Tipo | Login | Email | Acesso | |------|-------|-------|--------| -| **SuperAdmin** | `superadmin` | `Admin@2025!` | Full | -| **Company Admin** | `takeshi_yamamoto` | `Takeshi@2025` | Empresa | -| **Recruiter** | `maria_santos` | `User@2025` | Vagas | -| **Candidate** | `paulo_santos` | `User@2025` | Candidato | +| **SuperAdmin** | `lol` | `lol@gohorsejobs.com` | Full | +| **Company Admin** | `takeshi_yamamoto` | - | Empresa | +| **Recruiter** | `maria_santos` | - | Vagas | +| **Candidate** | `paulo_santos` | - | Candidato | + +**Senhas padrão:** +- SuperAdmin: *trocar no primeiro acesso* +- Demais usuários: `User@2025` ou `Takeshi@2025` --- diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 650d93c..9e8ea07 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -228,9 +228,13 @@ npm run seed ## 👤 Test Users +> **Nota:** O SuperAdmin foi atualizado via migration `032_update_superadmin_lol.sql`. + ### SuperAdmin -- **Login:** `superadmin` -- **Password:** `Admin@2025!` +- **Login:** `lol` +- **Email:** `lol@gohorsejobs.com` +- **Nome:** Dr. Horse Expert +- **Password:** *trocar no primeiro acesso* (status `force_change_password`) ### Company Admin | Login | Password | diff --git a/frontend/src/lib/__tests__/auth.test.ts b/frontend/src/lib/__tests__/auth.test.ts index 21a8d58..6b1eb5c 100644 --- a/frontend/src/lib/__tests__/auth.test.ts +++ b/frontend/src/lib/__tests__/auth.test.ts @@ -4,14 +4,14 @@ import { RegisterCandidateData } from '../auth'; const mockFetch = jest.fn(); global.fetch = mockFetch; -// Mock sessionStorage -const sessionStorageMock = { +// Mock localStorage +const localStorageMock = { getItem: jest.fn(), setItem: jest.fn(), removeItem: jest.fn(), clear: jest.fn(), }; -Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock }); +Object.defineProperty(window, 'localStorage', { value: localStorageMock }); // Mock config module to avoid initConfig fetching jest.mock('../config', () => ({ @@ -26,9 +26,9 @@ describe('Auth Module', () => { beforeEach(() => { jest.resetModules(); mockFetch.mockReset(); - sessionStorageMock.getItem.mockReset(); - sessionStorageMock.setItem.mockReset(); - sessionStorageMock.removeItem.mockReset(); + localStorageMock.getItem.mockReset(); + localStorageMock.setItem.mockReset(); + localStorageMock.removeItem.mockReset(); // Re-import the module fresh authModule = require('../auth'); @@ -149,12 +149,12 @@ describe('Auth Module', () => { expect(user).toBeDefined(); expect(user?.email).toBe('test@example.com'); - expect(sessionStorageMock.setItem).toHaveBeenCalledWith( + expect(localStorageMock.setItem).toHaveBeenCalledWith( 'job-portal-auth', expect.any(String) ); // Token is in cookie, not in storage - expect(sessionStorageMock.setItem).not.toHaveBeenCalledWith( + expect(localStorageMock.setItem).not.toHaveBeenCalledWith( 'auth_token', expect.any(String) ); @@ -176,15 +176,51 @@ describe('Auth Module', () => { it('should remove auth data from localStorage', () => { authModule.logout(); - expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('job-portal-auth'); - // expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('auth_token'); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('job-portal-auth'); + }); + }); + + describe('refreshSession', () => { + it('should restore user from /users/me endpoint', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + id: '123', + name: 'Test User', + email: 'test@example.com', + roles: ['candidate'], + }), + }); + + const user = await authModule.refreshSession(); + + expect(user).toBeDefined(); + expect(user?.email).toBe('test@example.com'); + expect(localStorageMock.setItem).toHaveBeenCalled(); + }); + + it('should clear storage on 401', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + + const user = await authModule.refreshSession(); + + expect(user).toBeNull(); + expect(localStorageMock.removeItem).toHaveBeenCalledWith('job-portal-auth'); + }); + + it('should return null on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const user = await authModule.refreshSession(); + + expect(user).toBeNull(); }); }); describe('getCurrentUser', () => { it('should return user from localStorage', () => { const storedUser = { id: '123', name: 'Test', email: 'test@test.com', role: 'candidate' }; - sessionStorageMock.getItem.mockReturnValueOnce(JSON.stringify(storedUser)); + localStorageMock.getItem.mockReturnValueOnce(JSON.stringify(storedUser)); const user = authModule.getCurrentUser(); @@ -192,7 +228,7 @@ describe('Auth Module', () => { }); it('should return null when no user stored', () => { - sessionStorageMock.getItem.mockReturnValueOnce(null); + localStorageMock.getItem.mockReturnValueOnce(null); const user = authModule.getCurrentUser(); diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 4ebbaab..a019c82 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -66,7 +66,7 @@ export async function login( // Store user info in sessionStorage (not token - token is in httpOnly cookie) if (typeof window !== "undefined") { - sessionStorage.setItem(AUTH_KEY, JSON.stringify(user)); + localStorage.setItem(AUTH_KEY, JSON.stringify(user)); } return user; @@ -78,7 +78,7 @@ export async function login( export async function logout(): Promise { if (typeof window !== "undefined") { - sessionStorage.removeItem(AUTH_KEY); + localStorage.removeItem(AUTH_KEY); // Call backend to clear the httpOnly cookie try { await fetch(`${getApiV1Url()}/auth/logout`, { @@ -93,17 +93,67 @@ export async function logout(): Promise { export function getCurrentUser(): User | null { if (typeof window !== "undefined") { - const stored = sessionStorage.getItem(AUTH_KEY); + const stored = localStorage.getItem(AUTH_KEY); if (stored) { const user = JSON.parse(stored); - console.log("%c[AUTH] User Loaded from Session", "color: #10b981", user.email); + console.log("%c[AUTH] User Loaded from Storage", "color: #10b981", user.email); return user; } } - console.warn("%c[AUTH] No user found in session", "color: #f59e0b"); + console.warn("%c[AUTH] No user found in storage", "color: #f59e0b"); return null; } +// Helper function to map backend roles to frontend role +function mapRoleFromBackend(roles: string[]): "candidate" | "admin" | "company" | "superadmin" { + if (roles.includes("superadmin") || roles.includes("SUPERADMIN")) { + return "superadmin"; + } + if (roles.includes("admin") || roles.includes("recruiter")) { + return "company"; + } + return "candidate"; +} + +/** + * Refreshes the session by calling /users/me with the HTTPOnly cookie. + * Use this on app mount to restore session across tabs/reloads. + */ +export async function refreshSession(): Promise { + try { + console.log("%c[AUTH] Attempting to refresh session...", "color: #3b82f6"); + const res = await fetch(`${getApiV1Url()}/users/me`, { + method: "GET", + credentials: "include", // Send HTTPOnly cookie + }); + + if (!res.ok) { + // Cookie expired or invalid - clear local storage + console.warn("%c[AUTH] Session refresh failed:", "color: #f59e0b", res.status); + localStorage.removeItem(AUTH_KEY); + return null; + } + + const userData = await res.json(); + + const user: User = { + id: userData.id, + name: userData.name || userData.full_name, + email: userData.email, + role: mapRoleFromBackend(userData.roles || []), + roles: userData.roles || [], + profileComplete: 80, + }; + + localStorage.setItem(AUTH_KEY, JSON.stringify(user)); + console.log("%c[AUTH] Session restored from cookie", "color: #10b981", user.email); + return user; + } catch (error) { + console.error("%c[AUTH] Failed to refresh session:", "color: #ef4444", error); + return null; + } +} + export function isAdminUser(user: User | null): boolean { if (!user) return false; const roles = user.roles || [];