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 React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -14,6 +14,14 @@ import {
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() {
const navigate = useNavigate();
const { setUser, refresh } = useAuth();
@ -22,41 +30,43 @@ export default function LoginPage() {
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
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 [showPrivacy, setShowPrivacy] = useState(false);
// 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 [totpError, setTotpError] = useState('');
const [totpLoading, setTotpLoading] = useState(false);
const [useRecovery, setUseRecovery] = useState(false);;
const [useRecovery, setUseRecovery] = useState(false);
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
const destFor = (user) => (user?.is_default_admin ? '/admin' : '/');
const destFor = (user?: User | null) => (user?.is_default_admin ? '/admin' : '/');
useEffect(() => {
api.authMode()
.then(d => {
.then(raw => {
const d = raw as AuthModeInfo;
setAuthMode(d);
if (d.auth_mode === 'single') navigate('/', { replace: true });
})
.catch(err => console.error('[LoginPage] authMode check failed', err));
api.me()
.then(d => {
.then(raw => {
const d = raw as { user?: User };
if (d.user) navigate(destFor(d.user), { replace: true });
})
.catch(err => console.error('[LoginPage] session check failed', err));
}, []); // eslint-disable-line
const handlePostLogin = (user) => {
const handlePostLogin = (user: User) => {
setUser(user);
if (user.must_change_password) {
setPendingUser(user);
@ -71,14 +81,14 @@ export default function LoginPage() {
}
};
const handleLogin = async (e) => {
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
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) {
setTotpChallenge(data.challenge_token);
setTotpChallenge(data.challenge_token ?? null);
setTotpCode('');
setTotpError('');
setUseRecovery(false);
@ -86,24 +96,24 @@ export default function LoginPage() {
}
handlePostLogin(data.user);
} catch (err) {
setError(err.message || 'Login failed.');
setError(errMessage(err, 'Login failed.'));
} finally {
setLoading(false);
}
};
const handleTotpSubmit = async (e) => {
const handleTotpSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setTotpError('');
setTotpLoading(true);
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;
else payload.code = totpCode;
const data = await api.totpChallenge(payload);
const data = await api.totpChallenge(payload) as { user: User };
handlePostLogin(data.user);
} catch (err) {
setTotpError(err.message || 'Invalid code.');
setTotpError(errMessage(err, 'Invalid code.'));
setTotpCode('');
} finally {
setTotpLoading(false);
@ -115,7 +125,7 @@ export default function LoginPage() {
const providerName = authMode?.oidc_provider_name || 'authentik';
const isAuthentikProvider = providerName.toLowerCase().includes('authentik');
const handleChangePassword = async (e) => {
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPw !== confirmPw) {
@ -141,7 +151,7 @@ export default function LoginPage() {
navigate(destFor(pendingUser), { replace: true });
}
} catch (err) {
toast.error(err.message || 'Failed to change password.');
toast.error(errMessage(err, 'Failed to change password.'));
} finally {
setPwLoading(false);
}
@ -150,7 +160,7 @@ export default function LoginPage() {
const handleAcknowledgePrivacy = async () => {
try {
await api.acknowledgePrivacy();
} catch {}
} catch { /* ignore */ }
refresh();
setShowPrivacy(false);
@ -249,7 +259,7 @@ export default function LoginPage() {
type="button"
variant={localEnabled ? 'outline' : 'default'}
className="w-full gap-2"
onClick={() => { window.location.href = authMode.oidc_login_url; }}
onClick={() => { window.location.href = authMode?.oidc_login_url || ''; }}
>
{isAuthentikProvider && (
<img
@ -257,7 +267,7 @@ export default function LoginPage() {
alt=""
aria-hidden="true"
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}
@ -301,7 +311,7 @@ export default function LoginPage() {
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10
<div className="text-sm text-destructive bg-destructive/10
border border-destructive/20 rounded-md px-3 py-2">
{error}
</div>