// src/lib/stores/auth.svelte.ts import { browser } from '$app/environment'; import { goto } from '$app/navigation'; type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate'; interface DecodedToken { user_id: string; role: UserRole; exp: number; } export interface User { id: string; email: string; role: UserRole; firstName: string; lastName: string; phone?: string; dateOfBirth?: string; loyaltyStamps?: number; referralCode?: string; referralCodeUses?: number; referralSavings?: number; profilePicUrl?: string; previousFirstName?: string; previousLastName?: string; twoFactorEnabled?: boolean; twoFactorRequired?: boolean; twoFactorMethod?: string; } class AuthStore { private token = $state(null); private user = $state(null); private loading = $state(true); constructor() { if (browser) { this.initializeAuth(); // Check token refresh every 2 minutes // (must be shorter than the 5-minute threshold so the // refresh check fires before the token actually expires) setInterval( () => { if (this.token) { this.refreshTokenIfNeeded(); } }, 2 * 60 * 1000 ); } } get isAuthenticated() { return this.token !== null && this.user !== null; } get currentUser() { return this.user; } get currentToken() { return this.token; } get isLoading() { return this.loading; } get hasLoaded() { return !this.loading; } private initializeAuth() { const storedToken = localStorage.getItem('authToken'); if (storedToken) { const decoded = this.decodeToken(storedToken); if (decoded && !this.isTokenExpired(decoded)) { this.token = storedToken; // Set basic user info from token this.user = { id: decoded.user_id, role: decoded.role, email: '', firstName: '', lastName: '', twoFactorEnabled: false, twoFactorRequired: false }; // Refresh first so any JTI invalidation from rotation // happens before other requests use this token. // Then fetch profile with the (potentially refreshed) token. this.refreshTokenIfNeeded().then(() => { this.fetchUserProfile(); }); } else { this.clearAuth(); } } this.loading = false; } private decodeToken(token: string): DecodedToken | null { try { const payload = token.split('.')[1]; const decoded = JSON.parse(atob(payload)); return decoded; } catch { return null; } } private isTokenExpired(decoded: DecodedToken): boolean { return decoded.exp * 1000 < Date.now(); } // Simple setters - UI handles the API calls setToken(token: string) { this.token = token; if (browser) { localStorage.setItem('authToken', token); } // Decode to get basic info const decoded = this.decodeToken(token); if (decoded) { this.user = { id: decoded.user_id, role: decoded.role, email: '', firstName: '', lastName: '', twoFactorEnabled: false, twoFactorRequired: false }; this.fetchUserProfile(); } } private async fetchUserProfile() { if (!this.token) return; try { const response = await fetch('/api/user/profile', { headers: { Authorization: `Bearer ${this.token}` } }); if (!response.ok) { throw new Error('Failed to fetch profile'); } const userData = await response.json(); this.user = userData; } catch { this.clearAuth(); } } // inside AuthStore logout = async () => { if (this.token) { try { await fetch('/api/logout', { method: 'POST', headers: { Authorization: `Bearer ${this.token}` } }); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { // Ignore network errors - still clear local state } } this.clearAuth(); // eslint-disable-next-line svelte/no-navigation-without-resolve goto('/', { invalidateAll: true }); }; private clearAuth() { this.token = null; this.user = null; if (browser) { localStorage.removeItem('authToken'); } } hasRole(requiredRole: UserRole | UserRole[]): boolean { if (!this.user) return false; const roles = Array.isArray(requiredRole) ? requiredRole : [requiredRole]; return roles.includes(this.user.role); } isAdmin(): boolean { return this.hasRole('admin'); } isVerified(): boolean { return this.hasRole(['verified_email', 'admin']); } // Refresh token before it expires async refreshTokenIfNeeded() { if (!this.token) return; const decoded = this.decodeToken(this.token); if (!decoded) { this.clearAuth(); return; } // Refresh if token expires in less than 5 minutes // (1-hour token lifetime from backend) const fiveMinutes = 5 * 60 * 1000; if (decoded.exp * 1000 - Date.now() < fiveMinutes) { try { const response = await fetch('/api/refresh-token', { method: 'POST', headers: { Authorization: `Bearer ${this.token}` } }); if (response.ok) { const data = await response.json(); this.setToken(data.token); } else { this.clearAuth(); } } catch (error) { console.error('Token refresh failed:', error); } } } // Manual refresh method async refreshProfile() { await this.fetchUserProfile(); } } export const authStore = new AuthStore();