503 lines
12 KiB
TypeScript
503 lines
12 KiB
TypeScript
import type {
|
||
AuthAuditLogEntry,
|
||
AuthAuditLogsResponse,
|
||
AuthCaptchaChallenge,
|
||
AuthEntryResponse,
|
||
AuthLiftRiskBlockResponse,
|
||
AuthLoginMethod,
|
||
AuthLoginOptionsResponse,
|
||
AuthLogoutAllResponse,
|
||
AuthMeResponse,
|
||
AuthPasswordChangeResponse,
|
||
AuthPasswordResetResponse,
|
||
AuthPhoneChangeResponse,
|
||
AuthPhoneLoginResponse,
|
||
AuthPhoneSendCodeResponse,
|
||
AuthProfileUpdateRequest,
|
||
AuthProfileUpdateResponse,
|
||
AuthRevokeSessionResponse,
|
||
AuthRiskBlocksResponse,
|
||
AuthRiskBlockSummary,
|
||
AuthSessionsResponse,
|
||
AuthSessionSummary,
|
||
AuthWechatBindPhoneResponse,
|
||
AuthWechatStartResponse,
|
||
LogoutResponse,
|
||
PublicUserSearchResponse,
|
||
} from '../../packages/shared/src/contracts/auth';
|
||
import type { RedeemProfileReferralInviteCodeResponse } from '../../packages/shared/src/contracts/runtime';
|
||
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 normalizeInviteCodeInput(inviteCode: string | undefined) {
|
||
return (inviteCode ?? '')
|
||
.trim()
|
||
.replace(/[^0-9a-z]/gi, '')
|
||
.toUpperCase();
|
||
}
|
||
|
||
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,
|
||
inviteCode?: string,
|
||
) {
|
||
const normalizedInviteCode = normalizeInviteCodeInput(inviteCode);
|
||
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(),
|
||
...(normalizedInviteCode ? { inviteCode: normalizedInviteCode } : {}),
|
||
}),
|
||
},
|
||
'登录失败',
|
||
PUBLIC_AUTH_REQUEST_OPTIONS,
|
||
);
|
||
|
||
setStoredAccessToken(response.token, { emit: false });
|
||
return response;
|
||
}
|
||
|
||
export async function redeemRegistrationInviteCode(inviteCode: string) {
|
||
return requestJson<RedeemProfileReferralInviteCodeResponse>(
|
||
'/api/profile/referrals/redeem-code',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
inviteCode: normalizeInviteCodeInput(inviteCode),
|
||
}),
|
||
},
|
||
'填写邀请码失败',
|
||
);
|
||
}
|
||
|
||
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 updateAuthProfile(payload: AuthProfileUpdateRequest) {
|
||
const response = await requestJson<AuthProfileUpdateResponse>(
|
||
'/api/profile/me',
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
displayName: payload.displayName?.trim() || undefined,
|
||
avatarDataUrl: payload.avatarDataUrl?.trim() || undefined,
|
||
}),
|
||
},
|
||
'更新资料失败',
|
||
);
|
||
|
||
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();
|
||
}
|
||
}
|