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

230 lines
6.8 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.

import { useEffect, useState, type FormEvent } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import * as authApi from '../../api/auth';
import { ApiError } from '../../api/http';
import { DEFAULT_TENANT, oauthRedirectUri } from '../../config';
import { useAuth } from '../../context/AuthContext';
import * as permApi from '../../api/permission';
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
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('');
const [error, setError] = useState('');
const [info, setInfo] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
const state = location.state as {
message?: string;
mfa_challenge_id?: string;
tenant?: string;
} | null;
if (state?.tenant) {
setTenant(state.tenant);
localStorage.setItem('tenant_slug', state.tenant);
}
if (state?.mfa_challenge_id) {
setMfaChallengeId(state.mfa_challenge_id);
setInfo(state.message ?? '請輸入驗證碼以完成登入');
navigate(location.pathname, { replace: true, state: null });
return;
}
if (state?.message) {
setInfo(state.message);
navigate(location.pathname, { replace: true, state: null });
}
}, [location.pathname, location.state, navigate]);
const startFederated = async (provider: authApi.FederatedProvider) => {
setError('');
setLoading(true);
try {
localStorage.setItem('tenant_slug', tenant);
const { oauth_url } = await authApi.loginSocialStart(
tenant,
provider,
oauthRedirectUri('/auth/callback/login'),
);
authApi.startOAuthRedirect(oauth_url);
} catch (err) {
setError(err instanceof ApiError ? err.message : '無法啟動登入');
setLoading(false);
}
};
const finishLogin = async () => {
syncSession();
await refreshRoles();
const me = await permApi.getMyPermissions();
const admin = permApi.isAdminRole(me.roles ?? []);
navigate(admin ? '/admin' : '/app');
};
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 || result.mfa_challenge_id) {
if (!result.mfa_challenge_id) {
throw new Error('缺少 MFA challenge');
}
setMfaChallengeId(result.mfa_challenge_id);
setTotpCode('');
setInfo('');
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 {
const code = totpCode.replace(/\s/g, '');
await authApi.loginMfaConfirm(tenant, mfaChallengeId, code);
await finishLogin();
} catch (err) {
setError(err instanceof ApiError ? err.message : '驗證碼錯誤');
} finally {
setLoading(false);
}
};
const backToPassword = () => {
setMfaChallengeId(null);
setTotpCode('');
setError('');
};
if (mfaChallengeId) {
return (
<div className="auth-card">
<h1></h1>
<p className="auth-hint">
{email ? (
<>
<strong>{email}</strong> TOTP
</>
) : (
<> TOTP</>
)}
App 6
</p>
<form onSubmit={submitMfa} className="form">
<label>
<input
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
inputMode="numeric"
autoComplete="one-time-code"
autoFocus
required
minLength={6}
maxLength={32}
placeholder="000000"
/>
</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>
);
}
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>}
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? '登入中…' : '登入'}
</button>
</form>
<div className="auth-divider">
<span></span>
</div>
<div className="auth-oauth">
<button
type="button"
className="btn-oauth"
disabled={loading}
onClick={() => startFederated('google')}
>
使 Google
</button>
<button
type="button"
className="btn-oauth btn-oauth-secondary"
disabled={loading}
onClick={() => startFederated('ldap')}
>
使 LDAP
</button>
</div>
<p className="auth-footer">
<Link to="/register"></Link>
{' · '}
<Link to="/register/confirm"></Link>
{' · '}
<Link to="/password/forgot"></Link>
</p>
</div>
);
}