| |
| |
| |
| |
|
|
| import { apiClient } from './client' |
| import type { |
| LoginRequest, |
| RegisterRequest, |
| AuthResponse, |
| CurrentUserResponse, |
| SendVerifyCodeRequest, |
| SendVerifyCodeResponse, |
| PublicSettings, |
| TotpLoginResponse, |
| TotpLogin2FARequest |
| } from '@/types' |
|
|
| |
| |
| |
| export type LoginResponse = AuthResponse | TotpLoginResponse |
|
|
| |
| |
| |
| export function isTotp2FARequired(response: LoginResponse): response is TotpLoginResponse { |
| return 'requires_2fa' in response && response.requires_2fa === true |
| } |
|
|
| |
| |
| |
| export function setAuthToken(token: string): void { |
| localStorage.setItem('auth_token', token) |
| } |
|
|
| |
| |
| |
| export function setRefreshToken(token: string): void { |
| localStorage.setItem('refresh_token', token) |
| } |
|
|
| |
| |
| |
| |
| export function setTokenExpiresAt(expiresIn: number): void { |
| const expiresAt = Date.now() + expiresIn * 1000 |
| localStorage.setItem('token_expires_at', String(expiresAt)) |
| } |
|
|
| |
| |
| |
| export function getAuthToken(): string | null { |
| return localStorage.getItem('auth_token') |
| } |
|
|
| |
| |
| |
| export function getRefreshToken(): string | null { |
| return localStorage.getItem('refresh_token') |
| } |
|
|
| |
| |
| |
| export function getTokenExpiresAt(): number | null { |
| const value = localStorage.getItem('token_expires_at') |
| return value ? parseInt(value, 10) : null |
| } |
|
|
| |
| |
| |
| export function clearAuthToken(): void { |
| localStorage.removeItem('auth_token') |
| localStorage.removeItem('refresh_token') |
| localStorage.removeItem('auth_user') |
| localStorage.removeItem('token_expires_at') |
| } |
|
|
| |
| |
| |
| |
| |
| export async function login(credentials: LoginRequest): Promise<LoginResponse> { |
| const { data } = await apiClient.post<LoginResponse>('/auth/login', credentials) |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| |
| export async function login2FA(request: TotpLogin2FARequest): Promise<AuthResponse> { |
| const { data } = await apiClient.post<AuthResponse>('/auth/login/2fa', request) |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| |
| export async function register(userData: RegisterRequest): Promise<AuthResponse> { |
| const { data } = await apiClient.post<AuthResponse>('/auth/register', userData) |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| export async function getCurrentUser() { |
| return apiClient.get<CurrentUserResponse>('/auth/me') |
| } |
|
|
| |
| |
| |
| |
| |
| export async function logout(): Promise<void> { |
| const refreshToken = getRefreshToken() |
|
|
| |
| if (refreshToken) { |
| try { |
| await apiClient.post('/auth/logout', { refresh_token: refreshToken }) |
| } catch { |
| |
| } |
| } |
|
|
| clearAuthToken() |
| } |
|
|
| |
| |
| |
| 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<OAuthTokenResponse> { |
| auth_result?: string |
| redirect?: string |
| error?: string |
| requires_2fa?: boolean |
| temp_token?: string |
| user_email_masked?: string |
| adoption_required?: boolean |
| suggested_display_name?: string |
| suggested_avatar_url?: string |
| } |
|
|
| export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse |
|
|
| export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse { |
| auth_result?: string |
| } |
|
|
| export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse { |
| auth_result?: string |
| provider?: string |
| redirect?: string |
| } |
|
|
| export type OAuthCompletionKind = 'login' | 'bind' |
|
|
| 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 |
| } |
|
|
| 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' |
| } |
|
|
| 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) |
| } |
|
|
| export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void { |
| if (tokens.refresh_token) { |
| setRefreshToken(tokens.refresh_token) |
| } |
| if (tokens.expires_in) { |
| setTokenExpiresAt(tokens.expires_in) |
| } |
| } |
|
|
| export async function prepareOAuthBindAccessTokenCookie(): Promise<void> { |
| if (!getAuthToken()) { |
| return |
| } |
| await apiClient.post('/auth/oauth/bind-token') |
| } |
|
|
| |
| |
| |
| |
| 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 |
| }) |
|
|
| |
| setAuthToken(data.access_token) |
| setRefreshToken(data.refresh_token) |
| setTokenExpiresAt(data.expires_in) |
|
|
| return data |
| } |
|
|
| |
| |
| |
| |
| export async function revokeAllSessions(): Promise<{ message: string }> { |
| const { data } = await apiClient.post<{ message: string }>('/auth/revoke-all-sessions') |
| return data |
| } |
|
|
| |
| |
| |
| |
| export function isAuthenticated(): boolean { |
| return getAuthToken() !== null |
| } |
|
|
| |
| |
| |
| |
| export async function getPublicSettings(): Promise<PublicSettings> { |
| const { data } = await apiClient.get<PublicSettings>('/settings/public') |
| return data |
| } |
|
|
| export type WeChatOAuthMode = 'open' | 'mp' |
| export type WeChatOAuthUnavailableReason = |
| | 'not_configured' |
| | 'capability_unknown' |
| | 'external_browser_required' |
| | 'wechat_browser_required' |
| | 'native_app_required' |
|
|
| export interface ResolvedWeChatOAuthStart { |
| mode: WeChatOAuthMode | null |
| openEnabled: boolean |
| mpEnabled: boolean |
| mobileEnabled: boolean |
| isWeChatBrowser: boolean |
| unavailableReason: WeChatOAuthUnavailableReason | null |
| } |
|
|
| export type WeChatOAuthPublicSettings = { |
| wechat_oauth_enabled?: boolean |
| wechat_oauth_open_enabled?: boolean |
| wechat_oauth_mp_enabled?: boolean |
| 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 |
| } |
|
|
| 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' |
| } |
|
|
| 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 |
| const mobileEnabled = typeof settings?.wechat_oauth_mobile_enabled === 'boolean' |
| ? settings.wechat_oauth_mobile_enabled |
| : false |
|
|
| if (isWeChatBrowser) { |
| if (mpEnabled) { |
| return { mode: 'mp', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null } |
| } |
| if (openEnabled) { |
| return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'external_browser_required' } |
| } |
| return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' } |
| } |
|
|
| if (openEnabled) { |
| return { mode: 'open', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null } |
| } |
| if (mpEnabled) { |
| return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'wechat_browser_required' } |
| } |
| return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' } |
| } |
|
|
| 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, |
| mobileEnabled: false, |
| isWeChatBrowser, |
| unavailableReason: 'capability_unknown', |
| } |
| } |
|
|
| return resolveWeChatOAuthStart(settings, normalizedUserAgent) |
| } |
|
|
| |
| |
| |
| |
| |
| export async function sendVerifyCode( |
| request: SendVerifyCodeRequest |
| ): Promise<SendVerifyCodeResponse> { |
| const { data } = await apiClient.post<SendVerifyCodeResponse>('/auth/send-verify-code', request) |
| return data |
| } |
|
|
| export async function sendPendingOAuthVerifyCode( |
| request: SendVerifyCodeRequest |
| ): Promise<PendingOAuthSendVerifyCodeResponse> { |
| const { data } = await apiClient.post<PendingOAuthSendVerifyCodeResponse>( |
| '/auth/oauth/pending/send-verify-code', |
| request |
| ) |
| return data |
| } |
|
|
| |
| |
| |
| export interface ValidatePromoCodeResponse { |
| valid: boolean |
| bonus_amount?: number |
| error_code?: string |
| message?: string |
| } |
|
|
| |
| |
| |
| |
| |
| export async function validatePromoCode(code: string): Promise<ValidatePromoCodeResponse> { |
| const { data } = await apiClient.post<ValidatePromoCodeResponse>('/auth/validate-promo-code', { code }) |
| return data |
| } |
|
|
| |
| |
| |
| export interface ValidateInvitationCodeResponse { |
| valid: boolean |
| error_code?: string |
| } |
|
|
| |
| |
| |
| |
| |
| export async function validateInvitationCode(code: string): Promise<ValidateInvitationCodeResponse> { |
| const { data } = await apiClient.post<ValidateInvitationCodeResponse>('/auth/validate-invitation-code', { code }) |
| return data |
| } |
|
|
| |
| |
| |
| export interface ForgotPasswordRequest { |
| email: string |
| turnstile_token?: string |
| } |
|
|
| |
| |
| |
| export interface ForgotPasswordResponse { |
| message: string |
| } |
|
|
| |
| |
| |
| |
| |
| export async function forgotPassword(request: ForgotPasswordRequest): Promise<ForgotPasswordResponse> { |
| const { data } = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', request) |
| return data |
| } |
|
|
| |
| |
| |
| export interface ResetPasswordRequest { |
| email: string |
| token: string |
| new_password: string |
| } |
|
|
| |
| |
| |
| export interface ResetPasswordResponse { |
| message: string |
| } |
|
|
| |
| |
| |
| |
| |
| export async function resetPassword(request: ResetPasswordRequest): Promise<ResetPasswordResponse> { |
| const { data } = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', request) |
| return data |
| } |
|
|
| |
| |
| |
| |
| |
| export async function completeLinuxDoOAuthRegistration( |
| invitationCode: string, |
| decision?: OAuthAdoptionDecision, |
| affiliateCode?: string |
| ): Promise<OAuthTokenResponse> { |
| return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode) |
| } |
|
|
| |
| |
| |
| |
| |
| export async function completeOIDCOAuthRegistration( |
| invitationCode: string, |
| decision?: OAuthAdoptionDecision, |
| affiliateCode?: string |
| ): Promise<OAuthTokenResponse> { |
| return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode) |
| } |
|
|
| export async function completeWeChatOAuthRegistration( |
| invitationCode: string, |
| decision?: OAuthAdoptionDecision, |
| affiliateCode?: string |
| ): Promise<OAuthTokenResponse> { |
| return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode) |
| } |
|
|
| async function createPendingOAuthAccount( |
| provider: 'linuxdo' | 'oidc' | 'wechat' | 'dingtalk', |
| invitationCode: string, |
| decision?: OAuthAdoptionDecision, |
| affiliateCode?: string |
| ): Promise<PendingOAuthCreateAccountResponse> { |
| const normalizedAffiliateCode = affiliateCode?.trim() |
| const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>( |
| `/auth/oauth/${provider}/complete-registration`, |
| { |
| invitation_code: invitationCode, |
| ...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}), |
| ...serializeOAuthAdoptionDecision(decision) |
| } |
| ) |
| return data |
| } |
|
|
| export async function createPendingLinuxDoOAuthAccount( |
| invitationCode: string, |
| decision?: OAuthAdoptionDecision, |
| affiliateCode?: string |
| ): Promise<PendingOAuthCreateAccountResponse> { |
| return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode) |
| } |
|
|
| export async function createPendingOIDCOAuthAccount( |
| invitationCode: string, |
| decision?: OAuthAdoptionDecision, |
| affiliateCode?: string |
| ): Promise<PendingOAuthCreateAccountResponse> { |
| return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode) |
| } |
|
|
| export async function createPendingWeChatOAuthAccount( |
| invitationCode: string, |
| decision?: OAuthAdoptionDecision, |
| affiliateCode?: string |
| ): Promise<PendingOAuthCreateAccountResponse> { |
| return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode) |
| } |
|
|
| export async function createPendingDingTalkOAuthAccount( |
| invitationCode: string, |
| decision?: OAuthAdoptionDecision, |
| affiliateCode?: string |
| ): Promise<PendingOAuthCreateAccountResponse> { |
| return createPendingOAuthAccount('dingtalk', invitationCode, decision, affiliateCode) |
| } |
|
|
| export async function completePendingOAuthBindLogin( |
| decision?: OAuthAdoptionDecision |
| ): Promise<PendingOAuthBindLoginResponse> { |
| const { data } = await apiClient.post<PendingOAuthBindLoginResponse>( |
| '/auth/oauth/pending/exchange', |
| serializeOAuthAdoptionDecision(decision) |
| ) |
| return data |
| } |
|
|
| export async function exchangePendingOAuthCompletion( |
| decision?: OAuthAdoptionDecision |
| ): Promise<PendingOAuthExchangeResponse> { |
| return completePendingOAuthBindLogin(decision) |
| } |
|
|
| export const authAPI = { |
| login, |
| login2FA, |
| isTotp2FARequired, |
| register, |
| getCurrentUser, |
| logout, |
| isAuthenticated, |
| setAuthToken, |
| setRefreshToken, |
| setTokenExpiresAt, |
| getAuthToken, |
| getRefreshToken, |
| getTokenExpiresAt, |
| clearAuthToken, |
| getPublicSettings, |
| sendVerifyCode, |
| sendPendingOAuthVerifyCode, |
| validatePromoCode, |
| validateInvitationCode, |
| forgotPassword, |
| resetPassword, |
| refreshToken, |
| revokeAllSessions, |
| getPendingOAuthBindLoginKind, |
| isPendingOAuthCreateAccountRequired, |
| hasPendingOAuthSuggestedProfile, |
| completePendingOAuthBindLogin, |
| createPendingLinuxDoOAuthAccount, |
| createPendingOIDCOAuthAccount, |
| createPendingWeChatOAuthAccount, |
| exchangePendingOAuthCompletion, |
| completeLinuxDoOAuthRegistration, |
| completeOIDCOAuthRegistration, |
| completeWeChatOAuthRegistration, |
| createPendingDingTalkOAuthAccount |
| } |
|
|
| export default authAPI |
|
|