siftlode/frontend/src/components/SettingsPanel.tsx
npeter83 84b55c0567 feat(account): GDPR self-service account + data deletion (5d)
Add DELETE /api/me/account: permanently erases the signed-in account and all its
personal data — OAuth tokens, subscriptions, tags, video states, playlists,
notifications all cascade on the users row (FK ON DELETE CASCADE); quota-audit
events are kept but anonymised (SET NULL); the email's invite/whitelist row is
removed too. Guards: the shared demo account can't be deleted, and the last
remaining admin can't delete itself. Frontend: a Danger zone in Settings -> Account
(non-demo) with a danger-confirm; on success the session clears and the app lands
on the welcome page. EN/HU/DE. Verified via curl: self-delete + session clear +
demo 403.
2026-06-19 15:46:49 +02:00

445 lines
16 KiB
TypeScript

import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard).
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is
// how the panel reads/writes it. See App.tsx.
export type PrefsSaveState = "idle" | "saving" | "saved" | "error";
export interface PrefsController {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
perf: boolean;
setPerf: (v: boolean) => void;
hints: boolean;
setHints: (v: boolean) => void;
notif: NotifSettings;
setNotif: (n: NotifSettings) => void;
dirty: boolean;
save: () => void;
discard: () => void;
saveState: PrefsSaveState;
}
type TabId = "appearance" | "notifications" | "account";
const TABS: { id: TabId; icon: typeof Monitor }[] = [
{ id: "appearance", icon: Monitor },
{ id: "notifications", icon: Bell },
{ id: "account", icon: User },
];
// Persist the active tab so a reload (F5) keeps the user where they were instead of
// snapping back to "appearance".
const SETTINGS_TAB_KEY = "siftlode.settingsTab";
function loadSettingsTab(): TabId {
const v = localStorage.getItem(SETTINGS_TAB_KEY);
return TABS.some((tabItem) => tabItem.id === v) ? (v as TabId) : "appearance";
}
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
// shown by the Header; the tab rail stays as in-page sub-navigation.
export default function SettingsPanel({
me,
prefs,
onOpenWizard,
}: {
me: Me;
prefs: PrefsController;
onOpenWizard: () => void;
}) {
const { t } = useTranslation();
const [tab, setTabState] = useState<TabId>(loadSettingsTab);
const setTab = (id: TabId) => {
setTabState(id);
localStorage.setItem(SETTINGS_TAB_KEY, id);
};
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl overflow-hidden">
<div className="flex">
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
{TABS.map((tabItem) => {
const active = tab === tabItem.id;
return (
<button
key={tabItem.id}
onClick={() => setTab(tabItem.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
active
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
: "text-muted hover:text-fg hover:bg-card/60"
}`}
>
<tabItem.icon className="w-4 h-4 shrink-0" />
{t(`settings.tabs.${tabItem.id}`)}
</button>
);
})}
</nav>
{/* Render only the active tab so the panel sizes to its actual content. (Stacking
every tab in one grid cell made the whole panel as tall as the tallest tab —
Account — leaving the short tabs with dead space and a needless scrollbar.) */}
<div className="flex-1 min-w-0 p-4">
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
</div>
</div>
{/* Save/Discard bar — spans the whole card (a change can come from any tab). Only the
Appearance/Notifications prefs are drafted; the Sync/Account tabs act immediately. */}
<PrefsSaveBar prefs={prefs} />
</div>
</div>
);
}
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears.
if (!prefs.dirty && prefs.saveState === "idle") return null;
const saving = prefs.saveState === "saving";
return (
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40">
<span className="text-sm text-muted">
{prefs.saveState === "saved"
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span>
: prefs.saveState === "error"
? <span className="text-red-500">{t("settings.save.failed")}</span>
: t("settings.save.unsaved")}
</span>
<div className="flex items-center gap-2">
<button
onClick={prefs.discard}
disabled={saving || !prefs.dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
>
<RotateCcw className="w-4 h-4" />
{t("settings.save.discard")}
</button>
<button
onClick={prefs.save}
disabled={saving || !prefs.dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
>
<Save className="w-4 h-4" />
{saving ? t("settings.save.saving") : t("settings.save.save")}
</button>
</div>
</div>
);
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mb-6">
<div className="text-xs uppercase tracking-wide text-muted mb-2">{title}</div>
{children}
</div>
);
}
function Row({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 text-sm">
<Tooltip hint={hint ?? ""}>
<span
className={
hint ? "underline decoration-dotted decoration-muted/40 underline-offset-4 cursor-help" : ""
}
>
{label}
</span>
</Tooltip>
{children}
</div>
);
}
function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
type="button"
onClick={() => onChange(!checked)}
className={`w-9 h-5 rounded-full transition relative shrink-0 ${checked ? "bg-accent" : "bg-border"}`}
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
checked ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
);
}
function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
const { theme, setTheme, view, setView, perf, setPerf, hints, setHints } = prefs;
return (
<>
<Section title={t("settings.appearance.colorScheme")}>
<div className="grid grid-cols-4 gap-2">
{SCHEMES.map((s) => (
<Tooltip key={s.id} hint={s.name}>
<button
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
className={`h-9 w-full rounded-lg border-2 transition ${
theme.scheme === s.id ? "border-fg" : "border-transparent"
}`}
style={{ background: s.swatch }}
/>
</Tooltip>
))}
</div>
</Section>
<Section title={t("settings.appearance.display")}>
<Row label={t("settings.appearance.darkMode")}>
<Switch
checked={theme.mode === "dark"}
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
/>
</Row>
<Row label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch checked={view === "list"} onChange={(v) => setView(v ? "list" : "grid")} />
</Row>
<Row
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
>
<Switch checked={perf} onChange={setPerf} />
</Row>
<Row
label={t("settings.appearance.showHints")}
hint={t("settings.appearance.showHintsHint")}
>
<Switch checked={hints} onChange={setHints} />
</Row>
</Section>
<Section title={t("settings.appearance.textSize")}>
<input
type="range"
min={0.9}
max={1.3}
step={0.02}
value={theme.fontScale}
onChange={(e) => setTheme({ ...theme, fontScale: Number(e.target.value) })}
className="w-full accent-accent"
/>
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
</Section>
</>
);
}
function Notifications({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft (live preview via configureNotifications there); the
// panel's Save bar persists it. The "send test" button below still fires immediately.
const { notif: cfg, setNotif } = prefs;
const update = (p: Partial<NotifSettings>) => setNotif({ ...cfg, ...p });
const autoOn = cfg.durationMs > 0;
return (
<Section title={t("settings.notifications.title")}>
<Row
label={t("settings.notifications.sound")}
hint={t("settings.notifications.soundHint")}
>
<Switch checked={cfg.sound} onChange={(v) => update({ sound: v })} />
</Row>
<Row
label={t("settings.notifications.autoDismiss")}
hint={t("settings.notifications.autoDismissHint")}
>
<Switch checked={autoOn} onChange={(v) => update({ durationMs: v ? 6000 : 0 })} />
</Row>
{autoOn && (
<div className="py-1.5 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted text-xs">{t("settings.notifications.dismissAfter")}</span>
<span className="text-muted text-xs">{(cfg.durationMs / 1000).toFixed(0)}s</span>
</div>
<input
type="range"
min={2000}
max={15000}
step={1000}
value={cfg.durationMs}
onChange={(e) => update({ durationMs: Number(e.target.value) })}
className="w-full accent-accent mt-1"
/>
</div>
)}
<button
onClick={() =>
notify({
level: "info",
title: t("settings.notifications.testTitle"),
message: t("settings.notifications.testMessage"),
sound: true,
})
}
className="mt-2 text-sm text-accent hover:underline"
>
{t("settings.notifications.sendTest")}
</button>
</Section>
);
}
function AccessRow({
title,
granted,
grantedHint,
enableHint,
onEnable,
}: {
title: string;
granted: boolean;
grantedHint: string;
enableHint: string;
onEnable: () => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-start justify-between gap-3 py-2">
<div className="min-w-0">
<div className="text-sm font-medium">{title}</div>
<p className="text-xs text-muted leading-relaxed mt-0.5">
{granted ? grantedHint : enableHint}
</p>
</div>
{granted ? (
<span className="shrink-0 text-[11px] px-2 py-1 rounded-full border border-accent/40 text-accent">
{t("settings.account.granted")}
</span>
) : (
<Tooltip hint={t("settings.account.enableHint")}>
<button
onClick={onEnable}
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
>
{t("settings.account.enable")}
</button>
</Tooltip>
)}
</div>
);
}
function Account({ me, onOpenWizard }: { me: Me; onOpenWizard: () => void }) {
const { t } = useTranslation();
const confirm = useConfirm();
const onDeleteAccount = async () => {
const ok = await confirm({
title: t("settings.account.deleteTitle"),
message: t("settings.account.deleteConfirm"),
confirmLabel: t("settings.account.deleteConfirmButton"),
danger: true,
});
if (!ok) return;
try {
await api.deleteAccount();
window.location.reload(); // session cleared server-side → lands on the welcome page
} catch {
/* the global error dialog surfaces the reason (e.g. last admin) */
}
};
return (
<>
<Section title={t("settings.account.title")}>
<div className="flex items-center gap-3 mb-3">
<Avatar
src={me.avatar_url}
fallback={me.display_name ?? me.email}
className="w-12 h-12 rounded-full"
/>
<div className="min-w-0">
<div className="font-semibold truncate">{me.display_name ?? me.email.split("@")[0]}</div>
<div className="text-xs text-muted truncate">{me.email}</div>
<div className="text-xs text-muted capitalize">{me.role}</div>
</div>
</div>
</Section>
{me.is_demo ? (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed">
{t("settings.account.demoNotice")}
</p>
</Section>
) : (
<Section title={t("settings.account.youtubeAccess")}>
<p className="text-xs text-muted leading-relaxed mb-1">
{t("settings.account.youtubeAccessIntro")}
</p>
<div className="divide-y divide-border">
<AccessRow
title={t("settings.account.readTitle")}
granted={me.can_read}
grantedHint={t("settings.account.readGrantedHint")}
enableHint={t("settings.account.readEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=read";
}}
/>
<AccessRow
title={t("settings.account.writeTitle")}
granted={me.can_write}
grantedHint={t("settings.account.writeGrantedHint")}
enableHint={t("settings.account.writeEnableHint")}
onEnable={() => {
window.location.href = "/auth/upgrade?access=write";
}}
/>
</div>
<button
onClick={onOpenWizard}
className="mt-2 text-sm text-accent hover:underline"
>
{t("settings.account.walkMeThrough")}
</button>
</Section>
)}
{!me.is_demo && (
<Section title={t("settings.account.dangerZone")}>
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.account.deleteHint")}</p>
<button
onClick={onDeleteAccount}
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-red-500/40 text-red-400 hover:bg-red-500/10 transition"
>
<Trash2 className="w-4 h-4" />
{t("settings.account.deleteAccount")}
</button>
</Section>
)}
</>
);
}