feat: unify user and admin login entry
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
+106
-23
@@ -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;
|
||||
}
|
||||
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'
|
||||
{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'
|
||||
{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>
|
||||
@@ -740,10 +803,17 @@ function AppShell() {
|
||||
}
|
||||
|
||||
if (!authed && !isUserPage) {
|
||||
return <Login t={t} onLogin={(token) => {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user