2025-12-18 13:50:39 +08:00
|
|
|
/**
|
|
|
|
|
* Authentication API endpoints
|
|
|
|
|
* Handles user login, registration, and logout operations
|
|
|
|
|
*/
|
|
|
|
|
|
2025-12-25 08:41:30 -08:00
|
|
|
import { apiClient } from './client'
|
|
|
|
|
import type {
|
|
|
|
|
LoginRequest,
|
|
|
|
|
RegisterRequest,
|
|
|
|
|
AuthResponse,
|
2025-12-29 03:17:25 +08:00
|
|
|
CurrentUserResponse,
|
2025-12-25 08:41:30 -08:00
|
|
|
SendVerifyCodeRequest,
|
|
|
|
|
SendVerifyCodeResponse,
|
2026-01-26 08:45:43 +08:00
|
|
|
PublicSettings,
|
|
|
|
|
TotpLoginResponse,
|
|
|
|
|
TotpLogin2FARequest
|
2025-12-25 08:41:30 -08:00
|
|
|
} from '@/types'
|
2025-12-18 13:50:39 +08:00
|
|
|
|
2026-01-26 08:45:43 +08:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
/**
|
|
|
|
|
* Store authentication token in localStorage
|
|
|
|
|
*/
|
|
|
|
|
export function setAuthToken(token: string): void {
|
2025-12-25 08:41:30 -08:00
|
|
|
localStorage.setItem('auth_token', token)
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 12:38:48 +08:00
|
|
|
/**
|
|
|
|
|
* 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))
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
/**
|
|
|
|
|
* Get authentication token from localStorage
|
|
|
|
|
*/
|
|
|
|
|
export function getAuthToken(): string | null {
|
2025-12-25 08:41:30 -08:00
|
|
|
return localStorage.getItem('auth_token')
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 12:38:48 +08:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
/**
|
|
|
|
|
* Clear authentication token from localStorage
|
|
|
|
|
*/
|
|
|
|
|
export function clearAuthToken(): void {
|
2025-12-25 08:41:30 -08:00
|
|
|
localStorage.removeItem('auth_token')
|
2026-02-05 12:38:48 +08:00
|
|
|
localStorage.removeItem('refresh_token')
|
2025-12-25 08:41:30 -08:00
|
|
|
localStorage.removeItem('auth_user')
|
2026-02-05 12:38:48 +08:00
|
|
|
localStorage.removeItem('token_expires_at')
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* User login
|
2026-01-26 08:45:43 +08:00
|
|
|
* @param credentials - Email and password
|
|
|
|
|
* @returns Authentication response with token and user data, or 2FA required response
|
|
|
|
|
*/
|
|
|
|
|
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
|
|
|
|
|
const { data } = await apiClient.post<LoginResponse>('/auth/login', credentials)
|
|
|
|
|
|
|
|
|
|
// Only store token if 2FA is not required
|
|
|
|
|
if (!isTotp2FARequired(data)) {
|
|
|
|
|
setAuthToken(data.access_token)
|
2026-02-05 12:38:48 +08:00
|
|
|
if (data.refresh_token) {
|
|
|
|
|
setRefreshToken(data.refresh_token)
|
|
|
|
|
}
|
|
|
|
|
if (data.expires_in) {
|
|
|
|
|
setTokenExpiresAt(data.expires_in)
|
|
|
|
|
}
|
2026-01-26 08:45:43 +08:00
|
|
|
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Complete login with 2FA code
|
|
|
|
|
* @param request - Temp token and TOTP code
|
2025-12-18 13:50:39 +08:00
|
|
|
* @returns Authentication response with token and user data
|
|
|
|
|
*/
|
2026-01-26 08:45:43 +08:00
|
|
|
export async function login2FA(request: TotpLogin2FARequest): Promise<AuthResponse> {
|
|
|
|
|
const { data } = await apiClient.post<AuthResponse>('/auth/login/2fa', request)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
// Store token and user data
|
2025-12-25 08:41:30 -08:00
|
|
|
setAuthToken(data.access_token)
|
2026-02-05 12:38:48 +08:00
|
|
|
if (data.refresh_token) {
|
|
|
|
|
setRefreshToken(data.refresh_token)
|
|
|
|
|
}
|
|
|
|
|
if (data.expires_in) {
|
|
|
|
|
setTokenExpiresAt(data.expires_in)
|
|
|
|
|
}
|
2025-12-25 08:41:30 -08:00
|
|
|
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
2025-12-25 08:41:30 -08:00
|
|
|
return data
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* User registration
|
|
|
|
|
* @param userData - Registration data (username, email, password)
|
|
|
|
|
* @returns Authentication response with token and user data
|
|
|
|
|
*/
|
|
|
|
|
export async function register(userData: RegisterRequest): Promise<AuthResponse> {
|
2025-12-25 08:41:30 -08:00
|
|
|
const { data } = await apiClient.post<AuthResponse>('/auth/register', userData)
|
2025-12-18 13:50:39 +08:00
|
|
|
|
|
|
|
|
// Store token and user data
|
2025-12-25 08:41:30 -08:00
|
|
|
setAuthToken(data.access_token)
|
2026-02-05 12:38:48 +08:00
|
|
|
if (data.refresh_token) {
|
|
|
|
|
setRefreshToken(data.refresh_token)
|
|
|
|
|
}
|
|
|
|
|
if (data.expires_in) {
|
|
|
|
|
setTokenExpiresAt(data.expires_in)
|
|
|
|
|
}
|
2025-12-25 08:41:30 -08:00
|
|
|
localStorage.setItem('auth_user', JSON.stringify(data.user))
|
2025-12-18 13:50:39 +08:00
|
|
|
|
2025-12-25 08:41:30 -08:00
|
|
|
return data
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get current authenticated user
|
|
|
|
|
* @returns User profile data
|
|
|
|
|
*/
|
2025-12-29 03:17:25 +08:00
|
|
|
export async function getCurrentUser() {
|
|
|
|
|
return apiClient.get<CurrentUserResponse>('/auth/me')
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* User logout
|
|
|
|
|
* Clears authentication token and user data from localStorage
|
2026-02-05 12:38:48 +08:00
|
|
|
* Optionally revokes the refresh token on the server
|
2025-12-18 13:50:39 +08:00
|
|
|
*/
|
2026-02-05 12:38:48 +08:00
|
|
|
export async function logout(): Promise<void> {
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 08:41:30 -08:00
|
|
|
clearAuthToken()
|
2026-02-05 12:38:48 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Refresh token response
|
|
|
|
|
*/
|
|
|
|
|
export interface RefreshTokenResponse {
|
|
|
|
|
access_token: string
|
|
|
|
|
refresh_token: string
|
|
|
|
|
expires_in: number
|
|
|
|
|
token_type: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
export interface OAuthTokenResponse {
|
|
|
|
|
access_token: string
|
2026-04-20 16:27:23 +08:00
|
|
|
refresh_token?: string
|
|
|
|
|
expires_in?: number
|
|
|
|
|
token_type?: string
|
2026-04-20 18:28:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-20 19:30:19 +08:00
|
|
|
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
|
2026-04-22 14:57:47 +08:00
|
|
|
auth_result?: string
|
2026-04-20 16:27:23 +08:00
|
|
|
redirect?: string
|
|
|
|
|
error?: string
|
2026-04-20 19:53:22 +08:00
|
|
|
requires_2fa?: boolean
|
|
|
|
|
temp_token?: string
|
|
|
|
|
user_email_masked?: string
|
2026-04-20 16:27:23 +08:00
|
|
|
adoption_required?: boolean
|
|
|
|
|
suggested_display_name?: string
|
|
|
|
|
suggested_avatar_url?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 19:30:19 +08:00
|
|
|
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
|
|
|
|
|
|
2026-04-22 14:57:47 +08:00
|
|
|
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {
|
|
|
|
|
auth_result?: string
|
|
|
|
|
}
|
2026-04-20 19:30:19 +08:00
|
|
|
|
2026-04-21 10:41:29 +08:00
|
|
|
export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse {
|
|
|
|
|
auth_result?: string
|
|
|
|
|
provider?: string
|
|
|
|
|
redirect?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
export type OAuthCompletionKind = 'login' | 'bind'
|
|
|
|
|
|
2026-04-20 17:39:57 +08:00
|
|
|
export interface OAuthAdoptionDecision {
|
|
|
|
|
adoptDisplayName?: boolean
|
|
|
|
|
adoptAvatar?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function serializeOAuthAdoptionDecision(
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
|
|
|
|
): Record<string, boolean> {
|
|
|
|
|
const payload: Record<string, boolean> = {}
|
|
|
|
|
|
|
|
|
|
if (typeof decision?.adoptDisplayName === 'boolean') {
|
|
|
|
|
payload.adopt_display_name = decision.adoptDisplayName
|
|
|
|
|
}
|
|
|
|
|
if (typeof decision?.adoptAvatar === 'boolean') {
|
|
|
|
|
payload.adopt_avatar = decision.adoptAvatar
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
export function isOAuthLoginCompletion(
|
|
|
|
|
completion: Partial<OAuthTokenResponse>
|
|
|
|
|
): completion is OAuthTokenResponse {
|
|
|
|
|
return typeof completion.access_token === 'string' && completion.access_token.trim().length > 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getOAuthCompletionKind(
|
|
|
|
|
completion: Partial<OAuthTokenResponse>
|
|
|
|
|
): OAuthCompletionKind {
|
|
|
|
|
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 19:30:19 +08:00
|
|
|
export function getPendingOAuthBindLoginKind(
|
|
|
|
|
completion: PendingOAuthBindLoginResponse
|
|
|
|
|
): OAuthCompletionKind {
|
|
|
|
|
return getOAuthCompletionKind(completion)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isPendingOAuthCreateAccountRequired(
|
|
|
|
|
completion: Pick<PendingOAuthBindLoginResponse, 'error'>
|
|
|
|
|
): 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 18:28:44 +08:00
|
|
|
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
|
|
|
|
|
if (tokens.refresh_token) {
|
|
|
|
|
setRefreshToken(tokens.refresh_token)
|
|
|
|
|
}
|
|
|
|
|
if (tokens.expires_in) {
|
|
|
|
|
setTokenExpiresAt(tokens.expires_in)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 10:26:22 +08:00
|
|
|
export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
|
|
|
|
|
if (!getAuthToken()) {
|
2026-04-20 18:28:44 +08:00
|
|
|
return
|
|
|
|
|
}
|
2026-04-22 10:26:22 +08:00
|
|
|
await apiClient.post('/auth/oauth/bind-token')
|
2026-04-20 18:28:44 +08:00
|
|
|
}
|
|
|
|
|
|
2026-02-05 12:38:48 +08:00
|
|
|
/**
|
|
|
|
|
* Refresh the access token using the refresh token
|
|
|
|
|
* @returns New token pair
|
|
|
|
|
*/
|
|
|
|
|
export async function refreshToken(): Promise<RefreshTokenResponse> {
|
|
|
|
|
const currentRefreshToken = getRefreshToken()
|
|
|
|
|
if (!currentRefreshToken) {
|
|
|
|
|
throw new Error('No refresh token available')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { data } = await apiClient.post<RefreshTokenResponse>('/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
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if user is authenticated
|
|
|
|
|
* @returns True if user has valid token
|
|
|
|
|
*/
|
|
|
|
|
export function isAuthenticated(): boolean {
|
2025-12-25 08:41:30 -08:00
|
|
|
return getAuthToken() !== null
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get public settings (no auth required)
|
|
|
|
|
* @returns Public settings including registration and Turnstile config
|
|
|
|
|
*/
|
|
|
|
|
export async function getPublicSettings(): Promise<PublicSettings> {
|
2025-12-25 08:41:30 -08:00
|
|
|
const { data } = await apiClient.get<PublicSettings>('/settings/public')
|
|
|
|
|
return data
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 00:05:42 +08:00
|
|
|
export type WeChatOAuthMode = 'open' | 'mp'
|
|
|
|
|
export type WeChatOAuthUnavailableReason =
|
|
|
|
|
| 'not_configured'
|
2026-04-21 01:48:23 +08:00
|
|
|
| 'capability_unknown'
|
2026-04-21 00:05:42 +08:00
|
|
|
| 'external_browser_required'
|
|
|
|
|
| 'wechat_browser_required'
|
2026-04-21 07:48:24 -07:00
|
|
|
| 'native_app_required'
|
2026-04-21 00:05:42 +08:00
|
|
|
|
|
|
|
|
export interface ResolvedWeChatOAuthStart {
|
|
|
|
|
mode: WeChatOAuthMode | null
|
|
|
|
|
openEnabled: boolean
|
|
|
|
|
mpEnabled: boolean
|
2026-04-21 07:48:24 -07:00
|
|
|
mobileEnabled: boolean
|
2026-04-21 00:05:42 +08:00
|
|
|
isWeChatBrowser: boolean
|
|
|
|
|
unavailableReason: WeChatOAuthUnavailableReason | null
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 00:46:40 +08:00
|
|
|
export type WeChatOAuthPublicSettings = {
|
2026-04-21 00:05:42 +08:00
|
|
|
wechat_oauth_enabled?: boolean
|
|
|
|
|
wechat_oauth_open_enabled?: boolean
|
|
|
|
|
wechat_oauth_mp_enabled?: boolean
|
2026-04-21 07:48:24 -07:00
|
|
|
wechat_oauth_mobile_enabled?: boolean
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isWeChatWebOAuthEnabled(
|
|
|
|
|
settings: WeChatOAuthPublicSettings | null | undefined,
|
|
|
|
|
): boolean {
|
|
|
|
|
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
|
|
|
|
|
const hasExplicitCapabilities =
|
|
|
|
|
typeof settings?.wechat_oauth_open_enabled === 'boolean' ||
|
|
|
|
|
typeof settings?.wechat_oauth_mp_enabled === 'boolean'
|
|
|
|
|
|
|
|
|
|
if (!hasExplicitCapabilities) {
|
|
|
|
|
return legacyEnabled
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return settings?.wechat_oauth_open_enabled === true || settings?.wechat_oauth_mp_enabled === true
|
2026-04-21 00:05:42 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 01:48:23 +08:00
|
|
|
export function hasExplicitWeChatOAuthCapabilities(
|
|
|
|
|
settings: WeChatOAuthPublicSettings | null | undefined,
|
|
|
|
|
): settings is WeChatOAuthPublicSettings & {
|
|
|
|
|
wechat_oauth_open_enabled: boolean
|
|
|
|
|
wechat_oauth_mp_enabled: boolean
|
|
|
|
|
} {
|
|
|
|
|
return typeof settings?.wechat_oauth_open_enabled === 'boolean'
|
|
|
|
|
&& typeof settings?.wechat_oauth_mp_enabled === 'boolean'
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-21 00:05:42 +08:00
|
|
|
export function resolveWeChatOAuthStart(
|
|
|
|
|
settings: WeChatOAuthPublicSettings | null | undefined,
|
|
|
|
|
userAgent?: string
|
|
|
|
|
): ResolvedWeChatOAuthStart {
|
|
|
|
|
const normalizedUserAgent = (userAgent
|
|
|
|
|
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
|
|
|
|
|
?? '').trim()
|
|
|
|
|
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
|
|
|
|
|
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
|
|
|
|
|
const openEnabled = typeof settings?.wechat_oauth_open_enabled === 'boolean'
|
|
|
|
|
? settings.wechat_oauth_open_enabled
|
|
|
|
|
: legacyEnabled
|
|
|
|
|
const mpEnabled = typeof settings?.wechat_oauth_mp_enabled === 'boolean'
|
|
|
|
|
? settings.wechat_oauth_mp_enabled
|
|
|
|
|
: legacyEnabled
|
2026-04-21 07:48:24 -07:00
|
|
|
const mobileEnabled = typeof settings?.wechat_oauth_mobile_enabled === 'boolean'
|
|
|
|
|
? settings.wechat_oauth_mobile_enabled
|
|
|
|
|
: false
|
2026-04-21 00:05:42 +08:00
|
|
|
|
|
|
|
|
if (isWeChatBrowser) {
|
|
|
|
|
if (mpEnabled) {
|
2026-04-21 07:48:24 -07:00
|
|
|
return { mode: 'mp', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null }
|
2026-04-21 00:05:42 +08:00
|
|
|
}
|
|
|
|
|
if (openEnabled) {
|
2026-04-21 07:48:24 -07:00
|
|
|
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'external_browser_required' }
|
2026-04-21 00:05:42 +08:00
|
|
|
}
|
2026-04-21 07:48:24 -07:00
|
|
|
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
|
2026-04-21 00:05:42 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (openEnabled) {
|
2026-04-21 07:48:24 -07:00
|
|
|
return { mode: 'open', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null }
|
2026-04-21 00:05:42 +08:00
|
|
|
}
|
|
|
|
|
if (mpEnabled) {
|
2026-04-21 07:48:24 -07:00
|
|
|
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'wechat_browser_required' }
|
2026-04-21 00:05:42 +08:00
|
|
|
}
|
2026-04-21 07:48:24 -07:00
|
|
|
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
|
2026-04-21 00:05:42 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 01:48:23 +08:00
|
|
|
export function resolveWeChatOAuthStartStrict(
|
|
|
|
|
settings: WeChatOAuthPublicSettings | null | undefined,
|
|
|
|
|
userAgent?: string,
|
|
|
|
|
): ResolvedWeChatOAuthStart {
|
|
|
|
|
const normalizedUserAgent = (userAgent
|
|
|
|
|
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
|
|
|
|
|
?? '').trim()
|
|
|
|
|
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
|
|
|
|
|
|
|
|
|
|
if (!hasExplicitWeChatOAuthCapabilities(settings)) {
|
|
|
|
|
return {
|
|
|
|
|
mode: null,
|
|
|
|
|
openEnabled: false,
|
|
|
|
|
mpEnabled: false,
|
2026-04-21 07:48:24 -07:00
|
|
|
mobileEnabled: false,
|
2026-04-21 01:48:23 +08:00
|
|
|
isWeChatBrowser,
|
|
|
|
|
unavailableReason: 'capability_unknown',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return resolveWeChatOAuthStart(settings, normalizedUserAgent)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
/**
|
|
|
|
|
* Send verification code to email
|
|
|
|
|
* @param request - Email and optional Turnstile token
|
|
|
|
|
* @returns Response with countdown seconds
|
|
|
|
|
*/
|
2025-12-25 08:41:30 -08:00
|
|
|
export async function sendVerifyCode(
|
|
|
|
|
request: SendVerifyCodeRequest
|
|
|
|
|
): Promise<SendVerifyCodeResponse> {
|
|
|
|
|
const { data } = await apiClient.post<SendVerifyCodeResponse>('/auth/send-verify-code', request)
|
|
|
|
|
return data
|
2025-12-18 13:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
2026-04-21 10:00:06 +08:00
|
|
|
export async function sendPendingOAuthVerifyCode(
|
|
|
|
|
request: SendVerifyCodeRequest
|
2026-04-21 10:41:29 +08:00
|
|
|
): Promise<PendingOAuthSendVerifyCodeResponse> {
|
|
|
|
|
const { data } = await apiClient.post<PendingOAuthSendVerifyCodeResponse>(
|
2026-04-21 10:00:06 +08:00
|
|
|
'/auth/oauth/pending/send-verify-code',
|
|
|
|
|
request
|
|
|
|
|
)
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-10 13:14:35 +08:00
|
|
|
/**
|
|
|
|
|
* 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<ValidatePromoCodeResponse> {
|
|
|
|
|
const { data } = await apiClient.post<ValidatePromoCodeResponse>('/auth/validate-promo-code', { code })
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-29 16:29:59 +08:00
|
|
|
/**
|
|
|
|
|
* 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<ValidateInvitationCodeResponse> {
|
|
|
|
|
const { data } = await apiClient.post<ValidateInvitationCodeResponse>('/auth/validate-invitation-code', { code })
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-24 22:33:45 +08:00
|
|
|
/**
|
|
|
|
|
* 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<ForgotPasswordResponse> {
|
|
|
|
|
const { data } = await apiClient.post<ForgotPasswordResponse>('/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<ResetPasswordResponse> {
|
|
|
|
|
const { data } = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', request)
|
|
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:35:34 +08:00
|
|
|
/**
|
|
|
|
|
* 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(
|
2026-04-20 17:39:57 +08:00
|
|
|
invitationCode: string,
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
2026-04-20 18:28:44 +08:00
|
|
|
): Promise<OAuthTokenResponse> {
|
2026-04-20 19:30:19 +08:00
|
|
|
return createPendingLinuxDoOAuthAccount(invitationCode, decision)
|
2026-03-09 00:35:34 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-13 23:38:58 +08:00
|
|
|
/**
|
|
|
|
|
* 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(
|
2026-04-20 17:39:57 +08:00
|
|
|
invitationCode: string,
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
2026-04-20 18:28:44 +08:00
|
|
|
): Promise<OAuthTokenResponse> {
|
2026-04-20 19:30:19 +08:00
|
|
|
return createPendingOIDCOAuthAccount(invitationCode, decision)
|
2026-04-20 18:28:44 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function completeWeChatOAuthRegistration(
|
|
|
|
|
invitationCode: string,
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
|
|
|
|
): Promise<OAuthTokenResponse> {
|
2026-04-20 19:30:19 +08:00
|
|
|
return createPendingWeChatOAuthAccount(invitationCode, decision)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function createPendingOAuthAccount(
|
|
|
|
|
provider: 'linuxdo' | 'oidc' | 'wechat',
|
|
|
|
|
invitationCode: string,
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
|
|
|
|
): Promise<PendingOAuthCreateAccountResponse> {
|
|
|
|
|
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
|
|
|
|
|
`/auth/oauth/${provider}/complete-registration`,
|
|
|
|
|
{
|
|
|
|
|
invitation_code: invitationCode,
|
|
|
|
|
...serializeOAuthAdoptionDecision(decision)
|
|
|
|
|
}
|
|
|
|
|
)
|
2026-03-13 23:38:58 +08:00
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 19:30:19 +08:00
|
|
|
export async function createPendingLinuxDoOAuthAccount(
|
|
|
|
|
invitationCode: string,
|
2026-04-20 17:39:57 +08:00
|
|
|
decision?: OAuthAdoptionDecision
|
2026-04-20 19:30:19 +08:00
|
|
|
): Promise<PendingOAuthCreateAccountResponse> {
|
|
|
|
|
return createPendingOAuthAccount('linuxdo', invitationCode, decision)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createPendingOIDCOAuthAccount(
|
|
|
|
|
invitationCode: string,
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
|
|
|
|
): Promise<PendingOAuthCreateAccountResponse> {
|
|
|
|
|
return createPendingOAuthAccount('oidc', invitationCode, decision)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function createPendingWeChatOAuthAccount(
|
|
|
|
|
invitationCode: string,
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
|
|
|
|
): Promise<PendingOAuthCreateAccountResponse> {
|
|
|
|
|
return createPendingOAuthAccount('wechat', invitationCode, decision)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function completePendingOAuthBindLogin(
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
|
|
|
|
): Promise<PendingOAuthBindLoginResponse> {
|
|
|
|
|
const { data } = await apiClient.post<PendingOAuthBindLoginResponse>(
|
2026-04-20 17:39:57 +08:00
|
|
|
'/auth/oauth/pending/exchange',
|
|
|
|
|
serializeOAuthAdoptionDecision(decision)
|
|
|
|
|
)
|
2026-04-20 16:27:23 +08:00
|
|
|
return data
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-20 19:30:19 +08:00
|
|
|
export async function exchangePendingOAuthCompletion(
|
|
|
|
|
decision?: OAuthAdoptionDecision
|
|
|
|
|
): Promise<PendingOAuthExchangeResponse> {
|
|
|
|
|
return completePendingOAuthBindLogin(decision)
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-18 13:50:39 +08:00
|
|
|
export const authAPI = {
|
|
|
|
|
login,
|
2026-01-26 08:45:43 +08:00
|
|
|
login2FA,
|
|
|
|
|
isTotp2FARequired,
|
2025-12-18 13:50:39 +08:00
|
|
|
register,
|
|
|
|
|
getCurrentUser,
|
|
|
|
|
logout,
|
|
|
|
|
isAuthenticated,
|
|
|
|
|
setAuthToken,
|
2026-02-05 12:38:48 +08:00
|
|
|
setRefreshToken,
|
|
|
|
|
setTokenExpiresAt,
|
2025-12-18 13:50:39 +08:00
|
|
|
getAuthToken,
|
2026-02-05 12:38:48 +08:00
|
|
|
getRefreshToken,
|
|
|
|
|
getTokenExpiresAt,
|
2025-12-18 13:50:39 +08:00
|
|
|
clearAuthToken,
|
|
|
|
|
getPublicSettings,
|
2026-01-10 13:14:35 +08:00
|
|
|
sendVerifyCode,
|
2026-04-21 10:00:06 +08:00
|
|
|
sendPendingOAuthVerifyCode,
|
2026-01-24 22:33:45 +08:00
|
|
|
validatePromoCode,
|
2026-01-29 16:29:59 +08:00
|
|
|
validateInvitationCode,
|
2026-01-24 22:33:45 +08:00
|
|
|
forgotPassword,
|
2026-02-05 12:38:48 +08:00
|
|
|
resetPassword,
|
|
|
|
|
refreshToken,
|
2026-03-09 00:35:34 +08:00
|
|
|
revokeAllSessions,
|
2026-04-20 19:30:19 +08:00
|
|
|
getPendingOAuthBindLoginKind,
|
|
|
|
|
isPendingOAuthCreateAccountRequired,
|
|
|
|
|
hasPendingOAuthSuggestedProfile,
|
|
|
|
|
completePendingOAuthBindLogin,
|
|
|
|
|
createPendingLinuxDoOAuthAccount,
|
|
|
|
|
createPendingOIDCOAuthAccount,
|
|
|
|
|
createPendingWeChatOAuthAccount,
|
2026-04-20 16:27:23 +08:00
|
|
|
exchangePendingOAuthCompletion,
|
2026-03-13 23:38:58 +08:00
|
|
|
completeLinuxDoOAuthRegistration,
|
2026-04-20 18:28:44 +08:00
|
|
|
completeOIDCOAuthRegistration,
|
|
|
|
|
completeWeChatOAuthRegistration
|
2025-12-25 08:41:30 -08:00
|
|
|
}
|
2025-12-18 13:50:39 +08:00
|
|
|
|
2025-12-25 08:41:30 -08:00
|
|
|
export default authAPI
|