feat: add native password and sms login
This commit is contained in:
@@ -1,14 +1,74 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ExternalLink, Loader2, LogIn } from 'lucide-react';
|
||||
import { Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
|
||||
const LOGIN_SERVICE_ERROR_MESSAGE = '登录服务暂时不可用,请稍后重试。';
|
||||
const LOGIN_ERROR_MESSAGE = '登录失败,请稍后重试。';
|
||||
const CAPTCHA_ERROR_MESSAGE = '图形验证码加载失败,请重试。';
|
||||
const SMS_ERROR_MESSAGE = '验证码发送失败,请稍后重试。';
|
||||
const PHONE_PATTERN = /^1\d{10}$/;
|
||||
|
||||
type LoginMode = 'password' | 'mobile';
|
||||
|
||||
type PublicLinks = {
|
||||
termsUrl: string | null;
|
||||
privacyUrl: string | null;
|
||||
forgotPasswordUrl: string | null;
|
||||
};
|
||||
|
||||
type PublicConfigResponse = {
|
||||
success?: unknown;
|
||||
links?: unknown;
|
||||
};
|
||||
|
||||
type CaptchaResponse = {
|
||||
success?: unknown;
|
||||
image?: {
|
||||
mimeType?: unknown;
|
||||
dataBase64?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type SmsResponse = {
|
||||
success?: unknown;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
const EMPTY_LINKS: PublicLinks = {
|
||||
termsUrl: null,
|
||||
privacyUrl: null,
|
||||
forgotPasswordUrl: null,
|
||||
};
|
||||
|
||||
function getSafeExternalUrl(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !value.trim()) return null;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function projectPublicLinks(response: PublicConfigResponse): PublicLinks {
|
||||
if (response.success !== true || !response.links || typeof response.links !== 'object') {
|
||||
return EMPTY_LINKS;
|
||||
}
|
||||
const links = response.links as Record<string, unknown>;
|
||||
return {
|
||||
termsUrl: getSafeExternalUrl(links.termsUrl),
|
||||
privacyUrl: getSafeExternalUrl(links.privacyUrl),
|
||||
forgotPasswordUrl: getSafeExternalUrl(links.forgotPasswordUrl),
|
||||
};
|
||||
}
|
||||
|
||||
function getLoginErrorMessage(error: string | null): string | null {
|
||||
const message = error?.trim();
|
||||
@@ -34,31 +94,124 @@ function getLoginErrorMessage(error: string | null): string | null {
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate();
|
||||
const loginWithBrowser = useAuthStore((state) => state.loginWithBrowser);
|
||||
const loginWithPassword = useAuthStore((state) => state.loginWithPassword);
|
||||
const loginWithMobile = useAuthStore((state) => state.loginWithMobile);
|
||||
const loading = useAuthStore((state) => state.loading);
|
||||
const error = useAuthStore((state) => state.error);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const logout = useAuthStore((state) => state.logout);
|
||||
const importUserModelConfig = useProviderStore((state) => state.importUserModelConfig);
|
||||
const [mode, setMode] = useState<LoginMode>('password');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [imageCode, setImageCode] = useState('');
|
||||
const [smsCode, setSmsCode] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [links, setLinks] = useState<PublicLinks>(EMPTY_LINKS);
|
||||
const [captchaRandomStr, setCaptchaRandomStr] = useState<string | null>(null);
|
||||
const [captchaImage, setCaptchaImage] = useState<string | null>(null);
|
||||
const [captchaLoading, setCaptchaLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [cooldownActive, setCooldownActive] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const captchaRequestRef = useRef(0);
|
||||
const captchaStartedRef = useRef(false);
|
||||
const displayError = getLoginErrorMessage(submitError || error);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void hostApiFetch<PublicConfigResponse>('/api/auth/public-config', { cache: 'no-store' })
|
||||
.then((response) => {
|
||||
if (active) setLinks(projectPublicLinks(response));
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setLinks(EMPTY_LINKS);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadCaptcha = useCallback(async () => {
|
||||
captchaStartedRef.current = true;
|
||||
const requestId = ++captchaRequestRef.current;
|
||||
const randomStr = crypto.randomUUID();
|
||||
setCaptchaLoading(true);
|
||||
setCaptchaRandomStr(null);
|
||||
setCaptchaImage(null);
|
||||
setImageCode('');
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const response = await hostApiFetch<CaptchaResponse>(
|
||||
`/api/auth/mobile-image-code?randomStr=${encodeURIComponent(randomStr)}`,
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
if (requestId !== captchaRequestRef.current) return;
|
||||
const mimeType = response.image?.mimeType;
|
||||
const dataBase64 = response.image?.dataBase64;
|
||||
if (
|
||||
response.success !== true
|
||||
|| typeof mimeType !== 'string'
|
||||
|| !/^image\/[a-z0-9.+-]+$/i.test(mimeType)
|
||||
|| typeof dataBase64 !== 'string'
|
||||
|| !dataBase64
|
||||
) {
|
||||
throw new Error(CAPTCHA_ERROR_MESSAGE);
|
||||
}
|
||||
setCaptchaRandomStr(randomStr);
|
||||
setCaptchaImage(`data:${mimeType};base64,${dataBase64}`);
|
||||
} catch {
|
||||
if (requestId === captchaRequestRef.current) {
|
||||
setSubmitError(CAPTCHA_ERROR_MESSAGE);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === captchaRequestRef.current) setCaptchaLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'mobile' || cooldownActive || captchaImage || captchaLoading || captchaStartedRef.current) return;
|
||||
void loadCaptcha();
|
||||
}, [captchaImage, captchaLoading, cooldownActive, loadCaptcha, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cooldownActive) return;
|
||||
const startedAt = Date.now();
|
||||
const ticker = window.setInterval(() => {
|
||||
const elapsedSeconds = Math.floor((Date.now() - startedAt) / 1_000);
|
||||
setCooldown(Math.max(0, 60 - elapsedSeconds));
|
||||
}, 1_000);
|
||||
const expiry = window.setTimeout(() => {
|
||||
setCooldownActive(false);
|
||||
setCooldown(0);
|
||||
void loadCaptcha();
|
||||
}, 60_000);
|
||||
return () => {
|
||||
window.clearInterval(ticker);
|
||||
window.clearTimeout(expiry);
|
||||
};
|
||||
}, [cooldownActive, loadCaptcha]);
|
||||
|
||||
useEffect(() => () => {
|
||||
captchaRequestRef.current += 1;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated() && !submitting) {
|
||||
navigate('/module-select', { replace: true });
|
||||
}
|
||||
}, [isAuthenticated, navigate, submitting]);
|
||||
|
||||
const handleBrowserLogin = async () => {
|
||||
const finishLogin = async (login: () => Promise<void>) => {
|
||||
setSubmitError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await loginWithBrowser();
|
||||
await login();
|
||||
const accessToken = useAuthStore.getState().accessToken;
|
||||
if (!accessToken) {
|
||||
throw new Error('Makelore sign-in did not return an access token');
|
||||
}
|
||||
if (!accessToken) throw new Error('Login did not return an access token');
|
||||
try {
|
||||
await importUserModelConfig(accessToken);
|
||||
} catch (syncError) {
|
||||
@@ -73,6 +226,75 @@ export function Login() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!agreed || !username.trim() || !password) return;
|
||||
void finishLogin(() => loginWithPassword({ username: username.trim(), password }));
|
||||
};
|
||||
|
||||
const handleMobileSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!agreed || !PHONE_PATTERN.test(phone) || !smsCode.trim()) return;
|
||||
void finishLogin(() => loginWithMobile({ phone, code: smsCode.trim() }));
|
||||
};
|
||||
|
||||
const handleModeChange = (nextMode: LoginMode) => {
|
||||
if (nextMode === mode) return;
|
||||
setMode(nextMode);
|
||||
if (nextMode === 'mobile' && !cooldownActive) {
|
||||
void loadCaptcha();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (
|
||||
sendingCode
|
||||
|| cooldownActive
|
||||
|| !PHONE_PATTERN.test(phone)
|
||||
|| !captchaRandomStr
|
||||
|| !captchaImage
|
||||
|| !imageCode.trim()
|
||||
) return;
|
||||
|
||||
const challenge = captchaRandomStr;
|
||||
setSendingCode(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const response = await hostApiFetch<SmsResponse>('/api/auth/mobile-code', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
body: JSON.stringify({
|
||||
phone,
|
||||
imageRandomStr: challenge,
|
||||
imageCode: imageCode.trim(),
|
||||
}),
|
||||
});
|
||||
if (response.success !== true) {
|
||||
throw new Error(SMS_ERROR_MESSAGE);
|
||||
}
|
||||
captchaRequestRef.current += 1;
|
||||
setCaptchaRandomStr(null);
|
||||
setCaptchaImage(null);
|
||||
setImageCode('');
|
||||
setCooldown(60);
|
||||
setCooldownActive(true);
|
||||
} catch (sendError) {
|
||||
const message = sendError instanceof Error ? sendError.message : SMS_ERROR_MESSAGE;
|
||||
await loadCaptcha();
|
||||
setSubmitError(message);
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const busy = loading || submitting;
|
||||
const passwordReady = agreed && Boolean(username.trim()) && Boolean(password) && !busy;
|
||||
const mobileReady = agreed && PHONE_PATTERN.test(phone) && Boolean(smsCode.trim()) && !busy;
|
||||
const sendReady = PHONE_PATTERN.test(phone)
|
||||
&& Boolean(captchaRandomStr && captchaImage && imageCode.trim())
|
||||
&& !sendingCode
|
||||
&& !cooldownActive;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-background px-4 py-8">
|
||||
<Card className="w-full max-w-[420px]">
|
||||
@@ -84,31 +306,100 @@ export function Login() {
|
||||
<p className="text-sm text-muted-foreground">登录 Makelore 账户</p>
|
||||
</div>
|
||||
</div>
|
||||
<div role="tablist" aria-label="登录方式" className="grid grid-cols-2 rounded-md bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'password'}
|
||||
className="rounded px-3 py-2 text-sm aria-selected:bg-background aria-selected:font-medium aria-selected:shadow-sm"
|
||||
onClick={() => handleModeChange('password')}
|
||||
>
|
||||
密码登录
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'mobile'}
|
||||
className="rounded px-3 py-2 text-sm aria-selected:bg-background aria-selected:font-medium aria-selected:shadow-sm"
|
||||
onClick={() => handleModeChange('mobile')}
|
||||
>
|
||||
验证码登录
|
||||
</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
请在浏览器中完成登录,并授权此桌面应用访问。
|
||||
</p>
|
||||
|
||||
{displayError && (
|
||||
<div role="alert" className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{displayError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="button" className="w-full" disabled={loading || submitting} onClick={handleBrowserLogin}>
|
||||
{loading || submitting ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
在浏览器中继续
|
||||
</Button>
|
||||
{mode === 'password' ? (
|
||||
<form className="space-y-4" onSubmit={handlePasswordSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-username">用户名</Label>
|
||||
<Input id="login-username" autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="login-password">密码</Label>
|
||||
{links.forgotPasswordUrl && (
|
||||
<a className="text-xs text-primary hover:underline" href={links.forgotPasswordUrl} target="_blank" rel="noopener noreferrer">忘记密码?</a>
|
||||
)}
|
||||
</div>
|
||||
<Input id="login-password" type="password" autoComplete="current-password" value={password} onChange={(event) => setPassword(event.target.value)} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={!passwordReady}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleMobileSubmit}>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-phone">手机号</Label>
|
||||
<Input id="login-phone" type="tel" inputMode="numeric" autoComplete="tel" maxLength={11} value={phone} onChange={(event) => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-image-code">图形验证码</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="login-image-code" autoComplete="off" value={imageCode} onChange={(event) => setImageCode(event.target.value)} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新图形验证码"
|
||||
className="flex h-10 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md border bg-muted"
|
||||
disabled={cooldownActive}
|
||||
onClick={() => void loadCaptcha()}
|
||||
>
|
||||
{captchaLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : captchaImage ? <img src={captchaImage} alt="图形验证码" className="h-full w-full object-contain" /> : <RefreshCw className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="login-sms-code">短信验证码</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="login-sms-code" inputMode="numeric" autoComplete="one-time-code" value={smsCode} onChange={(event) => setSmsCode(event.target.value)} />
|
||||
<Button type="button" variant="outline" className="w-28 shrink-0" disabled={!sendReady} onClick={() => void handleSendCode()}>
|
||||
{sendingCode ? '发送中…' : cooldownActive ? `${cooldown} 秒` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={!mobileReady}>
|
||||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
登录
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
你的密码会留在安全的登录页面中,不会输入到此应用。
|
||||
</div>
|
||||
<label className="flex items-start gap-2 text-xs leading-5 text-muted-foreground">
|
||||
<input type="checkbox" className="mt-1" checked={agreed} onChange={(event) => setAgreed(event.target.checked)} />
|
||||
<span>
|
||||
我已阅读并同意
|
||||
{links.termsUrl ? <a className="mx-1 text-primary hover:underline" href={links.termsUrl} target="_blank" rel="noopener noreferrer">用户协议</a> : '用户协议'}
|
||||
和
|
||||
{links.privacyUrl ? <a className="ml-1 text-primary hover:underline" href={links.privacyUrl} target="_blank" rel="noopener noreferrer">隐私政策</a> : '隐私政策'}
|
||||
</span>
|
||||
</label>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { create } from 'zustand';
|
||||
import { create, type StoreApi } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
@@ -64,6 +64,16 @@ type RefreshSessionOptions = {
|
||||
forceRefresh?: boolean;
|
||||
};
|
||||
|
||||
type PasswordLoginInput = {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
|
||||
type MobileLoginInput = {
|
||||
phone: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
type AuthState = {
|
||||
initialized: boolean;
|
||||
loading: boolean;
|
||||
@@ -80,7 +90,8 @@ type AuthState = {
|
||||
user: AuthUser | null;
|
||||
moduleAccess: ModuleAccess;
|
||||
init: () => Promise<void>;
|
||||
loginWithBrowser: () => Promise<void>;
|
||||
loginWithPassword: (input: PasswordLoginInput) => Promise<void>;
|
||||
loginWithMobile: (input: MobileLoginInput) => Promise<void>;
|
||||
refreshSession: (options?: RefreshSessionOptions) => Promise<string | null>;
|
||||
getValidAccessToken: () => Promise<string | null>;
|
||||
markActivity: () => Promise<void>;
|
||||
@@ -242,6 +253,50 @@ async function readCurrentModuleAccess(fallback: ModuleAccess): Promise<ModuleAc
|
||||
}
|
||||
}
|
||||
|
||||
async function loginViaHost(
|
||||
path: '/api/auth/login' | '/api/auth/mobile-login',
|
||||
input: PasswordLoginInput | MobileLoginInput,
|
||||
set: StoreApi<AuthState>['setState'],
|
||||
): Promise<void> {
|
||||
advanceAuthSessionEpoch();
|
||||
const operationEpoch = authSessionEpoch;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await hostApiFetch<AuthTokenResponse>(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
const session = parseMainSession(response.session);
|
||||
if (!response.success || !response.token || !session) {
|
||||
throw new Error(response.error || 'Login failed');
|
||||
}
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
|
||||
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
authBase: trimTrailingSlash(DEFAULT_AUTH_BASE),
|
||||
clientId: DEFAULT_CLIENT_ID,
|
||||
...sessionFieldsFromMain(session),
|
||||
user: createUserFromToken(response.token),
|
||||
moduleAccess,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
const terminal = isTerminalAuthError(error);
|
||||
const message = terminal
|
||||
? '登录已过期,请重新授权。'
|
||||
: (error instanceof Error ? error.message : String(error));
|
||||
advanceAuthSessionEpoch();
|
||||
set({ loading: false, error: message, ...getClearedSession() });
|
||||
throw new Error(message, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
@@ -371,44 +426,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
set({ initialized: true, loading: false, error: null, moduleAccess });
|
||||
},
|
||||
|
||||
loginWithBrowser: async () => {
|
||||
advanceAuthSessionEpoch();
|
||||
const operationEpoch = authSessionEpoch;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const response = await hostApiFetch<AuthTokenResponse>('/api/auth/browser/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
const session = parseMainSession(response.session);
|
||||
if (!response.success || !response.token || !session) {
|
||||
throw new Error(response.error || 'Browser authorization failed');
|
||||
}
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
loginWithPassword: (input) => loginViaHost('/api/auth/login', input, set),
|
||||
|
||||
const moduleAccess = await readCurrentModuleAccess({ ...DEFAULT_MODULE_ACCESS });
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
|
||||
set({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: null,
|
||||
authBase: trimTrailingSlash(DEFAULT_AUTH_BASE),
|
||||
clientId: DEFAULT_CLIENT_ID,
|
||||
...sessionFieldsFromMain(session),
|
||||
user: createUserFromToken(response.token),
|
||||
moduleAccess,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentAuthSessionEpoch(operationEpoch)) return;
|
||||
const terminal = isTerminalAuthError(error);
|
||||
const message = terminal
|
||||
? '登录已过期,请重新授权。'
|
||||
: (error instanceof Error ? error.message : String(error));
|
||||
advanceAuthSessionEpoch();
|
||||
set({ loading: false, error: message, ...getClearedSession() });
|
||||
throw new Error(message, { cause: error });
|
||||
}
|
||||
},
|
||||
loginWithMobile: (input) => loginViaHost('/api/auth/mobile-login', input, set),
|
||||
|
||||
refreshSession: async (options = {}) => {
|
||||
const state = get();
|
||||
|
||||
Reference in New Issue
Block a user