2026-07-04 22:53:51 -05:00
|
|
|
import { useEffect, useState, useCallback, type ComponentType, type ReactNode } from 'react';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { toast } from 'sonner';
|
|
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
User,
|
|
|
|
|
Mail,
|
|
|
|
|
KeyRound,
|
|
|
|
|
ShieldCheck,
|
|
|
|
|
Loader2,
|
|
|
|
|
History,
|
|
|
|
|
Monitor,
|
|
|
|
|
Smartphone,
|
|
|
|
|
ChevronRight,
|
|
|
|
|
Bell,
|
|
|
|
|
SendHorizontal,
|
|
|
|
|
ScanLine,
|
|
|
|
|
TriangleAlert,
|
|
|
|
|
Copy,
|
|
|
|
|
Check,
|
|
|
|
|
Lock,
|
|
|
|
|
LogOut,
|
2026-05-03 19:51:57 -05:00
|
|
|
} from 'lucide-react';
|
|
|
|
|
import { api } from '@/api';
|
2026-07-04 22:53:51 -05:00
|
|
|
import { errMessage } from '@/lib/utils';
|
|
|
|
|
import { useAuth, type User as AuthUser } from '@/hooks/useAuth';
|
2026-06-12 02:08:42 -05:00
|
|
|
import { useAutoSave } from '@/hooks/useAutoSave';
|
|
|
|
|
import { SaveStatus } from '@/components/ui/save-status';
|
2026-05-03 19:51:57 -05:00
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Input } from '@/components/ui/input';
|
|
|
|
|
import { Switch } from '@/components/ui/switch';
|
2026-05-15 01:36:56 -05:00
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
DialogDescription,
|
2026-05-15 01:36:56 -05:00
|
|
|
} from '@/components/ui/dialog';
|
2026-07-05 16:05:59 -05:00
|
|
|
import {
|
2026-07-06 14:23:53 -05:00
|
|
|
AlertDialog,
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
AlertDialogTitle,
|
2026-07-05 16:05:59 -05:00
|
|
|
} from '@/components/ui/alert-dialog';
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
type Settings = Record<string, unknown>;
|
|
|
|
|
|
|
|
|
|
interface Profile {
|
|
|
|
|
username?: string;
|
|
|
|
|
display_name?: string;
|
|
|
|
|
displayName?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
role?: string;
|
|
|
|
|
last_password_change_at?: string;
|
|
|
|
|
password_changed_at?: string;
|
|
|
|
|
[key: string]: unknown;
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
interface LoginEntry {
|
|
|
|
|
id: number | string;
|
|
|
|
|
success?: boolean;
|
|
|
|
|
user_agent?: string;
|
|
|
|
|
browser?: string;
|
|
|
|
|
os?: string;
|
|
|
|
|
device_type?: string;
|
|
|
|
|
logged_in_at?: string;
|
|
|
|
|
is_current_session?: boolean;
|
|
|
|
|
ip_address?: string;
|
|
|
|
|
location_city?: string;
|
|
|
|
|
location_region?: string;
|
|
|
|
|
location_country?: string;
|
|
|
|
|
location_isp?: string;
|
|
|
|
|
device_fingerprint?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function asProfile(data: unknown): Profile {
|
|
|
|
|
const d = data as { profile?: Profile; user?: Profile } | Profile | null | undefined;
|
2026-07-06 14:23:53 -05:00
|
|
|
return ((d as { profile?: Profile })?.profile ||
|
|
|
|
|
(d as { user?: Profile })?.user ||
|
|
|
|
|
(d as Profile) ||
|
|
|
|
|
{}) as Profile;
|
2026-07-04 22:53:51 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function displayNameOf(profile: Profile): string {
|
2026-06-07 01:17:49 -05:00
|
|
|
return profile.display_name || profile.displayName || profile.name || '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
export function asSettings(data: unknown): Settings {
|
|
|
|
|
const d = data as { settings?: Settings; notifications?: Settings } | Settings | null | undefined;
|
2026-07-06 14:23:53 -05:00
|
|
|
return ((d as { settings?: Settings })?.settings ||
|
|
|
|
|
(d as { notifications?: Settings })?.notifications ||
|
|
|
|
|
(d as Settings) ||
|
|
|
|
|
{}) as Settings;
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
function formatDateTime(value: unknown): string {
|
2026-05-03 19:51:57 -05:00
|
|
|
if (!value) return 'Not recorded';
|
2026-07-04 22:53:51 -05:00
|
|
|
const d = new Date(value as string | number);
|
2026-05-03 19:51:57 -05:00
|
|
|
if (Number.isNaN(d.getTime())) return String(value);
|
2026-07-06 14:23:53 -05:00
|
|
|
return (
|
|
|
|
|
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) +
|
|
|
|
|
' ' +
|
|
|
|
|
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
|
|
|
);
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function SectionCard({
|
|
|
|
|
title,
|
|
|
|
|
icon: Icon,
|
|
|
|
|
subtitle,
|
|
|
|
|
action,
|
|
|
|
|
children,
|
|
|
|
|
}: {
|
2026-07-04 22:53:51 -05:00
|
|
|
title: ReactNode;
|
|
|
|
|
icon: ComponentType<{ className?: string }>;
|
|
|
|
|
subtitle?: ReactNode;
|
|
|
|
|
action?: ReactNode;
|
|
|
|
|
children: ReactNode;
|
|
|
|
|
}) {
|
2026-05-03 19:51:57 -05:00
|
|
|
return (
|
2026-05-04 23:34:24 -05:00
|
|
|
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
|
2026-05-03 19:51:57 -05:00
|
|
|
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
2026-05-04 23:34:24 -05:00
|
|
|
<div className="h-9 w-9 rounded-lg border border-primary/15 bg-primary/10 flex items-center justify-center shrink-0">
|
2026-05-03 19:51:57 -05:00
|
|
|
<Icon className="h-4 w-4 text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
|
|
|
|
|
{title}
|
|
|
|
|
</h2>
|
2026-05-03 19:51:57 -05:00
|
|
|
{subtitle && <p className="text-sm text-muted-foreground mt-0.5">{subtitle}</p>}
|
|
|
|
|
</div>
|
2026-06-12 02:08:42 -05:00
|
|
|
{action && <div className="ml-auto shrink-0">{action}</div>}
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
<div>{children}</div>
|
|
|
|
|
</section>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
function FieldRow({ label, value }: { label: ReactNode; value?: ReactNode }) {
|
2026-05-03 19:51:57 -05:00
|
|
|
return (
|
|
|
|
|
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
|
|
|
{label}
|
|
|
|
|
</p>
|
2026-05-03 19:51:57 -05:00
|
|
|
<p className="mt-1 text-sm font-medium text-foreground truncate">{value || 'Not set'}</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function CheckRow({
|
|
|
|
|
id,
|
|
|
|
|
label,
|
|
|
|
|
checked,
|
|
|
|
|
onChange,
|
|
|
|
|
disabled,
|
|
|
|
|
}: {
|
2026-07-04 22:53:51 -05:00
|
|
|
id: string;
|
|
|
|
|
label: ReactNode;
|
|
|
|
|
checked?: boolean;
|
|
|
|
|
onChange?: (v: boolean) => void;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
}) {
|
2026-05-03 19:51:57 -05:00
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<label
|
|
|
|
|
htmlFor={id}
|
|
|
|
|
className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-4 py-3"
|
|
|
|
|
>
|
2026-05-03 19:51:57 -05:00
|
|
|
<span className="text-sm font-medium">{label}</span>
|
|
|
|
|
<Switch id={id} checked={!!checked} onCheckedChange={onChange} disabled={disabled} />
|
|
|
|
|
</label>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
function parseUserAgent(ua?: string | null): { browser: string; os: string; mobile: boolean } {
|
2026-05-15 01:36:56 -05:00
|
|
|
if (!ua) return { browser: 'Unknown', os: 'Unknown', mobile: false };
|
|
|
|
|
const s = ua;
|
|
|
|
|
const mobile = /iPhone|iPad|Android|Mobile/i.test(s);
|
2026-07-06 14:23:53 -05:00
|
|
|
const browser = /Edg\//i.test(s)
|
|
|
|
|
? 'Edge'
|
|
|
|
|
: /OPR\//i.test(s)
|
|
|
|
|
? 'Opera'
|
|
|
|
|
: /Chrome\//i.test(s)
|
|
|
|
|
? 'Chrome'
|
|
|
|
|
: /Firefox\//i.test(s)
|
|
|
|
|
? 'Firefox'
|
|
|
|
|
: /Safari\//i.test(s)
|
|
|
|
|
? 'Safari'
|
|
|
|
|
: /curl\//i.test(s)
|
|
|
|
|
? 'curl'
|
|
|
|
|
: 'Unknown';
|
|
|
|
|
const os = /iPhone|iPad/i.test(s)
|
|
|
|
|
? 'iOS'
|
|
|
|
|
: /Android/i.test(s)
|
|
|
|
|
? 'Android'
|
|
|
|
|
: /Windows/i.test(s)
|
|
|
|
|
? 'Windows'
|
|
|
|
|
: /Macintosh/i.test(s)
|
|
|
|
|
? 'macOS'
|
|
|
|
|
: /Linux/i.test(s)
|
|
|
|
|
? 'Linux'
|
|
|
|
|
: 'Unknown';
|
2026-05-15 01:36:56 -05:00
|
|
|
return { browser, os, mobile };
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
function deviceLabel(type?: string): string {
|
2026-05-15 22:45:38 -05:00
|
|
|
if (type === 'mobile') return 'Mobile';
|
|
|
|
|
if (type === 'tablet') return 'Tablet';
|
|
|
|
|
if (type === 'api') return 'API client';
|
|
|
|
|
return 'Desktop';
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function LoginHistoryModal({
|
|
|
|
|
history: providedHistory,
|
|
|
|
|
open,
|
|
|
|
|
onClose,
|
|
|
|
|
onLoaded,
|
|
|
|
|
}: {
|
2026-07-04 22:53:51 -05:00
|
|
|
history?: LoginEntry[];
|
|
|
|
|
open: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
onLoaded?: (rows: LoginEntry[]) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [history, setHistory] = useState<LoginEntry[]>([]);
|
2026-05-15 01:36:56 -05:00
|
|
|
const [loading, setLoading] = useState(false);
|
2026-07-05 16:05:59 -05:00
|
|
|
const [confirmSignOut, setConfirmSignOut] = useState(false);
|
|
|
|
|
const [signingOut, setSigningOut] = useState(false);
|
2026-05-15 01:36:56 -05:00
|
|
|
|
2026-07-05 16:05:59 -05:00
|
|
|
const fetchHistory = useCallback(() => {
|
2026-05-15 01:36:56 -05:00
|
|
|
setLoading(true);
|
2026-07-06 14:23:53 -05:00
|
|
|
return api
|
|
|
|
|
.loginHistory()
|
|
|
|
|
.then((raw) => {
|
2026-07-04 22:53:51 -05:00
|
|
|
const rows = (raw as { history?: LoginEntry[] }).history ?? [];
|
2026-05-15 22:45:38 -05:00
|
|
|
setHistory(rows);
|
|
|
|
|
onLoaded?.(rows);
|
|
|
|
|
})
|
2026-07-06 14:23:53 -05:00
|
|
|
.catch((err) => {
|
2026-05-29 01:06:20 -05:00
|
|
|
setHistory([]);
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to load login history.'));
|
2026-05-29 01:06:20 -05:00
|
|
|
})
|
2026-05-15 01:36:56 -05:00
|
|
|
.finally(() => setLoading(false));
|
2026-07-05 16:05:59 -05:00
|
|
|
}, [onLoaded]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
if (providedHistory?.length) {
|
|
|
|
|
setHistory(providedHistory);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
fetchHistory();
|
|
|
|
|
}, [open, providedHistory, fetchHistory]);
|
|
|
|
|
|
|
|
|
|
async function handleSignOutOthers() {
|
|
|
|
|
setSigningOut(true);
|
|
|
|
|
try {
|
|
|
|
|
const res = await api.logoutOthers();
|
|
|
|
|
const n = res?.count ?? 0;
|
2026-07-06 14:23:53 -05:00
|
|
|
toast.success(
|
|
|
|
|
n > 0
|
|
|
|
|
? `Signed out of ${n} other device${n === 1 ? '' : 's'}.`
|
|
|
|
|
: 'No other devices were signed in.',
|
|
|
|
|
);
|
2026-07-05 16:05:59 -05:00
|
|
|
setConfirmSignOut(false);
|
|
|
|
|
await fetchHistory(); // collapse the list to just this device
|
|
|
|
|
} catch (err) {
|
|
|
|
|
toast.error(errMessage(err, 'Failed to sign out other devices.'));
|
|
|
|
|
} finally {
|
|
|
|
|
setSigningOut(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-15 01:36:56 -05:00
|
|
|
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<Dialog
|
|
|
|
|
open={open}
|
|
|
|
|
onOpenChange={(v) => {
|
|
|
|
|
if (!v) onClose();
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-05-15 01:36:56 -05:00
|
|
|
<DialogContent className="sm:max-w-md border-border/60 bg-card/95 backdrop-blur-xl">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center gap-2 text-base">
|
|
|
|
|
<History className="h-4 w-4 text-primary" />
|
|
|
|
|
Login History
|
|
|
|
|
</DialogTitle>
|
2026-07-06 14:23:53 -05:00
|
|
|
<DialogDescription className="sr-only">Your last 10 sign-in events</DialogDescription>
|
2026-05-15 01:36:56 -05:00
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="mt-1 space-y-1">
|
|
|
|
|
{loading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-8 gap-2 text-muted-foreground text-sm">
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" /> Loading…
|
|
|
|
|
</div>
|
|
|
|
|
) : history.length === 0 ? (
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
|
|
|
No login history recorded.
|
|
|
|
|
</p>
|
|
|
|
|
) : (
|
|
|
|
|
history.map((entry, i) => {
|
|
|
|
|
const isFailed = entry.success === false;
|
|
|
|
|
const parsed = parseUserAgent(entry.user_agent);
|
|
|
|
|
const browser = entry.browser || parsed.browser;
|
|
|
|
|
const os = entry.os || parsed.os;
|
|
|
|
|
const deviceType = entry.device_type || (parsed.mobile ? 'mobile' : 'desktop');
|
|
|
|
|
const DeviceIcon =
|
|
|
|
|
deviceType === 'mobile' || deviceType === 'tablet' ? Smartphone : Monitor;
|
|
|
|
|
const isFirstSuccess =
|
|
|
|
|
!isFailed && history.slice(0, i).every((e) => e.success === false);
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={entry.id}
|
|
|
|
|
className={`flex items-start gap-3 rounded-lg border px-4 py-3 ${
|
|
|
|
|
isFailed
|
|
|
|
|
? 'border-destructive/30 bg-destructive/5'
|
|
|
|
|
: 'border-border/50 bg-muted/20'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<DeviceIcon
|
|
|
|
|
className={`h-4 w-4 mt-0.5 shrink-0 ${isFailed ? 'text-destructive/70' : 'text-muted-foreground'}`}
|
|
|
|
|
/>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="text-sm font-medium flex items-center flex-wrap gap-1.5">
|
|
|
|
|
{formatDateTime(entry.logged_in_at)}
|
|
|
|
|
{isFailed && (
|
|
|
|
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-destructive bg-destructive/10 px-1.5 py-0.5 rounded">
|
|
|
|
|
Failed attempt
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{entry.is_current_session && (
|
|
|
|
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded">
|
|
|
|
|
This session
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{!entry.is_current_session && isFirstSuccess && (
|
|
|
|
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-blue-500">
|
|
|
|
|
Most recent
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-06-04 03:38:32 -05:00
|
|
|
</p>
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
|
|
|
{deviceLabel(deviceType)} · {browser} on {os}
|
|
|
|
|
{entry.ip_address && (
|
|
|
|
|
<span className="ml-2 font-mono">{entry.ip_address}</span>
|
|
|
|
|
)}
|
|
|
|
|
{(entry.location_city || entry.location_country) && (
|
|
|
|
|
<span className="ml-2 text-muted-foreground/80">
|
|
|
|
|
—{' '}
|
|
|
|
|
{[entry.location_city, entry.location_region, entry.location_country]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(', ')}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2026-05-15 22:45:38 -05:00
|
|
|
</p>
|
2026-07-06 14:23:53 -05:00
|
|
|
{entry.location_isp && (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
|
|
|
|
|
{entry.location_isp}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{entry.device_fingerprint && (
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/70 mt-1 font-mono">
|
|
|
|
|
Device ID {entry.device_fingerprint}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-05-15 01:36:56 -05:00
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
)}
|
2026-05-15 01:36:56 -05:00
|
|
|
</div>
|
|
|
|
|
|
2026-07-05 16:05:59 -05:00
|
|
|
<div className="pt-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full gap-1.5 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
|
|
|
onClick={() => setConfirmSignOut(true)}
|
|
|
|
|
>
|
|
|
|
|
<LogOut className="h-3.5 w-3.5" />
|
|
|
|
|
Sign out of other devices
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-15 01:36:56 -05:00
|
|
|
<p className="text-[10px] text-muted-foreground/60 text-center pt-1">
|
2026-07-06 14:23:53 -05:00
|
|
|
Showing up to 10 most recent events including failed attempts. Device ID is a short
|
|
|
|
|
privacy-preserving identifier.
|
2026-05-15 22:45:38 -05:00
|
|
|
</p>
|
|
|
|
|
<p className="text-[10px] text-muted-foreground/60 text-center">
|
2026-07-06 14:23:53 -05:00
|
|
|
This information is shown only to you and is encrypted at rest. It is not shared with
|
|
|
|
|
admins.
|
2026-05-15 01:36:56 -05:00
|
|
|
</p>
|
2026-07-05 16:05:59 -05:00
|
|
|
|
|
|
|
|
<AlertDialog open={confirmSignOut} onOpenChange={setConfirmSignOut}>
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
<AlertDialogTitle>Sign out of other devices?</AlertDialogTitle>
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
This ends every other active session — anyone signed in on another browser or device
|
|
|
|
|
will be logged out. This device stays signed in.
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
<AlertDialogCancel disabled={signingOut}>Cancel</AlertDialogCancel>
|
|
|
|
|
<AlertDialogAction
|
2026-07-06 14:23:53 -05:00
|
|
|
onClick={(e) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
handleSignOutOthers();
|
|
|
|
|
}}
|
2026-07-05 16:05:59 -05:00
|
|
|
disabled={signingOut}
|
|
|
|
|
>
|
|
|
|
|
{signingOut ? 'Signing out…' : 'Sign out others'}
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
</AlertDialog>
|
2026-05-15 01:36:56 -05:00
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function LoginSummaryCard({
|
|
|
|
|
latestLogin,
|
|
|
|
|
loading,
|
|
|
|
|
onOpen,
|
|
|
|
|
}: {
|
2026-07-04 22:53:51 -05:00
|
|
|
latestLogin: LoginEntry | null;
|
|
|
|
|
loading: boolean;
|
|
|
|
|
onOpen: () => void;
|
|
|
|
|
}) {
|
2026-05-15 22:45:38 -05:00
|
|
|
const parsed = parseUserAgent(latestLogin?.user_agent);
|
|
|
|
|
const browser = latestLogin?.browser || parsed.browser;
|
|
|
|
|
const os = latestLogin?.os || parsed.os;
|
|
|
|
|
const deviceType = latestLogin?.device_type || (parsed.mobile ? 'mobile' : 'desktop');
|
|
|
|
|
const DeviceIcon = deviceType === 'mobile' || deviceType === 'tablet' ? Smartphone : Monitor;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onOpen}
|
|
|
|
|
className="group rounded-lg border border-border/60 bg-muted/25 px-4 py-3 text-left transition-colors hover:border-primary/35 hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start gap-3">
|
|
|
|
|
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background/60 text-muted-foreground group-hover:text-primary">
|
2026-07-06 14:23:53 -05:00
|
|
|
{loading ? (
|
|
|
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
|
|
|
) : (
|
|
|
|
|
<DeviceIcon className="h-4 w-4" />
|
|
|
|
|
)}
|
2026-05-15 22:45:38 -05:00
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
|
|
|
|
Last Login
|
|
|
|
|
</p>
|
2026-05-15 22:45:38 -05:00
|
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5 group-hover:text-primary" />
|
|
|
|
|
</div>
|
|
|
|
|
{latestLogin ? (
|
|
|
|
|
<>
|
|
|
|
|
<p className="mt-1 truncate text-sm font-semibold text-foreground">
|
|
|
|
|
{formatDateTime(latestLogin.logged_in_at)}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
|
|
|
{deviceLabel(deviceType)} · {browser} on {os}
|
|
|
|
|
</p>
|
2026-06-04 03:38:32 -05:00
|
|
|
{(latestLogin.location_city || latestLogin.location_country) && (
|
|
|
|
|
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">
|
2026-07-06 14:23:53 -05:00
|
|
|
{[
|
|
|
|
|
latestLogin.location_city,
|
|
|
|
|
latestLogin.location_region,
|
|
|
|
|
latestLogin.location_country,
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(', ')}
|
2026-06-04 03:38:32 -05:00
|
|
|
</p>
|
|
|
|
|
)}
|
2026-05-15 22:45:38 -05:00
|
|
|
{latestLogin.ip_address && (
|
2026-06-04 03:38:32 -05:00
|
|
|
<p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
|
2026-05-15 22:45:38 -05:00
|
|
|
{latestLogin.ip_address}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<p className="mt-1 text-sm font-semibold text-foreground">
|
|
|
|
|
{loading ? 'Checking login history...' : 'No login recorded yet'}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
|
|
|
Open history to view device and IP details.
|
|
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
function ProfileSummary({ profile, loading }: { profile: Profile; loading: boolean }) {
|
2026-05-15 01:36:56 -05:00
|
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
2026-07-04 22:53:51 -05:00
|
|
|
const [loginHistory, setLoginHistory] = useState<LoginEntry[]>([]);
|
2026-05-15 22:45:38 -05:00
|
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (loading) return;
|
|
|
|
|
setHistoryLoading(true);
|
2026-07-06 14:23:53 -05:00
|
|
|
api
|
|
|
|
|
.loginHistory()
|
|
|
|
|
.then((raw) => setLoginHistory((raw as { history?: LoginEntry[] }).history ?? []))
|
|
|
|
|
.catch((err) => {
|
2026-05-29 01:06:20 -05:00
|
|
|
setLoginHistory([]);
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to load login history.'));
|
2026-05-29 01:06:20 -05:00
|
|
|
})
|
2026-05-15 22:45:38 -05:00
|
|
|
.finally(() => setHistoryLoading(false));
|
|
|
|
|
}, [loading]);
|
2026-05-15 01:36:56 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<SectionCard title="Profile Summary" icon={User}>
|
|
|
|
|
<div className="px-6 py-6 text-sm text-muted-foreground">Loading profile…</div>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 03:38:32 -05:00
|
|
|
// Show the most recent SUCCESSFUL login in the summary card (not a failed attempt)
|
2026-07-06 14:23:53 -05:00
|
|
|
const latestLogin = loginHistory.find((l) => l.success !== false) || null;
|
2026-05-15 01:36:56 -05:00
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
return (
|
2026-05-15 01:36:56 -05:00
|
|
|
<>
|
|
|
|
|
<SectionCard title="Profile Summary" icon={User} subtitle="Your signed-in account details.">
|
|
|
|
|
<div className="px-6 py-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
|
|
|
<FieldRow label="Username" value={profile.username} />
|
2026-06-07 01:17:49 -05:00
|
|
|
<FieldRow label="Display Name" value={displayNameOf(profile)} />
|
2026-05-15 01:36:56 -05:00
|
|
|
<FieldRow label="Role" value={profile.role} />
|
|
|
|
|
|
2026-05-15 22:45:38 -05:00
|
|
|
<LoginSummaryCard
|
|
|
|
|
latestLogin={latestLogin}
|
|
|
|
|
loading={historyLoading}
|
|
|
|
|
onOpen={() => setHistoryOpen(true)}
|
|
|
|
|
/>
|
2026-05-15 01:36:56 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
<FieldRow
|
|
|
|
|
label="Password Changed"
|
|
|
|
|
value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)}
|
|
|
|
|
/>
|
2026-05-15 01:36:56 -05:00
|
|
|
</div>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
|
|
|
|
|
<LoginHistoryModal
|
2026-05-15 22:45:38 -05:00
|
|
|
history={loginHistory}
|
2026-05-15 01:36:56 -05:00
|
|
|
open={historyOpen}
|
|
|
|
|
onClose={() => setHistoryOpen(false)}
|
2026-05-15 22:45:38 -05:00
|
|
|
onLoaded={setLoginHistory}
|
2026-05-15 01:36:56 -05:00
|
|
|
/>
|
|
|
|
|
</>
|
2026-05-03 19:51:57 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
function EditProfile({ profile, onSaved }: { profile: Profile; onSaved: (p: Profile) => void }) {
|
2026-06-07 01:17:49 -05:00
|
|
|
const [displayName, setDisplayName] = useState(displayNameOf(profile));
|
2026-05-03 19:51:57 -05:00
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-06-07 01:17:49 -05:00
|
|
|
setDisplayName(displayNameOf(profile));
|
2026-07-03 19:47:14 -05:00
|
|
|
}, [profile]);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
const save = async () => {
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
const data = await api.updateProfile({ display_name: displayName.trim() || null });
|
|
|
|
|
toast.success('Profile saved.');
|
|
|
|
|
onSaved(asProfile(data));
|
|
|
|
|
} catch (err) {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to save profile.'));
|
2026-05-03 19:51:57 -05:00
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<SectionCard
|
|
|
|
|
title="Edit Profile"
|
|
|
|
|
icon={User}
|
|
|
|
|
subtitle="Choose how your name appears inside the app."
|
|
|
|
|
>
|
2026-05-03 19:51:57 -05:00
|
|
|
<div className="px-6 py-5 flex flex-col gap-3 sm:flex-row sm:items-end">
|
|
|
|
|
<div className="space-y-1.5 flex-1 max-w-md">
|
2026-07-06 14:23:53 -05:00
|
|
|
<label htmlFor="display-name" className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
Display name
|
|
|
|
|
</label>
|
|
|
|
|
<Input
|
|
|
|
|
id="display-name"
|
|
|
|
|
value={displayName}
|
|
|
|
|
onChange={(e) => setDisplayName(e.target.value)}
|
|
|
|
|
placeholder="Display name"
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
<Button onClick={save} disabled={saving} className="sm:mb-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
{saving ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
|
|
|
Saving…
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
'Save Profile'
|
|
|
|
|
)}
|
2026-05-03 19:51:57 -05:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-12 01:52:48 -05:00
|
|
|
// Exported: rendered on the Settings page ("Notifications" section). Lives here
|
|
|
|
|
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
|
2026-07-04 22:53:51 -05:00
|
|
|
function buildNotificationPayload(form: Settings) {
|
2026-05-03 19:51:57 -05:00
|
|
|
const payload = {
|
2026-07-04 22:53:51 -05:00
|
|
|
email: String(form.email || form.notification_email || ''),
|
2026-05-03 19:51:57 -05:00
|
|
|
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
|
|
|
|
|
notify_3_day: !!(form.notify_3_day ?? form.notify_3d),
|
|
|
|
|
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
|
|
|
|
|
notify_due: !!(form.notify_due ?? form.notify_day_of),
|
|
|
|
|
notify_overdue: !!(form.notify_overdue ?? form.notify_daily_overdue),
|
2026-05-30 14:33:55 -05:00
|
|
|
notify_amount_change: !!(form.notify_amount_change ?? true),
|
2026-07-04 22:53:51 -05:00
|
|
|
enabled: false,
|
|
|
|
|
notify_3d: false,
|
|
|
|
|
notify_1d: false,
|
|
|
|
|
notify_day_of: false,
|
|
|
|
|
notify_daily_overdue: false,
|
2026-05-03 19:51:57 -05:00
|
|
|
};
|
|
|
|
|
payload.enabled = payload.notifications_enabled;
|
|
|
|
|
payload.notify_3d = payload.notify_3_day;
|
|
|
|
|
payload.notify_1d = payload.notify_1_day;
|
|
|
|
|
payload.notify_day_of = payload.notify_due;
|
|
|
|
|
payload.notify_daily_overdue = payload.notify_overdue;
|
2026-06-12 02:08:42 -05:00
|
|
|
return payload;
|
|
|
|
|
}
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
export function NotificationPreferences({ settings }: { settings: Settings }) {
|
|
|
|
|
const [form, setForm] = useState<Settings>(settings);
|
2026-06-12 02:08:42 -05:00
|
|
|
|
|
|
|
|
// Auto-save: toggles persist almost instantly, the email field debounces so
|
|
|
|
|
// we never save a half-typed address. Local form stays the source of truth —
|
|
|
|
|
// no parent refresh that could clobber in-flight edits.
|
2026-07-06 14:23:53 -05:00
|
|
|
const { status, schedule, flush } = useAutoSave((payload) =>
|
|
|
|
|
api.updateProfileSettings(payload).catch((err) => {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to save notification preferences.'));
|
2026-06-12 02:08:42 -05:00
|
|
|
throw err;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const set = (k: string, v: unknown, delay?: number) =>
|
|
|
|
|
setForm((prev) => {
|
|
|
|
|
const next = { ...prev, [k]: v };
|
|
|
|
|
schedule(buildNotificationPayload(next), delay);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-06-12 02:08:42 -05:00
|
|
|
|
|
|
|
|
const payload = buildNotificationPayload(form);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
return (
|
2026-06-12 02:08:42 -05:00
|
|
|
<SectionCard
|
|
|
|
|
title="Notification Preferences"
|
|
|
|
|
icon={Mail}
|
|
|
|
|
subtitle="Manage email reminders for your bills. Changes save automatically."
|
|
|
|
|
action={<SaveStatus status={status} />}
|
|
|
|
|
>
|
2026-05-03 19:51:57 -05:00
|
|
|
<div className="px-6 py-5 space-y-4">
|
|
|
|
|
<div className="space-y-1.5 max-w-md">
|
2026-07-06 14:23:53 -05:00
|
|
|
<label htmlFor="profile-email" className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
Email
|
|
|
|
|
</label>
|
2026-06-12 02:08:42 -05:00
|
|
|
<Input
|
|
|
|
|
id="profile-email"
|
|
|
|
|
type="email"
|
|
|
|
|
value={payload.email}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => set('email', e.target.value, 900)}
|
2026-06-12 02:08:42 -05:00
|
|
|
onBlur={flush}
|
|
|
|
|
placeholder="you@example.com"
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
<CheckRow
|
|
|
|
|
id="n-enabled"
|
|
|
|
|
label="Notifications enabled"
|
|
|
|
|
checked={payload.notifications_enabled}
|
|
|
|
|
onChange={(v) => set('notifications_enabled', v)}
|
|
|
|
|
/>
|
|
|
|
|
<CheckRow
|
|
|
|
|
id="n-3"
|
|
|
|
|
label="Notify 3 days before"
|
|
|
|
|
checked={payload.notify_3_day}
|
|
|
|
|
onChange={(v) => set('notify_3_day', v)}
|
|
|
|
|
disabled={!payload.notifications_enabled}
|
|
|
|
|
/>
|
|
|
|
|
<CheckRow
|
|
|
|
|
id="n-1"
|
|
|
|
|
label="Notify 1 day before"
|
|
|
|
|
checked={payload.notify_1_day}
|
|
|
|
|
onChange={(v) => set('notify_1_day', v)}
|
|
|
|
|
disabled={!payload.notifications_enabled}
|
|
|
|
|
/>
|
|
|
|
|
<CheckRow
|
|
|
|
|
id="n-due"
|
|
|
|
|
label="Notify due date"
|
|
|
|
|
checked={payload.notify_due}
|
|
|
|
|
onChange={(v) => set('notify_due', v)}
|
|
|
|
|
disabled={!payload.notifications_enabled}
|
|
|
|
|
/>
|
|
|
|
|
<CheckRow
|
|
|
|
|
id="n-overdue"
|
|
|
|
|
label="Notify overdue"
|
|
|
|
|
checked={payload.notify_overdue}
|
|
|
|
|
onChange={(v) => set('notify_overdue', v)}
|
|
|
|
|
disabled={!payload.notifications_enabled}
|
|
|
|
|
/>
|
|
|
|
|
<CheckRow
|
|
|
|
|
id="n-amount"
|
|
|
|
|
label="Notify on price changes"
|
|
|
|
|
checked={payload.notify_amount_change}
|
|
|
|
|
onChange={(v) => set('notify_amount_change', v)}
|
|
|
|
|
disabled={!payload.notifications_enabled}
|
|
|
|
|
/>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
interface PushChannel {
|
|
|
|
|
value: string;
|
|
|
|
|
label: string;
|
|
|
|
|
urlLabel: string;
|
|
|
|
|
urlHint: string;
|
|
|
|
|
tokenLabel: string | null;
|
|
|
|
|
chatIdLabel?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const PUSH_CHANNELS: PushChannel[] = [
|
2026-07-06 14:23:53 -05:00
|
|
|
{
|
|
|
|
|
value: 'ntfy',
|
|
|
|
|
label: 'ntfy',
|
|
|
|
|
urlLabel: 'Topic URL',
|
|
|
|
|
urlHint: 'https://pulse.scheller.ltd/bills',
|
|
|
|
|
tokenLabel: 'Access token (optional)',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
value: 'gotify',
|
|
|
|
|
label: 'Gotify',
|
|
|
|
|
urlLabel: 'Server URL',
|
|
|
|
|
urlHint: 'http://192.168.1.11:8077',
|
|
|
|
|
tokenLabel: 'App token',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
value: 'discord',
|
|
|
|
|
label: 'Discord',
|
|
|
|
|
urlLabel: 'Webhook URL',
|
|
|
|
|
urlHint: 'https://discord.com/api/webhooks/…',
|
|
|
|
|
tokenLabel: null,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
value: 'telegram',
|
|
|
|
|
label: 'Telegram',
|
|
|
|
|
urlLabel: 'Server URL / n/a',
|
|
|
|
|
urlHint: 'Leave blank for api.telegram.org',
|
|
|
|
|
tokenLabel: 'Bot token',
|
|
|
|
|
chatIdLabel: 'Chat ID',
|
|
|
|
|
},
|
2026-06-03 21:43:54 -05:00
|
|
|
];
|
|
|
|
|
|
2026-06-12 01:52:48 -05:00
|
|
|
// Exported: rendered on the Settings page ("Notifications" section).
|
2026-07-06 14:23:53 -05:00
|
|
|
export function PushNotifications({
|
|
|
|
|
settings,
|
|
|
|
|
}: {
|
|
|
|
|
settings: Settings;
|
|
|
|
|
onSaved?: (settings?: Settings) => void;
|
|
|
|
|
}) {
|
|
|
|
|
const [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
|
|
|
|
|
const [channel, setChannel] = useState(String(settings.push_channel || 'ntfy'));
|
|
|
|
|
const [url, setUrl] = useState(String(settings.push_url || ''));
|
|
|
|
|
const [token, setToken] = useState(''); // never pre-filled for security
|
|
|
|
|
const [chatId, setChatId] = useState(String(settings.push_chat_id || ''));
|
2026-06-03 21:43:54 -05:00
|
|
|
const [tokenSet, setTokenSet] = useState(!!settings.push_token_set);
|
2026-07-06 14:23:53 -05:00
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
const [testing, setTesting] = useState(false);
|
2026-06-03 21:43:54 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const ch = PUSH_CHANNELS.find((c) => c.value === channel) || PUSH_CHANNELS[0]!;
|
2026-06-03 21:43:54 -05:00
|
|
|
|
2026-06-12 02:08:42 -05:00
|
|
|
// Auto-save. Toggle/channel persist immediately; URL and chat ID debounce.
|
|
|
|
|
// The token is deliberately NOT auto-saved while typing — a half-typed token
|
|
|
|
|
// must never overwrite a working one. It saves on blur, when complete.
|
2026-07-06 14:23:53 -05:00
|
|
|
const buildPatch = (
|
|
|
|
|
over: Partial<{ enabled: boolean; channel: string; url: string; chatId: string }> = {},
|
|
|
|
|
) => {
|
2026-06-12 02:08:42 -05:00
|
|
|
const s = { enabled, channel, url, chatId, ...over };
|
|
|
|
|
return {
|
|
|
|
|
notify_push_enabled: s.enabled,
|
|
|
|
|
push_channel: s.channel,
|
|
|
|
|
push_url: (s.url || '').trim() || null,
|
|
|
|
|
push_chat_id: (s.chatId || '').trim() || null,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
const { status, schedule, flush } = useAutoSave((patch) =>
|
|
|
|
|
api.updateProfileSettings(patch).catch((err) => {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to save push settings.'));
|
2026-06-12 02:08:42 -05:00
|
|
|
throw err;
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const saveToken = async () => {
|
|
|
|
|
const t = token.trim();
|
|
|
|
|
if (!t) return;
|
2026-06-03 21:43:54 -05:00
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
2026-06-12 02:08:42 -05:00
|
|
|
await api.updateProfileSettings({ ...buildPatch(), push_token: t });
|
|
|
|
|
setTokenSet(true);
|
2026-06-03 21:43:54 -05:00
|
|
|
setToken('');
|
2026-06-12 02:08:42 -05:00
|
|
|
toast.success('Token saved.');
|
2026-06-03 21:43:54 -05:00
|
|
|
} catch (err) {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to save token.'));
|
2026-06-03 21:43:54 -05:00
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const test = async () => {
|
|
|
|
|
setTesting(true);
|
|
|
|
|
try {
|
|
|
|
|
await api.testPushNotification();
|
|
|
|
|
toast.success('Test notification sent — check your device.');
|
|
|
|
|
} catch (err) {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Test failed. Check your channel settings.'));
|
2026-06-03 21:43:54 -05:00
|
|
|
} finally {
|
|
|
|
|
setTesting(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-12 02:08:42 -05:00
|
|
|
<SectionCard
|
|
|
|
|
title="Push Notifications"
|
|
|
|
|
icon={Bell}
|
|
|
|
|
subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram. Changes save automatically."
|
|
|
|
|
action={<SaveStatus status={status} />}
|
|
|
|
|
>
|
2026-06-03 21:43:54 -05:00
|
|
|
<div className="px-6 py-5 space-y-5">
|
|
|
|
|
{/* Master toggle — same CheckRow pattern as the email section */}
|
|
|
|
|
<CheckRow
|
|
|
|
|
id="push-enabled"
|
|
|
|
|
label="Enable push notifications"
|
|
|
|
|
checked={enabled}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(v) => {
|
|
|
|
|
setEnabled(v);
|
|
|
|
|
schedule(buildPatch({ enabled: v }), 150);
|
|
|
|
|
}}
|
2026-06-03 21:43:54 -05:00
|
|
|
/>
|
|
|
|
|
{enabled && (
|
|
|
|
|
<p className="text-xs text-muted-foreground -mt-3 pl-1">
|
|
|
|
|
Sent at 6 AM alongside email reminders. Use the test button below to verify immediately.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{enabled && (
|
|
|
|
|
<>
|
|
|
|
|
{/* Channel picker */}
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label className="text-xs font-medium text-muted-foreground">Channel</label>
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
{PUSH_CHANNELS.map((c) => (
|
2026-06-03 21:43:54 -05:00
|
|
|
<button
|
|
|
|
|
key={c.value}
|
|
|
|
|
type="button"
|
2026-07-06 14:23:53 -05:00
|
|
|
onClick={() => {
|
|
|
|
|
setChannel(c.value);
|
|
|
|
|
schedule(buildPatch({ channel: c.value }), 150);
|
|
|
|
|
}}
|
2026-06-03 21:43:54 -05:00
|
|
|
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
|
|
|
channel === c.value
|
|
|
|
|
? 'border-primary bg-primary text-primary-foreground'
|
|
|
|
|
: 'border-border bg-background hover:bg-muted text-foreground'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{c.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Channel-specific inputs */}
|
|
|
|
|
<div className="space-y-3 max-w-md">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label className="text-xs font-medium text-muted-foreground">{ch.urlLabel}</label>
|
|
|
|
|
<Input
|
|
|
|
|
value={url}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => {
|
|
|
|
|
setUrl(e.target.value);
|
|
|
|
|
schedule(buildPatch({ url: e.target.value }), 900);
|
|
|
|
|
}}
|
2026-06-12 02:08:42 -05:00
|
|
|
onBlur={flush}
|
2026-06-03 21:43:54 -05:00
|
|
|
placeholder={ch.urlHint}
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{ch.tokenLabel && (
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
{ch.tokenLabel}
|
|
|
|
|
{tokenSet && !token && (
|
|
|
|
|
<span className="ml-2 text-emerald-600 dark:text-emerald-400">✓ saved</span>
|
|
|
|
|
)}
|
|
|
|
|
</label>
|
|
|
|
|
<Input
|
|
|
|
|
value={token}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => setToken(e.target.value)}
|
2026-06-12 02:08:42 -05:00
|
|
|
onBlur={saveToken}
|
2026-07-06 14:23:53 -05:00
|
|
|
placeholder={
|
|
|
|
|
tokenSet
|
|
|
|
|
? '(leave blank to keep saved token)'
|
|
|
|
|
: 'Enter token — saves when you click away'
|
|
|
|
|
}
|
2026-06-03 21:43:54 -05:00
|
|
|
type="password"
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{ch.chatIdLabel && (
|
|
|
|
|
<div className="space-y-1.5">
|
2026-07-06 14:23:53 -05:00
|
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
{ch.chatIdLabel}
|
|
|
|
|
</label>
|
2026-06-03 21:43:54 -05:00
|
|
|
<Input
|
|
|
|
|
value={chatId}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => {
|
|
|
|
|
setChatId(e.target.value);
|
|
|
|
|
schedule(buildPatch({ chatId: e.target.value }), 900);
|
|
|
|
|
}}
|
2026-06-12 02:08:42 -05:00
|
|
|
onBlur={flush}
|
2026-06-03 21:43:54 -05:00
|
|
|
placeholder="e.g. 123456789"
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">
|
|
|
|
|
Send /start to your bot, then visit{' '}
|
2026-07-06 14:23:53 -05:00
|
|
|
<code className="rounded bg-muted px-1 py-0.5">
|
|
|
|
|
api.telegram.org/bot{'<token>'}/getUpdates
|
|
|
|
|
</code>{' '}
|
|
|
|
|
to find your chat ID.
|
2026-06-03 21:43:54 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Channel hints */}
|
|
|
|
|
{channel === 'ntfy' && (
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">
|
|
|
|
|
Your ntfy server is running at{' '}
|
2026-07-06 14:23:53 -05:00
|
|
|
<code className="rounded bg-muted px-1 py-0.5">pulse.scheller.ltd</code>. Set the
|
|
|
|
|
topic URL to{' '}
|
|
|
|
|
<code className="rounded bg-muted px-1 py-0.5">
|
|
|
|
|
https://pulse.scheller.ltd/your-topic
|
|
|
|
|
</code>
|
|
|
|
|
.
|
2026-06-03 21:43:54 -05:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{channel === 'gotify' && (
|
|
|
|
|
<p className="text-[11px] text-muted-foreground">
|
|
|
|
|
Your Gotify server is at{' '}
|
|
|
|
|
<code className="rounded bg-muted px-1 py-0.5">notify.originalsinners.org</code>.
|
|
|
|
|
Create an app in Gotify and paste the app token above.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-06-12 02:08:42 -05:00
|
|
|
<div className="px-6 py-4 border-t border-border/50 flex items-center gap-3">
|
2026-06-03 21:43:54 -05:00
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={test}
|
2026-06-12 02:08:42 -05:00
|
|
|
disabled={testing || saving || !enabled || !url.trim()}
|
2026-06-03 21:43:54 -05:00
|
|
|
className="gap-1.5"
|
|
|
|
|
>
|
2026-07-06 14:23:53 -05:00
|
|
|
{testing ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
|
|
|
Sending…
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<SendHorizontal className="h-3.5 w-3.5" />
|
|
|
|
|
Send test
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-06-03 21:43:54 -05:00
|
|
|
</Button>
|
2026-06-12 02:08:42 -05:00
|
|
|
<p className="text-[11px] text-muted-foreground">Settings save as you change them.</p>
|
2026-06-03 21:43:54 -05:00
|
|
|
</div>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
function ChangePassword() {
|
|
|
|
|
const [currentPassword, setCurrentPassword] = useState('');
|
|
|
|
|
const [newPassword, setNewPassword] = useState('');
|
|
|
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
const reset = () => {
|
|
|
|
|
setCurrentPassword('');
|
|
|
|
|
setNewPassword('');
|
|
|
|
|
setConfirmPassword('');
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
const submit = async (e: React.FormEvent) => {
|
2026-05-03 19:51:57 -05:00
|
|
|
e.preventDefault();
|
|
|
|
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
|
|
|
|
toast.error('All password fields are required.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
|
|
|
toast.error('New passwords do not match.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
await api.changeProfilePassword({
|
|
|
|
|
current_password: currentPassword,
|
|
|
|
|
new_password: newPassword,
|
|
|
|
|
confirm_new_password: confirmPassword,
|
|
|
|
|
});
|
|
|
|
|
reset();
|
|
|
|
|
toast.success('Password changed.');
|
|
|
|
|
} catch (err) {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to change password.'));
|
2026-05-03 19:51:57 -05:00
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-06-04 04:10:14 -05:00
|
|
|
<>
|
2026-07-06 14:23:53 -05:00
|
|
|
<TotpSection />
|
|
|
|
|
<SectionCard
|
|
|
|
|
title="Change Password"
|
|
|
|
|
icon={KeyRound}
|
|
|
|
|
subtitle="Update your password without exposing it in logs or page state beyond this form."
|
|
|
|
|
>
|
|
|
|
|
<form onSubmit={submit} className="px-6 py-5 grid gap-4 lg:grid-cols-3">
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label htmlFor="current-password" className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
Current password
|
|
|
|
|
</label>
|
|
|
|
|
<Input
|
|
|
|
|
id="current-password"
|
|
|
|
|
type="password"
|
|
|
|
|
autoComplete="current-password"
|
|
|
|
|
value={currentPassword}
|
|
|
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label htmlFor="new-password" className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
New password
|
|
|
|
|
</label>
|
|
|
|
|
<Input
|
|
|
|
|
id="new-password"
|
|
|
|
|
type="password"
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
value={newPassword}
|
|
|
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
<label htmlFor="confirm-password" className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
Confirm new password
|
|
|
|
|
</label>
|
|
|
|
|
<Input
|
|
|
|
|
id="confirm-password"
|
|
|
|
|
type="password"
|
|
|
|
|
autoComplete="new-password"
|
|
|
|
|
value={confirmPassword}
|
|
|
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button type="submit" disabled={saving} className="lg:col-start-3">
|
|
|
|
|
{saving ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
|
|
|
Saving…
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
'Change Password'
|
|
|
|
|
)}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</SectionCard>
|
2026-06-04 04:10:14 -05:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
function CopyButton({ text }: { text: string }) {
|
2026-06-04 04:10:14 -05:00
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
const copy = () => {
|
|
|
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
|
|
|
setCopied(true);
|
|
|
|
|
setTimeout(() => setCopied(false), 2000);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={copy}
|
|
|
|
|
className="ml-1.5 inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{copied ? (
|
|
|
|
|
<Check className="h-3.5 w-3.5 text-emerald-500" />
|
|
|
|
|
) : (
|
|
|
|
|
<Copy className="h-3.5 w-3.5" />
|
|
|
|
|
)}
|
2026-06-04 04:10:14 -05:00
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TotpSection() {
|
|
|
|
|
const { singleUserMode } = useAuth();
|
2026-07-04 22:53:51 -05:00
|
|
|
const [enabled, setEnabled] = useState<boolean | null>(null); // null = loading
|
|
|
|
|
const [step, setStep] = useState<'idle' | 'setup' | 'confirm' | 'recovery' | 'disable'>('idle');
|
2026-07-06 14:23:53 -05:00
|
|
|
const [setupData, setSetupData] = useState<{ secret?: string; qr_data_url?: string } | null>(
|
|
|
|
|
null,
|
|
|
|
|
);
|
2026-06-04 04:10:14 -05:00
|
|
|
const [code, setCode] = useState('');
|
2026-07-04 22:53:51 -05:00
|
|
|
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
|
2026-06-04 04:10:14 -05:00
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
|
|
|
|
const load = useCallback(() => {
|
|
|
|
|
if (singleUserMode) return;
|
2026-07-06 14:23:53 -05:00
|
|
|
api
|
|
|
|
|
.totpStatus()
|
|
|
|
|
.then((raw) => setEnabled(!!(raw as { enabled?: boolean }).enabled))
|
2026-06-04 04:10:14 -05:00
|
|
|
.catch(() => setEnabled(false));
|
|
|
|
|
}, [singleUserMode]);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
load();
|
|
|
|
|
}, [load]);
|
2026-06-04 04:10:14 -05:00
|
|
|
|
|
|
|
|
if (singleUserMode) return null;
|
|
|
|
|
if (enabled === null) return null;
|
|
|
|
|
|
|
|
|
|
const startSetup = async () => {
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const d = (await api.totpSetup()) as { secret?: string; qr_data_url?: string };
|
2026-06-04 04:10:14 -05:00
|
|
|
setSetupData(d);
|
|
|
|
|
setCode('');
|
|
|
|
|
setStep('setup');
|
|
|
|
|
} catch (err) {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to generate setup data.'));
|
2026-06-04 04:10:14 -05:00
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
const confirmEnable = async (e: React.FormEvent) => {
|
2026-06-04 04:10:14 -05:00
|
|
|
e.preventDefault();
|
2026-07-04 22:53:51 -05:00
|
|
|
if (!setupData) return;
|
2026-06-04 04:10:14 -05:00
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
2026-07-06 14:23:53 -05:00
|
|
|
const d = (await api.totpEnable({ secret: setupData.secret, code })) as {
|
|
|
|
|
recovery_codes?: string[];
|
|
|
|
|
};
|
2026-07-04 22:53:51 -05:00
|
|
|
setRecoveryCodes(d.recovery_codes ?? []);
|
2026-06-04 04:10:14 -05:00
|
|
|
setEnabled(true);
|
|
|
|
|
setStep('recovery');
|
|
|
|
|
} catch (err) {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Invalid code. Try again.'));
|
2026-06-04 04:10:14 -05:00
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
const confirmDisable = async (e: React.FormEvent) => {
|
2026-06-04 04:10:14 -05:00
|
|
|
e.preventDefault();
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
await api.totpDisable({ code });
|
|
|
|
|
setEnabled(false);
|
|
|
|
|
setStep('idle');
|
|
|
|
|
setCode('');
|
|
|
|
|
toast.success('Authenticator app removed.');
|
|
|
|
|
} catch (err) {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Invalid code.'));
|
2026-06-04 04:10:14 -05:00
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<SectionCard
|
|
|
|
|
title="Two-Factor Authentication"
|
|
|
|
|
icon={ScanLine}
|
|
|
|
|
subtitle="Require an authenticator app code on every sign-in."
|
|
|
|
|
>
|
2026-06-04 04:10:14 -05:00
|
|
|
<div className="px-6 py-5 space-y-5">
|
|
|
|
|
{/* Idle — enabled status */}
|
|
|
|
|
{step === 'idle' && (
|
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
|
|
|
|
<div className="flex items-center gap-3">
|
2026-07-06 14:23:53 -05:00
|
|
|
<div
|
|
|
|
|
className={`h-2.5 w-2.5 rounded-full ${enabled ? 'bg-emerald-500' : 'bg-muted-foreground/40'}`}
|
|
|
|
|
/>
|
|
|
|
|
<span className="text-sm">
|
|
|
|
|
{enabled ? 'Authenticator app is active' : 'Not configured'}
|
|
|
|
|
</span>
|
2026-06-04 04:10:14 -05:00
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
{enabled ? (
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setCode('');
|
|
|
|
|
setStep('disable');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Remove
|
|
|
|
|
</Button>
|
|
|
|
|
) : (
|
|
|
|
|
<Button size="sm" onClick={startSetup} disabled={saving}>
|
|
|
|
|
{saving ? 'Loading…' : 'Set up'}
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2026-06-04 04:10:14 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Setup — show QR code */}
|
|
|
|
|
{step === 'setup' && setupData && (
|
|
|
|
|
<div className="space-y-5">
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2026-07-06 14:23:53 -05:00
|
|
|
Scan the QR code with Google Authenticator, Authy, 1Password, Bitwarden, or any TOTP
|
|
|
|
|
app. Then enter the 6-digit code to confirm.
|
2026-06-04 04:10:14 -05:00
|
|
|
</p>
|
|
|
|
|
<div className="flex flex-col sm:flex-row gap-6 items-start">
|
2026-07-06 14:23:53 -05:00
|
|
|
<img
|
|
|
|
|
src={setupData.qr_data_url}
|
|
|
|
|
alt="TOTP QR code"
|
|
|
|
|
className="rounded-lg border border-border/60 w-40 h-40 shrink-0"
|
|
|
|
|
/>
|
2026-06-04 04:10:14 -05:00
|
|
|
<div className="space-y-3 min-w-0">
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
Can't scan? Enter this key manually:
|
|
|
|
|
</p>
|
2026-06-04 04:10:14 -05:00
|
|
|
<div className="flex items-center gap-1 font-mono text-sm bg-muted/30 border border-border/60 rounded px-3 py-2 break-all">
|
|
|
|
|
{setupData.secret}
|
2026-07-04 22:53:51 -05:00
|
|
|
<CopyButton text={setupData.secret || ''} />
|
2026-06-04 04:10:14 -05:00
|
|
|
</div>
|
|
|
|
|
<form onSubmit={confirmEnable} className="space-y-3 pt-1">
|
|
|
|
|
<Input
|
|
|
|
|
value={code}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => setCode(e.target.value)}
|
2026-06-04 04:10:14 -05:00
|
|
|
placeholder="000 000"
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
maxLength={7}
|
|
|
|
|
className="text-center tracking-widest font-mono text-lg max-w-[140px]"
|
|
|
|
|
autoFocus
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex gap-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button type="submit" size="sm" disabled={saving || !code.trim()}>
|
|
|
|
|
{saving ? 'Verifying…' : 'Confirm & Enable'}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setStep('idle');
|
|
|
|
|
setSetupData(null);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
2026-06-04 04:10:14 -05:00
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Recovery codes — shown once after enabling */}
|
|
|
|
|
{step === 'recovery' && (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 px-4 py-3">
|
|
|
|
|
<TriangleAlert className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
|
|
|
|
<p className="text-sm text-amber-600 dark:text-amber-400">
|
2026-07-06 14:23:53 -05:00
|
|
|
Save these recovery codes somewhere safe. Each code works once. If you lose your
|
|
|
|
|
phone, use one of these to sign in.
|
2026-06-04 04:10:14 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
2026-07-06 14:23:53 -05:00
|
|
|
{recoveryCodes.map((c) => (
|
|
|
|
|
<div
|
|
|
|
|
key={c}
|
|
|
|
|
className="flex items-center justify-between gap-1 font-mono text-xs bg-muted/30 border border-border/60 rounded px-2.5 py-1.5"
|
|
|
|
|
>
|
2026-06-04 04:10:14 -05:00
|
|
|
{c} <CopyButton text={c} />
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setStep('idle');
|
|
|
|
|
setRecoveryCodes([]);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Done — I've saved these
|
|
|
|
|
</Button>
|
2026-06-04 04:10:14 -05:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Disable — requires TOTP code */}
|
|
|
|
|
{step === 'disable' && (
|
|
|
|
|
<div className="space-y-4">
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Enter the current code from your authenticator app to remove 2FA.
|
|
|
|
|
</p>
|
2026-06-04 04:10:14 -05:00
|
|
|
<form onSubmit={confirmDisable} className="flex items-end gap-3">
|
|
|
|
|
<div className="space-y-1.5">
|
2026-07-06 14:23:53 -05:00
|
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
|
|
|
Authenticator code
|
|
|
|
|
</label>
|
2026-06-04 04:10:14 -05:00
|
|
|
<Input
|
|
|
|
|
value={code}
|
2026-07-06 14:23:53 -05:00
|
|
|
onChange={(e) => setCode(e.target.value)}
|
2026-06-04 04:10:14 -05:00
|
|
|
placeholder="000 000"
|
|
|
|
|
autoComplete="one-time-code"
|
|
|
|
|
maxLength={7}
|
|
|
|
|
className="text-center tracking-widest font-mono text-lg max-w-[140px]"
|
|
|
|
|
autoFocus
|
|
|
|
|
required
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-07-06 14:23:53 -05:00
|
|
|
<Button
|
|
|
|
|
type="submit"
|
|
|
|
|
variant="destructive"
|
|
|
|
|
size="sm"
|
|
|
|
|
disabled={saving || !code.trim()}
|
|
|
|
|
>
|
|
|
|
|
{saving ? 'Removing…' : 'Remove 2FA'}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setStep('idle');
|
|
|
|
|
setCode('');
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
2026-06-04 04:10:14 -05:00
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</SectionCard>
|
2026-05-03 19:51:57 -05:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
function PrivacySettings({
|
|
|
|
|
settings,
|
|
|
|
|
onSaved,
|
|
|
|
|
}: {
|
|
|
|
|
settings: Settings;
|
|
|
|
|
onSaved: (settings: Settings) => void;
|
|
|
|
|
}) {
|
2026-06-07 21:18:02 -05:00
|
|
|
const [geoEnabled, setGeoEnabled] = useState(!!settings.geolocation_enabled);
|
|
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
useEffect(() => {
|
|
|
|
|
setGeoEnabled(!!settings.geolocation_enabled);
|
|
|
|
|
}, [settings.geolocation_enabled]);
|
2026-06-07 21:18:02 -05:00
|
|
|
|
|
|
|
|
const save = async () => {
|
|
|
|
|
setSaving(true);
|
|
|
|
|
try {
|
|
|
|
|
await api.updateProfileSettings({ geolocation_enabled: geoEnabled });
|
|
|
|
|
toast.success('Privacy settings saved.');
|
|
|
|
|
onSaved({ ...settings, geolocation_enabled: geoEnabled });
|
|
|
|
|
} catch (err) {
|
2026-07-04 22:53:51 -05:00
|
|
|
toast.error(errMessage(err, 'Failed to save privacy settings.'));
|
2026-06-07 21:18:02 -05:00
|
|
|
} finally {
|
|
|
|
|
setSaving(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const changed = geoEnabled !== !!settings.geolocation_enabled;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-07-06 14:23:53 -05:00
|
|
|
<SectionCard
|
|
|
|
|
title="Privacy"
|
|
|
|
|
icon={Lock}
|
|
|
|
|
subtitle="Control what login data is collected about you."
|
|
|
|
|
>
|
2026-06-07 21:18:02 -05:00
|
|
|
<div className="px-6 py-5 space-y-4">
|
|
|
|
|
<CheckRow
|
|
|
|
|
id="geo-enabled"
|
|
|
|
|
label="Login geolocation"
|
|
|
|
|
checked={geoEnabled}
|
|
|
|
|
onChange={setGeoEnabled}
|
|
|
|
|
/>
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
When on, your login IP is resolved to a city/region via{' '}
|
2026-07-06 14:23:53 -05:00
|
|
|
<span className="font-mono text-[11px]">ip-api.com</span> over plain HTTP. Location data
|
|
|
|
|
is encrypted at rest and visible only to you. Turn off to keep all login data on-device.
|
2026-06-07 21:18:02 -05:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="px-6 py-4 border-t border-border/50 flex justify-end">
|
|
|
|
|
<Button onClick={save} disabled={saving || !changed}>
|
2026-07-06 14:23:53 -05:00
|
|
|
{saving ? (
|
|
|
|
|
<>
|
|
|
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
|
|
|
Saving…
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
'Save Privacy Settings'
|
|
|
|
|
)}
|
2026-06-07 21:18:02 -05:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</SectionCard>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
function ProfileNav() {
|
2026-07-04 22:53:51 -05:00
|
|
|
const items: [string, string][] = [
|
2026-05-03 19:51:57 -05:00
|
|
|
['#account', 'Account'],
|
|
|
|
|
['#security', 'Security'],
|
2026-06-07 21:18:02 -05:00
|
|
|
['#privacy', 'Privacy'],
|
2026-05-03 19:51:57 -05:00
|
|
|
];
|
|
|
|
|
return (
|
|
|
|
|
<div className="mb-6 flex flex-wrap gap-2">
|
|
|
|
|
{items.map(([href, label]) => (
|
|
|
|
|
<a
|
|
|
|
|
key={href}
|
|
|
|
|
href={href}
|
|
|
|
|
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
{label}
|
|
|
|
|
</a>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function ProfilePage() {
|
|
|
|
|
const { setUser, refresh } = useAuth();
|
2026-07-04 22:53:51 -05:00
|
|
|
const [profile, setProfile] = useState<Profile>({});
|
|
|
|
|
const [settings, setSettings] = useState<Settings>({});
|
2026-05-03 19:51:57 -05:00
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
let mounted = true;
|
2026-07-06 14:23:53 -05:00
|
|
|
Promise.all([api.profile(), api.profileSettings()])
|
2026-05-03 19:51:57 -05:00
|
|
|
.then(([profileData, settingsData]) => {
|
|
|
|
|
if (!mounted) return;
|
|
|
|
|
setProfile(asProfile(profileData));
|
|
|
|
|
setSettings(asSettings(settingsData));
|
|
|
|
|
})
|
2026-07-06 14:23:53 -05:00
|
|
|
.catch((err) => toast.error(errMessage(err, 'Failed to load profile.')))
|
|
|
|
|
.finally(() => {
|
|
|
|
|
if (mounted) setLoading(false);
|
|
|
|
|
});
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
return () => {
|
|
|
|
|
mounted = false;
|
|
|
|
|
};
|
2026-05-03 19:51:57 -05:00
|
|
|
}, []);
|
|
|
|
|
|
2026-07-04 22:53:51 -05:00
|
|
|
const handleProfileSaved = (nextProfile: Profile) => {
|
2026-07-06 14:23:53 -05:00
|
|
|
setProfile((prev) => ({ ...prev, ...nextProfile }));
|
|
|
|
|
setUser((prev) => (prev ? ({ ...prev, ...nextProfile } as AuthUser) : prev));
|
2026-05-03 19:51:57 -05:00
|
|
|
refresh();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
2026-05-04 23:34:24 -05:00
|
|
|
<div className="mx-auto w-full max-w-5xl">
|
|
|
|
|
<div className="mb-6 flex items-start justify-between gap-4">
|
2026-05-03 19:51:57 -05:00
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
|
2026-07-06 14:23:53 -05:00
|
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
|
|
|
Manage your account, security, and privacy. Notification preferences live in Settings.
|
|
|
|
|
</p>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
<div className="hidden sm:flex items-center gap-2 rounded-full border border-border bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
|
|
|
|
|
<ShieldCheck className="h-3.5 w-3.5 text-emerald-500" />
|
|
|
|
|
User-owned data only
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<ProfileNav />
|
|
|
|
|
|
2026-05-04 23:34:24 -05:00
|
|
|
<div className="space-y-5">
|
2026-05-03 19:51:57 -05:00
|
|
|
<div id="account" className="scroll-mt-6 space-y-6">
|
|
|
|
|
<ProfileSummary profile={profile} loading={loading} />
|
|
|
|
|
{!loading && <EditProfile profile={profile} onSaved={handleProfileSaved} />}
|
|
|
|
|
</div>
|
|
|
|
|
<div id="security" className="scroll-mt-6">
|
|
|
|
|
<ChangePassword />
|
|
|
|
|
</div>
|
2026-06-12 01:52:48 -05:00
|
|
|
{/* Notification preferences moved to Settings → Notifications */}
|
2026-06-07 21:18:02 -05:00
|
|
|
<div id="privacy" className="scroll-mt-6">
|
|
|
|
|
{!loading && <PrivacySettings settings={settings} onSaved={setSettings} />}
|
|
|
|
|
</div>
|
2026-05-03 19:51:57 -05:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|