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:
parent
818df6d410
commit
82bd13036a
|
|
@ -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';
|
import { api } from '@/api';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
|
|
@ -21,7 +21,7 @@ interface MeResponse {
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
user: User | null | undefined; // undefined = loading
|
user: User | null | undefined; // undefined = loading
|
||||||
singleUserMode: boolean;
|
singleUserMode: boolean;
|
||||||
setUser: (user: User | null | undefined) => void;
|
setUser: Dispatch<SetStateAction<User | null | undefined>>;
|
||||||
logout: () => void | Promise<void>;
|
logout: () => void | Promise<void>;
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
hasNewVersion: boolean;
|
hasNewVersion: boolean;
|
||||||
|
|
|
||||||
|
|
@ -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 { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
|
User, Mail, KeyRound, ShieldCheck, Loader2, History, Monitor, Smartphone, ChevronRight,
|
||||||
Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, Lock,
|
Bell, SendHorizontal, ScanLine, TriangleAlert, Copy, Check, Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '@/api';
|
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 { useAutoSave } from '@/hooks/useAutoSave';
|
||||||
import { SaveStatus } from '@/components/ui/save-status';
|
import { SaveStatus } from '@/components/ui/save-status';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
@ -15,28 +16,66 @@ import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
function asProfile(data) {
|
type Settings = Record<string, unknown>;
|
||||||
return data?.profile || data?.user || data || {};
|
|
||||||
|
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 || '';
|
return profile.display_name || profile.displayName || profile.name || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function asSettings(data) {
|
export function asSettings(data: unknown): Settings {
|
||||||
return data?.settings || data?.notifications || data || {};
|
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';
|
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);
|
if (Number.isNaN(d.getTime())) return String(value);
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
+ ' '
|
+ ' '
|
||||||
+ d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
+ 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 (
|
return (
|
||||||
<section className="overflow-hidden rounded-xl border border-border/70 bg-card/90 shadow-sm">
|
<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="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 (
|
return (
|
||||||
<div className="rounded-lg border border-border/60 bg-muted/25 px-4 py-3">
|
<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="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 (
|
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">
|
<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>
|
<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 };
|
if (!ua) return { browser: 'Unknown', os: 'Unknown', mobile: false };
|
||||||
const s = ua;
|
const s = ua;
|
||||||
const mobile = /iPhone|iPad|Android|Mobile/i.test(s);
|
const mobile = /iPhone|iPad|Android|Mobile/i.test(s);
|
||||||
|
|
@ -92,15 +137,20 @@ function parseUserAgent(ua) {
|
||||||
return { browser, os, mobile };
|
return { browser, os, mobile };
|
||||||
}
|
}
|
||||||
|
|
||||||
function deviceLabel(type) {
|
function deviceLabel(type?: string): string {
|
||||||
if (type === 'mobile') return 'Mobile';
|
if (type === 'mobile') return 'Mobile';
|
||||||
if (type === 'tablet') return 'Tablet';
|
if (type === 'tablet') return 'Tablet';
|
||||||
if (type === 'api') return 'API client';
|
if (type === 'api') return 'API client';
|
||||||
return 'Desktop';
|
return 'Desktop';
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }) {
|
function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }: {
|
||||||
const [history, setHistory] = useState([]);
|
history?: LoginEntry[];
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onLoaded?: (rows: LoginEntry[]) => void;
|
||||||
|
}) {
|
||||||
|
const [history, setHistory] = useState<LoginEntry[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -111,14 +161,14 @@ function LoginHistoryModal({ history: providedHistory, open, onClose, onLoaded }
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.loginHistory()
|
api.loginHistory()
|
||||||
.then(d => {
|
.then(raw => {
|
||||||
const rows = d.history ?? [];
|
const rows = (raw as { history?: LoginEntry[] }).history ?? [];
|
||||||
setHistory(rows);
|
setHistory(rows);
|
||||||
onLoaded?.(rows);
|
onLoaded?.(rows);
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
toast.error(err.message || 'Failed to load login history.');
|
toast.error(errMessage(err, 'Failed to load login history.'));
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [open, providedHistory, onLoaded]);
|
}, [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 parsed = parseUserAgent(latestLogin?.user_agent);
|
||||||
const browser = latestLogin?.browser || parsed.browser;
|
const browser = latestLogin?.browser || parsed.browser;
|
||||||
const os = latestLogin?.os || parsed.os;
|
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 [historyOpen, setHistoryOpen] = useState(false);
|
||||||
const [loginHistory, setLoginHistory] = useState([]);
|
const [loginHistory, setLoginHistory] = useState<LoginEntry[]>([]);
|
||||||
const [historyLoading, setHistoryLoading] = useState(false);
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
setHistoryLoading(true);
|
setHistoryLoading(true);
|
||||||
api.loginHistory()
|
api.loginHistory()
|
||||||
.then(d => setLoginHistory(d.history ?? []))
|
.then(raw => setLoginHistory((raw as { history?: LoginEntry[] }).history ?? []))
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
setLoginHistory([]);
|
setLoginHistory([]);
|
||||||
toast.error(err.message || 'Failed to load login history.');
|
toast.error(errMessage(err, 'Failed to load login history.'));
|
||||||
})
|
})
|
||||||
.finally(() => setHistoryLoading(false));
|
.finally(() => setHistoryLoading(false));
|
||||||
}, [loading]);
|
}, [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 [displayName, setDisplayName] = useState(displayNameOf(profile));
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
|
@ -342,7 +396,7 @@ function EditProfile({ profile, onSaved }) {
|
||||||
toast.success('Profile saved.');
|
toast.success('Profile saved.');
|
||||||
onSaved(asProfile(data));
|
onSaved(asProfile(data));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save profile.');
|
toast.error(errMessage(err, 'Failed to save profile.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -365,15 +419,20 @@ function EditProfile({ profile, onSaved }) {
|
||||||
|
|
||||||
// Exported: rendered on the Settings page ("Notifications" section). Lives here
|
// Exported: rendered on the Settings page ("Notifications" section). Lives here
|
||||||
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
|
// because it shares asSettings/CheckRow/SectionCard with the rest of this file.
|
||||||
function buildNotificationPayload(form) {
|
function buildNotificationPayload(form: Settings) {
|
||||||
const payload = {
|
const payload = {
|
||||||
email: form.email || form.notification_email || '',
|
email: String(form.email || form.notification_email || ''),
|
||||||
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
|
notifications_enabled: !!(form.notifications_enabled ?? form.enabled),
|
||||||
notify_3_day: !!(form.notify_3_day ?? form.notify_3d),
|
notify_3_day: !!(form.notify_3_day ?? form.notify_3d),
|
||||||
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
|
notify_1_day: !!(form.notify_1_day ?? form.notify_1d),
|
||||||
notify_due: !!(form.notify_due ?? form.notify_day_of),
|
notify_due: !!(form.notify_due ?? form.notify_day_of),
|
||||||
notify_overdue: !!(form.notify_overdue ?? form.notify_daily_overdue),
|
notify_overdue: !!(form.notify_overdue ?? form.notify_daily_overdue),
|
||||||
notify_amount_change: !!(form.notify_amount_change ?? true),
|
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.enabled = payload.notifications_enabled;
|
||||||
payload.notify_3d = payload.notify_3_day;
|
payload.notify_3d = payload.notify_3_day;
|
||||||
|
|
@ -383,20 +442,20 @@ function buildNotificationPayload(form) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationPreferences({ settings }) {
|
export function NotificationPreferences({ settings }: { settings: Settings }) {
|
||||||
const [form, setForm] = useState(settings);
|
const [form, setForm] = useState<Settings>(settings);
|
||||||
|
|
||||||
// Auto-save: toggles persist almost instantly, the email field debounces so
|
// 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 —
|
// we never save a half-typed address. Local form stays the source of truth —
|
||||||
// no parent refresh that could clobber in-flight edits.
|
// no parent refresh that could clobber in-flight edits.
|
||||||
const { status, schedule, flush } = useAutoSave(
|
const { status, schedule, flush } = useAutoSave(
|
||||||
(payload) => api.updateProfileSettings(payload).catch((err) => {
|
(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;
|
throw err;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const set = (k, v, delay) => setForm(prev => {
|
const set = (k: string, v: unknown, delay?: number) => setForm(prev => {
|
||||||
const next = { ...prev, [k]: v };
|
const next = { ...prev, [k]: v };
|
||||||
schedule(buildNotificationPayload(next), delay);
|
schedule(buildNotificationPayload(next), delay);
|
||||||
return next;
|
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: '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: '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: '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).
|
// 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 [enabled, setEnabled] = useState(!!settings.notify_push_enabled);
|
||||||
const [channel, setChannel] = useState(settings.push_channel || 'ntfy');
|
const [channel, setChannel] = useState(String(settings.push_channel || 'ntfy'));
|
||||||
const [url, setUrl] = useState(settings.push_url || '');
|
const [url, setUrl] = useState(String(settings.push_url || ''));
|
||||||
const [token, setToken] = useState(''); // never pre-filled for security
|
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 [tokenSet, setTokenSet] = useState(!!settings.push_token_set);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [testing, setTesting] = 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.
|
// Auto-save. Toggle/channel persist immediately; URL and chat ID debounce.
|
||||||
// The token is deliberately NOT auto-saved while typing — a half-typed token
|
// 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.
|
// 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 };
|
const s = { enabled, channel, url, chatId, ...over };
|
||||||
return {
|
return {
|
||||||
notify_push_enabled: s.enabled,
|
notify_push_enabled: s.enabled,
|
||||||
|
|
@ -471,7 +539,7 @@ export function PushNotifications({ settings, onSaved }) {
|
||||||
|
|
||||||
const { status, schedule, flush } = useAutoSave(
|
const { status, schedule, flush } = useAutoSave(
|
||||||
(patch) => api.updateProfileSettings(patch).catch((err) => {
|
(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;
|
throw err;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -486,7 +554,7 @@ export function PushNotifications({ settings, onSaved }) {
|
||||||
setToken('');
|
setToken('');
|
||||||
toast.success('Token saved.');
|
toast.success('Token saved.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save token.');
|
toast.error(errMessage(err, 'Failed to save token.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -498,7 +566,7 @@ export function PushNotifications({ settings, onSaved }) {
|
||||||
await api.testPushNotification();
|
await api.testPushNotification();
|
||||||
toast.success('Test notification sent — check your device.');
|
toast.success('Test notification sent — check your device.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Test failed. Check your channel settings.');
|
toast.error(errMessage(err, 'Test failed. Check your channel settings.'));
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
}
|
}
|
||||||
|
|
@ -651,7 +719,7 @@ function ChangePassword() {
|
||||||
setConfirmPassword('');
|
setConfirmPassword('');
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = async (e) => {
|
const submit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||||
toast.error('All password fields are required.');
|
toast.error('All password fields are required.');
|
||||||
|
|
@ -671,7 +739,7 @@ function ChangePassword() {
|
||||||
reset();
|
reset();
|
||||||
toast.success('Password changed.');
|
toast.success('Password changed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to change password.');
|
toast.error(errMessage(err, 'Failed to change password.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -703,7 +771,7 @@ function ChangePassword() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CopyButton({ text }) {
|
function CopyButton({ text }: { text: string }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const copy = () => {
|
const copy = () => {
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
|
@ -720,17 +788,17 @@ function CopyButton({ text }) {
|
||||||
|
|
||||||
function TotpSection() {
|
function TotpSection() {
|
||||||
const { singleUserMode } = useAuth();
|
const { singleUserMode } = useAuth();
|
||||||
const [enabled, setEnabled] = useState(null); // null = loading
|
const [enabled, setEnabled] = useState<boolean | null>(null); // null = loading
|
||||||
const [step, setStep] = useState('idle'); // idle | setup | confirm | recovery | disable
|
const [step, setStep] = useState<'idle' | 'setup' | 'confirm' | 'recovery' | 'disable'>('idle');
|
||||||
const [setupData, setSetupData] = useState(null); // { secret, qr_data_url }
|
const [setupData, setSetupData] = useState<{ secret?: string; qr_data_url?: string } | null>(null);
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [recoveryCodes, setRecoveryCodes] = useState([]);
|
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
if (singleUserMode) return;
|
if (singleUserMode) return;
|
||||||
api.totpStatus()
|
api.totpStatus()
|
||||||
.then(d => setEnabled(d.enabled))
|
.then(raw => setEnabled(!!(raw as { enabled?: boolean }).enabled))
|
||||||
.catch(() => setEnabled(false));
|
.catch(() => setEnabled(false));
|
||||||
}, [singleUserMode]);
|
}, [singleUserMode]);
|
||||||
|
|
||||||
|
|
@ -742,33 +810,34 @@ function TotpSection() {
|
||||||
const startSetup = async () => {
|
const startSetup = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const d = await api.totpSetup();
|
const d = await api.totpSetup() as { secret?: string; qr_data_url?: string };
|
||||||
setSetupData(d);
|
setSetupData(d);
|
||||||
setCode('');
|
setCode('');
|
||||||
setStep('setup');
|
setStep('setup');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to generate setup data.');
|
toast.error(errMessage(err, 'Failed to generate setup data.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmEnable = async (e) => {
|
const confirmEnable = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!setupData) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const d = await api.totpEnable({ secret: setupData.secret, code });
|
const d = await api.totpEnable({ secret: setupData.secret, code }) as { recovery_codes?: string[] };
|
||||||
setRecoveryCodes(d.recovery_codes);
|
setRecoveryCodes(d.recovery_codes ?? []);
|
||||||
setEnabled(true);
|
setEnabled(true);
|
||||||
setStep('recovery');
|
setStep('recovery');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Invalid code. Try again.');
|
toast.error(errMessage(err, 'Invalid code. Try again.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDisable = async (e) => {
|
const confirmDisable = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -778,7 +847,7 @@ function TotpSection() {
|
||||||
setCode('');
|
setCode('');
|
||||||
toast.success('Authenticator app removed.');
|
toast.success('Authenticator app removed.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Invalid code.');
|
toast.error(errMessage(err, 'Invalid code.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -814,7 +883,7 @@ function TotpSection() {
|
||||||
<p className="text-xs text-muted-foreground">Can't scan? Enter this key manually:</p>
|
<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">
|
<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}
|
{setupData.secret}
|
||||||
<CopyButton text={setupData.secret} />
|
<CopyButton text={setupData.secret || ''} />
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={confirmEnable} className="space-y-3 pt-1">
|
<form onSubmit={confirmEnable} className="space-y-3 pt-1">
|
||||||
<Input
|
<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 [geoEnabled, setGeoEnabled] = useState(!!settings.geolocation_enabled);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
|
@ -898,7 +967,7 @@ function PrivacySettings({ settings, onSaved }) {
|
||||||
toast.success('Privacy settings saved.');
|
toast.success('Privacy settings saved.');
|
||||||
onSaved({ ...settings, geolocation_enabled: geoEnabled });
|
onSaved({ ...settings, geolocation_enabled: geoEnabled });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to save privacy settings.');
|
toast.error(errMessage(err, 'Failed to save privacy settings.'));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -932,7 +1001,7 @@ function PrivacySettings({ settings, onSaved }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfileNav() {
|
function ProfileNav() {
|
||||||
const items = [
|
const items: [string, string][] = [
|
||||||
['#account', 'Account'],
|
['#account', 'Account'],
|
||||||
['#security', 'Security'],
|
['#security', 'Security'],
|
||||||
['#privacy', 'Privacy'],
|
['#privacy', 'Privacy'],
|
||||||
|
|
@ -954,8 +1023,8 @@ function ProfileNav() {
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { setUser, refresh } = useAuth();
|
const { setUser, refresh } = useAuth();
|
||||||
const [profile, setProfile] = useState({});
|
const [profile, setProfile] = useState<Profile>({});
|
||||||
const [settings, setSettings] = useState({});
|
const [settings, setSettings] = useState<Settings>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -969,15 +1038,15 @@ export default function ProfilePage() {
|
||||||
setProfile(asProfile(profileData));
|
setProfile(asProfile(profileData));
|
||||||
setSettings(asSettings(settingsData));
|
setSettings(asSettings(settingsData));
|
||||||
})
|
})
|
||||||
.catch(err => toast.error(err.message || 'Failed to load profile.'))
|
.catch(err => toast.error(errMessage(err, 'Failed to load profile.')))
|
||||||
.finally(() => mounted && setLoading(false));
|
.finally(() => { if (mounted) setLoading(false); });
|
||||||
|
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleProfileSaved = (nextProfile) => {
|
const handleProfileSaved = (nextProfile: Profile) => {
|
||||||
setProfile(prev => ({ ...prev, ...nextProfile }));
|
setProfile(prev => ({ ...prev, ...nextProfile }));
|
||||||
setUser(prev => prev ? { ...prev, ...nextProfile } : prev);
|
setUser(prev => prev ? { ...prev, ...nextProfile } as AuthUser : prev);
|
||||||
refresh();
|
refresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
Loading…
Reference in New Issue