feat(web-admin): add phone+OTP login mode to login page
This commit is contained in:
parent
7dc5881496
commit
06c2d02c21
|
|
@ -1,6 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
@ -9,39 +9,77 @@ import { apiClient } from '@/infrastructure/api/api-client';
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
user: { id: string; email: string; name: string; roles: string[] };
|
user: { id: string; email?: string; phone?: string; name: string; roles: string[]; tenantId?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LoginMethod = 'email' | 'phone';
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation('auth');
|
const { t } = useTranslation('auth');
|
||||||
|
const [loginMethod, setLoginMethod] = useState<LoginMethod>('email');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [smsCode, setSmsCode] = useState('');
|
||||||
|
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||||
|
const [smsSending, setSmsSending] = useState(false);
|
||||||
|
const cooldownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSendSms = async () => {
|
||||||
|
if (!phone.trim()) { setError('请先填写手机号'); return; }
|
||||||
|
setError(null);
|
||||||
|
setSmsSending(true);
|
||||||
|
try {
|
||||||
|
await apiClient('/api/v1/auth/sms/send', { method: 'POST', body: { phone: phone.trim(), purpose: 'login' } });
|
||||||
|
setSmsCooldown(60);
|
||||||
|
if (cooldownRef.current) clearInterval(cooldownRef.current);
|
||||||
|
cooldownRef.current = setInterval(() => {
|
||||||
|
setSmsCooldown((prev) => {
|
||||||
|
if (prev <= 1) { clearInterval(cooldownRef.current!); return 0; }
|
||||||
|
return prev - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '发送失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setSmsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSession = (data: LoginResponse) => {
|
||||||
|
localStorage.setItem('access_token', data.accessToken);
|
||||||
|
localStorage.setItem('refresh_token', data.refreshToken);
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
|
||||||
|
if (payload.tenantId) {
|
||||||
|
localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId }));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiClient<LoginResponse>('/api/v1/auth/login', {
|
if (loginMethod === 'email') {
|
||||||
method: 'POST',
|
const data = await apiClient<LoginResponse>('/api/v1/auth/login', {
|
||||||
body: { email, password },
|
method: 'POST',
|
||||||
});
|
body: { email, password },
|
||||||
|
});
|
||||||
localStorage.setItem('access_token', data.accessToken);
|
saveSession(data);
|
||||||
localStorage.setItem('refresh_token', data.refreshToken);
|
} else {
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
if (!smsCode.trim()) { setError('请输入短信验证码'); setIsLoading(false); return; }
|
||||||
|
const data = await apiClient<LoginResponse>('/api/v1/auth/login/otp', {
|
||||||
try {
|
method: 'POST',
|
||||||
const payload = JSON.parse(atob(data.accessToken.split('.')[1]));
|
body: { phone: phone.trim(), smsCode: smsCode.trim() },
|
||||||
if (payload.tenantId) {
|
});
|
||||||
localStorage.setItem('current_tenant', JSON.stringify({ id: payload.tenantId }));
|
saveSession(data);
|
||||||
}
|
}
|
||||||
} catch { /* ignore decode errors */ }
|
|
||||||
|
|
||||||
router.push('/dashboard');
|
router.push('/dashboard');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('loginFailed'));
|
setError(err instanceof Error ? err.message : t('loginFailed'));
|
||||||
|
|
@ -57,45 +95,63 @@ export default function LoginPage() {
|
||||||
<h1 className="text-3xl font-bold">{t('appTitle')}</h1>
|
<h1 className="text-3xl font-bold">{t('appTitle')}</h1>
|
||||||
<p className="text-muted-foreground mt-2">{t('adminConsole')}</p>
|
<p className="text-muted-foreground mt-2">{t('adminConsole')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Login method toggle */}
|
||||||
|
<div className="flex rounded-md border overflow-hidden">
|
||||||
|
<button type="button" onClick={() => setLoginMethod('email')}
|
||||||
|
className={`flex-1 py-2 text-sm font-medium transition-colors ${loginMethod === 'email' ? 'bg-primary text-primary-foreground' : 'bg-transparent text-muted-foreground hover:text-foreground'}`}>
|
||||||
|
邮箱登录
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setLoginMethod('phone')}
|
||||||
|
className={`flex-1 py-2 text-sm font-medium transition-colors ${loginMethod === 'phone' ? 'bg-primary text-primary-foreground' : 'bg-transparent text-muted-foreground hover:text-foreground'}`}>
|
||||||
|
手机验证码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
{loginMethod === 'email' ? (
|
||||||
<label className="block text-sm font-medium mb-1">{t('email')}</label>
|
<>
|
||||||
<input
|
<div>
|
||||||
type="email"
|
<label className="block text-sm font-medium mb-1">{t('email')}</label>
|
||||||
value={email}
|
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
className="w-full px-3 py-2 bg-input border rounded-md" placeholder={t('emailPlaceholder')} required />
|
||||||
className="w-full px-3 py-2 bg-input border rounded-md"
|
</div>
|
||||||
placeholder={t('emailPlaceholder')}
|
<div>
|
||||||
required
|
<label className="block text-sm font-medium mb-1">{t('password')}</label>
|
||||||
/>
|
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||||
</div>
|
className="w-full px-3 py-2 bg-input border rounded-md" required />
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-sm font-medium mb-1">{t('password')}</label>
|
</>
|
||||||
<input
|
) : (
|
||||||
type="password"
|
<>
|
||||||
value={password}
|
<div>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<label className="block text-sm font-medium mb-1">手机号</label>
|
||||||
className="w-full px-3 py-2 bg-input border rounded-md"
|
<input type="tel" value={phone} onChange={(e) => setPhone(e.target.value)}
|
||||||
required
|
className="w-full px-3 py-2 bg-input border rounded-md" placeholder="+86 138 0000 0000" required />
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
{error && (
|
<label className="block text-sm font-medium mb-1">短信验证码</label>
|
||||||
<p className="text-sm text-red-500">{error}</p>
|
<div className="flex gap-2">
|
||||||
|
<input type="text" value={smsCode} onChange={(e) => setSmsCode(e.target.value)}
|
||||||
|
className="flex-1 px-3 py-2 bg-input border rounded-md" placeholder="6 位验证码" maxLength={6} required />
|
||||||
|
<button type="button" onClick={handleSendSms} disabled={smsSending || smsCooldown > 0}
|
||||||
|
className="px-4 py-2 text-sm font-medium bg-secondary text-secondary-foreground rounded-md hover:opacity-90 disabled:opacity-50 whitespace-nowrap">
|
||||||
|
{smsSending ? '发送中...' : smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||||
type="submit"
|
<button type="submit" disabled={isLoading}
|
||||||
disabled={isLoading}
|
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50">
|
||||||
className="w-full py-2 bg-primary text-primary-foreground rounded-md hover:opacity-90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{isLoading ? t('signingIn') : t('signIn')}
|
{isLoading ? t('signingIn') : t('signIn')}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
{t('noAccount')}{' '}
|
{t('noAccount')}{' '}
|
||||||
<Link href="/register" className="text-primary hover:underline">
|
<Link href="/register" className="text-primary hover:underline">{t('createOne')}</Link>
|
||||||
{t('createOne')}
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue