315 lines
9.5 KiB
JavaScript
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>
|
|
);
|
|
}
|