230 lines
6.8 KiB
TypeScript
230 lines
6.8 KiB
TypeScript
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>
|
||
);
|
||
}
|