mirror of
https://github.com/temetro/temetro.git
synced 2026-08-31 04:47:57 +00:00
frontend: working profile settings with sticky save + real account deletion
- ToggleRow supports controlled checked/onCheckedChange; CopyField copy works - ProfilePanel loads/saves preferences via /api/settings, name via updateUser - dirty tracking with a sticky save bar that follows the scroll - Delete account: password-confirmed dialog wired to authClient.deleteUser - clinician ID / handle now show the real session user Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string | null>(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 (
|
||||||
|
<Dialog onOpenChange={reset} open={open}>
|
||||||
|
<DialogPopup className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<TriangleAlert className="size-4 text-destructive" />
|
||||||
|
{t("settings.profile.deleteDialog.title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("settings.profile.deleteDialog.description")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogPanel className="flex flex-col gap-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<FieldLabel required>
|
||||||
|
{t("settings.profile.deleteDialog.passwordLabel")}
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
autoComplete="current-password"
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
placeholder={t(
|
||||||
|
"settings.profile.deleteDialog.passwordPlaceholder",
|
||||||
|
)}
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||||
|
</DialogPanel>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||||
|
{t("settings.profile.deleteDialog.cancel")}
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
disabled={deleting || password.length === 0}
|
||||||
|
onClick={confirmDelete}
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{deleting
|
||||||
|
? t("settings.profile.deleteDialog.deleting")
|
||||||
|
: t("settings.profile.deleteDialog.confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogPopup>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
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 { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -52,10 +53,15 @@ export function ToggleRow({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
defaultChecked = false,
|
defaultChecked = false,
|
||||||
|
checked,
|
||||||
|
onCheckedChange,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
defaultChecked?: boolean;
|
defaultChecked?: boolean;
|
||||||
|
/** Pass `checked` + `onCheckedChange` to make the switch controlled. */
|
||||||
|
checked?: boolean;
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
|
<SettingsCard className="flex items-center justify-between gap-4 px-4 py-3.5">
|
||||||
@@ -65,7 +71,11 @@ export function ToggleRow({
|
|||||||
<p className="text-sm text-muted-foreground">{description}</p>
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Switch defaultChecked={defaultChecked} />
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
defaultChecked={checked === undefined ? defaultChecked : undefined}
|
||||||
|
onCheckedChange={onCheckedChange}
|
||||||
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,6 +90,16 @@ export function CopyField({
|
|||||||
value: string;
|
value: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
@@ -94,10 +114,15 @@ export function CopyField({
|
|||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
className="flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
className="flex items-center gap-1 rounded-full px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-accent"
|
||||||
|
onClick={copy}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Copy className="size-3.5" />
|
{copied ? (
|
||||||
{t("settings.copy")}
|
<Check className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3.5" />
|
||||||
|
)}
|
||||||
|
{copied ? t("settings.copied") : t("settings.copy")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { ChevronDown, Plus } from "lucide-react";
|
import { ChevronDown, Plus } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { DeleteAccountDialog } from "@/components/settings/delete-account-dialog";
|
||||||
import {
|
import {
|
||||||
CopyField,
|
CopyField,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
@@ -13,8 +15,15 @@ import {
|
|||||||
SettingsSection,
|
SettingsSection,
|
||||||
ToggleRow,
|
ToggleRow,
|
||||||
} from "@/components/settings/settings-parts";
|
} 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 = [
|
const patientNotifications = [
|
||||||
{ titleKey: "newLab", descKey: "newLabDesc" },
|
{ titleKey: "newLab", descKey: "newLabDesc" },
|
||||||
{ titleKey: "recordUpdated", descKey: "recordUpdatedDesc" },
|
{ titleKey: "recordUpdated", descKey: "recordUpdatedDesc" },
|
||||||
@@ -24,8 +33,100 @@ const patientNotifications = [
|
|||||||
{ titleKey: "visitScheduled", descKey: "visitScheduledDesc" },
|
{ titleKey: "visitScheduled", descKey: "visitScheduledDesc" },
|
||||||
] as const;
|
] 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() {
|
export function ProfilePanel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { data: session } = authClient.useSession();
|
||||||
|
const user = session?.user;
|
||||||
|
|
||||||
|
const [prefs, setPrefs] = useState<UserPreferences>(DEFAULT_PREFS);
|
||||||
|
const [baseline, setBaseline] = useState<UserPreferences>(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSection title={t("settings.profile.sectionTitle")}>
|
<SettingsSection title={t("settings.profile.sectionTitle")}>
|
||||||
@@ -33,12 +134,12 @@ export function ProfilePanel() {
|
|||||||
<CopyField
|
<CopyField
|
||||||
description={t("settings.profile.clinicianIdDescription")}
|
description={t("settings.profile.clinicianIdDescription")}
|
||||||
label={t("settings.profile.clinicianIdLabel")}
|
label={t("settings.profile.clinicianIdLabel")}
|
||||||
value="62a5278f-91c6-4912-b711-ee1c9c2f0a73"
|
value={user?.id ?? "—"}
|
||||||
/>
|
/>
|
||||||
<CopyField
|
<CopyField
|
||||||
description={t("settings.profile.handleDescription")}
|
description={t("settings.profile.handleDescription")}
|
||||||
label={t("settings.profile.handleLabel")}
|
label={t("settings.profile.handleLabel")}
|
||||||
value="dr-khalid"
|
value={username ?? user?.email ?? "—"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-end gap-4">
|
<div className="flex items-end gap-4">
|
||||||
@@ -46,7 +147,7 @@ export function ProfilePanel() {
|
|||||||
<FieldLabel>{t("settings.profile.avatar")}</FieldLabel>
|
<FieldLabel>{t("settings.profile.avatar")}</FieldLabel>
|
||||||
<Avatar className="size-10 rounded-xl">
|
<Avatar className="size-10 rounded-xl">
|
||||||
<AvatarFallback className="rounded-xl bg-muted text-sm font-medium">
|
<AvatarFallback className="rounded-xl bg-muted text-sm font-medium">
|
||||||
K
|
{initial}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,7 +155,10 @@ export function ProfilePanel() {
|
|||||||
<FieldLabel required>
|
<FieldLabel required>
|
||||||
{t("settings.profile.displayName")}
|
{t("settings.profile.displayName")}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Input defaultValue="Dr. Khalid" />
|
<Input
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,14 +176,22 @@ export function ProfilePanel() {
|
|||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<FieldLabel>{t("settings.profile.clinic")}</FieldLabel>
|
<FieldLabel>{t("settings.profile.clinic")}</FieldLabel>
|
||||||
<Input placeholder={t("settings.profile.clinicPlaceholder")} />
|
<Input
|
||||||
|
onChange={(event) => setPref("clinic", event.target.value)}
|
||||||
|
placeholder={t("settings.profile.clinicPlaceholder")}
|
||||||
|
value={String(prefs.clinic ?? "")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<FieldLabel required>
|
<FieldLabel required>
|
||||||
{t("settings.profile.contactEmail")}
|
{t("settings.profile.contactEmail")}
|
||||||
</FieldLabel>
|
</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
|
onChange={(event) =>
|
||||||
|
setPref("contactEmail", event.target.value)
|
||||||
|
}
|
||||||
placeholder={t("settings.profile.contactEmailPlaceholder")}
|
placeholder={t("settings.profile.contactEmailPlaceholder")}
|
||||||
|
value={String(prefs.contactEmail ?? "")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,9 +218,12 @@ export function ProfilePanel() {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{patientNotifications.map((item) => (
|
{patientNotifications.map((item) => (
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
defaultChecked
|
checked={Boolean(prefs[`notif.${item.titleKey}`])}
|
||||||
description={t(`settings.profile.notif.${item.descKey}`)}
|
description={t(`settings.profile.notif.${item.descKey}`)}
|
||||||
key={item.titleKey}
|
key={item.titleKey}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setPref(`notif.${item.titleKey}`, checked)
|
||||||
|
}
|
||||||
title={t(`settings.profile.notif.${item.titleKey}`)}
|
title={t(`settings.profile.notif.${item.titleKey}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -120,19 +235,21 @@ export function ProfilePanel() {
|
|||||||
title={t("settings.profile.accountNotifications")}
|
title={t("settings.profile.accountNotifications")}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<ToggleRow
|
{accountNotifications.map((item) => (
|
||||||
defaultChecked
|
<ToggleRow
|
||||||
description={t("settings.profile.notif.pendingApprovalsDesc")}
|
checked={Boolean(prefs[`notif.${item.titleKey}`])}
|
||||||
title={t("settings.profile.notif.pendingApprovals")}
|
description={t(`settings.profile.notif.${item.descKey}`)}
|
||||||
/>
|
key={item.titleKey}
|
||||||
<ToggleRow
|
onCheckedChange={(checked) =>
|
||||||
defaultChecked
|
setPref(`notif.${item.titleKey}`, checked)
|
||||||
description={t("settings.profile.notif.recordsSharedDesc")}
|
}
|
||||||
title={t("settings.profile.notif.recordsShared")}
|
title={t(`settings.profile.notif.${item.titleKey}`)}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Future features — intentionally not wired to persistence yet. */}
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
description={t("settings.profile.featuresDescription")}
|
description={t("settings.profile.featuresDescription")}
|
||||||
title={t("settings.profile.features")}
|
title={t("settings.profile.features")}
|
||||||
@@ -162,9 +279,28 @@ export function ProfilePanel() {
|
|||||||
{t("settings.profile.deleteAccountDescription")}
|
{t("settings.profile.deleteAccountDescription")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="destructive">{t("settings.profile.delete")}</Button>
|
<Button onClick={() => setDeleteOpen(true)} variant="destructive">
|
||||||
|
{t("settings.profile.delete")}
|
||||||
|
</Button>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
<DeleteAccountDialog onOpenChange={setDeleteOpen} open={deleteOpen} />
|
||||||
|
|
||||||
|
{dirty ? (
|
||||||
|
<div className="sticky bottom-4 z-10">
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-2xl border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("settings.profile.unsavedChanges")}
|
||||||
|
</p>
|
||||||
|
<Button disabled={saving} onClick={save} size="sm">
|
||||||
|
{saving
|
||||||
|
? t("settings.profile.saving")
|
||||||
|
: t("settings.profile.saveChanges")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -695,6 +695,7 @@
|
|||||||
},
|
},
|
||||||
"empty": "Nothing here yet.",
|
"empty": "Nothing here yet.",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
|
"copied": "Copied",
|
||||||
"records": {
|
"records": {
|
||||||
"description": "How patient records are sourced, stored, and displayed"
|
"description": "How patient records are sourced, stored, and displayed"
|
||||||
},
|
},
|
||||||
@@ -729,6 +730,23 @@
|
|||||||
"deleteAccount": "Delete account",
|
"deleteAccount": "Delete account",
|
||||||
"deleteAccountDescription": "Permanently delete your temetro account and any locally stored signing keys. This action cannot be undone.",
|
"deleteAccountDescription": "Permanently delete your temetro account and any locally stored signing keys. This action cannot be undone.",
|
||||||
"delete": "Delete",
|
"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": {
|
"notif": {
|
||||||
"newLab": "New lab result",
|
"newLab": "New lab result",
|
||||||
"newLabDesc": "Sent when a new lab result is available on a patient's chart",
|
"newLabDesc": "Sent when a new lab result is available on a patient's chart",
|
||||||
|
|||||||
@@ -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<string, boolean | string>;
|
||||||
|
|
||||||
|
export async function getSettings(): Promise<UserPreferences> {
|
||||||
|
const res = await apiFetch<{ preferences: UserPreferences }>("/api/settings");
|
||||||
|
return res.preferences ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
): Promise<UserPreferences> {
|
||||||
|
const res = await apiFetch<{ preferences: UserPreferences }>(
|
||||||
|
"/api/settings",
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ preferences }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.preferences ?? {};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user