init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

456
src/services/authService.ts Normal file
View File

@@ -0,0 +1,456 @@
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<AuthPhoneSendCodeResponse>(
'/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<AuthPhoneLoginResponse>(
'/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<AuthWechatBindPhoneResponse>(
'/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<AuthPhoneChangeResponse>(
'/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<AuthWechatStartResponse>(
`/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<AuthLoginOptionsResponse>(
'/api/auth/login-options',
{
method: 'GET',
},
'读取登录方式失败',
PUBLIC_AUTH_REQUEST_OPTIONS,
);
}
export async function authEntry(phone: string, password: string) {
const response = await requestJson<AuthEntryResponse>(
'/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<AuthPasswordChangeResponse>(
'/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<AuthPasswordResetResponse>(
'/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<AuthSessionSnapshot> {
const response = await requestJson<AuthMeResponse>(
'/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<PublicUserSearchResponse>(
`/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<PublicUserSearchResponse>(
`/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<AuthSessionsResponse>(
'/api/auth/sessions',
{
method: 'GET',
},
'读取登录设备失败',
);
return response.sessions;
}
export async function revokeAuthSession(sessionId: string) {
await requestJson<AuthRevokeSessionResponse>(
`/api/auth/sessions/${encodeURIComponent(sessionId)}/revoke`,
{
method: 'POST',
},
'移除登录设备失败',
);
}
export async function getAuthAuditLogs() {
const response = await requestJson<AuthAuditLogsResponse>(
'/api/auth/audit-logs',
{
method: 'GET',
},
'读取账号操作记录失败',
);
return response.logs;
}
export async function getAuthRiskBlocks() {
const response = await requestJson<AuthRiskBlocksResponse>(
'/api/auth/risk-blocks',
{
method: 'GET',
},
'读取安全状态失败',
);
return response.blocks;
}
export async function liftAuthRiskBlock(scopeType: 'phone' | 'ip') {
await requestJson<AuthLiftRiskBlockResponse>(
`/api/auth/risk-blocks/${encodeURIComponent(scopeType)}/lift`,
{
method: 'POST',
},
'解除保护失败',
);
}
export async function logoutAuthUser() {
try {
await requestJson<LogoutResponse>(
'/api/auth/logout',
{
method: 'POST',
},
'退出登录失败',
);
} finally {
clearAuthSession();
}
}
export async function logoutAllAuthSessions() {
try {
await requestJson<AuthLogoutAllResponse>(
'/api/auth/logout-all',
{
method: 'POST',
},
'退出全部设备失败',
);
} finally {
clearAuthSession();
}
}