2026-05-26 16:55:37 +00:00
|
|
|
|
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';
|
2026-05-27 09:28:13 +00:00
|
|
|
|
import { DEFAULT_TENANT, oauthRedirectUri } from '../../config';
|
2026-05-26 09:32:32 +00:00
|
|
|
|
import { useAuth } from '../../context/AuthContext';
|
|
|
|
|
|
import * as permApi from '../../api/permission';
|
|
|
|
|
|
|
|
|
|
|
|
export function LoginPage() {
|
|
|
|
|
|
const navigate = useNavigate();
|
2026-05-26 16:55:37 +00:00
|
|
|
|
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('');
|
2026-05-26 16:55:37 +00:00
|
|
|
|
const [mfaChallengeId, setMfaChallengeId] = useState<string | null>(null);
|
|
|
|
|
|
const [totpCode, setTotpCode] = useState('');
|
2026-05-26 09:32:32 +00:00
|
|
|
|
const [error, setError] = useState('');
|
2026-05-26 16:55:37 +00:00
|
|
|
|
const [info, setInfo] = useState('');
|
2026-05-26 09:32:32 +00:00
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
2026-05-26 16:55:37 +00:00
|
|
|
|
useEffect(() => {
|
2026-05-27 09:28:13 +00:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-05-26 16:55:37 +00:00
|
|
|
|
if (state?.message) {
|
|
|
|
|
|
setInfo(state.message);
|
|
|
|
|
|
navigate(location.pathname, { replace: true, state: null });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [location.pathname, location.state, navigate]);
|
|
|
|
|
|
|
2026-05-27 09:28:13 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 16:55:37 +00:00
|
|
|
|
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);
|
2026-05-26 16:55:37 +00:00
|
|
|
|
const result = await authApi.login(tenant, email, password);
|
2026-05-27 09:28:13 +00:00
|
|
|
|
if (result.mfa_required || result.mfa_challenge_id) {
|
2026-05-26 16:55:37 +00:00
|
|
|
|
if (!result.mfa_challenge_id) {
|
|
|
|
|
|
throw new Error('缺少 MFA challenge');
|
|
|
|
|
|
}
|
|
|
|
|
|
setMfaChallengeId(result.mfa_challenge_id);
|
2026-05-27 09:28:13 +00:00
|
|
|
|
setTotpCode('');
|
|
|
|
|
|
setInfo('');
|
2026-05-26 16:55:37 +00:00
|
|
|
|
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 {
|
2026-05-27 09:28:13 +00:00
|
|
|
|
const code = totpCode.replace(/\s/g, '');
|
|
|
|
|
|
await authApi.loginMfaConfirm(tenant, mfaChallengeId, code);
|
2026-05-26 16:55:37 +00:00
|
|
|
|
await finishLogin();
|
2026-05-26 09:32:32 +00:00
|
|
|
|
} catch (err) {
|
2026-05-26 16:55:37 +00:00
|
|
|
|
setError(err instanceof ApiError ? err.message : '驗證碼錯誤');
|
2026-05-26 09:32:32 +00:00
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 16:55:37 +00:00
|
|
|
|
const backToPassword = () => {
|
|
|
|
|
|
setMfaChallengeId(null);
|
|
|
|
|
|
setTotpCode('');
|
|
|
|
|
|
setError('');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
if (mfaChallengeId) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="auth-card">
|
|
|
|
|
|
<h1>雙因素驗證</h1>
|
2026-05-27 09:28:13 +00:00
|
|
|
|
<p className="auth-hint">
|
|
|
|
|
|
{email ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
帳號 <strong>{email}</strong> 已啟用 TOTP。
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>此帳號已啟用 TOTP。</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
請輸入驗證器 App 的 6 位數驗證碼,或一次性備援碼。
|
|
|
|
|
|
</p>
|
2026-05-26 16:55:37 +00:00
|
|
|
|
<form onSubmit={submitMfa} className="form">
|
|
|
|
|
|
<label>
|
|
|
|
|
|
驗證碼
|
|
|
|
|
|
<input
|
|
|
|
|
|
value={totpCode}
|
|
|
|
|
|
onChange={(e) => setTotpCode(e.target.value)}
|
|
|
|
|
|
inputMode="numeric"
|
|
|
|
|
|
autoComplete="one-time-code"
|
2026-05-27 09:28:13 +00:00
|
|
|
|
autoFocus
|
2026-05-26 16:55:37 +00:00
|
|
|
|
required
|
2026-05-27 09:28:13 +00:00
|
|
|
|
minLength={6}
|
|
|
|
|
|
maxLength={32}
|
|
|
|
|
|
placeholder="000000"
|
2026-05-26 16:55:37 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</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>}
|
2026-05-26 16:55:37 +00:00
|
|
|
|
{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>
|
2026-05-27 09:28:13 +00:00
|
|
|
|
<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>
|
2026-05-26 09:32:32 +00:00
|
|
|
|
<p className="auth-footer">
|
|
|
|
|
|
還沒有帳號? <Link to="/register">註冊</Link>
|
2026-05-26 16:55:37 +00:00
|
|
|
|
{' · '}
|
|
|
|
|
|
<Link to="/register/confirm">尚未完成驗證?</Link>
|
|
|
|
|
|
{' · '}
|
|
|
|
|
|
<Link to="/password/forgot">忘記密碼?</Link>
|
2026-05-26 09:32:32 +00:00
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|