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 { create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { Login } from './App.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', () => {
const root = create(
<Login onLogin={vi.fn()} t={(text) => text} />,
<MemoryRouter>
<Login onLogin={vi.fn()} t={(text) => text} initialAudience="admin" />
</MemoryRouter>,
);
try {
@@ -76,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();
}
+114 -31
View File
@@ -1,11 +1,11 @@
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 SearchModal from './components/SearchModal.js';
import NotificationPanel from './components/NotificationPanel.js';
import TooltipLayer from './components/TooltipLayer.js';
import { api } from './api.js';
import { clearAuthSession, hasValidAuthSession, hasValidUserSession, persistAuthSession } from './authSession.js';
import { clearAuthSession, hasValidAuthSession, hasValidUserSession, persistAuthSession, persistUserSession } from './authSession.js';
import {
FIRST_USE_DOC_REMINDER_KEY,
LEGACY_THEME_STORAGE_KEY,
@@ -39,7 +39,6 @@ const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
const UserLogin = lazy(() => import('./pages/UserLogin.js'));
const UserRegister = lazy(() => import('./pages/UserRegister.js'));
const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
@@ -127,9 +126,22 @@ function resolveStoredProfile(): UserProfile {
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
}
type LoginAudience = 'user' | 'admin';
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 [token, setToken] = useState('');
const [email, setEmail] = useState('');
@@ -208,25 +220,34 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
setLoading(false);
return;
}
if (role !== 'admin') {
if (audience === 'admin' && role !== 'admin') {
setError(t('该账号不是管理员,无法进入控制台。'));
setLoading(false);
return;
}
onLogin(jwt);
if (audience === 'admin') {
onLogin(jwt);
return;
}
persistUserSession(localStorage, jwt);
onUserLogin?.();
navigate('/user/dashboard');
} catch {
setError(t('无法连接到服务器'));
setLoading(false);
}
};
const handleLogin = () => {
if (mode === 'token') {
if (audience === 'admin' && mode === 'token') {
void handleTokenLogin();
} else {
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 (
<div className="login-shell">
<div className="login-surface animate-scale-in">
@@ -287,20 +308,51 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
</section>
<section className="login-auth-stage">
<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>
<p className="login-auth-copy">
{mode === 'token'
? t('请输入管理员令牌后继续。')
: t('使用管理员账号和密码进入控制台。')}
{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={mode === 'token'}
className={`login-mode-tab ${mode === 'token' ? 'active' : ''}`}
aria-selected={activeMode === 'token'}
className={`login-mode-tab ${activeMode === 'token' ? 'active' : ''}`}
onClick={() => {
setAudience('admin');
setMode('token');
setError('');
}}
@@ -310,8 +362,8 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
<button
type="button"
role="tab"
aria-selected={mode === 'account'}
className={`login-mode-tab ${mode === 'account' ? 'active' : ''}`}
aria-selected={activeMode === 'account'}
className={`login-mode-tab ${activeMode === 'account' ? 'active' : ''}`}
onClick={() => {
setMode('account');
setError('');
@@ -320,7 +372,7 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
{t('账号密码')}
</button>
</div>
{mode === 'token' ? (
{audience === 'admin' && mode === 'token' ? (
<>
<label className="login-auth-label" htmlFor="admin-token-input">{t('管理员令牌')}</label>
<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
id="admin-email-input"
id="login-email-input"
type="email"
placeholder={t('例如: admin@example.com')}
placeholder={audience === 'admin' ? t('例如: admin@example.com') : 'you@example.com'}
value={email}
onChange={(e) => {
setEmail(e.target.value);
@@ -353,9 +405,9 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
className="login-auth-input"
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
id="admin-password-input"
id="login-password-input"
type="password"
placeholder={t('密码')}
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('登录')}
</button>
<div className="login-auth-note">
{mode === 'token'
? t('仅校验本地服务访问权限,不会把令牌发送到第三方。')
: t('账号密码登录会签发管理员会话,不会保存明文密码。')}
{audience === 'user'
? t('普通用户登录会进入个人面板,可管理自己的 API Key 和用量。')
: mode === 'token'
? t('仅校验本地服务访问权限,不会把令牌发送到第三方。')
: t('账号密码登录会签发管理员会话,不会保存明文密码。')}
</div>
<div className="login-auth-footer">
<span>{t('管理员登录后继续。')}</span>
{audience === 'user' ? (
<span>
{t('还没有账号?')}
<NavLink to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>
{t('注册')}
</NavLink>
</span>
) : (
<span>{t('管理员登录后继续。')}</span>
)}
</div>
</div>
</section>
@@ -740,10 +803,17 @@ function AppShell() {
}
if (!authed && !isUserPage) {
return <Login t={t} onLogin={(token) => {
persistAuthSession(localStorage, token);
setAuthed(true);
}} />;
return (
<Login
t={t}
initialAudience="admin"
onLogin={(token) => {
persistAuthSession(localStorage, token);
setAuthed(true);
}}
onUserLogin={() => setUserAuthed(true)}
/>
);
}
if (isUserPage && !userAuthed && !isPublicUserPage) {
@@ -754,7 +824,20 @@ function AppShell() {
return (
<Suspense fallback={<RouteLoadingFallback />}>
<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)} />} />
</Routes>
</Suspense>