BillTracker/client/pages/LoginPage.jsx

315 lines
9.5 KiB
JavaScript

import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { APP_VERSION } from '@/lib/version';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
const AUTHENTIK_ICON_URL = 'https://gate.originalsinners.org/static/dist/assets/icons/icon.png';
const BUILD_LINK_URL = 'https://dream.scheller.ltd/null/BillTracker';
export default function LoginPage() {
const navigate = useNavigate();
const { setUser, refresh } = useAuth();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [authMode, setAuthMode] = useState({ local_enabled: true, oidc_enabled: false });
const [pendingUser, setPendingUser] = useState(null);
const [showChangePw, setShowChangePw] = useState(false);
const [showPrivacy, setShowPrivacy] = useState(false);
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
const destFor = (role) => (role === 'admin' ? '/admin' : '/');
useEffect(() => {
api.authMode()
.then(d => {
setAuthMode(d);
if (d.auth_mode === 'single') navigate('/', { replace: true });
})
.catch(() => {});
api.me()
.then(d => {
if (d.user) navigate(destFor(d.user.role), { replace: true });
})
.catch(() => {});
}, []); // eslint-disable-line
const handlePostLogin = (user) => {
setUser(user);
if (user.must_change_password) {
setPendingUser(user);
setShowChangePw(true);
} else if (user.first_login) {
setPendingUser(user);
setShowPrivacy(true);
} else {
navigate(destFor(user.role), { replace: true });
}
};
const handleLogin = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = await api.login({ username, password });
handlePostLogin(data.user);
} catch (err) {
setError(err.message || 'Login failed.');
} finally {
setLoading(false);
}
};
const localEnabled = authMode.local_enabled !== false;
const oidcEnabled = !!authMode.oidc_enabled && !!authMode.oidc_login_url;
const providerName = authMode.oidc_provider_name || 'authentik';
const handleChangePassword = async (e) => {
e.preventDefault();
if (newPw !== confirmPw) {
toast.error('Passwords do not match.');
return;
}
if (newPw.length < 6) {
toast.error('Password must be at least 6 characters.');
return;
}
setPwLoading(true);
try {
await api.changePassword({ new_password: newPw });
refresh();
toast.success('Password updated.');
setShowChangePw(false);
if (pendingUser?.first_login) {
setShowPrivacy(true);
} else {
navigate(destFor(pendingUser.role), { replace: true });
}
} catch (err) {
toast.error(err.message || 'Failed to change password.');
} finally {
setPwLoading(false);
}
};
const handleAcknowledgePrivacy = async () => {
try {
await api.acknowledgePrivacy();
} catch {}
refresh();
setShowPrivacy(false);
navigate(destFor(pendingUser?.role), { replace: true });
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-6">
<div className="w-full max-w-sm space-y-6">
{/* Logo / Brand */}
<div className="flex justify-center">
<img
src="/img/logo.png"
alt="BillTracker"
className="h-auto w-[82%] max-w-[22rem] object-contain drop-shadow-[0_1px_2px_rgba(0,0,0,0.45)]"
/>
</div>
{/* Card */}
<div className="surface-elevated p-8 space-y-6">
<div>
<h1 className="text-lg font-semibold">Sign in</h1>
<p className="text-sm text-muted-foreground mt-1">
{localEnabled ? 'Enter your credentials to continue.' : `Continue with ${providerName}.`}
</p>
</div>
{oidcEnabled && (
<Button
type="button"
variant={localEnabled ? 'outline' : 'default'}
className="w-full"
onClick={() => { window.location.href = authMode.oidc_login_url; }}
>
<img
src={AUTHENTIK_ICON_URL}
alt=""
aria-hidden="true"
className="mr-2 h-5 w-5 shrink-0 object-contain"
/>
Continue with {providerName}
</Button>
)}
{localEnabled && oidcEnabled && (
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="h-px flex-1 bg-border" />
<span>or</span>
<div className="h-px flex-1 bg-border" />
</div>
)}
{localEnabled && (
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="username">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
disabled={loading}
required
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
required
/>
</div>
{error && (
<div className="text-sm text-destructive bg-destructive/10
border border-destructive/20 rounded-md px-3 py-2">
{error}
</div>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Signing in…' : 'Sign In'}
</Button>
</form>
)}
<p className="text-center text-xs text-muted-foreground">
<a
href={BUILD_LINK_URL}
target="_blank"
rel="noreferrer"
className="underline-offset-4 transition-colors hover:text-foreground hover:underline"
>
Build v{APP_VERSION}
</a>
</p>
</div>
</div>
{/* Change Password Dialog */}
<Dialog open={showChangePw}>
<DialogContent onInteractOutside={e => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Change your password</DialogTitle>
<DialogDescription>
You must set a new password before continuing.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleChangePassword} className="space-y-4 pt-2">
<div className="space-y-1.5">
<Label>New password</Label>
<Input
type="password"
value={newPw}
onChange={e => setNewPw(e.target.value)}
required
/>
</div>
<div className="space-y-1.5">
<Label>Confirm password</Label>
<Input
type="password"
value={confirmPw}
onChange={e => setConfirmPw(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button type="submit" className="w-full" disabled={pwLoading}>
{pwLoading ? 'Saving…' : 'Set Password'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Privacy Dialog */}
<Dialog open={showPrivacy}>
<DialogContent onInteractOutside={e => e.preventDefault()}>
<DialogHeader>
<DialogTitle>Privacy notice</DialogTitle>
<DialogDescription>
Please read before continuing.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 text-sm text-muted-foreground py-2">
<p>Your financial data is stored privately and is accessible only to you.</p>
<div className="rounded-lg bg-muted/50 border border-border p-4 space-y-2">
<p className="font-medium text-foreground">What your administrator can do:</p>
<ul className="space-y-1.5">
<li className="flex gap-2">
<span className="text-emerald-400"></span>
<span>Reset your password if locked out</span>
</li>
<li className="flex gap-2">
<span className="text-red-400"></span>
<span>Cannot view your financial data</span>
</li>
</ul>
</div>
</div>
<DialogFooter>
<Button className="w-full" onClick={handleAcknowledgePrivacy}>
I understand continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}