feat: unify user and admin login entry

This commit is contained in:
boos
2026-05-19 10:53:38 +08:00
parent 27c2aca312
commit b6aae5f301
2 changed files with 119 additions and 33 deletions
+5 -2
View File
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { create, type ReactTestInstance } from 'react-test-renderer'; import { create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { Login } from './App.js'; import { Login } from './App.js';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js'; import { SITE_DOCS_URL, SITE_GITHUB_URL } from './docsLink.js';
@@ -21,7 +22,9 @@ describe('Login surface', () => {
it('renders a poster-style hero with a floating admin login panel', () => { it('renders a poster-style hero with a floating admin login panel', () => {
const root = create( const root = create(
<Login onLogin={vi.fn()} t={(text) => text} />, <MemoryRouter>
<Login onLogin={vi.fn()} t={(text) => text} initialAudience="admin" />
</MemoryRouter>,
); );
try { try {
@@ -76,7 +79,7 @@ describe('Login surface', () => {
expect(githubLink.props['aria-label']).toBe('GitHub'); expect(githubLink.props['aria-label']).toBe('GitHub');
expect(githubLink.props.target).toBe('_blank'); expect(githubLink.props.target).toBe('_blank');
expect(tokenInput.props.type).toBe('password'); 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 { } finally {
root?.unmount(); root?.unmount();
} }
+106 -23
View File
@@ -1,11 +1,11 @@
import React, { Suspense, lazy, useState, useEffect, useRef } from 'react'; import React, { Suspense, lazy, useState, useEffect, useRef } from 'react';
import { Routes, Route, NavLink, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, NavLink, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { ToastProvider, useToast } from './components/Toast.js'; import { ToastProvider, useToast } from './components/Toast.js';
import SearchModal from './components/SearchModal.js'; import SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js'; import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js'; import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js'; import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, hasValidUserSession, persistAuthSession } from './authSession.js'; import { clearAuthSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
import { import {
FIRST_USE_DOC_REMINDER_KEY, FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY, LEGACY_THEME_STORAGE_KEY,
@@ -39,7 +39,6 @@ const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const Monitors = lazy(() => import('./pages/Monitors.js')); const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js')); const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js')); const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
const UserLogin = lazy(() => import('./pages/UserLogin.js'));
const UserRegister = lazy(() => import('./pages/UserRegister.js')); const UserRegister = lazy(() => import('./pages/UserRegister.js'));
const UserDashboard = lazy(() => import('./pages/UserDashboard.js')); const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
const UsageRecords = lazy(() => import('./pages/UsageRecords.js')); const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
@@ -127,9 +126,22 @@ function resolveStoredProfile(): UserProfile {
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) }; return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
} }
} }
type LoginAudience = 'user' | 'admin';
type LoginMode = 'token' | 'account'; type LoginMode = 'token' | 'account';
export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) { 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 [mode, setMode] = useState<LoginMode>('token');
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -208,25 +220,34 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
setLoading(false); setLoading(false);
return; return;
} }
if (role !== 'admin') { if (audience === 'admin' && role !== 'admin') {
setError(t('该账号不是管理员,无法进入控制台。')); setError(t('该账号不是管理员,无法进入控制台。'));
setLoading(false); setLoading(false);
return; return;
} }
if (audience === 'admin') {
onLogin(jwt); onLogin(jwt);
return;
}
persistUserSession(localStorage, jwt);
onUserLogin?.();
navigate('/user/dashboard');
} catch { } catch {
setError(t('无法连接到服务器')); setError(t('无法连接到服务器'));
setLoading(false); setLoading(false);
} }
}; };
const handleLogin = () => { const handleLogin = () => {
if (mode === 'token') { if (audience === 'admin' && mode === 'token') {
void handleTokenLogin(); void handleTokenLogin();
} else { } else {
void handleAccountLogin(); void handleAccountLogin();
} }
}; };
const canSubmit = mode === 'token' ? !!token.trim() : !!email.trim() && !!password; const canSubmit = audience === 'admin' && mode === 'token'
? !!token.trim()
: !!email.trim() && !!password;
const activeMode = audience === 'user' ? 'account' : mode;
return ( return (
<div className="login-shell"> <div className="login-shell">
<div className="login-surface animate-scale-in"> <div className="login-surface animate-scale-in">
@@ -287,20 +308,51 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
</section> </section>
<section className="login-auth-stage"> <section className="login-auth-stage">
<div className="login-auth-panel"> <div className="login-auth-panel">
<div className="login-auth-eyebrow">{t('管理员入口')}</div> <div className="login-auth-eyebrow">
{audience === 'admin' ? t('管理员入口') : t('用户入口')}
</div>
<h2 className="login-auth-title">{t('登录')}</h2> <h2 className="login-auth-title">{t('登录')}</h2>
<p className="login-auth-copy"> <p className="login-auth-copy">
{mode === 'token' {audience === 'user'
? t('普通用户使用邮箱和密码进入个人面板。')
: activeMode === 'token'
? t('请输入管理员令牌后继续。') ? t('请输入管理员令牌后继续。')
: t('使用管理员账号和密码进入控制台。')} : t('使用管理员账号和密码进入控制台。')}
</p> </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('登录方式')}> <div className="login-mode-tabs" role="tablist" aria-label={t('登录方式')}>
<button <button
type="button" type="button"
role="tab" role="tab"
aria-selected={mode === 'token'} aria-selected={activeMode === 'token'}
className={`login-mode-tab ${mode === 'token' ? 'active' : ''}`} className={`login-mode-tab ${activeMode === 'token' ? 'active' : ''}`}
onClick={() => { onClick={() => {
setAudience('admin');
setMode('token'); setMode('token');
setError(''); setError('');
}} }}
@@ -310,8 +362,8 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
<button <button
type="button" type="button"
role="tab" role="tab"
aria-selected={mode === 'account'} aria-selected={activeMode === 'account'}
className={`login-mode-tab ${mode === 'account' ? 'active' : ''}`} className={`login-mode-tab ${activeMode === 'account' ? 'active' : ''}`}
onClick={() => { onClick={() => {
setMode('account'); setMode('account');
setError(''); setError('');
@@ -320,7 +372,7 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
{t('账号密码')} {t('账号密码')}
</button> </button>
</div> </div>
{mode === 'token' ? ( {audience === 'admin' && mode === 'token' ? (
<> <>
<label className="login-auth-label" htmlFor="admin-token-input">{t('管理员令牌')}</label> <label className="login-auth-label" htmlFor="admin-token-input">{t('管理员令牌')}</label>
<input <input
@@ -339,11 +391,11 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
</> </>
) : ( ) : (
<> <>
<label className="login-auth-label" htmlFor="admin-email-input">{t('邮箱')}</label> <label className="login-auth-label" htmlFor="login-email-input">{t('邮箱')}</label>
<input <input
id="admin-email-input" id="login-email-input"
type="email" type="email"
placeholder={t('例如: admin@example.com')} placeholder={audience === 'admin' ? t('例如: admin@example.com') : 'you@example.com'}
value={email} value={email}
onChange={(e) => { onChange={(e) => {
setEmail(e.target.value); setEmail(e.target.value);
@@ -353,9 +405,9 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
className="login-auth-input" className="login-auth-input"
autoComplete="username" autoComplete="username"
/> />
<label className="login-auth-label" htmlFor="admin-password-input">{t('密码')}</label> <label className="login-auth-label" htmlFor="login-password-input">{t('密码')}</label>
<input <input
id="admin-password-input" id="login-password-input"
type="password" type="password"
placeholder={t('密码')} placeholder={t('密码')}
value={password} value={password}
@@ -382,12 +434,23 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />{t('验证中...')}</> : t('登录')} {loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} />{t('验证中...')}</> : t('登录')}
</button> </button>
<div className="login-auth-note"> <div className="login-auth-note">
{mode === 'token' {audience === 'user'
? t('普通用户登录会进入个人面板,可管理自己的 API Key 和用量。')
: mode === 'token'
? t('仅校验本地服务访问权限,不会把令牌发送到第三方。') ? t('仅校验本地服务访问权限,不会把令牌发送到第三方。')
: t('账号密码登录会签发管理员会话,不会保存明文密码。')} : t('账号密码登录会签发管理员会话,不会保存明文密码。')}
</div> </div>
<div className="login-auth-footer"> <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> <span>{t('管理员登录后继续。')}</span>
)}
</div> </div>
</div> </div>
</section> </section>
@@ -740,10 +803,17 @@ function AppShell() {
} }
if (!authed && !isUserPage) { if (!authed && !isUserPage) {
return <Login t={t} onLogin={(token) => { return (
<Login
t={t}
initialAudience="admin"
onLogin={(token) => {
persistAuthSession(localStorage, token); persistAuthSession(localStorage, token);
setAuthed(true); setAuthed(true);
}} />; }}
onUserLogin={() => setUserAuthed(true)}
/>
);
} }
if (isUserPage && !userAuthed && !isPublicUserPage) { if (isUserPage && !userAuthed && !isPublicUserPage) {
@@ -754,7 +824,20 @@ function AppShell() {
return ( return (
<Suspense fallback={<RouteLoadingFallback />}> <Suspense fallback={<RouteLoadingFallback />}>
<Routes> <Routes>
<Route path="/user/login" element={<UserLogin onLogin={() => setUserAuthed(true)} />} /> <Route
path="/user/login"
element={(
<Login
t={t}
initialAudience="user"
onLogin={(token) => {
persistAuthSession(localStorage, token);
setAuthed(true);
}}
onUserLogin={() => setUserAuthed(true)}
/>
)}
/>
<Route path="/user/register" element={<UserRegister onLogin={() => setUserAuthed(true)} />} /> <Route path="/user/register" element={<UserRegister onLogin={() => setUserAuthed(true)} />} />
</Routes> </Routes>
</Suspense> </Suspense>