feat(release): publish minimal self-contained Magent source

This commit is contained in:
Magent release tooling
2026-09-19 16:58:12 +12:00
commit 5fa5d45535
272 changed files with 79305 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
"use client";
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean };
type Option = { value: string; label: string };
type Props = {
setting: AdminSetting;
label: string;
value: string;
help?: string;
placeholder?: string;
boolean?: boolean;
numeric?: boolean;
multiline?: boolean;
options?: Option[];
optionsUnavailable?: boolean;
onChange: (value: string) => void;
};
const SELECTS: Record<string, Option[]> = {
log_level: ["DEBUG", "INFO", "WARNING", "ERROR"].map((value) => ({ value, label: value })),
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({
value: String(index),
label: index === 0 ? "None — close when fixed" : String(index),
})),
issue_confirmation_interval_unit: ["days", "weeks", "months"].map((value) => ({
value,
label: value[0].toUpperCase() + value.slice(1),
})),
artwork_cache_mode: [
{ value: "remote", label: "Load from the internet" },
{ value: "cache", label: "Store locally" },
],
site_banner_tone: ["info", "warning", "error", "maintenance"].map((value) => ({
value,
label: value[0].toUpperCase() + value.slice(1),
})),
magent_notify_push_provider: ["ntfy", "gotify", "pushover", "webhook", "telegram", "discord"].map((value) => ({
value,
label: value,
})),
requests_data_source: [
{ value: "always_js", label: "Read directly from Seerr" },
{ value: "prefer_cache", label: "Use saved requests" },
],
};
const COLOR_DEFAULTS: Record<string, string> = {
site_banner_background_color: "#332814",
site_banner_border_color: "#a27b32",
};
export default function SettingField(props: Props) {
const { setting, label, value, help, placeholder, onChange } = props;
const id = `setting-${setting.key}`;
const options =
props.options ??
SELECTS[setting.key] ??
(setting.key === "log_http_client_level" || setting.key === "log_background_sync_level"
? SELECTS.log_level
: undefined);
const selectedOptions =
options && value && !options.some((option) => option.value === value)
? [{ value, label: `Current selection (${value})` }, ...options]
: options;
const isTime = setting.key === "requests_full_sync_time" || setting.key === "requests_cleanup_time";
const zeroAllowed = setting.key === "log_file_backup_count";
const minimum = zeroAllowed ? 0 : 1;
const maximum =
setting.key === "issue_confirmation_interval_value" ? 365 : setting.key.endsWith("_port") ? 65535 : undefined;
const colorDefault = COLOR_DEFAULTS[setting.key];
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : colorDefault;
const aria = { id, name: setting.key, "aria-describedby": help ? `${id}-help` : undefined };
if (props.boolean) {
return (
<div className="setting-field setting-switch">
<div>
<label htmlFor={id}>{label}</label>
{help && <p id={`${id}-help`}>{help}</p>}
</div>
<input
{...aria}
type="checkbox"
role="switch"
aria-checked={value.toLowerCase() === "true"}
checked={value.toLowerCase() === "true"}
onChange={(event) => onChange(String(event.target.checked))}
/>
</div>
);
}
return (
<div className={`setting-field ${props.multiline ? "field-span-full" : ""}`}>
<label htmlFor={id}>
{label}
{setting.sensitive && setting.isSet && <small>Saved</small>}
</label>
{props.optionsUnavailable ? (
<select {...aria} disabled value={value}>
<option value={value}>Save the connection, then reload available options</option>
</select>
) : colorDefault ? (
<div className="setting-color-control">
<input
id={`${id}-picker`}
type="color"
aria-label={`Choose ${label.toLowerCase()}`}
value={pickerValue}
onChange={(event) => onChange(event.target.value)}
/>
<input
{...aria}
type="text"
value={value}
pattern="#[0-9A-Fa-f]{6}"
placeholder={colorDefault}
autoComplete="off"
spellCheck={false}
onChange={(event) => onChange(event.target.value)}
/>
{value ? (
<button type="button" className="ghost-button" onClick={() => onChange("")}>
Use tone default
</button>
) : null}
</div>
) : selectedOptions ? (
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
{!value && <option value="">Choose an option</option>}
{selectedOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : props.multiline ? (
<textarea
{...aria}
rows={setting.key.includes("_pem") ? 6 : 3}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
) : (
<input
{...aria}
type={setting.sensitive ? "password" : props.numeric ? "number" : isTime ? "time" : "text"}
value={value}
min={props.numeric ? minimum : undefined}
max={props.numeric ? maximum : undefined}
step={props.numeric ? 1 : undefined}
autoComplete={setting.sensitive ? "new-password" : "off"}
spellCheck={false}
placeholder={setting.sensitive && setting.isSet ? "Leave blank to keep the saved value" : placeholder}
onChange={(event) => onChange(event.target.value)}
/>
)}
{help && <p id={`${id}-help`}>{help}</p>}
</div>
);
}
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useState, type ReactNode } from "react";
export default function SettingsRegion({
title,
id,
collapsed,
children,
}: {
title: string;
id: string;
collapsed: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(!collapsed);
return (
<section id={id} className={`admin-section admin-zone config-subsection ${open ? "" : "is-collapsed"}`}>
{collapsed && (
<button
type="button"
className="config-region-toggle"
aria-expanded={open}
aria-controls={`${id}-content`}
onClick={() => setOpen(!open)}
>
<strong>{title}</strong>
<span>
{open ? "Hide" : "Configure"} <b aria-hidden="true">{open ? "" : "+"}</b>
</span>
</button>
)}
<div id={`${id}-content`} hidden={!open}>
{children}
</div>
</section>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { notFound } from "next/navigation";
import SettingsPage from "../SettingsPage";
const ALLOWED_SECTIONS = new Set([
"seerr",
"jellyseerr",
"jellyfin",
"jellystat",
"artwork",
"sonarr",
"radarr",
"bazarr",
"prowlarr",
"qbittorrent",
"requests",
"issue-workflow",
"cache",
"logs",
"maintenance",
"magent",
"general",
"notifications",
"site",
]);
type PageProps = {
params: Promise<{ section: string }>;
};
export default async function AdminSectionPage({ params }: PageProps) {
const { section } = await params;
if (!ALLOWED_SECTIONS.has(section)) {
notFound();
}
return <SettingsPage section={section} />;
}
@@ -0,0 +1,133 @@
.page {
display: grid;
gap: 24px;
min-width: 0;
}
.page h2 {
margin: 0;
font-size: 1.15rem;
}
.page p {
margin: 0;
line-height: 1.65;
}
.summary,
.pending,
.panel {
display: grid;
align-content: start;
gap: 16px;
min-width: 0;
padding: 24px;
border: 1px solid var(--border);
border-radius: 12px;
}
.summary p,
.panel > p,
.muted,
.help {
color: var(--ink-muted);
}
.columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
align-items: start;
}
.pending {
border-color: var(--accent);
border-inline-start-width: 4px;
}
.notice {
padding: 16px;
border: 1px solid var(--accent);
border-radius: 8px;
}
.fields {
display: grid;
gap: 16px;
min-width: 0;
margin: 0;
padding: 0;
border: 0;
}
.legend {
margin-bottom: 16px;
color: var(--ink-muted);
font-size: 0.85rem;
}
.field {
display: grid;
gap: 8px;
font-weight: 600;
}
.field input {
width: 100%;
min-width: 0;
min-height: 44px;
box-sizing: border-box;
}
.field input[type="file"] {
padding: 10px;
font-size: 0.85rem;
}
.checkbox {
display: flex;
align-items: center;
gap: 12px;
min-height: 44px;
}
.checkbox input {
flex: 0 0 auto;
width: 20px;
height: 20px;
margin: 0;
}
.help {
font-size: 0.85rem;
}
.page button {
justify-self: start;
min-height: 44px;
white-space: normal;
}
.page input:focus-visible,
.page button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
@media (max-width: 1000px) {
.columns {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 600px) {
.summary,
.pending,
.panel {
padding: 18px;
}
.page button {
width: 100%;
}
}
+395
View File
@@ -0,0 +1,395 @@
"use client";
import { type FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { apiUrl, requestJson } from "../../lib/api-client";
import { authFetchOrThrow, ForbiddenError, UnauthorizedError } from "../../lib/auth";
import AdminShell from "../../ui/AdminShell";
import styles from "./backups.module.css";
type BackupDetails = {
created_at: string;
build: string;
include_cache: boolean;
};
type BackupStatus = {
format_version: number;
max_upload_bytes: number;
max_expanded_bytes: number;
include_cache_default: boolean;
pending_restore: (BackupDetails & { staged_at: string }) | null;
last_restore: { restored_at: string; rollback_directory: string; status?: string; message?: string } | null;
};
type RestoreResult = {
status: "staged";
restart_required: true;
backup: BackupDetails;
message: string;
};
const dateLabel = (value: string) => {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
};
const sizeLabel = (bytes: number) => `${Math.ceil(bytes / (1024 * 1024))} MiB`;
export default function BackupsPage() {
const router = useRouter();
const [data, setData] = useState<BackupStatus | null>(null);
const [loading, setLoading] = useState(true);
const [revision, setRevision] = useState(0);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState<"export" | "restore" | "cancel" | null>(null);
const [includeCache, setIncludeCache] = useState(false);
const [exportPassphrase, setExportPassphrase] = useState("");
const [confirmPassphrase, setConfirmPassphrase] = useState("");
const [restorePassphrase, setRestorePassphrase] = useState("");
const [confirmation, setConfirmation] = useState("");
const [file, setFile] = useState<File | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const handleError = useCallback(
(cause: unknown, fallback: string) => {
if (cause instanceof UnauthorizedError) {
router.replace("/login?next=%2Fadmin%2Fbackups");
return;
}
if (cause instanceof ForbiddenError) {
router.replace("/");
return;
}
setError(cause instanceof Error ? cause.message : fallback);
},
[router],
);
useEffect(() => {
void revision;
const abort = new AbortController();
setLoading(true);
void requestJson<BackupStatus>("/admin/backups", { signal: abort.signal })
.then((result) => {
if (abort.signal.aborted) return;
setData(result);
setIncludeCache(result.include_cache_default);
})
.catch((cause: unknown) => {
if (!abort.signal.aborted) handleError(cause, "Could not load backup settings.");
})
.finally(() => {
if (!abort.signal.aborted) setLoading(false);
});
return () => abort.abort();
}, [revision, handleError]);
const exportBackup = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (busy) return;
setError("");
setNotice("");
if (exportPassphrase.length < 12 || exportPassphrase.length > 1024) {
setError("Choose a backup passphrase between 12 and 1,024 characters.");
return;
}
if (exportPassphrase !== confirmPassphrase) {
setError("The backup passphrases do not match.");
return;
}
setBusy("export");
try {
const response = await authFetchOrThrow(apiUrl("/admin/backups/export"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passphrase: exportPassphrase, include_cache: includeCache }),
});
if (!response.ok) {
const payload: unknown = await response.json().catch(() => null);
const detail = payload && typeof payload === "object" && "detail" in payload ? payload.detail : null;
throw new Error(typeof detail === "string" ? detail : "Could not create the backup. Please try again.");
}
const blob = await response.blob();
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
const filename = response.headers.get("Content-Disposition")?.match(/filename="?([\w.-]+\.magent-backup)"?/);
link.href = downloadUrl;
link.download = filename?.[1] ?? `magent-${new Date().toISOString().slice(0, 10)}.magent-backup`;
link.hidden = true;
document.body.appendChild(link);
try {
link.click();
} finally {
link.remove();
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1000);
}
setExportPassphrase("");
setConfirmPassphrase("");
setNotice("Your encrypted backup is ready. Check your downloads and store its passphrase somewhere safe.");
} catch (cause) {
handleError(cause, "Could not create the backup.");
} finally {
setBusy(null);
}
};
const restoreBackup = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (busy || !data || data.pending_restore) return;
setError("");
setNotice("");
if (!file || file.size === 0) {
setError("Choose a Magent backup file to restore.");
return;
}
if (file.size > data.max_upload_bytes) {
setError(`The backup must be no larger than ${sizeLabel(data.max_upload_bytes)}.`);
return;
}
if (restorePassphrase.length < 12 || restorePassphrase.length > 1024) {
setError("Enter the backup passphrase, between 12 and 1,024 characters.");
return;
}
if (confirmation !== "RESTORE") {
setError("Type RESTORE to confirm that this backup will replace the current Magent data.");
return;
}
setBusy("restore");
try {
const form = new FormData();
form.append("file", file);
form.append("passphrase", restorePassphrase);
form.append("confirmation", confirmation);
const result = await requestJson<RestoreResult>("/admin/backups/restore", { method: "POST", body: form });
setData((current) =>
current ? { ...current, pending_restore: { ...result.backup, staged_at: new Date().toISOString() } } : current,
);
setRestorePassphrase("");
setConfirmation("");
setFile(null);
if (fileInput.current) fileInput.current.value = "";
setNotice(
"Backup checked and ready to restore. Restart Magent to apply it, or cancel the pending restore below.",
);
} catch (cause) {
handleError(cause, "Could not prepare the restore.");
} finally {
setBusy(null);
}
};
const cancelRestore = async () => {
if (busy) return;
setBusy("cancel");
setError("");
setNotice("");
try {
await requestJson("/admin/backups/restore", { method: "DELETE" });
setData((current) => (current ? { ...current, pending_restore: null } : current));
setNotice("Pending restore cancelled. Your current data is unchanged.");
} catch (cause) {
handleError(cause, "Could not cancel the restore.");
} finally {
setBusy(null);
}
};
return (
<AdminShell title="Backup & restore" subtitle="Save a secure copy of your Magent settings, database, and cache.">
<div className={styles.page}>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className={styles.notice} role="status">
{notice}
</p>
)}
{loading && <p role="status">Loading backup settings...</p>}
{!loading && !data && (
<button
type="button"
className="ghost-button"
onClick={() => {
setError("");
setRevision((current) => current + 1);
}}
>
Try again
</button>
)}
{data && (
<>
<section className={styles.summary} aria-labelledby="backup-contents">
<h2 id="backup-contents">What is saved</h2>
<p>
Every backup includes Magent settings, app connection credentials, branding, and the complete database
with accounts, invites, requests, and cached records. You can also include downloaded artwork caches.
</p>
<p>
Connected apps and media files need their own backups. App credentials configured through the
environment are included, but the deployment environment file, host paths, and signing or encryption
keys are not.
</p>
{data.last_restore && (
<p className={styles.muted}>
{data.last_restore.status === "rolled_back"
? "Last restore was rolled back"
: "Last restore completed"}
: {dateLabel(data.last_restore.restored_at)}.
{data.last_restore.status === "rolled_back" &&
` ${data.last_restore.message || "The previous data was recovered automatically."}`}
</p>
)}
</section>
{data.pending_restore && (
<section className={styles.pending} aria-labelledby="pending-restore-title">
<h2 id="pending-restore-title">Restore ready restart required</h2>
<p>
Backup from {dateLabel(data.pending_restore.created_at)}
{data.pending_restore.build ? ` (build ${data.pending_restore.build})` : ""}. Artwork cache{" "}
{data.pending_restore.include_cache ? "included" : "not included"}.
</p>
<p>
Restart the Magent container or service to apply this backup. Changes made since the backup was
created will be replaced. Afterwards, sign in again with an administrator account from the restored
backup.
</p>
<button type="button" className="ghost-button" onClick={cancelRestore} disabled={!!busy}>
{busy === "cancel" ? "Cancelling..." : "Cancel pending restore"}
</button>
</section>
)}
<div className={styles.columns}>
<section className={styles.panel} aria-labelledby="create-backup-title">
<h2 id="create-backup-title">Create a backup</h2>
<p>Download an encrypted backup file. Keep the file and its passphrase in a safe place.</p>
<p>
Backups must fit within {sizeLabel(data.max_upload_bytes)} encrypted and{" "}
{sizeLabel(data.max_expanded_bytes)} when expanded. If artwork makes your backup too large, leave
artwork caches unchecked.
</p>
<form onSubmit={exportBackup} aria-busy={busy === "export"}>
<fieldset className={styles.fields} disabled={!!busy}>
<legend className={styles.legend}>Backup options</legend>
<label className={styles.checkbox}>
<input
type="checkbox"
checked={includeCache}
onChange={(event) => setIncludeCache(event.target.checked)}
aria-describedby="cache-help"
/>
Include artwork caches
</label>
<p id="cache-help" className={styles.help}>
Adds downloaded images to the backup. This makes the file larger; images can otherwise be fetched
again. Database caches are always included.
</p>
<label className={styles.field}>
Backup passphrase
<input
type="password"
autoComplete="new-password"
value={exportPassphrase}
onChange={(event) => setExportPassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
aria-describedby="backup-passphrase-help"
/>
</label>
<p id="backup-passphrase-help" className={styles.help}>
Use at least 12 characters. This passphrase is separate from your login password. A lost
passphrase cannot be recovered.
</p>
<label className={styles.field}>
Confirm backup passphrase
<input
type="password"
autoComplete="new-password"
value={confirmPassphrase}
onChange={(event) => setConfirmPassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
/>
</label>
<button type="submit">
{busy === "export" ? "Preparing backup..." : "Download encrypted backup"}
</button>
</fieldset>
</form>
</section>
<section className={styles.panel} aria-labelledby="restore-backup-title">
<h2 id="restore-backup-title">Restore a backup</h2>
<p>
Restoring replaces Magent&apos;s settings and database, including users and invites. Download a
current backup first if you want to keep these changes.
</p>
<form onSubmit={restoreBackup} aria-busy={busy === "restore"}>
<fieldset className={styles.fields} disabled={!!busy || !!data.pending_restore}>
<legend className={styles.legend}>Choose and confirm a backup</legend>
<label className={styles.field}>
Backup file
<input
ref={fileInput}
type="file"
accept=".magent-backup,application/octet-stream"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
required
aria-describedby="backup-file-help"
/>
</label>
<p id="backup-file-help" className={styles.help}>
Choose a .magent-backup file, up to {sizeLabel(data.max_upload_bytes)}.
</p>
<label className={styles.field}>
Backup passphrase
<input
type="password"
autoComplete="off"
value={restorePassphrase}
onChange={(event) => setRestorePassphrase(event.target.value)}
minLength={12}
maxLength={1024}
required
/>
</label>
<label className={styles.field}>
Type RESTORE to confirm replacement
<input
type="text"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
pattern="RESTORE"
required
aria-describedby="restore-restart-help"
/>
</label>
<p id="restore-restart-help" className={styles.help}>
The backup is checked before being queued. It only takes effect when you restart Magent; you can
cancel before then. You will need to sign in using an account from the backup.
</p>
<button type="submit" className="danger-button">
{busy === "restore" ? "Checking and uploading..." : "Prepare restore"}
</button>
</fieldset>
</form>
</section>
</div>
</>
)}
</div>
</AdminShell>
);
}
+111
View File
@@ -0,0 +1,111 @@
/* Settings workspace. Shared Stitch tokens, compact controls and clear regions. */
.config-directory { display: grid; gap: 32px; max-width: 1120px; }
.config-directory-region { display: grid; gap: 16px; }
.config-directory-region header h2 { margin: 0 0 4px; font-size: 18px; }
.config-directory-region header p { margin: 0; color: var(--ops-muted); font-size: 13px; }
.config-directory-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.config-directory-link { display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); color: var(--ops-text); text-decoration: none; min-width: 0; transition: border-color .15s, background .15s; }
.config-directory-link:hover { border-color: var(--ops-primary-2); background: var(--ops-panel-2); }
.config-link-icon { flex: 0 0 34px; display: grid; place-items: center; height: 34px; border-radius: 8px; background: var(--ops-primary); color: var(--ops-primary-2); font: 11px "JetBrains Mono", monospace; }
.config-link-copy { display: grid; flex: 1; min-width: 0; gap: 4px; }
.config-link-copy strong { font-size: 14px; }
.config-link-copy small { font-size: 12px; font-weight: 400; color: var(--ops-muted); line-height: 1.5; }
.config-link-arrow { color: var(--ops-faint); }
.config-connection-badge { flex-shrink: 0; font: 11px "JetBrains Mono", monospace; color: var(--ops-muted); }
.config-connection-badge::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; }
.config-connection-badge.is-up { color: var(--ops-green); }
.config-connection-badge.is-down { color: var(--ops-red); }
.config-connection-badge.is-degraded { color: var(--ops-warn); }
.config-advanced-directory { border: 1px solid var(--ops-line); border-radius: 10px; padding: 18px; }
.config-advanced-directory > summary { cursor: pointer; color: var(--ops-text); }
.config-advanced-directory > summary > span { margin-left: 12px; color: var(--ops-muted); font-size: 12px; }
.config-advanced-directory[open] > summary { margin-bottom: 18px; }
.config-sidebar-home { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px 20px; font: 600 20px "DM Sans", sans-serif; text-decoration: none; color: var(--ops-primary-2); }
.config-sidebar-back { display: block; margin-top: 20px; padding: 12px 10px; color: var(--ops-muted); font-size: 12px; }
.config-desktop-navigation { display: grid; gap: 18px; }
.admin-sidebar .admin-nav-links a { font: 13px Inter, sans-serif; padding: 8px 10px; min-height: 34px; }
.admin-sidebar .admin-nav-title { font-size: 10px; }
.config-nav-advanced > summary { cursor: pointer; padding: 8px 10px; font-size: 12px; color: var(--ops-muted); }
.config-mobile-picker { display: none; }
.admin-card { min-width: 0; }
.admin-shell { grid-template-areas: "nav main"; }
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main rail"; }
.admin-shell--no-rail > .admin-card { width: 100%; max-width: 1280px; }
.admin-card .admin-header { margin-bottom: 24px; }
.admin-card .admin-header .lede { max-width: 720px; margin: 8px 0 0; font-size: 14px; }
.admin-card .admin-header .section-kicker { font-size: 10px; }
.config-service-status { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 20px; font-size: 12px; color: var(--ops-muted); }
.config-service-status button { margin-left: auto; }
.admin-form.admin-zone-stack { gap: 16px; }
.admin-form .config-subsection { padding: 22px !important; }
.config-subsection form { display: grid; gap: 18px; min-width: 0; }
.config-subsection .section-header { margin: 0; padding: 0; border: 0; align-items: center; }
.config-subsection .section-header h2 { font-size: 18px; padding: 0; }
.config-subsection .section-header h2::after { display: none; }
.config-subsection .section-subtitle { margin: -10px 0 0; font-size: 12px; line-height: 1.6; }
.config-subsection .admin-grid { gap: 20px 24px; }
.config-subsection .setting-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.config-subsection .setting-field > label, .config-subsection .setting-switch label { display: flex; align-items: center; gap: 10px; min-height: 0; padding: 0; margin: 0; border: 0; border-radius: 0; background: none; color: var(--ops-text); font: 500 13px Inter, sans-serif; text-transform: none; letter-spacing: 0; }
.config-subsection .setting-field label small { color: var(--ops-green); font-size: 11px; font-weight: 400; }
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
.config-subsection .setting-color-control { display: grid; grid-template-columns: 52px minmax(140px, 1fr) auto; align-items: center; gap: 8px; }
.config-subsection .setting-color-control input[type=color] { width: 52px; min-width: 52px; padding: 4px; cursor: pointer; }
.config-subsection .setting-color-control .ghost-button { min-height: 42px; padding: 9px 12px; white-space: nowrap; }
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
.setting-switch > div { display: grid; gap: 6px; }
.config-subsection .setting-switch input[type=checkbox] { appearance: none; -webkit-appearance: none; flex: 0 0 38px; width: 38px; height: 22px; min-height: 22px; padding: 2px; margin: 0; background: var(--ops-panel-3) !important; border: 1px solid var(--ops-line); border-radius: 20px !important; cursor: pointer; }
.config-subsection .setting-switch input[type=checkbox]::before { content: ''; display: block; width: 16px; height: 16px; background: var(--ops-muted); border-radius: 50%; transition: transform .15s; }
.config-subsection .setting-switch input[type=checkbox]:checked { background: var(--ops-primary-2) !important; border-color: var(--ops-primary-2) !important; }
.config-subsection .setting-switch input[type=checkbox]:checked::before { background: var(--ops-primary); transform: translateX(16px); }
.config-subsection .settings-section-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; padding-top: 16px; border-top: 1px solid var(--ops-line); }
.config-subsection .settings-inline-field { padding: 0; border: 0; background: none; min-height: 0; flex: 1 1 230px; max-width: 330px; }
.config-subsection .settings-inline-field span { font: 500 12px Inter, sans-serif; text-transform: none; }
.config-subsection button { font: 600 12px "DM Sans", "Segoe UI", sans-serif; min-height: 38px; }
.config-subsection .config-unsaved { margin-right: auto; font-size: 12px; color: var(--ops-warn); }
.config-subsection .config-region-toggle { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border: 0; background: transparent !important; padding: 0; color: var(--ops-text); text-align: left; box-shadow: none; }
.config-region-toggle strong { font-size: 15px; }
.config-region-toggle > span { font-size: 12px; color: var(--ops-muted); }
.config-region-toggle + div:not([hidden]) { margin-top: 18px; }
.config-subsection [hidden] { display: none !important; }
.config-region-toggle + div .config-subsection-heading { display: none; }
.config-inline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.config-inline-controls label { display: flex; align-items: center; gap: 8px; }
.config-inline-controls select { width: auto; }
.admin-card .maintenance-layout { grid-template-columns: 1fr; }
.admin-card .cache-table, .admin-card .log-viewer { overflow-x: auto; max-width: 100%; }
.admin-card .cache-row { min-width: 650px; }
.config-tool-link { padding: 16px 0; color: var(--ops-primary-2); font-size: 13px; }
@media (max-width: 1250px) {
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main"; }
}
@media (max-width: 1180px) {
.config-directory-links { grid-template-columns: 1fr; }
}
@media (max-width: 980px) {
.admin-shell-nav .admin-sidebar { display: block; padding: 12px 18px; }
.config-desktop-navigation { display: none; }
.config-mobile-picker { display: flex; align-items: center; gap: 16px; margin: 0; }
.config-mobile-picker > span { font: 500 12px Inter, sans-serif; color: var(--ops-muted); }
.config-mobile-picker select { flex: 1; width: 100%; min-width: 0; padding: 10px; font: 13px Inter, sans-serif; }
}
@media (max-width: 680px) {
.admin-form .admin-grid { grid-template-columns: 1fr; }
.admin-form .config-subsection { padding: 16px !important; }
.config-subsection .setting-color-control { grid-template-columns: 52px minmax(0, 1fr); }
.config-subsection .setting-color-control .ghost-button { grid-column: 1 / -1; }
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
.config-link-copy { flex-basis: calc(100% - 62px); }
.config-directory-link .config-connection-badge { margin-left: 46px; }
.config-link-arrow { display: none; }
.config-advanced-directory > summary > span { display: block; margin: 8px 0 0; }
.admin-card .admin-header { align-items: flex-start; gap: 14px; flex-direction: column; }
.config-subsection .section-header { align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.config-subsection .section-subtitle { margin-top: 0; }
.config-subsection .settings-section-actions > button { flex-grow: 1; }
.config-subsection .settings-section-actions .config-unsaved { flex-basis: 100%; }
.config-service-status { gap: 10px; }
}
+106
View File
@@ -0,0 +1,106 @@
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string };
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] };
export const CONFIG_GROUPS: ConfigGroup[] = [
{
title: "Media services",
description: "Connect the services that collect, repair and play your content.",
items: [
{ href: "/admin/seerr", label: "Seerr", description: "Requests and approvals", symbol: "SE", service: "Seerr" },
{
href: "/admin/jellyfin",
label: "Jellyfin",
description: "Playback and library availability",
symbol: "JF",
service: "Jellyfin",
},
{
href: "/admin/jellystat",
label: "Jellystat",
description: "Personal viewing statistics",
symbol: "JS",
service: "Jellystat",
},
{
href: "/admin/sonarr",
label: "Sonarr",
description: "TV collection and quality",
symbol: "SO",
service: "Sonarr",
},
{
href: "/admin/radarr",
label: "Radarr",
description: "Movie collection and quality",
symbol: "RA",
service: "Radarr",
},
{ href: "/admin/bazarr", label: "Bazarr", description: "Subtitle repairs", symbol: "BA", service: "Bazarr" },
{ href: "/admin/prowlarr", label: "Prowlarr", description: "Search sources", symbol: "PR", service: "Prowlarr" },
{
href: "/admin/qbittorrent",
label: "qBittorrent",
description: "Download progress and recovery",
symbol: "QB",
service: "qBittorrent",
},
],
},
{
title: "Preferences & access",
description: "Set the experience for your users and how issues are followed up.",
items: [
{ href: "/admin/site", label: "Site & sign-in", description: "Announcements and login options" },
{
href: "/admin/notifications",
label: "Email & notifications",
description: "Invites, password resets and repair updates",
},
{
href: "/admin/recaps",
label: "Monthly email recaps",
description: "Personal viewing emails, schedule and delivery history",
},
{
href: "/admin/newsletters",
label: "Newsletters",
description: "New arrivals, featured picks and weekly editions",
},
{
href: "/admin/issue-workflow",
label: "Issue follow-up",
description: "Confirmation emails and automatic closure",
},
{ href: "/admin/requests", label: "Request updates", description: "Refresh schedule and history retention" },
{ href: "/users", label: "User management", description: "Accounts, permissions, identity checks and repairs" },
{ href: "/admin/invites", label: "Invite policy & access", description: "Defaults, profiles and issued invites" },
],
},
{
title: "Advanced tools",
description: "Hosting and troubleshooting.",
advanced: true,
items: [
{ href: "/admin/general", label: "Hosting & proxy", description: "Public addresses and deployment options" },
{ href: "/setup", label: "Setup wizard", description: "Guided app connections and installation preferences" },
{
href: "/admin/backups",
label: "Backup & restore",
description: "Encrypted settings, database and cache backups",
},
{ href: "/admin/diagnostics", label: "System health", description: "Service checks and diagnostics" },
{ href: "/admin/logs", label: "Logs", description: "Recent activity and log settings" },
{ href: "/admin/cache", label: "Request cache", description: "Inspect saved request records" },
{ href: "/admin/artwork", label: "Artwork cache", description: "Poster storage and missing artwork" },
{ href: "/admin/maintenance", label: "Recovery & cleanup", description: "Database repair and history cleanup" },
],
},
];
export const serviceStatusLabel = (status?: string) =>
({
up: "Connected",
down: "Unavailable",
degraded: "Needs attention",
not_configured: "Not set up",
})[status ?? ""] ?? "Not checked";
+12
View File
@@ -0,0 +1,12 @@
"use client";
import AdminShell from "../../ui/AdminShell";
import AdminDiagnosticsPanel from "../../ui/AdminDiagnosticsPanel";
export default function AdminDiagnosticsPage() {
return (
<AdminShell title="Diagnostics" subtitle="Check connections and investigate service problems.">
<AdminDiagnosticsPanel />
</AdminShell>
);
}
@@ -0,0 +1,232 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../../lib/auth";
import { FEATURES, type FeatureAccess } from "../../lib/features";
import type { Row } from "./IdentityReviewPanel";
type Account = {
id: number;
username: string;
email: string | null;
profile_id: number | null;
last_login_at: string | null;
};
type Preview = {
accounts: Account[];
keep_id: number;
recommended_id: number;
revision: string;
can_confirm: boolean;
issues: string[];
proposed: Account & {
jellyfin_user_id: string;
seerr_user_id: number;
features: FeatureAccess;
expires_at: string | null;
is_blocked: boolean;
auto_search_enabled: boolean;
};
};
export default function DuplicateAccountRepair({
row,
onClose,
onSaved,
}: {
row: Row;
onClose: () => void;
onSaved: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const controller = useRef<AbortController | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [acknowledged, setAcknowledged] = useState(false);
const [error, setError] = useState("");
const submit = async (confirm = false, keepId?: number) => {
const abort = new AbortController();
controller.current?.abort();
controller.current = abort;
setError("");
setAcknowledged(false);
if (confirm) setSaving(true);
else setBusy(true);
try {
const response = await authFetch(`${getApiBase()}/admin/identities/duplicates/${confirm ? "confirm" : "check"}`, {
method: "POST",
signal: abort.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: row.user.id,
...(keepId ? { keep_id: keepId } : {}),
...(confirm ? { keep_id: preview?.keep_id, revision: preview?.revision } : {}),
}),
});
const data = await response.json();
if (!response.ok)
throw new Error(typeof data.detail === "string" ? data.detail : "Could not review these accounts.");
if (!abort.signal.aborted) {
if (confirm) onSaved();
else setPreview(data);
}
} catch (err) {
if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : "Repair failed. Preview again.");
setPreview(null);
}
} finally {
if (!abort.signal.aborted) {
setBusy(false);
setSaving(false);
}
}
};
// biome-ignore lint/correctness/useExhaustiveDependencies: The dialog preview runs once when this keyed modal mounts.
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.current?.showModal();
void submit();
return () => {
controller.current?.abort();
document.body.style.overflow = overflow;
previous?.focus();
};
}, []);
return (
<dialog
ref={dialog}
className="identity-resolve-dialog"
aria-labelledby="duplicates-title"
onCancel={(event) => {
event.preventDefault();
if (!saving) onClose();
}}
>
<div className="identity-resolve-content">
<header>
<h2 id="duplicates-title">Repair duplicate accounts</h2>
<button type="button" className="ghost-button" disabled={saving} onClick={onClose}>
Close
</button>
</header>
<p>
Review the Magent accounts for <strong>{row.user.username}</strong>. This repair keeps one account linked to
the verified Jellyfin identity.
</p>
{busy && <p role="status">Checking live service IDs and duplicate ownership...</p>}
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{!preview && !busy && (
<button type="button" disabled={saving} onClick={() => void submit()}>
Check again
</button>
)}
{preview && (
<section className="identity-confirm-panel" aria-label="Duplicate repair preview">
<label>
Magent account to keep
<select
disabled={busy || saving}
value={preview.keep_id}
onChange={(event) => void submit(false, Number(event.target.value))}
>
{preview.accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.username} Magent {account.id}
{account.id === preview.recommended_id ? " (recommended)" : ""}
</option>
))}
</select>
</label>
<p>The recommended row already owns the Jellyfin link, or is the oldest row when neither owns it.</p>
<div className="identity-mapping identity-duplicate-accounts">
{preview.accounts.map((account) => (
<div key={account.id}>
<strong>
Magent {account.id}
{account.id === preview.keep_id ? " · Keep" : " · Consolidate"}
</strong>
<p>{account.username}</p>
<p>
{account.email || "No email"} · Profile {account.profile_id ?? "None"}
</p>
<p>
Last login: {account.last_login_at ? new Date(account.last_login_at).toLocaleString() : "Never"}
</p>
</div>
))}
</div>
<h3>Resulting account</h3>
<p>
<strong>{preview.proposed.username}</strong> · Magent {preview.keep_id} · Seerr{" "}
{preview.proposed.seerr_user_id ?? "Not verified"}
</p>
<p>
Jellyfin / Jellystat: <code>{preview.proposed.jellyfin_user_id ?? "Not verified"}</code>
</p>
<p>
Email: {preview.proposed.email || "None"} · Profile: {preview.proposed.profile_id ?? "None"}
</p>
<p>
Access: {preview.proposed.is_blocked ? "Blocked" : "Not blocked"} · Expiry:{" "}
{preview.proposed.expires_at ? new Date(preview.proposed.expires_at).toLocaleString() : "None"} ·
Automatic search: {preview.proposed.auto_search_enabled ? "Enabled" : "Disabled"}
</p>
<ul>
{FEATURES.map((feature) => (
<li key={feature.key}>
{feature.label}: {preview.proposed.features[feature.key] ? "Enabled" : "Disabled"}
</li>
))}
</ul>
<p>
Request, issue, invitation and login activity history is retained. The selected account keeps its email
and profile. Any block, earlier expiry or disabled permission on either row is preserved.
</p>
<p>
Extra Magent rows are removed from the active directory after their details are archived. Their
outstanding emails are cancelled and their email subscriptions are not inherited. The kept account retains
its own subscriptions where still eligible. Password reset links must be requested again.
</p>
<p>
Jellyfin, Seerr and Jellystat accounts and media are unchanged. This action does not merge different
Jellyfin identities or delete upstream users.
</p>
{preview.issues.length > 0 && (
<ul className="identity-issues">
{preview.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
<label className="identity-import-option">
<span>
<input
type="checkbox"
checked={acknowledged}
disabled={busy || saving || !preview.can_confirm}
onChange={(event) => setAcknowledged(event.target.checked)}
/>{" "}
I confirm these rows belong to the same person and have reviewed the account to keep.
</span>
</label>
<button
type="button"
disabled={!preview.can_confirm || !acknowledged || busy || saving}
onClick={() => void submit(true)}
>
{saving ? "Rechecking and repairing..." : "Confirm duplicate repair"}
</button>
</section>
)}
</div>
</dialog>
);
}
@@ -0,0 +1,506 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../../lib/auth";
import "./identities.css";
import DuplicateAccountRepair from "./DuplicateAccountRepair";
import ResolveIdentityLink from "./ResolveIdentityLink";
type Identity = { id: string; name: string };
export type Row = {
user: { id: number; username: string; role: string; auth_provider: string; jellyseerr_user_id: number | null };
jellyfin: Identity | null;
candidate_jellyfin_id: string | null;
stored_jellyfin_id: string | null;
seerr: { id: number; name: string; jellyfin_id: string }[];
jellystat: { state: string; id?: string; name?: string };
basis: string;
issues: string[];
state: string;
can_confirm: boolean;
confirmed_at: string | null;
};
type Report = {
revision: string;
checked_at: string;
server_id: string | null;
services: Record<string, string>;
counts: Record<string, number>;
jellyfin_users: Identity[];
rows: Row[];
upstream: { platform: string; id: string; name: string; jellyfin_id: string | null; detail: string }[];
};
const labels: Record<string, string> = {
ready: "Ready to review",
confirmed: "Confirmed",
conflict: "Conflict",
unlinked: "Missing link",
unavailable: "Check incomplete",
};
const serviceLabels: Record<string, string> = {
available: "Checked",
unavailable: "Unavailable",
not_configured: "Not configured",
not_checked: "No IDs to check",
};
const basisLabels: Record<string, string> = {
confirmed_id: "Confirmed Jellyfin ID",
stored_jellyfin_id: "Stored Jellyfin ID",
stored_seerr_id: "Seerrs Jellyfin ID",
suggested_username: "Suggested from Jellyfin username — review before saving",
none: "No identity match",
};
const statsLabels: Record<string, string> = {
matched: "ID matches",
missing: "ID not found",
unavailable: "Could not check",
not_configured: "Not configured",
not_checked: "No ID to check",
};
export default function IdentityReviewPanel() {
const router = useRouter();
const [ready, setReady] = useState(false);
const [report, setReport] = useState<Report | null>(null);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [query, setQuery] = useState("");
const [filter, setFilter] = useState("all");
const [selected, setSelected] = useState<number[]>([]);
const [duplicates, setDuplicates] = useState<Row | null>(null);
const [resolving, setResolving] = useState<Row | null>(null);
const [reviewing, setReviewing] = useState(false);
const controller = useRef<AbortController | null>(null);
const reviewPanel = useRef<HTMLElement | null>(null);
useEffect(() => {
setQuery(new URLSearchParams(window.location.search).get("user") ?? "");
const abort = new AbortController();
void authFetch(`${getApiBase()}/auth/me`, { signal: abort.signal })
.then(async (response) => {
if (response.status === 401) {
router.replace("/login");
return;
}
if (!response.ok) throw new Error("Could not check administrator access. Refresh to try again.");
if ((await response.json()).role !== "admin") {
router.replace("/");
return;
}
if (!abort.signal.aborted) setReady(true);
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => {
abort.abort();
controller.current?.abort();
};
}, [router]);
useEffect(() => {
if (reviewing) reviewPanel.current?.focus();
}, [reviewing]);
const responseData = async (response: Response) => {
if (response.status === 401) {
router.replace("/login");
throw new Error("Your session has ended. Sign in again.");
}
if (response.status === 403) {
router.replace("/");
throw new Error("Administrator access is required.");
}
const data = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof data.detail === "string" ? data.detail : "The identity check could not complete. Try again.",
);
return data;
};
const runCheck = async () => {
controller.current?.abort();
const abort = new AbortController();
controller.current = abort;
setBusy(true);
setError("");
setNotice("");
setSelected([]);
setReviewing(false);
setReport(null);
try {
const data = await responseData(await authFetch(`${getApiBase()}/admin/identities`, { signal: abort.signal }));
if (!abort.signal.aborted) setReport(data);
} catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not check identities.");
} finally {
if (!abort.signal.aborted) setBusy(false);
}
};
const save = async () => {
if (!report || saving || !selected.length) return;
setSaving(true);
setError("");
setNotice("");
try {
const data = await responseData(
await authFetch(`${getApiBase()}/admin/identities/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ revision: report.revision, user_ids: selected }),
}),
);
setNotice(
`${data.confirmed} account ${data.confirmed === 1 ? "link" : "links"} confirmed and saved. Run another check to see the updated mappings.`,
);
// The scan describes the previous database state and cannot be reused for another write.
setReport(null);
setSelected([]);
setReviewing(false);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save identity links.");
setReport(null);
setSelected([]);
setReviewing(false);
} finally {
setSaving(false);
}
};
const needle = query.trim().toLowerCase();
const filtered =
report?.rows.filter(
(row) =>
(filter === "all" || row.state === filter) &&
[
row.user.username,
row.user.id,
row.candidate_jellyfin_id,
row.user.jellyseerr_user_id,
...row.seerr.map((entry) => entry.id),
]
.join(" ")
.toLowerCase()
.includes(needle),
) ?? [];
const selectedRows = report?.rows.filter((row) => selected.includes(row.user.id)) ?? [];
const eligible = filtered.filter((row) => row.can_confirm).map((row) => row.user.id);
const toggle = (id: number) => {
setReviewing(false);
setSelected((current) => (current.includes(id) ? current.filter((value) => value !== id) : [...current, id]));
};
return (
<div className="identity-review">
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className="status-banner" role="status">
{notice}
</p>
)}
{!ready && !error && <p role="status">Checking administrator access</p>}
{ready && (
<>
<section className="identity-intro admin-panel">
<div>
<h2>Confirm user IDs</h2>
<p>
Jellyfins server and user IDs identify each account. Seerr and Jellystat are checked against that same
user ID.
</p>
<p>
Review the proposed links before saving. Repair incorrect Magent links after reviewing the IDs.
Duplicate ownership and upstream changes require individual review.
</p>
</div>
<button type="button" onClick={runCheck} disabled={busy || saving}>
{busy ? "Checking all accounts…" : report ? "Run check again" : "Check all user IDs"}
</button>
</section>
{busy && (
<p role="status">
Reading the live user directories and checking Jellystat IDs. This can take up to a minute.
</p>
)}
{report && (
<>
<div className="identity-service-strip">
{Object.entries(report.services).map(([service, state]) => (
<span key={service}>
<strong>{service === "seerr" ? "Seerr" : service === "jellyfin" ? "Jellyfin" : "Jellystat"}</strong>{" "}
{serviceLabels[state] ?? state}
</span>
))}
</div>
<p className="identity-meta">
Checked {new Date(report.checked_at).toLocaleString()} · Jellyfin server{" "}
<code>{report.server_id ?? "Unavailable"}</code>
</p>
<div className="identity-counts">
{["magent", "ready", "confirmed", "conflict", "unlinked", "unavailable"].map((state) => (
<div key={state}>
<strong>{report.counts[state]}</strong>
<span>{state === "magent" ? "Magent accounts" : labels[state]}</span>
</div>
))}
</div>
<p className="identity-meta">
Includes duplicate Magent rows hidden in the user directory. Jellystat checks cover IDs found in
Jellyfin, Seerr and stored Magent links; historical Jellystat-only IDs are outside this check.
</p>
<div className="identity-filters">
<label>
Find an account
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Username or user ID"
disabled={saving}
/>
</label>
<label>
Show
<select value={filter} onChange={(event) => setFilter(event.target.value)} disabled={saving}>
<option value="all">All accounts</option>
{Object.entries(labels).map(([state, label]) => (
<option key={state} value={state}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="identity-selection">
<span>
{filtered.length} accounts shown · {selected.length} selected
</span>
<button
type="button"
className="ghost-button"
disabled={saving || !eligible.length}
onClick={() => {
setSelected((current) => [...new Set([...current, ...eligible])]);
setReviewing(false);
}}
>
Select ready accounts shown
</button>
<button
type="button"
className="ghost-button"
disabled={saving || !selected.length}
onClick={() => {
setSelected([]);
setReviewing(false);
}}
>
Clear selection
</button>
<button type="button" disabled={saving || !selected.length} onClick={() => setReviewing(true)}>
Review selected links ({selected.length})
</button>
</div>
{reviewing && (
<section
className="identity-confirm-panel"
ref={reviewPanel}
tabIndex={-1}
aria-label="Review links before saving"
>
<h2>Save these {selected.length} account links?</h2>
<p>
Each selected Magent account will be linked to the Jellyfin ID and Seerr ID shown below. The live
IDs will be checked again before saving.
</p>
<ul>
{selectedRows.map((row) => (
<li key={row.user.id}>
<strong>{row.user.username}</strong> · Magent {row.user.id} Jellyfin{" "}
<code>{row.candidate_jellyfin_id}</code> Seerr {row.seerr[0].id}
</li>
))}
</ul>
<p>
Saving links does not merge or delete accounts. Existing requests and playback history stay with
their service IDs.
</p>
<div className="identity-confirm-actions">
<button type="button" onClick={save} disabled={saving}>
{saving ? "Rechecking and saving…" : "Confirm and save links"}
</button>
<button
type="button"
className="ghost-button"
disabled={saving}
onClick={() => setReviewing(false)}
>
Back to review
</button>
</div>
</section>
)}
<section className="identity-accounts" aria-label="Account identity results">
{!filtered.length && <p>No accounts match these filters.</p>}
{filtered.map((row) => (
<article className="identity-account" key={row.user.id}>
<header>
<div className="identity-account-name">
{row.can_confirm && (
<input
type="checkbox"
aria-label={`Select ${row.user.username} (Magent ${row.user.id})`}
checked={selected.includes(row.user.id)}
disabled={saving}
onChange={() => toggle(row.user.id)}
/>
)}
<div>
<h2>{row.user.username}</h2>
<span>
Magent {row.user.id} ·{" "}
{row.user.auth_provider === "jellyseerr" ? "Seerr" : row.user.auth_provider} sign-in
</span>
</div>
</div>
<span className={`identity-badge is-${row.state}`}>{labels[row.state]}</span>
</header>
<dl className="identity-mapping">
<div>
<dt>Jellyfin user ID</dt>
<dd>
<code>{row.candidate_jellyfin_id ?? "No match"}</code>
{row.jellyfin && <span>{row.jellyfin.name}</span>}
<small>{basisLabels[row.basis]}</small>
{row.stored_jellyfin_id && row.stored_jellyfin_id !== row.candidate_jellyfin_id && (
<small>Stored: {row.stored_jellyfin_id}</small>
)}
</dd>
</div>
<div>
<dt>Seerr user ID</dt>
<dd>
<strong>
{row.seerr.length ? row.seerr.map((entry) => entry.id).join(", ") : "No match"}
</strong>
<span>{row.seerr.map((entry) => entry.name).join(", ")}</span>
<small>Stored in Magent: {row.user.jellyseerr_user_id ?? "Not linked"}</small>
</dd>
</div>
<div>
<dt>Jellystat user ID</dt>
<dd>
<code>{row.jellystat.id ?? "Not verified"}</code>
<span>{statsLabels[row.jellystat.state] ?? row.jellystat.state}</span>
</dd>
</div>
</dl>
{row.issues.length > 0 && (
<ul className="identity-issues">
{row.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{(row.state === "unlinked" || row.state === "conflict") && (
<div className="identity-resolution-entry">
<p className="identity-meta">
Compare the correct Jellyfin identity with the stored links and review the smallest safe
repair.
</p>
<button
type="button"
className="ghost-button"
disabled={saving || report.services.jellyfin !== "available"}
onClick={() => setResolving(row)}
>
Review repair
</button>
{row.issues.some(
(issue) =>
issue.includes("resolve to this Jellyfin ID") || issue.includes("share this username"),
) && (
<button
type="button"
className="ghost-button"
disabled={saving}
onClick={() => setDuplicates(row)}
>
Repair duplicate accounts
</button>
)}
</div>
)}
{row.state === "unavailable" && (
<p className="identity-meta">
A required service could not be checked. Check its connection and run this again.
</p>
)}
{row.confirmed_at && (
<p className="identity-meta">Last confirmed {new Date(row.confirmed_at).toLocaleString()}</p>
)}
</article>
))}
</section>
{report.upstream.length > 0 && (
<details className="identity-upstream">
<summary>{report.upstream.length} upstream accounts need review</summary>
<ul>
{report.upstream.map((entry) => (
<li key={`${entry.platform}-${entry.id}`}>
<strong>
{entry.platform}: {entry.name}
</strong>{" "}
· ID <code>{entry.id}</code>
{entry.jellyfin_id && (
<span>
{" "}
· Jellyfin <code>{entry.jellyfin_id}</code>
</span>
)}
<p>{entry.detail}</p>
</li>
))}
</ul>
</details>
)}
</>
)}
</>
)}
{duplicates && (
<DuplicateAccountRepair
row={duplicates}
onClose={() => setDuplicates(null)}
onSaved={() => {
setDuplicates(null);
void runCheck().then(() => setNotice("Duplicate accounts repaired. History retained and links rechecked."));
}}
/>
)}
{resolving && report && (
<ResolveIdentityLink
row={resolving}
accounts={report.jellyfin_users}
onClose={() => setResolving(null)}
onSaved={() => {
setResolving(null);
setReport(null);
setSelected([]);
setReviewing(false);
setNotice("Account links repaired and saved. Run another check to see the updated mappings.");
}}
/>
)}
</div>
);
}
@@ -0,0 +1,290 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../../lib/auth";
import type { Row } from "./IdentityReviewPanel";
type Preview = {
revision: string;
server_id: string;
row: Row;
before: { jellyfin_user_id: string | null; seerr_user_id: number | null };
seerr_users: { id: number; name: string; jellyfin_id: string | null }[];
scope: string;
action: string;
};
export default function ResolveIdentityLink({
row,
accounts,
onClose,
onSaved,
}: {
row: Row;
accounts: { id: string; name: string }[];
onClose: () => void;
onSaved: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const controller = useRef<AbortController | null>(null);
const [chosen, setChosen] = useState(row.candidate_jellyfin_id ?? "");
const [inspectSeerr, setInspectSeerr] = useState("");
const [createSeerr, setCreateSeerr] = useState(false);
const [preview, setPreview] = useState<Preview | null>(null);
const [busy, setBusy] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const overflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.current?.showModal();
return () => {
controller.current?.abort();
document.body.style.overflow = overflow;
previous?.focus();
};
}, []);
const submit = async (confirm: boolean) => {
if (!chosen || busy || saving || (confirm && !preview?.row.can_confirm)) return;
const abort = new AbortController();
controller.current = abort;
setError("");
if (confirm) setSaving(true);
else {
setBusy(true);
setPreview(null);
}
try {
const response = await authFetch(`${getApiBase()}/admin/identities/repair/${confirm ? "confirm" : "check"}`, {
method: "POST",
signal: abort.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: row.user.id,
jellyfin_user_id: chosen,
create_seerr: createSeerr,
...(confirm ? { revision: preview?.revision } : {}),
}),
});
const data = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
response.status === 401
? "Your session has ended. Sign in again."
: typeof data.detail === "string"
? data.detail
: "Could not check the account links. Try again.",
);
if (!abort.signal.aborted) {
if (confirm) onSaved();
else setPreview(data);
}
} catch (err) {
if (!abort.signal.aborted) {
setError(err instanceof Error ? err.message : "Could not resolve the link.");
setPreview(null);
}
} finally {
if (!abort.signal.aborted) {
setBusy(false);
setSaving(false);
}
}
};
return (
<dialog
ref={dialog}
className="identity-resolve-dialog"
aria-labelledby="resolve-title"
onCancel={(event) => {
event.preventDefault();
if (!saving) onClose();
}}
>
<div className="identity-resolve-content">
<header>
<h2 id="resolve-title">Review account repair</h2>
<button type="button" className="ghost-button" onClick={onClose} disabled={saving}>
Close
</button>
</header>
<p>
Review <strong>{row.user.username}</strong> (Magent {row.user.id}) against their Jellyfin account. Confirm
that these identities belong to the same person before repairing Magent.
</p>
<label>
Jellyfin account
<select
value={chosen}
disabled={saving}
onChange={(event) => {
controller.current?.abort();
setBusy(false);
setPreview(null);
setError("");
setCreateSeerr(false);
setChosen(event.target.value);
}}
>
<option value="">Choose an account</option>
{[...accounts]
.sort((a, b) => a.name.localeCompare(b.name))
.map((account) => (
<option key={account.id} value={account.id}>
{account.name} {account.id}
</option>
))}
</select>
</label>
<label className="identity-import-option">
<span>
<input
type="checkbox"
checked={createSeerr}
disabled={busy || saving}
onChange={(event) => {
setCreateSeerr(event.target.checked);
setPreview(null);
}}
/>{" "}
This person has no existing Seerr account. Import only the selected Jellyfin account if it is missing.
</span>
</label>
<button type="button" onClick={() => void submit(false)} disabled={!chosen || busy || saving}>
{busy ? "Checking all platform links…" : "Preview repair"}
</button>
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{busy && <p role="status">Checking live IDs and whether another Magent account already owns this identity.</p>}
{preview && (
<section className="identity-confirm-panel" aria-label="Selected account links" aria-live="polite">
<h3>{preview.row.can_confirm ? "Ready to repair" : "This link needs attention"}</h3>
<p className="identity-meta">
Jellyfin server <code>{preview.server_id ?? "Unavailable"}</code>
</p>
<div className="identity-mapping">
<div>
<strong>Current Magent links</strong>
<p>
Jellyfin: <code>{preview.before.jellyfin_user_id ?? "Not linked"}</code>
</p>
<p>Seerr: {preview.before.seerr_user_id ?? "Not linked"}</p>
</div>
<div>
<strong>Proposed Magent links</strong>
<p>
Jellyfin: <code>{preview.row.candidate_jellyfin_id}</code>
</p>
<p>
Seerr:{" "}
{preview.row.seerr.length === 1
? preview.row.seerr[0].id
: preview.action === "import_seerr"
? "Assigned by Seerr during import"
: "Not verified"}
</p>
</div>
</div>
<dl className="identity-mapping">
<div>
<dt>Jellyfin</dt>
<dd>
{preview.row.jellyfin?.name ?? "Account not found"}
<code>{preview.row.candidate_jellyfin_id}</code>
</dd>
</div>
<div>
<dt>Seerr</dt>
<dd>
{preview.row.seerr.length
? preview.row.seerr.map((account) => `${account.name} (ID ${account.id})`).join(", ")
: "No matching Jellyfin ID. Check this users Jellyfin account link in Seerr, then check again."}
</dd>
</div>
<div>
<dt>Jellystat</dt>
<dd>
<code>{preview.row.jellystat.id ?? "Not verified"}</code>
{preview.row.jellystat.state === "matched"
? "Same Jellyfin ID verified"
: preview.row.jellystat.state === "missing"
? "This ID is missing from Jellystat. Check its Jellyfin sync, then check again."
: "Could not verify this ID. Check the Jellystat connection and try again."}
</dd>
</div>
</dl>
{preview.row.issues.length > 0 && (
<ul className="identity-issues">
{preview.row.issues.map((issue) => (
<li key={issue}>{issue}</li>
))}
</ul>
)}
{preview.row.state === "unavailable" && (
<p>A required service is unavailable. Restore its connection and check again.</p>
)}
{preview.row.seerr.length !== 1 && (
<div className="identity-upstream-guidance">
<h3>Check the existing Seerr account</h3>
<p>Choose an account to inspect its current Jellyfin ID. This selection does not change or link it.</p>
<label>
Seerr account to inspect
<select value={inspectSeerr} onChange={(event) => setInspectSeerr(event.target.value)}>
<option value="">Choose an existing account</option>
{preview.seerr_users.map((account) => (
<option key={account.id} value={account.id}>
{account.name} (ID {account.id})
</option>
))}
</select>
</label>
{preview.seerr_users
.filter((account) => String(account.id) === inspectSeerr)
.map((account) => (
<p key={account.id}>
Current Jellyfin ID: <code>{account.jellyfin_id ?? "Not linked"}</code>
</p>
))}
<p>
If this is the same person, use Seerr's account settings to reconnect their existing account to
Jellyfin, then preview again. Linking requires that user's Jellyfin sign-in in Seerr. Keep the
existing Seerr account to preserve its requests and settings.
</p>
<p>
If they have never had a Seerr account, import just their Jellyfin account from Seerr's Users page,
then preview again. Do not import a second account to work around an existing identity mismatch.
</p>
</div>
)}
<p>{preview.scope}</p>
<p>
Repair records the previous and new links, your administrator name and the time. Live IDs and duplicate
ownership are rechecked before the change is saved.
</p>
{preview.before.jellyfin_user_id &&
preview.before.jellyfin_user_id !== preview.row.candidate_jellyfin_id && (
<p>
Changing the Jellyfin identity also revokes identity-bound email subscriptions. The user will need to
opt in again.
</p>
)}
<button type="button" disabled={!preview.row.can_confirm || saving} onClick={() => void submit(true)}>
{saving
? "Rechecking and saving…"
: preview.action === "import_seerr"
? "Import Seerr account and repair links"
: "Confirm repair"}
</button>
</section>
)}
</div>
</dialog>
);
}
@@ -0,0 +1,80 @@
.identity-review { display: grid; gap: 20px; min-width: 0; }
.identity-resolution-entry { display: grid; justify-items: start; gap: 12px; margin-top: 16px; }
.identity-resolve-dialog { position: fixed; inset: 0; margin: auto; overflow: auto; overscroll-behavior: contain; width: min(900px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); padding: 0; color: var(--ops-text); background: var(--ops-panel, #1b1b1d); border: 1px solid var(--ops-line); border-radius: 16px; }
.identity-resolve-dialog::backdrop { background: #000b; backdrop-filter: blur(4px); }
.identity-resolve-content { display: grid; gap: 20px; padding: 24px; min-width: 0; }
.identity-resolve-content > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.identity-resolve-content h2, .identity-resolve-content h3 { margin: 0; }
.identity-resolve-content h2 { font-size: 1.2rem; }
.identity-resolve-content label { display: grid; gap: 8px; min-width: 0; }
.identity-resolve-content select { width: 100%; min-width: 0; }
.identity-resolve-content p { overflow-wrap: anywhere; }
@media (max-width: 540px) { .identity-resolve-content { padding: 16px; } }
.identity-review p { margin: 0; line-height: 1.65; }
.identity-review code { overflow-wrap: anywhere; font-size: .8rem; }
.identity-intro { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px; }
.identity-intro h2 { margin: 0 0 8px; font-size: 1.1rem; }
.identity-intro p { max-width: 760px; color: var(--ops-muted); }
.identity-intro button { flex-shrink: 0; }
.identity-service-strip { display: flex; flex-wrap: wrap; gap: 14px 24px; }
.identity-service-strip span { font-size: .88rem; color: var(--ops-muted); }
.identity-service-strip strong { color: var(--ops-text); margin-right: 6px; }
.identity-meta { color: var(--ops-muted); font-size: .82rem; }
.identity-counts { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 12px; }
.identity-counts > div { border: 1px solid var(--ops-line); border-radius: 12px; padding: 16px; display: grid; gap: 4px; }
.identity-counts strong { font-size: 1.8rem; }
.identity-counts span { color: var(--ops-muted); font-size: .78rem; }
.identity-filters { display: grid; grid-template-columns: minmax(0, 1fr) 220px; gap: 16px; }
.identity-filters label { display: grid; gap: 8px; font-size: .85rem; }
.identity-filters input, .identity-filters select { width: 100%; min-width: 0; }
.identity-selection, .identity-confirm-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
.identity-selection > span { color: var(--ops-muted); font-size: .85rem; margin-right: auto; }
.identity-accounts { display: grid; gap: 16px; }
.identity-account { border: 1px solid var(--ops-line); border-radius: 14px; padding: 22px; min-width: 0; }
.identity-account > header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.identity-account-name { display: flex; gap: 12px; align-items: center; min-width: 0; }
.identity-account-name h2 { font-size: 1.05rem; margin: 0 0 3px; overflow-wrap: anywhere; white-space: pre-wrap; }
.identity-account-name span { font-size: .8rem; color: var(--ops-muted); }
.identity-review input[type=checkbox] { width: 20px; height: 20px; flex-shrink: 0; accent-color: var(--ops-primary-2); }
.identity-badge { border-radius: 100px; padding: 5px 10px; font-size: .73rem; background: #263242; color: #d9e2ef; white-space: nowrap; }
.identity-badge.is-ready { background: #18374c; color: #a3dbff; }
.identity-badge.is-confirmed { background: #17382d; color: #9ce3bd; }
.identity-badge.is-conflict { background: #492d28; color: #ffc1ac; }
.identity-badge.is-unavailable, .identity-badge.is-unlinked { background: #40391f; color: #ead696; }
.identity-mapping { display: grid; grid-template-columns: 1.3fr .8fr 1.3fr; gap: 22px; margin: 22px 0 0; }
.identity-mapping > div { min-width: 0; }
.identity-mapping dt { color: var(--ops-muted); font-size: .75rem; margin-bottom: 8px; }
.identity-mapping dd { display: grid; gap: 5px; margin: 0; overflow-wrap: anywhere; }
.identity-mapping dd small { color: var(--ops-muted); line-height: 1.5; }
.identity-issues { margin: 20px 0 0; padding: 14px 14px 14px 30px; color: #ffc1ac; background: #492d2833; border-radius: 8px; font-size: .83rem; line-height: 1.7; }
.identity-account > .identity-meta { margin-top: 16px; }
.identity-confirm-panel { border: 1px solid var(--ops-primary-2); border-radius: 12px; padding: 24px; display: grid; gap: 16px; }
.identity-confirm-panel h2 { margin: 0; font-size: 1.15rem; }
.identity-confirm-panel ul { margin: 0; padding-left: 20px; max-height: 260px; overflow: auto; }
.identity-confirm-panel li { line-height: 1.9; overflow-wrap: anywhere; }
.identity-upstream { border-top: 1px solid var(--ops-line); padding-top: 20px; }
.identity-upstream summary { cursor: pointer; }
.identity-upstream ul { padding-left: 20px; }
.identity-upstream li { margin: 16px 0; overflow-wrap: anywhere; }
@media (max-width: 980px) {
.identity-intro { align-items: flex-start; flex-direction: column; }
.identity-counts { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.identity-mapping { grid-template-columns: 1fr; gap: 16px; }
}
@media (max-width: 540px) {
.identity-filters { grid-template-columns: 1fr; }
.identity-account { padding: 16px; }
.identity-account > header { flex-direction: column; }
.identity-intro, .identity-confirm-panel { padding: 16px; }
.identity-counts { gap: 8px; }
.identity-counts > div { padding: 10px; }
.identity-counts strong { font-size: 1.4rem; }
.identity-selection button { width: 100%; }
}
.identity-upstream-guidance { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--ops-line); }
.identity-import-option > span { display: flex; align-items: flex-start; gap: 10px; line-height: 1.6; }
.identity-import-option input[type=checkbox] { flex: 0 0 20px; margin: 3px 0 0; }
.identity-duplicate-accounts { grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); }
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function IdentityReviewPage() {
redirect("/users?view=identities");
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
import PortalClient from "../../portal/PortalClient";
export default function AdminIssuesPage() {
return <PortalClient workspace="issue" />;
}
@@ -0,0 +1,34 @@
.newsletter-admin { min-width: 0; }
.newsletter-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
.newsletter-tabs button { background: transparent; border: 1px solid var(--ops-line); color: var(--ops-muted); padding: 12px 16px; font-size: 13px; text-transform: none; }
.newsletter-tabs button[aria-pressed=true] { background: #c7bdff14; border-color: #c7bdff60; color: #d5cdff; }
.newsletter-create { display: flex; align-items: flex-end; gap: 12px; flex-shrink: 0; }
.newsletter-create .recap-month-label { margin: 0; }
.newsletter-editions { display: grid; gap: 10px; margin-top: 24px; max-height: 430px; overflow-y: auto; }
.newsletter-edition { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; text-align: left; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: transparent; color: var(--ops-text); text-transform: none; }
.newsletter-edition > span:first-child { min-width: 0; }
.newsletter-edition.is-active { border-color: #c7bdff70; background: #c7bdff09; }
.newsletter-edition strong { display: block; font-size: 14px; font-weight: 500; overflow-wrap: anywhere; }
.newsletter-edition small { display: block; font-size: 11px; line-height: 1.8; color: var(--ops-muted); margin-top: 7px; }
.newsletter-editor .recap-schedule-form { margin-bottom: 22px; }
.newsletter-admin textarea { border: 1px solid var(--ops-line); border-radius: 8px; padding: 12px; width: 100%; min-width: 0; resize: vertical; font: 13px/1.7 Inter, sans-serif; color: var(--ops-text); }
.newsletter-optional { font-size: 11px; color: var(--ops-faint); }
.newsletter-selection-heading { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.newsletter-selection-heading h3 { margin: 0; }
.newsletter-selection-heading span { color: #bcb3eb; font-size: 12px; }
.newsletter-titles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; max-height: 640px; overflow-y: auto; padding: 1px; }
.newsletter-title { display: flex; gap: 14px; padding: 14px; border: 1px solid var(--ops-line); border-radius: 10px; min-width: 0; background: #ffffff02; }
.newsletter-title.is-selected { border-color: #c7bdff60; background: #c7bdff08; }
.newsletter-poster { position: relative; flex: 0 0 68px; width: 68px; height: 102px; display: grid; place-items: center; overflow: hidden; border-radius: 6px; background: #353039; color: #c7bdff; font-size: 10px; }
.newsletter-poster img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.newsletter-title-copy { min-width: 0; }
.newsletter-title h4 { margin: 0; font-size: 13px; font-weight: 500; line-height: 1.6; overflow-wrap: anywhere; }
.newsletter-title p { margin: 6px 0; font-size: 11px; }
.newsletter-title .recap-checkbox { padding: 5px 0; gap: 8px; font-size: 11px; }
.newsletter-title .recap-checkbox input { flex: 0 0 16px; width: 16px; height: 16px; }
.newsletter-editor .recap-preview, .newsletter-send { border-top: 1px solid var(--ops-line); padding-top: 24px; margin-top: 24px; }
.newsletter-send > .recap-month-label { max-width: 350px; }
.newsletter-send input { min-width: 0; width: 100%; min-height: 44px; padding: 10px; border: 1px solid var(--ops-line); border-radius: 8px; font: 13px Inter, sans-serif; }
.newsletter-cancel { margin-top: 24px; color: var(--ops-muted); }
@media (max-width: 1200px) { .newsletter-titles { grid-template-columns: repeat(2, minmax(0, 1fr)); } .newsletter-create { flex-direction: column; align-items: stretch; } }
@media (max-width: 700px) { .newsletter-titles { grid-template-columns: 1fr; } .newsletter-create { width: 100%; } .newsletter-edition { align-items: flex-start; flex-direction: column; gap: 10px; } .newsletter-tabs button { padding: 10px 12px; font-size: 12px; } }
+924
View File
@@ -0,0 +1,924 @@
"use client";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import AdminShell from "../../ui/AdminShell";
import { authFetch, getApiBase } from "../../lib/auth";
import "../../email-recaps/recaps.css";
import "./newsletters.css";
type Settings = {
enabled: boolean;
weekday: number;
hour: number;
limit_titles: number;
public_url: string;
intro: string;
revision: number;
next_send_at?: number | null;
last_error?: string;
};
type Title = {
id: string;
title: string;
type: "movie" | "series";
year: number | null;
has_artwork: boolean;
items: { id: string; season: number | null; number: number | null }[];
selected: boolean;
featured: boolean;
};
type Edition = {
id: string;
subject: string;
intro: string;
revision: number;
state: string;
origin: string;
send_at: number | null;
created_at: number;
content: { titles: Title[]; total_titles: number; period_start: string; period_end: string };
};
type Summary = Omit<Edition, "content"> & { titles: number; period_start: string; period_end: string };
type Delivery = {
id: string;
subject: string;
kind: string;
email: string;
username: string | null;
state: string;
attempts: number;
updated_at: number;
next_attempt_at: number;
detail: string;
};
type Overview = {
settings: Settings;
ready: boolean;
detail: string;
editions: Summary[];
deliveries: Delivery[];
total: number;
subscribers: number;
};
type Preview = { id: string; revision: number; subject: string; body_html: string; body_text: string };
const daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
const dateLabel = (value?: number | null) =>
value
? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`
: "Not scheduled";
const labels: Record<string, string> = {
draft: "Draft",
scheduled: "Scheduled",
queued: "Queued",
complete: "Finished",
skipped: "Skipped",
preparing: "Preparing email",
sending: "Sending",
sent: "Accepted by mail server",
retry: "Retry scheduled",
failed: "Failed",
unknown: "Needs review",
cancelled: "Cancelled",
};
const editableFields = (edition: Edition) => ({
subject: edition.subject,
intro: edition.intro,
titles: edition.content.titles.map(({ id, selected, featured }) => ({ id, selected, featured })),
});
const scheduleFields = (settings: Settings) => ({
enabled: settings.enabled,
weekday: settings.weekday,
hour: settings.hour,
limit_titles: settings.limit_titles,
intro: settings.intro,
revision: settings.revision,
});
function Poster({ title }: { title: Title }) {
const [failed, setFailed] = useState(false);
return (
<div className="newsletter-poster">
<span aria-hidden="true">{title.type === "series" ? "TV" : "MOVIE"}</span>
{title.has_artwork && !failed && (
<img
src={`${getApiBase()}/admin/newsletters/artwork/${title.id}`}
alt=""
loading="lazy"
onError={() => setFailed(true)}
/>
)}
</div>
);
}
export default function NewslettersAdminPage() {
const router = useRouter();
const [data, setData] = useState<Overview | null>(null);
const [settings, setSettings] = useState<Settings | null>(null);
const [tab, setTab] = useState<"editions" | "schedule" | "history">("editions");
const [edition, setEdition] = useState<Edition | null>(null);
const [saved, setSaved] = useState<Edition | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [mode, setMode] = useState<"html" | "text">("html");
const [days, setDays] = useState(7);
const [sendAt, setSendAt] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState("");
const [offset, setOffset] = useState(0);
const [refresh, setRefresh] = useState(0);
const testRequest = useRef<{ key: string; id: string } | null>(null);
const initialized = useRef(false);
const actionController = useRef<AbortController | null>(null);
const parse = useCallback(
async (response: Response) => {
if (response.status === 401) {
router.replace("/login?next=%2Fadmin%2Fnewsletters");
throw new Error("Sign in to continue.");
}
if (response.status === 403) {
router.replace("/");
throw new Error("Administrator access is required.");
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof result.detail === "string" ? result.detail : "Could not complete this action. Please try again.",
);
return result;
},
[router],
);
useEffect(() => {
void refresh;
const abort = new AbortController();
void authFetch(`${getApiBase()}/admin/newsletters?offset=${offset}`, { signal: abort.signal })
.then(parse)
.then((result: Overview) => {
if (abort.signal.aborted) return;
setData(result);
if (!initialized.current) {
setSettings(result.settings);
initialized.current = true;
}
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [offset, refresh, parse]);
useEffect(() => {
if (
!data?.editions.some((row) => ["scheduled", "queued"].includes(row.state)) &&
!data?.deliveries.some((row) => ["queued", "preparing", "sending", "retry"].includes(row.state))
)
return;
const timer = window.setInterval(() => setRefresh((value) => value + 1), 10000);
return () => window.clearInterval(timer);
}, [data]);
useEffect(() => () => actionController.current?.abort(), []);
const dirty =
!!edition && !!saved && JSON.stringify(editableFields(edition)) !== JSON.stringify(editableFields(saved));
const settingsDirty =
!!settings && !!data && JSON.stringify(scheduleFields(settings)) !== JSON.stringify(scheduleFields(data.settings));
const selected = edition?.content.titles.filter((title) => title.selected) || [];
const featured = selected.filter((title) => title.featured).length;
const isDraft = edition?.state === "draft";
const validPreview =
!!edition && !!preview && preview.id === edition.id && preview.revision === edition.revision && !dirty;
const hasContent = selected.length > 0 || !!edition?.intro.trim();
const remember = (row: Edition) => {
setEdition(row);
setSaved(row);
setPreview(null);
setSendAt("");
testRequest.current = null;
};
const action = async <T,>(
name: string,
path: string,
method: string,
payload: unknown,
done: (result: T) => void,
) => {
if (busy) return;
const abort = new AbortController();
actionController.current = abort;
setBusy(name);
setError("");
setNotice("");
try {
const result = await parse(
await authFetch(`${getApiBase()}/admin/newsletters${path}`, {
method,
signal: abort.signal,
headers: { "Content-Type": "application/json" },
...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
}),
);
if (!abort.signal.aborted) {
done(result as T);
setRefresh((value) => value + 1);
}
} catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not complete this action.");
} finally {
if (!abort.signal.aborted) setBusy("");
}
};
const changeTitle = (id: string, field: "selected" | "featured", value: boolean) => {
if (!edition) return;
setEdition({
...edition,
content: {
...edition.content,
titles: edition.content.titles.map((title) =>
title.id !== id
? title
: { ...title, [field]: value, ...(field === "selected" && !value ? { featured: false } : {}) },
),
},
});
setPreview(null);
};
const saveDraft = (event: FormEvent) => {
event.preventDefault();
if (edition)
void action(
"save",
`/editions/${edition.id}`,
"PUT",
{ revision: edition.revision, ...editableFields(edition) },
(row: Edition) => {
remember(row);
setNotice("Draft saved. Preview this version before sending.");
},
);
};
const publish = (scheduled: boolean) => {
if (!edition || !validPreview || !hasContent) return;
void action(
"publish",
`/editions/${edition.id}/publish`,
"POST",
{ revision: edition.revision, send_at: scheduled ? `${sendAt}:00Z` : null },
(row: Edition) => {
remember(row);
setNotice(`Edition scheduled for ${dateLabel(row.send_at)}. The saved content is now fixed.`);
},
);
};
const sendTest = () => {
if (!edition || !validPreview) return;
const key = `${edition.id}:${edition.revision}`;
if (testRequest.current?.key !== key) testRequest.current = { key, id: crypto.randomUUID() };
void action(
"test",
`/editions/${edition.id}/test`,
"POST",
{ revision: edition.revision, request_id: testRequest.current.id },
(result: { message: string }) => {
setNotice(result.message);
testRequest.current = null;
setOffset(0);
},
);
};
return (
<AdminShell
title="Newsletters"
subtitle="New arrivals, fresh episodes and a little inspiration for the next watch."
actions={
<a className="ghost-button" href="/admin/notifications">
Email settings
</a>
}
>
<div className="recap-admin newsletter-admin">
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className="status-banner" role="status">
{notice}
</p>
)}
{!data && !error && <p role="status">Loading newsletters</p>}
{!data && error && (
<button
type="button"
className="ghost-button"
onClick={() => {
setError("");
setRefresh((value) => value + 1);
}}
>
Try again
</button>
)}
{data && settings && (
<>
<div className="recap-overview-strip">
<div>
<span className={`recap-pill ${data.settings.enabled ? "is-enabled" : ""}`}>
{data.settings.enabled ? "Weekly sending is on" : "Weekly sending is paused"}
</span>
<p>
{data.settings.enabled
? `Next edition ${dateLabel(data.settings.next_send_at)}`
: "Create a one-off edition or set a weekly rhythm."}
</p>
</div>
<div className="recap-subscriber-count">
<strong>{data.subscribers}</strong>
<span>confirmed {data.subscribers === 1 ? "subscriber" : "subscribers"}</span>
</div>
</div>
<nav className="newsletter-tabs" aria-label="Newsletter sections">
{(["editions", "schedule", "history"] as const).map((value) => (
<button type="button" key={value} aria-pressed={tab === value} onClick={() => setTab(value)}>
{{ editions: "Editions", schedule: "Weekly schedule", history: "Delivery history" }[value]}
</button>
))}
</nav>
{!data.ready && (
<p className="recap-setup-note">
{data.detail}{" "}
<a className="recap-text-link" href="/admin/notifications">
Email settings
</a>{" "}
·{" "}
<a className="recap-text-link" href="/admin/jellyfin">
Jellyfin settings
</a>
</p>
)}
{tab === "editions" && (
<>
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">A fresh edition</span>
<h2>Whats new in your library</h2>
<p>Collect arrivals from Jellyfin, choose your picks and add a note to your community.</p>
</div>
<div className="newsletter-create">
<label className="recap-month-label" htmlFor="arrival-period">
Arrival period
<select
id="arrival-period"
value={days}
disabled={!!busy || dirty}
onChange={(event) => setDays(Number(event.target.value))}
>
{[7, 14, 30].map((value) => (
<option key={value} value={value}>
Last {value} days
</option>
))}
</select>
</label>
<button
type="button"
className="account-primary"
disabled={!!busy || dirty}
onClick={() =>
void action("create", "/drafts", "POST", { days }, (row: Edition) => {
remember(row);
setNotice("Arrivals collected. Choose the titles you want to include.");
})
}
>
{busy === "create" ? "Collecting arrivals…" : "Create draft"}
</button>
</div>
</div>
{data.editions.length ? (
<div className="newsletter-editions">
{data.editions.map((row) => (
<button
type="button"
className={`newsletter-edition ${edition?.id === row.id ? "is-active" : ""}`}
key={row.id}
disabled={!!busy || dirty}
onClick={() => void action("open", `/editions/${row.id}`, "GET", undefined, remember)}
>
<span>
<strong>{row.subject}</strong>
<small>
{row.titles} {row.titles === 1 ? "title" : "titles"} ·{" "}
{row.origin === "weekly" ? "Weekly edition" : "Custom edition"} ·{" "}
{dateLabel(row.send_at || row.created_at)}
</small>
</span>
<span className="recap-pill">{labels[row.state] || row.state}</span>
</button>
))}
</div>
) : (
<div className="recap-empty">
<span aria-hidden="true"></span>
<h3>Something good to watch</h3>
<p>
Your first edition starts with the latest additions to your library. Collect a draft to begin.
</p>
</div>
)}
{dirty && (
<p className="recap-muted">Save or discard the current changes before opening another edition.</p>
)}
</section>
{edition && (
<section className="admin-panel recap-panel newsletter-editor">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">{isDraft ? "Make it yours" : "Saved edition"}</span>
<h2>{isDraft ? "Edit your newsletter" : edition.subject}</h2>
<p>
Arrivals from {edition.content.period_start.slice(0, 10)} to{" "}
{edition.content.period_end.slice(0, 10)} (UTC). TV additions are grouped by show.
</p>
</div>
<span className="recap-pill">{labels[edition.state] || edition.state}</span>
</div>
<form className="recap-schedule-form" onSubmit={saveDraft}>
<label htmlFor="newsletter-subject">
Email subject
<input
id="newsletter-subject"
maxLength={150}
required
value={edition.subject}
disabled={!!busy || !isDraft}
onChange={(event) => {
setEdition({ ...edition, subject: event.target.value });
setPreview(null);
}}
/>
</label>
<label htmlFor="newsletter-intro">
Announcement <span className="newsletter-optional">Optional</span>
<textarea
id="newsletter-intro"
rows={4}
maxLength={2000}
placeholder="A welcome, a weekend recommendation, or a quick update…"
disabled={!!busy || !isDraft}
value={edition.intro}
onChange={(event) => {
setEdition({ ...edition, intro: event.target.value });
setPreview(null);
}}
/>
<small>Plain text, shared with every subscriber receiving this edition.</small>
</label>
<div className="newsletter-selection-heading">
<h3>Choose the lineup</h3>
<span>
{selected.length}/24 included · {featured}/3 featured
</span>
</div>
{edition.content.total_titles > edition.content.titles.length && (
<p className="recap-muted">
Showing the {edition.content.titles.length} newest titles of {edition.content.total_titles}{" "}
found in this period.
</p>
)}
{edition.content.titles.length ? (
<div className="newsletter-titles">
{edition.content.titles.map((title) => (
<article
className={`newsletter-title ${title.selected ? "is-selected" : ""}`}
key={title.id}
>
<Poster title={title} />
<div className="newsletter-title-copy">
<h4>{title.title}</h4>
<p>
{title.type === "movie"
? `Movie${title.year ? ` · ${title.year}` : ""}`
: `${title.items.length} new ${title.items.length === 1 ? "episode" : "episodes"}`}
</p>
<label className="recap-checkbox">
<input
type="checkbox"
aria-label={`Include ${title.title}`}
checked={title.selected}
disabled={!!busy || !isDraft || (!title.selected && selected.length >= 24)}
onChange={(event) => changeTitle(title.id, "selected", event.target.checked)}
/>
<span>Include</span>
</label>
<label className="recap-checkbox">
<input
type="checkbox"
aria-label={`Feature ${title.title}`}
checked={title.featured}
disabled={
!!busy || !isDraft || !title.selected || (!title.featured && featured >= 3)
}
onChange={(event) => changeTitle(title.id, "featured", event.target.checked)}
/>
<span>Featured pick</span>
</label>
</div>
</article>
))}
</div>
) : (
<p>No new titles were found in this period. You can still create an announcement edition.</p>
)}
<div className="recap-actions">
{isDraft && (
<button type="submit" className="account-primary" disabled={!!busy || !dirty}>
{busy === "save" ? "Saving…" : "Save draft"}
</button>
)}
<button
type="button"
className="account-secondary"
disabled={!!busy}
onClick={() => void action("reload", `/editions/${edition.id}`, "GET", undefined, remember)}
>
{dirty ? "Discard changes" : "Reload edition"}
</button>
<button
type="button"
className="account-secondary"
disabled={!!busy || dirty || settingsDirty || edition.state === "cancelled"}
onClick={() =>
void action(
"preview",
`/editions/${edition.id}/preview`,
"POST",
{ revision: edition.revision },
(result: Preview) => {
setPreview(result);
setMode("html");
},
)
}
>
{busy === "preview" ? "Preparing preview…" : "Preview edition"}
</button>
</div>
</form>
<p className="recap-muted">
Each recipients email includes only titles available to their linked Jellyfin account. Posters
are included in the email.
</p>
{dirty && <p className="recap-muted">Save the draft to preview and send this version.</p>}
{settingsDirty && (
<p className="recap-muted">Save or reload your weekly settings before previewing or sending.</p>
)}
{validPreview && preview && (
<div className="recap-preview">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Email preview</span>
<h3>{preview.subject}</h3>
<p>This shows the full selection. Your test uses your own library access.</p>
</div>
<div className="recap-mode-buttons">
<button type="button" aria-pressed={mode === "html"} onClick={() => setMode("html")}>
Email design
</button>
<button type="button" aria-pressed={mode === "text"} onClick={() => setMode("text")}>
Plain text
</button>
</div>
</div>
{mode === "html" ? (
<iframe
title="Newsletter email preview"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={preview.body_html}
/>
) : (
<pre className="recap-plain-preview">{preview.body_text}</pre>
)}
</div>
)}
{edition.state !== "cancelled" && (
<div className="newsletter-send">
<h3>{isDraft ? "Ready for the inbox?" : "Delivery controls"}</h3>
<p>
A test goes to your own confirmed newsletter email.{" "}
<a href="/profile#newsletters">Manage your subscription </a>
</p>
<div className="recap-actions">
<button
type="button"
className="account-secondary"
disabled={!!busy || !validPreview || !hasContent || !data.ready || settingsDirty}
onClick={sendTest}
>
{busy === "test" ? "Queuing test…" : "Send newsletter test to me"}
</button>
</div>
{isDraft ? (
<>
<label className="recap-month-label" htmlFor="newsletter-send-time">
Schedule for (UTC)
<input
id="newsletter-send-time"
type="datetime-local"
value={sendAt}
disabled={!!busy}
onChange={(event) => setSendAt(event.target.value)}
/>
</label>
<div className="recap-actions">
<button
type="button"
className="account-primary"
disabled={
!!busy || !validPreview || !hasContent || !data.ready || settingsDirty || !sendAt
}
onClick={() => publish(true)}
>
Schedule edition
</button>
<button
type="button"
className="account-secondary"
disabled={
!!busy ||
!validPreview ||
!hasContent ||
!data.ready ||
settingsDirty ||
!data.subscribers
}
onClick={() => publish(false)}
>
Send now to {data.subscribers} {data.subscribers === 1 ? "subscriber" : "subscribers"}
</button>
</div>
<p className="recap-muted">
Preview the saved edition before sending. Scheduling fixes the content for this edition.
Users must be subscribed by its send time.
</p>
</>
) : (
<p>{dateLabel(edition.send_at)} · Check Delivery history for individual results.</p>
)}
{["draft", "scheduled", "queued"].includes(edition.state) && (
<button
type="button"
className="ghost-button newsletter-cancel"
disabled={!!busy || dirty}
onClick={() =>
void action("cancel", `/editions/${edition.id}/cancel`, "POST", {}, (row: Edition) => {
remember(row);
setNotice("Edition cancelled. Pending emails have been stopped.");
})
}
>
Cancel edition
</button>
)}
</div>
)}
</section>
)}
</>
)}
{tab === "schedule" && (
<section className="admin-panel recap-panel">
<span className="recap-eyebrow">Set the rhythm</span>
<h2>A weekly discovery</h2>
<p>
Automatically collect the previous seven days of arrivals and send an edition to confirmed
subscribers. Weeks without new arrivals are skipped.
</p>
<form
className="recap-schedule-form"
onSubmit={(event) => {
event.preventDefault();
void action("settings", "", "PUT", scheduleFields(settings), (result: Settings) => {
setSettings(result);
setData({ ...data, settings: result });
setPreview(null);
setNotice(
result.enabled
? `Weekly settings saved. Next edition ${dateLabel(result.next_send_at)}.`
: "Settings saved. Automatic weekly editions are paused.",
);
});
}}
>
<label htmlFor="newsletter-public-url">
Public Magent address
<input id="newsletter-public-url" type="url" value={settings.public_url} readOnly />
<small>
Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Watch links use the public
playback URL in <Link href="/admin/jellyfin">Jellyfin settings</Link>.
</small>
</label>
<div className="recap-schedule-fields">
<label htmlFor="newsletter-weekday">
Send day
<select
id="newsletter-weekday"
value={settings.weekday}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, weekday: Number(event.target.value) })}
>
{daysOfWeek.map((day, index) => (
<option value={index} key={day}>
{day}
</option>
))}
</select>
</label>
<label htmlFor="newsletter-hour">
Send time (UTC)
<select
id="newsletter-hour"
value={settings.hour}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}
>
{Array.from({ length: 24 }, (_, hour) => (
<option key={hour} value={hour}>
{String(hour).padStart(2, "0")}:00 UTC
</option>
))}
</select>
</label>
</div>
<label htmlFor="newsletter-limit">
Titles per weekly edition
<select
id="newsletter-limit"
value={settings.limit_titles}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, limit_titles: Number(event.target.value) })}
>
{Array.from({ length: 24 }, (_, index) => (
<option key={index + 1} value={index + 1}>
{index + 1}
</option>
))}
</select>
<small>Newest titles first. Multiple episodes count as one show.</small>
</label>
<label htmlFor="newsletter-default-intro">
Default announcement
<textarea
id="newsletter-default-intro"
rows={4}
maxLength={2000}
value={settings.intro}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, intro: event.target.value })}
/>
<small>Appears in future weekly editions and newly created drafts.</small>
</label>
<label className="recap-checkbox">
<input
type="checkbox"
checked={settings.enabled}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })}
/>
<span>Enable automatic weekly newsletters</span>
</label>
<p className="recap-muted">
The schedule starts at the next future send time, in UTC. Pausing stops pending automatic editions.
Custom editions keep their individual schedules.
</p>
<div className="recap-actions">
<button type="submit" className="account-primary" disabled={!!busy || !settingsDirty}>
{busy === "settings" ? "Saving…" : "Save weekly settings"}
</button>
<button
type="button"
className="account-secondary"
disabled={!!busy}
onClick={() =>
void action("settings-reload", "", "GET", undefined, (result: Overview) => {
setData(result);
setSettings(result.settings);
setPreview(null);
})
}
>
Reload settings
</button>
</div>
</form>
{data.settings.last_error && <p className="recap-setup-note">{data.settings.last_error}</p>}
</section>
)}
{tab === "history" && (
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">From queue to inbox</span>
<h2>Delivery history</h2>
<p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p>
</div>
<button
type="button"
className="ghost-button"
disabled={!!busy}
onClick={() => setRefresh((value) => value + 1)}
>
Refresh history
</button>
</div>
{data.deliveries.length ? (
<>
<div className="recap-history-scroll">
<table className="recap-history">
<thead>
<tr>
<th scope="col">Recipient</th>
<th scope="col">Edition</th>
<th scope="col">Delivery</th>
<th scope="col">Updated</th>
</tr>
</thead>
<tbody>
{data.deliveries.map((row) => (
<tr key={row.id}>
<td>
<strong>{row.username || "Removed account"}</strong>
<small>{row.email}</small>
</td>
<td>
{row.subject}
<small>{row.kind === "test" ? "Test email" : "Newsletter"}</small>
</td>
<td>
<span
className={`recap-pill ${row.state === "sent" ? "is-enabled" : ["failed", "unknown"].includes(row.state) ? "is-attention" : ""}`}
>
{labels[row.state] || row.state}
</span>
<small>
{row.attempts} {row.attempts === 1 ? "attempt" : "attempts"} ·{" "}
{row.detail || "Waiting for the next worker check."}
</small>
{row.state === "retry" && <small>Next attempt {dateLabel(row.next_attempt_at)}</small>}
{row.state === "unknown" && (
<small>Automatic retries are stopped to avoid a duplicate email.</small>
)}
</td>
<td>{dateLabel(row.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="recap-pagination">
<span>
{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}
</span>
<div className="recap-actions">
<button
type="button"
className="ghost-button"
disabled={!offset}
onClick={() => setOffset(Math.max(0, offset - 50))}
>
Previous
</button>
<button
type="button"
className="ghost-button"
disabled={offset + 50 >= data.total}
onClick={() => setOffset(offset + 50)}
>
Next
</button>
</div>
</div>
</>
) : (
<div className="recap-empty">
<span aria-hidden="true"></span>
<h3>Your first edition starts here</h3>
<p>Preview a draft and send yourself a test. Delivery results will appear here.</p>
</div>
)}
</section>
)}
</>
)}
</div>
</AdminShell>
);
}
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase, getToken } from "../lib/auth";
import AdminShell from "../ui/AdminShell";
import { CONFIG_GROUPS, serviceStatusLabel } from "./configNavigation";
type ServiceState = { name: string; status: string };
export default function AdminLandingPage() {
const router = useRouter();
const [services, setServices] = useState<ServiceState[]>([]);
const [ready, setReady] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
const load = async () => {
if (!getToken()) {
router.replace("/login");
return;
}
try {
const response = await authFetch(`${getApiBase()}/auth/me`);
if (!response.ok) {
router.replace("/login");
return;
}
if ((await response.json())?.role !== "admin") {
router.replace("/");
return;
}
if (!active) return;
setReady(true);
const status = await authFetch(`${getApiBase()}/status/services`);
if (!status.ok) throw new Error("Status unavailable");
const data = await status.json();
if (active) setServices(Array.isArray(data.services) ? data.services : []);
} catch {
if (active) setError("Connection status is unavailable. Refresh the page to try again.");
}
};
void load();
return () => {
active = false;
};
}, [router]);
return (
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
{!ready ? (
error ? (
<p className="error-banner" role="alert">
{error}
</p>
) : (
<p role="status">Loading settings</p>
)
) : (
<div className="config-directory">
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
<section className="config-directory-region" key={group.title}>
<header>
<h2>{group.title}</h2>
<p>{group.description}</p>
</header>
<div className="config-directory-links">
{group.items.map((item) => {
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase());
return (
<a href={item.href} key={item.href} className="config-directory-link">
{item.symbol && (
<span className="config-link-icon" aria-hidden="true">
{item.symbol}
</span>
)}
<span className="config-link-copy">
<strong>{item.label}</strong>
<small>{item.description}</small>
</span>
{item.service && (
<span className={`config-connection-badge is-${service?.status ?? "unknown"}`}>
{serviceStatusLabel(service?.status)}
</span>
)}
<span className="config-link-arrow" aria-hidden="true">
</span>
</a>
);
})}
</div>
</section>
))}
<details className="config-advanced-directory">
<summary>
<strong>Advanced tools</strong>
<span>Hosting, logs, caches and recovery</span>
</summary>
<div className="config-directory-links">
{CONFIG_GROUPS.filter((group) => group.advanced)
.flatMap((group) => group.items)
.map((item) => (
<a href={item.href} key={item.href} className="config-directory-link">
<span className="config-link-copy">
<strong>{item.label}</strong>
<small>{item.description}</small>
</span>
<span className="config-link-arrow" aria-hidden="true">
</span>
</a>
))}
</div>
</details>
</div>
)}
</AdminShell>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminProfilesRedirectPage() {
redirect("/admin/invites");
}
+526
View File
@@ -0,0 +1,526 @@
"use client";
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import AdminShell from "../../ui/AdminShell";
import { authFetch, getApiBase } from "../../lib/auth";
import "../../email-recaps/recaps.css";
type Settings = { enabled: boolean; day: number; hour: number; public_url: string; next_send_at?: number | null };
type Delivery = {
id: string;
month: string;
kind: string;
email: string;
username: string | null;
state: string;
attempts: number;
created_at: number;
updated_at: number;
next_attempt_at: number;
detail: string;
};
type Overview = {
settings: Settings;
ready: boolean;
detail: string;
months: string[];
deliveries: Delivery[];
total: number;
subscribers: number;
worker_enabled: boolean;
};
type Preview = { month: string; subject: string; body_html: string; body_text: string; email: string | null };
const monthLabel = (month: string) =>
new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, { month: "long", year: "numeric", timeZone: "UTC" });
const dateLabel = (value?: number | null) =>
value
? `${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`
: "Not scheduled";
const stateLabels: Record<string, string> = {
queued: "Queued",
preparing: "Preparing report",
sending: "Sending",
sent: "Accepted by mail server",
retry: "Retry scheduled",
failed: "Failed",
unknown: "Needs review",
cancelled: "Cancelled",
};
export default function EmailRecapsAdminPage() {
const router = useRouter();
const [data, setData] = useState<Overview | null>(null);
const [settings, setSettings] = useState<Settings>({ enabled: false, day: 2, hour: 9, public_url: "" });
const [month, setMonth] = useState("");
const [preview, setPreview] = useState<Preview | null>(null);
const [previewMode, setPreviewMode] = useState<"html" | "text">("html");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [busy, setBusy] = useState("");
const [offset, setOffset] = useState(0);
const [revision, setRevision] = useState(0);
const testRequest = useRef<{ month: string; id: string } | null>(null);
const initialized = useRef(false);
const previewController = useRef<AbortController | null>(null);
const responseData = useCallback(
async (response: Response) => {
if (response.status === 401) {
router.replace("/login?next=%2Fadmin%2Frecaps");
throw new Error("Sign in to continue.");
}
if (response.status === 403) {
router.replace("/");
throw new Error("Administrator access is required.");
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not complete this action. Check your settings and try again.",
);
return result;
},
[router],
);
useEffect(() => {
void revision;
const abort = new AbortController();
void authFetch(`${getApiBase()}/admin/email-recaps?offset=${offset}`, { signal: abort.signal })
.then(responseData)
.then((result: Overview) => {
if (abort.signal.aborted) return;
setData(result);
if (!initialized.current) {
setSettings(result.settings);
setMonth(result.months[0] || "");
initialized.current = true;
}
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [offset, revision, responseData]);
useEffect(() => {
if (!data?.deliveries.some((delivery) => ["queued", "preparing", "sending", "retry"].includes(delivery.state)))
return;
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000);
return () => window.clearInterval(timer);
}, [data]);
useEffect(() => () => previewController.current?.abort(), []);
const dirty =
!!data &&
(settings.enabled !== data.settings.enabled ||
settings.day !== data.settings.day ||
settings.hour !== data.settings.hour ||
settings.public_url !== data.settings.public_url);
const save = async (event: FormEvent) => {
event.preventDefault();
if (busy) return;
setBusy("save");
setError("");
setNotice("");
try {
const { enabled, day, hour } = settings;
const result = (await responseData(
await authFetch(`${getApiBase()}/admin/email-recaps`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, day, hour }),
}),
)) as Settings;
setSettings(result);
setData((current) => (current ? { ...current, settings: result } : current));
setPreview(null);
setNotice(
result.enabled
? `Schedule saved. Next send: ${dateLabel(result.next_send_at)}.`
: "Settings saved. Scheduled delivery is paused.",
);
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save the schedule.");
} finally {
setBusy("");
}
};
const loadPreview = async () => {
if (busy || !month) return;
const abort = new AbortController();
previewController.current?.abort();
previewController.current = abort;
setBusy("preview");
setError("");
setNotice("");
setPreview(null);
try {
const result = (await responseData(
await authFetch(`${getApiBase()}/admin/email-recaps/preview?month=${month}`, { signal: abort.signal }),
)) as Preview;
if (!abort.signal.aborted) setPreview(result);
} catch (err) {
if (!abort.signal.aborted) setError(err instanceof Error ? err.message : "Could not prepare your preview.");
} finally {
if (!abort.signal.aborted) setBusy("");
}
};
const sendTest = async () => {
if (busy || !preview || preview.month !== month) return;
if (!testRequest.current || testRequest.current.month !== month)
testRequest.current = { month, id: crypto.randomUUID() };
setBusy("test");
setError("");
setNotice("");
try {
const result = await responseData(
await authFetch(`${getApiBase()}/admin/email-recaps/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ month, request_id: testRequest.current.id }),
}),
);
setNotice(result.message);
testRequest.current = null;
setOffset(0);
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not queue your test.");
} finally {
setBusy("");
}
};
return (
<AdminShell
title="Monthly email recaps"
subtitle="Give each user a personal look back at their month in viewing."
actions={
<a className="ghost-button" href="/admin/notifications">
Email settings
</a>
}
>
<div className="recap-admin">
{error && (
<p className="error-banner" role="alert">
{error}
</p>
)}
{notice && (
<p className="status-banner" role="status">
{notice}
</p>
)}
{!data && !error && <p role="status">Loading email recaps</p>}
{!data && error && (
<button
className="ghost-button"
type="button"
onClick={() => {
setError("");
setRevision((value) => value + 1);
}}
>
Try again
</button>
)}
{data && (
<>
<div className="recap-overview-strip">
<div>
<span className={`recap-pill ${data.settings.enabled ? "is-enabled" : ""}`}>
{data.settings.enabled ? "Schedule running" : "Schedule paused"}
</span>
<p>
{data.settings.enabled
? `Next send ${dateLabel(data.settings.next_send_at)}`
: "Start the schedule when youre ready for monthly delivery."}
</p>
</div>
<div className="recap-subscriber-count">
<strong>{data.subscribers}</strong>
<span>confirmed {data.subscribers === 1 ? "subscriber" : "subscribers"}</span>
</div>
</div>
<div className="recap-admin-grid">
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Set the rhythm</span>
<h2>Monthly schedule</h2>
</div>
</div>
<p>
Send the previous months report to users who have opted in and confirmed their email. All report
periods and send times use UTC.
</p>
<form className="recap-schedule-form" onSubmit={save}>
<label htmlFor="recap-public-url">
Public Magent address
<input id="recap-public-url" type="url" value={settings.public_url} readOnly />
<small>
Inherited from <Link href="/admin/general">Hosting &amp; proxy</Link>. Email links update when
that address changes.
</small>
</label>
<div className="recap-schedule-fields">
<label htmlFor="recap-day">
Day of the month
<select
id="recap-day"
value={settings.day}
onChange={(event) => setSettings({ ...settings, day: Number(event.target.value) })}
disabled={!!busy}
>
{Array.from({ length: 28 }, (_, index) => (
<option key={index + 1} value={index + 1}>
{index + 1}
</option>
))}
</select>
</label>
<label htmlFor="recap-hour">
Send time (UTC)
<select
id="recap-hour"
value={settings.hour}
onChange={(event) => setSettings({ ...settings, hour: Number(event.target.value) })}
disabled={!!busy}
>
{Array.from({ length: 24 }, (_, hour) => (
<option key={hour} value={hour}>
{String(hour).padStart(2, "0")}:00 UTC
</option>
))}
</select>
</label>
</div>
<label className="recap-checkbox">
<input
type="checkbox"
checked={settings.enabled}
disabled={!!busy}
onChange={(event) => setSettings({ ...settings, enabled: event.target.checked })}
/>
<span>Enable scheduled monthly recaps</span>
</label>
<p className="recap-muted">
Starting or changing the schedule begins at its next future send time. Pausing cancels queued
monthly emails.
</p>
<button className="account-primary" type="submit" disabled={!!busy || !dirty}>
{busy === "save" ? "Saving…" : "Save schedule"}
</button>
</form>
{!data.ready && (
<p className="recap-setup-note">
{data.detail} <a href="/admin/notifications">Review email settings </a>
</p>
)}
</section>
<section className="admin-panel recap-panel">
<span className="recap-eyebrow">Make it yours</span>
<h2>Preview your recap</h2>
<p>
See your own viewing highlights in the email design. A test goes only to your confirmed profile email.
</p>
<label className="recap-month-label" htmlFor="recap-month">
Report month
<select
id="recap-month"
value={month}
disabled={!!busy}
onChange={(event) => {
setMonth(event.target.value);
setPreview(null);
testRequest.current = null;
}}
>
{data.months.map((value) => (
<option key={value} value={value}>
{monthLabel(value)}
</option>
))}
</select>
</label>
<div className="recap-actions">
<button
type="button"
className="account-primary"
onClick={() => void loadPreview()}
disabled={!!busy || dirty || !month}
>
{busy === "preview" ? "Preparing preview…" : "Preview my recap"}
</button>
<button
type="button"
className="account-secondary"
onClick={() => void sendTest()}
disabled={!!busy || dirty || !preview || !data.ready}
>
{busy === "test" ? "Queuing test…" : "Send test to me"}
</button>
</div>
{dirty && <p className="recap-muted">Save your settings before previewing or sending a test.</p>}
<div className="recap-preview-guidance">
<h3>One email. Your month.</h3>
<ul>
<li>Minutes, movies, episodes and requests</li>
<li>Changes from the previous month</li>
<li>Most watched titles and your longest run</li>
<li>A link to the full report and easy unsubscribe</li>
</ul>
<a href="/profile#monthly-recaps">Confirm your email in Profile </a>
</div>
</section>
</div>
{preview && (
<section className="admin-panel recap-panel recap-preview">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Email preview</span>
<h2>{preview.subject}</h2>
<p>For {preview.email || "your profile email"} · Preview links use your saved public address.</p>
</div>
<div className="recap-mode-buttons">
<button type="button" aria-pressed={previewMode === "html"} onClick={() => setPreviewMode("html")}>
Email design
</button>
<button type="button" aria-pressed={previewMode === "text"} onClick={() => setPreviewMode("text")}>
Plain text
</button>
</div>
</div>
{previewMode === "html" ? (
<iframe
title="Monthly recap email preview"
sandbox=""
referrerPolicy="no-referrer"
srcDoc={preview.body_html}
/>
) : (
<pre className="recap-plain-preview">{preview.body_text}</pre>
)}
</section>
)}
<section className="admin-panel recap-panel">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">From queue to inbox</span>
<h2>Delivery history</h2>
<p>Server acceptance is recorded here. Inbox placement depends on your mail provider.</p>
</div>
<button
type="button"
className="ghost-button"
disabled={!!busy}
onClick={() => {
setError("");
setRevision((value) => value + 1);
}}
>
Refresh history
</button>
</div>
{data.deliveries.length ? (
<>
<div className="recap-history-scroll">
<table className="recap-history">
<thead>
<tr>
<th scope="col">Recipient</th>
<th scope="col">Report</th>
<th scope="col">Delivery</th>
<th scope="col">Updated</th>
</tr>
</thead>
<tbody>
{data.deliveries.map((delivery) => (
<tr key={delivery.id}>
<td>
<strong>{delivery.username || "Removed account"}</strong>
<small>{delivery.email}</small>
</td>
<td>
{monthLabel(delivery.month)}
<small>
{delivery.kind === "test"
? "Test email"
: delivery.kind === "on_demand"
? "Requested by user"
: "Scheduled recap"}
</small>
</td>
<td>
<span
className={`recap-pill ${delivery.state === "sent" ? "is-enabled" : ["failed", "unknown"].includes(delivery.state) ? "is-attention" : ""}`}
>
{stateLabels[delivery.state] || delivery.state}
</span>
<small>
{delivery.attempts} {delivery.attempts === 1 ? "attempt" : "attempts"} ·{" "}
{delivery.detail || "Waiting for the next worker check."}
</small>
{delivery.state === "retry" && (
<small>Next attempt {dateLabel(delivery.next_attempt_at)}</small>
)}
{delivery.state === "unknown" && (
<small>Automatic retries are stopped to avoid a duplicate email.</small>
)}
</td>
<td>{dateLabel(delivery.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="recap-pagination">
<span>
{offset + 1}{Math.min(offset + 50, data.total)} of {data.total}
</span>
<div className="recap-actions">
<button
className="ghost-button"
type="button"
disabled={!offset}
onClick={() => setOffset(Math.max(0, offset - 50))}
>
Previous
</button>
<button
className="ghost-button"
type="button"
disabled={offset + 50 >= data.total}
onClick={() => setOffset(offset + 50)}
>
Next
</button>
</div>
</div>
</>
) : (
<div className="recap-empty">
<span aria-hidden="true"></span>
<h3>Your first recap starts here</h3>
<p>
Preview your email, send yourself a test, then start the monthly schedule. Delivery results will
appear here.
</p>
</div>
)}
</section>
</>
)}
</div>
</AdminShell>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import AdminShell from "../../ui/AdminShell";
type RequestRow = {
id: number;
title?: string | null;
year?: number | null;
type?: string | null;
statusLabel?: string | null;
requestedBy?: string | null;
createdAt?: string | null;
};
const REQUEST_STAGE_OPTIONS = [
{ value: "all", label: "All stages" },
{ value: "pending", label: "Waiting for approval" },
{ value: "approved", label: "Approved" },
{ value: "in_progress", label: "In progress" },
{ value: "working", label: "Working on it" },
{ value: "partial", label: "Partially ready" },
{ value: "ready", label: "Ready to watch" },
{ value: "declined", label: "Declined" },
];
const formatDateTime = (value?: string | null) => {
if (!value) return "Unknown";
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString();
};
export default function AdminRequestsAllPage() {
const router = useRouter();
const [rows, setRows] = useState<RequestRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [pageSize, setPageSize] = useState(50);
const [page, setPage] = useState(1);
const [stage, setStage] = useState("all");
const pageCount = useMemo(() => {
if (!total || pageSize <= 0) return 1;
return Math.max(1, Math.ceil(total / pageSize));
}, [total, pageSize]);
const load = useCallback(async () => {
if (!getToken()) {
router.push("/login");
return;
}
setLoading(true);
setError(null);
try {
const baseUrl = getApiBase();
const skip = (page - 1) * pageSize;
const params = new URLSearchParams({
take: String(pageSize),
skip: String(skip),
});
if (stage !== "all") {
params.set("stage", stage);
}
const response = await authFetch(`${baseUrl}/admin/requests/all?${params.toString()}`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (response.status === 403) {
router.push("/");
return;
}
throw new Error(`Load failed: ${response.status}`);
}
const data = await response.json();
setRows(Array.isArray(data?.results) ? data.results : []);
setTotal(Number(data?.total ?? 0));
} catch (err) {
console.error(err);
setError("Unable to load requests.");
} finally {
setLoading(false);
}
}, [page, pageSize, router, stage]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (page > pageCount) {
setPage(pageCount);
}
}, [pageCount, page]);
useEffect(() => {
void stage;
setPage(1);
}, [stage]);
return (
<AdminShell title="All requests" subtitle="Paginated view of every cached request.">
<section className="admin-section">
<div className="admin-toolbar">
<div className="admin-toolbar-info">
<span>{total.toLocaleString()} total</span>
</div>
<div className="admin-toolbar-actions">
<label className="admin-select">
<span>Stage</span>
<select value={stage} onChange={(e) => setStage(e.target.value)}>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="admin-select">
<span>Per page</span>
<select value={pageSize} onChange={(e) => setPageSize(Number(e.target.value))}>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
</div>
</div>
{loading ? (
<div className="status-banner">Loading requests</div>
) : error ? (
<div className="error-banner">{error}</div>
) : rows.length === 0 ? (
<div className="status-banner">No requests found.</div>
) : (
<div className="admin-table">
<div className="admin-table-head">
<span>Request</span>
<span>Status</span>
<span>Requested by</span>
<span>Created</span>
</div>
{rows.map((row) => (
<button
key={row.id}
type="button"
className="admin-table-row"
onClick={() => router.push(`/requests/${row.id}`)}
>
<span>
{row.title || `Request #${row.id}`}
{row.year ? ` (${row.year})` : ""}
</span>
<span>{row.statusLabel || "Unknown"}</span>
<span>{row.requestedBy || "Unknown"}</span>
<span>{formatDateTime(row.createdAt)}</span>
</button>
))}
</div>
)}
<div className="admin-pagination">
<button type="button" onClick={() => setPage(1)} disabled={page <= 1}>
First
</button>
<button type="button" onClick={() => setPage(page - 1)} disabled={page <= 1}>
Previous
</button>
<span>
Page {page} of {pageCount}
</span>
<button type="button" onClick={() => setPage(page + 1)} disabled={page >= pageCount}>
Next
</button>
<button type="button" onClick={() => setPage(pageCount)} disabled={page >= pageCount}>
Last
</button>
</div>
</section>
</AdminShell>
);
}
+276
View File
@@ -0,0 +1,276 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import AdminShell from "../../ui/AdminShell";
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
type FlowStage = {
title: string;
input: string;
action: string;
output: string;
};
const REQUEST_FLOW: FlowStage[] = [
{
title: "Identity + access",
input: "Jellyfin/local login",
action: "Magent validates credentials and role",
output: "JWT token + user scope",
},
{
title: "Request intake",
input: "Seerr request ID",
action: "Magent snapshots request + media metadata",
output: "Unified request state",
},
{
title: "Queue orchestration",
input: "Approved request",
action: "Sonarr/Radarr add/search operations",
output: "Grab decision",
},
{
title: "Download execution",
input: "Selected release",
action: "qBittorrent downloads + reports progress",
output: "Import-ready payload",
},
{
title: "Library import",
input: "Completed download",
action: "Sonarr/Radarr import and finalize",
output: "Available media object",
},
{
title: "Playback availability",
input: "Imported media",
action: "Jellyfin refresh + link resolution",
output: "Ready-to-watch state",
},
];
export default function AdminSystemGuidePage() {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
useEffect(() => {
let active = true;
const load = async () => {
if (!getToken()) {
router.push("/login");
return;
}
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/me`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
router.push("/");
return;
}
const me = await response.json();
if (!active) return;
if (me?.role !== "admin") {
router.push("/");
return;
}
setAuthorized(true);
} catch (error) {
console.error(error);
router.push("/");
} finally {
if (active) setLoading(false);
}
};
void load();
return () => {
active = false;
};
}, [router]);
if (loading) {
return <main className="card">Loading system guide...</main>;
}
if (!authorized) {
return null;
}
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">How it works</span>
<h2>Admin flow map</h2>
<p>Identity Request intake Queue orchestration Download Import Playback.</p>
<span className="small-pill">Admin only</span>
</div>
</div>
);
return (
<AdminShell title="System guide" subtitle="Service connections, controls, and recovery paths." rail={rail}>
<section className="admin-section system-guide">
<div className="admin-panel">
<h2>End-to-end system flow</h2>
<p className="lede">
This is the runtime path the platform follows from authentication through to playback availability.
</p>
<div className="system-flow-track">
{REQUEST_FLOW.map((stage, index) => (
<div key={stage.title} className="system-flow-segment">
<article className="system-flow-card">
<div className="system-flow-card-title">
{index + 1}. {stage.title}
</div>
<div className="system-flow-card-row">
<span>Input</span>
<strong>{stage.input}</strong>
</div>
<div className="system-flow-card-row">
<span>Action</span>
<strong>{stage.action}</strong>
</div>
<div className="system-flow-card-row">
<span>Output</span>
<strong>{stage.output}</strong>
</div>
</article>
{index < REQUEST_FLOW.length - 1 && (
<div className="system-flow-arrow" aria-hidden="true">
</div>
)}
</div>
))}
</div>
</div>
<div className="admin-panel">
<h2>What each service is responsible for</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Magent</h3>
<p>
Handles authentication, request pages, live event updates, invite workflows, diagnostics, notifications,
and admin operations.
</p>
</article>
<article className="system-guide-card">
<h3>Seerr</h3>
<p>
Stores the request itself and remains the request-state source for approval and media request metadata.
</p>
</article>
<article className="system-guide-card">
<h3>Jellyfin</h3>
<p>Provides user sign-in identity and the final playback destination once content is available.</p>
</article>
<article className="system-guide-card">
<h3>Sonarr / Radarr</h3>
<p>Control queue placement, quality-profile decisions, import handling, and release monitoring.</p>
</article>
<article className="system-guide-card">
<h3>Prowlarr</h3>
<p>Provides search/indexer coverage for Arr-side release searches.</p>
</article>
<article className="system-guide-card">
<h3>qBittorrent</h3>
<p>Executes the download and exposes live progress, paused states, and queue visibility.</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>Operational controls by area</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>General</h3>
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
</article>
<article className="system-guide-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
</article>
<article className="system-guide-card">
<h3>Users</h3>
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
</article>
<article className="system-guide-card">
<h3>Invite management</h3>
<p>Master template, profile assignment, invite access policy, invite emails, and trace map lineage.</p>
</article>
<article className="system-guide-card">
<h3>Requests + cache</h3>
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
</article>
<article className="system-guide-card">
<h3>Maintenance + diagnostics</h3>
<p>
Connectivity checks, live diagnostics, database repair, cleanup, log review, and nuclear flush/resync
operations.
</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>User and invite model</h2>
<ol className="system-decision-list">
<li>Jellyfin is used for sign-in identity and user presence across the platform.</li>
<li>Seerr provides request ownership and request-state data for Magent request pages.</li>
<li>Invite links, invite profiles, blanket rules, and invite-access controls are managed inside Magent.</li>
<li>If invite tracing is enabled, the lineage view shows who invited whom and how the chain branches.</li>
<li>Cross-system removal and ban flows are initiated from Magent admin controls.</li>
</ol>
</div>
<div className="admin-panel">
<h2>Stall recovery path (decision flow)</h2>
<ol className="system-decision-list">
<li>
Request approved but not in Arr queue <span></span> run <strong>Re-add to Arr</strong>.
</li>
<li>
In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options.
</li>
<li>
Release exists and user should not pick manually <span></span> run{" "}
<strong>Search + auto-download</strong>.
</li>
<li>
Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>.
</li>
<li>
Imported but not visible to user <span></span> validate Jellyfin visibility/link from request page.
</li>
</ol>
</div>
<div className="admin-panel">
<h2>Live update surfaces</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Landing page</h3>
<p>Recent request activity refreshes live for signed-in users.</p>
</article>
<article className="system-guide-card">
<h3>Request pages</h3>
<p>Timeline state, queue activity, and torrent progress are pushed live without refresh.</p>
</article>
<article className="system-guide-card">
<h3>Admin views</h3>
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
</article>
</div>
</div>
</section>
</AdminShell>
);
}