feat: merge opus comfyui and login updates
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Login } from './App.js';
|
||||
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
||||
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
@@ -23,7 +23,7 @@ describe('Login surface', () => {
|
||||
it('renders a poster-style hero with a floating admin login panel', () => {
|
||||
const root = create(
|
||||
<MemoryRouter>
|
||||
<Login onLogin={vi.fn()} t={(text) => text} initialAudience="admin" />
|
||||
<AdminLoginPage onLogin={vi.fn()} t={(text) => text} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('Login surface', () => {
|
||||
expect(githubLink.props['aria-label']).toBe('GitHub');
|
||||
expect(githubLink.props.target).toBe('_blank');
|
||||
expect(tokenInput.props.type).toBe('password');
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['普通用户', '管理员', 'Auth Token', '账号密码']);
|
||||
expect(loginModeTabs.map((node) => collectText(node))).toEqual(['Auth Token', '账号密码']);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ const { apiMock, authSessionMock } = vi.hoisted(() => ({
|
||||
hasValidAuthSession: vi.fn(),
|
||||
persistAuthSession: vi.fn(),
|
||||
clearAuthSession: vi.fn(),
|
||||
getSessionToken: vi.fn(),
|
||||
commitSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -31,6 +34,9 @@ vi.mock('./authSession.js', () => ({
|
||||
hasValidAuthSession: authSessionMock.hasValidAuthSession,
|
||||
persistAuthSession: authSessionMock.persistAuthSession,
|
||||
clearAuthSession: authSessionMock.clearAuthSession,
|
||||
getSessionToken: authSessionMock.getSessionToken,
|
||||
commitSession: authSessionMock.commitSession,
|
||||
clearSession: authSessionMock.clearSession,
|
||||
}));
|
||||
|
||||
vi.mock('./components/SearchModal.js', () => ({
|
||||
@@ -138,6 +144,9 @@ describe('App mobile layout', () => {
|
||||
vi.useFakeTimers();
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
authSessionMock.hasValidAuthSession.mockReturnValue(true);
|
||||
authSessionMock.getSessionToken.mockImplementation((audience: string) =>
|
||||
audience === 'admin' ? 'fake-admin-token' : null,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -12,6 +12,9 @@ const { apiMock, authSessionMock } = vi.hoisted(() => ({
|
||||
hasValidAuthSession: vi.fn(),
|
||||
persistAuthSession: vi.fn(),
|
||||
clearAuthSession: vi.fn(),
|
||||
getSessionToken: vi.fn(),
|
||||
commitSession: vi.fn(),
|
||||
clearSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -31,6 +34,9 @@ vi.mock('./authSession.js', () => ({
|
||||
hasValidAuthSession: authSessionMock.hasValidAuthSession,
|
||||
persistAuthSession: authSessionMock.persistAuthSession,
|
||||
clearAuthSession: authSessionMock.clearAuthSession,
|
||||
getSessionToken: authSessionMock.getSessionToken,
|
||||
commitSession: authSessionMock.commitSession,
|
||||
clearSession: authSessionMock.clearSession,
|
||||
}));
|
||||
|
||||
vi.mock('./components/SearchModal.js', () => ({
|
||||
@@ -140,6 +146,9 @@ describe('App mobile sidebar', () => {
|
||||
vi.useFakeTimers();
|
||||
apiMock.getEvents.mockResolvedValue([]);
|
||||
authSessionMock.hasValidAuthSession.mockReturnValue(true);
|
||||
authSessionMock.getSessionToken.mockImplementation((audience: string) =>
|
||||
audience === 'admin' ? 'fake-admin-token' : null,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
+33
-366
@@ -1,11 +1,11 @@
|
||||
import React, { Suspense, lazy, useState, useEffect, useRef } from 'react';
|
||||
import { Routes, Route, NavLink, Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom';
|
||||
import { ToastProvider, useToast } from './components/Toast.js';
|
||||
import SearchModal from './components/SearchModal.js';
|
||||
import NotificationPanel from './components/NotificationPanel.js';
|
||||
import TooltipLayer from './components/TooltipLayer.js';
|
||||
import { api } from './api.js';
|
||||
import { clearAuthSession, clearUserSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
|
||||
import { clearSession, getSessionToken } from './authSession.js';
|
||||
import {
|
||||
FIRST_USE_DOC_REMINDER_KEY,
|
||||
LEGACY_THEME_STORAGE_KEY,
|
||||
@@ -13,12 +13,13 @@ import {
|
||||
USER_PROFILE_STORAGE_KEY,
|
||||
} from './appLocalState.js';
|
||||
import { I18nProvider, useI18n } from './i18n.js';
|
||||
import { resolveLoginErrorMessage } from './loginError.js';
|
||||
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
|
||||
import { SITE_DOCS_URL } from './docsLink.js';
|
||||
import { useAnimatedVisibility } from './components/useAnimatedVisibility.js';
|
||||
import { useIsMobile } from './components/useIsMobile.js';
|
||||
import { MobileDrawer } from './components/MobileDrawer.js';
|
||||
import CenteredModal from './components/CenteredModal.js';
|
||||
import AdminLoginPage from './pages/auth/AdminLoginPage.js';
|
||||
import UserLoginPage from './pages/auth/UserLoginPage.js';
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard.js'));
|
||||
const Sites = lazy(() => import('./pages/Sites.js'));
|
||||
const Accounts = lazy(() => import('./pages/Accounts.js'));
|
||||
@@ -126,341 +127,6 @@ function resolveStoredProfile(): UserProfile {
|
||||
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
||||
}
|
||||
}
|
||||
type LoginAudience = 'user' | 'admin';
|
||||
type LoginMode = 'token' | 'account';
|
||||
|
||||
export function Login({
|
||||
onLogin,
|
||||
onUserLogin,
|
||||
t,
|
||||
initialAudience = 'user',
|
||||
}: {
|
||||
onLogin: (token: string) => void;
|
||||
onUserLogin?: () => void;
|
||||
t: (text: string) => string;
|
||||
initialAudience?: LoginAudience;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [audience, setAudience] = useState<LoginAudience>(initialAudience);
|
||||
const [mode, setMode] = useState<LoginMode>('token');
|
||||
const [token, setToken] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const capabilityRows = [
|
||||
{
|
||||
title: t('统一代理网关'),
|
||||
description: t('一个 Key、一个入口,兼容 OpenAI / Claude 下游格式'),
|
||||
},
|
||||
{
|
||||
title: t('自动模型发现'),
|
||||
description: t('上游新增模型自动出现在模型列表,零配置路由生成'),
|
||||
},
|
||||
{
|
||||
title: t('智能路由引擎'),
|
||||
description: t('按成本、延迟、成功率自动选择最优通道,故障自动转移'),
|
||||
},
|
||||
];
|
||||
const handleTokenLogin = async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/settings/auth/info', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
onLogin(token);
|
||||
} else {
|
||||
let reason = '';
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
|
||||
if (typeof parsed.message === 'string') reason = parsed.message;
|
||||
else if (typeof parsed.error === 'string') reason = parsed.error;
|
||||
else reason = text;
|
||||
} catch {
|
||||
reason = text;
|
||||
}
|
||||
}
|
||||
} catch { }
|
||||
setError(t(resolveLoginErrorMessage(res.status, reason)));
|
||||
setLoading(false);
|
||||
}
|
||||
} catch {
|
||||
setError(t('无法连接到服务器'));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleAccountLogin = async () => {
|
||||
if (!email.trim() || !password) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim(), password }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const rawErr = data?.error;
|
||||
const message = data?.message
|
||||
|| (typeof rawErr === 'string' ? rawErr : rawErr?.message)
|
||||
|| t('登录失败,请检查邮箱和密码');
|
||||
setError(t(String(message)));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = String(data?.token || data?.data?.token || '').trim();
|
||||
const role = String(data?.user?.role || data?.data?.user?.role || '').trim();
|
||||
if (!jwt) {
|
||||
setError(t('登录失败,请检查邮箱和密码'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (audience === 'admin' && role !== 'admin') {
|
||||
setError(t('该账号不是管理员,无法进入控制台。'));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (audience === 'admin') {
|
||||
onLogin(jwt);
|
||||
return;
|
||||
}
|
||||
persistUserSession(localStorage, jwt);
|
||||
onUserLogin?.();
|
||||
navigate('/user/dashboard');
|
||||
} catch {
|
||||
setError(t('无法连接到服务器'));
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleLogin = () => {
|
||||
if (audience === 'admin' && mode === 'token') {
|
||||
void handleTokenLogin();
|
||||
} else {
|
||||
void handleAccountLogin();
|
||||
}
|
||||
};
|
||||
const canSubmit = audience === 'admin' && mode === 'token'
|
||||
? !!token.trim()
|
||||
: !!email.trim() && !!password;
|
||||
const activeMode = audience === 'user' ? 'account' : mode;
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-surface animate-scale-in">
|
||||
<section className="login-brand-panel login-brand-panel-light">
|
||||
<div className="login-brand-header">
|
||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||
<div className="brand-mark-canvas">
|
||||
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-brand-summary">
|
||||
<div className="login-brand-name">BoosAPI</div>
|
||||
<div className="login-brand-kicker">{t('中转站的中转站')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-brand-copy-block">
|
||||
<p className="login-brand-copy">
|
||||
{t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="login-compat-line">{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')}</div>
|
||||
<div className="login-capability-list">
|
||||
{capabilityRows.map((feature, index) => (
|
||||
<div key={feature.title} className="login-capability-row">
|
||||
<div className="login-capability-index">{String(index + 1).padStart(2, '0')}</div>
|
||||
<div className="login-capability-content">
|
||||
<div className="login-capability-title">{feature.title}</div>
|
||||
<p className="login-capability-desc">{feature.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="login-brand-footer">
|
||||
<a
|
||||
href={SITE_GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="login-icon-link"
|
||||
aria-label="GitHub"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={SITE_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="login-doc-link"
|
||||
>
|
||||
{t('部署文档')}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-auth-stage">
|
||||
<div className="login-auth-panel">
|
||||
<div className="login-auth-eyebrow">
|
||||
{audience === 'admin' ? t('管理员入口') : t('用户入口')}
|
||||
</div>
|
||||
<h2 className="login-auth-title">{t('登录')}</h2>
|
||||
<p className="login-auth-copy">
|
||||
{audience === 'user'
|
||||
? t('普通用户使用邮箱和密码进入个人面板。')
|
||||
: activeMode === 'token'
|
||||
? t('请输入管理员令牌后继续。')
|
||||
: t('使用管理员账号和密码进入控制台。')}
|
||||
</p>
|
||||
<div className="login-mode-tabs" role="tablist" aria-label={t('登录入口')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={audience === 'user'}
|
||||
className={`login-mode-tab ${audience === 'user' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setAudience('user');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
{t('普通用户')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={audience === 'admin'}
|
||||
className={`login-mode-tab ${audience === 'admin' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setAudience('admin');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
{t('管理员')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="login-mode-tabs" role="tablist" aria-label={t('登录方式')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMode === 'token'}
|
||||
className={`login-mode-tab ${activeMode === 'token' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setAudience('admin');
|
||||
setMode('token');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
{t('Auth Token')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeMode === 'account'}
|
||||
className={`login-mode-tab ${activeMode === 'account' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('account');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
{t('账号密码')}
|
||||
</button>
|
||||
</div>
|
||||
{audience === 'admin' && mode === 'token' ? (
|
||||
<>
|
||||
<label className="login-auth-label" htmlFor="admin-token-input">{t('管理员令牌')}</label>
|
||||
<input
|
||||
id="admin-token-input"
|
||||
type="password"
|
||||
placeholder={t('管理员令牌')}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="login-auth-label" htmlFor="login-email-input">{t('邮箱')}</label>
|
||||
<input
|
||||
id="login-email-input"
|
||||
type="email"
|
||||
placeholder={audience === 'admin' ? t('例如: admin@example.com') : 'you@example.com'}
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="login-auth-input"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="login-password-input">{t('密码')}</label>
|
||||
<input
|
||||
id="login-password-input"
|
||||
type="password"
|
||||
placeholder={t('密码')}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={loading || !canSubmit}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />{t('验证中...')}</> : t('登录')}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{audience === 'user'
|
||||
? t('普通用户登录会进入个人面板,可管理自己的 API Key 和用量。')
|
||||
: mode === 'token'
|
||||
? t('仅校验本地服务访问权限,不会把令牌发送到第三方。')
|
||||
: t('账号密码登录会签发管理员会话,不会保存明文密码。')}
|
||||
</div>
|
||||
<div className="login-auth-footer">
|
||||
{audience === 'user' ? (
|
||||
<span>
|
||||
{t('还没有账号?')}
|
||||
<NavLink to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>
|
||||
{t('注册')}
|
||||
</NavLink>
|
||||
</span>
|
||||
) : (
|
||||
<span>{t('管理员登录后继续。')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function UserProfileModal({
|
||||
open,
|
||||
profile,
|
||||
@@ -638,8 +304,9 @@ function RouteLoadingFallback() {
|
||||
}
|
||||
function AppShell() {
|
||||
const { language, toggleLanguage, t } = useI18n();
|
||||
const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage));
|
||||
const [userAuthed, setUserAuthed] = useState(() => hasValidUserSession(localStorage));
|
||||
const location = useLocation();
|
||||
const [authed, setAuthed] = useState(() => getSessionToken('admin') !== null);
|
||||
const [userAuthed, setUserAuthed] = useState(() => getSessionToken('user') !== null);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -754,7 +421,7 @@ function AppShell() {
|
||||
useEffect(() => {
|
||||
if (!authed) return;
|
||||
const check = () => {
|
||||
if (hasValidAuthSession(localStorage)) return;
|
||||
if (getSessionToken('admin') !== null) return;
|
||||
setAuthed(false);
|
||||
toast.info(t('会话已过期,请重新登录'));
|
||||
};
|
||||
@@ -799,28 +466,38 @@ function AppShell() {
|
||||
toast.success(t('个人信息已保存'));
|
||||
};
|
||||
const isUserPage = location.pathname.startsWith('/user/');
|
||||
const isAdminLoginPage = location.pathname === '/boos';
|
||||
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
|
||||
// The default `/` route now serves the user-facing login; admins land on `/boos`.
|
||||
const isDefaultLoginRoot = !authed && !userAuthed && location.pathname === '/';
|
||||
|
||||
if (isUserPage && isPublicUserPage) {
|
||||
if (userAuthed) return <Navigate to="/user/dashboard" />;
|
||||
if (authed) return <Navigate to="/" />;
|
||||
}
|
||||
|
||||
if (isAdminLoginPage) {
|
||||
if (authed) return <Navigate to="/" />;
|
||||
return (
|
||||
<AdminLoginPage
|
||||
t={t}
|
||||
onLogin={() => setAuthed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDefaultLoginRoot) {
|
||||
return <Navigate to="/user/login" />;
|
||||
}
|
||||
|
||||
if (!authed && !isUserPage) {
|
||||
if (userAuthed) {
|
||||
return <Navigate to="/user/dashboard" />;
|
||||
}
|
||||
return (
|
||||
<Login
|
||||
t={t}
|
||||
initialAudience="admin"
|
||||
onLogin={(token) => {
|
||||
persistAuthSession(localStorage, token);
|
||||
setAuthed(true);
|
||||
}}
|
||||
onUserLogin={() => setUserAuthed(true)}
|
||||
/>
|
||||
);
|
||||
// Anyone hitting an admin-only path without admin auth gets bounced to the
|
||||
// user login (or dashboard if they are a logged-in user). Admins know to
|
||||
// visit `/boos` directly.
|
||||
return <Navigate to="/user/login" />;
|
||||
}
|
||||
|
||||
if (isUserPage && !userAuthed && !isPublicUserPage) {
|
||||
@@ -833,17 +510,7 @@ function AppShell() {
|
||||
<Routes>
|
||||
<Route
|
||||
path="/user/login"
|
||||
element={(
|
||||
<Login
|
||||
t={t}
|
||||
initialAudience="user"
|
||||
onLogin={(token) => {
|
||||
persistAuthSession(localStorage, token);
|
||||
setAuthed(true);
|
||||
}}
|
||||
onUserLogin={() => setUserAuthed(true)}
|
||||
/>
|
||||
)}
|
||||
element={<UserLoginPage t={t} onLogin={() => setUserAuthed(true)} />}
|
||||
/>
|
||||
<Route path="/user/register" element={<UserRegister onLogin={() => setUserAuthed(true)} />} />
|
||||
</Routes>
|
||||
@@ -984,8 +651,8 @@ function AppShell() {
|
||||
{t('个人信息')}
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
clearAuthSession(localStorage);
|
||||
clearUserSession(localStorage);
|
||||
clearSession('admin');
|
||||
clearSession('user');
|
||||
setAuthed(false);
|
||||
setUserAuthed(false);
|
||||
}} className="user-dropdown-item danger">
|
||||
|
||||
+23
-11
@@ -1,4 +1,4 @@
|
||||
import { clearAuthSession, getAuthToken, getUserToken, clearUserSession } from "./authSession.js";
|
||||
import { clearSession, getSessionToken } from "./authSession.js";
|
||||
|
||||
type BufferLike = {
|
||||
from(data: ArrayBuffer): { toString(encoding: "base64"): string };
|
||||
@@ -12,10 +12,10 @@ type RequestOptions = RequestInit & {
|
||||
};
|
||||
|
||||
function requireAuthToken(): string {
|
||||
const token = getAuthToken(localStorage);
|
||||
const token = getSessionToken('admin', localStorage);
|
||||
if (!token) {
|
||||
const hadToken = !!localStorage.getItem("auth_token");
|
||||
clearAuthSession(localStorage);
|
||||
clearSession('admin', localStorage);
|
||||
if (
|
||||
hadToken &&
|
||||
typeof window !== "undefined" &&
|
||||
@@ -127,8 +127,8 @@ async function fetchAuthenticatedResponse(
|
||||
headers,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
const hadToken = !!getAuthToken(localStorage);
|
||||
clearAuthSession(localStorage);
|
||||
const hadToken = !!getSessionToken('admin', localStorage);
|
||||
clearSession('admin', localStorage);
|
||||
if (
|
||||
hadToken &&
|
||||
typeof window !== "undefined" &&
|
||||
@@ -182,9 +182,9 @@ async function fetchUserResponse(
|
||||
}
|
||||
}
|
||||
|
||||
const token = getUserToken(localStorage);
|
||||
const token = getSessionToken('user', localStorage);
|
||||
if (!token) {
|
||||
clearUserSession(localStorage);
|
||||
clearSession('user', localStorage);
|
||||
throw new Error("User session expired");
|
||||
}
|
||||
const headers = new Headers(fetchOptions.headers ?? {});
|
||||
@@ -200,7 +200,7 @@ async function fetchUserResponse(
|
||||
headers,
|
||||
});
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
clearUserSession(localStorage);
|
||||
clearSession('user', localStorage);
|
||||
throw new Error("User session expired");
|
||||
}
|
||||
return res;
|
||||
@@ -325,8 +325,20 @@ function normalizeProxyLogRow(row: any): any {
|
||||
routeId: row.routeId ?? row.route_id ?? null,
|
||||
channelId: row.channelId ?? row.channel_id ?? null,
|
||||
accountId: row.accountId ?? row.account_id ?? null,
|
||||
siteId: row.siteId ?? row.site_id ?? null,
|
||||
username: row.username ?? null,
|
||||
siteName: row.siteName ?? row.site_name ?? null,
|
||||
siteUrl: row.siteUrl ?? row.site_url ?? null,
|
||||
downstreamApiKeyId:
|
||||
row.downstreamApiKeyId ?? row.downstream_api_key_id ?? null,
|
||||
downstreamKeyId:
|
||||
row.downstreamKeyId ?? row.downstream_key_id ?? row.downstreamApiKeyId ?? row.downstream_api_key_id ?? null,
|
||||
downstreamKeyName:
|
||||
row.downstreamKeyName ?? row.downstream_key_name ?? null,
|
||||
downstreamKeyGroupName:
|
||||
row.downstreamKeyGroupName ?? row.downstream_key_group_name ?? null,
|
||||
downstreamKeyTags:
|
||||
row.downstreamKeyTags ?? parseJsonArrayField(row.downstream_key_tags),
|
||||
modelRequested: row.modelRequested ?? row.model_requested ?? "",
|
||||
modelActual: row.modelActual ?? row.model_actual ?? null,
|
||||
httpStatus: row.httpStatus ?? row.http_status ?? null,
|
||||
@@ -1381,7 +1393,7 @@ export const api = {
|
||||
normalizeProxyDebugTraceDetail,
|
||||
) as Promise<ProxyDebugTraceDetail>,
|
||||
checkModels: (accountId: number) =>
|
||||
request(`/api/models/check/${accountId}`, { method: "POST" }),
|
||||
request(`/api/accounts/${accountId}/models/refresh`, { method: "POST" }),
|
||||
getSiteDistribution: () => request("/api/stats/site-distribution"),
|
||||
getSiteTrend: (days = 7) => request(`/api/stats/site-trend?days=${days}`),
|
||||
getSiteSnapshot: async (days = 7, options?: { refresh?: boolean }) => {
|
||||
@@ -1795,9 +1807,9 @@ export const api = {
|
||||
data: TestChatRequestPayload,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
const token = getAuthToken(localStorage);
|
||||
const token = getSessionToken('admin', localStorage);
|
||||
if (!token) {
|
||||
clearAuthSession(localStorage);
|
||||
clearSession('admin', localStorage);
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
return fetch("/api/test/chat/stream", {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { clearAuthSession } from './authSession.js';
|
||||
import { clearSession } from './authSession.js';
|
||||
|
||||
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
|
||||
export const LEGACY_THEME_STORAGE_KEY = 'theme';
|
||||
@@ -20,7 +20,7 @@ function resolveStorage(storage?: StorageLike | null): StorageLike | null {
|
||||
export function clearAppInstallationState(storage?: StorageLike | null): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
clearAuthSession(target as never);
|
||||
clearSession('admin', target as never);
|
||||
target.removeItem(THEME_MODE_STORAGE_KEY);
|
||||
target.removeItem(LEGACY_THEME_STORAGE_KEY);
|
||||
target.removeItem(USER_PROFILE_STORAGE_KEY);
|
||||
|
||||
@@ -2,14 +2,21 @@ import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
AUTH_SESSION_DURATION_MS,
|
||||
clearAuthSession,
|
||||
clearSession,
|
||||
commitSession,
|
||||
getAuthToken,
|
||||
getSessionToken,
|
||||
getUserToken,
|
||||
hasValidAuthSession,
|
||||
hasValidUserSession,
|
||||
persistAuthSession,
|
||||
persistUserSession,
|
||||
} from './authSession.js';
|
||||
|
||||
function createMemoryStorage() {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
raw: store,
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
@@ -22,7 +29,7 @@ function createMemoryStorage() {
|
||||
};
|
||||
}
|
||||
|
||||
describe('authSession', () => {
|
||||
describe('authSession (legacy admin API)', () => {
|
||||
it('persists token with expiration and reads it before expiry', () => {
|
||||
const storage = createMemoryStorage();
|
||||
persistAuthSession(storage, 'token-1', 60_000, 1_000);
|
||||
@@ -48,3 +55,77 @@ describe('authSession', () => {
|
||||
expect(getAuthToken(storage, 2_000)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('authSession (legacy user API)', () => {
|
||||
it('persists token with expiration and reads it before expiry', () => {
|
||||
const storage = createMemoryStorage();
|
||||
persistUserSession(storage, 'user-token', 60_000, 1_000);
|
||||
|
||||
expect(getUserToken(storage, 10_000)).toBe('user-token');
|
||||
expect(hasValidUserSession(storage, 10_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('admin and user sessions live in separate slots', () => {
|
||||
const storage = createMemoryStorage();
|
||||
persistAuthSession(storage, 'admin-token', 60_000, 1_000);
|
||||
persistUserSession(storage, 'user-token', 60_000, 1_000);
|
||||
|
||||
expect(getAuthToken(storage, 5_000)).toBe('admin-token');
|
||||
expect(getUserToken(storage, 5_000)).toBe('user-token');
|
||||
|
||||
clearAuthSession(storage);
|
||||
expect(getAuthToken(storage, 5_000)).toBeNull();
|
||||
expect(getUserToken(storage, 5_000)).toBe('user-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('authSession (commitSession unified API)', () => {
|
||||
it('writes admin tokens to the same slot as persistAuthSession', () => {
|
||||
const storage = createMemoryStorage();
|
||||
commitSession('admin', 'unified-admin', 60_000, storage, 1_000);
|
||||
|
||||
expect(getAuthToken(storage, 5_000)).toBe('unified-admin');
|
||||
expect(getSessionToken('admin', storage, 5_000)).toBe('unified-admin');
|
||||
});
|
||||
|
||||
it('writes user tokens to the same slot as persistUserSession', () => {
|
||||
const storage = createMemoryStorage();
|
||||
commitSession('user', 'unified-user', 60_000, storage, 1_000);
|
||||
|
||||
expect(getUserToken(storage, 5_000)).toBe('unified-user');
|
||||
expect(getSessionToken('user', storage, 5_000)).toBe('unified-user');
|
||||
});
|
||||
|
||||
it('audiences do not bleed across each other', () => {
|
||||
const storage = createMemoryStorage();
|
||||
commitSession('admin', 'a', 60_000, storage, 1_000);
|
||||
commitSession('user', 'u', 60_000, storage, 1_000);
|
||||
|
||||
clearSession('admin', storage);
|
||||
|
||||
expect(getSessionToken('admin', storage, 5_000)).toBeNull();
|
||||
expect(getSessionToken('user', storage, 5_000)).toBe('u');
|
||||
});
|
||||
|
||||
it('empty token clears the slot', () => {
|
||||
const storage = createMemoryStorage();
|
||||
commitSession('admin', 'first', 60_000, storage, 1_000);
|
||||
commitSession('admin', ' ', 60_000, storage, 1_000);
|
||||
|
||||
expect(getSessionToken('admin', storage, 5_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('expired token is purged on read', () => {
|
||||
const storage = createMemoryStorage();
|
||||
commitSession('user', 'expiring', 60_000, storage, 1_000);
|
||||
|
||||
expect(getSessionToken('user', storage, 1_000_000)).toBeNull();
|
||||
});
|
||||
|
||||
it('legacy entries without expiresAt get auto-migrated', () => {
|
||||
const storage = createMemoryStorage();
|
||||
storage.setItem('auth_token', 'legacy-admin');
|
||||
expect(getSessionToken('admin', storage, 1_000)).toBe('legacy-admin');
|
||||
expect(storage.getItem('auth_token_expires_at')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+129
-74
@@ -4,115 +4,170 @@ const USER_JWT_STORAGE_KEY = 'user_jwt_token';
|
||||
const USER_JWT_EXPIRES_AT_STORAGE_KEY = 'user_jwt_expires_at';
|
||||
export const AUTH_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
export type SessionAudience = 'admin' | 'user';
|
||||
|
||||
type StorageLike = {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
};
|
||||
|
||||
type AudienceKeys = {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
const AUDIENCE_KEYS: Record<SessionAudience, AudienceKeys> = {
|
||||
admin: {
|
||||
token: AUTH_TOKEN_STORAGE_KEY,
|
||||
expiresAt: AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY,
|
||||
},
|
||||
user: {
|
||||
token: USER_JWT_STORAGE_KEY,
|
||||
expiresAt: USER_JWT_EXPIRES_AT_STORAGE_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
function resolveStorage(storage?: StorageLike | null): StorageLike | null {
|
||||
if (storage) return storage;
|
||||
if (typeof localStorage !== 'undefined') return localStorage;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function clearAuthSession(storage?: StorageLike | null): void {
|
||||
/**
|
||||
* Persist a session token for the given audience. Replaces any existing token.
|
||||
* Empty / whitespace-only tokens clear the session instead.
|
||||
*/
|
||||
export function commitSession(
|
||||
audience: SessionAudience,
|
||||
token: string,
|
||||
ttlMs: number = AUTH_SESSION_DURATION_MS,
|
||||
storage?: StorageLike | null,
|
||||
nowMs: number = Date.now(),
|
||||
): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
target.removeItem(AUTH_TOKEN_STORAGE_KEY);
|
||||
target.removeItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY);
|
||||
|
||||
const cleanToken = (token || '').trim();
|
||||
const keys = AUDIENCE_KEYS[audience];
|
||||
if (!cleanToken) {
|
||||
target.removeItem(keys.token);
|
||||
target.removeItem(keys.expiresAt);
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
|
||||
target.setItem(keys.token, cleanToken);
|
||||
target.setItem(keys.expiresAt, String(expiresAt));
|
||||
}
|
||||
|
||||
/** Drop the session token for the given audience. */
|
||||
export function clearSession(
|
||||
audience: SessionAudience,
|
||||
storage?: StorageLike | null,
|
||||
): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
const keys = AUDIENCE_KEYS[audience];
|
||||
target.removeItem(keys.token);
|
||||
target.removeItem(keys.expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the session token for the given audience, returning null if expired or
|
||||
* missing. Auto-migrates legacy entries (token without an expiresAt) to a
|
||||
* fresh TTL.
|
||||
*/
|
||||
export function getSessionToken(
|
||||
audience: SessionAudience,
|
||||
storage?: StorageLike | null,
|
||||
nowMs: number = Date.now(),
|
||||
): string | null {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return null;
|
||||
|
||||
const keys = AUDIENCE_KEYS[audience];
|
||||
const token = (target.getItem(keys.token) || '').trim();
|
||||
if (!token) return null;
|
||||
|
||||
const expiresAtRaw = target.getItem(keys.expiresAt);
|
||||
if (!expiresAtRaw) {
|
||||
// Legacy migration: stamp a fresh TTL the first time we see a tokenless entry.
|
||||
commitSession(audience, token, AUTH_SESSION_DURATION_MS, target, nowMs);
|
||||
return token;
|
||||
}
|
||||
|
||||
const expiresAt = Number(expiresAtRaw);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
|
||||
clearSession(audience, target);
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy thin-shell API. Prefer commitSession / clearSession / getSessionToken.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** @deprecated use {@link clearSession}('admin'). */
|
||||
export function clearAuthSession(storage?: StorageLike | null): void {
|
||||
clearSession('admin', storage);
|
||||
}
|
||||
|
||||
/** @deprecated use {@link commitSession}('admin', ...). */
|
||||
export function persistAuthSession(
|
||||
storage: StorageLike | null | undefined,
|
||||
token: string,
|
||||
ttlMs = AUTH_SESSION_DURATION_MS,
|
||||
nowMs = Date.now(),
|
||||
ttlMs: number = AUTH_SESSION_DURATION_MS,
|
||||
nowMs: number = Date.now(),
|
||||
): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
|
||||
const cleanToken = (token || '').trim();
|
||||
if (!cleanToken) {
|
||||
clearAuthSession(target);
|
||||
return;
|
||||
}
|
||||
|
||||
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
|
||||
target.setItem(AUTH_TOKEN_STORAGE_KEY, cleanToken);
|
||||
target.setItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
|
||||
commitSession('admin', token, ttlMs, storage, nowMs);
|
||||
}
|
||||
|
||||
export function getAuthToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return null;
|
||||
|
||||
const token = (target.getItem(AUTH_TOKEN_STORAGE_KEY) || '').trim();
|
||||
if (!token) return null;
|
||||
|
||||
const expiresAtRaw = target.getItem(AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY);
|
||||
if (!expiresAtRaw) {
|
||||
// Legacy migration: set a default TTL the first time we read an old session.
|
||||
persistAuthSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
|
||||
return token;
|
||||
}
|
||||
|
||||
const expiresAt = Number(expiresAtRaw);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
|
||||
clearAuthSession(target);
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
/** @deprecated use {@link getSessionToken}('admin'). */
|
||||
export function getAuthToken(
|
||||
storage?: StorageLike | null,
|
||||
nowMs: number = Date.now(),
|
||||
): string | null {
|
||||
return getSessionToken('admin', storage, nowMs);
|
||||
}
|
||||
|
||||
export function hasValidAuthSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
|
||||
return !!getAuthToken(storage, nowMs);
|
||||
/** @deprecated derive from {@link getSessionToken}('admin'). */
|
||||
export function hasValidAuthSession(
|
||||
storage?: StorageLike | null,
|
||||
nowMs: number = Date.now(),
|
||||
): boolean {
|
||||
return !!getSessionToken('admin', storage, nowMs);
|
||||
}
|
||||
|
||||
/** @deprecated use {@link clearSession}('user'). */
|
||||
export function clearUserSession(storage?: StorageLike | null): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
target.removeItem(USER_JWT_STORAGE_KEY);
|
||||
target.removeItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
|
||||
clearSession('user', storage);
|
||||
}
|
||||
|
||||
/** @deprecated use {@link commitSession}('user', ...). */
|
||||
export function persistUserSession(
|
||||
storage: StorageLike | null | undefined,
|
||||
token: string,
|
||||
ttlMs = AUTH_SESSION_DURATION_MS,
|
||||
nowMs = Date.now(),
|
||||
ttlMs: number = AUTH_SESSION_DURATION_MS,
|
||||
nowMs: number = Date.now(),
|
||||
): void {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return;
|
||||
const cleanToken = (token || '').trim();
|
||||
if (!cleanToken) {
|
||||
clearUserSession(target);
|
||||
return;
|
||||
}
|
||||
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
|
||||
target.setItem(USER_JWT_STORAGE_KEY, cleanToken);
|
||||
target.setItem(USER_JWT_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
|
||||
commitSession('user', token, ttlMs, storage, nowMs);
|
||||
}
|
||||
|
||||
export function getUserToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
|
||||
const target = resolveStorage(storage);
|
||||
if (!target) return null;
|
||||
const token = (target.getItem(USER_JWT_STORAGE_KEY) || '').trim();
|
||||
if (!token) return null;
|
||||
const expiresAtRaw = target.getItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
|
||||
if (!expiresAtRaw) {
|
||||
persistUserSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
|
||||
return token;
|
||||
}
|
||||
const expiresAt = Number(expiresAtRaw);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
|
||||
clearUserSession(target);
|
||||
return null;
|
||||
}
|
||||
return token;
|
||||
/** @deprecated use {@link getSessionToken}('user'). */
|
||||
export function getUserToken(
|
||||
storage?: StorageLike | null,
|
||||
nowMs: number = Date.now(),
|
||||
): string | null {
|
||||
return getSessionToken('user', storage, nowMs);
|
||||
}
|
||||
|
||||
export function hasValidUserSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
|
||||
return !!getUserToken(storage, nowMs);
|
||||
/** @deprecated derive from {@link getSessionToken}('user'). */
|
||||
export function hasValidUserSession(
|
||||
storage?: StorageLike | null,
|
||||
nowMs: number = Date.now(),
|
||||
): boolean {
|
||||
return !!getSessionToken('user', storage, nowMs);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { api } from '../api.js';
|
||||
import { useToast } from './Toast.js';
|
||||
import { persistAuthSession } from '../authSession.js';
|
||||
import { commitSession } from '../authSession.js';
|
||||
import { useAnimatedVisibility } from './useAnimatedVisibility.js';
|
||||
|
||||
export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
@@ -43,7 +43,7 @@ export default function ChangeKeyModal({ open, onClose }: { open: boolean; onClo
|
||||
const res = await api.changeAuthToken(oldToken, newToken);
|
||||
if (res.success) {
|
||||
toast.success('Token 已更新,请使用新 Token 重新登录');
|
||||
persistAuthSession(localStorage, newToken);
|
||||
commitSession('admin', newToken, undefined, localStorage);
|
||||
onClose();
|
||||
setOldToken('');
|
||||
setNewToken('');
|
||||
|
||||
@@ -4125,6 +4125,70 @@ textarea {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-surface {
|
||||
width: min(560px, 100%);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-panel {
|
||||
min-height: 0;
|
||||
padding: 28px 24px 18px;
|
||||
border-radius: 26px 26px 18px 18px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-panel::before {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-panel::after {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-header {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-summary {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .brand-mark-frame-hero {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .brand-mark-canvas {
|
||||
border-radius: 18px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-logo {
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-brand-name {
|
||||
font-size: 34px;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-auth-stage {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.login-shell[data-variant="user"] .login-auth-panel {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .login-surface,
|
||||
[data-theme="dark"] .brand-mark-frame,
|
||||
[data-theme="dark"] .brand-mark-canvas,
|
||||
|
||||
@@ -11,7 +11,35 @@ describe('resolveLoginErrorMessage', () => {
|
||||
expect(resolveLoginErrorMessage(401, 'Missing Authorization header')).toBe('登录令牌无效');
|
||||
});
|
||||
|
||||
it('returns generic forbidden message for unrelated 403', () => {
|
||||
expect(resolveLoginErrorMessage(403, 'something else')).toBe('没有访问权限');
|
||||
});
|
||||
|
||||
it('returns server error message for 5xx', () => {
|
||||
expect(resolveLoginErrorMessage(500, 'internal error')).toBe('服务端异常,请稍后重试');
|
||||
expect(resolveLoginErrorMessage(503, '')).toBe('服务端异常,请稍后重试');
|
||||
});
|
||||
|
||||
it('routes 400 by reason', () => {
|
||||
expect(resolveLoginErrorMessage(400, 'invalid email format')).toBe('邮箱格式不合法');
|
||||
expect(resolveLoginErrorMessage(400, 'password missing')).toBe('密码不能为空');
|
||||
expect(resolveLoginErrorMessage(400, '')).toBe('请求参数错误');
|
||||
});
|
||||
|
||||
it('routes 409 by reason', () => {
|
||||
expect(resolveLoginErrorMessage(409, 'no_account')).toBe('此邮箱尚未注册');
|
||||
expect(resolveLoginErrorMessage(409, 'NO ACCOUNT')).toBe('此邮箱尚未注册');
|
||||
expect(resolveLoginErrorMessage(409, 'account locked too many tries')).toBe(
|
||||
'账号已被锁定,请稍后重试',
|
||||
);
|
||||
expect(resolveLoginErrorMessage(409, '')).toBe('登录冲突,请稍后再试');
|
||||
});
|
||||
|
||||
it('handles network sentinel', () => {
|
||||
expect(resolveLoginErrorMessage('network', '')).toBe('网络异常,请检查连接');
|
||||
});
|
||||
|
||||
it('falls back to generic message for unmapped statuses', () => {
|
||||
expect(resolveLoginErrorMessage(418, '')).toBe('登录失败,请检查服务状态');
|
||||
});
|
||||
});
|
||||
|
||||
+37
-2
@@ -1,17 +1,52 @@
|
||||
export type LoginErrorStatus = number | 'network';
|
||||
|
||||
function normalizeReason(reason: string): string {
|
||||
return (reason || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveLoginErrorMessage(status: number, reason: string): string {
|
||||
export function resolveLoginErrorMessage(
|
||||
status: LoginErrorStatus,
|
||||
reason: string,
|
||||
): string {
|
||||
const normalizedReason = normalizeReason(reason);
|
||||
|
||||
if (status === 'network') {
|
||||
return '网络异常,请检查连接';
|
||||
}
|
||||
|
||||
if (status === 400) {
|
||||
if (normalizedReason.includes('invalid email')) {
|
||||
return '邮箱格式不合法';
|
||||
}
|
||||
if (normalizedReason.includes('password')) {
|
||||
return '密码不能为空';
|
||||
}
|
||||
return '请求参数错误';
|
||||
}
|
||||
|
||||
if (status === 403 && normalizedReason.includes('ip not allowed')) {
|
||||
return '当前 IP 不在管理白名单中';
|
||||
}
|
||||
if (status === 401 || (status === 403 && normalizedReason.includes('invalid token'))) {
|
||||
return '登录令牌无效';
|
||||
}
|
||||
if (status >= 500) {
|
||||
if (status === 403) {
|
||||
return '没有访问权限';
|
||||
}
|
||||
|
||||
if (status === 409) {
|
||||
if (normalizedReason.includes('no_account') || normalizedReason.includes('no account')) {
|
||||
return '此邮箱尚未注册';
|
||||
}
|
||||
if (normalizedReason.includes('locked')) {
|
||||
return '账号已被锁定,请稍后重试';
|
||||
}
|
||||
return '登录冲突,请稍后再试';
|
||||
}
|
||||
|
||||
if (typeof status === 'number' && status >= 500) {
|
||||
return '服务端异常,请稍后重试';
|
||||
}
|
||||
|
||||
return '登录失败,请检查服务状态';
|
||||
}
|
||||
|
||||
@@ -307,7 +307,19 @@ export default function Accounts() {
|
||||
const verifyFailureHint = buildVerifyFailureHint(verifyResult);
|
||||
const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult);
|
||||
|
||||
const setSegment = (nextSegment: ConnectionsSegment) => {
|
||||
const setSegment = (
|
||||
nextSegment: ConnectionsSegment,
|
||||
event?: React.MouseEvent | React.KeyboardEvent,
|
||||
) => {
|
||||
// Guard against synthetic clicks dispatched by third-party libraries
|
||||
// (e.g. @visactor/vrender's event manager has been observed re-firing
|
||||
// the segmented-button onClick after unrelated interactions, which
|
||||
// silently navigates the page back to the default "session" segment
|
||||
// mid-session). Only honour the call when it was driven by a real
|
||||
// user-initiated DOM event, or when it was invoked programmatically
|
||||
// without an event argument (e.g. test fixtures).
|
||||
if (event && !event.isTrusted) return;
|
||||
if (nextSegment === activeSegment) return;
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (nextSegment === "session") params.delete("segment");
|
||||
else params.set("segment", nextSegment);
|
||||
@@ -1437,7 +1449,7 @@ export default function Accounts() {
|
||||
<button
|
||||
key={segment.value}
|
||||
type="button"
|
||||
onClick={() => setSegment(segment.value)}
|
||||
onClick={(event) => setSegment(segment.value, event)}
|
||||
data-tooltip={segment.tooltip}
|
||||
data-tooltip-side={segment.tooltipSide}
|
||||
data-tooltip-align={segment.tooltipAlign}
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getSessionToken } from '../authSession.js';
|
||||
|
||||
export default function ComfyUI() {
|
||||
const [isDark, setIsDark] = React.useState(true);
|
||||
const location = useLocation();
|
||||
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const token = getSessionToken(audience, localStorage);
|
||||
const iframeSrc = token ? `/comfy/?token=${encodeURIComponent(token)}` : '';
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
@@ -22,16 +28,28 @@ export default function ComfyUI() {
|
||||
background: isDark ? '#1a1a2e' : '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
src="/comfyui"
|
||||
style={{
|
||||
width: '100%',
|
||||
{iframeSrc ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
title="ComfyUI"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
title="ComfyUI"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 14,
|
||||
}}>
|
||||
登录状态已过期,请重新登录。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+216
-10
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { getAuthToken } from '../authSession.js';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getSessionToken } from '../authSession.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
|
||||
|
||||
type AgentRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
@@ -54,6 +56,11 @@ type StreamAccumulator = {
|
||||
workflowJson: unknown;
|
||||
};
|
||||
|
||||
type StoredAgentSession = {
|
||||
messages?: AgentMessage[];
|
||||
selectedModel?: string;
|
||||
};
|
||||
|
||||
const WELCOME_MESSAGE: AgentMessage = {
|
||||
id: 'welcome',
|
||||
role: 'system',
|
||||
@@ -71,6 +78,53 @@ function createId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function sessionStorageKey(audience: 'admin' | 'user') {
|
||||
return `boosapi.comfyuiAgent.${audience}.session`;
|
||||
}
|
||||
|
||||
function isValidMessage(value: unknown): value is AgentMessage {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
const item = value as Partial<AgentMessage>;
|
||||
return (
|
||||
typeof item.id === 'string' &&
|
||||
(item.role === 'user' || item.role === 'assistant' || item.role === 'system') &&
|
||||
typeof item.content === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
|
||||
try {
|
||||
const raw = localStorage.getItem(sessionStorageKey(audience));
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as StoredAgentSession;
|
||||
const messages = Array.isArray(parsed.messages)
|
||||
? parsed.messages.filter(isValidMessage)
|
||||
: undefined;
|
||||
return {
|
||||
messages: messages?.length ? messages : undefined,
|
||||
selectedModel:
|
||||
typeof parsed.selectedModel === 'string' ? parsed.selectedModel : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistStoredSession(
|
||||
audience: 'admin' | 'user',
|
||||
messages: AgentMessage[],
|
||||
selectedModel: string,
|
||||
) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
sessionStorageKey(audience),
|
||||
JSON.stringify({ messages, selectedModel }),
|
||||
);
|
||||
} catch {
|
||||
// Ignore quota/private-mode failures; chat still works for the current page.
|
||||
}
|
||||
}
|
||||
|
||||
function angleLabel(angle?: string) {
|
||||
if (angle === 'front') return '正面';
|
||||
if (angle === 'back') return '背面';
|
||||
@@ -124,11 +178,25 @@ async function readSseStream(
|
||||
|
||||
export default function ComfyUIAgent() {
|
||||
const toast = useToast();
|
||||
const [messages, setMessages] = useState<AgentMessage[]>([WELCOME_MESSAGE]);
|
||||
const location = useLocation();
|
||||
const sessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const initialStoredSession = useMemo(
|
||||
() => readStoredSession(sessionAudience),
|
||||
[sessionAudience],
|
||||
);
|
||||
const [messages, setMessages] = useState<AgentMessage[]>(
|
||||
() => initialStoredSession.messages || [WELCOME_MESSAGE],
|
||||
);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [streaming, setStreaming] = useState<StreamAccumulator | null>(null);
|
||||
const [status, setStatus] = useState<AgentStatus | null>(null);
|
||||
const [modelOptions, setModelOptions] = useState<string[]>([]);
|
||||
const [selectedModel, setSelectedModel] = useState(
|
||||
() => initialStoredSession.selectedModel || '',
|
||||
);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -139,13 +207,38 @@ export default function ComfyUIAgent() {
|
||||
}
|
||||
return streaming?.workflowJson || null;
|
||||
}, [messages, streaming]);
|
||||
const filteredModelOptions = useMemo(
|
||||
() => {
|
||||
const filtered = filterModelTesterModelNames(modelOptions, modelSearch).slice(0, 80);
|
||||
if (selectedModel && !filtered.includes(selectedModel)) {
|
||||
return [selectedModel, ...filtered];
|
||||
}
|
||||
return filtered;
|
||||
},
|
||||
[modelOptions, modelSearch, selectedModel],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [messages, streaming?.content]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getAuthToken(localStorage);
|
||||
const stored = readStoredSession(sessionAudience);
|
||||
setMessages(stored.messages || [WELCOME_MESSAGE]);
|
||||
setSelectedModel(stored.selectedModel || '');
|
||||
setModelSearch('');
|
||||
setStreaming(null);
|
||||
setLoading(false);
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
}, [sessionAudience]);
|
||||
|
||||
useEffect(() => {
|
||||
persistStoredSession(sessionAudience, messages, selectedModel);
|
||||
}, [messages, selectedModel, sessionAudience]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getSessionToken(sessionAudience, localStorage);
|
||||
if (!token) return;
|
||||
void fetch('/api/comfyui-agent/status', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
@@ -153,7 +246,40 @@ export default function ComfyUIAgent() {
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
.then((payload) => setStatus(payload || null))
|
||||
.catch(() => setStatus(null));
|
||||
}, []);
|
||||
}, [sessionAudience]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadRouteModels = async () => {
|
||||
const token = getSessionToken(sessionAudience, localStorage);
|
||||
if (!token) return;
|
||||
setLoadingModels(true);
|
||||
try {
|
||||
const response = await fetch('/api/comfyui-agent/models', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
const names = Array.isArray(payload?.models)
|
||||
? payload.models
|
||||
.map((model: unknown) => (typeof model === 'string' ? model.trim() : ''))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
setModelOptions(names);
|
||||
setSelectedModel((current) => {
|
||||
if (current && names.includes(current)) return current;
|
||||
const configuredModel = String(payload?.defaultModel || status?.model || '').trim();
|
||||
if (configuredModel && names.includes(configuredModel)) return configuredModel;
|
||||
return names[0] || configuredModel;
|
||||
});
|
||||
} catch {
|
||||
toast.error('加载模型路由失败');
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadRouteModels();
|
||||
}, [sessionAudience, status?.model, toast]);
|
||||
|
||||
const exportWorkflow = () => {
|
||||
const workflow = latestWorkflow || {
|
||||
@@ -250,7 +376,7 @@ export default function ComfyUIAgent() {
|
||||
const content = input.trim();
|
||||
if (!content || loading) return;
|
||||
|
||||
const token = getAuthToken(localStorage);
|
||||
const token = getSessionToken(sessionAudience, localStorage);
|
||||
if (!token) {
|
||||
toast.error('登录状态已过期,请重新登录');
|
||||
return;
|
||||
@@ -291,7 +417,10 @@ export default function ComfyUIAgent() {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ messages: payloadMessages }),
|
||||
body: JSON.stringify({
|
||||
messages: payloadMessages,
|
||||
model: selectedModel || status?.model,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -395,12 +524,45 @@ export default function ComfyUIAgent() {
|
||||
<div>
|
||||
<h1 style={titleStyle}>视频助手</h1>
|
||||
<div style={statusStyle}>
|
||||
模型 {status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
|
||||
模型 {selectedModel || status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
|
||||
导出工作流
|
||||
</button>
|
||||
<div style={headerActionsStyle}>
|
||||
<label style={modelPickerStyle}>
|
||||
<span style={modelPickerLabelStyle}>对话模型</span>
|
||||
<input
|
||||
value={modelSearch}
|
||||
onChange={(event) => setModelSearch(event.target.value)}
|
||||
placeholder={loadingModels ? '加载模型...' : '搜索模型路由'}
|
||||
disabled={loading || loadingModels || modelOptions.length === 0}
|
||||
style={modelSearchInputStyle}
|
||||
/>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(event) => {
|
||||
setSelectedModel(event.target.value);
|
||||
setModelSearch('');
|
||||
}}
|
||||
disabled={loading || loadingModels || modelOptions.length === 0}
|
||||
style={modelSelectStyle}
|
||||
>
|
||||
{modelOptions.length === 0 ? (
|
||||
<option value={status?.model || ''}>
|
||||
{loadingModels ? '模型加载中' : (status?.model || '暂无模型路由')}
|
||||
</option>
|
||||
) : filteredModelOptions.length === 0 ? (
|
||||
<option value={selectedModel}>没有匹配模型</option>
|
||||
) : (
|
||||
filteredModelOptions.map((model) => (
|
||||
<option key={model} value={model}>{model}</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
|
||||
导出工作流
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section style={quickPromptStyle}>
|
||||
@@ -506,6 +668,14 @@ const headerStyle: CSSProperties = {
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const headerActionsStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 10,
|
||||
flexWrap: 'wrap',
|
||||
};
|
||||
|
||||
const titleStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 18,
|
||||
@@ -518,6 +688,42 @@ const statusStyle: CSSProperties = {
|
||||
color: 'var(--color-text-muted)',
|
||||
};
|
||||
|
||||
const modelPickerStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(130px, 180px) minmax(180px, 260px)',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
};
|
||||
|
||||
const modelPickerLabelStyle: CSSProperties = {
|
||||
gridColumn: '1 / -1',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-text-muted)',
|
||||
};
|
||||
|
||||
const modelSearchInputStyle: CSSProperties = {
|
||||
minHeight: 36,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '7px 10px',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: 12,
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const modelSelectStyle: CSSProperties = {
|
||||
minHeight: 36,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '7px 10px',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: 12,
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const quickPromptStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../api.js';
|
||||
import { clearAuthSession, getAuthToken } from '../authSession.js';
|
||||
import { clearSession, getSessionToken } from '../authSession.js';
|
||||
import {
|
||||
DEBUG_TABS,
|
||||
DEFAULT_INPUTS,
|
||||
@@ -1555,8 +1555,8 @@ export default function ModelTester() {
|
||||
try {
|
||||
const response = await api.proxyTestStream(payload, controller.signal);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const hadToken = Boolean(getAuthToken(localStorage));
|
||||
clearAuthSession(localStorage);
|
||||
const hadToken = Boolean(getSessionToken('admin', localStorage));
|
||||
clearSession('admin', localStorage);
|
||||
if (hadToken) window.location.reload();
|
||||
throw new Error('会话已过期');
|
||||
}
|
||||
|
||||
@@ -266,14 +266,20 @@ async function copyTextToClipboard(text: string) {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
function formatLatency(ms: number) {
|
||||
function formatLatency(ms?: number | null) {
|
||||
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
||||
return "-";
|
||||
}
|
||||
if (ms >= 1000) {
|
||||
return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`;
|
||||
}
|
||||
return `${ms}ms`;
|
||||
}
|
||||
|
||||
function latencyColor(ms: number) {
|
||||
function latencyColor(ms?: number | null) {
|
||||
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
||||
return "var(--color-text-muted)";
|
||||
}
|
||||
if (ms >= 3000) return "var(--color-danger)";
|
||||
if (ms >= 2000)
|
||||
return "color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))";
|
||||
@@ -285,7 +291,10 @@ function latencyColor(ms: number) {
|
||||
return "var(--color-success)";
|
||||
}
|
||||
|
||||
function latencyBgColor(ms: number) {
|
||||
function latencyBgColor(ms?: number | null) {
|
||||
if (typeof ms !== "number" || !Number.isFinite(ms)) {
|
||||
return "var(--color-bg-muted, transparent)";
|
||||
}
|
||||
if (ms >= 3000)
|
||||
return "color-mix(in srgb, var(--color-danger) 12%, transparent)";
|
||||
if (ms >= 1000)
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
resolveRoutingProfilePreset,
|
||||
type RoutingWeights,
|
||||
} from './helpers/routingProfiles.js';
|
||||
import { clearAuthSession } from '../authSession.js';
|
||||
import { clearSession } from '../authSession.js';
|
||||
import { clearAppInstallationState } from '../appLocalState.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
|
||||
@@ -2615,7 +2615,7 @@ export default function Settings() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
clearAuthSession(localStorage);
|
||||
clearSession('admin', localStorage);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { getAuthToken } from '../authSession.js';
|
||||
import { getSessionToken } from '../authSession.js';
|
||||
import { getBrand } from '../components/BrandIcon.js';
|
||||
import CenteredModal from '../components/CenteredModal.js';
|
||||
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
|
||||
@@ -568,9 +568,14 @@ export default function Sites() {
|
||||
|
||||
const handleSaveDisabledModels = async () => {
|
||||
if (!editor || editor.mode !== 'edit') return;
|
||||
const siteId = editor.editingSiteId;
|
||||
if (typeof siteId !== 'number' || !Number.isFinite(siteId)) {
|
||||
toast.error('未选中有效站点,无法保存禁用模型');
|
||||
return;
|
||||
}
|
||||
setDisabledModelsSaving(true);
|
||||
try {
|
||||
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
|
||||
await api.updateSiteDisabledModels(siteId, disabledModels);
|
||||
try {
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success('禁用模型列表已保存,路由已重建');
|
||||
@@ -621,7 +626,7 @@ export default function Sites() {
|
||||
setProbeCompleted(false);
|
||||
|
||||
try {
|
||||
const token = getAuthToken(localStorage);
|
||||
const token = getSessionToken('admin', localStorage);
|
||||
const params = new URLSearchParams({ scope: probeScope });
|
||||
if (probeScope === 'single' && probeModel.trim()) params.set('modelName', probeModel.trim());
|
||||
const threshold = parseInt(probeLatencyThreshold, 10);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { userApi } from '../api.js';
|
||||
import { clearUserSession, getUserToken } from '../authSession.js';
|
||||
import { clearSession, getSessionToken } from '../authSession.js';
|
||||
|
||||
interface UserProfile {
|
||||
id: number;
|
||||
@@ -45,7 +45,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const checkAuth = useCallback(() => {
|
||||
if (!getUserToken(localStorage)) {
|
||||
if (!getSessionToken('user', localStorage)) {
|
||||
navigate('/user/login');
|
||||
return false;
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearUserSession(localStorage);
|
||||
clearSession('user', localStorage);
|
||||
onLogout?.();
|
||||
navigate('/user/login', { replace: true });
|
||||
};
|
||||
|
||||
+4
-106
@@ -1,109 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { persistUserSession } from '../authSession.js';
|
||||
import UserLoginPage from './auth/UserLoginPage.js';
|
||||
import { useI18n } from '../i18n.js';
|
||||
|
||||
export default function UserLogin({ onLogin }: { onLogin?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim() || !password) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim(), password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const errMsg = typeof data?.error === 'string' ? data.error : (data?.error?.message || data?.message || `登录失败 (${res.status})`);
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
const result = await res.json();
|
||||
persistUserSession(localStorage, result.token);
|
||||
onLogin?.();
|
||||
navigate('/user/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '登录失败,请检查邮箱和密码');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-shell">
|
||||
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
|
||||
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
|
||||
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
|
||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||
<div className="brand-mark-canvas">
|
||||
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
|
||||
<div className="login-brand-name">BoosAPI</div>
|
||||
<div className="login-brand-kicker">用户登录</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="login-auth-stage">
|
||||
<div className="login-auth-panel">
|
||||
<h2 className="login-auth-title">欢迎回来</h2>
|
||||
<p className="login-auth-copy">使用邮箱和密码登录你的账号。</p>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="login-auth-label" htmlFor="user-email-input">邮箱</label>
|
||||
<input
|
||||
id="user-email-input"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="user-password-input" style={{ marginTop: 12 }}>密码</label>
|
||||
<input
|
||||
id="user-password-input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(''); }}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
{error && (
|
||||
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email.trim() || !password}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 登录中...</> : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
|
||||
<span>
|
||||
还没有账号?<Link to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>注册</Link>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const { t } = useI18n();
|
||||
return <UserLoginPage t={t} onLogin={onLogin} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { persistUserSession } from '../authSession.js';
|
||||
import { commitSession } from '../authSession.js';
|
||||
|
||||
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
@@ -35,7 +35,7 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
throw new Error(data?.error || `注册失败 (${res.status})`);
|
||||
}
|
||||
const result = await res.json();
|
||||
persistUserSession(localStorage, result.token);
|
||||
commitSession('user', result.token, undefined, localStorage);
|
||||
onLogin?.();
|
||||
navigate('/user/dashboard');
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { commitSession } from '../../authSession.js';
|
||||
import { resolveLoginErrorMessage } from '../../loginError.js';
|
||||
import LoginShell from './LoginShell.js';
|
||||
|
||||
interface AdminLoginPageProps {
|
||||
t: (text: string) => string;
|
||||
/** Called with the admin auth token when login succeeds. */
|
||||
onLogin: (token: string) => void;
|
||||
}
|
||||
|
||||
async function readErrorReason(res: Response): Promise<string> {
|
||||
try {
|
||||
const text = await res.text();
|
||||
if (!text) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
|
||||
if (typeof parsed.message === 'string') return parsed.message;
|
||||
if (typeof parsed.error === 'string') return parsed.error;
|
||||
const nestedMessage =
|
||||
parsed.error && typeof parsed.error === 'object'
|
||||
? (parsed.error as { message?: unknown }).message
|
||||
: undefined;
|
||||
if (typeof nestedMessage === 'string') return nestedMessage;
|
||||
return text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
|
||||
const [token, setToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleTokenLogin = async () => {
|
||||
if (!token.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/settings/auth/info', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (res.ok) {
|
||||
commitSession('admin', token);
|
||||
onLogin(token);
|
||||
return;
|
||||
}
|
||||
const reason = await readErrorReason(res);
|
||||
setError(t(resolveLoginErrorMessage(res.status, reason)));
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = !!token.trim();
|
||||
|
||||
return (
|
||||
<LoginShell
|
||||
t={t}
|
||||
variant="admin"
|
||||
eyebrow={t('管理员入口')}
|
||||
title={t('登录')}
|
||||
description={t('请输入管理员令牌后继续。')}
|
||||
error={error}
|
||||
footer={<span>{t('管理员登录后继续。')}</span>}
|
||||
>
|
||||
<label className="login-auth-label" htmlFor="admin-token-input">
|
||||
{t('管理员令牌')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-token-input"
|
||||
type="password"
|
||||
placeholder={t('管理员令牌')}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleTokenLogin()}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
onClick={handleTokenLogin}
|
||||
disabled={loading || !canSubmit}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('验证中...')}
|
||||
</>
|
||||
) : (
|
||||
t('登录')
|
||||
)}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
|
||||
</div>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import React from 'react';
|
||||
import { SITE_DOCS_URL, SITE_GITHUB_URL } from '../../docsLink.js';
|
||||
|
||||
export type LoginShellVariant = 'admin' | 'user';
|
||||
|
||||
interface LoginShellProps {
|
||||
t: (text: string) => string;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: React.ReactNode;
|
||||
error?: string;
|
||||
/** When set, the brand panel renders the rich admin layout (capability list, footer links). */
|
||||
variant?: LoginShellVariant;
|
||||
/** Footer rendered under the form (registration links, mode hints, etc). */
|
||||
footer?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const ADMIN_CAPABILITY_KEYS: Array<{ titleKey: string; descKey: string }> = [
|
||||
{ titleKey: '统一代理网关', descKey: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
|
||||
{ titleKey: '自动模型发现', descKey: '上游新增模型自动出现在模型列表,零配置路由生成' },
|
||||
{ titleKey: '智能路由引擎', descKey: '按成本、延迟、成功率自动选择最优通道,故障自动转移' },
|
||||
];
|
||||
|
||||
export default function LoginShell({
|
||||
t,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
error,
|
||||
variant = 'admin',
|
||||
footer,
|
||||
children,
|
||||
}: LoginShellProps) {
|
||||
return (
|
||||
<div className="login-shell" data-variant={variant}>
|
||||
<div className="login-surface animate-scale-in">
|
||||
<section
|
||||
className="login-brand-panel login-brand-panel-light"
|
||||
style={variant === 'user' ? { padding: '28px 24px' } : undefined}
|
||||
>
|
||||
<div
|
||||
className="login-brand-header"
|
||||
style={variant === 'user' ? { justifyContent: 'center' } : undefined}
|
||||
>
|
||||
<div className="brand-mark-frame brand-mark-frame-hero">
|
||||
<div className="brand-mark-canvas">
|
||||
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="login-brand-summary"
|
||||
style={variant === 'user' ? { textAlign: 'center' } : undefined}
|
||||
>
|
||||
<div className="login-brand-name">BoosAPI</div>
|
||||
<div className="login-brand-kicker">
|
||||
{variant === 'user' ? t('用户登录') : t('中转站的中转站')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{variant === 'admin' && (
|
||||
<>
|
||||
<div className="login-brand-copy-block">
|
||||
<p className="login-brand-copy">
|
||||
{t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="login-compat-line">
|
||||
{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')}
|
||||
</div>
|
||||
<div className="login-capability-list">
|
||||
{ADMIN_CAPABILITY_KEYS.map((feature, index) => (
|
||||
<div key={feature.titleKey} className="login-capability-row">
|
||||
<div className="login-capability-index">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</div>
|
||||
<div className="login-capability-content">
|
||||
<div className="login-capability-title">{t(feature.titleKey)}</div>
|
||||
<p className="login-capability-desc">{t(feature.descKey)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="login-brand-footer">
|
||||
<a
|
||||
href={SITE_GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="login-icon-link"
|
||||
aria-label="GitHub"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={SITE_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="login-doc-link"
|
||||
>
|
||||
{t('部署文档')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
<section className="login-auth-stage">
|
||||
<div className="login-auth-panel">
|
||||
<div className="login-auth-eyebrow">{eyebrow}</div>
|
||||
<h2 className="login-auth-title">{title}</h2>
|
||||
<p className="login-auth-copy">{description}</p>
|
||||
{children}
|
||||
{error && (
|
||||
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{footer && <div className="login-auth-footer">{footer}</div>}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface OAuthSlotProps {
|
||||
t: (text: string) => string;
|
||||
variant?: 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for the third-party login entry point.
|
||||
*
|
||||
* Renders a stable DOM node (`data-testid="oauth-slot"`) so a follow-up
|
||||
* change can attach the real provider list and `commitSession('user', token)`
|
||||
* exchange flow without touching `UserLoginPage`.
|
||||
*
|
||||
* This component intentionally performs no network I/O, no navigation, and
|
||||
* does not touch `authSession`. Tests assert that property.
|
||||
*/
|
||||
export default function OAuthSlot({ t }: OAuthSlotProps) {
|
||||
return (
|
||||
<div
|
||||
data-testid="oauth-slot"
|
||||
className="login-oauth-slot"
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: '12px 0',
|
||||
borderTop: '1px dashed var(--color-border)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
}}
|
||||
>
|
||||
<span>{t('第三方登录')}</span>
|
||||
<span style={{ fontSize: 11 }}>
|
||||
{t('即将上线,敬请期待')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { commitSession } from '../../authSession.js';
|
||||
import { resolveLoginErrorMessage } from '../../loginError.js';
|
||||
import LoginShell from './LoginShell.js';
|
||||
import OAuthSlot from './OAuthSlot.js';
|
||||
|
||||
interface UserLoginPageProps {
|
||||
t: (text: string) => string;
|
||||
/** Optional callback invoked after a successful login + persistence. */
|
||||
onLogin?: () => void;
|
||||
}
|
||||
|
||||
type UserMode = 'account' | 'apiKey';
|
||||
|
||||
function readErrorReason(data: any): string {
|
||||
if (!data) return '';
|
||||
if (typeof data.error === 'string') return data.error;
|
||||
const nested =
|
||||
data.error && typeof data.error === 'object'
|
||||
? (data.error as { message?: unknown }).message
|
||||
: undefined;
|
||||
return typeof nested === 'string' ? nested : (data.message || '');
|
||||
}
|
||||
|
||||
export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<UserMode>('account');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleAccountSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!email.trim() || !password) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim(), password }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
setError(t(resolveLoginErrorMessage(res.status, String(readErrorReason(data)))));
|
||||
return;
|
||||
}
|
||||
const jwt = String(data?.token || data?.data?.token || '').trim();
|
||||
if (!jwt) {
|
||||
setError(t(resolveLoginErrorMessage(401, '')));
|
||||
return;
|
||||
}
|
||||
commitSession('user', jwt);
|
||||
onLogin?.();
|
||||
navigate('/user/dashboard');
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeySubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!apiKey.trim()) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/users/login-api-key', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: apiKey.trim() }),
|
||||
});
|
||||
const data = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
setError(t(resolveLoginErrorMessage(res.status, String(readErrorReason(data)))));
|
||||
return;
|
||||
}
|
||||
const jwt = String(data?.token || data?.data?.token || '').trim();
|
||||
if (!jwt) {
|
||||
setError(t(resolveLoginErrorMessage(401, '')));
|
||||
return;
|
||||
}
|
||||
commitSession('user', jwt);
|
||||
onLogin?.();
|
||||
navigate('/user/dashboard');
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const description = mode === 'account'
|
||||
? t('使用邮箱和密码登录你的账号。')
|
||||
: t('使用账户中已签发的 API Key 直接登录。');
|
||||
const footer = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||
<span>
|
||||
{t('还没有账号?')}
|
||||
<Link
|
||||
to="/user/register"
|
||||
style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}
|
||||
>
|
||||
{t('注册')}
|
||||
</Link>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>
|
||||
← {t('管理员登录')}
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<LoginShell
|
||||
t={t}
|
||||
variant="user"
|
||||
eyebrow={t('用户入口')}
|
||||
title={t('欢迎回来')}
|
||||
description={description}
|
||||
error={error}
|
||||
footer={footer}
|
||||
>
|
||||
<div className="login-mode-tabs" role="tablist" aria-label={t('登录方式')}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'account'}
|
||||
className={`login-mode-tab ${mode === 'account' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('account');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
{t('邮箱密码')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'apiKey'}
|
||||
className={`login-mode-tab ${mode === 'apiKey' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('apiKey');
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
{t('API Key')}
|
||||
</button>
|
||||
</div>
|
||||
{mode === 'account' ? (
|
||||
<form onSubmit={handleAccountSubmit}>
|
||||
<label className="login-auth-label" htmlFor="user-email-input">
|
||||
{t('邮箱')}
|
||||
</label>
|
||||
<input
|
||||
id="user-email-input"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<label
|
||||
className="login-auth-label"
|
||||
htmlFor="user-password-input"
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
{t('密码')}
|
||||
</label>
|
||||
<input
|
||||
id="user-password-input"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !email.trim() || !password}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('登录中...')}
|
||||
</>
|
||||
) : (
|
||||
t('登录')
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleApiKeySubmit}>
|
||||
<label className="login-auth-label" htmlFor="user-api-key-input">
|
||||
{t('API Key')}
|
||||
</label>
|
||||
<input
|
||||
id="user-api-key-input"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
setApiKey(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !apiKey.trim()}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
style={{ marginTop: 12 }}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('登录中...')}
|
||||
</>
|
||||
) : (
|
||||
t('使用 API Key 登录')
|
||||
)}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{t('API Key 可在「我的 API Key」页面创建,仅识别已绑定到本账号的 Key。')}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
<OAuthSlot t={t} variant="user" />
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,13 @@ describe('accountConnection helpers', () => {
|
||||
capabilities: { proxyOnly: true },
|
||||
accessToken: 'api-token-ignored-by-explicit-mode',
|
||||
})).toBe('session');
|
||||
expect(resolveAccountCredentialMode({
|
||||
extraConfig: '{"credentialMode":"apikey"}',
|
||||
accessToken: 'api-key-is-still-stored-as-access-token',
|
||||
})).toBe('apikey');
|
||||
expect(resolveAccountCredentialMode({
|
||||
extra_config: '{"credentialMode":"session"}',
|
||||
})).toBe('session');
|
||||
expect(resolveAccountCredentialMode({})).toBe('apikey');
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,21 @@ export function resolveAccountCredentialMode(account: any): 'session' | 'apikey'
|
||||
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
|
||||
if (rawMode === 'apikey') return 'apikey';
|
||||
if (rawMode === 'session') return 'session';
|
||||
const rawExtraConfig = typeof account?.extraConfig === 'string'
|
||||
? account.extraConfig
|
||||
: typeof account?.extra_config === 'string'
|
||||
? account.extra_config
|
||||
: '';
|
||||
if (rawExtraConfig.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawExtraConfig);
|
||||
const parsedMode = String(parsed?.credentialMode || '').trim().toLowerCase();
|
||||
if (parsedMode === 'apikey') return 'apikey';
|
||||
if (parsedMode === 'session') return 'session';
|
||||
} catch {
|
||||
// Ignore invalid legacy extra_config and continue with older fallbacks.
|
||||
}
|
||||
}
|
||||
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
|
||||
return account.capabilities.proxyOnly ? 'apikey' : 'session';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user