- Convert HTML comments in script sections to eslint-disable-next-line - Fix err->_err references in catch blocks across 8 files - Fix required→_required and onclose→_onclose prop mismatches - Revert BookingCreateModal.svelte from no-unused-vars agent damage - Fix broken regex in account page - Fix .writable (not in Svelte 5 stable) back to + - Fix NavBar dynamic href links with proper eslint-disable
237 lines
4.9 KiB
TypeScript
237 lines
4.9 KiB
TypeScript
// src/lib/stores/auth.svelte.ts
|
|
import { browser } from '$app/environment';
|
|
import { goto } from '$app/navigation';
|
|
|
|
export type UserRole = 'unverified_email' | 'verified_email' | 'admin' | 'guest' | 'affiliate';
|
|
|
|
export 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;
|
|
}
|
|
|
|
class AuthStore {
|
|
private token = $state<string | null>(null);
|
|
private user = $state<User | null>(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: ''
|
|
};
|
|
|
|
// 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: ''
|
|
};
|
|
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}` }
|
|
});
|
|
} 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();
|