refactor(ts): ProfilePage → TypeScript; widen AuthContext setUser to accept updaters

ProfilePage (profile summary + login history + edit + notifications + push +
2FA + privacy) — exports asSettings/NotificationPreferences/PushNotifications
now typed, so SettingsPage picks up real types. Widened useAuth setUser to
Dispatch<SetStateAction<User|null|undefined>> to allow the updater form.
This commit is contained in:
null 2026-07-04 22:53:51 -05:00
parent 818df6d410
commit 82bd13036a
2 changed files with 139 additions and 70 deletions

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, type Dispatch, type SetStateAction, type ReactNode } from 'react';
import { api } from '@/api';
export interface User {
@ -21,7 +21,7 @@ interface MeResponse {
interface AuthContextValue {
user: User | null | undefined; // undefined = loading
singleUserMode: boolean;
setUser: (user: User | null | undefined) => void;
setUser: Dispatch<SetStateAction<User | null | undefined>>;
logout: () => void | Promise<void>;
refresh: () => void;
hasNewVersion: boolean;

View File

@ -1,11 +1,12 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, type ComponentType, type ReactNode } 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 { errMessage } from '@/lib/utils';
import { useAuth, type User as AuthUser } from '@/hooks/useAuth';
import { useAutoSave } from '@/hooks/useAutoSave';
import { SaveStatus } from '@/components/ui/save-status';
import { Button } from '@/components/ui/button';
@ -15,28 +16,66 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from '@/components/ui/dialog';
function asProfile(data) {
return data?.profile || data?.user || data || {};
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;
}
function displayNameOf(profile) {
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;
return ((d as { profile?: Profile })?.profile || (d as { user?: Profile })?.user || (d as Profile) || {}) as Profile;
}
function displayNameOf(profile: Profile): string {
return profile.display_name || profile.displayName || profile.name || '';
}
export function asSettings(data) {
return data?.settings || data?.notifications || data || {};
export function asSettings(data: unknown): Settings {
const d = data as { settings?: Settings; notifications?: Settings } | Settings | null | undefined;
return ((d as { settings?: Settings })?.settings || (d as { notifications?: Settings })?.notifications || (d as Settings) || {}) as Settings;
}
function formatDateTime(value) {
function formatDateTime(value: unknown): string {
if (!value) return 'Not recorded';
const d = new Date(value);
const d = new Date(value as string | number);
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, action, children }) {
function SectionCard({ title, icon: Icon, subtitle, action, children }: {
title: ReactNode;
icon: ComponentType<{ className?: string }>;
subtitle?: ReactNode;
action?: ReactNode;
children: ReactNode;
}) {
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">
@ -54,7 +93,7 @@ function SectionCard({ title, icon: Icon, subtitle, action, children }) {
);
}
function FieldRow({ label, value }) {
function FieldRow({ label, value }: { label: ReactNode; value?: ReactNode }) {
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>
@ -63,7 +102,13 @@ function FieldRow({ label, value }) {
);
}
function CheckRow({ id, label, checked, onChange, disabled }) {
function CheckRow({ id, label, checked, onChange, disabled }: {
id: string;
label: ReactNode;
checked?: boolean;
onChange?: (v: boolean) => void;
disabled?: boolean;
}) {
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>
@ -72,7 +117,7 @@ function CheckRow({ id, label, checked, onChange, disabled }) {
);
}
function parseUserAgent(ua) {
function parseUserAgent(ua?: string | null): { browser: string; os: string; mobile: boolean } {
if (!ua) return { browser: 'Unknown', os: 'Unknown', mobile: false };
const s = ua;
const mobile = /iPhone|iPad|Android|Mobile/i.test(s);
@ -92,15 +137,20 @@ function parseUserAgent(ua) {
return { browser, os, mobile };
}
function deviceLabel(type) {
function deviceLabel(type?: string): string {
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([]);
function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }: {
history?: LoginEntry[];
open: boolean;
onClose: () => void;
onLoaded?: (rows: LoginEntry[]) => void;
}) {
const [history, setHistory] = useState<LoginEntry[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
@ -111,14 +161,14 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
}
setLoading(true);
api.loginHistory()
.then(d => {
const rows = d.history ?? [];
.then(raw => {
const rows = (raw as { history?: LoginEntry[] }).history ?? [];
setHistory(rows);
onLoaded?.(rows);
})
.catch(err => {
setHistory([]);
toast.error(err.message || 'Failed to load login history.');
toast.error(errMessage(err, 'Failed to load login history.'));
})
.finally(() => setLoading(false));
}, [open, providedHistory, onLoaded]);
@ -214,7 +264,11 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
);
}
function LoginSummaryCard({ latestLogin, loading, onOpen }) {
function LoginSummaryCard({ latestLogin, loading, onOpen }: {
latestLogin: LoginEntry | null;
loading: boolean;
onOpen: () => void;
}) {
const parsed = parseUserAgent(latestLogin?.user_agent);
const browser = latestLogin?.browser || parsed.browser;
const os = latestLogin?.os || parsed.os;
@ -271,19 +325,19 @@ function LoginSummaryCard({ latestLogin, loading, onOpen }) {
);
}
function ProfileSummary({ profile, loading }) {
function ProfileSummary({ profile, loading }: { profile: Profile; loading: boolean }) {
const [historyOpen, setHistoryOpen] = useState(false);
const [loginHistory, setLoginHistory] = useState([]);
const [loginHistory, setLoginHistory] = useState<LoginEntry[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
useEffect(() => {
if (loading) return;
setHistoryLoading(true);
api.loginHistory()
.then(d => setLoginHistory(d.history ?? []))
.then(raw => setLoginHistory((raw as { history?: LoginEntry[] }).history ?? []))
.catch(err => {
setLoginHistory([]);
toast.error(err.message || 'Failed to load login history.');
toast.error(errMessage(err, 'Failed to load login history.'));
})
.finally(() => setHistoryLoading(false));
}, [loading]);
@ -327,7 +381,7 @@ function ProfileSummary({ profile, loading }) {
);
}
function EditProfile({ profile, onSaved }) {
function EditProfile({ profile, onSaved }: { profile: Profile; onSaved: (p: Profile) => void }) {
const [displayName, setDisplayName] = useState(displayNameOf(profile));
const [saving, setSaving] = useState(false);
@ -342,7 +396,7 @@ function EditProfile({ profile, onSaved }) {
toast.success('Profile saved.');
onSaved(asProfile(data));
} catch (err) {
toast.error(err.message || 'Failed to save profile.');
toast.error(errMessage(err, 'Failed to save profile.'));
} finally {
setSaving(false);
}
@ -365,15 +419,20 @@ function EditProfile({ profile, onSaved }) {
// Exported: rendered on the Settings page ("Notifications" section). Lives here
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
function buildNotificationPayload(form) {
function buildNotificationPayload(form: Settings) {
const payload = {
email: form.email || form.notification_email || '',
email: String(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),
enabled: false,
notify_3d: false,
notify_1d: false,
notify_day_of: false,
notify_daily_overdue: false,
};
payload.enabled = payload.notifications_enabled;
payload.notify_3d = payload.notify_3_day;
@ -383,20 +442,20 @@ function buildNotificationPayload(form) {
return payload;
}
export function NotificationPreferences({ settings }) {
const [form, setForm] = useState(settings);
export function NotificationPreferences({ settings }: { settings: Settings }) {
const [form, setForm] = useState<Settings>(settings);
// 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.
const { status, schedule, flush } = useAutoSave(
(payload) => api.updateProfileSettings(payload).catch((err) => {
toast.error(err.message || 'Failed to save notification preferences.');
toast.error(errMessage(err, 'Failed to save notification preferences.'));
throw err;
}),
);
const set = (k, v, delay) => setForm(prev => {
const set = (k: string, v: unknown, delay?: number) => setForm(prev => {
const next = { ...prev, [k]: v };
schedule(buildNotificationPayload(next), delay);
return next;
@ -436,7 +495,16 @@ export function NotificationPreferences({ settings }) {
);
}
const PUSH_CHANNELS = [
interface PushChannel {
value: string;
label: string;
urlLabel: string;
urlHint: string;
tokenLabel: string | null;
chatIdLabel?: string;
}
const PUSH_CHANNELS: PushChannel[] = [
{ 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 },
@ -444,22 +512,22 @@ const PUSH_CHANNELS = [
];
// Exported: rendered on the Settings page ("Notifications" section).
export function PushNotifications({ settings, onSaved }) {
export function PushNotifications({ settings }: { settings: Settings; onSaved?: (settings?: Settings) => void }) {
const [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
const [channel, setChannel] = useState(settings.push_channel || 'ntfy');
const [url, setUrl] = useState(settings.push_url || '');
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(settings.push_chat_id || '');
const [chatId, setChatId] = useState(String(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 ch = PUSH_CHANNELS.find(c => c.value === channel) || PUSH_CHANNELS[0]!;
// 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.
const buildPatch = (over = {}) => {
const buildPatch = (over: Partial<{ enabled: boolean; channel: string; url: string; chatId: string }> = {}) => {
const s = { enabled, channel, url, chatId, ...over };
return {
notify_push_enabled: s.enabled,
@ -471,7 +539,7 @@ export function PushNotifications({ settings, onSaved }) {
const { status, schedule, flush } = useAutoSave(
(patch) => api.updateProfileSettings(patch).catch((err) => {
toast.error(err.message || 'Failed to save push settings.');
toast.error(errMessage(err, 'Failed to save push settings.'));
throw err;
}),
);
@ -486,7 +554,7 @@ export function PushNotifications({ settings, onSaved }) {
setToken('');
toast.success('Token saved.');
} catch (err) {
toast.error(err.message || 'Failed to save token.');
toast.error(errMessage(err, 'Failed to save token.'));
} finally {
setSaving(false);
}
@ -498,7 +566,7 @@ export function PushNotifications({ settings, onSaved }) {
await api.testPushNotification();
toast.success('Test notification sent — check your device.');
} catch (err) {
toast.error(err.message || 'Test failed. Check your channel settings.');
toast.error(errMessage(err, 'Test failed. Check your channel settings.'));
} finally {
setTesting(false);
}
@ -651,7 +719,7 @@ function ChangePassword() {
setConfirmPassword('');
};
const submit = async (e) => {
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!currentPassword || !newPassword || !confirmPassword) {
toast.error('All password fields are required.');
@ -671,7 +739,7 @@ function ChangePassword() {
reset();
toast.success('Password changed.');
} catch (err) {
toast.error(err.message || 'Failed to change password.');
toast.error(errMessage(err, 'Failed to change password.'));
} finally {
setSaving(false);
}
@ -703,7 +771,7 @@ function ChangePassword() {
);
}
function CopyButton({ text }) {
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text).then(() => {
@ -720,17 +788,17 @@ function CopyButton({ text }) {
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 [enabled, setEnabled] = useState<boolean | null>(null); // null = loading
const [step, setStep] = useState<'idle' | 'setup' | 'confirm' | 'recovery' | 'disable'>('idle');
const [setupData, setSetupData] = useState<{ secret?: string; qr_data_url?: string } | null>(null);
const [code, setCode] = useState('');
const [recoveryCodes, setRecoveryCodes] = useState([]);
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
if (singleUserMode) return;
api.totpStatus()
.then(d => setEnabled(d.enabled))
.then(raw => setEnabled(!!(raw as { enabled?: boolean }).enabled))
.catch(() => setEnabled(false));
}, [singleUserMode]);
@ -742,33 +810,34 @@ function TotpSection() {
const startSetup = async () => {
setSaving(true);
try {
const d = await api.totpSetup();
const d = await api.totpSetup() as { secret?: string; qr_data_url?: string };
setSetupData(d);
setCode('');
setStep('setup');
} catch (err) {
toast.error(err.message || 'Failed to generate setup data.');
toast.error(errMessage(err, 'Failed to generate setup data.'));
} finally {
setSaving(false);
}
};
const confirmEnable = async (e) => {
const confirmEnable = async (e: React.FormEvent) => {
e.preventDefault();
if (!setupData) return;
setSaving(true);
try {
const d = await api.totpEnable({ secret: setupData.secret, code });
setRecoveryCodes(d.recovery_codes);
const d = await api.totpEnable({ secret: setupData.secret, code }) as { recovery_codes?: string[] };
setRecoveryCodes(d.recovery_codes ?? []);
setEnabled(true);
setStep('recovery');
} catch (err) {
toast.error(err.message || 'Invalid code. Try again.');
toast.error(errMessage(err, 'Invalid code. Try again.'));
} finally {
setSaving(false);
}
};
const confirmDisable = async (e) => {
const confirmDisable = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
try {
@ -778,7 +847,7 @@ function TotpSection() {
setCode('');
toast.success('Authenticator app removed.');
} catch (err) {
toast.error(err.message || 'Invalid code.');
toast.error(errMessage(err, 'Invalid code.'));
} finally {
setSaving(false);
}
@ -814,7 +883,7 @@ function TotpSection() {
<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} />
<CopyButton text={setupData.secret || ''} />
</div>
<form onSubmit={confirmEnable} className="space-y-3 pt-1">
<Input
@ -885,7 +954,7 @@ function TotpSection() {
);
}
function PrivacySettings({ settings, onSaved }) {
function PrivacySettings({ settings, onSaved }: { settings: Settings; onSaved: (settings: Settings) => void }) {
const [geoEnabled, setGeoEnabled] = useState(!!settings.geolocation_enabled);
const [saving, setSaving] = useState(false);
@ -898,7 +967,7 @@ function PrivacySettings({ settings, onSaved }) {
toast.success('Privacy settings saved.');
onSaved({ ...settings, geolocation_enabled: geoEnabled });
} catch (err) {
toast.error(err.message || 'Failed to save privacy settings.');
toast.error(errMessage(err, 'Failed to save privacy settings.'));
} finally {
setSaving(false);
}
@ -932,7 +1001,7 @@ function PrivacySettings({ settings, onSaved }) {
}
function ProfileNav() {
const items = [
const items: [string, string][] = [
['#account', 'Account'],
['#security', 'Security'],
['#privacy', 'Privacy'],
@ -954,8 +1023,8 @@ function ProfileNav() {
export default function ProfilePage() {
const { setUser, refresh } = useAuth();
const [profile, setProfile] = useState({});
const [settings, setSettings] = useState({});
const [profile, setProfile] = useState<Profile>({});
const [settings, setSettings] = useState<Settings>({});
const [loading, setLoading] = useState(true);
useEffect(() => {
@ -969,15 +1038,15 @@ export default function ProfilePage() {
setProfile(asProfile(profileData));
setSettings(asSettings(settingsData));
})
.catch(err => toast.error(err.message || 'Failed to load profile.'))
.finally(() => mounted && setLoading(false));
.catch(err => toast.error(errMessage(err, 'Failed to load profile.')))
.finally(() => { if (mounted) setLoading(false); });
return () => { mounted = false; };
}, []);
const handleProfileSaved = (nextProfile) => {
const handleProfileSaved = (nextProfile: Profile) => {
setProfile(prev => ({ ...prev, ...nextProfile }));
setUser(prev => prev ? { ...prev, ...nextProfile } : prev);
setUser(prev => prev ? { ...prev, ...nextProfile } as AuthUser : prev);
refresh();
};