diff --git a/frontend/components/settings/delete-account-dialog.tsx b/frontend/components/settings/delete-account-dialog.tsx new file mode 100644 index 0000000..f98ab46 --- /dev/null +++ b/frontend/components/settings/delete-account-dialog.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState } from "react"; +import { TriangleAlert } from "lucide-react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { FieldLabel } from "@/components/settings/settings-parts"; +import { authClient } from "@/lib/auth-client"; + +// Confirms (with the user's password) before permanently deleting the account +// via Better Auth's deleteUser, then bounces to /login. +export function DeleteAccountDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { t } = useTranslation(); + const [password, setPassword] = useState(""); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + + const reset = (next: boolean) => { + if (!next) { + setPassword(""); + setError(null); + setDeleting(false); + } + onOpenChange(next); + }; + + const confirmDelete = async () => { + setDeleting(true); + setError(null); + const { error: err } = await authClient.deleteUser({ password }); + if (err) { + setError(err.message ?? t("settings.profile.deleteDialog.failed")); + setDeleting(false); + return; + } + // Sessions are revoked server-side; send the user back to login. + window.location.href = "/login"; + }; + + return ( + + + + + + {t("settings.profile.deleteDialog.title")} + + + {t("settings.profile.deleteDialog.description")} + + + +
+ + {t("settings.profile.deleteDialog.passwordLabel")} + + setPassword(event.target.value)} + placeholder={t( + "settings.profile.deleteDialog.passwordPlaceholder", + )} + type="password" + value={password} + /> +
+ {error ?

{error}

: null} +
+ + }> + {t("settings.profile.deleteDialog.cancel")} + + + +
+
+ ); +} diff --git a/frontend/components/settings/settings-parts.tsx b/frontend/components/settings/settings-parts.tsx index 5fced4d..c64f45c 100644 --- a/frontend/components/settings/settings-parts.tsx +++ b/frontend/components/settings/settings-parts.tsx @@ -1,7 +1,8 @@ "use client"; import type { ReactNode } from "react"; -import { Copy } from "lucide-react"; +import { useState } from "react"; +import { Check, Copy } from "lucide-react"; import { useTranslation } from "react-i18next"; import { cn } from "@/lib/utils"; @@ -52,10 +53,15 @@ export function ToggleRow({ title, description, defaultChecked = false, + checked, + onCheckedChange, }: { title: string; description?: string; defaultChecked?: boolean; + /** Pass `checked` + `onCheckedChange` to make the switch controlled. */ + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; }) { return ( @@ -65,7 +71,11 @@ export function ToggleRow({

{description}

) : null} - +
); } @@ -80,6 +90,16 @@ export function CopyField({ value: string; }) { const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + const copy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard unavailable (insecure context) — silently ignore. + } + }; return (
@@ -94,10 +114,15 @@ export function CopyField({
diff --git a/frontend/components/settings/settings-preferences.tsx b/frontend/components/settings/settings-preferences.tsx index 78e4a1e..14aa68a 100644 --- a/frontend/components/settings/settings-preferences.tsx +++ b/frontend/components/settings/settings-preferences.tsx @@ -1,11 +1,13 @@ "use client"; +import { useEffect, useState } from "react"; import { ChevronDown, Plus } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { DeleteAccountDialog } from "@/components/settings/delete-account-dialog"; import { CopyField, FieldLabel, @@ -13,8 +15,15 @@ import { SettingsSection, ToggleRow, } from "@/components/settings/settings-parts"; +import { authClient } from "@/lib/auth-client"; +import { + getSettings, + saveSettings, + type UserPreferences, +} from "@/lib/settings"; +import { notify } from "@/lib/toast"; -// Keys into settings.profile.notif.* — these toggles are illustrative. +// Keys into settings.profile.notif.* — each maps to a persisted preference. const patientNotifications = [ { titleKey: "newLab", descKey: "newLabDesc" }, { titleKey: "recordUpdated", descKey: "recordUpdatedDesc" }, @@ -24,8 +33,100 @@ const patientNotifications = [ { titleKey: "visitScheduled", descKey: "visitScheduledDesc" }, ] as const; +const accountNotifications = [ + { titleKey: "pendingApprovals", descKey: "pendingApprovalsDesc" }, + { titleKey: "recordsShared", descKey: "recordsSharedDesc" }, +] as const; + +// All notification toggles default to on; profile extras default to empty. +const DEFAULT_PREFS: UserPreferences = { + "notif.newLab": true, + "notif.recordUpdated": true, + "notif.approvalRequested": true, + "notif.changeApproved": true, + "notif.newMessage": true, + "notif.visitScheduled": true, + "notif.pendingApprovals": true, + "notif.recordsShared": true, + clinic: "", + contactEmail: "", +}; + export function ProfilePanel() { const { t } = useTranslation(); + const { data: session } = authClient.useSession(); + const user = session?.user; + + const [prefs, setPrefs] = useState(DEFAULT_PREFS); + const [baseline, setBaseline] = useState(DEFAULT_PREFS); + const [name, setName] = useState(""); + const [baselineName, setBaselineName] = useState(""); + const [saving, setSaving] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + getSettings() + .then((stored) => { + if (cancelled) return; + const merged = { ...DEFAULT_PREFS, ...stored }; + setPrefs(merged); + setBaseline(merged); + }) + .catch(() => { + // Keep the defaults; the page still works and Save will retry. + }); + return () => { + cancelled = true; + }; + }, []); + + // Seed the display name from the session once it loads. + useEffect(() => { + if (user?.name) { + setName((prev) => prev || user.name); + setBaselineName((prev) => prev || user.name); + } + }, [user?.name]); + + const setPref = (key: string, value: boolean | string) => + setPrefs((prev) => ({ ...prev, [key]: value })); + + const dirty = + name !== baselineName || JSON.stringify(prefs) !== JSON.stringify(baseline); + + const save = async () => { + setSaving(true); + try { + const saved = await saveSettings(prefs); + const trimmed = name.trim(); + if (trimmed && trimmed !== baselineName) { + const { error } = await authClient.updateUser({ name: trimmed }); + if (error) throw new Error(error.message ?? "updateUser failed"); + setName(trimmed); + setBaselineName(trimmed); + } + const merged = { ...DEFAULT_PREFS, ...saved }; + setPrefs(merged); + setBaseline(merged); + notify.success( + t("settings.profile.savedTitle"), + t("settings.profile.savedBody"), + ); + } catch { + notify.error( + t("settings.profile.saveFailedTitle"), + t("settings.profile.saveFailedBody"), + ); + } finally { + setSaving(false); + } + }; + + const initial = (name || user?.name || "?").trim().charAt(0).toUpperCase(); + const username = + (user as { username?: string | null } | undefined)?.username ?? null; + return ( <> @@ -33,12 +134,12 @@ export function ProfilePanel() {
@@ -46,7 +147,7 @@ export function ProfilePanel() { {t("settings.profile.avatar")} - K + {initial}
@@ -54,7 +155,10 @@ export function ProfilePanel() { {t("settings.profile.displayName")} - + setName(event.target.value)} + value={name} + /> @@ -72,14 +176,22 @@ export function ProfilePanel() {
{t("settings.profile.clinic")} - + setPref("clinic", event.target.value)} + placeholder={t("settings.profile.clinicPlaceholder")} + value={String(prefs.clinic ?? "")} + />
{t("settings.profile.contactEmail")} + setPref("contactEmail", event.target.value) + } placeholder={t("settings.profile.contactEmailPlaceholder")} + value={String(prefs.contactEmail ?? "")} />
@@ -106,9 +218,12 @@ export function ProfilePanel() {
{patientNotifications.map((item) => ( + setPref(`notif.${item.titleKey}`, checked) + } title={t(`settings.profile.notif.${item.titleKey}`)} /> ))} @@ -120,19 +235,21 @@ export function ProfilePanel() { title={t("settings.profile.accountNotifications")} >
- - + {accountNotifications.map((item) => ( + + setPref(`notif.${item.titleKey}`, checked) + } + title={t(`settings.profile.notif.${item.titleKey}`)} + /> + ))}
+ {/* Future features — intentionally not wired to persistence yet. */}
- +
+ + + + {dirty ? ( +
+
+

+ {t("settings.profile.unsavedChanges")} +

+ +
+
+ ) : null} ); } diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 43c5884..b917d3a 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -695,6 +695,7 @@ }, "empty": "Nothing here yet.", "copy": "Copy", + "copied": "Copied", "records": { "description": "How patient records are sourced, stored, and displayed" }, @@ -729,6 +730,23 @@ "deleteAccount": "Delete account", "deleteAccountDescription": "Permanently delete your temetro account and any locally stored signing keys. This action cannot be undone.", "delete": "Delete", + "unsavedChanges": "You have unsaved changes", + "saveChanges": "Save changes", + "saving": "Saving…", + "savedTitle": "Settings saved", + "savedBody": "Your preferences are up to date.", + "saveFailedTitle": "Couldn't save settings", + "saveFailedBody": "Please try again.", + "deleteDialog": { + "title": "Delete your account?", + "description": "This permanently deletes your account, removes you from your clinics, and cannot be undone.", + "passwordLabel": "Confirm your password", + "passwordPlaceholder": "Your password", + "cancel": "Cancel", + "confirm": "Delete account", + "deleting": "Deleting…", + "failed": "Couldn't delete the account. Check your password and try again." + }, "notif": { "newLab": "New lab result", "newLabDesc": "Sent when a new lab result is available on a patient's chart", diff --git a/frontend/lib/settings.ts b/frontend/lib/settings.ts new file mode 100644 index 0000000..9b670fe --- /dev/null +++ b/frontend/lib/settings.ts @@ -0,0 +1,24 @@ +import { apiFetch } from "@/lib/api-client"; + +// Per-user preferences persisted by the backend (`user_settings` table) as a +// flat key → boolean/string map. Unknown keys are preserved, so features can +// add settings without coordinating with this file. +export type UserPreferences = Record; + +export async function getSettings(): Promise { + const res = await apiFetch<{ preferences: UserPreferences }>("/api/settings"); + return res.preferences ?? {}; +} + +export async function saveSettings( + preferences: UserPreferences, +): Promise { + const res = await apiFetch<{ preferences: UserPreferences }>( + "/api/settings", + { + method: "PUT", + body: JSON.stringify({ preferences }), + }, + ); + return res.preferences ?? {}; +}