Files
beaver_project/auth-portal/src/app/register/page.tsx

313 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import Image from 'next/image';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useState } from 'react';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { buildFrontendHandoffUrl, configureProviderOnboarding, register, withNext } from '@/lib/auth-client';
import { pickPortalText } from '@/lib/i18n/core';
import { usePortalI18n } from '@/lib/i18n/provider';
import type { TokenResponse } from '@/types/auth';
const PROVIDER_OPTIONS = [
{ id: 'openrouter', label: 'OpenRouter', model: 'openrouter/anthropic/claude-sonnet-4.5' },
{ id: 'openai', label: 'OpenAI', model: 'openai/gpt-5' },
{ id: 'anthropic', label: 'Anthropic', model: 'claude-sonnet-4.5' },
{ id: 'dashscope', label: 'DashScope', model: 'qwen-plus' },
{ id: 'deepseek', label: 'DeepSeek', model: 'deepseek-chat' },
{ id: 'gemini', label: 'Gemini', model: 'gemini/gemini-2.5-pro' },
{ id: 'moonshot', label: 'Moonshot', model: 'moonshot/kimi-k2.5' },
{ id: 'minimax', label: 'MiniMax', model: 'minimax/minimax-m1' },
{ id: 'siliconflow', label: 'SiliconFlow', model: 'Qwen/Qwen3-32B' },
{ id: 'volcengine', label: 'VolcEngine', model: 'volcengine/deepseek-v3' },
{ id: 'vllm', label: 'vLLM / Local', model: 'hosted_vllm/local-model' },
];
export default function RegisterPage() {
const { locale } = usePortalI18n();
const searchParams = useSearchParams();
const nextPath = searchParams?.get('next') || '/mcp';
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [registrationResponse, setRegistrationResponse] = useState<TokenResponse | null>(null);
const [provider, setProvider] = useState(PROVIDER_OPTIONS[0].id);
const [model, setModel] = useState(PROVIDER_OPTIONS[0].model);
const [apiKey, setApiKey] = useState('');
const [apiBase, setApiBase] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const [loading, setLoading] = useState(false);
const [onboardingLoading, setOnboardingLoading] = useState(false);
const [error, setError] = useState('');
const [onboardingError, setOnboardingError] = useState('');
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setLoading(true);
setError('');
try {
if (password !== confirmPassword) {
throw new Error(pickPortalText(locale, '两次输入的密码不一致', 'Passwords do not match'));
}
const response = await register(username, email, password);
setRegistrationResponse(response);
} catch (err) {
setError(err instanceof Error ? err.message : pickPortalText(locale, '注册失败,请稍后重试', 'Sign-up failed. Please try again.'));
} finally {
setLoading(false);
}
};
const handleProviderChange = (value: string) => {
const previousDefault = PROVIDER_OPTIONS.find((item) => item.id === provider)?.model;
const selected = PROVIDER_OPTIONS.find((item) => item.id === value);
setProvider(value);
if (selected && (!model.trim() || model === previousDefault)) {
setModel(selected.model);
}
};
const continueWithoutProvider = () => {
if (!registrationResponse) return;
window.location.replace(buildFrontendHandoffUrl(registrationResponse, nextPath));
};
const handleProviderSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!registrationResponse) return;
setOnboardingLoading(true);
setOnboardingError('');
try {
const response = await configureProviderOnboarding({
username,
password,
provider,
model,
api_key: apiKey,
api_base: apiBase,
});
window.location.replace(buildFrontendHandoffUrl(response, nextPath));
} catch (err) {
setOnboardingError(err instanceof Error ? err.message : pickPortalText(locale, '模型配置失败,请检查后重试', 'Provider setup failed. Check the values and try again.'));
} finally {
setOnboardingLoading(false);
}
};
return (
<main className="portal-page">
<div className="portal-toolbar">
<LanguageSwitcher />
</div>
<section className="auth-page">
<div className="portal-panel">
{registrationResponse ? (
<div className="auth-card login-card register-card">
<BrandHeader title={pickPortalText(locale, '配置模型', 'Model Setup')} />
<form className="auth-form" onSubmit={handleProviderSubmit}>
<div className="field login-field">
<label className="visually-hidden" htmlFor="provider">{pickPortalText(locale, '模型提供商', 'Model provider')}</label>
<select
id="provider"
value={provider}
onChange={(event) => handleProviderChange(event.target.value)}
disabled={onboardingLoading}
>
{PROVIDER_OPTIONS.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="model">{pickPortalText(locale, '模型', 'Model')}</label>
<input
id="model"
value={model}
onChange={(event) => setModel(event.target.value)}
placeholder={pickPortalText(locale, '模型', 'Model')}
required
disabled={onboardingLoading}
/>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="apiKey">API Key</label>
<input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
autoComplete="off"
placeholder={provider === 'vllm' ? pickPortalText(locale, 'API Key 可选', 'API key optional') : 'API Key'}
required={provider !== 'vllm'}
disabled={onboardingLoading}
/>
<VisibilityButton
active={showApiKey}
onClick={() => setShowApiKey((value) => !value)}
label={pickPortalText(locale, showApiKey ? '隐藏 API Key' : '显示 API Key', showApiKey ? 'Hide API key' : 'Show API key')}
/>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="apiBase">API Base</label>
<input
id="apiBase"
value={apiBase}
onChange={(event) => setApiBase(event.target.value)}
placeholder="API Base"
disabled={onboardingLoading}
/>
</div>
<div className="error-text">{onboardingError}</div>
<button className="primary-button" type="submit" disabled={onboardingLoading}>
{onboardingLoading ? pickPortalText(locale, '写入中...', 'Saving...') : <ArrowRightIcon />}
</button>
<button className="secondary-button" type="button" onClick={continueWithoutProvider} disabled={onboardingLoading}>
{pickPortalText(locale, '跳过', 'Skip')}
</button>
</form>
</div>
) : (
<div className="auth-card login-card register-card">
<BrandHeader title="Beaver AgentSandbox" />
<form className="auth-form" onSubmit={handleSubmit}>
<div className="field login-field">
<label className="visually-hidden" htmlFor="username">{pickPortalText(locale, '用户名', 'Username')}</label>
<input
id="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder={pickPortalText(locale, '用户名', 'Username')}
required
/>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="email">{pickPortalText(locale, '邮箱', 'Email')}</label>
<input
id="email"
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
autoComplete="email"
placeholder={pickPortalText(locale, '邮箱', 'Email')}
/>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="password">{pickPortalText(locale, '密码', 'Password')}</label>
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
placeholder={pickPortalText(locale, '密码', 'Password')}
required
/>
<VisibilityButton
active={showPassword}
onClick={() => setShowPassword((value) => !value)}
label={pickPortalText(locale, showPassword ? '隐藏密码' : '显示密码', showPassword ? 'Hide password' : 'Show password')}
/>
</div>
<div className="field login-field">
<label className="visually-hidden" htmlFor="confirmPassword">{pickPortalText(locale, '确认密码', 'Confirm password')}</label>
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
placeholder={pickPortalText(locale, '确认密码', 'Confirm password')}
required
/>
<VisibilityButton
active={showConfirmPassword}
onClick={() => setShowConfirmPassword((value) => !value)}
label={pickPortalText(locale, showConfirmPassword ? '隐藏确认密码' : '显示确认密码', showConfirmPassword ? 'Hide confirm password' : 'Show confirm password')}
/>
</div>
<div className="error-text">{error}</div>
<button className="primary-button" type="submit" disabled={loading}>
{loading ? pickPortalText(locale, '注册中...', 'Creating...') : <ArrowRightIcon />}
</button>
</form>
<div className="login-divider">
<span>{pickPortalText(locale, '或', 'or')}</span>
</div>
<div className="auth-footer login-footer">
<span>{pickPortalText(locale, '已有账号?', 'Already have an account?')}</span>
<Link href={withNext('/login', nextPath)}>{pickPortalText(locale, '登录', 'Sign in')} <span aria-hidden="true"></span></Link>
</div>
</div>
)}
</div>
</section>
</main>
);
}
function BrandHeader({ title }: { title: string }) {
return (
<>
<Image
src="/boardware-logo.jpg"
alt="Boardware logo"
width={120}
height={120}
className="login-logo"
priority
/>
<h1>{title}</h1>
</>
);
}
function VisibilityButton({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
return (
<button className="ghost-icon-button" type="button" onClick={onClick} aria-label={label} aria-pressed={active}>
<EyeIcon />
</button>
);
}
function EyeIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3.75 12s2.8-5.25 8.25-5.25S20.25 12 20.25 12s-2.8 5.25-8.25 5.25S3.75 12 3.75 12Z" />
<path d="m4.75 4.75 14.5 14.5" />
<path d="M9.9 9.9a3 3 0 0 0 4.2 4.2" />
</svg>
);
}
function ArrowRightIcon() {
return (
<svg className="button-arrow" viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 12h13" />
<path d="m13 6 6 6-6 6" />
</svg>
);
}