BillTracker/client/pages/ProfilePage.jsx

981 lines
39 KiB
JavaScript

import React, { useEffect, useState, useCallback } from 'react';
import { toast } from 'sonner';
import {
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, Lock,
} from 'lucide-react';
import { api } from '@/api';
import { useAuth } from '@/hooks/useAuth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
function asProfile(data) {
return data?.profile || data?.user || data || {};
}
function displayNameOf(profile) {
return profile.display_name || profile.displayName || profile.name || '';
}
export function asSettings(data) {
return data?.settings || data?.notifications || data || {};
}
function formatDateTime(value) {
if (!value) return 'Not recorded';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return String(value);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
+ ' '
+ d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function SectionCard({ title, icon: Icon, subtitle, children }) {
return (
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
<div className="h-9 w-9 rounded-lg border border-primary/15 bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0">
<h2 className="text-xs font-bold uppercase tracking-widest text-muted-foreground">{title}</h2>
{subtitle && <p className="text-sm text-muted-foreground mt-0.5">{subtitle}</p>}
</div>
</div>
<div>{children}</div>
</section>
);
}
function FieldRow({ label, value }) {
return (
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{label}</p>
<p className="mt-1 text-sm font-medium text-foreground truncate">{value || 'Not set'}</p>
</div>
);
}
function CheckRow({ id, label, checked, onChange, disabled }) {
return (
<label htmlFor={id} className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-muted/20 px-4 py-3">
<span className="text-sm font-medium">{label}</span>
<Switch id={id} checked={!!checked} onCheckedChange={onChange} disabled={disabled} />
</label>
);
}
function parseUserAgent(ua) {
if (!ua) return { browser: 'Unknown', os: 'Unknown', mobile: false };
const s = ua;
const mobile = /iPhone|iPad|Android|Mobile/i.test(s);
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';
return { browser, os, mobile };
}
function deviceLabel(type) {
if (type === 'mobile') return 'Mobile';
if (type === 'tablet') return 'Tablet';
if (type === 'api') return 'API client';
return 'Desktop';
}
function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }) {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) return;
if (providedHistory?.length) {
setHistory(providedHistory);
return;
}
setLoading(true);
api.loginHistory()
.then(d => {
const rows = d.history ?? [];
setHistory(rows);
onLoaded?.(rows);
})
.catch(err => {
setHistory([]);
toast.error(err.message || 'Failed to load login history.');
})
.finally(() => setLoading(false));
}, [open, providedHistory, onLoaded]);
return (
<Dialog open={open} onOpenChange={v => { if (!v) onClose(); }}>
<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>
<DialogDescription className="sr-only">
Your last 10 sign-in events
</DialogDescription>
</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 ? (
<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>
)}
</p>
<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>
)}
</p>
{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>
</div>
);
})}
</div>
<p className="text-[10px] text-muted-foreground/60 text-center pt-1">
Showing up to 10 most recent events including failed attempts. Device ID is a short privacy-preserving identifier.
</p>
<p className="text-[10px] text-muted-foreground/60 text-center">
This information is shown only to you and is encrypted at rest. It is not shared with admins.
</p>
</DialogContent>
</Dialog>
);
}
function LoginSummaryCard({ latestLogin, loading, onOpen }) {
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">
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <DeviceIcon className="h-4 w-4" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Last Login</p>
<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>
{(latestLogin.location_city || latestLogin.location_country) && (
<p className="mt-0.5 truncate text-xs text-muted-foreground/70">
{[latestLogin.location_city, latestLogin.location_region, latestLogin.location_country].filter(Boolean).join(', ')}
</p>
)}
{latestLogin.ip_address && (
<p className="mt-0.5 truncate font-mono text-[11px] text-muted-foreground">
{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>
);
}
function ProfileSummary({ profile, loading }) {
const [historyOpen, setHistoryOpen] = useState(false);
const [loginHistory, setLoginHistory] = useState([]);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (loading) return;
setHistoryLoading(true);
api.loginHistory()
.then(d => setLoginHistory(d.history ?? []))
.catch(err => {
setLoginHistory([]);
toast.error(err.message || 'Failed to load login history.');
})
.finally(() => setHistoryLoading(false));
}, [loading]);
if (loading) {
return (
<SectionCard title="Profile Summary" icon={User}>
<div className="px-6 py-6 text-sm text-muted-foreground">Loading profile</div>
</SectionCard>
);
}
// Show the most recent SUCCESSFUL login in the summary card (not a failed attempt)
const latestLogin = loginHistory.find(l => l.success !== false) || null;
return (
<>
<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} />
<FieldRow label="Display Name" value={displayNameOf(profile)} />
<FieldRow label="Role" value={profile.role} />
<LoginSummaryCard
latestLogin={latestLogin}
loading={historyLoading}
onOpen={() => setHistoryOpen(true)}
/>
<FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} />
</div>
</SectionCard>
<LoginHistoryModal
history={loginHistory}
open={historyOpen}
onClose={() => setHistoryOpen(false)}
onLoaded={setLoginHistory}
/>
</>
);
}
function EditProfile({ profile, onSaved }) {
const [displayName, setDisplayName] = useState(displayNameOf(profile));
const [saving, setSaving] = useState(false);
useEffect(() => {
setDisplayName(displayNameOf(profile));
}, [profile.display_name, profile.displayName, profile.name]);
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) {
toast.error(err.message || 'Failed to save profile.');
} finally {
setSaving(false);
}
};
return (
<SectionCard title="Edit Profile" icon={User} subtitle="Choose how your name appears inside the app.">
<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">
<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" />
</div>
<Button onClick={save} disabled={saving} className="sm:mb-0">
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save Profile'}
</Button>
</div>
</SectionCard>
);
}
// Exported: rendered on the Settings page ("Notifications" section). Lives here
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
export function NotificationPreferences({ settings, onSaved }) {
const [form, setForm] = useState(settings);
const [saving, setSaving] = useState(false);
useEffect(() => setForm(settings), [settings]);
const set = (k, v) => setForm(prev => ({ ...prev, [k]: v }));
const payload = {
email: form.email || form.notification_email || '',
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),
notify_amount_change: !!(form.notify_amount_change ?? true),
};
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;
const save = async () => {
setSaving(true);
try {
const data = await api.updateProfileSettings(payload);
toast.success('Notification preferences saved.');
onSaved(asSettings(data));
} catch (err) {
toast.error(err.message || 'Failed to save notification preferences.');
} finally {
setSaving(false);
}
};
return (
<SectionCard title="Notification Preferences" icon={Mail} subtitle="Manage email reminders for your bills.">
<div className="px-6 py-5 space-y-4">
<div className="space-y-1.5 max-w-md">
<label htmlFor="profile-email" className="text-xs font-medium text-muted-foreground">Email</label>
<Input id="profile-email" type="email" value={payload.email} onChange={e => set('email', e.target.value)} placeholder="you@example.com" />
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<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} />
</div>
</div>
<div className="px-6 py-4 border-t border-border/50 flex justify-end">
<Button onClick={save} disabled={saving}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save Preferences'}
</Button>
</div>
</SectionCard>
);
}
const PUSH_CHANNELS = [
{ 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' },
];
// Exported: rendered on the Settings page ("Notifications" section).
export function PushNotifications({ settings, onSaved }) {
const [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
const [channel, setChannel] = useState(settings.push_channel || 'ntfy');
const [url, setUrl] = useState(settings.push_url || '');
const [token, setToken] = useState(''); // never pre-filled for security
const [chatId, setChatId] = useState(settings.push_chat_id || '');
const [tokenSet, setTokenSet] = useState(!!settings.push_token_set);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const ch = PUSH_CHANNELS.find(c => c.value === channel) || PUSH_CHANNELS[0];
const save = async () => {
setSaving(true);
try {
const patch = {
notify_push_enabled: enabled,
push_channel: channel,
push_url: url.trim() || null,
push_chat_id: chatId.trim() || null,
};
if (token.trim()) patch.push_token = token.trim();
await api.updateProfileSettings(patch);
setTokenSet(!!token.trim() || tokenSet);
setToken('');
toast.success('Push notification settings saved.');
onSaved?.();
} catch (err) {
toast.error(err.message || 'Failed to save push settings.');
} finally {
setSaving(false);
}
};
const test = async () => {
setTesting(true);
try {
await api.testPushNotification();
toast.success('Test notification sent — check your device.');
} catch (err) {
toast.error(err.message || 'Test failed. Check your channel settings.');
} finally {
setTesting(false);
}
};
return (
<SectionCard title="Push Notifications" icon={Bell} subtitle="Get bill reminders on your phone via ntfy, Gotify, Discord, or Telegram.">
<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}
onChange={setEnabled}
/>
{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">
{PUSH_CHANNELS.map(c => (
<button
key={c.value}
type="button"
onClick={() => setChannel(c.value)}
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}
onChange={e => setUrl(e.target.value)}
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}
onChange={e => setToken(e.target.value)}
placeholder={tokenSet ? '(leave blank to keep saved token)' : 'Enter token…'}
type="password"
autoComplete="off"
/>
</div>
)}
{ch.chatIdLabel && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{ch.chatIdLabel}</label>
<Input
value={chatId}
onChange={e => setChatId(e.target.value)}
placeholder="e.g. 123456789"
autoComplete="off"
/>
<p className="text-[11px] text-muted-foreground">
Send /start to your bot, then visit{' '}
<code className="rounded bg-muted px-1 py-0.5">api.telegram.org/bot{'<token>'}/getUpdates</code>
{' '}to find your chat ID.
</p>
</div>
)}
</div>
{/* Channel hints */}
{channel === 'ntfy' && (
<p className="text-[11px] text-muted-foreground">
Your ntfy server is running at{' '}
<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>.
</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>
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between gap-3">
<Button
type="button"
variant="outline"
size="sm"
onClick={test}
disabled={testing || !enabled || !url.trim()}
className="gap-1.5"
>
{testing
? <><Loader2 className="h-3.5 w-3.5 animate-spin" />Sending</>
: <><SendHorizontal className="h-3.5 w-3.5" />Send test</>}
</Button>
<Button onClick={save} disabled={saving}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save'}
</Button>
</div>
</SectionCard>
);
}
function ChangePassword() {
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [saving, setSaving] = useState(false);
const reset = () => {
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
};
const submit = async (e) => {
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) {
toast.error(err.message || 'Failed to change password.');
} finally {
setSaving(false);
}
};
return (
<>
<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>
</>
);
}
function CopyButton({ text }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<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" />}
</button>
);
}
function TotpSection() {
const { singleUserMode } = useAuth();
const [enabled, setEnabled] = useState(null); // null = loading
const [step, setStep] = useState('idle'); // idle | setup | confirm | recovery | disable
const [setupData, setSetupData] = useState(null); // { secret, qr_data_url }
const [code, setCode] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState([]);
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
if (singleUserMode) return;
api.totpStatus()
.then(d => setEnabled(d.enabled))
.catch(() => setEnabled(false));
}, [singleUserMode]);
useEffect(() => { load(); }, [load]);
if (singleUserMode) return null;
if (enabled === null) return null;
const startSetup = async () => {
setSaving(true);
try {
const d = await api.totpSetup();
setSetupData(d);
setCode('');
setStep('setup');
} catch (err) {
toast.error(err.message || 'Failed to generate setup data.');
} finally {
setSaving(false);
}
};
const confirmEnable = async (e) => {
e.preventDefault();
setSaving(true);
try {
const d = await api.totpEnable({ secret: setupData.secret, code });
setRecoveryCodes(d.recovery_codes);
setEnabled(true);
setStep('recovery');
} catch (err) {
toast.error(err.message || 'Invalid code. Try again.');
} finally {
setSaving(false);
}
};
const confirmDisable = async (e) => {
e.preventDefault();
setSaving(true);
try {
await api.totpDisable({ code });
setEnabled(false);
setStep('idle');
setCode('');
toast.success('Authenticator app removed.');
} catch (err) {
toast.error(err.message || 'Invalid code.');
} finally {
setSaving(false);
}
};
return (
<SectionCard title="Two-Factor Authentication" icon={ScanLine} subtitle="Require an authenticator app code on every sign-in.">
<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">
<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>
</div>
{enabled
? <Button variant="outline" size="sm" onClick={() => { setCode(''); setStep('disable'); }}>Remove</Button>
: <Button size="sm" onClick={startSetup} disabled={saving}>{saving ? 'Loading…' : 'Set up'}</Button>
}
</div>
)}
{/* Setup — show QR code */}
{step === 'setup' && setupData && (
<div className="space-y-5">
<p className="text-sm text-muted-foreground">
Scan the QR code with Google Authenticator, Authy, 1Password, Bitwarden, or any TOTP app. Then enter the 6-digit code to confirm.
</p>
<div className="flex flex-col sm:flex-row gap-6 items-start">
<img src={setupData.qr_data_url} alt="TOTP QR code" className="rounded-lg border border-border/60 w-40 h-40 shrink-0" />
<div className="space-y-3 min-w-0">
<p className="text-xs text-muted-foreground">Can't scan? Enter this key manually:</p>
<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}
<CopyButton text={setupData.secret} />
</div>
<form onSubmit={confirmEnable} className="space-y-3 pt-1">
<Input
value={code}
onChange={e => setCode(e.target.value)}
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">
<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>
</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">
Save these recovery codes somewhere safe. Each code works once. If you lose your phone, use one of these to sign in.
</p>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{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">
{c} <CopyButton text={c} />
</div>
))}
</div>
<Button size="sm" onClick={() => { setStep('idle'); setRecoveryCodes([]); }}>Done — I've saved these</Button>
</div>
)}
{/* Disable — requires TOTP code */}
{step === 'disable' && (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">Enter the current code from your authenticator app to remove 2FA.</p>
<form onSubmit={confirmDisable} className="flex items-end gap-3">
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Authenticator code</label>
<Input
value={code}
onChange={e => setCode(e.target.value)}
placeholder="000 000"
autoComplete="one-time-code"
maxLength={7}
className="text-center tracking-widest font-mono text-lg max-w-[140px]"
autoFocus
required
/>
</div>
<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>
</form>
</div>
)}
</div>
</SectionCard>
);
}
function PrivacySettings({ settings, onSaved }) {
const [geoEnabled, setGeoEnabled] = useState(!!settings.geolocation_enabled);
const [saving, setSaving] = useState(false);
useEffect(() => { setGeoEnabled(!!settings.geolocation_enabled); }, [settings.geolocation_enabled]);
const save = async () => {
setSaving(true);
try {
await api.updateProfileSettings({ geolocation_enabled: geoEnabled });
toast.success('Privacy settings saved.');
onSaved({ ...settings, geolocation_enabled: geoEnabled });
} catch (err) {
toast.error(err.message || 'Failed to save privacy settings.');
} finally {
setSaving(false);
}
};
const changed = geoEnabled !== !!settings.geolocation_enabled;
return (
<SectionCard title="Privacy" icon={Lock} subtitle="Control what login data is collected about you.">
<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{' '}
<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.
</p>
</div>
<div className="px-6 py-4 border-t border-border/50 flex justify-end">
<Button onClick={save} disabled={saving || !changed}>
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving</> : 'Save Privacy Settings'}
</Button>
</div>
</SectionCard>
);
}
function ProfileNav() {
const items = [
['#account', 'Account'],
['#security', 'Security'],
['#privacy', 'Privacy'],
];
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();
const [profile, setProfile] = useState({});
const [settings, setSettings] = useState({});
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
Promise.all([
api.profile(),
api.profileSettings(),
])
.then(([profileData, settingsData]) => {
if (!mounted) return;
setProfile(asProfile(profileData));
setSettings(asSettings(settingsData));
})
.catch(err => toast.error(err.message || 'Failed to load profile.'))
.finally(() => mounted && setLoading(false));
return () => { mounted = false; };
}, []);
const handleProfileSaved = (nextProfile) => {
setProfile(prev => ({ ...prev, ...nextProfile }));
setUser(prev => prev ? { ...prev, ...nextProfile } : prev);
refresh();
};
return (
<div className="mx-auto w-full max-w-5xl">
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
<p className="text-sm text-muted-foreground mt-0.5">Manage your account, security, and privacy. Notification preferences live in Settings.</p>
</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 />
<div className="space-y-5">
<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>
{/* Notification preferences moved to Settings → Notifications */}
<div id="privacy" className="scroll-mt-6">
{!loading && <PrivacySettings settings={settings} onSaved={setSettings} />}
</div>
</div>
</div>
);
}