storage.ts's registry gives each key a kind (pref, device, view, cache, public); a key without one does not compile. A contract test refuses a key spelled out elsewhere, an account preference read or written straight from storage instead of through usePref, and an e2e seed of a key nobody reads. The keys the next account's wipe keeps are the public ones. The seven preferences kept only in the browser move onto usePref: start on Home, the onboarding dismissal, the release banner's last version, the channel manager's layout, the Plex playlist layout, the playlist rail's sort and channel discovery's sort. usePref reads an older bare-string copy, and a value only this browser held is saved to the server once, so the account's other devices get it. Performance mode is the device's: it is neither read from the account nor saved to it.
901 lines
34 KiB
TypeScript
901 lines
34 KiB
TypeScript
import { useState } from "react";
|
|
import { connectGoogle } from "../lib/googleConnect";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { formatDate, formatDateTime, formatUnit } from "../lib/format";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import {
|
|
Activity,
|
|
Bell,
|
|
Check,
|
|
Download,
|
|
KeyRound,
|
|
Loader2,
|
|
Monitor,
|
|
RefreshCw,
|
|
Trash2,
|
|
User,
|
|
} from "lucide-react";
|
|
import { DownloadFormats } from "./ProfileEditor";
|
|
import DiagnosticsSettings from "./DiagnosticsSettings";
|
|
import ApiTokens from "./ApiTokens";
|
|
import { type SettingsTabId } from "../lib/settingsTab";
|
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
|
import { api, HttpError, type Me } from "../lib/api";
|
|
import { displayName, roleKey } from "../lib/api/account";
|
|
import { notifyActionError } from "../lib/actionErrors";
|
|
import { LS, useTabPersistedState } from "../lib/storage";
|
|
import Avatar from "./ui/Avatar";
|
|
import { notify, type NotifSettings } from "../lib/notifications";
|
|
import Tooltip from "./ui/Tooltip";
|
|
import { Section, SwitchRow } from "./ui/form";
|
|
import { Badge } from "./ui/Badge";
|
|
import { Button } from "./ui/Button";
|
|
import { type SaveState } from "./ui/DraftSaveBar";
|
|
import { useConfirm } from "./confirmContext";
|
|
import { StateMessage } from "./ui/QueryState";
|
|
import { useWizard } from "./wizardContext";
|
|
import { START_ON_HOME_OPTIONS, START_ON_HOME_PREF } from "./navigationInit";
|
|
import { usePref } from "../lib/usePref";
|
|
import { usePrefs } from "./prefsContext";
|
|
import Tabs from "./ui/Tabs";
|
|
import { useTabPanel } from "./ui/tabPanel";
|
|
import { useMe } from "../lib/useMe";
|
|
import { Pressable } from "./ui/Pressable";
|
|
import { Slider } from "./ui/NativeControls";
|
|
import { Input, Textarea } from "./ui/Input";
|
|
import { SURFACE } from "./ui/surfaces";
|
|
|
|
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
|
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
|
|
// outcome. PrefsProvider owns the state + the save; this controller
|
|
// is how the panel reads/writes it.
|
|
export type PrefsSaveState = SaveState;
|
|
export interface PrefsController {
|
|
theme: ThemePrefs;
|
|
setTheme: (t: ThemePrefs) => void;
|
|
perf: boolean;
|
|
setPerf: (v: boolean) => void;
|
|
hints: boolean;
|
|
setHints: (v: boolean) => void;
|
|
railLabels: boolean;
|
|
setRailLabels: (v: boolean) => void;
|
|
notif: NotifSettings;
|
|
setNotif: (n: NotifSettings) => void;
|
|
saveState: PrefsSaveState;
|
|
}
|
|
|
|
type TabId = SettingsTabId;
|
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
|
{ id: "appearance", icon: Monitor },
|
|
{ id: "notifications", icon: Bell },
|
|
{ id: "downloads", icon: Download },
|
|
{ id: "tokens", icon: KeyRound },
|
|
{ id: "account", icon: User },
|
|
{ id: "diagnostics", icon: Activity },
|
|
];
|
|
|
|
// 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() {
|
|
const me = useMe();
|
|
const { t } = useTranslation();
|
|
// The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider.
|
|
const prefs = usePrefs();
|
|
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
|
|
const { openWizard } = useWizard();
|
|
const [tabRaw, setTab] = useTabPersistedState(LS.settingsTab, "appearance");
|
|
const panel = useTabPanel();
|
|
// Clamp at render so a stale/removed tab id falls back to Appearance.
|
|
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
|
|
? (tabRaw as TabId)
|
|
: "appearance";
|
|
|
|
return (
|
|
<div className="p-4 max-w-3xl w-full mx-auto">
|
|
<div className={`${SURFACE.panel} overflow-hidden`}>
|
|
<div className="flex flex-col sm:flex-row">
|
|
{/* Tab rail: a scrolling row above the content on a phone, the side rail from `sm` up (see
|
|
Tabs). Active is a clear accent pill. */}
|
|
<Tabs
|
|
idBase={panel.idBase}
|
|
orientation="vertical"
|
|
className="flex gap-1 overflow-x-auto border-b border-border/60 p-2 sm:w-36 sm:shrink-0 sm:flex-col sm:border-b-0 sm:border-r"
|
|
active={tab}
|
|
onChange={setTab}
|
|
tabs={TABS.map((tabItem) => ({
|
|
id: tabItem.id,
|
|
label: t(`settings.tabs.${tabItem.id}`),
|
|
icon: tabItem.icon,
|
|
testId: `settings-tab-${tabItem.id}`,
|
|
}))}
|
|
/>
|
|
|
|
{/* 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 {...panel.panelProps(tab)} className="flex-1 min-w-0 p-4">
|
|
{tab === "appearance" && <Appearance prefs={prefs} />}
|
|
{tab === "notifications" && <Notifications prefs={prefs} />}
|
|
{tab === "downloads" && <Downloads />}
|
|
{tab === "tokens" && <ApiTokens />}
|
|
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
|
{tab === "diagnostics" && <DiagnosticsSettings />}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Auto-save indicator — a change on any tab persists immediately; this just flashes the
|
|
outcome (the Account tab's actions have their own feedback). */}
|
|
<SaveIndicator state={prefs.saveState} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the
|
|
// bottom of the settings card and fades after a success (the state resets to "idle" in App).
|
|
function SaveIndicator({ state }: { state: PrefsSaveState }) {
|
|
const { t } = useTranslation();
|
|
if (state === "idle") return null;
|
|
return (
|
|
<div
|
|
data-testid="settings-save-indicator"
|
|
data-state={state}
|
|
className="flex items-center justify-end gap-1.5 border-t border-border/60 px-4 py-2.5 text-sm"
|
|
>
|
|
{state === "saving" ? (
|
|
<span className="inline-flex items-center gap-1.5 text-muted">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{t("settings.save.saving")}
|
|
</span>
|
|
) : state === "saved" ? (
|
|
<span className="inline-flex items-center gap-1.5 text-success">
|
|
<Check className="w-4 h-4" />
|
|
{t("settings.save.saved")}
|
|
</span>
|
|
) : (
|
|
<span className="text-danger">{t("settings.save.failed")}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Appearance({ prefs }: { prefs: PrefsController }) {
|
|
const { t } = useTranslation();
|
|
// Controlled by PrefsProvider: each change applies locally at once and auto-saves (debounced).
|
|
const { theme, setTheme, perf, setPerf, hints, setHints, railLabels, setRailLabels } = prefs;
|
|
// The landing page: read at boot (NavigationProvider), so a change applies from the next start.
|
|
const [startOnHome, setStartOnHome] = usePref(START_ON_HOME_PREF, true, START_ON_HOME_OPTIONS);
|
|
|
|
return (
|
|
<>
|
|
<Section title={t("settings.appearance.colorScheme")}>
|
|
{/* One row whatever the count: a fifth scheme wrapped alone onto a second line. */}
|
|
<div className="grid auto-cols-fr grid-flow-col gap-2">
|
|
{SCHEMES.map((s) => (
|
|
<Tooltip key={s.id} hint={s.name}>
|
|
<Pressable
|
|
onClick={() => setTheme({ ...theme, scheme: s.id as Scheme })}
|
|
aria-label={s.name}
|
|
aria-pressed={theme.scheme === s.id}
|
|
className={`h-9 w-full rounded-lg border-2 transition touch-target ${
|
|
theme.scheme === s.id ? "border-fg" : "border-transparent"
|
|
}`}
|
|
style={{ background: s.swatch }}
|
|
/>
|
|
</Tooltip>
|
|
))}
|
|
</div>
|
|
</Section>
|
|
|
|
<Section title={t("settings.appearance.display")}>
|
|
<SwitchRow
|
|
label={t("settings.appearance.darkMode")}
|
|
checked={theme.mode === "dark"}
|
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
|
/>
|
|
<SwitchRow
|
|
label={t("settings.appearance.backgroundImage")}
|
|
hint={t("settings.appearance.backgroundImageHint")}
|
|
checked={theme.bgImage}
|
|
onChange={(v) => setTheme({ ...theme, bgImage: v })}
|
|
/>
|
|
{theme.bgImage && (
|
|
<div className="pl-1 pb-1">
|
|
<div className="flex items-center justify-between text-xs text-muted mb-1">
|
|
<span>{t("settings.appearance.backgroundFade")}</span>
|
|
<span className="tabular-nums">{theme.bgFade}%</span>
|
|
</div>
|
|
<Slider
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={theme.bgFade}
|
|
onChange={(v) => setTheme({ ...theme, bgFade: v })}
|
|
aria-label={t("settings.appearance.backgroundFade")}
|
|
/>
|
|
</div>
|
|
)}
|
|
<SwitchRow
|
|
label={t("settings.appearance.performanceMode")}
|
|
hint={t("settings.appearance.performanceModeHint")}
|
|
checked={perf}
|
|
onChange={setPerf}
|
|
/>
|
|
<SwitchRow
|
|
label={t("settings.appearance.showHints")}
|
|
hint={t("settings.appearance.showHintsHint")}
|
|
checked={hints}
|
|
onChange={setHints}
|
|
/>
|
|
<SwitchRow
|
|
label={t("settings.appearance.railLabels")}
|
|
hint={t("settings.appearance.railLabelsHint")}
|
|
checked={railLabels}
|
|
onChange={setRailLabels}
|
|
/>
|
|
</Section>
|
|
|
|
<Section title={t("settings.appearance.startup")}>
|
|
<SwitchRow
|
|
label={t("settings.appearance.startOnHome")}
|
|
hint={t("settings.appearance.startOnHomeHint")}
|
|
checked={startOnHome}
|
|
onChange={setStartOnHome}
|
|
/>
|
|
</Section>
|
|
|
|
<Section title={t("settings.appearance.textSize")}>
|
|
<Slider
|
|
min={0.9}
|
|
max={1.3}
|
|
step={0.02}
|
|
value={theme.fontScale}
|
|
onChange={(v) => setTheme({ ...theme, fontScale: v })}
|
|
aria-label={t("settings.appearance.textSize")}
|
|
/>
|
|
<div className="text-right text-xs text-muted">{Math.round(theme.fontScale * 100)}%</div>
|
|
</Section>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function Notifications({ prefs }: { prefs: PrefsController }) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
// Controlled by PrefsProvider (live preview via configureNotifications there), which auto-saves
|
|
// it. The "send test" button below 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")}>
|
|
<SwitchRow
|
|
label={t("settings.notifications.sound")}
|
|
hint={t("settings.notifications.soundHint")}
|
|
checked={cfg.sound}
|
|
onChange={(v) => update({ sound: v })}
|
|
/>
|
|
<SwitchRow
|
|
label={t("settings.notifications.autoDismiss")}
|
|
hint={t("settings.notifications.autoDismissHint")}
|
|
checked={autoOn}
|
|
onChange={(v) => update({ durationMs: v ? 6000 : 0 })}
|
|
/>
|
|
{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">
|
|
{formatUnit(Math.round(cfg.durationMs / 1000), "second", lang)}
|
|
</span>
|
|
</div>
|
|
<Slider
|
|
className="mt-1"
|
|
min={2000}
|
|
max={15000}
|
|
step={1000}
|
|
value={cfg.durationMs}
|
|
onChange={(v) => update({ durationMs: v })}
|
|
aria-label={t("settings.notifications.dismissAfter")}
|
|
/>
|
|
</div>
|
|
)}
|
|
<Button
|
|
onClick={() =>
|
|
notify({
|
|
level: "info",
|
|
title: t("settings.notifications.testTitle"),
|
|
message: t("settings.notifications.testMessage"),
|
|
sound: true,
|
|
})
|
|
}
|
|
variant="link"
|
|
className="mt-2 text-sm"
|
|
>
|
|
{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 ? (
|
|
<Badge tone="accent">{t("settings.account.granted")}</Badge>
|
|
) : (
|
|
<Tooltip hint={t("settings.account.enableHint")}>
|
|
<Button variant="glass" size="sm" className="shrink-0" onClick={onEnable}>
|
|
{t("settings.account.enable")}
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Sign-in methods: link a Google account to a password account (or vice-versa set a password),
|
|
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
|
|
// (auth.py attaches the identity to the signed-in account via /auth/google/connect); the password form posts
|
|
// directly with inline errors.
|
|
export function SignInMethods({ me }: { me: Me }) {
|
|
const { t } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const [open, setOpen] = useState(false);
|
|
const [current, setCurrent] = useState("");
|
|
const [next, setNext] = useState("");
|
|
const [err, setErr] = useState<string | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [sessBusy, setSessBusy] = useState(false);
|
|
|
|
// SA4: sign every OTHER session for this account out (this one stays). For a shared/left-behind
|
|
// device or a suspected compromise.
|
|
const logoutOthers = async () => {
|
|
if (
|
|
!(await confirm({
|
|
title: t("settings.account.sessions.confirmTitle"),
|
|
message: t("settings.account.sessions.confirmBody"),
|
|
confirmLabel: t("settings.account.sessions.action"),
|
|
danger: true,
|
|
}))
|
|
)
|
|
return;
|
|
setSessBusy(true);
|
|
try {
|
|
await api.logoutOthers();
|
|
// Every API token went with the other sessions: the Tokens tab asks again when it next opens,
|
|
// rather than trusting its cached list for another half minute.
|
|
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
|
|
notify({ level: "success", message: t("settings.account.sessions.done") });
|
|
} catch (e) {
|
|
notifyActionError(e, t("settings.account.sessions.failed"));
|
|
}
|
|
setSessBusy(false);
|
|
};
|
|
|
|
const submit = async (e: React.SubmitEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
setErr(null);
|
|
setBusy(true);
|
|
const confirmCurrent = me.has_password ? current : undefined;
|
|
try {
|
|
await api.setPassword(next, confirmCurrent);
|
|
setOpen(false);
|
|
setCurrent("");
|
|
setNext("");
|
|
// Refresh `me` so has_password flips and the section switches to "Change password". Every API
|
|
// token went with the old password, so the Tokens tab asks again when it next opens, too.
|
|
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
|
|
await qc.invalidateQueries({ queryKey: qk.me() });
|
|
notify({
|
|
level: "success",
|
|
message: t("settings.account.password.saved", { email: me.email }),
|
|
});
|
|
} catch (e) {
|
|
setErr(
|
|
(e instanceof HttpError ? e.detail : undefined) ?? t("settings.account.password.failed"),
|
|
);
|
|
}
|
|
setBusy(false);
|
|
};
|
|
|
|
// Offer Google only when this instance has Google OAuth configured (or the account is already
|
|
// linked — then we still show its "Connected" status even if the instance creds were removed).
|
|
const showGoogle = me.google_enabled || me.has_google;
|
|
|
|
return (
|
|
<Section title={t("settings.account.signInMethods")}>
|
|
{showGoogle && (
|
|
<div className="flex items-start justify-between gap-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{t("settings.account.googleLink.title")}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{me.has_google
|
|
? t("settings.account.googleLink.connectedHint")
|
|
: t("settings.account.googleLink.connectHint")}
|
|
</p>
|
|
{/* Which address Google actually answers with — sent only when it differs from the
|
|
account's own (see /api/me). "Connected" alone left that unanswerable, and it is
|
|
exactly what someone who registered with a `+tag` needs to know. */}
|
|
{me.google_email && (
|
|
<p className="text-xs text-fg/80 mt-1.5">
|
|
<span className="text-muted">{t("settings.account.googleLink.signedInAs")}</span>{" "}
|
|
{me.google_email}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{me.has_google ? (
|
|
<Badge tone="accent">{t("settings.account.googleLink.connected")}</Badge>
|
|
) : (
|
|
<Button variant="glass" size="sm" onClick={() => connectGoogle("identity")}>
|
|
{t("settings.account.googleLink.connect")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className={showGoogle ? "border-t border-border mt-1 pt-1" : ""}>
|
|
<div className="flex items-start justify-between gap-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{t("settings.account.password.title")}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{me.has_password
|
|
? t("settings.account.password.setHint")
|
|
: t("settings.account.password.unsetHint")}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="glass"
|
|
size="sm"
|
|
aria-expanded={open}
|
|
onClick={() => {
|
|
setOpen((o) => !o);
|
|
setErr(null);
|
|
}}
|
|
>
|
|
{me.has_password
|
|
? t("settings.account.password.change")
|
|
: t("settings.account.password.set")}
|
|
</Button>
|
|
</div>
|
|
{open && (
|
|
<form onSubmit={submit} className="space-y-2 pt-1 pb-1">
|
|
{me.has_password && (
|
|
<Input
|
|
className="w-full"
|
|
type="password"
|
|
value={current}
|
|
onChange={(e) => setCurrent(e.target.value)}
|
|
placeholder={t("settings.account.password.current")}
|
|
autoComplete="current-password"
|
|
/>
|
|
)}
|
|
<Input
|
|
className="w-full"
|
|
type="password"
|
|
value={next}
|
|
onChange={(e) => setNext(e.target.value)}
|
|
placeholder={t("settings.account.password.new")}
|
|
autoComplete="new-password"
|
|
/>
|
|
{err && (
|
|
<p role="alert" className="text-xs text-danger">
|
|
{err}
|
|
</p>
|
|
)}
|
|
<Button type="submit" variant="primary" size="sm" disabled={busy || !next}>
|
|
{busy ? t("settings.account.password.saving") : t("settings.account.password.save")}
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</div>
|
|
|
|
<div className="border-t border-border mt-1 pt-1">
|
|
<div className="flex items-start justify-between gap-3 py-2">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{t("settings.account.sessions.title")}</div>
|
|
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
|
{t("settings.account.sessions.hint")}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="glass"
|
|
size="sm"
|
|
className="shrink-0"
|
|
onClick={logoutOthers}
|
|
disabled={sessBusy}
|
|
>
|
|
{t("settings.account.sessions.action")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// Two-way Plex watch-state sync (Phase A): the owner links their account to the Plex admin account
|
|
// and does a one-time "Plex is master" import. Admin-only (rides the server admin token) and only
|
|
// shown when the Plex module is enabled. Two-way push/pull arrive in later phases.
|
|
function PlexWatchSync() {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
const qc = useQueryClient();
|
|
const link = useQuery({ queryKey: qk.plexWatchLink(), queryFn: () => api.plexWatchLink() });
|
|
const enabled = link.data?.sync_enabled ?? false;
|
|
|
|
const announce = (imp: { watched: number; in_progress: number } | null | undefined) => {
|
|
if (imp) {
|
|
notify({
|
|
level: "success",
|
|
message: t("settings.plexSync.imported", {
|
|
watched: imp.watched,
|
|
in_progress: imp.in_progress,
|
|
}),
|
|
});
|
|
// The browse grid's watch badges read plex_states — refresh them.
|
|
qc.invalidateQueries({ queryKey: qk.plex() });
|
|
}
|
|
};
|
|
|
|
// No onError on these: they're Plex, not YouTube (notifyYouTubeActionError would wrongly say
|
|
// "connect YouTube"). Their 409/500 open the global error dialog and the rest go to the app-wide
|
|
// reporter (lib/queryClient). Actual import failures come back as 200 {error}, handled by announce.
|
|
const toggle = useMutation({
|
|
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
|
meta: { failTitle: "errors.writeFailed.plexWatchLink" },
|
|
onSuccess: (res) => {
|
|
qc.setQueryData(qk.plexWatchLink(), res);
|
|
announce(res.import);
|
|
},
|
|
});
|
|
const reimport = useMutation({
|
|
mutationFn: () => api.plexWatchImport(),
|
|
meta: { failTitle: "errors.writeFailed.plexWatchImport" },
|
|
onSuccess: (res) => {
|
|
qc.setQueryData(qk.plexWatchLink(), res);
|
|
announce(res.import);
|
|
},
|
|
});
|
|
|
|
const busy = toggle.isPending || reimport.isPending;
|
|
// Reflect the toggle's target immediately (optimistic) so the switch responds on click rather than
|
|
// only flipping once the multi-second import returns — otherwise it reads as "nothing happened".
|
|
const shown = toggle.isPending ? Boolean(toggle.variables) : enabled;
|
|
const last = link.data?.last_watch_sync_at
|
|
? formatDateTime(link.data.last_watch_sync_at, lang)
|
|
: null;
|
|
|
|
return (
|
|
<Section title={t("settings.plexSync.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
|
|
<SwitchRow
|
|
label={t("settings.plexSync.toggle")}
|
|
hint={t("settings.plexSync.toggleHint")}
|
|
checked={shown}
|
|
disabled={busy}
|
|
onChange={(v) => !busy && toggle.mutate(v)}
|
|
/>
|
|
{busy ? (
|
|
<div className="mt-2 flex items-center gap-2 text-xs text-muted">
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
{t("settings.plexSync.importing")}
|
|
</div>
|
|
) : (
|
|
enabled && (
|
|
<div className="mt-2 flex items-center justify-between gap-3">
|
|
<p className="text-xs text-muted">
|
|
{last
|
|
? t("settings.plexSync.lastSync", { when: last })
|
|
: t("settings.plexSync.never")}
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
icon={<RefreshCw />}
|
|
loading={reimport.isPending}
|
|
onClick={() => reimport.mutate()}
|
|
>
|
|
{t("settings.plexSync.importNow")}
|
|
</Button>
|
|
</div>
|
|
)
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// Per-user download authentication: store your own login cookies for an auth-walled host
|
|
// (e.g. a private Facebook group you belong to) so downloads from it use YOUR login. Write-only —
|
|
// the stored blob is never shown back; each row shows only the host + whether it's set + when.
|
|
// A cookie-authed download is kept private to your account (never cache-shared with another user).
|
|
// Exported for a focused DOM test (write-only behaviour); rendered on the Downloads tab below.
|
|
export function DownloadAuth() {
|
|
const { t, i18n } = useTranslation();
|
|
const qc = useQueryClient();
|
|
const confirm = useConfirm();
|
|
const q = useQuery({
|
|
queryKey: qk.downloadCredentials(),
|
|
queryFn: () => api.downloadCredentials(),
|
|
});
|
|
const [openHost, setOpenHost] = useState<string | null>(null);
|
|
const [draft, setDraft] = useState("");
|
|
|
|
const save = useMutation({
|
|
mutationFn: ({ host, cookies }: { host: string; cookies: string }) =>
|
|
api.setDownloadCredential(host, cookies),
|
|
onSuccess: (res) => {
|
|
qc.setQueryData(qk.downloadCredentials(), res);
|
|
setOpenHost(null);
|
|
setDraft("");
|
|
notify({ level: "success", message: t("settings.downloadAuth.saved") });
|
|
},
|
|
// Validation errors (empty / not-a-cookies.txt) surface via the global error modal, which
|
|
// localizes the structured { code: "cookies" } detail (see lib/api core localizeDetail); the
|
|
// statuses it does not cover go to the app-wide reporter.
|
|
meta: { failTitle: "settings.downloadAuth.failed" },
|
|
});
|
|
const remove = useMutation({
|
|
mutationFn: (host: string) => api.deleteDownloadCredential(host),
|
|
meta: { failTitle: "errors.writeFailed.downloadCookiesRemove" },
|
|
onSuccess: (res) => {
|
|
qc.setQueryData(qk.downloadCredentials(), res);
|
|
notify({ level: "success", message: t("settings.downloadAuth.removed") });
|
|
},
|
|
});
|
|
|
|
const hosts = q.data?.hosts ?? [];
|
|
const onRemove = async (host: string) => {
|
|
const ok = await confirm({
|
|
title: t("settings.downloadAuth.removeConfirmTitle"),
|
|
message: t("settings.downloadAuth.removeConfirmBody"),
|
|
confirmLabel: t("settings.downloadAuth.removeConfirmAction"),
|
|
danger: true,
|
|
});
|
|
if (ok) remove.mutate(host);
|
|
};
|
|
|
|
return (
|
|
<Section title={t("settings.downloadAuth.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.downloadAuth.intro")}</p>
|
|
{/* The host list is server-provided (always non-empty on success), so an empty `hosts` means
|
|
the fetch failed or is still loading — render an honest state, never a bare empty section
|
|
that reads as "nothing to configure here". */}
|
|
{q.isError && hosts.length === 0 ? (
|
|
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
|
{t("settings.downloadAuth.loadError")}
|
|
</StateMessage>
|
|
) : q.isLoading ? (
|
|
<StateMessage>{t("common.loading")}</StateMessage>
|
|
) : (
|
|
<div className="divide-y divide-border">
|
|
{hosts.map((h) => {
|
|
const when = h.updated_at ? formatDate(h.updated_at, i18n.language) : "";
|
|
const open = openHost === h.host_key;
|
|
return (
|
|
<div key={h.host_key} className="py-2" data-testid={`cookie-host-${h.host_key}`}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-medium">{h.label}</div>
|
|
<p
|
|
className="text-xs text-muted mt-0.5"
|
|
data-testid={`cookie-status-${h.host_key}`}
|
|
>
|
|
{h.configured
|
|
? t("settings.downloadAuth.configured", { when })
|
|
: t("settings.downloadAuth.notSet")}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
{h.configured && (
|
|
<Button
|
|
variant="danger-soft"
|
|
size="sm"
|
|
onClick={() => onRemove(h.host_key)}
|
|
disabled={remove.isPending}
|
|
>
|
|
{t("settings.downloadAuth.remove")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setOpenHost(open ? null : h.host_key);
|
|
setDraft("");
|
|
}}
|
|
>
|
|
{h.configured
|
|
? t("settings.downloadAuth.update")
|
|
: t("settings.downloadAuth.set")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{open && (
|
|
<div className="mt-2 space-y-2">
|
|
{/* Write-only: the field is ALWAYS empty on open — the stored blob is never
|
|
fetched or prefilled. */}
|
|
<Textarea
|
|
className="w-full font-mono resize-y"
|
|
value={draft}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
rows={5}
|
|
placeholder={t("settings.downloadAuth.placeholder")}
|
|
data-testid={`cookie-input-${h.host_key}`}
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
/>
|
|
<p className="text-2xs text-muted leading-relaxed">
|
|
{t("settings.downloadAuth.help")}
|
|
</p>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
disabled={!draft.trim() || save.isPending}
|
|
onClick={() => save.mutate({ host: h.host_key, cookies: draft })}
|
|
>
|
|
{save.isPending
|
|
? t("settings.downloadAuth.saving")
|
|
: t("settings.downloadAuth.save")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setOpenHost(null);
|
|
setDraft("");
|
|
}}
|
|
>
|
|
{t("settings.downloadAuth.cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
/** Who is signed in: name, address, role — and the latest sign-in on any device, so one the user does
|
|
* not recognise is something they can see. */
|
|
export function AccountHeader({ me }: { me: Me }) {
|
|
const { t, i18n } = useTranslation();
|
|
const lang = i18n.language;
|
|
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">{displayName(me)}</div>
|
|
<div className="text-xs text-muted truncate">{me.email}</div>
|
|
{/* Translated, like the sidebar's tooltip: the raw `user` read in English under HU. */}
|
|
<div className="text-xs text-muted">{t(`nav.role.${roleKey(me)}`)}</div>
|
|
<div className="text-xs text-muted" data-testid="account-last-login">
|
|
{me.last_login_at
|
|
? t("settings.account.lastLogin", { time: formatDateTime(me.last_login_at, lang) })
|
|
: t("settings.account.lastLoginNone")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
// How this account downloads: its formats (the download dialog's choices) and its sign-ins to sites
|
|
// that need one. Preferences of the downloading, not of the account's identity.
|
|
function Downloads() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<>
|
|
<Section title={t("settings.downloads.formats")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">
|
|
{t("settings.downloads.formatsIntro")}
|
|
</p>
|
|
<DownloadFormats />
|
|
</Section>
|
|
<DownloadAuth />
|
|
</>
|
|
);
|
|
}
|
|
|
|
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();
|
|
// Session cleared server-side → /api/me 401s → Welcome. The flag shows the confirmation banner.
|
|
window.location.href = "/?deleted=1";
|
|
} catch (e) {
|
|
// A refusal with a reason (the last admin, a 409) is already in the global error dialog, which
|
|
// the reporter skips; anything else used to end the confirmed delete with nothing said.
|
|
notifyActionError(e, t("errors.writeFailed.deleteAccount"));
|
|
}
|
|
};
|
|
return (
|
|
<>
|
|
<AccountHeader me={me} />
|
|
|
|
<SignInMethods me={me} />
|
|
|
|
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
|
|
|
{me.google_enabled ? (
|
|
// YouTube access requires Google OAuth; hidden when this instance has no Google configured.
|
|
<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={() => connectGoogle("read")}
|
|
/>
|
|
<AccessRow
|
|
title={t("settings.account.writeTitle")}
|
|
granted={me.can_write}
|
|
grantedHint={t("settings.account.writeGrantedHint")}
|
|
enableHint={t("settings.account.writeEnableHint")}
|
|
onEnable={() => connectGoogle("write")}
|
|
/>
|
|
</div>
|
|
<Button variant="link" className="mt-2 text-sm" onClick={onOpenWizard}>
|
|
{t("settings.account.walkMeThrough")}
|
|
</Button>
|
|
</Section>
|
|
) : null}
|
|
|
|
<Section title={t("settings.account.dangerZone")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">
|
|
{t("settings.account.deleteHint")}
|
|
</p>
|
|
<Button variant="danger-soft" size="md" onClick={onDeleteAccount}>
|
|
<Trash2 className="w-4 h-4" />
|
|
{t("settings.account.deleteAccount")}
|
|
</Button>
|
|
</Section>
|
|
</>
|
|
);
|
|
}
|