import type { AuthAuditLogEntry, AuthAuditLogsResponse, AuthCaptchaChallenge, AuthEntryResponse, AuthLiftRiskBlockResponse, AuthLoginMethod, AuthLoginOptionsResponse, AuthLogoutAllResponse, AuthMeResponse, AuthPasswordChangeResponse, AuthPasswordResetResponse, AuthPhoneChangeResponse, AuthPhoneLoginResponse, AuthPhoneSendCodeResponse, AuthRevokeSessionResponse, AuthRiskBlocksResponse, AuthRiskBlockSummary, AuthSessionsResponse, AuthSessionSummary, AuthUser, AuthWechatBindPhoneResponse, AuthWechatStartResponse, LogoutResponse, PublicUserSearchResponse, } from '../../packages/shared/src/contracts/auth'; import { ApiClientError, type ApiRequestOptions, clearStoredAccessToken, emitAuthStateChange, requestJson, setStoredAccessToken, } from './apiClient'; export type { AuthUser } from '../../packages/shared/src/contracts/auth'; export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth'; export type AuthSessionSnapshot = { user: import('../../packages/shared/src/contracts/auth').AuthUser | null; availableLoginMethods: AuthLoginMethod[]; }; export type { AuthSessionSummary }; export type { AuthCaptchaChallenge }; export type { AuthAuditLogEntry }; export type { AuthRiskBlockSummary }; export type ConsumedAuthCallback = { provider: 'wechat' | 'unknown'; bindingStatus: string | null; error: string | null; }; // 登录前公开认证入口不能误带旧 token,也不能先触发 refresh 探测, // 否则无会话用户点击“获取验证码”时会先打出一条无意义的 /auth/refresh 401。 const PUBLIC_AUTH_REQUEST_OPTIONS = { skipAuth: true, skipRefresh: true, } satisfies ApiRequestOptions; const LAST_LOGIN_PHONE_STORAGE_KEY = 'genarrative:last-login-phone'; export function normalizePhoneInput(phoneInput: string) { return phoneInput.replace(/[^\d+]/gu, '').trim(); } export function getStoredLastLoginPhone() { if (typeof window === 'undefined') { return ''; } return window.localStorage.getItem(LAST_LOGIN_PHONE_STORAGE_KEY) ?? ''; } export function setStoredLastLoginPhone(phone: string) { if (typeof window === 'undefined') { return; } const normalizedPhone = normalizePhoneInput(phone); if (!normalizedPhone) { return; } window.localStorage.setItem(LAST_LOGIN_PHONE_STORAGE_KEY, normalizedPhone); } export function getCaptchaChallengeFromError( error: unknown, ): AuthCaptchaChallenge | null { if ( error instanceof ApiClientError && error.code === 'CAPTCHA_REQUIRED' && error.details && typeof error.details === 'object' && 'captchaChallenge' in error.details ) { const challenge = (error.details as { captchaChallenge?: unknown }) .captchaChallenge; if ( challenge && typeof challenge === 'object' && 'challengeId' in challenge && 'promptText' in challenge && 'imageDataUrl' in challenge && 'expiresInSeconds' in challenge ) { return challenge as AuthCaptchaChallenge; } } return null; } export function clearAuthSession() { clearStoredAccessToken({ emit: false }); emitAuthStateChange(); } export async function sendPhoneLoginCode( phone: string, scene: 'login' | 'bind_phone' | 'change_phone' | 'reset_password' = 'login', captcha?: { challengeId?: string; answer?: string; }, ) { const response = await requestJson( '/api/auth/phone/send-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: normalizePhoneInput(phone), scene, captchaChallengeId: captcha?.challengeId?.trim() || undefined, captchaAnswer: captcha?.answer?.trim() || undefined, }), }, '发送验证码失败', PUBLIC_AUTH_REQUEST_OPTIONS, ); return response; } export async function loginWithPhoneCode(phone: string, code: string) { const response = await requestJson( '/api/auth/phone/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: normalizePhoneInput(phone), code: code.trim(), }), }, '登录失败', PUBLIC_AUTH_REQUEST_OPTIONS, ); setStoredAccessToken(response.token, { emit: false }); return response.user; } export async function bindWechatPhone(phone: string, code: string) { const response = await requestJson( '/api/auth/wechat/bind-phone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: normalizePhoneInput(phone), code: code.trim(), }), }, '绑定手机号失败', ); setStoredAccessToken(response.token, { emit: false }); return response.user; } export async function changePhoneNumber(phone: string, code: string) { const response = await requestJson( '/api/auth/phone/change', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: normalizePhoneInput(phone), code: code.trim(), }), }, '更换手机号失败', ); return response.user; } export async function startWechatLogin() { const response = await requestJson( `/api/auth/wechat/start?redirectPath=${encodeURIComponent(window.location.pathname)}`, { method: 'GET', }, '微信登录暂不可用', PUBLIC_AUTH_REQUEST_OPTIONS, ); window.location.assign(response.authorizationUrl); } export async function getAuthLoginOptions() { return requestJson( '/api/auth/login-options', { method: 'GET', }, '读取登录方式失败', PUBLIC_AUTH_REQUEST_OPTIONS, ); } export async function authEntry(phone: string, password: string) { const response = await requestJson( '/api/auth/entry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: normalizePhoneInput(phone), password: password.trim(), }), }, '登录失败', PUBLIC_AUTH_REQUEST_OPTIONS, ); setStoredAccessToken(response.token, { emit: false }); return response.user; } export async function changePassword( currentPassword: string, newPassword: string, ) { const response = await requestJson( '/api/auth/password/change', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword: currentPassword.trim() || undefined, newPassword: newPassword.trim(), }), }, '修改密码失败', ); return response.user; } export async function resetPassword( phone: string, code: string, newPassword: string, ) { const response = await requestJson( '/api/auth/password/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone: normalizePhoneInput(phone), code: code.trim(), newPassword: newPassword.trim(), }), }, '重置密码失败', PUBLIC_AUTH_REQUEST_OPTIONS, ); setStoredAccessToken(response.token, { emit: false }); return response.user; } export function consumeAuthCallbackResult(): ConsumedAuthCallback | null { if (typeof window === 'undefined') { return null; } const hash = window.location.hash.startsWith('#') ? window.location.hash.slice(1) : window.location.hash; if (!hash) { return null; } const params = new URLSearchParams(hash); const authToken = params.get('auth_token'); const authError = params.get('auth_error'); const providerValue = params.get('auth_provider'); const bindingStatus = params.get('auth_binding_status'); if (!authToken && !authError) { return null; } if (authToken) { setStoredAccessToken(authToken, { emit: false }); } if (typeof window.history?.replaceState === 'function') { window.history.replaceState( null, '', `${window.location.pathname}${window.location.search}`, ); } return { provider: providerValue === 'wechat' ? 'wechat' : 'unknown', bindingStatus, error: authError, }; } export async function getCurrentAuthUser(): Promise { const response = await requestJson( '/api/auth/me', { method: 'GET', }, '读取当前用户失败', { // 会话恢复阶段允许 401 作为“未登录”信号,不应再广播一次全局鉴权事件。 notifyAuthStateChange: false, }, ); return { user: response.user, availableLoginMethods: response.availableLoginMethods, }; } export async function getPublicAuthUserByCode(code: string) { const response = await requestJson( `/api/auth/public-users/by-code/${encodeURIComponent(code.trim())}`, { method: 'GET', }, '读取用户信息失败', PUBLIC_AUTH_REQUEST_OPTIONS, ); return response.user; } export async function getPublicAuthUserById(userId: string) { const response = await requestJson( `/api/auth/public-users/by-id/${encodeURIComponent(userId.trim())}`, { method: 'GET', }, '读取用户信息失败', PUBLIC_AUTH_REQUEST_OPTIONS, ); return response.user; } export async function getAuthSessions() { const response = await requestJson( '/api/auth/sessions', { method: 'GET', }, '读取登录设备失败', ); return response.sessions; } export async function revokeAuthSession(sessionId: string) { await requestJson( `/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`, { method: 'POST', }, '移除登录设备失败', ); } export async function getAuthAuditLogs() { const response = await requestJson( '/api/auth/audit-logs', { method: 'GET', }, '读取账号操作记录失败', ); return response.logs; } export async function getAuthRiskBlocks() { const response = await requestJson( '/api/auth/risk-blocks', { method: 'GET', }, '读取安全状态失败', ); return response.blocks; } export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') { await requestJson( `/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`, { method: 'POST', }, '解除保护失败', ); } export async function logoutAuthUser() { try { await requestJson( '/api/auth/logout', { method: 'POST', }, '退出登录失败', ); } finally { clearAuthSession(); } } export async function logoutAllAuthSessions() { try { await requestJson( '/api/auth/logout-all', { method: 'POST', }, '退出全部设备失败', ); } finally { clearAuthSession(); } }