327 lines
8.7 KiB
TypeScript
327 lines
8.7 KiB
TypeScript
/**
|
|
* 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<string | null>(null)
|
|
const refreshToken = ref<string | null>(null)
|
|
const tokenExpiresAt = ref<number | null>(null)
|
|
const user = ref<DiscordUser | null>(null)
|
|
const teams = ref<Team[]>([])
|
|
const isLoading = ref(false)
|
|
const error = ref<string | null>(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 (import.meta.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 (import.meta.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 (import.meta.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 (import.meta.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 (import.meta.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 (import.meta.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 (import.meta.client) {
|
|
window.location.href = authUrl
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle Discord OAuth callback
|
|
*/
|
|
async function handleDiscordCallback(code: string, state: string) {
|
|
if (import.meta.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 (import.meta.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,
|
|
}
|
|
})
|