refactor(ts): LoginPage → TypeScript (auth-mode/TOTP/change-pw/privacy flows)

This commit is contained in:
null 2026-07-04 22:23:31 -05:00
parent 42e67dfdeb
commit 14f1fedfd0
1 changed files with 35 additions and 25 deletions

View File

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react';
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { api } from '@/api'; import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth'; import { errMessage } from '@/lib/utils';
import { useAuth, type User } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
@ -14,6 +14,14 @@ import {
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker'; const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
interface AuthModeInfo {
auth_mode?: string;
local_enabled?: boolean;
oidc_enabled?: boolean;
oidc_login_url?: string;
oidc_provider_name?: string;
}
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { setUser, refresh } = useAuth(); const { setUser, refresh } = useAuth();
@ -22,41 +30,43 @@ export default function LoginPage() {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [authMode, setAuthMode] = useState(null); // null = still loading const [authMode, setAuthMode] = useState<AuthModeInfo | null>(null); // null = still loading
const [pendingUser, setPendingUser] = useState(null); const [pendingUser, setPendingUser] = useState<User | null>(null);
const [showChangePw, setShowChangePw] = useState(false); const [showChangePw, setShowChangePw] = useState(false);
const [showPrivacy, setShowPrivacy] = useState(false); const [showPrivacy, setShowPrivacy] = useState(false);
// TOTP challenge state // TOTP challenge state
const [totpChallenge, setTotpChallenge] = useState(null); // challenge_token string const [totpChallenge, setTotpChallenge] = useState<string | null>(null); // challenge_token string
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [totpError, setTotpError] = useState(''); const [totpError, setTotpError] = useState('');
const [totpLoading, setTotpLoading] = useState(false); const [totpLoading, setTotpLoading] = useState(false);
const [useRecovery, setUseRecovery] = useState(false);; const [useRecovery, setUseRecovery] = useState(false);
const [newPw, setNewPw] = useState(''); const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false); const [pwLoading, setPwLoading] = useState(false);
const destFor = (user) => (user?.is_default_admin ? '/admin' : '/'); const destFor = (user?: User | null) => (user?.is_default_admin ? '/admin' : '/');
useEffect(() => { useEffect(() => {
api.authMode() api.authMode()
.then(d => { .then(raw => {
const d = raw as AuthModeInfo;
setAuthMode(d); setAuthMode(d);
if (d.auth_mode === 'single') navigate('/', { replace: true }); if (d.auth_mode === 'single') navigate('/', { replace: true });
}) })
.catch(err => console.error('[LoginPage] authMode check failed', err)); .catch(err => console.error('[LoginPage] authMode check failed', err));
api.me() api.me()
.then(d => { .then(raw => {
const d = raw as { user?: User };
if (d.user) navigate(destFor(d.user), { replace: true }); if (d.user) navigate(destFor(d.user), { replace: true });
}) })
.catch(err => console.error('[LoginPage] session check failed', err)); .catch(err => console.error('[LoginPage] session check failed', err));
}, []); // eslint-disable-line }, []); // eslint-disable-line
const handlePostLogin = (user) => { const handlePostLogin = (user: User) => {
setUser(user); setUser(user);
if (user.must_change_password) { if (user.must_change_password) {
setPendingUser(user); setPendingUser(user);
@ -71,14 +81,14 @@ export default function LoginPage() {
} }
}; };
const handleLogin = async (e) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setLoading(true); setLoading(true);
try { try {
const data = await api.login({ username, password }); const data = await api.login({ username, password }) as { requires_totp?: boolean; challenge_token?: string; user: User };
if (data.requires_totp) { if (data.requires_totp) {
setTotpChallenge(data.challenge_token); setTotpChallenge(data.challenge_token ?? null);
setTotpCode(''); setTotpCode('');
setTotpError(''); setTotpError('');
setUseRecovery(false); setUseRecovery(false);
@ -86,24 +96,24 @@ export default function LoginPage() {
} }
handlePostLogin(data.user); handlePostLogin(data.user);
} catch (err) { } catch (err) {
setError(err.message || 'Login failed.'); setError(errMessage(err, 'Login failed.'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleTotpSubmit = async (e) => { const handleTotpSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setTotpError(''); setTotpError('');
setTotpLoading(true); setTotpLoading(true);
try { try {
const payload = { challenge_token: totpChallenge }; const payload: { challenge_token: string | null; recovery_code?: string; code?: string } = { challenge_token: totpChallenge };
if (useRecovery) payload.recovery_code = totpCode; if (useRecovery) payload.recovery_code = totpCode;
else payload.code = totpCode; else payload.code = totpCode;
const data = await api.totpChallenge(payload); const data = await api.totpChallenge(payload) as { user: User };
handlePostLogin(data.user); handlePostLogin(data.user);
} catch (err) { } catch (err) {
setTotpError(err.message || 'Invalid code.'); setTotpError(errMessage(err, 'Invalid code.'));
setTotpCode(''); setTotpCode('');
} finally { } finally {
setTotpLoading(false); setTotpLoading(false);
@ -115,7 +125,7 @@ export default function LoginPage() {
const providerName = authMode?.oidc_provider_name || 'authentik'; const providerName = authMode?.oidc_provider_name || 'authentik';
const isAuthentikProvider = providerName.toLowerCase().includes('authentik'); const isAuthentikProvider = providerName.toLowerCase().includes('authentik');
const handleChangePassword = async (e) => { const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (newPw !== confirmPw) { if (newPw !== confirmPw) {
@ -141,7 +151,7 @@ export default function LoginPage() {
navigate(destFor(pendingUser), { replace: true }); navigate(destFor(pendingUser), { replace: true });
} }
} catch (err) { } catch (err) {
toast.error(err.message || 'Failed to change password.'); toast.error(errMessage(err, 'Failed to change password.'));
} finally { } finally {
setPwLoading(false); setPwLoading(false);
} }
@ -150,7 +160,7 @@ export default function LoginPage() {
const handleAcknowledgePrivacy = async () => { const handleAcknowledgePrivacy = async () => {
try { try {
await api.acknowledgePrivacy(); await api.acknowledgePrivacy();
} catch {} } catch { /* ignore */ }
refresh(); refresh();
setShowPrivacy(false); setShowPrivacy(false);
@ -249,7 +259,7 @@ export default function LoginPage() {
type="button" type="button"
variant={localEnabled ? 'outline' : 'default'} variant={localEnabled ? 'outline' : 'default'}
className="w-full gap-2" className="w-full gap-2"
onClick={() => { window.location.href = authMode.oidc_login_url; }} onClick={() => { window.location.href = authMode?.oidc_login_url || ''; }}
> >
{isAuthentikProvider && ( {isAuthentikProvider && (
<img <img
@ -257,7 +267,7 @@ export default function LoginPage() {
alt="" alt=""
aria-hidden="true" aria-hidden="true"
className="h-5 w-5 object-contain shrink-0" className="h-5 w-5 object-contain shrink-0"
onError={e => { e.target.style.display = 'none'; }} onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }}
/> />
)} )}
Continue with {providerName} Continue with {providerName}