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:
parent
1f9aacf81b
commit
31fadc1b11
4 changed files with 122 additions and 24 deletions
18
README.md
18
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`
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 || [];
|
||||
|
|
|
|||
Loading…
Reference in a new issue