365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
import { useEffect, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
User, Mail, KeyRound, ShieldCheck, Loader2,
|
|
} 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 {
|
|
ImportSpreadsheetSection,
|
|
DownloadMyDataSection,
|
|
ImportMyDataSection,
|
|
ImportHistorySection as DataImportHistorySection,
|
|
} from '@/pages/DataPage';
|
|
|
|
function asProfile(data) {
|
|
return data?.profile || data?.user || data || {};
|
|
}
|
|
|
|
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="table-surface">
|
|
<div className="px-6 py-4 border-b border-border/50 flex items-center gap-3">
|
|
<div className="h-9 w-9 rounded-lg 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 ProfileSummary({ profile, 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>
|
|
);
|
|
}
|
|
|
|
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 xl:grid-cols-5">
|
|
<FieldRow label="Username" value={profile.username} />
|
|
<FieldRow label="Display Name" value={profile.display_name || profile.displayName} />
|
|
<FieldRow label="Role" value={profile.role} />
|
|
<FieldRow label="Last Login" value={formatDateTime(profile.last_login_at || profile.last_login)} />
|
|
<FieldRow label="Password Changed" value={formatDateTime(profile.last_password_change_at || profile.password_changed_at)} />
|
|
</div>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
function EditProfile({ profile, onSaved }) {
|
|
const [displayName, setDisplayName] = useState(profile.display_name || profile.displayName || '');
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setDisplayName(profile.display_name || profile.displayName || '');
|
|
}, [profile.display_name, profile.displayName]);
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
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 || '',
|
|
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),
|
|
};
|
|
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} />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<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-[1fr_1fr_1fr_auto] lg:items-end">
|
|
<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}>
|
|
{saving ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Saving…</> : 'Change Password'}
|
|
</Button>
|
|
</form>
|
|
</SectionCard>
|
|
);
|
|
}
|
|
|
|
function ProfileNav() {
|
|
const items = [
|
|
['#account', 'Account'],
|
|
['#security', 'Security'],
|
|
['#notifications', 'Notifications'],
|
|
['#data', 'My Data'],
|
|
];
|
|
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>
|
|
);
|
|
}
|
|
|
|
function DataManagement() {
|
|
const [history, setHistory] = useState(null);
|
|
const [historyLoading, setHistoryLoading] = useState(true);
|
|
|
|
const loadHistory = async () => {
|
|
setHistoryLoading(true);
|
|
try {
|
|
const { history } = await api.importHistory();
|
|
setHistory(history);
|
|
} catch {
|
|
setHistory([]);
|
|
} finally {
|
|
setHistoryLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { loadHistory(); }, []);
|
|
|
|
return (
|
|
<div id="data" className="scroll-mt-6 space-y-6">
|
|
<div>
|
|
<h2 className="text-sm font-semibold tracking-tight">My Data</h2>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
Import spreadsheet history, import your SQLite data export, and export your user-owned records.
|
|
</p>
|
|
</div>
|
|
<div className="grid gap-6 lg:grid-cols-2 lg:items-start">
|
|
<ImportSpreadsheetSection onHistoryRefresh={loadHistory} />
|
|
<ImportMyDataSection onHistoryRefresh={loadHistory} />
|
|
</div>
|
|
<DownloadMyDataSection />
|
|
<DataImportHistorySection history={history} loading={historyLoading} onRefresh={loadHistory} />
|
|
</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>
|
|
<div className="mb-8 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, notifications, password, exports, and import history.</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-6">
|
|
<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>
|
|
<div id="notifications" className="scroll-mt-6">
|
|
{!loading && <NotificationPreferences settings={settings} onSaved={setSettings} />}
|
|
</div>
|
|
<DataManagement />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|