feat: support admin account login
This commit is contained in:
+15
-3
@@ -22,7 +22,7 @@ mod sites;
|
|||||||
mod tokens;
|
mod tokens;
|
||||||
mod users;
|
mod users;
|
||||||
|
|
||||||
/// Middleware to require admin authentication via Bearer token.
|
/// Middleware to require admin authentication via config token or admin JWT.
|
||||||
async fn require_admin_auth(
|
async fn require_admin_auth(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
@@ -35,8 +35,20 @@ async fn require_admin_auth(
|
|||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
.ok_or_else(|| AppError::Unauthorized("Missing or invalid Authorization header".into()))?;
|
||||||
|
|
||||||
if auth_header != state.config.auth.auth_token {
|
if auth_header == state.config.auth.auth_token {
|
||||||
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
return Ok(next.run(request).await);
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::auth::jwt::validate_jwt(&state.config.auth.jwt_secret, auth_header) {
|
||||||
|
Ok(claims) if claims.role == "admin" => {}
|
||||||
|
Ok(_) => {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Admin account is required for this route".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
return Err(AppError::Unauthorized("Invalid admin token".into()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(next.run(request).await)
|
Ok(next.run(request).await)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function collectText(node: ReactTestInstance): string {
|
|||||||
|
|
||||||
describe('Login surface', () => {
|
describe('Login surface', () => {
|
||||||
it('uses the site root as the documentation URL', () => {
|
it('uses the site root as the documentation URL', () => {
|
||||||
expect(SITE_DOCS_URL).toBe('https://metapi.cita777.me');
|
expect(SITE_DOCS_URL).toBe('https://boosapi.cita777.me');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the author github profile for the login github shortcut', () => {
|
it('uses the author github profile for the login github shortcut', () => {
|
||||||
@@ -42,7 +42,7 @@ describe('Login surface', () => {
|
|||||||
&& node.props.className.includes('brand-mark-canvas')
|
&& node.props.className.includes('brand-mark-canvas')
|
||||||
));
|
));
|
||||||
|
|
||||||
expect(pageText).toContain('Metapi');
|
expect(pageText).toContain('BoosAPI');
|
||||||
expect(pageText).toContain('中转站的中转站');
|
expect(pageText).toContain('中转站的中转站');
|
||||||
expect(pageText).not.toContain('一个 API Key,一个入口');
|
expect(pageText).not.toContain('一个 API Key,一个入口');
|
||||||
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
expect(pageText).toContain('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API');
|
||||||
@@ -62,6 +62,11 @@ describe('Login surface', () => {
|
|||||||
node.type === 'input'
|
node.type === 'input'
|
||||||
&& node.props.placeholder === '管理员令牌'
|
&& node.props.placeholder === '管理员令牌'
|
||||||
));
|
));
|
||||||
|
const loginModeTabs = root.root.findAll((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.className === 'string'
|
||||||
|
&& node.props.className.includes('login-mode-tab')
|
||||||
|
));
|
||||||
const githubLink = root.root.find((node) => (
|
const githubLink = root.root.find((node) => (
|
||||||
node.type === 'a'
|
node.type === 'a'
|
||||||
&& node.props.href === SITE_GITHUB_URL
|
&& node.props.href === SITE_GITHUB_URL
|
||||||
@@ -71,6 +76,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', '账号密码']);
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-17
@@ -124,8 +124,13 @@ function resolveStoredProfile(): UserProfile {
|
|||||||
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
type LoginMode = 'token' | 'account';
|
||||||
|
|
||||||
export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) {
|
export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) {
|
||||||
|
const [mode, setMode] = useState<LoginMode>('token');
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const capabilityRows = [
|
const capabilityRows = [
|
||||||
@@ -142,7 +147,7 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
|||||||
description: t('按成本、延迟、成功率自动选择最优通道,故障自动转移'),
|
description: t('按成本、延迟、成功率自动选择最优通道,故障自动转移'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const handleLogin = async () => {
|
const handleTokenLogin = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -175,6 +180,50 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
|||||||
setLoading(false);
|
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 message = data?.message || data?.error || 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 (role !== 'admin') {
|
||||||
|
setError(t('该账号不是管理员,无法进入控制台。'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onLogin(jwt);
|
||||||
|
} catch {
|
||||||
|
setError(t('无法连接到服务器'));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleLogin = () => {
|
||||||
|
if (mode === 'token') {
|
||||||
|
void handleTokenLogin();
|
||||||
|
} else {
|
||||||
|
void handleAccountLogin();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const canSubmit = mode === 'token' ? !!token.trim() : !!email.trim() && !!password;
|
||||||
return (
|
return (
|
||||||
<div className="login-shell">
|
<div className="login-shell">
|
||||||
<div className="login-surface animate-scale-in">
|
<div className="login-surface animate-scale-in">
|
||||||
@@ -237,20 +286,86 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
|||||||
<div className="login-auth-panel">
|
<div className="login-auth-panel">
|
||||||
<div className="login-auth-eyebrow">{t('管理员入口')}</div>
|
<div className="login-auth-eyebrow">{t('管理员入口')}</div>
|
||||||
<h2 className="login-auth-title">{t('登录')}</h2>
|
<h2 className="login-auth-title">{t('登录')}</h2>
|
||||||
<p className="login-auth-copy">{t('请输入管理员令牌后继续。')}</p>
|
<p className="login-auth-copy">
|
||||||
<label className="login-auth-label" htmlFor="admin-token-input">{t('管理员令牌')}</label>
|
{mode === 'token'
|
||||||
<input
|
? t('请输入管理员令牌后继续。')
|
||||||
id="admin-token-input"
|
: t('使用管理员账号和密码进入控制台。')}
|
||||||
type="password"
|
</p>
|
||||||
placeholder={t('管理员令牌')}
|
<div className="login-mode-tabs" role="tablist" aria-label={t('登录方式')}>
|
||||||
value={token}
|
<button
|
||||||
onChange={(e) => {
|
type="button"
|
||||||
setToken(e.target.value);
|
role="tab"
|
||||||
setError('');
|
aria-selected={mode === 'token'}
|
||||||
}}
|
className={`login-mode-tab ${mode === 'token' ? 'active' : ''}`}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
onClick={() => {
|
||||||
className="login-auth-input"
|
setMode('token');
|
||||||
/>
|
setError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Auth Token')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={mode === 'account'}
|
||||||
|
className={`login-mode-tab ${mode === 'account' ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setMode('account');
|
||||||
|
setError('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('账号密码')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{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="admin-email-input">{t('邮箱')}</label>
|
||||||
|
<input
|
||||||
|
id="admin-email-input"
|
||||||
|
type="email"
|
||||||
|
placeholder={t('例如: admin@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="admin-password-input">{t('密码')}</label>
|
||||||
|
<input
|
||||||
|
id="admin-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 && (
|
{error && (
|
||||||
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
|
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
|
||||||
{error}
|
{error}
|
||||||
@@ -258,12 +373,16 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogin}
|
onClick={handleLogin}
|
||||||
disabled={loading || !token}
|
disabled={loading || !canSubmit}
|
||||||
className="btn btn-primary login-auth-submit"
|
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('登录')}
|
{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">{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}</div>
|
<div className="login-auth-note">
|
||||||
|
{mode === 'token'
|
||||||
|
? t('仅校验本地服务访问权限,不会把令牌发送到第三方。')
|
||||||
|
: t('账号密码登录会签发管理员会话,不会保存明文密码。')}
|
||||||
|
</div>
|
||||||
<div className="login-auth-footer">
|
<div className="login-auth-footer">
|
||||||
<span>{t('管理员登录后继续。')}</span>
|
<span>{t('管理员登录后继续。')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4045,7 +4045,37 @@ textarea {
|
|||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-mode-tabs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-primary) 12%, var(--color-border));
|
||||||
|
border-radius: 16px;
|
||||||
|
background: color-mix(in srgb, var(--color-bg) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-mode-tab {
|
||||||
|
min-height: 38px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.18s ease, color 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-mode-tab.active {
|
||||||
|
background: var(--color-bg-card);
|
||||||
|
color: var(--color-primary);
|
||||||
|
box-shadow: 0 10px 20px rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.login-auth-label {
|
.login-auth-label {
|
||||||
|
display: block;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
Reference in New Issue
Block a user