template-monorepo/frontend/src/pages/user/LoginPage.tsx

163 lines
4.9 KiB
TypeScript
Raw Normal View History

import { useEffect, useState, type FormEvent } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
2026-05-26 09:32:32 +00:00
import * as authApi from '../../api/auth';
import { ApiError } from '../../api/http';
import { DEFAULT_TENANT } from '../../config';
import { useAuth } from '../../context/AuthContext';
import * as permApi from '../../api/permission';
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
2026-05-26 09:32:32 +00:00
const { syncSession, refreshRoles } = useAuth();
const [tenant, setTenant] = useState(
() => localStorage.getItem('tenant_slug') ?? DEFAULT_TENANT,
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [mfaChallengeId, setMfaChallengeId] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState('');
2026-05-26 09:32:32 +00:00
const [error, setError] = useState('');
const [info, setInfo] = useState('');
2026-05-26 09:32:32 +00:00
const [loading, setLoading] = useState(false);
useEffect(() => {
const state = location.state as { message?: string } | null;
if (state?.message) {
setInfo(state.message);
navigate(location.pathname, { replace: true, state: null });
}
}, [location.pathname, location.state, navigate]);
const finishLogin = async () => {
syncSession();
await refreshRoles();
const me = await permApi.getMyPermissions();
const admin = permApi.isAdminRole(me.roles ?? []);
navigate(admin ? '/admin' : '/app');
};
2026-05-26 09:32:32 +00:00
const submit = async (e: FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
localStorage.setItem('tenant_slug', tenant);
const result = await authApi.login(tenant, email, password);
if (result.mfa_required) {
if (!result.mfa_challenge_id) {
throw new Error('缺少 MFA challenge');
}
setMfaChallengeId(result.mfa_challenge_id);
return;
}
await finishLogin();
} catch (err) {
if (err instanceof ApiError && err.code === 28505000) {
setError('帳號尚未完成 Email 驗證,請先完成註冊驗證。');
} else {
setError(err instanceof ApiError ? err.message : '登入失敗');
}
} finally {
setLoading(false);
}
};
const submitMfa = async (e: FormEvent) => {
e.preventDefault();
if (!mfaChallengeId) return;
setError('');
setLoading(true);
try {
await authApi.loginMfaConfirm(tenant, mfaChallengeId, totpCode);
await finishLogin();
2026-05-26 09:32:32 +00:00
} catch (err) {
setError(err instanceof ApiError ? err.message : '驗證碼錯誤');
2026-05-26 09:32:32 +00:00
} finally {
setLoading(false);
}
};
const backToPassword = () => {
setMfaChallengeId(null);
setTotpCode('');
setError('');
};
if (mfaChallengeId) {
return (
<div className="auth-card">
<h1></h1>
<p className="auth-hint"> App 6 </p>
<form onSubmit={submitMfa} className="form">
<label>
<input
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
required
maxLength={6}
/>
</label>
{error && <p className="form-error">{error}</p>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '驗證中…' : '確認登入'}
</button>
<button
type="button"
className="btn-link"
onClick={backToPassword}
disabled={loading}
>
</button>
</form>
</div>
);
}
2026-05-26 09:32:32 +00:00
return (
<div className="auth-card">
<h1></h1>
<form onSubmit={submit} className="form">
<label>
<input value={tenant} onChange={(e) => setTenant(e.target.value)} />
</label>
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</label>
<label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <p className="form-error">{error}</p>}
{info && <p className="form-ok">{info}</p>}
2026-05-26 09:32:32 +00:00
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '登入中…' : '登入'}
</button>
</form>
<p className="auth-footer">
<Link to="/register"></Link>
{' · '}
<Link to="/register/confirm"></Link>
{' · '}
<Link to="/password/forgot"></Link>
2026-05-26 09:32:32 +00:00
</p>
</div>
);
}