SEC-10 (account pre-emption / "pre-hijack"): a password set on an account whose email was never verified is untrusted — whoever registered the address (to pre-empt the real owner) could have chosen it. Both the Google-adoption callback and admin activation (_activate_user_by_email) now drop that pre-set password and bump the session epoch when the account isn't email_verified, so a later Google login / admin approval can't hand the pre-set password a working login. No-op for a verified account (the legit no-SMTP flow). +2 tests. SEC-11 (link/upgrade bound to the session default): the OAuth link/upgrade GETs resolved to the session DEFAULT account, so a tab acting as a secondary account would link Google / the YouTube grant to the wrong account. The frontend now threads the active account through the navigation URL (withActiveAccount -> ?account=<active>), and _complete_link accepts any wallet account, not only the default, so the identity lands on the account that started the flow.
684 lines
26 KiB
TypeScript
684 lines
26 KiB
TypeScript
import { useState } from "react";
|
|
import { withActiveAccount } from "../lib/activeAccount";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
|
|
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
|
import { api, HttpError, type Me } from "../lib/api";
|
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
|
import Avatar from "./Avatar";
|
|
import { notify, type NotifSettings } from "../lib/notifications";
|
|
import Tooltip from "./Tooltip";
|
|
import { inputCls, Section, SettingRow, Switch } from "./ui/form";
|
|
import { type SaveState } from "./ui/DraftSaveBar";
|
|
import { useConfirm } from "./ConfirmProvider";
|
|
import { useWizard } from "./WizardProvider";
|
|
import { usePrefs } from "./PrefsProvider";
|
|
import Tabs from "./Tabs";
|
|
import { useMe } from "../lib/useMe";
|
|
|
|
// 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. App owns the state + the save; this controller is
|
|
// how the panel reads/writes it. See App.tsx.
|
|
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;
|
|
notif: NotifSettings;
|
|
setNotif: (n: NotifSettings) => 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 },
|
|
];
|
|
|
|
// 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] = useAccountPersistedState(LS.settingsTab, "appearance");
|
|
// 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="glass rounded-2xl overflow-hidden">
|
|
<div className="flex">
|
|
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
|
<Tabs
|
|
orientation="vertical"
|
|
className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1"
|
|
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 className="flex-1 min-w-0 p-4">
|
|
{tab === "appearance" && <Appearance prefs={prefs} />}
|
|
{tab === "notifications" && <Notifications prefs={prefs} />}
|
|
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
|
|
</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 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, 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 })}
|
|
aria-label={s.name}
|
|
aria-pressed={theme.scheme === s.id}
|
|
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")}>
|
|
<SettingRow label={t("settings.appearance.darkMode")}>
|
|
<Switch
|
|
label={t("settings.appearance.darkMode")}
|
|
checked={theme.mode === "dark"}
|
|
onChange={(v) => setTheme({ ...theme, mode: v ? "dark" : "light" })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("settings.appearance.backgroundImage")}
|
|
hint={t("settings.appearance.backgroundImageHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.appearance.backgroundImage")}
|
|
checked={theme.bgImage}
|
|
onChange={(v) => setTheme({ ...theme, bgImage: v })}
|
|
/>
|
|
</SettingRow>
|
|
{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>
|
|
<input
|
|
type="range"
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
value={theme.bgFade}
|
|
onChange={(e) => setTheme({ ...theme, bgFade: Number(e.target.value) })}
|
|
aria-label={t("settings.appearance.backgroundFade")}
|
|
className="w-full accent-accent"
|
|
/>
|
|
</div>
|
|
)}
|
|
<SettingRow
|
|
label={t("settings.appearance.performanceMode")}
|
|
hint={t("settings.appearance.performanceModeHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.appearance.performanceMode")}
|
|
checked={perf}
|
|
onChange={setPerf}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("settings.appearance.showHints")}
|
|
hint={t("settings.appearance.showHintsHint")}
|
|
>
|
|
<Switch label={t("settings.appearance.showHints")} checked={hints} onChange={setHints} />
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("settings.appearance.showTuner")}
|
|
hint={t("settings.appearance.showTunerHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.appearance.showTuner")}
|
|
checked={theme.showTuner}
|
|
onChange={(v) => setTheme({ ...theme, showTuner: v })}
|
|
/>
|
|
</SettingRow>
|
|
</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) })}
|
|
aria-label={t("settings.appearance.textSize")}
|
|
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")}>
|
|
<SettingRow
|
|
label={t("settings.notifications.sound")}
|
|
hint={t("settings.notifications.soundHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.notifications.sound")}
|
|
checked={cfg.sound}
|
|
onChange={(v) => update({ sound: v })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow
|
|
label={t("settings.notifications.autoDismiss")}
|
|
hint={t("settings.notifications.autoDismissHint")}
|
|
>
|
|
<Switch
|
|
label={t("settings.notifications.autoDismiss")}
|
|
checked={autoOn}
|
|
onChange={(v) => update({ durationMs: v ? 6000 : 0 })}
|
|
/>
|
|
</SettingRow>
|
|
{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) })}
|
|
aria-label={t("settings.notifications.dismissAfter")}
|
|
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-2xs 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>
|
|
);
|
|
}
|
|
|
|
// 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 current session via /auth/link); the password form posts
|
|
// directly with inline errors. Demo accounts never see this (handled by the caller).
|
|
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();
|
|
notify({ level: "success", message: t("settings.account.sessions.done") });
|
|
} catch {
|
|
notify({ level: "error", message: t("settings.account.sessions.failed") });
|
|
} finally {
|
|
setSessBusy(false);
|
|
}
|
|
};
|
|
|
|
const submit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setErr(null);
|
|
setBusy(true);
|
|
try {
|
|
await api.setPassword(next, me.has_password ? current : undefined);
|
|
setOpen(false);
|
|
setCurrent("");
|
|
setNext("");
|
|
// Refresh `me` so has_password flips and the section switches to "Change password".
|
|
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"),
|
|
);
|
|
} finally {
|
|
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>
|
|
</div>
|
|
{me.has_google ? (
|
|
<span className="shrink-0 text-2xs px-2 py-1 rounded-full border border-accent/40 text-accent">
|
|
{t("settings.account.googleLink.connected")}
|
|
</span>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
window.location.href = withActiveAccount("/auth/link");
|
|
}}
|
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
|
>
|
|
{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
|
|
onClick={() => {
|
|
setOpen((o) => !o);
|
|
setErr(null);
|
|
}}
|
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition"
|
|
>
|
|
{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
|
|
type="password"
|
|
value={current}
|
|
onChange={(e) => setCurrent(e.target.value)}
|
|
placeholder={t("settings.account.password.current")}
|
|
autoComplete="current-password"
|
|
className={inputCls}
|
|
/>
|
|
)}
|
|
<input
|
|
type="password"
|
|
value={next}
|
|
onChange={(e) => setNext(e.target.value)}
|
|
placeholder={t("settings.account.password.new")}
|
|
autoComplete="new-password"
|
|
className={inputCls}
|
|
/>
|
|
{err && <p className="text-xs text-danger">{err}</p>}
|
|
<button
|
|
type="submit"
|
|
disabled={busy || !next}
|
|
className="px-3 py-1.5 rounded-lg text-sm btn-primary font-medium disabled:opacity-40 transition"
|
|
>
|
|
{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
|
|
onClick={logoutOthers}
|
|
disabled={sessBusy}
|
|
className="shrink-0 glass-card glass-hover px-3 py-1.5 rounded-xl text-sm transition disabled:opacity-40"
|
|
>
|
|
{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 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"), and their 409/500 are already surfaced by the global error modal — a caller
|
|
// toast would double it. Actual import failures come back as 200 {error}, handled by announce.
|
|
const toggle = useMutation({
|
|
mutationFn: (next: boolean) => api.plexWatchSetLink(next),
|
|
onSuccess: (res) => {
|
|
qc.setQueryData(qk.plexWatchLink(), res);
|
|
announce(res.import);
|
|
},
|
|
});
|
|
const reimport = useMutation({
|
|
mutationFn: () => api.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
|
|
? new Date(link.data.last_watch_sync_at).toLocaleString(i18n.language)
|
|
: null;
|
|
|
|
return (
|
|
<Section title={t("settings.plexSync.title")}>
|
|
<p className="text-xs text-muted leading-relaxed mb-2">{t("settings.plexSync.intro")}</p>
|
|
<SettingRow label={t("settings.plexSync.toggle")} hint={t("settings.plexSync.toggleHint")}>
|
|
<Switch checked={shown} disabled={busy} onChange={(v) => !busy && toggle.mutate(v)} />
|
|
</SettingRow>
|
|
{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
|
|
onClick={() => reimport.mutate()}
|
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-xl text-sm border border-border hover:bg-card/60 transition shrink-0"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
{t("settings.plexSync.importNow")}
|
|
</button>
|
|
</div>
|
|
)
|
|
)}
|
|
</Section>
|
|
);
|
|
}
|
|
|
|
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 {
|
|
/* 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 && <SignInMethods me={me} />}
|
|
|
|
{me.role === "admin" && me.plex_enabled && <PlexWatchSync />}
|
|
|
|
{me.is_demo ? (
|
|
<Section title={t("settings.account.youtubeAccess")}>
|
|
<p className="text-xs text-muted leading-relaxed">{t("settings.account.demoNotice")}</p>
|
|
</Section>
|
|
) : 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={() => {
|
|
window.location.href = withActiveAccount("/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 = withActiveAccount("/auth/upgrade?access=write");
|
|
}}
|
|
/>
|
|
</div>
|
|
<button onClick={onOpenWizard} className="mt-2 text-sm text-accent hover:underline">
|
|
{t("settings.account.walkMeThrough")}
|
|
</button>
|
|
</Section>
|
|
) : null}
|
|
|
|
{!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-danger/40 text-danger hover:bg-danger/10 transition"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
{t("settings.account.deleteAccount")}
|
|
</button>
|
|
</Section>
|
|
)}
|
|
</>
|
|
);
|
|
}
|