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
This commit is contained in:
Yamamoto 2026-01-03 09:33:55 -03:00
parent 1f9aacf81b
commit 31fadc1b11
4 changed files with 122 additions and 24 deletions

View file

@ -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`
---

View file

@ -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 |

View file

@ -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();

View file

@ -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<void> {
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<void> {
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<User | null> {
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 || [];