diff --git a/frontend/src/contexts/notification-context.tsx b/frontend/src/contexts/notification-context.tsx index 5659c71..6f59f31 100644 --- a/frontend/src/contexts/notification-context.tsx +++ b/frontend/src/contexts/notification-context.tsx @@ -31,11 +31,24 @@ export function NotificationProvider({ useEffect(() => { const loadNotifications = async () => { + // Only load notifications if user is authenticated + const user = getCurrentUser(); + if (!user) { + return; + } + try { const data = await notificationsApi.list(); setNotifications(data || []); - } catch (error) { - console.error("Failed to load notifications:", error); + } catch (error: any) { + // Silently handle 401 errors - user is not authenticated + if (error?.status === 401 || error?.silent) { + return; + } + // Only log other errors in development + if (process.env.NODE_ENV === 'development') { + console.debug("Could not load notifications:", error?.message || 'Unknown error'); + } setNotifications([]); } }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index bb6842e..d51932e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -2,26 +2,25 @@ import { toast } from "sonner"; import { Job } from "./types"; import { getApiUrl, getBackofficeUrl, initConfig } from "./config"; -// API Base URL - now uses runtime config -// Fetched from /api/config at runtime, falls back to build-time env or defaults +// Track if we've shown an auth error toast to avoid spam +let hasShownAuthError = false; /** * Helper to log CRUD actions for the 'Activity Log' or console */ function logCrudAction(action: string, entity: string, details?: any) { - console.log(`[CRUD] ${action.toUpperCase()} ${entity}`, details); + // Only log in development + if (process.env.NODE_ENV === 'development') { + console.log(`[CRUD] ${action.toUpperCase()} ${entity}`, details); + } } /** * Generic API Request Wrapper */ async function apiRequest(endpoint: string, options: RequestInit = {}): Promise { - // Token can be stored as 'auth_token' (from auth.ts login) or 'token' (legacy) const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null; - // Ensure config is loaded before making request (from dev branch) - // await initConfig(); // Commented out to reduce risk if not present in HEAD - const headers: Record = { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), @@ -35,10 +34,23 @@ async function apiRequest(endpoint: string, options: RequestInit = {}): Promi const response = await fetch(`${getApiUrl()}${endpoint}`, { ...options, headers, - credentials: "include", // Enable cookie sharing + credentials: "include", }); if (!response.ok) { + // Handle 401 silently - it's expected for unauthenticated users + if (response.status === 401) { + // Clear any stale auth data + if (typeof window !== 'undefined') { + localStorage.removeItem("job-portal-auth"); + } + // Throw a specific error that can be caught and handled silently + const error = new Error('Unauthorized'); + (error as any).status = 401; + (error as any).silent = true; + throw error; + } + const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || `Request failed with status ${response.status}`); } @@ -72,6 +84,7 @@ export interface ApiJob { companyName?: string; companyLogoUrl?: string; companyId: string; +<<<<<<< Updated upstream location?: string | null; type?: string; // Legacy alias employmentType?: string; @@ -81,6 +94,7 @@ export interface ApiJob { salaryType?: string; currency?: string; description: string; +<<<<<<< Updated upstream requirements?: unknown; status: string; createdAt: string; @@ -117,7 +131,7 @@ export interface AdminCompany { description?: string; active: boolean; verified: boolean; - createdAt: string; // camelCase as returned by Go json tag + createdAt: string; updatedAt?: string; } @@ -229,7 +243,6 @@ export const usersApi = { method: "DELETE", }); }, - // Merged from HEAD getMe: () => apiRequest("/api/v1/users/me"), updateMe: (data: any) => @@ -238,7 +251,7 @@ export const usersApi = { body: JSON.stringify(data), }), }; -export const adminUsersApi = usersApi; // Alias for backward compatibility if needed +export const adminUsersApi = usersApi; // --- Admin Backoffice API --- export const adminAccessApi = { @@ -338,7 +351,7 @@ export const adminCompaniesApi = { // Companies API (Public/Shared) export const companiesApi = { - list: () => apiRequest("/api/v1/companies"), // Using AdminCompany as fallback type + list: () => apiRequest("/api/v1/companies"), getById: (id: string) => apiRequest(`/api/v1/companies/${id}`), create: (data: { name: string; slug: string; email?: string }) => @@ -357,7 +370,7 @@ export interface CreateJobPayload { salaryMax?: number; salaryType?: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly'; currency?: 'BRL' | 'USD' | 'EUR' | 'GBP' | 'JPY'; - salaryNegotiable?: boolean; // When true, candidate proposes salary + salaryNegotiable?: boolean; employmentType?: 'full-time' | 'part-time' | 'dispatch' | 'contract' | 'temporary' | 'training' | 'voluntary' | 'permanent'; workingHours?: string; location?: string; @@ -373,7 +386,6 @@ export const jobsApi = { type?: string; workMode?: string; companyId?: string; - // Advanced filters salaryMin?: number; salaryMax?: number; currency?: string; @@ -389,7 +401,6 @@ export const jobsApi = { if (params.type) query.append("employmentType", params.type); if (params.workMode) query.append("workMode", params.workMode); if (params.companyId) query.append("companyId", params.companyId); - // Advanced filters if (params.salaryMin) query.append("salaryMin", params.salaryMin.toString()); if (params.salaryMax) query.append("salaryMax", params.salaryMax.toString()); if (params.currency) query.append("currency", params.currency); @@ -412,7 +423,6 @@ export const jobsApi = { }, update: (id: string, data: Partial) => { logCrudAction("update", "jobs", { id, ...data }); - console.log("[JOBS_API] Updating job:", id, data); return apiRequest(`/api/v1/jobs/${id}`, { method: "PUT", body: JSON.stringify(data), @@ -420,7 +430,6 @@ export const jobsApi = { }, delete: (id: string) => { logCrudAction("delete", "jobs", { id }); - console.log("[JOBS_API] Deleting job:", id); return apiRequest(`/api/v1/jobs/${id}`, { method: "DELETE", }); @@ -456,7 +465,7 @@ export const jobsApi = { body: JSON.stringify({ active }), }), - // Favorites + // Favorites - wrap with silent error handling getFavorites: () => apiRequest(`/api/v1/applications?${query.toString()}`); }, listMyApplications: () => { - // Backend should support /applications/me or similar. Using /applications/me for now. return apiRequest("/api/v1/applications/me"); }, delete: (id: string) => { @@ -542,10 +550,6 @@ export const storageApi = { ), uploadFile: async (file: File, folder = "uploads") => { - // Use backend proxy to avoid CORS/403 - // Note: initConfig usage removed as it was commented out in apiRequest, but we might need it if proxy depends on it? - // Let's assume apiRequest handles auth. But here we use raw fetch. - // We should probably rely on the auth token in localStorage. const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null; const formData = new FormData(); @@ -580,11 +584,8 @@ export const storageApi = { // --- Helper Functions --- export function transformApiJobToFrontend(apiJob: ApiJob): Job { - // Requirements might come as a string derived from DB let reqs: string[] = []; if (apiJob.requirements) { - // Assuming it might be a JSON string or just text - // Simple split by newline for now if it's a block of text if (apiJob.requirements.startsWith('[')) { try { reqs = JSON.parse(apiJob.requirements); @@ -596,7 +597,6 @@ export function transformApiJobToFrontend(apiJob: ApiJob): Job { } } - // Format salary let salaryLabel: string | undefined; if (apiJob.salaryMin && apiJob.salaryMax) { salaryLabel = `R$ ${apiJob.salaryMin.toLocaleString('pt-BR')} - R$ ${apiJob.salaryMax.toLocaleString('pt-BR')}`; @@ -688,7 +688,6 @@ export const ticketsApi = { body: JSON.stringify({ message }), }); }, - // Admin methods listAll: () => { return apiRequest("/api/v1/support/tickets/all"); }, @@ -720,12 +719,10 @@ export const profileApi = { }); }, async uploadAvatar(file: File) { - // 1. Get Presigned URL const { url, key, publicUrl } = await apiRequest<{ url: string; key: string; publicUrl?: string }>( `/api/v1/storage/upload-url?filename=${encodeURIComponent(file.name)}&contentType=${encodeURIComponent(file.type)}&folder=avatars` ); - // 2. Upload to S3/R2 directly const uploadRes = await fetch(url, { method: "PUT", headers: { @@ -738,19 +735,14 @@ export const profileApi = { throw new Error("Failed to upload image to storage"); } - // 3. Update Profile with the Key (or URL if public) - // We save the key. The frontend or backend should resolve it to a full URL if needed. - // For now, assuming saving the key is what's requested ("salvando as chaves"). - // We use the generic updateProfile method. const avatarUrl = publicUrl || key; - console.log("[PROFILE_FLOW] Upload complete. Saving avatar URL:", avatarUrl); const res = await fetch(`${getApiUrl()}/api/v1/users/me/profile`, { method: "PATCH", headers: { "Content-Type": "application/json", }, - credentials: "include", // Use httpOnly cookie + credentials: "include", body: JSON.stringify({ avatarUrl }) }); @@ -762,10 +754,8 @@ export const profileApi = { // ============================================================================= // BACKOFFICE API (Stripe, Admin Dashboard, etc.) // ============================================================================= -// Backoffice URL - now uses runtime config async function backofficeRequest(endpoint: string, options: RequestInit = {}): Promise { - // Token can be stored as 'auth_token' (from auth.ts login) or 'token' (legacy) const token = typeof window !== 'undefined' ? (localStorage.getItem("auth_token") || localStorage.getItem("token")) : null; const headers: Record = { @@ -781,10 +771,16 @@ async function backofficeRequest(endpoint: string, options: RequestInit = {}) const response = await fetch(`${getBackofficeUrl()}${endpoint}`, { ...options, headers, - credentials: "include", // Use httpOnly cookie + credentials: "include", }); if (!response.ok) { + if (response.status === 401) { + const error = new Error('Unauthorized'); + (error as any).status = 401; + (error as any).silent = true; + throw error; + } const errorData = await response.json().catch(() => ({})); throw new Error(errorData.message || `Request failed with status ${response.status}`); } @@ -854,13 +850,11 @@ export const plansApi = { }; export const backofficeApi = { - // Admin Dashboard admin: { getStats: () => backofficeRequest("/admin/stats"), getRevenue: () => backofficeRequest("/admin/revenue"), getSubscriptionsByPlan: () => backofficeRequest("/admin/subscriptions-by-plan"), }, - // Stripe stripe: { createCheckoutSession: (data: CheckoutSessionRequest) => backofficeRequest("/stripe/checkout", { @@ -872,7 +866,6 @@ export const backofficeApi = { method: "POST", body: JSON.stringify({ returnUrl }), }), - // Admin listSubscriptions: (customerId: string) => backofficeRequest(`/stripe/subscriptions/${customerId}`), }, @@ -951,8 +944,6 @@ export const credentialsApi = { }), }; -// Duplicate storageApi removed - // --- Email Templates & Settings --- export interface EmailTemplate { diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index d52779d..b401116 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -6,6 +6,10 @@ const AUTH_KEY = "job-portal-auth"; // API URL now uses runtime config const getApiV1Url = () => `${getApiUrl()}/api/v1`; +// Flag to prevent repeated logs +let hasLoggedNoSession = false; +let isRefreshingSession = false; + interface LoginResponse { token: string; user: { @@ -25,7 +29,6 @@ export async function login( role?: "candidate" | "admin" | "company" // Deprecated argument, kept for signature compatibility if needed, but ignored ): Promise { try { - console.log("%c[AUTH] Attempting login...", "color: #3b82f6; font-weight: bold", { email }); const res = await fetch(`${getApiV1Url()}/auth/login`, { method: "POST", headers: { @@ -45,13 +48,9 @@ export async function login( const data: LoginResponse = await res.json(); // Map backend response to frontend User type - // Note: The backend returns roles as an array of strings. The frontend expects a single 'role' or we need to adapt. - // For now we map the first role or main role to the 'role' field. let userRole: "candidate" | "admin" | "company" | "superadmin" = "candidate"; - // Check for SuperAdmin (Platform Admin) if (data.user.roles.includes("superadmin") || data.user.roles.includes("SUPERADMIN")) { userRole = "superadmin"; - // Check for Company Admin (now called 'admin') or Recruiter } else if (data.user.roles.includes("admin") || data.user.roles.includes("recruiter")) { userRole = "company"; } @@ -61,19 +60,20 @@ export async function login( name: data.user.name, email: data.user.email, role: userRole, - roles: data.user.roles, // Extend User type if needed, or just keep it here + roles: data.user.roles, avatarUrl: data.user.avatar_url, - profileComplete: 80, // Mocked for now + profileComplete: 80, }; - // Store user info in sessionStorage (not token - token is in httpOnly cookie) + // Reset the no-session flag since we have a user now + hasLoggedNoSession = false; + if (typeof window !== "undefined") { localStorage.setItem(AUTH_KEY, JSON.stringify(user)); } return user; } catch (error) { - console.error("%c[AUTH] Login Error:", "color: #ef4444; font-weight: bold", error); throw error; } } @@ -81,14 +81,14 @@ export async function login( export async function logout(): Promise { if (typeof window !== "undefined") { localStorage.removeItem(AUTH_KEY); - // Call backend to clear the httpOnly cookie + hasLoggedNoSession = false; try { await fetch(`${getApiV1Url()}/auth/logout`, { method: "POST", credentials: "include", }); } catch (error) { - console.error("[AUTH] Logout error:", error); + // Silent fail on logout } } } @@ -97,12 +97,12 @@ export function getCurrentUser(): User | null { if (typeof window !== "undefined") { const stored = localStorage.getItem(AUTH_KEY); if (stored) { - const user = JSON.parse(stored); - // console.log("%c[AUTH] User Loaded from Storage", "color: #10b981", user.email); - return user; + return JSON.parse(stored); + } + // Only log once per session to avoid console spam + if (!hasLoggedNoSession) { + hasLoggedNoSession = true; } - // User not in storage (normal state) - console.log("%c[AUTH] No user session found", "color: #94a3b8"); } return null; } @@ -123,21 +123,26 @@ function mapRoleFromBackend(roles: string[]): "candidate" | "admin" | "company" * Use this on app mount to restore session across tabs/reloads. */ export async function refreshSession(): Promise { + // Prevent multiple concurrent refresh attempts + if (isRefreshingSession) { + return null; + } + + isRefreshingSession = true; + try { // Ensure runtime config is loaded before making the request if (typeof window !== "undefined") { await import("./config").then((m) => m.initConfig()); } - console.log("%c[AUTH] Attempting to refresh session...", "color: #3b82f6"); const res = await fetch(`${getApiV1Url()}/users/me`, { method: "GET", - credentials: "include", // Send HTTPOnly cookie + credentials: "include", }); if (!res.ok) { - // Cookie expired or invalid - clear local storage - console.log("%c[AUTH] Session refresh: No session", "color: #94a3b8", res.status); + // 401 is expected when not logged in - clean up silently localStorage.removeItem(AUTH_KEY); return null; } @@ -155,18 +160,14 @@ export async function refreshSession(): Promise { }; localStorage.setItem(AUTH_KEY, JSON.stringify(user)); - console.log("%c[AUTH] Session restored from cookie", "color: #10b981", user.email); + hasLoggedNoSession = false; return user; } catch (error) { - // Only log for non-network errors in development - // "Failed to fetch" is expected when API is unreachable (normal for unauthenticated users) - if (error instanceof TypeError && error.message.includes('Failed to fetch')) { - // Silent fail for network errors - this is expected - console.log("%c[AUTH] Session check skipped (API unreachable)", "color: #94a3b8"); - } else { - console.error("%c[AUTH] Failed to refresh session:", "color: #ef4444", error); - } + // Network errors are expected when API is unreachable + localStorage.removeItem(AUTH_KEY); return null; + } finally { + isRefreshingSession = false; } } @@ -191,7 +192,7 @@ export interface RegisterCandidateData { name: string; email: string; password: string; - username: string; // identifier + username: string; phone: string; birthDate?: string; address?: string; @@ -205,8 +206,6 @@ export interface RegisterCandidateData { } export async function registerCandidate(data: RegisterCandidateData): Promise { - console.log('[registerCandidate] Sending request:', { ...data, password: '***' }); - const res = await fetch(`${getApiV1Url()}/auth/register`, { method: "POST", headers: { @@ -217,21 +216,13 @@ export async function registerCandidate(data: RegisterCandidateData): Promise ({})); - console.error('[registerCandidate] Error response:', res.status, errorData); throw new Error(errorData.message || `Registration failed: ${res.status}`); } - - const responseData = await res.json().catch(() => ({})); - console.log('[registerCandidate] Success response:', { - ...responseData, - token: responseData.token ? '***' : undefined - }); } export function getToken(): string | null { // Token is now in httpOnly cookie, not accessible from JS - // This function is kept for backward compatibility but returns null return null; } @@ -242,7 +233,7 @@ export interface RegisterCompanyData { phone: string; password?: string; confirmPassword?: string; - document?: string; // cnpj + document?: string; website?: string; yearsInMarket?: string; description?: string; @@ -251,31 +242,16 @@ export interface RegisterCompanyData { city?: string; state?: string; birthDate?: string; - cnpj?: string; // alias for document + cnpj?: string; } export async function registerCompany(data: RegisterCompanyData): Promise { - console.log('[registerCompany] Sending request:', { ...data, password: '***' }); - - // Map frontend fields to backend DTO - // We are using /auth/register-company (new endpoint) OR adapting /companies? - // Let's assume we use the existing /auth/register but with role='company' and company details? - // Or if the backend supports /companies creating a user. - // Given the previous refactor plan, we probably want to hit an auth registration endpoint that creates both. - // Let's check if there is a specific handler for this. - // For now, I will assume we send to /auth/register-company if it exists, or /companies if it handles user creation. - - // Actually, let's map to what the backend likely expects for a full registration: - // CreateCompanyRequest usually only has company data. - // The backend might need an update to handle "Register Company + Admin" in one go if not already present. - // Let's stick to the payload structure and verify backend later. - const payload = { name: data.companyName, - slug: data.companyName.toLowerCase().replace(/\s+/g, '-'), // Generate slug + slug: data.companyName.toLowerCase().replace(/\s+/g, '-'), document: data.document || data.cnpj, phone: data.phone, - email: data.email, // Company email + email: data.email, website: data.website, address: data.address, zip_code: data.zipCode, @@ -283,17 +259,13 @@ export async function registerCompany(data: RegisterCompanyData): Promise state: data.state, description: data.description, years_in_market: data.yearsInMarket, - - // Admin User Data (if supported by endpoint) admin_email: data.email, - admin_password: data.password, // Keep for backward compatibility if needed - password: data.password, // Correct field for CreateCompanyRequest - admin_name: data.companyName, // Or we add a contactPerson field + admin_password: data.password, + password: data.password, + admin_name: data.companyName, admin_birth_date: data.birthDate }; - // We'll use a new endpoint /auth/register-company to be safe, or just /companies if we know it handles it. - // Previously it was POST /companies with admin_email. const res = await fetch(`${getApiV1Url()}/auth/register/company`, { method: "POST", headers: { @@ -304,11 +276,8 @@ export async function registerCompany(data: RegisterCompanyData): Promise if (!res.ok) { const errorData = await res.json().catch(() => ({})); - console.error('[registerCompany] Error response:', res.status, errorData); throw new Error(errorData.message || `Registration failed: ${res.status}`); } - const responseData = await res.json().catch(() => ({})); - console.log('[registerCompany] Success - Company created:', responseData); - return responseData; + return res.json(); }