/** * Authentication API endpoints * Handles user login, registration, and logout operations */ import { apiClient } from './client' import type { LoginRequest, RegisterRequest, AuthResponse, CurrentUserResponse, SendVerifyCodeRequest, SendVerifyCodeResponse, PublicSettings, TotpLoginResponse, TotpLogin2FARequest } from '@/types' /** * Login response type - can be either full auth or 2FA required */ export type LoginResponse = AuthResponse | TotpLoginResponse /** * Type guard to check if login response requires 2FA */ export function isTotp2FARequired(response: LoginResponse): response is TotpLoginResponse { return 'requires_2fa' in response && response.requires_2fa === true } /** * Store authentication token in localStorage */ export function setAuthToken(token: string): void { localStorage.setItem('auth_token', token) } /** * Store refresh token in localStorage */ export function setRefreshToken(token: string): void { localStorage.setItem('refresh_token', token) } /** * Store token expiration timestamp in localStorage * Converts expires_in (seconds) to absolute timestamp (milliseconds) */ export function setTokenExpiresAt(expiresIn: number): void { const expiresAt = Date.now() + expiresIn * 1000 localStorage.setItem('token_expires_at', String(expiresAt)) } /** * Get authentication token from localStorage */ export function getAuthToken(): string | null { return localStorage.getItem('auth_token') } /** * Get refresh token from localStorage */ export function getRefreshToken(): string | null { return localStorage.getItem('refresh_token') } /** * Get token expiration timestamp from localStorage */ export function getTokenExpiresAt(): number | null { const value = localStorage.getItem('token_expires_at') return value ? parseInt(value, 10) : null } /** * Clear authentication token from localStorage */ export function clearAuthToken(): void { localStorage.removeItem('auth_token') localStorage.removeItem('refresh_token') localStorage.removeItem('auth_user') localStorage.removeItem('token_expires_at') } /** * User login * @param credentials - Email and password * @returns Authentication response with token and user data, or 2FA required response */ export async function login(credentials: LoginRequest): Promise { const { data } = await apiClient.post('/auth/login', credentials) // Only store token if 2FA is not required if (!isTotp2FARequired(data)) { setAuthToken(data.access_token) if (data.refresh_token) { setRefreshToken(data.refresh_token) } if (data.expires_in) { setTokenExpiresAt(data.expires_in) } localStorage.setItem('auth_user', JSON.stringify(data.user)) } return data } /** * Complete login with 2FA code * @param request - Temp token and TOTP code * @returns Authentication response with token and user data */ export async function login2FA(request: TotpLogin2FARequest): Promise { const { data } = await apiClient.post('/auth/login/2fa', request) // Store token and user data setAuthToken(data.access_token) if (data.refresh_token) { setRefreshToken(data.refresh_token) } if (data.expires_in) { setTokenExpiresAt(data.expires_in) } localStorage.setItem('auth_user', JSON.stringify(data.user)) return data } /** * User registration * @param userData - Registration data (username, email, password) * @returns Authentication response with token and user data */ export async function register(userData: RegisterRequest): Promise { const { data } = await apiClient.post('/auth/register', userData) // Store token and user data setAuthToken(data.access_token) if (data.refresh_token) { setRefreshToken(data.refresh_token) } if (data.expires_in) { setTokenExpiresAt(data.expires_in) } localStorage.setItem('auth_user', JSON.stringify(data.user)) return data } /** * Get current authenticated user * @returns User profile data */ export async function getCurrentUser() { return apiClient.get('/auth/me') } /** * User logout * Clears authentication token and user data from localStorage * Optionally revokes the refresh token on the server */ export async function logout(): Promise { const refreshToken = getRefreshToken() // Try to revoke the refresh token on the server if (refreshToken) { try { await apiClient.post('/auth/logout', { refresh_token: refreshToken }) } catch { // Ignore errors - we still want to clear local state } } clearAuthToken() } /** * Refresh token response */ export interface RefreshTokenResponse { access_token: string refresh_token: string expires_in: number token_type: string } export interface OAuthTokenResponse { access_token: string refresh_token?: string expires_in?: number token_type?: string } export interface PendingOAuthBindLoginResponse extends Partial { redirect?: string error?: string adoption_required?: boolean suggested_display_name?: string suggested_avatar_url?: string } export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {} export type OAuthCompletionKind = 'login' | 'bind' export interface OAuthAdoptionDecision { adoptDisplayName?: boolean adoptAvatar?: boolean } function serializeOAuthAdoptionDecision( decision?: OAuthAdoptionDecision ): Record { const payload: Record = {} if (typeof decision?.adoptDisplayName === 'boolean') { payload.adopt_display_name = decision.adoptDisplayName } if (typeof decision?.adoptAvatar === 'boolean') { payload.adopt_avatar = decision.adoptAvatar } return payload } export function isOAuthLoginCompletion( completion: Partial ): completion is OAuthTokenResponse { return typeof completion.access_token === 'string' && completion.access_token.trim().length > 0 } export function getOAuthCompletionKind( completion: Partial ): OAuthCompletionKind { return isOAuthLoginCompletion(completion) ? 'login' : 'bind' } export function getPendingOAuthBindLoginKind( completion: PendingOAuthBindLoginResponse ): OAuthCompletionKind { return getOAuthCompletionKind(completion) } export function isPendingOAuthCreateAccountRequired( completion: Pick ): boolean { return completion.error === 'invitation_required' } export function hasPendingOAuthSuggestedProfile( completion: Pick< PendingOAuthBindLoginResponse, 'suggested_display_name' | 'suggested_avatar_url' > ): boolean { return Boolean(completion.suggested_display_name || completion.suggested_avatar_url) } export function persistOAuthTokenContext(tokens: Partial): void { if (tokens.refresh_token) { setRefreshToken(tokens.refresh_token) } if (tokens.expires_in) { setTokenExpiresAt(tokens.expires_in) } } export function prepareOAuthBindAccessTokenCookie(): void { if (typeof document === 'undefined' || typeof window === 'undefined') { return } const token = getAuthToken() if (!token) { return } const secure = window.location.protocol === 'https:' ? '; Secure' : '' const path = resolveOAuthBindCookiePath() document.cookie = `oauth_bind_access_token=${encodeURIComponent(token)}; Path=${path}/auth/oauth; Max-Age=600; SameSite=Lax${secure}` } function resolveOAuthBindCookiePath(): string { const apiBase = ((import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1').replace(/\/$/, '') try { return new URL(apiBase, window.location.origin).pathname.replace(/\/$/, '') || '/api/v1' } catch { if (apiBase.startsWith('/')) { return apiBase } return '/api/v1' } } /** * Refresh the access token using the refresh token * @returns New token pair */ export async function refreshToken(): Promise { const currentRefreshToken = getRefreshToken() if (!currentRefreshToken) { throw new Error('No refresh token available') } const { data } = await apiClient.post('/auth/refresh', { refresh_token: currentRefreshToken }) // Update tokens in localStorage setAuthToken(data.access_token) setRefreshToken(data.refresh_token) setTokenExpiresAt(data.expires_in) return data } /** * Revoke all sessions for the current user * @returns Response with message */ export async function revokeAllSessions(): Promise<{ message: string }> { const { data } = await apiClient.post<{ message: string }>('/auth/revoke-all-sessions') return data } /** * Check if user is authenticated * @returns True if user has valid token */ export function isAuthenticated(): boolean { return getAuthToken() !== null } /** * Get public settings (no auth required) * @returns Public settings including registration and Turnstile config */ export async function getPublicSettings(): Promise { const { data } = await apiClient.get('/settings/public') return data } /** * Send verification code to email * @param request - Email and optional Turnstile token * @returns Response with countdown seconds */ export async function sendVerifyCode( request: SendVerifyCodeRequest ): Promise { const { data } = await apiClient.post('/auth/send-verify-code', request) return data } /** * Validate promo code response */ export interface ValidatePromoCodeResponse { valid: boolean bonus_amount?: number error_code?: string message?: string } /** * Validate promo code (public endpoint, no auth required) * @param code - Promo code to validate * @returns Validation result with bonus amount if valid */ export async function validatePromoCode(code: string): Promise { const { data } = await apiClient.post('/auth/validate-promo-code', { code }) return data } /** * Validate invitation code response */ export interface ValidateInvitationCodeResponse { valid: boolean error_code?: string } /** * Validate invitation code (public endpoint, no auth required) * @param code - Invitation code to validate * @returns Validation result */ export async function validateInvitationCode(code: string): Promise { const { data } = await apiClient.post('/auth/validate-invitation-code', { code }) return data } /** * Forgot password request */ export interface ForgotPasswordRequest { email: string turnstile_token?: string } /** * Forgot password response */ export interface ForgotPasswordResponse { message: string } /** * Request password reset link * @param request - Email and optional Turnstile token * @returns Response with message */ export async function forgotPassword(request: ForgotPasswordRequest): Promise { const { data } = await apiClient.post('/auth/forgot-password', request) return data } /** * Reset password request */ export interface ResetPasswordRequest { email: string token: string new_password: string } /** * Reset password response */ export interface ResetPasswordResponse { message: string } /** * Reset password with token * @param request - Email, token, and new password * @returns Response with message */ export async function resetPassword(request: ResetPasswordRequest): Promise { const { data } = await apiClient.post('/auth/reset-password', request) return data } /** * Complete LinuxDo OAuth registration by supplying an invitation code * @param invitationCode - Invitation code entered by the user * @returns Token pair on success */ export async function completeLinuxDoOAuthRegistration( invitationCode: string, decision?: OAuthAdoptionDecision ): Promise { return createPendingLinuxDoOAuthAccount(invitationCode, decision) } /** * Complete OIDC OAuth registration by supplying an invitation code * @param invitationCode - Invitation code entered by the user * @returns Token pair on success */ export async function completeOIDCOAuthRegistration( invitationCode: string, decision?: OAuthAdoptionDecision ): Promise { return createPendingOIDCOAuthAccount(invitationCode, decision) } export async function completeWeChatOAuthRegistration( invitationCode: string, decision?: OAuthAdoptionDecision ): Promise { return createPendingWeChatOAuthAccount(invitationCode, decision) } async function createPendingOAuthAccount( provider: 'linuxdo' | 'oidc' | 'wechat', invitationCode: string, decision?: OAuthAdoptionDecision ): Promise { const { data } = await apiClient.post( `/auth/oauth/${provider}/complete-registration`, { invitation_code: invitationCode, ...serializeOAuthAdoptionDecision(decision) } ) return data } export async function createPendingLinuxDoOAuthAccount( invitationCode: string, decision?: OAuthAdoptionDecision ): Promise { return createPendingOAuthAccount('linuxdo', invitationCode, decision) } export async function createPendingOIDCOAuthAccount( invitationCode: string, decision?: OAuthAdoptionDecision ): Promise { return createPendingOAuthAccount('oidc', invitationCode, decision) } export async function createPendingWeChatOAuthAccount( invitationCode: string, decision?: OAuthAdoptionDecision ): Promise { return createPendingOAuthAccount('wechat', invitationCode, decision) } export async function completePendingOAuthBindLogin( decision?: OAuthAdoptionDecision ): Promise { const { data } = await apiClient.post( '/auth/oauth/pending/exchange', serializeOAuthAdoptionDecision(decision) ) return data } export async function exchangePendingOAuthCompletion( decision?: OAuthAdoptionDecision ): Promise { return completePendingOAuthBindLogin(decision) } export const authAPI = { login, login2FA, isTotp2FARequired, register, getCurrentUser, logout, isAuthenticated, setAuthToken, setRefreshToken, setTokenExpiresAt, getAuthToken, getRefreshToken, getTokenExpiresAt, clearAuthToken, getPublicSettings, sendVerifyCode, validatePromoCode, validateInvitationCode, forgotPassword, resetPassword, refreshToken, revokeAllSessions, getPendingOAuthBindLoginKind, isPendingOAuthCreateAccountRequired, hasPendingOAuthSuggestedProfile, completePendingOAuthBindLogin, createPendingLinuxDoOAuthAccount, createPendingOIDCOAuthAccount, createPendingWeChatOAuthAccount, exchangePendingOAuthCompletion, completeLinuxDoOAuthRegistration, completeOIDCOAuthRegistration, completeWeChatOAuthRegistration } export default authAPI