feat: merge opus comfyui and login updates
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user