/** * Authentication Store * * Manages user authentication state, Discord OAuth flow, and JWT tokens. * Persists auth state to localStorage for session persistence. */ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import type { DiscordUser, Team } from '~/types' export const useAuthStore = defineStore('auth', () => { // ============================================================================ // State // ============================================================================ const token = ref(null) const refreshToken = ref(null) const tokenExpiresAt = ref(null) const user = ref(null) const teams = ref([]) const isLoading = ref(false) const error = ref(null) // ============================================================================ // Getters // ============================================================================ const isAuthenticated = computed(() => { return token.value !== null && user.value !== null }) const isTokenValid = computed(() => { if (!tokenExpiresAt.value) return false return Date.now() < tokenExpiresAt.value }) const needsRefresh = computed(() => { if (!tokenExpiresAt.value) return false // Refresh if token expires in less than 5 minutes return Date.now() > tokenExpiresAt.value - 5 * 60 * 1000 }) const currentUser = computed(() => user.value) const userTeams = computed(() => teams.value) const userId = computed(() => user.value?.id ?? null) // ============================================================================ // Actions // ============================================================================ /** * Initialize auth state from localStorage */ function initializeAuth() { if (process.client) { const storedToken = localStorage.getItem('auth_token') const storedRefreshToken = localStorage.getItem('refresh_token') const storedExpiresAt = localStorage.getItem('token_expires_at') const storedUser = localStorage.getItem('user') const storedTeams = localStorage.getItem('teams') if (storedToken) token.value = storedToken if (storedRefreshToken) refreshToken.value = storedRefreshToken if (storedExpiresAt) tokenExpiresAt.value = parseInt(storedExpiresAt) if (storedUser) user.value = JSON.parse(storedUser) if (storedTeams) teams.value = JSON.parse(storedTeams) // Check if token needs refresh if (needsRefresh.value && refreshToken.value) { refreshAccessToken() } } } /** * Set authentication data after successful login */ function setAuth(data: { access_token: string refresh_token: string expires_in: number user: DiscordUser teams?: Team[] }) { token.value = data.access_token refreshToken.value = data.refresh_token tokenExpiresAt.value = Date.now() + data.expires_in * 1000 user.value = data.user if (data.teams) teams.value = data.teams // Persist to localStorage if (process.client) { localStorage.setItem('auth_token', data.access_token) localStorage.setItem('refresh_token', data.refresh_token) localStorage.setItem('token_expires_at', tokenExpiresAt.value.toString()) localStorage.setItem('user', JSON.stringify(data.user)) if (data.teams) localStorage.setItem('teams', JSON.stringify(data.teams)) } error.value = null } /** * Set user teams (loaded separately from login) */ function setTeams(userTeams: Team[]) { teams.value = userTeams if (process.client) { localStorage.setItem('teams', JSON.stringify(userTeams)) } } /** * Clear authentication data (logout) */ function clearAuth() { token.value = null refreshToken.value = null tokenExpiresAt.value = null user.value = null teams.value = [] error.value = null // Clear localStorage if (process.client) { localStorage.removeItem('auth_token') localStorage.removeItem('refresh_token') localStorage.removeItem('token_expires_at') localStorage.removeItem('user') localStorage.removeItem('teams') } } /** * Refresh access token using refresh token */ async function refreshAccessToken() { if (!refreshToken.value) { clearAuth() return false } isLoading.value = true error.value = null try { const config = useRuntimeConfig() const response = await $fetch<{ access_token: string expires_in: number }>(`${config.public.apiUrl}/api/auth/refresh`, { method: 'POST', body: { refresh_token: refreshToken.value, }, }) token.value = response.access_token tokenExpiresAt.value = Date.now() + response.expires_in * 1000 // Update localStorage if (process.client) { localStorage.setItem('auth_token', response.access_token) localStorage.setItem('token_expires_at', tokenExpiresAt.value.toString()) } return true } catch (err: any) { console.error('Failed to refresh token:', err) error.value = err.message || 'Failed to refresh authentication' clearAuth() return false } finally { isLoading.value = false } } /** * Redirect to Discord OAuth login */ function loginWithDiscord() { const config = useRuntimeConfig() const clientId = config.public.discordClientId const redirectUri = config.public.discordRedirectUri if (!clientId || !redirectUri) { error.value = 'Discord OAuth not configured' console.error('Missing Discord OAuth configuration') return } // Generate random state for CSRF protection const state = Math.random().toString(36).substring(7) if (process.client) { sessionStorage.setItem('oauth_state', state) } // Build Discord OAuth URL const params = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'identify email', state, }) const authUrl = `https://discord.com/api/oauth2/authorize?${params.toString()}` // Redirect to Discord if (process.client) { window.location.href = authUrl } } /** * Handle Discord OAuth callback */ async function handleDiscordCallback(code: string, state: string) { if (process.client) { const storedState = sessionStorage.getItem('oauth_state') if (!storedState || storedState !== state) { error.value = 'Invalid OAuth state - possible CSRF attack' return false } sessionStorage.removeItem('oauth_state') } isLoading.value = true error.value = null try { const config = useRuntimeConfig() const response = await $fetch<{ access_token: string refresh_token: string expires_in: number user: DiscordUser }>(`${config.public.apiUrl}/api/auth/discord/callback`, { method: 'POST', body: { code, state }, }) setAuth(response) // Load user teams await loadUserTeams() return true } catch (err: any) { console.error('Discord OAuth callback failed:', err) error.value = err.message || 'Authentication failed' return false } finally { isLoading.value = false } } /** * Load user's teams from API */ async function loadUserTeams() { if (!token.value) return try { const config = useRuntimeConfig() const response = await $fetch<{ teams: Team[] }>( `${config.public.apiUrl}/api/auth/me`, { headers: { Authorization: `Bearer ${token.value}`, }, } ) setTeams(response.teams) } catch (err: any) { console.error('Failed to load user teams:', err) // Don't set error - teams are optional } } /** * Logout user */ function logout() { clearAuth() // Redirect to home page if (process.client) { navigateTo('/') } } // ============================================================================ // Return Store API // ============================================================================ return { // State token: readonly(token), refreshToken: readonly(refreshToken), user: readonly(user), teams: readonly(teams), isLoading: readonly(isLoading), error: readonly(error), // Getters isAuthenticated, isTokenValid, needsRefresh, currentUser, userTeams, userId, // Actions initializeAuth, setAuth, setTeams, clearAuth, refreshAccessToken, loginWithDiscord, handleDiscordCallback, loadUserTeams, logout, } })