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
+391
View File
@@ -0,0 +1,391 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from "./lib/auth";
import {
normalizeRecentResults,
normalizeSearchResults,
type RecentRequest,
type RequestSearchResult,
} from "./lib/request-results";
import { useEffectiveRole } from "./lib/viewMode";
import PageHeading from "./ui/PageHeading";
import RequestStageFilter, { type RequestStage } from "./ui/RequestStageFilter";
export default function HomePage() {
const router = useRouter();
const [query, setQuery] = useState("");
const [recent, setRecent] = useState<RecentRequest[]>([]);
const [recentError, setRecentError] = useState<string | null>(null);
const [recentLoading, setRecentLoading] = useState(false);
const [searchResults, setSearchResults] = useState<RequestSearchResult[]>([]);
const [searchError, setSearchError] = useState<string | null>(null);
const [role, setRole] = useState<string | null>(null);
const effectiveRole = useEffectiveRole(role);
const isAdmin = effectiveRole === "admin";
const [recentDays, setRecentDays] = useState(90);
const [recentStage, setRecentStage] = useState<RequestStage>("all");
const [authReady, setAuthReady] = useState(false);
const submit = (event: React.FormEvent) => {
event.preventDefault();
const trimmed = query.trim();
if (!trimmed) return;
if (/^\d+$/.test(trimmed)) {
router.push(`/requests/${encodeURIComponent(trimmed)}`);
return;
}
void runSearch(trimmed);
};
useEffect(() => {
if (!getToken()) {
router.push("/login");
return;
}
let cancelled = false;
const load = async () => {
setRecentLoading(true);
setRecentError(null);
try {
const baseUrl = getApiBase();
const meResponse = await authFetch(`${baseUrl}/auth/me`);
if (!meResponse.ok) {
if (meResponse.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error(`Auth failed: ${meResponse.status}`);
}
const me = await meResponse.json();
if (cancelled) return;
const userRole = me?.role ?? null;
setRole(userRole);
setAuthReady(true);
const take = isAdmin ? 50 : 6;
const params = new URLSearchParams({
take: String(take),
days: String(recentDays),
});
if (recentStage !== "all") {
params.set("stage", recentStage);
}
const response = await authFetch(`${baseUrl}/requests/recent?${params.toString()}`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error(`Recent requests failed: ${response.status}`);
}
const data = await response.json();
if (cancelled) return;
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results));
}
} catch (error) {
console.error(error);
if (!cancelled) setRecentError("Recent requests are not available right now.");
} finally {
if (!cancelled) setRecentLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [isAdmin, recentDays, recentStage, router]);
useEffect(() => {
if (!authReady) {
return;
}
if (!getToken()) {
return;
}
const baseUrl = getApiBase();
let closed = false;
let source: EventSource | null = null;
const connect = async () => {
try {
const streamToken = await getEventStreamToken();
if (closed) return;
const params = new URLSearchParams({
stream_token: streamToken,
recent_days: String(recentDays),
});
if (recentStage !== "all") {
params.set("recent_stage", recentStage);
}
const streamUrl = `${baseUrl}/events/stream?${params.toString()}`;
source = new EventSource(streamUrl);
source.onmessage = (event) => {
if (closed) return;
try {
const payload = JSON.parse(event.data);
if (!payload || typeof payload !== "object") {
return;
}
if (payload.type === "home_recent") {
if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results));
setRecentError(null);
setRecentLoading(false);
} else if (typeof payload.error === "string" && payload.error.trim()) {
setRecentError("Recent requests are not available right now.");
setRecentLoading(false);
}
return;
}
} catch (error) {
console.error(error);
}
};
} catch (error) {
if (closed) return;
console.error(error);
}
};
void connect();
return () => {
closed = true;
source?.close();
};
}, [authReady, recentDays, recentStage]);
const runSearch = async (term: string) => {
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error(`Search failed: ${response.status}`);
}
const data = await response.json();
if (Array.isArray(data?.results)) {
setSearchResults(normalizeSearchResults(data.results));
setSearchError(null);
}
} catch (error) {
console.error(error);
setSearchError("Search failed. Try a request ID instead.");
setSearchResults([]);
}
};
const resolveArtworkUrl = (url?: string | null) => {
if (!url) return null;
return url.startsWith("http") ? url : `${getApiBase()}${url}`;
};
const formatRequestTime = (value?: string | null) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString();
};
const activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? "").toLowerCase();
return !label.includes("ready") && !label.includes("available") && !label.includes("declined");
}).length;
const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? "").toLowerCase();
return label.includes("ready") || label.includes("available");
}).length;
const requestCardState = (value?: string) => {
const label = String(value ?? "").toLowerCase();
if (label.includes("partial")) return { key: "attention", label: value || "Partially ready", progress: 65 };
if (!/not |unavailable|waiting/.test(label) && (label.includes("ready") || label.includes("available")))
return { key: "ready", label: value || "Ready", progress: 100 };
if (label.includes("declined") || label.includes("failed") || label.includes("error"))
return { key: "attention", label: value || "Needs attention", progress: 12 };
if (label.includes("working") || label.includes("progress") || label.includes("download"))
return { key: "processing", label: value || "In progress", progress: 58 };
return { key: "waiting", label: value || "Waiting", progress: 4 };
};
return (
<main className="card home-page">
<PageHeading
title="My requests"
description="Follow your requests from collection to ready to watch."
actions={
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
<input
id="request-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Dune 2021 or 1289"
/>
<button type="submit">Find request</button>
</div>
</form>
}
/>
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
<div className="home-section-heading">
<div>
<span className="section-kicker">Search results</span>
<h2>
{searchError
? "Search unavailable"
: `${searchResults.length} match${searchResults.length === 1 ? "" : "es"} found`}
</h2>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setSearchResults([]);
setSearchError(null);
}}
>
Clear
</button>
</div>
{searchError ? (
<div className="error-banner">{searchError}</div>
) : (
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || "Untitled"}-${index}`}
type="button"
className="home-result-card"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
>
<span>
<strong>
{item.title || "Untitled"}
{item.year ? ` (${item.year})` : ""}
</strong>
<small>{item.type?.toUpperCase() || "MEDIA"}</small>
</span>
<span>{!item.requestId ? "Not requested" : item.statusLabel || "Already requested"}</span>
</button>
))}
</div>
)}
</section>
)}
<section className="home-metric-strip" aria-label="Request summary">
<div>
<span>In view</span>
<strong>{recent.length}</strong>
</div>
<div>
<span>In progress</span>
<strong>{activeRecentCount}</strong>
</div>
<div>
<span>Ready</span>
<strong>{readyRecentCount}</strong>
</div>
</section>
<section className="recent home-recent">
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{isAdmin ? "Recent requests" : "My recent requests"}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Period</span>
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
<option value={0}>All time</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
</div>
)}
</div>
{authReady && <RequestStageFilter value={recentStage} onChange={setRecentStage} />}
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests...</span>
</div>
) : recentError ? (
<div className="error-banner">{recentError}</div>
) : recent.length === 0 ? (
<div className="home-empty-state">
<strong>No requests match these filters</strong>
<span>Try a wider period or a different stage.</span>
</div>
) : (
(isAdmin ? recent : recent.slice(0, 6)).map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className={`recent-card is-${requestCardState(item.statusLabel).key}`}
>
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ""}
alt=""
loading="lazy"
/>
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">
#{item.id}
</span>
)}
<span className="recent-info">
<span className="recent-title">
{item.title || "Untitled"}
{item.year ? ` (${item.year})` : ""}
</span>
<span className="recent-status-badge">
<span aria-hidden="true">
{
{ ready: "✓", processing: "↻", attention: "!", waiting: "◷" }[
requestCardState(item.statusLabel).key
]
}
</span>
{item.statusLabel || "Status not available yet"}
</span>
<span className="recent-meta">
Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ""}
</span>
</span>
<span className="recent-open-cue" aria-hidden="true">
Open
</span>
</button>
))
)}
</div>
</section>
</main>
);
}
+102
View File
@@ -0,0 +1,102 @@
/* Top-navigation workspace and streamlined account screens. */
.admin-shell--top-nav > .admin-card { width: 100%; max-width: none; padding: 24px 0; }
.settings-top-navigation { display: flex; justify-content: space-between; align-items: center; gap: 20px; padding: 0 0 20px; border-bottom: 1px solid var(--ops-line-soft); }
.settings-top-navigation a { color: var(--ops-muted); font-size: 13px; text-decoration: none; }
.settings-top-navigation label { display: flex; align-items: center; gap: 12px; margin: 0; padding: 0; }
.settings-top-navigation label span { color: var(--ops-faint); font: 12px Inter, sans-serif; text-transform: none; }
.settings-top-navigation select { width: 260px; min-height: 42px; font: 13px Inter, sans-serif; padding: 10px 12px; border-radius: 8px; }
.admin-supplemental { margin-top: 28px; border-top: 1px solid var(--ops-line-soft); padding-top: 20px; }
.admin-supplemental > summary { cursor: pointer; color: var(--ops-muted); font-size: 13px; margin-bottom: 18px; }
.admin-supplemental .admin-rail-stack { display: block; max-width: 960px; }
.account-eyebrow { font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.account-identity { display: flex; align-items: center; gap: 12px; min-width: 0; }
.account-identity > div { display: grid; gap: 4px; min-width: 0; }
.account-identity strong { font-size: 14px; overflow-wrap: anywhere; }
.account-identity > div > span { font-size: 12px; color: var(--ops-faint); }
.account-avatar { display: grid; place-items: center; flex-shrink: 0; width: 44px; height: 44px; border: 1px solid #45434f; border-radius: 14px; background: #25242c; color: #dedaff; font: 600 20px "DM Sans", sans-serif; }
.account-tabs { display: flex; gap: 26px; border-bottom: 1px solid var(--ops-line-soft); margin-bottom: 24px; }
.account-tabs button { min-height: 46px; padding: 0 2px; border: 0; border-radius: 0 !important; border-bottom: 2px solid transparent; border-color: transparent !important; background: transparent !important; color: var(--ops-muted); font: 500 14px "DM Sans", sans-serif; box-shadow: none; text-transform: none; }
.account-tabs button[aria-selected=true] { color: #dedaff; border-bottom-color: #bcb3ff !important; }
.account-page [hidden] { display: none !important; }
.account-panel { border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); padding: 32px; animation: account-appear .18s ease-out; }
.account-section-intro { margin-bottom: 26px; }
.account-section-intro h2 { margin: 0 0 8px; font-size: 21px; }
.account-section-intro p { margin: 0; font-size: 13px; color: var(--ops-muted); line-height: 1.6; }
.account-form { display: grid; gap: 10px; max-width: 500px; }
.account-form fieldset { display: grid; gap: 10px; min-width: 0; padding: 0; margin: 0; border: 0; }
.account-form label { display: block; padding: 0; margin: 0; border: 0; background: none; color: var(--ops-text); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
.account-form input { display: block; width: 100%; min-width: 0; min-height: 46px; padding: 11px 13px; margin: 0; border: 1px solid var(--ops-line); border-radius: 8px; font: 14px Inter, sans-serif; }
.account-form fieldset label:not(:first-child) { margin-top: 10px; }
.account-form .account-hint { margin: 0; color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
.account-form-actions { display: flex; gap: 10px; margin-top: 14px; }
button.account-primary { min-height: 44px; padding: 11px 20px; border: 1px solid #c7bdff !important; border-radius: 8px; background: #c7bdff !important; color: #1c172c !important; font: 700 13px "DM Sans", sans-serif; text-transform: none; transition: background .15s, opacity .15s; }
button.account-primary:hover:not(:disabled) { background: #d8d1ff !important; }
button.account-primary:disabled { opacity: .4; cursor: not-allowed; }
button.account-secondary { min-height: 44px; padding: 11px 16px; border: 1px solid var(--ops-line); background: transparent !important; color: var(--ops-muted); font: 500 13px "DM Sans", sans-serif; text-transform: none; }
.account-notice { margin: 8px 0 0; padding: 12px 14px; border: 1px solid var(--ops-line); border-radius: 8px; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.account-notice.is-error { color: #ffb6b6; border-color: #763d44; background: #311e23; }
.account-notice.is-status { color: #aae0cb; border-color: #365c50; background: #1b2924; }
.account-login-message { color: #ded8ed; border-color: #514a60; background: #26222d; white-space: pre-line; }
.account-connected { display: flex; align-items: center; gap: 9px; border-top: 1px solid var(--ops-line-soft); margin-top: 32px; padding-top: 20px; color: var(--ops-faint); font-size: 12px; }
.account-connection-dot { width: 6px; height: 6px; background: #83bda7; border-radius: 50%; flex-shrink: 0; }
.account-request-summary { display: flex; gap: 40px; align-items: center; padding-bottom: 24px; margin-bottom: 28px; border-bottom: 1px solid var(--ops-line-soft); }
.account-request-summary > div { display: grid; gap: 5px; }
.account-request-summary strong { font: 600 25px "DM Sans", sans-serif; }
.account-request-summary span { color: var(--ops-muted); font-size: 12px; }
.account-request-summary a { margin-left: auto; font-size: 13px; color: #d0c8ff; text-decoration: none; }
.account-list-heading { font-size: 14px; margin: 0 0 8px; }
.account-access-list { list-style: none; padding: 0; margin: 0; }
.account-access-list > li { padding: 18px 0; border-bottom: 1px solid var(--ops-line-soft); }
.account-access-list > li:last-child { border: 0; }
.account-access-summary { display: flex; justify-content: space-between; gap: 16px; }
.account-access-summary strong { font-size: 13px; font-weight: 500; }
.account-access-summary time { font-size: 12px; color: var(--ops-muted); text-align: right; }
.account-access-list details { margin-top: 8px; font-size: 12px; color: var(--ops-faint); }
.account-access-list summary { cursor: pointer; }
.account-access-list dl { display: grid; gap: 8px; margin-bottom: 0; }
.account-access-list dl > div { display: flex; gap: 12px; flex-wrap: wrap; }
.account-access-list dd { margin: 0; color: var(--ops-muted); overflow-wrap: anywhere; }
.account-empty { color: var(--ops-muted); font-size: 14px; line-height: 1.6; padding: 16px 0; }
.account-empty button { margin-top: 8px; }
.page:has(> .login-page) { padding: 0; background: #121214; }
.page > main.login-page { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100dvh; width: 100%; max-width: none; margin: 0; padding: 40px 20px; background: radial-gradient(ellipse at 50% 15%, #24202e 0, transparent 60%); }
.login-card { width: 100%; max-width: 430px; border: 1px solid #3a3841; border-radius: 20px; padding: 32px; background: #1c1b1f; box-shadow: 0 22px 90px #0003; }
.login-brand { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 36px; }
.login-brand > a { display: flex; align-items: center; gap: 10px; color: #eeeaf5; text-decoration: none; font: 600 23px "DM Sans", sans-serif; }
.magent-mark { width: 38px; height: 38px; }
.login-beta { padding: 4px 8px; border: 1px solid #44404d; border-radius: 6px; font: 10px "JetBrains Mono", monospace; color: #bdb5cf; text-transform: uppercase; }
.login-card header { margin-bottom: 24px; }
.login-card h1 { margin: 0 0 8px; font-size: 30px; line-height: 1.2; color: #f1edf6; }
.login-card header p { margin: 0; color: #aba5b7; font-size: 13px; }
.login-methods { display: flex; gap: 4px; padding: 4px; margin: 0 0 16px; background: #151417; border: 1px solid #3b3842; border-radius: 9px; }
.login-methods button { flex: 1; min-height: 36px; padding: 8px; border: 0; border-radius: 6px !important; background: transparent !important; color: #b3adbf; font: 500 13px "DM Sans", sans-serif; text-transform: none; }
.login-methods button[aria-pressed=true] { background: #36313f !important; color: #eee7ff; }
.login-method-help { font-size: 12px; line-height: 1.5; margin: 0 0 10px; color: #aba5b7; }
.login-form input { background: #151417 !important; border-color: #46414e !important; }
.login-password-label { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 12px; }
.login-password-label a { color: #c4b9e5; font-size: 12px; text-decoration: none; }
.login-password-field { position: relative; }
.login-password-field input { padding-right: 48px; }
.login-password-field .password-visibility { position: absolute; top: 1px; right: 1px; width: 44px; height: calc(100% - 2px); min-height: 42px; padding: 12px; border: 0; background: transparent !important; color: #aba5b7; box-shadow: none; }
.password-visibility svg { width: 19px; height: 19px; display: block; }
.login-form .login-submit { display: flex; justify-content: space-between; align-items: center; margin-top: 14px; min-height: 46px; }
.login-card footer { border-top: 1px solid #36323c; margin-top: 28px; padding-top: 22px; text-align: center; color: #aaa3b5; font-size: 12px; }
.login-card footer a { margin-left: 4px; color: #d4c7ff; text-decoration: none; font-weight: 500; }
.login-credit { color: #87818f; font-size: 11px; margin: 24px 0 0; }
.account-page :focus-visible, .login-page :focus-visible, .settings-top-navigation :focus-visible { outline: 2px solid #c7bdff; outline-offset: 3px; }
@keyframes account-appear { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) { .account-panel { animation: none; } }
@media (max-width: 680px) {
.account-identity strong { max-width: 130px; }
.account-avatar { display: none; }
.account-panel { padding: 22px 20px; }
.account-request-summary { gap: 24px; flex-wrap: wrap; }
.account-request-summary a { width: 100%; margin: 0; }
.account-access-summary { flex-direction: column; gap: 6px; }
.account-access-summary time { text-align: left; }
.settings-top-navigation { gap: 14px; flex-wrap: wrap; }
.settings-top-navigation label { flex: 1; min-width: 220px; }
.settings-top-navigation select { flex: 1; width: 100%; }
.login-card { padding: 26px 24px; }
.page > main.login-page { padding: 24px 16px; }
}
+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>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import PageHeading from "../ui/PageHeading";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type SiteInfo = {
changelog?: string;
};
type ChangelogGroup = {
date: string;
entries: string[];
};
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
const parseChangelog = (raw: string): ChangelogGroup[] => {
const groups: ChangelogGroup[] = [];
for (const rawLine of raw.split("\n")) {
const line = rawLine.trim();
if (!line) continue;
const [candidateDate, ...messageParts] = line.split("|");
if (DATE_PATTERN.test(candidateDate) && messageParts.length > 0) {
const message = messageParts.join("|").trim();
if (!message) continue;
const currentGroup = groups[groups.length - 1];
if (currentGroup?.date === candidateDate) {
currentGroup.entries.push(message);
} else {
groups.push({ date: candidateDate, entries: [message] });
}
continue;
}
if (groups.length === 0) {
groups.push({ date: "Updates", entries: [line] });
} else {
groups[groups.length - 1].entries.push(line);
}
}
return groups;
};
export default function ChangelogPage() {
const router = useRouter();
const [groups, setGroups] = useState<ChangelogGroup[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getToken();
if (!token) {
router.push("/login");
return;
}
let active = true;
const load = async () => {
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/site/info`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error("Failed to load changelog");
}
const data: SiteInfo = await response.json();
if (!active) return;
setGroups(parseChangelog(data?.changelog ?? ""));
} catch (err) {
console.error(err);
if (!active) return;
setGroups([]);
} finally {
if (active) setLoading(false);
}
};
void load();
return () => {
active = false;
};
}, [router]);
const content = useMemo(() => {
if (loading) {
return <div className="loading-text">Loading changelog...</div>;
}
if (groups.length === 0) {
return <div className="meta">No updates posted yet.</div>;
}
return (
<div className="changelog-groups">
{groups.map((group) => (
<section key={group.date} className="changelog-group">
<h2>{group.date}</h2>
<ul className="changelog-list">
{group.entries.map((entry, index) => (
<li key={`${group.date}-${entry}-${index}`}>{entry}</li>
))}
</ul>
</section>
))}
</div>
);
}, [groups, loading]);
return (
<main className="card changelog-page">
<PageHeading title="Changelog" description="Whats new and improved in Magent." />
{content}
</main>
);
}
+34
View File
@@ -0,0 +1,34 @@
import "./style.css";
export const metadata = { title: "Coming soon | Magent" };
export default function ComingSoonPage() {
return (
<main className="launch-cover">
<div className="launch-brand">MAGENT</div>
<span className="launch-badge">COMING SOON</span>
<h1>
Your next watch.
<br />
<em>Made simpler.</em>
</h1>
<p className="launch-intro">
The new Magent is on its way. Easier requests, clearer updates and a simpler way to get things fixed.
</p>
<ol className="launch-path" aria-label="Request journey">
{["Request", "Track", "Watch"].map((label, index) => (
<li key={label}>
<span>0{index + 1}</span>
<strong>{label}</strong>
</li>
))}
</ol>
<p className="launch-note">Were getting everything ready. Check back soon.</p>
<footer>
<strong>Magent</strong>
<span>Your media member portal</span>
<a href="/login">Admin sign in</a>
</footer>
</main>
);
}
+16
View File
@@ -0,0 +1,16 @@
.page:has(.launch-cover) { max-width: none; margin: 0; padding: 0; }
.launch-cover { box-sizing: border-box; min-height: 100svh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 64px 24px 24px; text-align: center; background: radial-gradient(ellipse at 50% 25%, #252039 0%, transparent 55%), #101012; color: #f4f0ff; }
.launch-brand { font-size: 14px; letter-spacing: .3em; color: #c7bdff; font-weight: 700; margin-bottom: 36px; }
.launch-badge { border: 1px solid #6eddec66; color: #8be7f1; border-radius: 30px; padding: 8px 18px; font-size: 12px; letter-spacing: .15em; }
.launch-cover h1 { font-size: clamp(40px, 7vw, 88px); line-height: 1.08; letter-spacing: -.045em; margin: 28px 0 22px; }
.launch-cover h1 em { color: #c7bdff; font-style: normal; }
.launch-intro { max-width: 560px; font-size: 18px; line-height: 1.6; color: #bcb8c9; margin: 0; }
.launch-path { display: grid; grid-template-columns: repeat(3, 1fr); width: min(580px, 100%); margin: 40px 0 24px; padding: 0; list-style: none; border: 1px solid #ffffff20; border-radius: 16px; background: #ffffff04; }
.launch-path > li { padding: 22px 12px; display: grid; gap: 8px; }
.launch-path > li + li { border-left: 1px solid #ffffff15; }
.launch-path span { color: #8be7f1; font-size: 12px; }
.launch-path strong { font-size: 18px; }
.launch-note { color: #a9a4b5; font-size: 14px; }
.launch-cover footer { display: flex; flex-wrap: wrap; justify-content: center; gap: 14px; margin-top: 64px; font-size: 12px; color: #a9a4b5; }
.launch-cover footer a { color: #c7bdff; text-underline-offset: 3px; }
.launch-cover a:focus-visible { outline: 2px solid #8be7f1; outline-offset: 5px; }
+145
View File
@@ -0,0 +1,145 @@
"use client";
import { useEffect, useState } from "react";
import { getApiBase } from "../lib/auth";
import BrandingLogo from "../ui/BrandingLogo";
import "./recaps.css";
type LinkAction = { action: "confirm" | "unsubscribe"; token: string };
export default function EmailRecapLinkPage() {
const [link, setLink] = useState<LinkAction | null>(null);
const [state, setState] = useState("loading");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
let controller: AbortController | null = null;
const checkLink = () => {
controller?.abort();
const abort = new AbortController();
controller = abort;
setError("");
setState("loading");
setLink(null);
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
const params = new URLSearchParams(window.location.hash.slice(1));
const action = params.get("action");
const token = params.get("token") || "";
if ((action !== "confirm" && action !== "unsubscribe") || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
setError("This email link is incomplete. Open Profile to manage your monthly recaps.");
setState("error");
return;
}
const payload = { action, token } as LinkAction;
setLink(payload);
void fetch(`${getApiBase()}/email-recaps/check`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: abort.signal,
credentials: "omit",
})
.then(async (response) => {
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not check this email link. Please open it again.",
);
if (!abort.signal.aborted) setState(result.state);
})
.catch((err: Error) => {
if (!abort.signal.aborted) {
setError(err.message);
setState("error");
}
});
};
checkLink();
window.addEventListener("hashchange", checkLink);
return () => {
controller?.abort();
window.removeEventListener("hashchange", checkLink);
};
}, []);
const apply = async () => {
if (!link || busy) return;
setBusy(true);
setError("");
try {
const response = await fetch(`${getApiBase()}/email-recaps/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(link),
credentials: "omit",
});
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof result.detail === "string" ? result.detail : "Could not update your preference. Please try again.",
);
setState(result.state);
window.history.replaceState(null, "", "/email-recaps");
} catch (err) {
setError(err instanceof Error ? err.message : "Could not update your preference.");
} finally {
setBusy(false);
}
};
const done = state === "enabled" || state === "off";
return (
<main className="recap-link-page">
<a className="recap-brand" href="/login">
<BrandingLogo className="brand-logo" />
<span>Magent</span>
</a>
<section className="account-panel">
<span className="recap-eyebrow">Personal viewing reports</span>
<h1>
{state === "enabled"
? "Youre on the list."
: state === "off"
? "Recaps are turned off."
: state === "loading"
? "Checking your email link"
: state === "error"
? "This link needs another look"
: link?.action === "unsubscribe"
? "Unsubscribe from recaps?"
: "Your month, delivered."}
</h1>
<p>
{state === "enabled"
? "Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs."
: state === "off"
? "You wont receive further monthly recaps. You can turn them back on in Profile."
: state === "ready" && link?.action === "unsubscribe"
? "This turns off all personal viewing report emails. You can still explore all your reports in Magent."
: state === "ready"
? "Confirm to email yourself your minutes, movies, episodes, longest run and requests. Manage automatic monthly delivery separately in Profile."
: ""}
</p>
{error && (
<p className="account-notice is-error" role="alert">
{error}
</p>
)}
{state === "ready" && (
<button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>
{busy ? "Updating…" : link?.action === "unsubscribe" ? "Unsubscribe from recaps" : "Confirm email recaps"}
</button>
)}
{(done || state === "error") && (
<a className="recap-text-link" href="/profile#monthly-recaps">
Manage email preferences
</a>
)}
{state === "loading" && <p role="status">One moment</p>}
</section>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
.recap-admin { display: grid; gap: 24px; }
.recap-admin-grid { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr); gap: 24px; }
.recap-panel.admin-panel { margin: 0; padding: 28px; min-width: 0; border: 1px solid var(--ops-line); border-radius: 14px; background: var(--ops-panel); }
.recap-panel h2, .recap-preference h2 { margin: 8px 0 12px; font-size: 22px; }
.recap-panel h3 { font-size: 16px; }
.recap-panel p, .recap-preference p { color: var(--ops-muted); font-size: 13px; line-height: 1.7; }
.recap-panel a, .recap-preference a, .recap-text-link { color: #c7bdff; text-decoration: none; }
.recap-panel a:hover, .recap-preference a:hover, .recap-text-link:hover { text-decoration: underline; }
.recap-eyebrow { display: block; color: #bcb3eb; font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; }
.recap-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.recap-section-heading > div { min-width: 0; }
.recap-pill { display: inline-flex; align-items: center; padding: 6px 10px; border: 1px solid var(--ops-line); border-radius: 99px; font-size: 11px; line-height: 1.4; color: var(--ops-muted); white-space: nowrap; }
.recap-pill.is-enabled { color: #cfc7fc; background: #c7bdff12; border-color: #c7bdff40; }
.recap-pill.is-attention { color: #eab9a6; border-color: #eab9a650; }
.recap-overview-strip { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 24px 28px; border: 1px solid var(--ops-line); border-radius: 14px; background: linear-gradient(110deg, #c7bdff0c, transparent 65%), var(--ops-panel); }
.recap-overview-strip p { color: var(--ops-muted); font-size: 13px; margin: 12px 0 0; line-height: 1.6; }
.recap-subscriber-count { display: flex; align-items: center; gap: 14px; }
.recap-subscriber-count strong { color: #d8d0ff; font-size: 36px; font-weight: 500; }
.recap-subscriber-count span { max-width: 100px; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
.recap-schedule-form { display: grid; gap: 18px; margin-top: 24px; }
.recap-schedule-form label, .recap-month-label { display: grid; gap: 9px; padding: 0; margin: 0; color: var(--ops-text); font-size: 13px; text-transform: none; border: 0; background: none; }
.recap-schedule-form input:not([type=checkbox]), .recap-schedule-form select, .recap-month-label select { min-width: 0; width: 100%; min-height: 44px; border: 1px solid var(--ops-line); border-radius: 8px; padding: 10px 12px; font: 13px Inter, sans-serif; }
.recap-schedule-form small { color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
.recap-schedule-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.recap-schedule-form .recap-checkbox { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.recap-checkbox input { width: 18px; height: 18px; accent-color: #c7bdff; }
.recap-schedule-form button { justify-self: start; }
.recap-schedule-form .recap-muted { margin: -6px 0 0; }
.recap-panel .recap-muted, .recap-preference .recap-muted { font-size: 12px; color: var(--ops-faint); }
.recap-setup-note { padding: 14px 16px; border: 1px solid var(--ops-line); border-radius: 8px; margin: 20px 0 0; }
.recap-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.recap-month-label { margin-top: 24px; }
.recap-preview-guidance { border-top: 1px solid var(--ops-line); margin-top: 28px; padding-top: 16px; }
.recap-preview-guidance ul { padding-left: 18px; margin: 18px 0; color: var(--ops-muted); font-size: 13px; line-height: 2; }
.recap-preview-guidance a { font-size: 13px; }
.recap-mode-buttons { display: flex; flex-shrink: 0; gap: 6px; }
.recap-mode-buttons button { background: transparent !important; color: var(--ops-muted); border: 1px solid var(--ops-line); padding: 10px 12px; font-size: 12px; text-transform: none; }
.recap-mode-buttons button[aria-pressed=true] { border-color: #c7bdff70; color: #d5cdff; background: #c7bdff10 !important; }
.recap-preview iframe { display: block; width: 100%; height: 1050px; margin-top: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: #131315; }
.recap-plain-preview { white-space: pre-wrap; overflow-wrap: anywhere; padding: 24px; background: #131315; border: 1px solid var(--ops-line); border-radius: 10px; color: var(--ops-muted); font-size: 13px; line-height: 1.8; }
.recap-history-scroll { overflow-x: auto; margin-top: 18px; }
.recap-history { width: 100%; border-collapse: collapse; font-size: 12px; }
.recap-history th { color: var(--ops-faint); font-size: 11px; font-weight: 500; text-align: left; }
.recap-history th, .recap-history td { padding: 16px 12px; border-bottom: 1px solid var(--ops-line-soft); vertical-align: top; }
.recap-history td { min-width: 135px; line-height: 1.7; }
.recap-history td:first-child { min-width: 170px; }
.recap-history td:nth-child(3) { min-width: 245px; max-width: 400px; }
.recap-history strong { display: block; font-weight: 500; }
.recap-history small { display: block; margin-top: 6px; font-size: 11px; color: var(--ops-muted); overflow-wrap: anywhere; }
.recap-pagination { display: flex; align-items: center; justify-content: space-between; gap: 14px; color: var(--ops-muted); font-size: 12px; margin-top: 16px; }
.recap-pagination .recap-actions { margin: 0; }
.recap-empty { text-align: center; padding: 36px 20px 24px; }
.recap-empty > span { color: #bcb3eb; font-size: 28px; }
.recap-empty p { max-width: 430px; margin: 12px auto; }
.recap-preference { border-top: 1px solid var(--ops-line); margin-top: 28px; padding-top: 28px; scroll-margin-top: 24px; }
.recap-preference p { max-width: 620px; }
.recap-delivery-address { overflow-wrap: anywhere; }
.page > main.recap-link-page { max-width: 600px; margin: 70px auto; }
.recap-brand { display: flex; align-items: center; justify-content: center; gap: 12px; margin-bottom: 32px; text-decoration: none; color: var(--ops-text); font: 500 25px "DM Sans", sans-serif; }
.recap-brand img { width: 42px; height: 42px; object-fit: contain; }
.recap-brand .brand-logo { width: 42px; height: 42px; flex: 0 0 42px; }
.recap-link-page h1 { font-size: clamp(25px, 5vw, 36px); margin: 16px 0; }
.recap-link-page p { font-size: 14px; line-height: 1.8; color: var(--ops-muted); }
.recap-link-page button, .recap-link-page .recap-text-link { margin-top: 16px; }
.recap-link-page .recap-text-link { display: inline-block; font-size: 14px; }
@media (max-width: 1000px) { .recap-admin-grid { grid-template-columns: 1fr; } }
@media (max-width: 600px) {
.recap-panel.admin-panel { padding: 20px 16px; }
.recap-overview-strip, .recap-section-heading { flex-direction: column; gap: 16px; }
.recap-overview-strip { padding: 20px; }
.recap-subscriber-count span { max-width: none; }
.recap-schedule-fields { gap: 12px; }
.recap-panel h2, .recap-preference h2 { font-size: 20px; }
.recap-preview iframe { height: 1200px; }
.page > main.recap-link-page { margin: 36px auto; }
.recap-link-page .account-panel { padding: 26px 22px; }
.recap-pagination { flex-wrap: wrap; }
}
.recap-delivery-choice { display: grid; gap: 8px; margin-block: 16px; }
.recap-delivery-choice select { width: 100%; min-width: 0; }
+113
View File
@@ -0,0 +1,113 @@
"use client";
import PageHeading from "../ui/PageHeading";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from "../lib/auth";
type Profile = {
username?: string;
};
export default function FeedbackPage() {
const router = useRouter();
const [profile, setProfile] = useState<Profile | null>(null);
const [category, setCategory] = useState("bug");
const [message, setMessage] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!getToken()) {
router.push("/login");
return;
}
const load = async () => {
try {
const baseUrl = getApiBase();
const response = await authFetchOrThrow(`${baseUrl}/auth/me`);
if (!response.ok) {
throw new Error("Could not load profile.");
}
const data = await response.json();
setProfile({ username: data?.username });
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push("/login");
return;
}
console.error(error);
}
};
void load();
}, [router]);
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setStatus(null);
if (!message.trim()) {
setStatus("Please write a short message before sending.");
return;
}
setSubmitting(true);
try {
const baseUrl = getApiBase();
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: category,
message: message.trim(),
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Request failed: ${response.status}`);
}
setMessage("");
setStatus("Thanks! Your message has been sent.");
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push("/login");
return;
}
console.error(error);
setStatus("That did not send. Please try again.");
} finally {
setSubmitting(false);
}
};
return (
<main className="card feedback-page">
<PageHeading title="Feedback" description="Share an idea or tell us what could work better." />
<form className="account-panel account-form feedback-form" onSubmit={submit}>
<label htmlFor="feedback-user">Your username</label>
<input id="feedback-user" value={profile?.username ?? ""} readOnly />
<label htmlFor="feedback-type">What is this about?</label>
<select id="feedback-type" value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="bug">Bug (something is broken)</option>
<option value="feature">Feature idea (new option)</option>
</select>
<label htmlFor="feedback-message">Tell us what happened</label>
<textarea
id="feedback-message"
rows={6}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Write the details here..."
/>
{status && <div className="status-banner">{status}</div>}
<button type="submit" disabled={submitting}>
{submitting ? "Sending..." : "Send feedback"}
</button>
</form>
</main>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import AuthLayout from "../ui/AuthLayout";
import { getApiBase } from "../lib/auth";
export default function ForgotPasswordPage() {
const router = useRouter();
const [identifier, setIdentifier] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!identifier.trim()) {
setError("Enter your username or email.");
return;
}
setLoading(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/password/forgot`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: identifier.trim() }),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(typeof data?.detail === "string" ? data.detail : "Unable to send reset link.");
}
setStatus(
typeof data?.message === "string"
? data.message
: "If an account exists for that username or email, a password reset link has been sent.",
);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Unable to send reset link.");
} finally {
setLoading(false);
}
};
return (
<AuthLayout title="Forgot password?" description="Enter your username or email to request a reset link.">
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
<label>
Username or email
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
autoComplete="username"
placeholder="you@example.com"
/>
</label>
{error && (
<div className="account-notice is-error" role="alert">
{error}
</div>
)}
{status && (
<div className="account-notice is-status" role="status">
{status}
</div>
)}
<div className="auth-actions">
<button type="submit" className="account-primary" disabled={loading}>
{loading ? "Sending reset link…" : "Send reset link"}
</button>
</div>
<button type="button" className="ghost-button" onClick={() => router.push("/login")} disabled={loading}>
Back to sign in
</button>
</form>
</AuthLayout>
);
}
File diff suppressed because it is too large Load Diff
+177
View File
@@ -0,0 +1,177 @@
import PageHeading from "../ui/PageHeading";
import "../welcome.css";
export default function HowItWorksPage() {
return (
<main className="friendly-guide">
<PageHeading
title="A little help getting started."
description="Magent looks after your requests. Jellyfin is where you watch them."
/>
<nav aria-label="Quick links">
<a href="/welcome">Welcome page</a>
<a href="/">My Requests</a>
<a href="/profile">My profile</a>
</nav>
<details open>
<summary>Request a movie or TV show</summary>
<ol>
<li>
<strong>Choose Movie or TV show.</strong>
<p>
Open <a href="/new-requests">New Requests</a> and pick what youre looking for.
</p>
</li>
<li>
<strong>Search and choose the right title.</strong>
<p>
For TV, choose the seasons you want. If its already requested, open that request to see its progress.
</p>
</li>
<li>
<strong>Check your choices and send it.</strong>
<p>Choose from the quality options shown. These come from the librarys settings.</p>
</li>
<li>
<strong>Follow it in My Requests.</strong>
<p>
Well show whats happening and any next step you can take. Some titles need approval or may not have a
suitable download yet.
</p>
</li>
</ol>
</details>
<details>
<summary>Understand the six progress steps</summary>
<ol>
<li>
<strong>Requested:</strong> Your request has been received.
</li>
<li>
<strong>Approved:</strong> It has permission to go ahead.
</li>
<li>
<strong>Library collection:</strong> The library is tracking whats collected and whats missing.
</li>
<li>
<strong>Release search:</strong> A suitable download is being looked for. Waiting here can mean there isnt
a good match yet.
</li>
<li>
<strong>Download:</strong> The files are being downloaded. TV requests can include several episodes or a
season pack.
</li>
<li>
<strong>Available to watch:</strong> Jellyfin has added the content. Use the watch button to open it.
</li>
</ol>
<p>
A finished download still needs to be added to the media library. Wait for Available to watch before heading
over.
</p>
</details>
<details>
<summary>Something looks stuck</summary>
<ol>
<li>
<strong>Open the request.</strong>
<p>Read its current status and next step.</p>
</li>
<li>
<strong>Choose Recheck request.</strong>
<p>Magent checks the connected services again to refresh where things are up to.</p>
</li>
<li>
<strong>Follow the action offered.</strong>
<p>
You may be able to restart a search or review suitable releases. Choose Best pick when offered if youre
unsure.
</p>
</li>
</ol>
<p>
Remote activity explains the latest check. Open it to see the full list. A successful search doesnt always
mean a download was found.
</p>
</details>
<details>
<summary>Report a problem and follow the fix</summary>
<ol>
<li>
<strong>
Open <a href="/portal/issues">Issues</a>.
</strong>
<p>Choose whats wrong: missing content, broken picture, wrong download, audio, subtitles, or playback.</p>
</li>
<li>
<strong>Choose the affected content.</strong>
<p>
Find the movie or show. For TV, select the affected seasons or episodes; you can choose more than one.
</p>
</li>
<li>
<strong>Read What will happen, then submit.</strong>
<p>
It tells you whether the selected files will be replaced, missing content searched for, subtitles checked,
or playback investigated.
</p>
</li>
<li>
<strong>Follow the issues progress.</strong>
<p>Open your reported issue to see the work recorded and where the fix is up to.</p>
</li>
<li>
<strong>Tell us if it worked.</strong>
<p>
When a supported repair is detected as ready to check, Magent can email you. Try the content, then choose
Yes if its fixed or No if you still need help.
</p>
</li>
</ol>
<p>
Add your email in <a href="/profile">My profile</a> so updates can reach you. Reminder and automatic closure
timings depend on the sites settings.
</p>
</details>
<details>
<summary>Invite someone</summary>
<ol>
<li>
<strong>
Open <a href="/profile/invites">Invites</a>.
</strong>
<p>If invites are enabled for your account, give your invite a name youll recognise.</p>
</li>
<li>
<strong>Add a welcome note, or skip it.</strong>
<p>A custom invite code is optional too.</p>
</li>
<li>
<strong>Choose how to share it.</strong>
<p>Copy the link yourself, or enter an email address to send it directly.</p>
</li>
<li>
<strong>Manage it later.</strong>
<p>
You can return to your invites to check them or disable a link. Your accounts invite limits apply
automatically.
</p>
</li>
</ol>
</details>
<details>
<summary>Update your account</summary>
<p>
Open the account menu and choose <a href="/profile">My profile</a> to update your contact email, view your
activity, or use the password options available for your account.
</p>
<p>
Looking for your downloads instead? <a href="/">My Requests</a> is your starting point.
</p>
</details>
<footer>
Ready? <a href="/welcome">Choose where to go next </a>
</footer>
</main>
);
}
+282
View File
@@ -0,0 +1,282 @@
"use client";
import { useState } from "react";
import { getApiBase } from "../lib/auth";
export type Breakdown = { name: string; minutes: number };
export type Day = { date: string; minutes: number };
export type Transcoding = {
video_minutes: number;
audio_minutes: number;
hardware_video_minutes: number;
software_video_minutes: number;
unknown_hardware_minutes: number;
unknown_video_minutes: number;
unknown_audio_minutes: number;
hardware: Breakdown[];
audio_codecs: Breakdown[];
gpu_busy_minutes: null;
};
export type Stats = {
state: "ready" | "not_configured" | "unlinked";
is_admin: boolean;
days: number;
updated_at?: string;
summary: null | {
minutes: number;
movies: number;
episodes: number;
plays: number;
current_streak: number;
longest_streak: number;
active_days: number;
};
daily?: Day[];
patterns?: {
average_play_minutes: number;
longest_play_minutes: number;
weekend_percent: number;
weekdays: Breakdown[];
media: Breakdown[];
};
top_titles?: { artwork_url?: string | null; title: string; type: string; minutes: number; plays: number }[];
recent?: {
id: string;
title: string;
series: string;
episode?: string;
type: string;
minutes: number;
played_at: string;
client: string;
method: string;
artwork_url?: string | null;
}[];
clients?: Breakdown[];
methods?: Breakdown[];
transcoding?: Transcoding;
requests: {
total: number;
movies: number;
tv: number;
pending: number;
approved: number;
declined: number;
recent: { request_id: number; title: string; media_type: string; status: number }[];
};
};
export const number = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 0 });
export const dateLabel = (date: string) =>
new Date(date).toLocaleDateString(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
export function ViewingChart({ daily }: { daily: Day[] }) {
const [selected, setSelected] = useState<number | null>(null);
const bucket = daily.length > 100 ? 7 : daily.length > 35 ? 3 : 1;
const bars: { start: string; end: string; minutes: number }[] = [];
for (let i = 0; i < daily.length; i += bucket) {
const group = daily.slice(i, i + bucket);
bars.push({
start: group[0].date,
end: group[group.length - 1].date,
minutes: group.reduce((sum, day) => sum + day.minutes, 0),
});
}
const peak = Math.max(1, ...bars.map((bar) => bar.minutes));
const active = selected === null ? null : bars[selected];
return (
<section className="stats-panel stats-viewing" aria-labelledby="viewing-title">
<div className="stats-panel-heading">
<div>
<h2 id="viewing-title">Your viewing rhythm</h2>
<p>{bucket === 1 ? "Daily" : `${bucket}-day`} watch time · UTC</p>
</div>
<span className="stats-unit">Minutes</span>
</div>
<div className="stats-chart-detail" aria-live="polite">
{active
? `${dateLabel(active.start)}${active.end !== active.start ? ` ${dateLabel(active.end)}` : ""} · ${number(active.minutes)} minutes`
: "Select a bar to explore your watch time."}
</div>
<div className="stats-chart">
<div className="stats-chart-scale" aria-hidden="true">
<span>{number(peak)}</span>
<span>{number(peak / 2)}</span>
<span>0</span>
</div>
<div className="stats-chart-bars">
{bars.map((bar, index) => (
<button
type="button"
className={selected === index ? "is-selected" : ""}
key={bar.start}
aria-label={`${dateLabel(bar.start)}${bar.end !== bar.start ? ` to ${dateLabel(bar.end)}` : ""}: ${number(bar.minutes)} minutes`}
aria-pressed={selected === index}
onClick={() => setSelected(index)}
onFocus={() => setSelected(index)}
>
<span style={{ height: `${bar.minutes > 0 ? Math.max(2, (bar.minutes / peak) * 100) : 1}%` }} />
</button>
))}
</div>
</div>
<div className="stats-chart-axis" aria-hidden="true">
<span>{bars[0] && dateLabel(bars[0].start)}</span>
<span>{bars.length > 0 && dateLabel(bars[bars.length - 1].end)}</span>
</div>
</section>
);
}
export function BreakdownCard({ title, rows }: { title: string; rows: Breakdown[] }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0);
return (
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>{title}</h2>
</div>
{rows.length ? (
<div className="stats-breakdown">
{rows.map((row) => (
<div key={row.name}>
<div className="stats-breakdown-label">
<span>{row.name}</span>
<strong>{number(row.minutes)} min</strong>
</div>
<div className="stats-meter">
<span style={{ width: `${total ? (row.minutes / total) * 100 : 0}%` }} />
</div>
</div>
))}
</div>
) : (
<p className="stats-muted">Your next watch will start the story here.</p>
)}
</section>
);
}
const minutes = (value: number) => `${value.toLocaleString(undefined, { maximumFractionDigits: 1 })} min`;
export function StreamingCard({ rows, transcoding }: { rows: Breakdown[]; transcoding?: Transcoding }) {
const total = rows.reduce((sum, row) => sum + row.minutes, 0);
return (
<section className="stats-panel stats-streaming">
<div className="stats-panel-heading">
<h2>How you streamed</h2>
</div>
<div className="stats-breakdown">
{rows.map((row) => (
<div key={row.name}>
<div className="stats-breakdown-label">
<span>{row.name}</span>
<strong>{minutes(row.minutes)}</strong>
</div>
<div className="stats-meter">
<span style={{ width: `${total ? (row.minutes / total) * 100 : 0}%` }} />
</div>
</div>
))}
</div>
{transcoding && (
<div className="stats-transcoding">
<h3>Transcoding playback time</h3>
<div className="stats-transcode-metrics">
<div>
<span>GPU-assisted video</span>
<strong>
{transcoding.hardware_video_minutes === 0 &&
(transcoding.unknown_hardware_minutes > 0 || transcoding.unknown_video_minutes > 0)
? "Not recorded"
: minutes(transcoding.hardware_video_minutes)}
</strong>
<small>
{transcoding.hardware.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`).join(" / ") ||
"Hardware-accelerated video"}
</small>
</div>
<div>
<span>Audio transcoding</span>
<strong>
{transcoding.audio_minutes === 0 && transcoding.unknown_audio_minutes > 0
? "Not recorded"
: minutes(transcoding.audio_minutes)}
</strong>
<small>
{transcoding.audio_codecs
.slice(0, 3)
.map((entry) => `${entry.name} · ${minutes(entry.minutes)}`)
.join(" / ") || "Audio converted for your player"}
</small>
</div>
</div>
<dl className="stats-transcode-details">
<div>
<dt>Total video transcoding</dt>
<dd>
{transcoding.video_minutes === 0 && transcoding.unknown_video_minutes > 0
? "Not recorded"
: minutes(transcoding.video_minutes)}
</dd>
</div>
{transcoding.software_video_minutes > 0 && (
<div>
<dt>Software video</dt>
<dd>{minutes(transcoding.software_video_minutes)}</dd>
</div>
)}
{transcoding.unknown_hardware_minutes > 0 && (
<div>
<dt>Video hardware not recorded</dt>
<dd>{minutes(transcoding.unknown_hardware_minutes)}</dd>
</div>
)}
</dl>
{(transcoding.unknown_audio_minutes > 0 || transcoding.unknown_video_minutes > 0) && (
<p className="stats-muted">
Some stream details are missing: video {minutes(transcoding.unknown_video_minutes)}, audio{" "}
{minutes(transcoding.unknown_audio_minutes)}.
</p>
)}
<p className="stats-muted">
Playback minutes, with audio and video counted separately. They can overlap. GPU busy time is not recorded
by Jellystat.
</p>
</div>
)}
</section>
);
}
export function RecentArtwork({ url, type }: { url?: string | null; type: string }) {
const [failed, setFailed] = useState(false);
return (
<div className={`stats-media-icon stats-media-icon-${type}`} aria-hidden="true">
{url && !failed ? (
<img
src={`${getApiBase()}${url}`}
alt=""
width={44}
height={66}
loading="lazy"
onError={() => setFailed(true)}
/>
) : (
<span>{type === "episode" ? "TV" : type === "movie" ? "MV" : "▶"}</span>
)}
</div>
);
}
export function StatsNavigation({ reports = false }: { reports?: boolean }) {
return (
<nav className="stats-view-tabs" aria-label="My Stats views">
<a href="/insights" aria-current={!reports ? "page" : undefined}>
Overview
</a>
<a href="/insights/reports" aria-current={reports ? "page" : undefined}>
Monthly reports
</a>
</nav>
);
}
+338
View File
@@ -0,0 +1,338 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../lib/auth";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import {
type Stats,
BreakdownCard,
RecentArtwork,
StatsNavigation,
StreamingCard,
ViewingChart,
dateLabel,
number,
} from "./components";
import "./stats.css";
export default function InsightsPage() {
const router = useRouter();
const [days, setDays] = useState(30);
const [data, setData] = useState<Stats | null>(null);
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const load = useCallback(
async (signal: AbortSignal) => {
setBusy(true);
setError("");
setData(null);
try {
const response = await authFetch(`${getApiBase()}/insights?days=${days}`, { signal });
if (response.status === 401) {
router.replace("/login?next=%2Finsights");
return;
}
if (response.status === 403)
throw new Error("Your account cannot access viewing stats. Please contact an administrator.");
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(
typeof result.detail === "string"
? result.detail
: "Your viewing stats are temporarily unavailable. Please try again shortly.",
);
}
const result = (await response.json()) as Stats;
if (!signal.aborted) setData(result);
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : "Could not load your stats.");
} finally {
if (!signal.aborted) setBusy(false);
}
},
[days, router],
);
useEffect(() => {
void revision;
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load, revision]);
const summary = data?.summary;
return (
<main className="stats-page">
<PageHeading
title="My Stats"
description="Your viewing, in numbers. Watch time, favourite stories, and the requests that started it all."
actions={
<button
className="ghost-button"
type="button"
disabled={busy}
onClick={() => setRevision((value) => value + 1)}
>
{busy ? "Loading…" : "Refresh stats"}
</button>
}
/>
<StatsNavigation />
<div className="stats-toolbar">
<fieldset className="stats-period">
<legend className="stats-sr-only">Stats period</legend>
{[7, 30, 90, 365].map((value) => (
<button type="button" key={value} aria-pressed={days === value} onClick={() => setDays(value)}>
{value === 365 ? "Past year" : `${value} days`}
</button>
))}
</fieldset>
<p className="stats-source">
<span className={data?.state === "ready" ? "stats-source-dot is-ready" : "stats-source-dot"} />
From Jellystat
{data?.updated_at && (
<span>
{" "}
· Updated{" "}
{new Date(data.updated_at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
</span>
)}
</p>
</div>
{busy && (
<div className="stats-state" role="status">
<span className="stats-state-symbol" aria-hidden="true">
</span>
<h2>Gathering your stats</h2>
<p>Fetching your viewing history from Jellystat.</p>
</div>
)}
{error && (
<div className="stats-state" role="alert">
<h2>Stats couldnt load</h2>
<p>{error}</p>
<button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
</div>
)}
{data?.state === "not_configured" && (
<section className="stats-state">
<span className="stats-state-symbol" aria-hidden="true">
</span>
<h2>Your viewing story starts here</h2>
<p>
{isAdmin
? "Connect your Jellystat instance to bring personal viewing stats into Magent."
: "Viewing stats will appear here once your administrator connects Jellystat."}
</p>
{isAdmin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
)}
</section>
)}
{data?.state === "unlinked" && (
<section className="stats-state">
<h2>Link your viewing account</h2>
<p>
Your Magent account needs a Jellyfin identity to find your stats. Sign in using Jellyfin, or ask your
administrator to sync Jellyfin users.
</p>
</section>
)}
{summary && (
<>
<section className="stats-metrics" aria-label="Viewing totals">
<article className="stats-metric stats-metric-accent">
<span>Minutes watched</span>
<strong>{number(summary.minutes)}</strong>
<small>
{number(summary.minutes / 60)} hours across {number(summary.plays)} plays
</small>
</article>
<article className="stats-metric">
<span>Movies played</span>
<strong>{number(summary.movies)}</strong>
<small>Different movies you pressed play on</small>
</article>
<article className="stats-metric">
<span>Episodes played</span>
<strong>{number(summary.episodes)}</strong>
<small>Different episodes in your history</small>
</article>
<article className="stats-metric">
<span>Requests made</span>
<strong>{number(data.requests.total)}</strong>
<small>
{number(data.requests.movies)} movies · {number(data.requests.tv)} TV requests
</small>
</article>
</section>
{summary.plays === 0 && (
<div className="stats-notice" role="status">
No viewing history in this period yet. Try a longer period, or come back after your next watch.
</div>
)}
<div className="stats-main-grid">
<ViewingChart key={`${days}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel stats-highlights">
<div className="stats-panel-heading">
<h2>A little watch history</h2>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.current_streak}
<small> days</small>
</span>
<div>
<strong>Current streak</strong>
<p>Consecutive viewing days through today or yesterday.</p>
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.longest_streak}
<small> days</small>
</span>
<div>
<strong>Longest run</strong>
<p>Your best streak in this period.</p>
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.active_days}
<small> days</small>
</span>
<div>
<strong>Time for a story</strong>
<p>Days with at least a minute watched.</p>
</div>
</div>
</section>
</div>
<div className="stats-three-grid">
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>Most watched</h2>
<span className="stats-unit">By minutes</span>
</div>
{data.top_titles?.length ? (
<ol className="stats-top-titles">
{data.top_titles.map((title, index) => (
<li key={`${title.title}-${index}`}>
<span className="stats-rank">{String(index + 1).padStart(2, "0")}</span>
<div>
<strong>{title.title}</strong>
<small>
{title.type === "series" ? "TV series" : title.type === "movie" ? "Movie" : "Other media"} ·{" "}
{title.plays} plays
</small>
</div>
<span>
{number(title.minutes)}
<small>min</small>
</span>
</li>
))}
</ol>
) : (
<p className="stats-muted">Your favourites will find their place here.</p>
)}
</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
</>
)}
{data && (
<div className="stats-main-grid">
{summary && (
<section className="stats-panel stats-history">
<div className="stats-panel-heading">
<h2>Recently watched</h2>
<span className="stats-unit">Latest 20 plays</span>
</div>
{data.recent?.length ? (
<div className="stats-history-list">
{data.recent.map((play) => (
<article key={play.id}>
<RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} />
<div className="stats-history-title">
<strong>{play.series || play.title}</strong>
<small>
{play.series ? `${play.episode} · ${play.title}` : play.type === "movie" ? "Movie" : "Media"}
</small>
<span>
{play.client} · {play.method}
</span>
</div>
<div className="stats-history-time">
<strong>{number(play.minutes)} min</strong>
<time dateTime={play.played_at}>{dateLabel(play.played_at)}</time>
</div>
</article>
))}
</div>
) : (
<p className="stats-muted">Plays recorded by Jellystat will appear here.</p>
)}
</section>
)}
<section className="stats-panel stats-requests">
<div className="stats-panel-heading">
<h2>Your requests</h2>
<a href="/">View all</a>
</div>
<div className="stats-request-total">
<strong>{data.requests.total}</strong>
<span>submitted in the past {days} days</span>
</div>
<div className="stats-request-counts">
<span>
<strong>{data.requests.pending}</strong> Pending
</span>
<span>
<strong>{data.requests.approved}</strong> Approved
</span>
<span>
<strong>{data.requests.declined}</strong> Declined
</span>
</div>
{data.requests.recent.length > 0 ? (
<ul className="stats-request-list">
{data.requests.recent.map((request) => (
<li key={request.request_id}>
<a href={`/requests/${request.request_id}`}>
{request.title || `Request ${request.request_id}`}
<span aria-hidden="true"></span>
</a>
</li>
))}
</ul>
) : (
<p className="stats-muted">
Something on your watchlist? <a href="/new-requests">Make a request.</a>
</p>
)}
</section>
</div>
)}
{summary && (
<p className="stats-footnote">
Private to your account · Stats come from Jellystat and may take a minute to refresh. Counts describe plays,
including unfinished watches. Movies are identified from Jellystats movie libraries; other media still
contributes to watch time. Charts and streaks use UTC and the activity dates recorded by Jellystat.
</p>
)}
</main>
);
}
@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../../lib/auth";
type Delivery = { id: string; month: string; state: string; detail: string };
type Preference = { email: string | null; can_send: boolean; state: string; detail: string; deliveries: Delivery[] };
export default function EmailReportControl({ month }: { month: string }) {
const [data, setData] = useState<Preference | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState("");
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const request = useRef<{ month: string; id: string } | null>(null);
const pending =
data?.deliveries.some((item) => ["queued", "preparing", "sending", "retry"].includes(item.state)) ?? false;
useEffect(() => {
void revision;
const abort = new AbortController();
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal })
.then(async (response) => {
if (!response.ok) throw new Error("Could not load your report email preferences. Refresh to try again.");
const result = await response.json();
if (!abort.signal.aborted) setData(result);
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [revision]);
useEffect(() => {
if (!pending) return;
const timer = window.setInterval(() => setRevision((value) => value + 1), 10000);
return () => window.clearInterval(timer);
}, [pending]);
const send = async () => {
if (busy || !data?.can_send) return;
setBusy(true);
setError("");
setNotice("");
if (request.current?.month !== month) request.current = { month, id: crypto.randomUUID() };
try {
const response = await authFetch(`${getApiBase()}/profile/email-recaps/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ month, request_id: request.current.id }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(typeof result.detail === "string" ? result.detail : "Could not queue your report. Try again.");
setNotice(result.message);
request.current = null;
setRevision((value) => value + 1);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not queue your report.");
} finally {
setBusy(false);
}
};
return (
<section className="stats-panel report-email-panel" aria-label="Email your report">
<div className="stats-panel-heading">
<h2>Email yourself this report</h2>
<a href="/profile#monthly-recaps">Email preferences</a>
</div>
<p>
Choose a month above, including the current month so far, then send its viewing and request summary to your
confirmed profile email.
</p>
{data?.can_send ? (
<p>
<strong>{data.email}</strong> · One report email every five minutes.
</p>
) : (
data && (
<p>
{data.state === "enabled"
? data.detail
: "Confirm your profile email in Email preferences first. You can choose on-demand delivery without automatic monthly emails."}
</p>
)
)}
<button type="button" disabled={busy || !data?.can_send} onClick={() => void send()}>
{busy ? "Queueing report…" : "Email this report"}
</button>
{notice && <p role="status">{notice}</p>}
{error && <p role="alert">{error}</p>}
{!!data?.deliveries.length && (
<details>
<summary>Recent report emails</summary>
<ul>
{data.deliveries.map((item) => (
<li key={item.id}>
<strong>{item.month}</strong> · {item.state === "sent" ? "Accepted by mail server" : item.state} {" "}
{item.detail || "Waiting for delivery"}
</li>
))}
</ul>
</details>
)}
</section>
);
}
+533
View File
@@ -0,0 +1,533 @@
"use client";
import EmailReportControl from "./EmailReportControl";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../../lib/auth";
import { useEffectiveRole } from "../../lib/viewMode";
import PageHeading from "../../ui/PageHeading";
import {
type Stats,
BreakdownCard,
RecentArtwork,
StatsNavigation,
StreamingCard,
ViewingChart,
dateLabel,
number,
} from "../components";
import "../stats.css";
import "./reports.css";
type Change = { current: number; previous: number; difference: number; percent: number | null };
type MonthlyReport = Omit<Stats, "days"> & {
month: string;
available_months: string[];
is_partial: boolean;
comparison_capped: boolean;
period_start: string;
period_end: string;
comparison_month: string;
comparison_start: string;
comparison_end: string;
previous_summary?: Stats["summary"];
previous_requests?: Omit<Stats["requests"], "recent">;
changes?: Record<"minutes" | "movies" | "episodes" | "plays" | "active_days" | "longest_streak" | "requests", Change>;
};
const monthLabel = (month: string, short = false) =>
new Date(`${month}-01T00:00:00Z`).toLocaleDateString(undefined, {
month: short ? "short" : "long",
year: "numeric",
timeZone: "UTC",
});
const decimal = (value: number) => value.toLocaleString(undefined, { maximumFractionDigits: 1 });
function ChangeLabel({ change, unit = "" }: { change: Change; unit?: string }) {
const delta = change.difference;
return (
<div className={`report-change ${delta > 0 ? "is-up" : delta < 0 ? "is-down" : "is-flat"}`}>
<span>
{delta === 0
? "No change"
: `${delta > 0 ? "+" : ""}${decimal(Math.abs(delta))}${unit}${change.percent === null ? "" : ` (${delta > 0 ? "+" : ""}${decimal(Math.abs(change.percent))}%)`}`}
</span>
<small>
{change.percent === null
? "No activity recorded in the comparison period"
: `Previously ${decimal(change.previous)}${unit}`}
</small>
</div>
);
}
export default function MonthlyReportsPage() {
const router = useRouter();
const [month, setMonth] = useState("");
const [monthReady, setMonthReady] = useState(false);
const [months, setMonths] = useState<string[]>([]);
const [data, setData] = useState<MonthlyReport | null>(null);
const isAdmin = useEffectiveRole(data?.is_admin ? "admin" : "user") === "admin";
const [busy, setBusy] = useState(true);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const [downloading, setDownloading] = useState(false);
const [downloadError, setDownloadError] = useState("");
const downloadController = useRef<AbortController | null>(null);
useEffect(() => {
setMonth(new URLSearchParams(window.location.search).get("month") || "");
setMonthReady(true);
}, []);
useEffect(() => {
if (monthReady)
window.history.replaceState(null, "", `/insights/reports${month ? `?month=${encodeURIComponent(month)}` : ""}`);
}, [month, monthReady]);
useEffect(() => () => downloadController.current?.abort(), []);
const load = useCallback(
async (signal: AbortSignal) => {
setBusy(true);
setError("");
setData(null);
setDownloadError("");
try {
const query = month ? `?month=${encodeURIComponent(month)}` : "";
const response = await authFetch(`${getApiBase()}/insights/reports/monthly${query}`, { signal });
if (response.status === 401) {
router.replace(`/login?next=${encodeURIComponent(`/insights/reports${query}`)}`);
return;
}
if (response.status === 403)
throw new Error("Your account cannot access viewing reports. Please contact an administrator.");
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(
typeof result.detail === "string"
? result.detail
: "Your report is temporarily unavailable. Please try again shortly.",
);
}
const result = (await response.json()) as MonthlyReport;
if (!signal.aborted) {
setData(result);
setMonths(result.available_months);
}
} catch (err) {
if (!signal.aborted) setError(err instanceof Error ? err.message : "Could not load your report.");
} finally {
if (!signal.aborted) setBusy(false);
}
},
[month, router],
);
useEffect(() => {
void revision;
if (!monthReady) return;
const controller = new AbortController();
void load(controller.signal);
return () => controller.abort();
}, [load, revision, monthReady]);
const download = async () => {
if (data?.state !== "ready" || downloading) return;
const selected = data.month;
const controller = new AbortController();
downloadController.current = controller;
setDownloading(true);
setDownloadError("");
try {
const response = await authFetch(`${getApiBase()}/insights/reports/monthly.csv?month=${selected}`, {
signal: controller.signal,
});
if (response.status === 401) {
router.replace(`/login?next=${encodeURIComponent(`/insights/reports?month=${selected}`)}`);
return;
}
if (!response.ok) throw new Error("The report could not be downloaded. Please try again.");
const blob = await response.blob();
if (controller.signal.aborted) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `magent-monthly-report-${selected}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) {
if (!controller.signal.aborted)
setDownloadError(err instanceof Error ? err.message : "Could not download your report.");
} finally {
if (!controller.signal.aborted) setDownloading(false);
}
};
const selectedMonth = month || data?.month || "";
const monthIndex = months.indexOf(selectedMonth);
const summary = data?.summary;
const changes = data?.changes;
return (
<main className="stats-page reports-page">
<PageHeading
title="Monthly report"
description="Your month in viewing. See what you watched, what changed, and what you requested."
actions={
<>
<button
className="ghost-button"
type="button"
disabled={busy || downloading}
onClick={() => setRevision((value) => value + 1)}
>
Refresh report
</button>
<button
className="ghost-button"
type="button"
disabled={busy || downloading || data?.state !== "ready"}
onClick={() => void download()}
>
{downloading ? "Downloading…" : "Download CSV"}
</button>
</>
}
/>
<StatsNavigation reports />
<div className="stats-toolbar">
<div className="report-month-picker">
<button
type="button"
className="ghost-button"
aria-label="Previous month"
disabled={busy || downloading || monthIndex < 0 || monthIndex >= months.length - 1}
onClick={() => setMonth(months[monthIndex + 1])}
>
</button>
<label>
<span className="stats-sr-only">Report month</span>
<select
value={selectedMonth}
disabled={busy || downloading || !months.length}
onChange={(event) => setMonth(event.target.value)}
>
{!selectedMonth && <option value="">Latest complete month</option>}
{months.map((value, index) => (
<option value={value} key={value}>
{monthLabel(value)}
{index === 0 ? " · month to date" : ""}
</option>
))}
</select>
</label>
<button
type="button"
className="ghost-button"
aria-label="Next month"
disabled={busy || downloading || monthIndex <= 0}
onClick={() => setMonth(months[monthIndex - 1])}
>
</button>
</div>
<p className="stats-source">
<span className={data?.state === "ready" ? "stats-source-dot is-ready" : "stats-source-dot"} />
From Jellystat · UTC
</p>
</div>
{downloadError && (
<p className="stats-notice" role="alert">
{downloadError}
</p>
)}
{data?.state === "ready" && !busy && <EmailReportControl month={data.month} />}
{busy && (
<div className="stats-state" role="status">
<span className="stats-state-symbol" aria-hidden="true">
</span>
<h2>Putting your month together</h2>
<p>Gathering your viewing history and the previous months comparison.</p>
</div>
)}
{error && (
<div className="stats-state" role="alert">
<h2>Report couldnt load</h2>
<p>{error}</p>
<button type="button" className="ghost-button" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
{month && (
<button type="button" className="ghost-button" onClick={() => setMonth("")}>
Latest complete month
</button>
)}
</div>
)}
{data?.state === "not_configured" && (
<section className="stats-state">
<h2>Your monthly story starts here</h2>
<p>
{isAdmin
? "Connect Jellystat to bring your monthly viewing reports into Magent."
: "Monthly reports will appear once your administrator connects Jellystat."}
</p>
{isAdmin && (
<a className="stats-action" href="/admin/jellystat">
Connect Jellystat
</a>
)}
</section>
)}
{data?.state === "unlinked" && (
<section className="stats-state">
<h2>Link your viewing account</h2>
<p>
Your report needs your Jellyfin account link. Sign in using Jellyfin, or ask your administrator to review
your user identities.
</p>
{isAdmin && (
<a className="stats-action" href="/admin/identities">
Review user identities
</a>
)}
</section>
)}
{data && summary && changes && (
<>
<section className="report-intro" aria-label="Report period">
<div>
<span className="report-kicker">{data.is_partial ? "Month to date" : "Your monthly recap"}</span>
<h2>{monthLabel(data.month)}</h2>
<p>
{data.is_partial
? `Compared with the same elapsed time in ${monthLabel(data.comparison_month)}${data.comparison_capped ? ", capped at the end of that month" : ""}.`
: `Compared with ${monthLabel(data.comparison_month)}.`}
</p>
</div>
<div className="report-period-meta">
<span>{data.is_partial ? "In progress" : "Complete month"}</span>
<small>
{data.updated_at &&
`Updated ${new Date(data.updated_at).toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short", timeZone: "UTC" })} UTC`}
</small>
</div>
</section>
<section className="stats-metrics" aria-label="Monthly totals">
<article className="stats-metric stats-metric-accent">
<span>Minutes watched</span>
<strong>{number(summary.minutes)}</strong>
<small>
{decimal(summary.minutes / 60)} hours across {number(summary.plays)} plays
</small>
<ChangeLabel change={changes.minutes} unit=" min" />
</article>
<article className="stats-metric">
<span>Movies played</span>
<strong>{number(summary.movies)}</strong>
<small>Different movies you pressed play on</small>
<ChangeLabel change={changes.movies} />
</article>
<article className="stats-metric">
<span>Episodes played</span>
<strong>{number(summary.episodes)}</strong>
<small>Different episodes in your history</small>
<ChangeLabel change={changes.episodes} />
</article>
<article className="stats-metric">
<span>Requests made</span>
<strong>{number(data.requests.total)}</strong>
<small>
{data.requests.movies} movies · {data.requests.tv} TV requests
</small>
<ChangeLabel change={changes.requests} />
</article>
</section>
{summary.plays === 0 && (
<div className="stats-notice" role="status">
No viewing history was recorded for this month. Your request totals and comparison are still shown.
</div>
)}
<div className="stats-main-grid">
<ViewingChart key={`${data.month}-${revision}`} daily={data.daily ?? []} />
<section className="stats-panel report-highlights">
<div className="stats-panel-heading">
<h2>Your viewing habits</h2>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.active_days}
<small> days</small>
</span>
<div>
<strong>Days you watched</strong>
<p>At least one minute of viewing.</p>
<ChangeLabel change={changes.active_days} unit=" days" />
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{summary.longest_streak}
<small> days</small>
</span>
<div>
<strong>Longest run</strong>
<p>Consecutive viewing days this month.</p>
<ChangeLabel change={changes.longest_streak} unit=" days" />
</div>
</div>
<div className="stats-highlight">
<span className="stats-highlight-number">
{data.daily?.length ? number(summary.minutes / data.daily.length) : 0}
<small> min</small>
</span>
<div>
<strong>Daily average</strong>
<p>Across the calendar days in this report.</p>
</div>
</div>
</section>
</div>
{data.patterns && (
<>
<section className="report-pattern-summary" aria-label="Viewing insights">
<article>
<span>Average play</span>
<strong>
{decimal(data.patterns.average_play_minutes)} <small>min</small>
</strong>
<p>Time per recorded playback session.</p>
</article>
<article>
<span>Longest play</span>
<strong>
{decimal(data.patterns.longest_play_minutes)} <small>min</small>
</strong>
<p>Your longest recorded session this month.</p>
</article>
<article>
<span>Weekend viewing</span>
<strong>
{decimal(data.patterns.weekend_percent)}
<small>%</small>
</strong>
<p>Share of viewing on Saturday and Sunday (UTC).</p>
</article>
</section>
<div className="stats-main-grid">
<BreakdownCard title="Your week in viewing (UTC)" rows={data.patterns.weekdays} />
<BreakdownCard title="Movies, TV and more" rows={data.patterns.media} />
</div>
</>
)}
<div className="stats-three-grid">
<section className="stats-panel">
<div className="stats-panel-heading">
<h2>Most watched</h2>
<span className="stats-unit">By minutes</span>
</div>
{data.top_titles?.length ? (
<ol className="stats-top-titles report-top-titles">
{data.top_titles.map((title, index) => (
<li key={`${title.title}-${index}`}>
<RecentArtwork url={title.artwork_url} type={title.type} />
<div>
<strong>{title.title}</strong>
<small>
{title.type === "series" ? "TV series" : title.type === "movie" ? "Movie" : "Other media"} ·{" "}
{title.plays} plays
</small>
</div>
<span>
{number(title.minutes)}
<small>min</small>
</span>
</li>
))}
</ol>
) : (
<p className="stats-muted">Your most watched titles will appear here.</p>
)}
</section>
<BreakdownCard title="Your players" rows={data.clients ?? []} />
<StreamingCard rows={data.methods ?? []} transcoding={data.transcoding} />
</div>
<div className="stats-main-grid">
<section className="stats-panel stats-history">
<div className="stats-panel-heading">
<h2>A look back</h2>
<span className="stats-unit">Latest 20 plays this month</span>
</div>
{data.recent?.length ? (
<div className="stats-history-list">
{data.recent.map((play) => (
<article key={play.id}>
<RecentArtwork key={play.artwork_url ?? play.id} url={play.artwork_url} type={play.type} />
<div className="stats-history-title">
<strong>{play.series || play.title}</strong>
<small>
{play.series ? `${play.episode} · ${play.title}` : play.type === "movie" ? "Movie" : "Media"}
</small>
<span>
{play.client} · {play.method}
</span>
</div>
<div className="stats-history-time">
<strong>{number(play.minutes)} min</strong>
<time dateTime={play.played_at}>{dateLabel(play.played_at)}</time>
</div>
</article>
))}
</div>
) : (
<p className="stats-muted">Plays recorded during this month will appear here.</p>
)}
</section>
<section className="stats-panel stats-requests">
<div className="stats-panel-heading">
<h2>Your requests</h2>
<a href="/">View all</a>
</div>
<div className="stats-request-total">
<strong>{data.requests.total}</strong>
<span>submitted in {monthLabel(data.month, true)}</span>
</div>
<div className="stats-request-counts">
<span>
<strong>{data.requests.pending}</strong> Pending
</span>
<span>
<strong>{data.requests.approved}</strong> Approved
</span>
<span>
<strong>{data.requests.declined}</strong> Declined
</span>
</div>
{data.requests.recent.length ? (
<ul className="stats-request-list">
{data.requests.recent.map((request) => (
<li key={request.request_id}>
<a href={`/requests/${request.request_id}`}>
{request.title || `Request ${request.request_id}`}
<span aria-hidden="true"></span>
</a>
</li>
))}
</ul>
) : (
<p className="stats-muted">No requests recorded during this month.</p>
)}
<p className="stats-muted">Statuses reflect where these requests are now.</p>
</section>
</div>
<p className="stats-footnote">
Private to your account · Based on history retained by Jellystat and requests available in Magent. Plays
include unfinished watches. Dates and streaks use UTC. Reports may take a minute to refresh; historical
totals can change when retained history or library metadata changes.
</p>
</>
)}
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
.report-month-picker { display: flex; align-items: center; gap: 8px; min-width: 0; }
.report-month-picker > button { min-width: 38px; min-height: 42px; padding: 8px; }
.report-month-picker label { min-width: 0; }
.report-month-picker select { width: 100%; min-height: 42px; padding: 10px 32px 10px 14px; border: 1px solid var(--ops-line); border-radius: 8px; background: var(--ops-panel); color: var(--ops-text); font-size: 13px; }
.report-month-picker :disabled { opacity: .5; cursor: default; }
.report-intro { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 8px 0; }
.report-kicker { color: var(--ops-faint); font-size: 12px; }
.report-intro h2 { margin: 8px 0; font-size: clamp(24px, 3vw, 32px); color: var(--ops-text); }
.report-intro p { margin: 0; color: var(--ops-muted); font-size: 13px; line-height: 1.7; }
.report-period-meta { display: grid; justify-items: end; gap: 10px; text-align: right; }
.report-period-meta > span { padding: 6px 10px; border: 1px solid var(--ops-line); border-radius: 6px; color: #d1c6ff; font-size: 11px; white-space: nowrap; }
.report-period-meta small { color: var(--ops-faint); font-size: 11px; line-height: 1.6; }
.report-change { display: grid; gap: 5px; font-size: 12px; line-height: 1.6; }
.stats-metric > .report-change { padding-top: 12px; border-top: 1px solid var(--ops-line-soft); }
.report-change > span { color: var(--ops-muted); }
.report-change.is-up > span { color: #d1c6ff; }
.report-change small { color: var(--ops-faint); font-size: 11px; }
.report-highlights .report-change { margin-top: 8px; }
.report-top-titles li { grid-template-columns: minmax(0, 1fr) auto; }
.reports-page .stats-highlight { grid-template-columns: 85px minmax(0, 1fr); }
@media (max-width: 760px) {
.report-intro { align-items: start; flex-direction: column; gap: 16px; }
.report-period-meta { justify-items: start; text-align: left; }
.report-month-picker { width: 100%; }
.report-month-picker label { flex: 1; }
}
.report-email-panel { display: grid; gap: 12px; }
.report-email-panel > button { justify-self: start; }
.report-email-panel p, .report-email-panel li { overflow-wrap: anywhere; }
.report-pattern-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; margin:20px 0; }
.report-pattern-summary article { padding:24px; border:1px solid #45404e; border-radius:16px; background:linear-gradient(135deg,#262137,#14292d); }
.report-pattern-summary span { color:#d0c6e7; font-size:13px; }
.report-pattern-summary strong { display:block; font-size:36px; margin:12px 0; color:#c5baff; }
.report-pattern-summary small { font-size:16px; }
.report-pattern-summary p { color:#b6b6c0; font-size:13px; margin:0; }
.report-top-titles li { grid-template-columns:44px minmax(0,1fr) auto; gap:12px; }
.report-top-titles li > div { flex:1; min-width:0; }
.report-top-titles li > .stats-media-icon { width:44px; }
@media(max-width:640px) { .report-pattern-summary { grid-template-columns:1fr; } }
+110
View File
@@ -0,0 +1,110 @@
.stats-page { padding-bottom: 32px !important; }
.stats-view-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--ops-line-soft); }
.stats-view-tabs a { padding: 0 0 14px; border-bottom: 2px solid transparent; color: var(--ops-muted); font-size: 13px; text-decoration: none; }
.stats-view-tabs a[aria-current=page] { border-color: #c7bdff; color: #d1c6ff; }
.stats-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
.stats-period { display: flex; padding: 4px; margin: 0; min-width: 0; gap: 4px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); }
.stats-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.stats-period button { min-height: 38px; padding: 8px 16px; border: 0; border-radius: 6px; background: transparent !important; color: var(--ops-muted) !important; font-size: 13px; text-transform: none; }
.stats-period button[aria-pressed=true] { background: #c7bdff !important; color: #211a36 !important; font-weight: 700; }
.stats-source { margin: 0; font-size: 12px; color: var(--ops-muted); }
.stats-source-dot { display: inline-block; height: 6px; width: 6px; margin-right: 8px; border-radius: 50%; background: var(--ops-faint); }
.stats-source-dot.is-ready { background: #95d5b2; }
.stats-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
.stats-metric { display: grid; align-content: start; gap: 12px; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.stats-metric > span { font-size: 13px; color: var(--ops-muted); }
.stats-metric > strong { font: 600 clamp(28px, 3vw, 42px)/1.15 "DM Sans", sans-serif; color: var(--ops-text); letter-spacing: -.03em; }
.stats-metric > small { color: var(--ops-faint); font-size: 12px; line-height: 1.6; }
.stats-metric-accent { border-color: #655987; background: linear-gradient(135deg, #2f2940, var(--ops-panel)); }
.stats-metric-accent > strong { color: #d5cbff; }
.stats-main-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(280px, 1fr); gap: 24px; }
.stats-three-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.stats-panel { min-width: 0; padding: 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); }
.stats-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 24px; }
.stats-panel h2 { margin: 0; color: var(--ops-text); font-size: 17px; font-weight: 600; }
.stats-panel-heading p { margin: 8px 0 0; color: var(--ops-faint); font-size: 12px; }
.stats-panel-heading a { font-size: 12px; white-space: nowrap; color: #c7bdff; }
.stats-unit { color: var(--ops-faint); font-size: 11px; white-space: nowrap; }
.stats-chart-detail { min-height: 28px; color: var(--ops-muted); font-size: 12px; }
.stats-chart { height: 180px; display: grid; grid-template-columns: 38px minmax(0, 1fr); gap: 12px; margin-top: 12px; }
.stats-chart-scale { display: flex; flex-direction: column; justify-content: space-between; text-align: right; font: 10px "JetBrains Mono", monospace; color: var(--ops-faint); }
.stats-chart-bars { display: flex; align-items: stretch; gap: clamp(2px, .5vw, 8px); background: repeating-linear-gradient(to top, var(--ops-line-soft) 0px, var(--ops-line-soft) 1px, transparent 1px, transparent 50%); }
.stats-chart-bars button { display: flex; align-items: flex-end; justify-content: center; padding: 0; min-width: 0; flex: 1; border: 0; background: transparent !important; border-radius: 3px; }
.stats-chart-bars button > span { display: block; width: 100%; max-width: 44px; background: #9085b8; border-radius: 3px 3px 0 0; }
.stats-chart-bars button:is(:hover, :focus-visible, .is-selected) > span { background: #d1c6ff; }
.stats-chart-axis { display: flex; justify-content: space-between; padding-left: 50px; margin-top: 12px; color: var(--ops-faint); font-size: 11px; }
.stats-highlight { display: grid; grid-template-columns: 85px minmax(0, 1fr); gap: 16px; align-items: center; padding: 19px 0; border-top: 1px solid var(--ops-line-soft); }
.stats-highlight:first-of-type { border-top: 0; }
.stats-highlight-number { font-size: 28px; color: #d1c6ff; font-weight: 600; }
.stats-highlight-number small { font-size: 11px; color: var(--ops-faint); font-weight: 400; }
.stats-highlight strong { font-size: 13px; color: var(--ops-text); }
.stats-highlight p { margin: 6px 0 0; color: var(--ops-faint); font-size: 12px; line-height: 1.5; }
.stats-top-titles { list-style: none; margin: 0; padding: 0; display: grid; gap: 20px; }
.stats-top-titles li { display: grid; grid-template-columns: 22px minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.stats-rank { font: 11px "JetBrains Mono", monospace; color: var(--ops-faint); }
.stats-top-titles strong { display: block; font-size: 13px; font-weight: 500; color: var(--ops-text); overflow-wrap: anywhere; }
.stats-top-titles small { display: block; margin-top: 5px; font-size: 11px; color: var(--ops-faint); }
.stats-top-titles li > span:last-child { text-align: right; font-size: 13px; color: var(--ops-muted); }
.stats-breakdown { display: grid; gap: 24px; }
.stats-breakdown-label { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 10px; font-size: 12px; }
.stats-breakdown-label span { color: var(--ops-muted); overflow-wrap: anywhere; }
.stats-breakdown-label strong { white-space: nowrap; font-size: 11px; color: var(--ops-faint); font-weight: 400; }
.stats-meter { height: 5px; background: var(--ops-line-soft); border-radius: 5px; overflow: hidden; }
.stats-meter > span { display: block; height: 100%; background: #a497c9; border-radius: 5px; }
.stats-history-list { display: grid; }
.stats-history-list article { display: flex; align-items: center; gap: 14px; padding: 15px 0; border-top: 1px solid var(--ops-line-soft); }
.stats-history-list article:first-child { padding-top: 0; border-top: 0; }
.stats-media-icon { display: grid; place-items: center; flex: 0 0 44px; height: 66px; border-radius: 6px; overflow: hidden; background: #373043; color: #cfc1eb; font: 10px "JetBrains Mono", monospace; }
.stats-media-icon img { display: block; width: 100%; height: 100%; object-fit: cover; }
.stats-transcoding { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--ops-line-soft); }
.stats-transcoding h3 { margin: 0 0 16px; color: var(--ops-text); font-size: 13px; font-weight: 500; }
.stats-transcode-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.stats-transcode-metrics > div { display: grid; align-content: start; gap: 8px; min-width: 0; }
.stats-transcode-metrics span { color: var(--ops-muted); font-size: 11px; }
.stats-transcode-metrics strong { color: #d1c6ff; font-size: 20px; font-weight: 500; overflow-wrap: anywhere; }
.stats-transcode-metrics small { color: var(--ops-faint); font-size: 10px; line-height: 1.7; overflow-wrap: anywhere; }
.stats-transcode-details { display: grid; gap: 10px; margin: 20px 0 12px; }
.stats-transcode-details > div { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; font-size: 11px; color: var(--ops-muted); }
.stats-transcode-details dd { margin: 0; white-space: nowrap; color: var(--ops-faint); }
.stats-transcoding > p { margin: 12px 0 0; font-size: 10px; }
.stats-media-icon-movie { background: #3c322c; color: #e5bfa8; }
.stats-history-title { flex: 1; min-width: 0; }
.stats-history-title strong { display: block; color: var(--ops-text); font-size: 13px; font-weight: 500; overflow-wrap: anywhere; }
.stats-history-title small, .stats-history-title > span { display: block; color: var(--ops-faint); font-size: 11px; line-height: 1.6; margin-top: 3px; overflow-wrap: anywhere; }
.stats-history-title > span { font-size: 10px; }
.stats-history-time { display: grid; gap: 8px; text-align: right; flex-shrink: 0; }
.stats-history-time strong { font-size: 12px; color: var(--ops-muted); font-weight: 500; }
.stats-history-time time { font-size: 11px; color: var(--ops-faint); }
.stats-requests { align-self: start; }
.stats-request-total { display: flex; align-items: center; gap: 16px; }
.stats-request-total > strong { font-size: 36px; color: var(--ops-text); }
.stats-request-total > span { max-width: 15ch; color: var(--ops-muted); font-size: 12px; line-height: 1.6; }
.stats-request-counts { display: flex; justify-content: space-between; gap: 8px; padding: 20px 0; margin-top: 16px; border-block: 1px solid var(--ops-line-soft); }
.stats-request-counts > span { font-size: 11px; color: var(--ops-faint); }
.stats-request-counts strong { display: block; margin-bottom: 8px; color: var(--ops-text); font-size: 18px; font-weight: 500; }
.stats-request-list { list-style: none; margin: 10px 0 0; padding: 0; }
.stats-request-list a { display: flex; justify-content: space-between; gap: 16px; padding: 14px 0; color: var(--ops-muted); font-size: 12px; text-decoration: none; overflow-wrap: anywhere; }
.stats-request-list a:hover { color: #d1c6ff; }
.stats-request-list a > span { color: var(--ops-faint); }
.stats-state { display: grid; justify-items: center; gap: 14px; padding: 56px 24px; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); text-align: center; }
.stats-state h2 { margin: 0; font-size: 22px; color: var(--ops-text); }
.stats-state p { margin: 0; max-width: 60ch; font-size: 14px; color: var(--ops-muted); line-height: 1.8; }
.stats-state-symbol { margin-bottom: 8px; color: #c7bdff; font-size: 36px; }
.stats-action { display: inline-block; margin-top: 8px; padding: 12px 20px; background: #c7bdff; color: #211a36; border-radius: 8px; font-size: 13px; font-weight: 600; text-decoration: none; }
.stats-notice { padding: 16px 20px; border: 1px solid var(--ops-line); border-radius: 8px; color: var(--ops-muted); font-size: 13px; line-height: 1.6; }
.stats-muted, .stats-footnote { color: var(--ops-faint); font-size: 12px; line-height: 1.8; }
.stats-footnote { margin: 0; }
@media (max-width: 1100px) {
.stats-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stats-main-grid { grid-template-columns: minmax(0, 1.5fr) minmax(260px, 1fr); }
.stats-three-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.stats-three-grid > :first-child { grid-column: 1 / -1; }
}
@media (max-width: 760px) {
.stats-main-grid, .stats-three-grid { grid-template-columns: minmax(0, 1fr); gap: 20px; }
.stats-metric { padding: 18px; gap: 10px; }
.stats-panel { padding: 20px; }
.stats-period { width: 100%; }
.stats-period button { flex: 1; padding-inline: 8px; }
.stats-metrics { gap: 12px; }
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { authFetch, getApiBase, clearToken } from "../../../lib/auth";
import ResolutionChoice from "../../../ui/ResolutionChoice";
type Issue = {
id: number;
kind: string;
title: string;
status: string;
permissions?: { can_confirm_resolution?: boolean };
};
export default function ConfirmIssuePage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [item, setItem] = useState<Issue | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState("");
const login = useCallback(() => {
clearToken();
router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`);
}, [id, router]);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setItem(null);
setError("");
setResult("");
const load = async () => {
try {
const response = await authFetch(`${getApiBase()}/portal/items/${id}`, {
signal: controller.signal,
cache: "no-store",
});
if (response.status === 401) {
login();
return;
}
if (!response.ok)
throw new Error("This issue is unavailable. Please sign in with the account that reported it.");
const data = await response.json();
if (data.item?.kind !== "issue") throw new Error("This link does not belong to an issue.");
setItem(data.item);
} catch (err) {
if (!controller.signal.aborted)
setError(err instanceof Error ? err.message : "Could not load this issue. Please try again.");
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
void load();
return () => controller.abort();
// The confirmation link identifies one issue. Never submit an answer on GET.
}, [id, login]);
const answer = async (resolved: boolean) => {
if (busy) return;
setBusy(true);
setError("");
try {
const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ resolved }),
});
if (response.status === 401) {
login();
return;
}
if (!response.ok)
throw new Error(
"Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.",
);
setResult(
resolved
? "Thanks! Your issue is now closed."
: "Thanks for letting us know. Your issue stays open for another look.",
);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not save your answer. Please try again.");
} finally {
setBusy(false);
}
};
return (
<main className="resolution-response-page">
{error && (
<p role="alert" className="status-banner">
{error}
</p>
)}
{loading ? (
<p role="status">Loading your issue</p>
) : result ? (
<section className="resolution-choice" role="status">
<h2>{result}</h2>
<a href="/portal/issues">Back to issues</a>
</section>
) : item ? (
item.status === "awaiting_confirmation" && item.permissions?.can_confirm_resolution ? (
<ResolutionChoice title={item.title} busy={busy} onAnswer={(value) => void answer(value)} />
) : (
<section className="resolution-choice">
<h2>
{item.status === "awaiting_confirmation"
? "This question is for the person who reported the issue."
: "No answer is needed right now."}
</h2>
<p>{item.title}</p>
<a href={`/portal/issues?item=${item.id}`}>View issue</a>
</section>
)
) : null}
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import "./styles/tokens.css";
import "./globals.css";
import "./ops-redesign.css";
import "./admin/config.css";
import "./account.css";
import "./workspace.css";
import "./portal/issue-flow.css";
import type { ReactNode } from "react";
import BrandingFavicon from "./ui/BrandingFavicon";
import FeatureGate from "./ui/FeatureGate";
import ApplicationChrome from "./ui/ApplicationChrome";
import SetupGate from "./ui/SetupGate";
import AdminViewGate from "./ui/AdminViewGate";
export const metadata = {
title: "Magent",
description: "Request timeline and AI triage for media requests",
icons: { icon: "/api/branding/favicon.ico" },
};
// A request-specific CSP nonce is generated in proxy.ts. Dynamic rendering lets
// Next.js apply that nonce to its framework and hydration scripts.
export const dynamic = "force-dynamic";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" data-theme="dark">
<body>
<BrandingFavicon />
<div className="page">
<SetupGate>
<ApplicationChrome />
<AdminViewGate>
<FeatureGate>{children}</FeatureGate>
</AdminViewGate>
</SetupGate>
</div>
</body>
</html>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { apiUrl, requestJson } from "./api-client";
describe("api client", () => {
it("normalizes relative API paths", () => {
expect(apiUrl("health")).toBe("/api/health");
expect(apiUrl("/health")).toBe("/api/health");
});
it("returns typed JSON from successful responses", async () => {
const transport = async () => new Response(JSON.stringify({ status: "ok" }), { status: 200 });
const result = await requestJson<{ status: string }>("/health", undefined, transport);
expect(result).toEqual({ status: "ok" });
});
it("uses the API error detail when a request fails", async () => {
const transport = async () => new Response(JSON.stringify({ detail: "Not available" }), { status: 409 });
await expect(requestJson("/requests/1", undefined, transport)).rejects.toEqual(
expect.objectContaining({ status: 409, message: "Not available" }),
);
});
});
+52
View File
@@ -0,0 +1,52 @@
import { authFetchOrThrow, getApiBase } from "./auth";
export type ApiTransport = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export class ApiClientError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiClientError";
this.status = status;
}
}
const errorMessage = (payload: unknown, fallback: string) => {
if (!payload || typeof payload !== "object") return fallback;
const record = payload as Record<string, unknown>;
for (const key of ["detail", "error", "message"]) {
const value = record[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return fallback;
};
export const apiUrl = (path: string) => {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `${getApiBase()}${normalizedPath}`;
};
export async function requestJson<T>(
path: string,
init?: RequestInit,
transport: ApiTransport = authFetchOrThrow,
): Promise<T> {
const response = await transport(apiUrl(path), init);
if (response.status === 204) return undefined as T;
const text = await response.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = text;
}
}
if (!response.ok) {
throw new ApiClientError(response.status, errorMessage(payload, text || `Request failed: ${response.status}`));
}
return payload as T;
}
+100
View File
@@ -0,0 +1,100 @@
const AUTH_STATE_COOKIE = "magent_logged_in";
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? "/api";
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
if (typeof document === "undefined") return;
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`;
};
const clearCookie = (name: string) => {
if (typeof document === "undefined") return;
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`;
};
export const getToken = () => {
if (typeof document === "undefined") return null;
const cookies = document.cookie.split(";").map((entry) => entry.trim());
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`));
if (!marker) return null;
const [, value] = marker.split("=", 2);
return value || null;
};
export const setToken = (_token: string) => {
setCookie(AUTH_STATE_COOKIE, "1", 60 * 60 * 12);
};
export const clearToken = () => {
clearCookie(AUTH_STATE_COOKIE);
if (typeof window === "undefined") return;
const baseUrl = getApiBase();
void fetch(`${baseUrl}/auth/logout`, {
method: "POST",
credentials: "include",
keepalive: true,
}).catch(() => undefined);
};
export const logout = async () => {
const baseUrl = getApiBase();
clearCookie(AUTH_STATE_COOKIE);
await fetch(`${baseUrl}/auth/logout`, {
method: "POST",
credentials: "include",
});
};
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers || {});
return fetch(input, { ...init, headers, credentials: "include" });
};
export const getEventStreamToken = async () => {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/stream-token`);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Stream token request failed: ${response.status}`);
}
const data = await response.json();
const token = typeof data?.stream_token === "string" ? data.stream_token : "";
if (!token) {
throw new Error("Stream token not returned");
}
return token;
};
export class UnauthorizedError extends Error {
constructor() {
super("Unauthorized");
this.name = "UnauthorizedError";
}
}
export class ForbiddenError extends Error {
constructor() {
super("Forbidden");
this.name = "ForbiddenError";
}
}
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await authFetch(input, init);
if (response.status === 401) {
clearToken();
throw new UnauthorizedError();
}
if (response.status === 403) {
throw new ForbiddenError();
}
return response;
};
export const readResponseText = async (response: Response) => {
try {
return (await response.text()).trim();
} catch {
return "";
}
};
+43
View File
@@ -0,0 +1,43 @@
export const FEATURES = [
{ key: "stats", label: "My Stats", description: "View personal viewing history, reports and request report emails." },
{ key: "requests", label: "My Requests", description: "View existing requests, their progress and request actions." },
{
key: "new_requests",
label: "New Requests",
description: "Search for movies and TV shows and submit new requests.",
},
{
key: "issues",
label: "Issues",
description: "Report problems, follow up on issues and use available repair tools.",
},
{ key: "invites", label: "Invites", description: "Create and manage invitations within the existing invite limits." },
{
key: "ignore_profile_limits",
label: "Ignore profile limits",
description:
"Allow explicit manual downloads outside quality, size, language or custom-format limits. Automatic searches keep the assigned profile.",
},
] as const;
export type Feature = (typeof FEATURES)[number]["key"];
export type FeatureAccess = Record<Feature, boolean>;
export function featureForPath(path: string): Feature | undefined {
if (path === "/insights" || path.startsWith("/insights/")) return "stats";
if (path === "/" || path.startsWith("/requests/")) return "requests";
if (path === "/new-requests") return "new_requests";
if (path.startsWith("/issues/confirm/") || path.startsWith("/portal/issues")) return "issues";
if (path.startsWith("/profile/invites")) return "invites";
if (path === "/portal/requests") return "requests";
}
export function canAccess(
user: { role?: string; features?: Partial<FeatureAccess>; invite_management_enabled?: boolean } | null,
feature?: Feature,
) {
if (!feature) return true;
if (!user) return false;
if (user.role === "admin") return true;
return (
user.features?.[feature] ??
(feature === "invites" ? Boolean(user.invite_management_enabled) : feature !== "ignore_profile_limits")
);
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { loginErrorMessage } from "./login-errors";
const errorResponse = (status: number, payload: unknown) => new Response(JSON.stringify(payload), { status });
describe("login error messages", () => {
it("identifies a site security rejection without blaming the account", async () => {
expect(await loginErrorMessage(errorResponse(403, { detail: "Cross-origin state change rejected" }))).toBe(
"Sign-in was blocked by the site's security configuration. Please contact an administrator.",
);
});
it.each(["User is blocked", "User access has expired", "Unknown upstream error"])(
"keeps a generic account message for %s",
async (detail) => {
expect(await loginErrorMessage(errorResponse(403, { detail }))).toBe(
"This account cannot sign in. Please contact an administrator.",
);
},
);
it.each([
null,
[],
{ detail: ["Cross-origin state change rejected"] },
{ detail: "Cross-origin state change rejected: private upstream detail" },
{ detail: "<script>private upstream detail</script>" },
])("does not render or loosely match unexpected response bodies: %j", async (payload) => {
expect(await loginErrorMessage(errorResponse(403, payload))).toBe(
"This account cannot sign in. Please contact an administrator.",
);
});
it("handles a non-JSON proxy denial safely", async () => {
expect(await loginErrorMessage(new Response("<html>Forbidden</html>", { status: 403 }))).toBe(
"This account cannot sign in. Please contact an administrator.",
);
});
it.each([
[401, "Check your username and password, then try again."],
[400, "Check your username and password, then try again."],
[429, "Too many attempts. Please wait a moment and try again."],
[500, "Sign-in is temporarily unavailable. Please try again shortly."],
[502, "Sign-in is temporarily unavailable. Please try again shortly."],
])("preserves the existing message for HTTP %s", async (status, expected) => {
expect(
await loginErrorMessage(errorResponse(status as number, { detail: "Cross-origin state change rejected" })),
).toBe(expected);
});
});
+17
View File
@@ -0,0 +1,17 @@
export async function loginErrorMessage(response: Response): Promise<string> {
if (response.status === 429) return "Too many attempts. Please wait a moment and try again.";
if (response.status >= 500) return "Sign-in is temporarily unavailable. Please try again shortly.";
if (response.status === 403) {
const payload: unknown = await response.json().catch(() => null);
if (
payload !== null &&
typeof payload === "object" &&
"detail" in payload &&
payload.detail === "Cross-origin state change rejected"
) {
return "Sign-in was blocked by the site's security configuration. Please contact an administrator.";
}
return "This account cannot sign in. Please contact an administrator.";
}
return "Check your username and password, then try again.";
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { normalizeRecentResults, normalizeSearchResults } from "./request-results";
describe("request result normalization", () => {
it("replaces placeholder request titles", () => {
expect(normalizeRecentResults([{ id: 42, title: "Request 42", year: 2024 }])).toEqual([
expect.objectContaining({ id: 42, title: "Request #42", year: 2024 }),
]);
});
it("drops malformed search results", () => {
expect(normalizeSearchResults([null, { title: "" }, { title: "Drive", requestId: 3991 }])).toEqual([
expect.objectContaining({ title: "Drive", requestId: 3991 }),
]);
});
});
+74
View File
@@ -0,0 +1,74 @@
export interface RecentRequest {
id: number;
title: string;
year?: number;
type?: string;
statusLabel?: string;
artwork?: { poster_url?: string; backdrop_url?: string };
createdAt?: string | null;
}
export interface RequestSearchResult {
title: string;
year?: number;
type?: string;
requestId?: number;
statusLabel?: string;
requestedBy?: string | null;
accessible?: boolean;
}
const recordValue = (value: unknown): Record<string, unknown> | null =>
value !== null && typeof value === "object" ? (value as Record<string, unknown>) : null;
const optionalString = (value: unknown) => (typeof value === "string" ? value : undefined);
const optionalNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
export const normalizeRecentResults = (items: unknown): RecentRequest[] => {
if (!Array.isArray(items)) return [];
return items.flatMap((value) => {
const item = recordValue(value);
const id = optionalNumber(item?.id);
if (!item || id === undefined) return [];
const rawTitle = optionalString(item.title);
const placeholder = rawTitle?.trim().toLowerCase() === `request ${id}`;
const rawArtwork = recordValue(item.artwork);
const artwork = rawArtwork
? {
poster_url: optionalString(rawArtwork.poster_url),
backdrop_url: optionalString(rawArtwork.backdrop_url),
}
: undefined;
return [
{
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: optionalNumber(item.year),
type: optionalString(item.type),
statusLabel: optionalString(item.statusLabel),
artwork,
createdAt: item.createdAt === null ? null : optionalString(item.createdAt),
},
];
});
};
export const normalizeSearchResults = (items: unknown): RequestSearchResult[] => {
if (!Array.isArray(items)) return [];
return items.flatMap((value) => {
const item = recordValue(value);
const title = optionalString(item?.title);
if (!item || !title) return [];
return [
{
title,
year: optionalNumber(item.year),
type: optionalString(item.type),
requestId: optionalNumber(item.requestId),
statusLabel: optionalString(item.statusLabel),
requestedBy: item.requestedBy === null ? null : optionalString(item.requestedBy),
accessible: Boolean(item.accessible),
},
];
});
};
+15
View File
@@ -0,0 +1,15 @@
let locks = 0;
let previous = "";
export function lockBodyScroll() {
if (locks++ === 0) {
previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
}
let released = false;
return () => {
if (released) return;
released = true;
if (--locks === 0) document.body.style.overflow = previous;
};
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { getEffectiveRole, isAdminPage } from "./user-view-policy";
describe("user view preview policy", () => {
it("downgrades only the displayed administrator role during preview", () => {
expect(getEffectiveRole("admin", true)).toBe("user");
expect(getEffectiveRole("admin", false)).toBe("admin");
for (const role of ["user", null, undefined]) {
expect(getEffectiveRole(role, true)).toBe(role);
expect(getEffectiveRole(role, false)).toBe(role);
}
});
it("covers configuration, nested admin pages, user management and setup", () => {
for (const path of [
"/admin",
"/admin/",
"/admin/backups",
"/admin/recaps",
"/users",
"/users/42",
"/setup",
"/admin?section=site",
"/%61dmin/diagnostics",
]) {
expect(isAdminPage(path), path).toBe(true);
}
});
it("does not restrict normal member pages or similarly named paths", () => {
for (const path of [
"/",
"/profile",
"/profile/invites",
"/portal/issues",
"/requests/3580",
"/insights",
"/administrator",
"/users-guide",
]) {
expect(isAdminPage(path), path).toBe(false);
}
});
it("keeps public first-install setup separate from admin authentication", () => {
expect(isAdminPage("/setup", false)).toBe(false);
expect(isAdminPage("/admin/backups", false)).toBe(true);
});
});
+16
View File
@@ -0,0 +1,16 @@
// Preview never promotes a user or changes server-side account permissions.
export function getEffectiveRole(role: string | null | undefined, preview: boolean) {
return preview && role === "admin" ? "user" : role;
}
export function isAdminPage(pathname: string, includeSetup = true): boolean {
let path = pathname.split(/[?#]/, 1)[0];
try {
path = decodeURIComponent(path);
} catch {
// Let the router handle malformed URLs; never infer a more privileged role.
}
path = path.replace(/\/{2,}/g, "/");
const roots = includeSetup ? ["/admin", "/users", "/setup"] : ["/admin", "/users"];
return roots.some((root) => path === root || path.startsWith(`${root}/`));
}
+68
View File
@@ -0,0 +1,68 @@
"use client";
import { useEffect, useSyncExternalStore } from "react";
import { getEffectiveRole } from "./user-view-policy";
const USER_VIEW_STORAGE_KEY = "magent_user_view_preview";
const USER_VIEW_EVENT = "magent:user-view-change";
let fallbackPreview = false;
const readUserViewPreview = () => {
if (typeof window === "undefined") return false;
try {
return window.sessionStorage.getItem(USER_VIEW_STORAGE_KEY) === "1";
} catch {
return fallbackPreview;
}
};
const applyDocumentMode = (enabled: boolean) => {
if (typeof document === "undefined") return;
document.documentElement.dataset.userView = enabled ? "true" : "false";
};
export const setUserViewPreview = (enabled: boolean) => {
if (typeof window === "undefined") return;
fallbackPreview = enabled;
try {
if (enabled) {
window.sessionStorage.setItem(USER_VIEW_STORAGE_KEY, "1");
} else {
window.sessionStorage.removeItem(USER_VIEW_STORAGE_KEY);
}
} catch {
// Preview still works for this document when browser storage is unavailable.
}
applyDocumentMode(enabled);
window.dispatchEvent(new CustomEvent(USER_VIEW_EVENT, { detail: { enabled } }));
};
const subscribe = (notify: () => void) => {
window.addEventListener(USER_VIEW_EVENT, notify);
window.addEventListener("storage", notify);
return () => {
window.removeEventListener(USER_VIEW_EVENT, notify);
window.removeEventListener("storage", notify);
};
};
// Unknown during server rendering/initial hydration: admin pages must not mount
// and fetch privileged data before the saved per-tab preview mode is known.
const serverSnapshot = (): boolean | null => null;
export const useUserViewState = () => {
const value = useSyncExternalStore(subscribe, readUserViewPreview, serverSnapshot);
useEffect(() => {
if (value !== null) applyDocumentMode(value);
}, [value]);
return { enabled: value === true, ready: value !== null };
};
export const useUserViewPreview = () => useUserViewState().enabled;
export const useEffectiveRole = (role?: string | null) => {
const { enabled, ready } = useUserViewState();
return getEffectiveRole(role, !ready || enabled);
};
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { type FormEvent, useEffect, useState } from "react";
import { getApiBase, setToken } from "../lib/auth";
import { loginErrorMessage } from "../lib/login-errors";
import AuthLayout from "../ui/AuthLayout";
type LoginMode = "jellyfin" | "local";
type LoginOptions = {
showJellyfinLogin: boolean;
showLocalLogin: boolean;
showForgotPassword: boolean;
showSignupLink: boolean;
};
const DEFAULT_OPTIONS: LoginOptions = {
showJellyfinLogin: true,
showLocalLogin: true,
showForgotPassword: true,
showSignupLink: true,
};
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [mode, setMode] = useState<LoginMode>("jellyfin");
const [options, setOptions] = useState<LoginOptions>(DEFAULT_OPTIONS);
const [optionsReady, setOptionsReady] = useState(false);
const [loginMessage, setLoginMessage] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const canSignIn = options.showJellyfinLogin || options.showLocalLogin;
const selectedMode: LoginMode =
mode === "jellyfin" && options.showJellyfinLogin ? "jellyfin" : options.showLocalLogin ? "local" : "jellyfin";
useEffect(() => {
const controller = new AbortController();
const load = async () => {
try {
const response = await fetch(`${getApiBase()}/site/public`, { signal: controller.signal });
if (!response.ok) throw new Error("Options unavailable");
const data = await response.json();
if (controller.signal.aborted) return;
setOptions({
showJellyfinLogin: data?.login?.showJellyfinLogin !== false,
showLocalLogin: data?.login?.showLocalLogin !== false,
showForgotPassword: data?.login?.showForgotPassword !== false,
showSignupLink: data?.login?.showSignupLink !== false,
});
setLoginMessage(typeof data?.login?.message === "string" ? data.login.message.trim() : "");
} catch {
// Keep the normal sign-in methods available during a settings outage.
} finally {
if (!controller.signal.aborted) setOptionsReady(true);
}
};
void load();
return () => controller.abort();
}, []);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (loading || !canSignIn || !optionsReady) return;
setError("");
setLoading(true);
try {
const response = await fetch(
`${getApiBase()}${selectedMode === "jellyfin" ? "/auth/jellyfin/login" : "/auth/login"}`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: username.trim(), password }),
credentials: "include",
},
);
if (!response.ok) {
setError(await loginErrorMessage(response));
return;
}
const data = await response.json();
if (!data?.authenticated) {
setError("Could not sign in. Please try again.");
return;
}
setToken("cookie");
const next = new URLSearchParams(window.location.search).get("next") || "";
const allowedNext =
[
"/insights",
"/insights/reports",
"/profile",
"/profile#monthly-recaps",
"/profile#newsletters",
"/admin/recaps",
"/admin/newsletters",
"/setup",
"/admin/backups",
].includes(next) ||
/^\/insights\/reports\?month=[0-9]{4}-(?:0[1-9]|1[0-2])$/.test(next) ||
/^\/issues\/confirm\/\d+$/.test(next);
window.location.assign(allowedNext ? next : "/welcome");
} catch {
setError("Could not reach Magent. Check your connection and try again.");
} finally {
setLoading(false);
}
};
return (
<AuthLayout
title="Welcome back."
description="Sign in to your media workspace."
footer={
optionsReady &&
options.showSignupLink && (
<>
Have an invite?{" "}
<a href="/signup">
Create an account <span aria-hidden="true"></span>
</a>
</>
)
}
>
{loginMessage && (
<p className="account-notice account-login-message" role="status">
{loginMessage}
</p>
)}
{optionsReady && options.showJellyfinLogin && options.showLocalLogin && (
<fieldset className="login-methods" aria-label="Sign-in account">
<button
type="button"
aria-pressed={selectedMode === "jellyfin"}
disabled={loading}
onClick={() => {
setMode("jellyfin");
setError("");
}}
>
Jellyfin
</button>
<button
type="button"
aria-pressed={selectedMode === "local"}
disabled={loading}
onClick={() => {
setMode("local");
setError("");
}}
>
Magent
</button>
</fieldset>
)}
{!optionsReady ? (
<p className="account-hint" role="status">
Loading sign-in
</p>
) : !canSignIn ? (
<p className="account-notice is-error" role="alert">
Sign-in is currently unavailable. Please contact an administrator.
</p>
) : (
<form className="account-form login-form" onSubmit={submit}>
<p className="login-method-help">
{selectedMode === "jellyfin" ? "Use your Jellyfin account." : "Use your Magent account."}
</p>
<label htmlFor="login-username">Username</label>
<input
id="login-username"
name="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
required
disabled={loading}
/>
<div className="login-password-label">
<label htmlFor="login-password">Password</label>
{options.showForgotPassword && <a href="/forgot-password">Forgot password?</a>}
</div>
<div className="login-password-field">
<input
id="login-password"
name="password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
disabled={loading}
/>
<button
type="button"
className="password-visibility"
aria-label={showPassword ? "Hide password" : "Show password"}
aria-pressed={showPassword}
onClick={() => setShowPassword(!showPassword)}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
<circle cx="12" cy="12" r="3" />
{showPassword && <path d="m3 3 18 18" />}
</svg>
</button>
</div>
{error && (
<p className="account-notice is-error" role="alert">
{error}
</p>
)}
<button type="submit" className="account-primary login-submit" disabled={loading}>
{loading ? "Signing in…" : "Sign in"}
<span aria-hidden="true"></span>
</button>
</form>
)}
</AuthLayout>
);
}
@@ -0,0 +1,740 @@
"use client";
import PageHeading from "../ui/PageHeading";
import { lockBodyScroll } from "../lib/scrollLock";
import "./request-progress.css";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type MediaType = "movie" | "tv";
type DiscoveryResult = {
title: string;
year?: number | null;
type: MediaType;
tmdbId: number;
requestId?: number | null;
statusLabel?: string | null;
overview?: string | null;
posterPath?: string | null;
backdropPath?: string | null;
};
type RequestOptions = {
media: DiscoveryResult & {
seasons: Array<{
seasonNumber: number;
name: string;
episodeCount: number;
airDate?: string | null;
}>;
originalLanguage?: { code: string } | null;
existingRequestId?: number | null;
};
destination: {
collector: "Sonarr" | "Radarr";
serverName: string;
defaultProfileId: number;
profiles: Array<{ id: number; name: string }>;
};
};
type OperationEvent = {
id: string;
service: string;
state: "active" | "complete" | "error";
message: string;
duration_ms?: number | null;
status_code?: number | null;
};
type OperationProgress = {
status: "running" | "complete" | "error";
duration_ms?: number | null;
events: OperationEvent[];
};
const mediaChoices: Array<{
type: MediaType;
eyebrow: string;
title: string;
description: string;
collector: "Radarr" | "Sonarr";
icon: string;
}> = [
{
type: "movie",
eyebrow: "Film",
title: "Movie",
description: "Find a film and send it through Seerr to Radarr.",
collector: "Radarr",
icon: "/service-icons/radarr.svg",
},
{
type: "tv",
eyebrow: "Series",
title: "TV show",
description: "Choose a series, the seasons you want, and send it to Sonarr.",
collector: "Sonarr",
icon: "/service-icons/sonarr.svg",
},
];
const artworkUrl = (path?: string | null, size: "w185" | "w342" = "w342") => {
if (!path) return null;
return `https://image.tmdb.org/t/p/${size}${path.startsWith("/") ? path : `/${path}`}`;
};
const apiError = async (response: Response, fallback: string) => {
try {
const payload = await response.json();
if (typeof payload?.detail === "string" && payload.detail.trim()) return payload.detail;
if (typeof payload?.message === "string" && payload.message.trim()) return payload.message;
} catch {
// The upstream response was not JSON. Use the friendly fallback below.
}
return fallback;
};
export default function NewRequestClient() {
const router = useRouter();
const searchSectionRef = useRef<HTMLElement | null>(null);
const resultsSectionRef = useRef<HTMLElement | null>(null);
const configureSectionRef = useRef<HTMLElement | null>(null);
const [mediaType, setMediaType] = useState<MediaType | null>(null);
const [query, setQuery] = useState("");
const [searching, setSearching] = useState(false);
const [searchAttempted, setSearchAttempted] = useState(false);
const [results, setResults] = useState<DiscoveryResult[]>([]);
const [selected, setSelected] = useState<DiscoveryResult | null>(null);
const [options, setOptions] = useState<RequestOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(false);
const [selectedSeasons, setSelectedSeasons] = useState<number[]>([]);
const [acceptOriginalLanguage, setAcceptOriginalLanguage] = useState<boolean | null>(null);
const [submitting, setSubmitting] = useState(false);
const [progressOpen, setProgressOpen] = useState(false);
const progressDialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
if (!progressOpen) return;
const previous = document.activeElement as HTMLElement | null;
progressDialog.current?.showModal();
const unlock = lockBodyScroll();
return () => {
progressDialog.current?.close();
unlock();
previous?.focus();
};
}, [progressOpen]);
const [operation, setOperation] = useState<OperationProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => {
if (!getToken()) router.push("/login");
}, [router]);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const requestedType = params.get("type");
const requestedQuery = params.get("query")?.trim();
if ((requestedType === "movie" || requestedType === "tv") && requestedQuery) {
setMediaType(requestedType);
setQuery(requestedQuery);
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
}
}, []);
const selectedTitleId = selected?.tmdbId;
useEffect(() => {
if (selectedTitleId) configureSectionRef.current?.focus();
}, [selectedTitleId]);
const changeTitle = () => {
setSelected(null);
setOptions(null);
setAcceptOriginalLanguage(null);
setSelectedSeasons([]);
setOperation(null);
setError(null);
setSuccess(null);
window.requestAnimationFrame(() => document.getElementById("request-title-search")?.focus());
};
const resetAfterType = (nextType: MediaType) => {
setMediaType(nextType);
setQuery("");
setResults([]);
setSearchAttempted(false);
setSelected(null);
setOptions(null);
setAcceptOriginalLanguage(null);
setSelectedSeasons([]);
setOperation(null);
setError(null);
setSuccess(null);
window.setTimeout(() => searchSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), 80);
};
const runSearch = async (event: React.FormEvent) => {
event.preventDefault();
if (!mediaType) return;
const term = query.trim();
if (!term) {
setError("Enter a title to search for.");
return;
}
setSearching(true);
setSearchAttempted(true);
setSelected(null);
setOptions(null);
setAcceptOriginalLanguage(null);
setOperation(null);
setError(null);
setSuccess(null);
try {
const baseUrl = getApiBase();
const params = new URLSearchParams({ query: term, media_type: mediaType });
const response = await authFetch(`${baseUrl}/requests/search?${params.toString()}`);
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) throw new Error(await apiError(response, `Search failed (${response.status}).`));
const payload = await response.json();
const mapped: DiscoveryResult[] = Array.isArray(payload?.results)
? payload.results
.filter((item: Record<string, unknown>) => item.type === mediaType && Number(item.tmdbId) > 0)
.map((item: Record<string, unknown>) => ({
title: String(item?.title || "Untitled"),
year: typeof item?.year === "number" ? item.year : null,
type: mediaType,
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === "number" ? item.requestId : null,
statusLabel: typeof item?.statusLabel === "string" ? item.statusLabel : null,
overview: typeof item?.overview === "string" ? item.overview : null,
posterPath: typeof item.posterPath === "string" ? item.posterPath : null,
backdropPath: typeof item.backdropPath === "string" ? item.backdropPath : null,
}))
: [];
setResults(mapped);
window.setTimeout(() => resultsSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 80);
} catch (caught) {
setResults([]);
setError(caught instanceof Error ? caught.message : "Search is unavailable right now.");
} finally {
setSearching(false);
}
};
const selectResult = async (item: DiscoveryResult) => {
setSelected(item);
setOptions(null);
setAcceptOriginalLanguage(null);
setSelectedSeasons([]);
setOperation(null);
setError(null);
setSuccess(null);
if (item.requestId) {
return;
}
setLoadingOptions(true);
try {
const baseUrl = getApiBase();
const params = new URLSearchParams({ media_type: item.type, tmdb_id: String(item.tmdbId) });
const response = await authFetch(`${baseUrl}/requests/request-options?${params.toString()}`);
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok)
throw new Error(await apiError(response, `Could not load request options (${response.status}).`));
const payload = (await response.json()) as RequestOptions;
const refreshedSelection: DiscoveryResult = {
...item,
title: payload.media.title || item.title,
year: payload.media.year ?? item.year,
overview: payload.media.overview || item.overview,
posterPath: payload.media.posterPath || item.posterPath,
backdropPath: payload.media.backdropPath || item.backdropPath,
requestId: payload.media.existingRequestId || item.requestId,
statusLabel: payload.media.existingRequestId ? "Already requested" : item.statusLabel,
};
setSelected(refreshedSelection);
if (payload.media.existingRequestId) {
setResults((current) =>
current.map((result) =>
result.tmdbId === item.tmdbId && result.type === item.type ? refreshedSelection : result,
),
);
return;
}
setOptions(payload);
setSelectedSeasons(payload.media.seasons.map((season) => season.seasonNumber));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Could not load request options.");
} finally {
setLoadingOptions(false);
}
};
const pollOperation = async (operationId: string) => {
try {
const response = await authFetch(`${getApiBase()}/operations/${operationId}`);
if (response.ok) setOperation((await response.json()) as OperationProgress);
} catch {
// The request response remains authoritative if a progress poll is interrupted.
}
};
const submitRequest = async () => {
if (!selected || !options || submitting) return;
if (options.media.originalLanguage && acceptOriginalLanguage === null) {
setError("Choose an audio language option before requesting.");
return;
}
if (selected.type === "tv" && selectedSeasons.length === 0) {
setError("Select at least one season.");
return;
}
setProgressOpen(true);
setSubmitting(true);
setError(null);
setSuccess(null);
const operationId = globalThis.crypto?.randomUUID?.() ?? `request-${Date.now()}`;
setOperation({ status: "running", events: [] });
const interval = window.setInterval(() => void pollOperation(operationId), 500);
try {
const response = await authFetch(`${getApiBase()}/requests/create`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Magent-Operation-ID": operationId,
"X-Magent-Operation-Label": `Requesting ${selected.title}`,
},
body: JSON.stringify({
mediaType: selected.type,
tmdbId: selected.tmdbId,
acceptOriginalLanguage: acceptOriginalLanguage === true,
seasons: selected.type === "tv" ? selectedSeasons : undefined,
}),
});
await pollOperation(operationId);
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) throw new Error(await apiError(response, `Request failed (${response.status}).`));
const payload = await response.json();
const requestId = typeof payload?.requestId === "number" ? payload.requestId : null;
setSelected((current) =>
current ? { ...current, requestId, statusLabel: payload?.statusLabel ?? current.statusLabel } : current,
);
setResults((current) =>
current.map((item) =>
item.tmdbId === selected.tmdbId && item.type === selected.type
? { ...item, requestId, statusLabel: payload?.statusLabel ?? item.statusLabel }
: item,
),
);
setSuccess("Your request has been received.");
} catch (caught) {
setError(caught instanceof Error ? caught.message : "The request could not be submitted.");
} finally {
window.clearInterval(interval);
await pollOperation(operationId);
setSubmitting(false);
}
};
const setEverySeason = (checked: boolean) => {
setSelectedSeasons(checked && options ? options.media.seasons.map((season) => season.seasonNumber) : []);
};
const selectedPoster = artworkUrl(selected?.posterPath, "w185");
const currentFlowStep = success ? 5 : selected ? 4 : searchAttempted ? 3 : mediaType ? 2 : 1;
return (
<main className="card request-portal-page">
<dialog
ref={progressDialog}
className="create-request-dialog"
aria-labelledby="create-progress-title"
onCancel={() => setProgressOpen(false)}
onClose={() => setProgressOpen(false)}
>
<div className="create-progress-header">
<span>Request progress</span>
<button
type="button"
className="ghost-button"
onClick={() => setProgressOpen(false)}
aria-label="Close request progress"
>
Close
</button>
</div>
<div className="create-progress-body" aria-live="polite" aria-atomic="true">
{submitting && <span className="create-progress-spinner" aria-hidden="true" />}
<h2 id="create-progress-title">
{submitting ? "Sending your request" : success ? "Request received" : "Your request needs attention"}
</h2>
<p className="create-progress-title">{selected?.title}</p>
<p>
{submitting
? operation?.events.some((event) => event.service === "Sonarr" || event.service === "Radarr")
? "Setting up your title for collection. Please wait."
: "Checking your selection and sending it to the request service. Please wait."
: success
? "Your request is now in the pipeline. Follow it to see approval, download progress and when it is ready to watch."
: error || "We could not confirm the result. Check My requests before trying again."}
</p>
{submitting && (
<div className="create-progress-track" role="progressbar" aria-label="Submitting request">
<span />
</div>
)}
{success && (
<div className="create-progress-stage">
<span>Current stage</span>
<strong>{selected?.statusLabel || "Request received"}</strong>
</div>
)}
</div>
<div className="create-progress-actions">
{!submitting && (
<button
type="button"
className="create-progress-follow"
onClick={() => router.push(selected?.requestId ? `/requests/${selected.requestId}` : "/")}
>
{success ? "Follow your request" : "Check My requests"} <span aria-hidden="true">&rarr;</span>
</button>
)}
{!submitting && (
<button type="button" className="ghost-button" onClick={() => setProgressOpen(false)}>
{success ? "Back to browsing" : "Back to request"}
</button>
)}
{submitting && (
<small>You can close this window. Submission will continue while you stay on this page.</small>
)}
</div>
</dialog>
<PageHeading title="New request" description="Find a movie or TV show and choose what you want to watch." />
<ol className="request-master-stepper" aria-label="New request progress">
{["Type", "Search", "Select", "Config", "Submit"].map((label, index) => {
const step = index + 1;
return (
<li
key={label}
className={step === currentFlowStep ? "is-active" : step < currentFlowStep ? "is-complete" : ""}
>
<span>{step < currentFlowStep ? "✓" : step}</span>
<strong>{label}</strong>
</li>
);
})}
</ol>
{error && <div className="error-banner request-flow-alert">{error}</div>}
{success && <div className="status-banner request-flow-alert">{success}</div>}
{!selected && (
<section className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">01</span>
<div>
<span>Start here</span>
<h2>What are you looking for?</h2>
</div>
</div>
<div className="request-type-grid">
{mediaChoices.map((choice) => (
<button
key={choice.type}
type="button"
className={`request-type-card ${mediaType === choice.type ? "is-selected" : ""}`}
onClick={() => resetAfterType(choice.type)}
aria-pressed={mediaType === choice.type}
>
<span className="request-type-card-body">
<span className="request-service-icon">
<img src={choice.icon} alt={`${choice.collector} logo`} />
</span>
<span className="request-type-card-copy">
<span>{choice.eyebrow}</span>
<strong>{choice.title}</strong>
<span className="request-type-description">{choice.description}</span>
<b>{mediaType === choice.type ? "Selected" : `Choose ${choice.title.toLowerCase()}`}</b>
</span>
</span>
</button>
))}
</div>
</section>
)}
{mediaType && !selected && (
<section ref={searchSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">02</span>
<div>
<span>{mediaType === "tv" ? "TV show selected" : "Movie selected"}</span>
<h2>Search for the title</h2>
</div>
</div>
<form className="request-flow-search" onSubmit={runSearch}>
<label htmlFor="request-title-search">Title</label>
<div>
<input
id="request-title-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={mediaType === "tv" ? "Search TV shows" : "Search movies"}
autoComplete="off"
/>
<button type="submit" disabled={searching}>
{searching ? "Searching…" : "Search Seerr"}
</button>
</div>
</form>
</section>
)}
{mediaType && !selected && searchAttempted && !searching && (
<section ref={resultsSectionRef} className="request-flow-stage is-current">
<div className="request-flow-heading">
<span className="request-flow-number">03</span>
<div>
<span>Search results</span>
<h2>{results.length ? "Select the right title" : "No matches found"}</h2>
</div>
</div>
{results.length === 0 ? (
<div className="request-flow-empty">
<strong>Nothing matched {query.trim()}.</strong>
<p>Check the spelling or try a shorter title.</p>
</div>
) : (
<div className="request-result-grid">
{results.map((item) => {
const poster = artworkUrl(item.posterPath);
return (
<button
key={`${item.type}:${item.tmdbId}`}
type="button"
className="request-result-card"
onClick={() => void selectResult(item)}
>
<span className="request-result-poster">
{poster ? <img src={poster} alt="" loading="lazy" /> : <i>No artwork</i>}
</span>
<span className="request-result-copy">
<small>
{item.type === "tv" ? "TV show" : "Movie"}
{item.year ? ` · ${item.year}` : ""}
</small>
<strong>{item.title}</strong>
<p>{item.overview || "Select this title to view the available request options."}</p>
<b>{item.requestId ? item.statusLabel || "Already requested" : "Select title"}</b>
</span>
</button>
);
})}
</div>
)}
</section>
)}
{selected && (
<section
ref={configureSectionRef}
tabIndex={-1}
aria-labelledby="request-configure-title"
className="request-flow-stage is-current request-configure-stage"
>
<div className="request-flow-heading">
<span className="request-flow-number">04</span>
<div>
<span>Final step</span>
<h2 id="request-configure-title">
{selected.requestId
? "This title is already in the pipeline"
: selected.type === "tv"
? "Choose seasons and request"
: "Review and request"}
</h2>
</div>
</div>
<button type="button" className="ghost-button" onClick={changeTitle} disabled={loadingOptions || submitting}>
Change title
</button>
<div className="request-selection-summary">
<span className="request-selection-poster">
{selectedPoster ? <img src={selectedPoster} alt="" /> : <i>No artwork</i>}
</span>
<div>
<small>
{selected.type === "tv" ? "TV show" : "Movie"}
{selected.year ? ` · ${selected.year}` : ""}
</small>
<h3>{selected.title}</h3>
<p>{selected.overview || "Ready to configure."}</p>
</div>
</div>
{selected.requestId ? (
<div className="request-existing-state">
<div>
<span>Current status</span>
<strong>{selected.statusLabel || "Already requested"}</strong>
<p>Request #{selected.requestId} is already being tracked by Magent.</p>
</div>
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>
Open request
</button>
</div>
) : loadingOptions ? (
<div className="request-flow-empty">
<strong>Checking Seerr and {selected.type === "tv" ? "Sonarr" : "Radarr"}</strong>
<p>Preparing your request options.</p>
</div>
) : options ? (
<div className="request-options-layout">
{selected.type === "tv" && (
<fieldset className="request-season-picker">
<legend>Which seasons?</legend>
<div className="request-season-actions">
<button type="button" onClick={() => setEverySeason(true)}>
Select all
</button>
<button type="button" onClick={() => setEverySeason(false)}>
Clear
</button>
</div>
<div className="request-season-grid">
{options.media.seasons.map((season) => (
<label
key={season.seasonNumber}
className={selectedSeasons.includes(season.seasonNumber) ? "is-selected" : ""}
>
<input
type="checkbox"
checked={selectedSeasons.includes(season.seasonNumber)}
onChange={(event) =>
setSelectedSeasons((current) =>
event.target.checked
? [...current, season.seasonNumber].sort((a, b) => a - b)
: current.filter((value) => value !== season.seasonNumber),
)
}
/>
<span>
<strong>{season.name}</strong>
<small>
{season.episodeCount} episode{season.episodeCount === 1 ? "" : "s"}
</small>
</span>
</label>
))}
</div>
</fieldset>
)}
{options.media.originalLanguage && (
<div className="request-language-notice">
<h3>Choose your audio language</h3>
<p>
This titles original language is{" "}
<strong>
{new Intl.DisplayNames(["en"], { type: "language" }).of(options.media.originalLanguage.code) ||
options.media.originalLanguage.code}
</strong>
. An English audio track may not be available. Title metadata does not confirm the audio or
subtitles in a download.
</p>
<label>
<input
type="radio"
name="request-audio"
checked={acceptOriginalLanguage === true}
onChange={() => setAcceptOriginalLanguage(true)}
disabled={submitting}
/>
<span>
Original{" "}
{new Intl.DisplayNames(["en"], { type: "language" }).of(options.media.originalLanguage.code)}{" "}
audio Im happy to watch in the original language.
</span>
</label>
<label>
<input
type="radio"
name="request-audio"
checked={acceptOriginalLanguage === false}
onChange={() => setAcceptOriginalLanguage(false)}
disabled={submitting}
/>
<span>Keep standard audio requirements. This title may remain waiting for an English release.</span>
</label>
<small>
{acceptOriginalLanguage
? selected.type === "movie"
? "Search for original-language audio using the same quality requirements."
: "Continue with your selected seasons and the configured TV quality requirements."
: "Choose an option to continue. An English-only profile may leave this title waiting for a suitable release."}
</small>
</div>
)}
<div className="request-submit-bar">
<div>
<span>Delivery route</span>
<strong>Seerr {options.destination.collector} Jellyfin</strong>
<small>Your request uses the default quality set by your administrator.</small>
</div>
<button
type="button"
onClick={() => void submitRequest()}
disabled={
submitting ||
(Boolean(options.media.originalLanguage) && acceptOriginalLanguage === null) ||
(selected.type === "tv" && selectedSeasons.length === 0)
}
>
{submitting ? "Sending request…" : `Request ${selected.type === "tv" ? "show" : "movie"}`}
</button>
</div>
</div>
) : null}
{operation && (
<button type="button" className="ghost-button" onClick={() => setProgressOpen(true)}>
View request progress
</button>
)}
{success && selected.requestId && (
<div className="request-complete-actions">
<button type="button" onClick={() => router.push(`/requests/${selected.requestId}`)}>
Follow your request
</button>
<button type="button" className="ghost-button" onClick={() => resetAfterType(selected.type)}>
Request something else
</button>
</div>
)}
</section>
)}
</main>
);
}
+9
View File
@@ -0,0 +1,9 @@
import NewRequestClient from "./NewRequestClient";
export const metadata = {
title: "New Requests | Magent",
};
export default function NewRequestsPage() {
return <NewRequestClient />;
}
@@ -0,0 +1,18 @@
.create-request-dialog { width:min(560px,calc(100vw - 32px)); max-height:90dvh; overflow:auto; padding:28px; border:1px solid #514d62; border-radius:20px; background:#1b1b22; color:#eeeaf5; box-shadow:0 30px 100px #0009; }
.create-request-dialog::backdrop { background:#080910c9; backdrop-filter:blur(6px); }
.create-progress-header { display:flex; align-items:center; justify-content:space-between; gap:16px; color:#79e0eb; font-size:13px; }
.create-progress-body { padding:24px 0; }
.create-progress-body h2 { font-size:clamp(25px,4vw,34px); margin:12px 0; }
.create-progress-body p { color:#c3bfce; line-height:1.7; overflow-wrap:anywhere; }
.create-progress-body .create-progress-title { font-size:20px; color:#fff; font-weight:600; }
.create-progress-spinner { display:block; width:42px; height:42px; border:4px solid #ffffff20; border-top-color:#70e0e4; border-radius:50%; animation:create-spin .8s linear infinite; }
.create-progress-track { height:6px; background:#ffffff15; overflow:hidden; border-radius:6px; margin-top:24px; }
.create-progress-track span { display:block; width:35%; height:100%; background:#8edee5; animation:create-track 1.5s ease-in-out infinite alternate; }
.create-progress-stage { display:grid; gap:8px; border:1px solid #4c536a; border-radius:12px; padding:18px; background:#242938; }
.create-progress-stage span { color:#b9b6c6; font-size:12px; }
.create-progress-actions { display:grid; gap:12px; }
.create-progress-actions .create-progress-follow { padding:18px; background:#c5b8ff; color:#191629; font-size:18px; font-weight:700; border-radius:12px; }
.create-progress-actions small { color:#b9b6c6; line-height:1.6; }
@keyframes create-spin { to { transform:rotate(360deg); } }
@keyframes create-track { to { transform:translateX(185%); } }
@media(prefers-reduced-motion:reduce) { .create-progress-spinner,.create-progress-track span { animation:none; } }
@@ -0,0 +1,157 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { getApiBase } from "../lib/auth";
import BrandingLogo from "../ui/BrandingLogo";
import "../email-recaps/recaps.css";
type LinkAction = { action: "confirm" | "unsubscribe"; token: string };
export default function NewsletterLinkPage() {
const [link, setLink] = useState<LinkAction | null>(null);
const [state, setState] = useState("loading");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const currentLink = useRef<LinkAction | null>(null);
useEffect(() => {
let controller: AbortController | null = null;
const checkLink = () => {
controller?.abort();
const abort = new AbortController();
controller = abort;
setError("");
setState("loading");
setLink(null);
setBusy(false);
currentLink.current = null;
// Fragments stay out of web-server access logs and referrers. Opening the link only checks it.
const params = new URLSearchParams(window.location.hash.slice(1));
const action = params.get("action");
const token = params.get("token") || "";
if ((action !== "confirm" && action !== "unsubscribe") || !/^[A-Za-z0-9_-]{40,100}$/.test(token)) {
setError("This email link is incomplete. Open Profile to manage your newsletters.");
setState("error");
return;
}
const payload = { action, token } as LinkAction;
currentLink.current = payload;
setLink(payload);
void fetch(`${getApiBase()}/newsletter-subscription/check`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: abort.signal,
credentials: "omit",
})
.then(async (response) => {
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(
typeof result.detail === "string"
? result.detail
: "Could not check this email link. Please open it again.",
);
if (!abort.signal.aborted) setState(result.state);
})
.catch((err: Error) => {
if (!abort.signal.aborted) {
setError(err.message);
setState("error");
}
});
};
checkLink();
window.addEventListener("hashchange", checkLink);
return () => {
currentLink.current = null;
controller?.abort();
window.removeEventListener("hashchange", checkLink);
};
}, []);
const apply = async () => {
if (!link || busy) return;
const payload = link;
setBusy(true);
setError("");
try {
const response = await fetch(`${getApiBase()}/newsletter-subscription/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "omit",
});
const result = await response.json().catch(() => ({}));
if (currentLink.current !== payload) return;
if (!response.ok)
throw new Error(
typeof result.detail === "string" ? result.detail : "Could not update your preference. Please try again.",
);
setState(result.state);
window.history.replaceState(null, "", "/newsletter-subscription");
} catch (err) {
if (currentLink.current === payload)
setError(err instanceof Error ? err.message : "Could not update your preference.");
} finally {
if (currentLink.current === payload) setBusy(false);
}
};
const done = state === "enabled" || state === "off";
return (
<main className="recap-link-page">
<a className="recap-brand" href="/login">
<BrandingLogo className="brand-logo" />
<span>Magent</span>
</a>
<section className="account-panel">
<span className="recap-eyebrow">Magent newsletters</span>
<h1>
{state === "enabled"
? "Youre on the list."
: state === "off"
? "Newsletters are turned off."
: state === "loading"
? "Checking your email link"
: state === "error"
? "This link needs another look"
: link?.action === "unsubscribe"
? "Unsubscribe from newsletters?"
: "Your next watch starts here."}
</h1>
<p>
{state === "enabled"
? "Your email is confirmed. Youll receive your personal viewing recap when the monthly schedule runs."
: state === "off"
? "You wont receive further monthly recaps. You can turn them back on in Profile."
: state === "ready" && link?.action === "unsubscribe"
? "This turns off new-arrival newsletters. Your personal monthly recaps are managed separately."
: state === "ready"
? "Confirm to receive new movies, TV updates and featured picks, with posters and links to watch."
: ""}
</p>
{error && (
<p className="account-notice is-error" role="alert">
{error}
</p>
)}
{state === "ready" && (
<button type="button" className="account-primary" disabled={busy} onClick={() => void apply()}>
{busy
? "Updating…"
: link?.action === "unsubscribe"
? "Unsubscribe from newsletters"
: "Confirm newsletter subscription"}
</button>
)}
{(done || state === "error") && (
<a className="recap-text-link" href="/profile#newsletters">
Manage email preferences
</a>
)}
{state === "loading" && <p role="status">One moment</p>}
</section>
</main>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Link from "next/link";
import PageHeading from "./ui/PageHeading";
export default function NotFound() {
return (
<main className="card">
<PageHeading title="Page not found" description="This link may have moved or no longer be available." />
<p>
<Link href="/"> Back to my requests</Link>
</p>
</main>
);
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from "next/navigation";
import MyRequests from "./MyRequests";
export const dynamic = "force-dynamic";
export default function HomePage() {
if (process.env.MAGENT_COMING_SOON === "true") redirect("/coming-soon");
return <MyRequests />;
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export default function IssueFlowStep({
number,
title,
summary,
active,
complete,
onEdit,
children,
}: {
number: number;
title: string;
summary: string;
active: boolean;
complete: boolean;
onEdit: () => void;
children: ReactNode;
}) {
const heading = useRef<HTMLHeadingElement>(null);
useEffect(() => {
if (!active || number === 1) return;
heading.current?.focus({ preventScroll: true });
heading.current?.scrollIntoView({ block: "nearest", behavior: "instant" });
}, [active, number]);
if (!active && !complete) return null;
return (
<section className={`issue-procedure-step ${active ? "is-current" : "is-complete"}`} aria-label={title}>
{active ? (
<>
<div className="issue-flow-heading">
<span className="issue-step-number" aria-hidden="true">
{String(number).padStart(2, "0")}
</span>
<h2 ref={heading} tabIndex={-1}>
{title}
</h2>
</div>
<div className="issue-procedure-content">{children}</div>
</>
) : (
<button
type="button"
className="issue-step-summary"
onClick={onEdit}
aria-label={`Change ${title}: ${summary}`}
>
<span className="issue-step-number" aria-hidden="true">
</span>
<span className="issue-step-summary-copy">
<small>{title}</small>
<strong>{summary}</strong>
</span>
<span className="issue-step-change">Change</span>
</button>
)}
</section>
);
}
File diff suppressed because it is too large Load Diff
+56
View File
@@ -0,0 +1,56 @@
/* One expanded procedure at a time; completed steps become editable summaries. */
.issue-flow-progressive .issue-guided-form { padding: 0; border: 0; }
.issue-wizard-fields { display: grid; gap: 10px; min-width: 0; margin: 0; padding: 0; border: 0; }
.issue-procedure-step { min-width: 0; border-bottom: 1px solid var(--ops-line-soft); }
.issue-procedure-step:last-child { border-bottom: 0; }
.issue-procedure-step.is-current { padding: 16px 0 8px; }
.issue-procedure-step .issue-flow-heading { align-items: center; margin-bottom: 18px; }
.issue-procedure-step h2 { scroll-margin-top: 130px; }
.issue-procedure-content { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; min-width: 0; animation: issue-step-enter 150ms ease-out; }
.issue-flow-progressive .issue-category-card { min-height: 122px; gap: 6px; padding: 14px; }
.issue-flow-progressive .issue-media-finder { padding: 0; border: 0; background: transparent; }
.page .issue-flow-progressive .issue-step-summary {
display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center;
gap: 12px; width: 100%; padding: 10px 0; border: 0 !important;
background: transparent !important; text-align: left; color: var(--ops-text) !important;
box-shadow: none; text-transform: none;
}
.issue-step-summary .issue-step-number { width: 28px; height: 28px; color: var(--ops-primary-2); }
.issue-step-summary-copy { display: grid; gap: 3px; min-width: 0; }
.issue-step-summary-copy small { color: var(--ops-muted); font-size: 11px; font-weight: 500; }
.issue-step-summary-copy strong { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.issue-step-change { color: var(--ops-primary-2); font-size: 12px; }
.issue-step-summary:hover .issue-step-change { text-decoration: underline; }
.issue-procedure-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; align-items: center; flex-wrap: wrap; gap: 10px; }
.issue-procedure-actions button { min-height: 44px; }
.page .issue-procedure-actions > button:not(.ghost-button) {
background: #c7bdff !important; border-color: #c7bdff !important; color: #1c172c !important;
}
.issue-procedure-actions button:disabled { opacity: .4; }
.issue-selection-count { margin-right: auto; color: var(--ops-muted); font-size: 12px; }
.issue-flow-progressive .issue-choice-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.issue-flow-progressive .issue-choice-row button { display: flex; align-items: center; justify-content: flex-start; gap: 10px; min-height: 48px; }
.issue-device-check { display: grid; place-items: center; width: 22px; height: 22px; flex: 0 0 22px; border: 1px solid currentColor; border-radius: 6px; }
/* Legacy global button colours are !important; scoped overrides keep toggles visible. */
.page .issue-flow-progressive button[aria-pressed='true'] {
border-color: #c7bdff !important; background: #373147 !important; color: #f5f0ff !important;
box-shadow: inset 0 0 0 1px #c7bdff;
}
.issue-device-feedback { margin: 4px 0 0; color: var(--ops-muted); font-size: 12px; line-height: 1.5; }
.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr) auto; padding: 0; border: 0; }
.issue-flow-progressive .issue-resolution-card h3 { margin: 0; font-size: 19px; line-height: 1.4; }
.issue-flow-progressive .issue-resolution-card p { margin-top: 8px; font-size: 13px; }
.issue-flow-progressive .status-banner { display: grid; gap: 10px; }
.issue-flow-progressive .status-banner button { justify-self: start; }
@keyframes issue-step-enter { from { opacity: .5; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 680px) {
.issue-flow-progressive .issue-category-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.issue-flow-progressive .issue-category-card { min-height: 112px; padding: 12px; }
.issue-flow-progressive .issue-category-card p { display: none; }
.issue-flow-progressive .issue-category-card strong { font-size: 13px; }
.issue-procedure-step .issue-flow-heading h2 { font-size: 19px; }
.issue-flow-progressive .issue-step-summary { gap: 8px; }
.issue-flow-progressive .issue-choice-row button { font-size: 12px; padding: 10px; }
.issue-flow-progressive .issue-resolution-card { grid-template-columns: minmax(0, 1fr); }
}
@media (prefers-reduced-motion: reduce) { .issue-procedure-content { animation: none; } }
+5
View File
@@ -0,0 +1,5 @@
import PortalClient from "../PortalClient";
export default function IssuePortalPage() {
return <PortalClient workspace="issue" />;
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function PortalIndexPage() {
redirect("/new-requests");
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RequestPortalPage() {
redirect("/new-requests");
}
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../lib/auth";
import "../email-recaps/recaps.css";
type Preference = {
automatic_monthly: boolean;
state: "off" | "pending" | "expired" | "enabled";
email: string | null;
can_subscribe: boolean;
detail: string;
schedule_enabled: boolean;
next_send_at: number | null;
day: number;
hour: number;
resend_after: number | null;
};
const scheduled = (value: number) =>
`${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" })} UTC`;
export default function MonthlyRecapPreference() {
const router = useRouter();
const [data, setData] = useState<Preference | null>(null);
const [automatic, setAutomatic] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [revision, setRevision] = useState(0);
const [now, setNow] = useState(Date.now());
useEffect(() => {
void revision;
const abort = new AbortController();
setError("");
void authFetch(`${getApiBase()}/profile/email-recaps`, { signal: abort.signal })
.then(async (response) => {
if (response.status === 401) {
router.replace("/login?next=%2Fprofile");
return;
}
if (!response.ok) throw new Error("Could not load your email preference. Please try again.");
const result = (await response.json()) as Preference;
if (!abort.signal.aborted) {
setData(result);
setAutomatic(result.automatic_monthly);
}
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [revision, router]);
useEffect(() => {
if (!data?.resend_after || data.state === "enabled" || data.resend_after * 1000 <= Date.now()) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [data?.resend_after, data?.state]);
const save = async (enabled: boolean, monthly = automatic) => {
if (busy) return;
setBusy(true);
setError("");
setNotice("");
try {
const response = await authFetch(`${getApiBase()}/profile/email-recaps`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled, automatic_monthly: monthly }),
});
if (response.status === 401) {
router.replace("/login?next=%2Fprofile");
return;
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(typeof result.detail === "string" ? result.detail : "Could not update your email preference.");
setData(result);
setAutomatic(result.automatic_monthly);
setNow(Date.now());
setNotice(
result.message || (enabled ? "Personal report emails are enabled." : "Personal report emails are off."),
);
} catch (err) {
setError(err instanceof Error ? err.message : "Could not update your email preference.");
// A confirmation may be pending even if SMTP could not confirm delivery.
const response = await authFetch(`${getApiBase()}/profile/email-recaps`).catch(() => null);
if (response?.ok) {
const fresh = await response.json();
setData(fresh);
setAutomatic(fresh.automatic_monthly);
setNow(Date.now());
}
} finally {
setBusy(false);
}
};
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0;
return (
<section className="recap-preference" id="monthly-recaps" aria-labelledby="recap-preference-title">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">A little look back</span>
<h2 id="recap-preference-title">Your reports, your choice.</h2>
</div>
{data && (
<span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
{
{ off: "Off", pending: "Check your inbox", expired: "Confirmation expired", enabled: "Email confirmed" }[
data.state
]
}
</span>
)}
</div>
<p>
Email yourself your viewing report whenever you want. Choose a previous month or the current month so far, and
decide whether you also want automatic monthly emails.{" "}
<a href="/insights/reports">Explore your latest report </a>
</p>
{!data && !error && <p role="status">Loading your email preference</p>}
{data && (
<>
{data.state === "enabled" ? (
<p className="recap-delivery-address">
Recaps will go to <strong>{data.email}</strong>.{" "}
{!data.automatic_monthly
? "On demand only: choose a month in Reports and email it whenever you want."
: data.schedule_enabled && data.next_send_at
? `Next scheduled send: ${scheduled(data.next_send_at)}.`
: "The administrator has paused scheduled delivery."}
</p>
) : (
<p>
{data.state === "pending"
? `Check ${data.email} for a confirmation link. It expires after 24 hours. No viewing history is emailed until you confirm.`
: data.state === "expired"
? "Request a new confirmation link to turn on your recaps."
: "Confirm your profile email to turn this on. You can unsubscribe from any recap or here in Profile."}
</p>
)}
{!data.can_subscribe && data.state !== "enabled" && <p className="recap-muted">{data.detail}</p>}
{data.state !== "enabled" && data.can_subscribe && automatic && !data.schedule_enabled && (
<p className="recap-muted">
You can subscribe now. Monthly sends will begin when your administrator starts the schedule.
</p>
)}
<label className="recap-delivery-choice">
Delivery preference
<select
value={automatic ? "monthly" : "manual"}
disabled={busy || data.state === "pending"}
onChange={(event) => {
const monthly = event.target.value === "monthly";
setAutomatic(monthly);
if (data.state === "enabled") void save(true, monthly);
}}
>
<option value="manual">On demand only</option>
<option value="monthly">On demand + automatic monthly emails</option>
</select>
</label>
<div className="recap-actions">
{data.state !== "enabled" && (
<button
type="button"
className="account-primary"
disabled={busy || !data.can_subscribe || cooldown > 0}
onClick={() => void save(true)}
>
{busy
? "Sending confirmation…"
: data.state === "off"
? "Confirm my email for reports"
: "Send a new confirmation"}
</button>
)}
{data.state !== "off" && (
<button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>
{busy ? "Updating…" : data.state === "enabled" ? "Turn off report emails" : "Cancel subscription"}
</button>
)}
<button
type="button"
className="account-secondary"
disabled={busy}
onClick={() => {
setNotice("");
setRevision((value) => value + 1);
}}
>
Refresh preference
</button>
</div>
{cooldown > 0 && data.state !== "enabled" && (
<p className="recap-muted">
Another confirmation can be requested in {Math.ceil(cooldown / 60)}{" "}
{Math.ceil(cooldown / 60) === 1 ? "minute" : "minutes"}.
</p>
)}
</>
)}
{error && (
<p className="account-notice is-error" role="alert">
{error}
{!data && (
<button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
)}
</p>
)}
{notice && (
<p className="account-notice is-status" role="status">
{notice}
</p>
)}
</section>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, getApiBase } from "../lib/auth";
import "../email-recaps/recaps.css";
type Preference = {
state: "off" | "pending" | "expired" | "enabled";
email: string | null;
can_subscribe: boolean;
detail: string;
schedule_enabled: boolean;
next_send_at: number | null;
weekday: number;
hour: number;
resend_after: number | null;
};
const scheduled = (value: number) =>
`${new Date(value * 1000).toLocaleString(undefined, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" })} UTC`;
export default function NewsletterPreference() {
const router = useRouter();
const [data, setData] = useState<Preference | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [revision, setRevision] = useState(0);
const [now, setNow] = useState(Date.now());
useEffect(() => {
void revision;
const abort = new AbortController();
setError("");
void authFetch(`${getApiBase()}/profile/newsletters`, { signal: abort.signal })
.then(async (response) => {
if (response.status === 401) {
router.replace("/login?next=%2Fprofile");
return;
}
if (!response.ok) throw new Error("Could not load your email preference. Please try again.");
const result = (await response.json()) as Preference;
if (!abort.signal.aborted) setData(result);
})
.catch((err: Error) => {
if (!abort.signal.aborted) setError(err.message);
});
return () => abort.abort();
}, [revision, router]);
useEffect(() => {
if (!data?.resend_after || data.state === "enabled" || data.resend_after * 1000 <= Date.now()) return;
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [data?.resend_after, data?.state]);
const save = async (enabled: boolean) => {
if (busy) return;
setBusy(true);
setError("");
setNotice("");
try {
const response = await authFetch(`${getApiBase()}/profile/newsletters`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled }),
});
if (response.status === 401) {
router.replace("/login?next=%2Fprofile");
return;
}
const result = await response.json().catch(() => ({}));
if (!response.ok)
throw new Error(typeof result.detail === "string" ? result.detail : "Could not update your email preference.");
setData(result);
setNow(Date.now());
setNotice(result.message || (enabled ? "Newsletters are on." : "Newsletters are off."));
} catch (err) {
setError(err instanceof Error ? err.message : "Could not update your email preference.");
// A confirmation may be pending even if SMTP could not confirm delivery.
const response = await authFetch(`${getApiBase()}/profile/newsletters`).catch(() => null);
if (response?.ok) {
setData(await response.json());
setNow(Date.now());
}
} finally {
setBusy(false);
}
};
const cooldown = data?.resend_after ? Math.max(0, Math.ceil(data.resend_after - now / 1000)) : 0;
return (
<section className="recap-preference" id="newsletters" aria-labelledby="newsletter-preference-title">
<div className="recap-section-heading">
<div>
<span className="recap-eyebrow">Your next watch</span>
<h2 id="newsletter-preference-title">New in your library.</h2>
</div>
{data && (
<span className={`recap-pill ${data.state === "enabled" ? "is-enabled" : ""}`}>
{
{ off: "Off", pending: "Check your inbox", expired: "Confirmation expired", enabled: "Subscribed" }[
data.state
]
}
</span>
)}
</div>
<p>
Your minutes, movies, episodes, longest run and requests, in one personal monthly email.{" "}
<a href="/insights/reports">Explore your latest report </a>
</p>
{!data && !error && <p role="status">Loading your email preference</p>}
{data && (
<>
{data.state === "enabled" ? (
<p className="recap-delivery-address">
Newsletters will go to <strong>{data.email}</strong>.{" "}
{data.schedule_enabled && data.next_send_at
? `Next scheduled send: ${scheduled(data.next_send_at)}.`
: "Weekly sending is paused. You may still receive editions scheduled by your administrator."}
</p>
) : (
<p>
{data.state === "pending"
? `Check ${data.email} for a confirmation link. It expires after 24 hours. Your newsletter subscription starts after you confirm.`
: data.state === "expired"
? "Request a new confirmation link to turn on newsletters."
: "Subscribe with your profile email. If you already confirmed it for monthly recaps, you can turn this on straight away. Otherwise, we?ll send a confirmation link."}
</p>
)}
{!data.can_subscribe && data.state !== "enabled" && <p className="recap-muted">{data.detail}</p>}
{data.state !== "enabled" && data.can_subscribe && !data.schedule_enabled && (
<p className="recap-muted">You can subscribe now, ready for the next edition your administrator sends.</p>
)}
<div className="recap-actions">
{data.state !== "enabled" && (
<button
type="button"
className="account-primary"
disabled={busy || !data.can_subscribe || cooldown > 0}
onClick={() => void save(true)}
>
{busy
? "Sending confirmation…"
: data.state === "off"
? "Email me new arrivals"
: "Resend newsletter confirmation"}
</button>
)}
{data.state !== "off" && (
<button type="button" className="account-secondary" disabled={busy} onClick={() => void save(false)}>
{busy
? "Updating…"
: data.state === "enabled"
? "Turn off newsletters"
: "Cancel newsletter subscription"}
</button>
)}
<button
type="button"
className="account-secondary"
disabled={busy}
onClick={() => {
setNotice("");
setRevision((value) => value + 1);
}}
>
Refresh newsletter preference
</button>
</div>
{cooldown > 0 && data.state !== "enabled" && (
<p className="recap-muted">
Another confirmation can be requested in {Math.ceil(cooldown / 60)}{" "}
{Math.ceil(cooldown / 60) === 1 ? "minute" : "minutes"}.
</p>
)}
</>
)}
{error && (
<p className="account-notice is-error" role="alert">
{error}
{!data && (
<button type="button" className="account-secondary" onClick={() => setRevision((value) => value + 1)}>
Try again
</button>
)}
</p>
)}
{notice && (
<p className="account-notice is-status" role="status">
{notice}
</p>
)}
</section>
);
}
+629
View File
@@ -0,0 +1,629 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
import { useEffectiveRole } from "../../lib/viewMode";
import InviteDeliveryChoice from "../../ui/InviteDeliveryChoice";
import PageHeading from "../../ui/PageHeading";
type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean };
type OwnedInvite = {
id: number;
code: string;
label?: string | null;
description?: string | null;
code_available?: boolean;
recipient_email?: string | null;
max_uses?: number | null;
use_count: number;
remaining_uses?: number | null;
enabled: boolean;
expires_at?: string | null;
is_usable?: boolean;
created_at?: string | null;
};
type OwnedInvitesResponse = {
invites?: OwnedInvite[];
invite_access?: { enabled?: boolean; managed_by_master?: boolean };
master_invite?: {
id: number;
code: string;
label?: string | null;
max_uses?: number | null;
expires_at?: string | null;
} | null;
};
type InviteForm = {
code: string;
label: string;
description: string;
recipient_email: string;
enabled: boolean;
message: string;
};
type DeliveryMethod = "" | "manual" | "email";
const defaultInviteForm = (): InviteForm => ({
code: "",
label: "",
description: "",
recipient_email: "",
enabled: true,
message: "",
});
const formatDate = (value?: string | null) => {
if (!value) return "Never";
const date = new Date(value);
return Number.isNaN(date.valueOf()) ? value : date.toLocaleString();
};
const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
export default function ProfileInvitesPage() {
const router = useRouter();
const [profile, setProfile] = useState<ProfileInfo | null>(null);
const [invites, setInvites] = useState<OwnedInvite[]>([]);
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false);
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false);
const [masterInvite, setMasterInvite] = useState<OwnedInvitesResponse["master_invite"]>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [flowStep, setFlowStep] = useState(1);
const [useCustomCode, setUseCustomCode] = useState(false);
const [deliveryMethod, setDeliveryMethod] = useState<DeliveryMethod>("");
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm());
const [createdInvite, setCreatedInvite] = useState<OwnedInvite | null>(null);
const effectiveRole = useEffectiveRole(profile?.role);
const canManageInvites =
effectiveRole === "admin" ||
(profile?.role === "admin" ? Boolean(profile.invite_management_enabled) : inviteAccessEnabled);
const signupBaseUrl = useMemo(() => {
if (typeof window === "undefined") return "/signup";
return `${window.location.origin}/signup`;
}, []);
const loadInvites = useCallback(async () => {
const response = await authFetch(`${getApiBase()}/auth/profile/invites`);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error("Could not load your invite workspace.");
}
const data = (await response.json()) as OwnedInvitesResponse;
setInvites(Array.isArray(data.invites) ? data.invites : []);
setInviteAccessEnabled(Boolean(data.invite_access?.enabled));
setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master));
setMasterInvite(data.master_invite ?? null);
}, [router]);
useEffect(() => {
if (!getToken()) {
router.push("/login");
return;
}
const load = async () => {
try {
const profileResponse = await authFetch(`${getApiBase()}/auth/profile`);
if (!profileResponse.ok) {
if (profileResponse.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error("Could not load your profile.");
}
const profileData = await profileResponse.json();
setProfile(profileData?.user ?? null);
await loadInvites();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not load your invite workspace.");
} finally {
setLoading(false);
}
};
void load();
}, [loadInvites, router]);
const resetFlow = () => {
setEditingId(null);
setFlowStep(1);
setUseCustomCode(false);
setDeliveryMethod("");
setInviteForm(defaultInviteForm());
};
const editInvite = (invite: OwnedInvite) => {
setEditingId(invite.id);
setCreatedInvite(null);
setFlowStep(4);
setUseCustomCode(true);
setDeliveryMethod(invite.recipient_email ? "email" : "manual");
setInviteForm({
code: invite.code,
label: invite.label ?? "",
description: invite.description ?? "",
recipient_email: invite.recipient_email ?? "",
enabled: invite.enabled !== false,
message: "",
});
setError(null);
setStatus(null);
window.scrollTo({ top: 0, behavior: "smooth" });
};
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault();
if (!canManageInvites) return;
const inviteName = inviteForm.label.trim();
const recipientEmail = inviteForm.recipient_email.trim();
if (!inviteName) {
setError("Give this invite a name so you can recognise it later.");
return;
}
if (!deliveryMethod) {
setError("Choose how you want to deliver the invite.");
return;
}
if (deliveryMethod === "email" && !isValidEmail(recipientEmail)) {
setError("Enter a valid recipient email address.");
return;
}
setSaving(true);
setError(null);
setStatus(null);
try {
const response = await authFetch(
editingId == null
? `${getApiBase()}/auth/profile/invites`
: `${getApiBase()}/auth/profile/invites/${editingId}`,
{
method: editingId == null ? "POST" : "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: useCustomCode ? inviteForm.code || null : null,
label: inviteName,
description: inviteForm.description || null,
recipient_email: deliveryMethod === "email" ? recipientEmail : null,
enabled: inviteForm.enabled,
send_email: editingId == null && deliveryMethod === "email",
message: inviteForm.message || null,
}),
},
);
if (!response.ok) {
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
throw new Error((await response.text()) || "Could not save the invite.");
}
const data = await response.json();
const savedInvite = data?.invite as OwnedInvite | undefined;
setStatus(
data?.email?.status === "ok"
? `Invite created and emailed to ${data.email.recipient_email}.`
: data?.email?.status === "error"
? `Invite created, but the email could not be sent: ${data.email.detail}`
: editingId == null
? "Invite link created and ready to share."
: "Invite updated.",
);
resetFlow();
if (editingId == null && savedInvite) setCreatedInvite(savedInvite);
await loadInvites();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not save the invite.");
} finally {
setSaving(false);
}
};
const deleteInvite = async (invite: OwnedInvite) => {
if (!canManageInvites) return;
if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return;
setError(null);
try {
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: "DELETE" });
if (!response.ok) throw new Error((await response.text()) || "Could not delete the invite.");
if (editingId === invite.id) resetFlow();
setStatus(`Deleted ${invite.label || invite.code}.`);
await loadInvites();
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Could not delete the invite.");
}
};
const copyInviteLink = async (invite: OwnedInvite) => {
if (!canManageInvites) return;
try {
let usableInvite = invite;
if (!invite.code_available) {
const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}/rotate`, {
method: "POST",
});
if (!response.ok) throw new Error((await response.text()) || "Could not generate a replacement link.");
const data = await response.json();
usableInvite = data.invite as OwnedInvite;
setInvites((current) => current.map((item) => (item.id === invite.id ? usableInvite : item)));
}
const url = `${signupBaseUrl}?code=${encodeURIComponent(usableInvite.code)}`;
await navigator.clipboard.writeText(url);
setStatus(
`Copied the link for ${invite.label || usableInvite.code}. Keep it safe; Magent will not display it again after this page reloads.`,
);
} catch {
setError("Could not generate or copy the invite link.");
}
};
const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, "");
const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6));
const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : "";
if (loading) return <main className="card">Loading invite workspace</main>;
return (
<main className="card invites-page">
<PageHeading title="Invites" description="Invite someone to your media library and manage the links you share." />
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
{!canManageInvites ? (
<section className="profile-section profile-tab-panel">
<h2>Invites are not enabled for your account</h2>
<p className="lede">Ask an administrator if you need permission to invite someone.</p>
</section>
) : (
<section className="profile-section profile-invites-section profile-tab-panel">
<div className="invite-flow-heading">
<div>
<span className="eyebrow">Invite flow</span>
<h2>{editingId == null ? "Create an invite" : `Edit ${inviteForm.label || "invite"}`}</h2>
<p className="lede">Set up the invite one decision at a time.</p>
</div>
{editingId != null && (
<button type="button" className="ghost-button" onClick={resetFlow}>
Cancel edit
</button>
)}
</div>
{createdInvite && editingId == null ? (
<div className="invite-created-card" role="status">
<span className="eyebrow">Invite ready</span>
<h3>{createdInvite.label || "Your invite"}</h3>
<p>
{createdInvite.recipient_email
? `The invite was emailed to ${createdInvite.recipient_email}.`
: "Copy this link and send it to the person you are inviting."}
</p>
<div className="invite-created-link">
<input value={createdInviteUrl} readOnly aria-label="Created invite link" />
<button type="button" onClick={() => void copyInviteLink(createdInvite)}>
Copy link
</button>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setCreatedInvite(null);
resetFlow();
}}
>
Create another invite
</button>
</div>
) : (
<form onSubmit={saveInvite} className="invite-flow-form">
<ol className="invite-flow-route" aria-label="Invite creation progress">
{["Identity", "Description", "Access", "Delivery"].map((label, index) => {
const step = index + 1;
return (
<li key={label} className={step === flowStep ? "is-active" : step < flowStep ? "is-complete" : ""}>
<span>{String(step).padStart(2, "0")}</span>
<strong>{label}</strong>
</li>
);
})}
</ol>
<section className={`invite-flow-step ${flowStep > 1 ? "is-complete" : "is-active"}`}>
<header>
<span className="invite-flow-number">01</span>
<div>
<span className="eyebrow">Identity</span>
<h3>Who is this invite for?</h3>
<p>Give it a name that will make sense when you return later.</p>
</div>
</header>
<div className="invite-flow-fields">
<label>
<span>Invite name</span>
<input
value={inviteForm.label}
onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))}
placeholder="Family, that guy from work, the neighbour"
/>
</label>
<label className="invite-flow-choice-line">
<input
type="checkbox"
checked={useCustomCode}
disabled={editingId != null}
onChange={(event) => {
setUseCustomCode(event.target.checked);
if (!event.target.checked) setInviteForm((current) => ({ ...current, code: "" }));
}}
/>
<span>
<strong>Choose a custom invite code</strong>
<small>
The code appears at the end of the sign-up link. Leave this off and Magent will create a secure
code for you.
</small>
</span>
</label>
{useCustomCode && (
<label>
<span>Custom code</span>
<input
value={inviteForm.code}
disabled={editingId != null}
onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))}
placeholder="At least 6 letters or numbers"
/>
<small>
This becomes <code>/signup?code={inviteForm.code || "YOUR-CODE"}</code>.
</small>
</label>
)}
{flowStep === 1 && (
<div className="invite-flow-actions">
<button type="button" disabled={!identityReady} onClick={() => setFlowStep(2)}>
Continue to description
</button>
</div>
)}
</div>
</section>
{flowStep >= 2 && (
<section className={`invite-flow-step ${flowStep > 2 ? "is-complete" : "is-active"}`}>
<header>
<span className="invite-flow-number">02</span>
<div>
<span className="eyebrow">Description</span>
<h3>Add a welcome note</h3>
<p>This optional message is shown on the sign-up page.</p>
</div>
</header>
<div className="invite-flow-fields">
<label>
<span>Welcome note (optional)</span>
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) =>
setInviteForm((current) => ({ ...current, description: event.target.value }))
}
placeholder="Welcome! Use this link to create your account."
/>
</label>
{flowStep === 2 && (
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setFlowStep(1)}>
Back
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setInviteForm((current) => ({ ...current, description: "" }));
setFlowStep(3);
}}
>
Skip
</button>
<button type="button" onClick={() => setFlowStep(3)}>
Continue
</button>
</div>
)}
</div>
</section>
)}
{flowStep >= 3 && (
<section className={`invite-flow-step ${flowStep > 3 ? "is-complete" : "is-active"}`}>
<header>
<span className="invite-flow-number">03</span>
<div>
<span className="eyebrow">Access</span>
<h3>Account access is applied automatically</h3>
<p>Magent uses the safe invite policy configured by an administrator.</p>
</div>
</header>
<div className="invite-flow-fields">
<div className="invite-policy-note">
<strong>Standard user access</strong>
<span>
{inviteManagedByMaster && masterInvite
? `Using the “${masterInvite.label || masterInvite.code}” invite policy. Usage and expiry limits are automatic.`
: "This invite creates a standard user account using your configured defaults."}
</span>
</div>
{flowStep === 3 && (
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setFlowStep(2)}>
Back
</button>
<button type="button" onClick={() => setFlowStep(4)}>
Continue to delivery
</button>
</div>
)}
</div>
</section>
)}
{flowStep >= 4 && (
<section className="invite-flow-step is-active">
<header>
<span className="invite-flow-number">04</span>
<div>
<span className="eyebrow">Delivery</span>
<h3>How will they receive it?</h3>
<p>Copy the link yourself, or let Magent email it directly.</p>
</div>
</header>
<div className="invite-flow-fields">
<InviteDeliveryChoice
value={deliveryMethod}
onChange={(method) => {
setDeliveryMethod(method);
if (method === "manual")
setInviteForm((current) => ({ ...current, recipient_email: "", message: "" }));
}}
/>
{deliveryMethod === "manual" && (
<div className="invite-delivery-summary">
<strong>Your link will appear as soon as the invite is created.</strong>
<span>No email address is required and Magent will not send a message.</span>
</div>
)}
{deliveryMethod === "email" && (
<div className="invite-flow-field-grid invite-delivery-fields">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))
}
placeholder="person@example.com"
/>
</label>
<label>
<span>Email note (optional)</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({ ...current, message: event.target.value }))
}
placeholder="A short personal message"
/>
</label>
</div>
)}
{editingId != null && (
<label className="invite-status-control">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) =>
setInviteForm((current) => ({ ...current, enabled: event.target.checked }))
}
/>
<span>
<strong>{inviteForm.enabled ? "Invite enabled" : "Invite disabled"}</strong>
<small>Disable this existing invite to stop its link from accepting sign-ups.</small>
</span>
</label>
)}
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setFlowStep(3)}>
Back
</button>
<button
type="submit"
disabled={
saving ||
!deliveryMethod ||
(deliveryMethod === "email" && !isValidEmail(inviteForm.recipient_email))
}
>
{saving
? "Saving…"
: editingId != null
? "Save invite"
: deliveryMethod === "email"
? "Create and email invite"
: "Create invite link"}
</button>
</div>
</div>
</section>
)}
</form>
)}
<div className="profile-invites-list">
<div className="invite-flow-heading">
<div>
<span className="eyebrow">Your invites</span>
<h2>Created invites</h2>
<p className="lede">Copy, edit, disable, or remove invitations you have made.</p>
</div>
</div>
{invites.length === 0 ? (
<div className="status-banner">You have not created any invites yet.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>{invite.label || "Unnamed invite"}</strong>
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? "" : "is-muted"}`}>
{invite.is_usable ? "Ready" : "Unavailable"}
</span>
</div>
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">{invite.description}</p>
)}
<div className="admin-meta-row">
<span>Delivery: {invite.recipient_email || "Manual link"}</span>
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === "number" ? ` / ${invite.max_uses}` : ""}
</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => void copyInviteLink(invite)}>
{invite.code_available ? "Copy link" : "Generate replacement link"}
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit
</button>
<button type="button" onClick={() => void deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</section>
)}
</main>
);
}
+485
View File
@@ -0,0 +1,485 @@
"use client";
import { useRouter } from "next/navigation";
import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
import { canAccess, type FeatureAccess } from "../lib/features";
import { useEffectiveRole } from "../lib/viewMode";
import PageHeading from "../ui/PageHeading";
import MonthlyRecapPreference from "./MonthlyRecapPreference";
import NewsletterPreference from "./NewsletterPreference";
type ProfileInfo = {
features?: FeatureAccess;
username: string;
email?: string | null;
role: string;
auth_provider: string;
password_change_supported?: boolean;
password_provider?: "local" | "jellyfin" | null;
};
type ActivityEntry = {
ip: string;
user_agent: string;
first_seen_at: string;
last_seen_at: string;
};
type ProfileResponse = {
user: ProfileInfo;
stats?: { total: number; ready: number; in_progress: number };
activity?: { recent: ActivityEntry[] };
};
type Notice = { tone: "status" | "error"; message: string } | null;
type ProfileTab = "overview" | "security" | "activity";
const TABS: { key: ProfileTab; label: string }[] = [
{ key: "overview", label: "Account" },
{ key: "security", label: "Security" },
{ key: "activity", label: "Activity" },
];
const normalizeTab = (value: string | null): ProfileTab =>
value === "security" || value === "activity" ? value : "overview";
const formatDate = (value?: string) => {
if (!value) return "Not recorded";
const date = new Date(value);
return Number.isNaN(date.valueOf())
? "Not recorded"
: date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" });
};
const deviceName = (agent: string) => {
const value = (agent || "").toLowerCase();
const browser = value.includes("edg/")
? "Edge"
: value.includes("firefox/") || value.includes("fxios/")
? "Firefox"
: value.includes("chrome/") || value.includes("crios/")
? "Chrome"
: value.includes("safari/")
? "Safari"
: "Browser";
const device = /iphone|ipad/.test(value)
? "iOS"
: value.includes("android")
? "Android"
: value.includes("windows")
? "Windows"
: value.includes("macintosh")
? "Mac"
: value.includes("linux")
? "Linux"
: "";
return device ? `${browser} on ${device}` : browser;
};
const responseMessage = async (response: Response, fallback: string) => {
const data = await response.json().catch(() => null);
return typeof data?.detail === "string" && data.detail.trim() ? data.detail : fallback;
};
export default function ProfilePage() {
const router = useRouter();
const [data, setData] = useState<ProfileResponse | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [activeTab, setActiveTab] = useState<ProfileTab>("overview");
const [email, setEmail] = useState("");
const [emailSaving, setEmailSaving] = useState(false);
const [emailNotice, setEmailNotice] = useState<Notice>(null);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
const [passwordNotice, setPasswordNotice] = useState<Notice>(null);
const [showAllActivity, setShowAllActivity] = useState(false);
const loadProfile = useCallback(async () => {
if (!getToken()) {
router.replace("/login?next=%2Fprofile");
return;
}
setLoading(true);
setLoadError("");
try {
const response = await authFetch(`${getApiBase()}/auth/profile`);
if (response.status === 401) {
clearToken();
router.replace("/login?next=%2Fprofile");
return;
}
if (!response.ok) throw new Error("Could not load your profile. Please try again.");
const profile = (await response.json()) as ProfileResponse;
setData(profile);
setEmail(profile.user.email ?? "");
} catch {
setLoadError("Could not load your profile. Please try again.");
} finally {
setLoading(false);
}
}, [router]);
useEffect(() => {
void loadProfile();
}, [loadProfile]);
useEffect(() => {
const syncTab = () => setActiveTab(normalizeTab(new URLSearchParams(window.location.search).get("tab")));
syncTab();
window.addEventListener("popstate", syncTab);
return () => window.removeEventListener("popstate", syncTab);
}, []);
const selectTab = (tab: ProfileTab) => {
setActiveTab(tab);
router.replace(tab === "overview" ? "/profile" : `/profile?tab=${tab}`, { scroll: false });
};
const tabKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
let next = index;
if (event.key === "ArrowRight") next = (index + 1) % TABS.length;
else if (event.key === "ArrowLeft") next = (index + TABS.length - 1) % TABS.length;
else if (event.key === "Home") next = 0;
else if (event.key === "End") next = TABS.length - 1;
else return;
event.preventDefault();
selectTab(TABS[next].key);
document.getElementById(`profile-tab-${TABS[next].key}`)?.focus();
};
const saveEmail = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (emailSaving) return;
setEmailSaving(true);
setEmailNotice(null);
try {
const response = await authFetch(`${getApiBase()}/auth/profile/email`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() || null }),
});
if (response.status === 401) {
clearToken();
router.replace("/login?next=%2Fprofile");
return;
}
if (!response.ok)
throw new Error(await responseMessage(response, "Could not save your email. Please try again."));
const result = await response.json();
const saved = typeof result.email === "string" ? result.email : "";
setData((current) => (current ? { ...current, user: { ...current.user, email: saved || null } } : current));
setEmail(saved);
setEmailNotice({ tone: "status", message: saved ? "Email saved." : "Email removed." });
} catch (error) {
setEmailNotice({ tone: "error", message: error instanceof Error ? error.message : "Could not save your email." });
} finally {
setEmailSaving(false);
}
};
const savePassword = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (passwordSaving) return;
setPasswordNotice(null);
if (newPassword.trim().length < 8) {
setPasswordNotice({ tone: "error", message: "Use at least 8 characters for your new password." });
return;
}
if (newPassword !== confirmPassword) {
setPasswordNotice({ tone: "error", message: "The new passwords do not match." });
return;
}
setPasswordSaving(true);
try {
const response = await authFetch(`${getApiBase()}/auth/password`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
});
if (!response.ok)
throw new Error(await responseMessage(response, "Could not change your password. Please try again."));
const result = await response.json();
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPasswordNotice({
tone: "status",
message:
result.provider === "jellyfin"
? "Password updated for Jellyfin and Magent. Seerr uses the same password."
: "Password updated.",
});
} catch (error) {
setPasswordNotice({
tone: "error",
message: error instanceof Error ? error.message : "Could not change your password.",
});
} finally {
setPasswordSaving(false);
}
};
const user = data?.user;
const effectiveRole = useEffectiveRole(user?.role);
const passwordProvider = user?.password_provider ?? (user?.auth_provider === "jellyfin" ? "jellyfin" : "local");
const canChangePassword =
user?.password_change_supported ?? ["local", "jellyfin"].includes(user?.auth_provider ?? "");
const emailChanged = email.trim() !== (user?.email ?? "");
const recent = data?.activity?.recent ?? [];
const notice = (value: Notice) =>
value && (
<p className={`account-notice is-${value.tone}`} role={value.tone === "error" ? "alert" : "status"}>
{value.message}
</p>
);
return (
<main className="account-page">
<PageHeading
title="My profile"
description="Your contact details, security, and activity."
actions={
user && (
<div className="account-identity">
<span className="account-avatar" aria-hidden="true">
{user.username.slice(0, 1).toUpperCase()}
</span>
<div>
<strong>{user.username}</strong>
<span>{effectiveRole === "admin" ? "Administrator" : "Member"}</span>
</div>
</div>
)
}
/>
{loading ? (
<p className="account-empty" role="status">
Loading your profile
</p>
) : loadError ? (
<div className="account-empty">
<p role="alert">{loadError}</p>
<button type="button" className="account-secondary" onClick={() => void loadProfile()}>
Try again
</button>
</div>
) : (
user && (
<>
<div className="account-tabs" role="tablist" aria-label="Profile sections">
{TABS.map((tab, index) => (
<button
key={tab.key}
id={`profile-tab-${tab.key}`}
type="button"
role="tab"
aria-selected={activeTab === tab.key}
aria-controls={`profile-panel-${tab.key}`}
tabIndex={activeTab === tab.key ? 0 : -1}
onKeyDown={(event) => tabKeyDown(event, index)}
onClick={() => selectTab(tab.key)}
>
{tab.label}
</button>
))}
</div>
<section
className="account-panel"
id="profile-panel-overview"
role="tabpanel"
aria-labelledby="profile-tab-overview"
hidden={activeTab !== "overview"}
>
<div className="account-section-intro">
<h2>Contact email</h2>
<p>For password recovery and updates on your reported issues.</p>
</div>
<form className="account-form" onSubmit={saveEmail}>
<label htmlFor="profile-email">Email address</label>
<input
id="profile-email"
name="email"
type="email"
autoComplete="email"
placeholder="you@example.com"
value={email}
disabled={emailSaving}
onChange={(event) => {
setEmail(event.target.value);
setEmailNotice(null);
}}
/>
{!user.email && (
<p className="account-hint">Add an email so we can let you know when a fix is ready.</p>
)}
{user.email && !email.trim() && (
<p className="account-hint">Saving without an email stops account and issue emails.</p>
)}
{notice(emailNotice)}
<div className="account-form-actions">
<button type="submit" className="account-primary" disabled={emailSaving || !emailChanged}>
{emailSaving ? "Saving…" : "Save email"}
</button>
{emailChanged && (
<button
type="button"
className="account-secondary"
disabled={emailSaving}
onClick={() => {
setEmail(user.email ?? "");
setEmailNotice(null);
}}
>
Discard
</button>
)}
</div>
</form>
<div className="account-connected">
<span className="account-connection-dot" aria-hidden="true" />
<span>
{user.auth_provider === "jellyfin"
? "Connected with your Jellyfin account"
: user.auth_provider === "local"
? "Signed in with a Magent account"
: "Signed in with your media account"}
</span>
</div>
{canAccess({ ...user, role: effectiveRole ?? undefined }, "stats") && (
<MonthlyRecapPreference key={user.email || "no-email"} />
)}
<NewsletterPreference key={`newsletter-${user.email || "no-email"}`} />
</section>
<section
className="account-panel"
id="profile-panel-security"
role="tabpanel"
aria-labelledby="profile-tab-security"
hidden={activeTab !== "security"}
>
<div className="account-section-intro">
<h2>Change password</h2>
<p>
{passwordProvider === "jellyfin"
? "One password for Jellyfin, Seerr and Magent."
: "Keep your Magent account secure."}
</p>
</div>
{canChangePassword ? (
<form className="account-form" onSubmit={savePassword}>
<fieldset disabled={passwordSaving}>
<label htmlFor="profile-current-password">Current password</label>
<input
id="profile-current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
required
/>
<label htmlFor="profile-new-password">New password</label>
<input
id="profile-new-password"
type="password"
autoComplete="new-password"
aria-describedby="password-length"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
minLength={8}
required
/>
<p id="password-length" className="account-hint">
At least 8 characters.
</p>
<label htmlFor="profile-confirm-password">Confirm new password</label>
<input
id="profile-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
minLength={8}
required
/>
</fieldset>
{notice(passwordNotice)}
<div className="account-form-actions">
<button type="submit" className="account-primary" disabled={passwordSaving}>
{passwordSaving ? "Updating…" : "Update password"}
</button>
</div>
</form>
) : (
<p className="account-empty">
Password changes are managed by your sign-in provider. Contact an administrator for help.
</p>
)}
</section>
<section
className="account-panel"
id="profile-panel-activity"
role="tabpanel"
aria-labelledby="profile-tab-activity"
hidden={activeTab !== "activity"}
>
<div className="account-section-intro">
<h2>Your activity</h2>
<p>Your requests and recent account access.</p>
</div>
{data?.stats && (
<div className="account-request-summary">
<div>
<strong>{data.stats.total}</strong>
<span>Requests</span>
</div>
<div>
<strong>{data.stats.ready}</strong>
<span>Ready to watch</span>
</div>
<a href="/">
View my requests <span aria-hidden="true"></span>
</a>
</div>
)}
<h3 className="account-list-heading">Recent account access</h3>
{recent.length ? (
<ul className="account-access-list">
{(showAllActivity ? recent : recent.slice(0, 5)).map((entry, index) => (
<li key={`${entry.ip}-${entry.last_seen_at}-${index}`}>
<div className="account-access-summary">
<strong>{deviceName(entry.user_agent)}</strong>
<time dateTime={entry.last_seen_at}>{formatDate(entry.last_seen_at)}</time>
</div>
<details>
<summary>Connection details</summary>
<dl>
<div>
<dt>IP address</dt>
<dd>{entry.ip || "Not recorded"}</dd>
</div>
<div>
<dt>First seen</dt>
<dd>{formatDate(entry.first_seen_at)}</dd>
</div>
</dl>
</details>
</li>
))}
</ul>
) : (
<p className="account-empty">No recent activity yet.</p>
)}
{recent.length > 5 && (
<button
className="account-secondary"
type="button"
onClick={() => setShowAllActivity(!showAllActivity)}
>
{showAllActivity ? "Show less" : "Show all activity"}
</button>
)}
</section>
</>
)
)}
</main>
);
}
@@ -0,0 +1,173 @@
"use client";
import { useEffect, useRef, useState } from "react";
import "./latest-activity.css";
import { lockBodyScroll } from "../../lib/scrollLock";
type Event = { id: string; service: string; state: string; message: string; started_at?: string; finished_at?: string };
type Operation = {
summary?: { title: string; message: string; next: string; action?: string };
id: string;
label: string;
status: string;
events: Event[];
};
export default function LatestActivity({
operation,
besideDownload,
onDismiss,
}: {
operation: Operation;
besideDownload: boolean;
onDismiss: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(true);
useEffect(() => {
void operation.id;
setOpen(true);
}, [operation.id]);
// Events are appended in order. Client result events have no timestamp.
const latest = operation.events.at(-1);
const working = operation.status === "running" || operation.status === "searching";
const message = latest?.message ?? "";
const choosing = /[1-9]\d* releases? (found|shown)/i.test(message);
const sent =
operation.status === "complete" &&
/sent|accepted.*release/i.test(message) &&
/download|release|Sonarr|Radarr/i.test(message);
const interrupted = latest?.id === "connection-error";
const status =
operation.summary?.title ??
(working
? "Working on it"
: choosing
? "Choose a download"
: operation.status === "complete"
? "Done"
: "Needs your attention");
const currentStep =
operation.summary?.message ??
(working
? /send release/i.test(operation.label)
? "Sending your download..."
: /search/i.test(operation.label)
? "Looking for a download..."
: latest?.service === "Jellyfin"
? "Checking if it is ready to watch..."
: latest?.service === "qBittorrent"
? "Checking your download..."
: "Checking your request..."
: interrupted
? "The connection was lost."
: choosing
? "The search is finished. Choose a version to download."
: sent
? "Your download has been sent."
: operation.status === "error"
? "We could not finish this step."
: "This check is finished.");
const nextStep =
operation.summary?.next ??
(working
? "Please wait. You can close this box while we work."
: interrupted
? "Close this box and check the request before trying again."
: choosing
? "Close this box to see the available downloads."
: sent
? "You can close this box. The request will update when the download starts."
: operation.status === "error"
? "Close this box to review the request and its available options."
: "Close this box to see the updated request status.");
const progress = (
<div
className={`activity-process is-${working ? "working" : operation.status}`}
role="progressbar"
aria-label={currentStep}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={working || operation.status === "error" ? undefined : 100}
>
<span />
</div>
);
useEffect(() => {
if (!open) return;
const element = dialog.current;
element?.showModal();
const unlock = lockBodyScroll();
return () => {
element?.close();
unlock();
trigger.current?.focus();
};
}, [open]);
return (
<div className={`request-overview-block latest-activity ${besideDownload ? "beside-download" : "full-row"}`}>
<button
ref={trigger}
type="button"
className="latest-activity-trigger"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className="latest-activity-heading">
<span className="request-overview-label">Latest activity</span>
<span className={`latest-activity-badge is-${operation.status}`}>{status}</span>
</span>
<span className="latest-activity-message" role="status">
{working && <span className="activity-spinner" aria-hidden="true" />}
{currentStep}
</span>
{progress}
<span className="latest-activity-more">View progress</span>
</button>
<dialog
ref={dialog}
className="activity-dialog"
aria-labelledby="activity-dialog-title"
onCancel={() => setOpen(false)}
onClose={() => setOpen(false)}
>
<div className="activity-dialog-content">
<header>
<div>
<span className="request-overview-label">Request progress</span>
<h2 id="activity-dialog-title">{status}</h2>
</div>
<button type="button" onClick={() => setOpen(false)}>
Close
</button>
</header>
<div className="activity-current" role="status" aria-live="polite" aria-atomic="true">
<p className="activity-current-step">
{working && <span className="activity-spinner" aria-hidden="true" />}
{currentStep}
</p>
{progress}
<p className="activity-next-step">{nextStep}</p>
</div>
{!working && (
<footer>
<button
type="button"
onClick={() => {
setOpen(false);
onDismiss();
}}
>
{operation.summary?.action ?? "Dismiss activity"}
</button>
</footer>
)}
</div>
</dialog>
</div>
);
}
@@ -0,0 +1,80 @@
"use client";
import { useEffect, useState } from "react";
import { authFetch, getApiBase } from "../../lib/auth";
type AudioChoice = {
language: { code: string } | null;
originalEnabled?: boolean;
canChange?: boolean;
profileLanguage?: string;
};
export default function RequestLanguage({
requestId,
disabled,
onApply,
}: {
requestId: string;
disabled: boolean;
onApply: (code: string) => Promise<void>;
}) {
const [choice, setChoice] = useState<AudioChoice | null>(null);
const [error, setError] = useState<string | null>(null);
const [revision, setRevision] = useState(0);
useEffect(() => {
void revision;
const controller = new AbortController();
void authFetch(`${getApiBase()}/requests/${requestId}/language`, { signal: controller.signal })
.then(async (response) => {
if (!response.ok) throw new Error("Could not check the audio settings. Reload the request to try again.");
return response.json();
})
.then((data) => {
if (!controller.signal.aborted) setChoice(data);
})
.catch((e) => {
if (!controller.signal.aborted) setError(e.message);
});
return () => controller.abort();
}, [requestId, revision]);
if (error && !choice) return <p role="alert">{error}</p>;
if (!choice?.language) return null;
const code = choice.language.code;
const name = new Intl.DisplayNames(["en"], { type: "language" }).of(code) || code;
return (
<section className="request-language-notice" aria-label="Audio language">
<h2>
{name} audio {choice.originalEnabled ? "enabled" : "may need your approval"}
</h2>
<p>
This movie was originally made in {name}. An English dub may not exist.{" "}
{choice.originalEnabled
? "Radarr is set to accept its original audio."
: `The current audio requirement is ${choice.profileLanguage || "set by the library"}. This can leave the request waiting even when an original-language release exists.`}
</p>
<p>
Accepting original audio keeps the quality requirements. Subtitles and individual release audio tracks are not
guaranteed by title metadata.
</p>
{choice.canChange && !choice.originalEnabled && (
<button
type="button"
disabled={disabled}
onClick={async () => {
setError(null);
try {
await onApply(code);
setRevision((v) => v + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "The audio choice could not be saved.");
}
}}
>
Use {name} audio &amp; search
</button>
)}
{error && <p role="alert">{error}</p>}
</section>
);
}
@@ -0,0 +1,35 @@
.latest-activity.beside-download { grid-column: 7 / -1; grid-row: 2; }
.latest-activity.full-row { grid-column: 1 / -1; }
.latest-activity .latest-activity-trigger { display: grid; gap: .6rem; width: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; box-shadow: none; text-transform: none; }
.latest-activity-trigger:focus-visible { outline: 2px solid var(--ops-accent, #83d7f7); outline-offset: 6px; }
.latest-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.latest-activity-message { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-size: .875rem; line-height: 1.5; font-weight: 400; }
.latest-activity-more { color: var(--ops-accent, #83d7f7); font-size: .75rem; }
.latest-activity-badge { font-size: .7rem; font-weight: 500; color: var(--ops-muted, #bbb); }
.latest-activity-badge.is-error { color: #ff9b9b; }
.latest-activity-badge.is-complete { color: #55dec0; }
.activity-dialog { position: fixed; inset: 0; margin: auto; width: min(720px, calc(100vw - 32px)); max-width: none; max-height: min(760px, calc(100dvh - 40px)); padding: 0; border: 1px solid var(--ops-border, #444); border-radius: 16px; color: var(--ops-text, #eee); background: var(--ops-surface, #1c1c1e); overflow: auto; box-shadow: 0 24px 80px #0008; }
.activity-dialog::backdrop { background: #000a; backdrop-filter: blur(5px); }
.activity-dialog-content { padding: 1.25rem; }
.activity-dialog header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; }
.activity-dialog h2 { font-size: 1.2rem; margin: .4rem 0; }
.activity-dialog small { color: var(--ops-muted, #bbb); }
.activity-dialog footer { display: flex; justify-content: flex-end; margin-top: 1rem; }
@media (max-width: 720px) {
.latest-activity.beside-download { grid-column: 1 / -1; grid-row: auto; }
}
.activity-current { padding: 1rem 0 .25rem; }
.activity-current-step { font-size: 1.05rem; font-weight: 600; margin: 0 0 1rem; }
.activity-next-step { color: var(--ops-muted, #bbb); font-size: .875rem; line-height: 1.6; margin: 1rem 0 0; }
.activity-process { height: 6px; width: 100%; overflow: hidden; border-radius: 999px; background: #ffffff14; }
.activity-process > span { display: block; height: 100%; width: 100%; border-radius: inherit; background: #55dec0; }
.activity-process.is-working > span { width: 35%; background: var(--ops-accent, #83d7f7); animation: activity-process-slide 1.6s ease-in-out infinite alternate; }
.activity-process.is-error > span { background: #e6b86c; }
@keyframes activity-process-slide { from { transform: translateX(0); } to { transform: translateX(185%); } }
@media (prefers-reduced-motion: reduce) { .activity-process.is-working > span { animation: none; width: 100%; opacity: .65; } }
.activity-spinner { display: inline-block; width: 28px; height: 28px; flex: 0 0 28px; border: 3px solid #ffffff26; border-top-color: var(--ops-accent, #83d7f7); border-right-color: var(--ops-accent, #83d7f7); border-radius: 50%; animation: activity-spinner-turn .8s linear infinite; }
.activity-current-step, .latest-activity .latest-activity-message { display: flex; align-items: center; gap: 12px; }
@keyframes activity-spinner-turn { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .activity-spinner { animation: none; } }
File diff suppressed because it is too large Load Diff
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import AuthLayout from "../ui/AuthLayout";
import { getApiBase } from "../lib/auth";
type ResetVerification = {
status: string;
recipient_hint?: string;
auth_provider?: string;
expires_at?: string;
};
function ResetPasswordPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [verification, setVerification] = useState<ResetVerification | null>(null);
const [loading, setLoading] = useState(false);
const [verifying, setVerifying] = useState(true);
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
useEffect(() => {
const verifyToken = async () => {
if (!token) {
setError("Password reset link is invalid or missing.");
setVerifying(false);
return;
}
setVerifying(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/password/reset/verify?token=${encodeURIComponent(token)}`);
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(typeof data?.detail === "string" ? data.detail : "Password reset link is invalid.");
}
setVerification(data);
} catch (err) {
console.error(err);
setVerification(null);
setError(err instanceof Error ? err.message : "Password reset link is invalid.");
} finally {
setVerifying(false);
}
};
void verifyToken();
}, [token]);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (!token) {
setError("Password reset link is invalid or missing.");
return;
}
if (password.trim().length < 8) {
setError("Password must be at least 8 characters.");
return;
}
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
setLoading(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/password/reset`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token, new_password: password }),
});
const data = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(typeof data?.detail === "string" ? data.detail : "Unable to reset password.");
}
setStatus("Password updated. You can now sign in with the new password.");
setPassword("");
setConfirmPassword("");
window.setTimeout(() => router.push("/login"), 1200);
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Unable to reset password.");
} finally {
setLoading(false);
}
};
const providerLabel = verification?.auth_provider === "jellyfin" ? "Jellyfin, Seerr, and Magent" : "Magent";
return (
<AuthLayout title="Reset password" description="Choose a new password of at least 8 characters.">
<form className="account-form login-form auth-flow-form" onSubmit={submit}>
{verifying && <div className="status-banner">Checking password reset link</div>}
{!verifying && verification && (
<div className="status-banner">
This reset link was sent to {verification.recipient_hint || "your email"} and will update the password used
for {providerLabel}.
</div>
)}
<label>
New password
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
disabled={!verification || loading}
/>
</label>
<label>
Confirm new password
<input
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
disabled={!verification || loading}
/>
</label>
{error && (
<div className="account-notice is-error" role="alert">
{error}
</div>
)}
{status && (
<div className="account-notice is-status" role="status">
{status}
</div>
)}
<div className="auth-actions">
<button type="submit" className="account-primary" disabled={loading || verifying || !verification}>
{loading ? "Updating password…" : "Reset password"}
</button>
</div>
<button type="button" className="ghost-button" onClick={() => router.push("/login")} disabled={loading}>
Back to sign in
</button>
</form>
</AuthLayout>
);
}
export default function ResetPasswordPage() {
return (
<Suspense
fallback={
<AuthLayout title="Reset password" description="Choose a new password for your account.">
<p role="status">Checking your reset link</p>
</AuthLayout>
}
>
<ResetPasswordPageContent />
</Suspense>
);
}
+623
View File
@@ -0,0 +1,623 @@
"use client";
import { useEffect, useState, type FormEvent } from "react";
import { apiUrl, requestJson } from "../lib/api-client";
import { authFetch, ForbiddenError, logout, setToken, UnauthorizedError } from "../lib/auth";
import MagentMark from "../ui/MagentMark";
import { serviceStatusLabel } from "../admin/configNavigation";
import {
ALL_FIELDS,
APPS,
PREFERENCES,
bootstrapApplicationUrl,
configuredApp,
settingsPayload,
settingsValues,
type AppDefinition,
type Field,
type Setting,
type SetupState,
type SetupStatus,
type SetupStep,
type Values,
} from "./setup-model";
import styles from "./setup.module.css";
type Check = { status: string; message?: string };
type CollectorOptions = { rootFolders: { path: string }[]; qualityProfiles: { id: number; name: string }[] };
const steps: { id: SetupStep; label: string }[] = [
{ id: "administrator", label: "Administrator" },
{ id: "apps", label: "Apps" },
{ id: "preferences", label: "Preferences" },
{ id: "review", label: "Review" },
];
const json = (body: unknown, method = "POST"): RequestInit => ({
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const message = (error: unknown) =>
error instanceof Error ? error.message : "Something went wrong. Please try again.";
export default function SetupPage() {
const [status, setStatus] = useState<SetupStatus | null>(null);
const [state, setState] = useState<SetupState | null>(null);
const [step, setStep] = useState<SetupStep>("administrator");
const [settings, setSettings] = useState<Setting[]>([]);
const [draft, setDraft] = useState<Values>({});
const [ready, setReady] = useState(false);
const [admin, setAdmin] = useState(false);
const [forbidden, setForbidden] = useState(false);
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [setupToken, setSetupToken] = useState("");
const [applicationUrl, setApplicationUrl] = useState("");
const [checks, setChecks] = useState<Record<string, Check>>({});
const [options, setOptions] = useState<Record<string, CollectorOptions>>({});
const [accepted, setAccepted] = useState(false);
const values = { ...settingsValues(settings), ...draft };
useEffect(() => {
setApplicationUrl(window.location.origin);
const controller = new AbortController();
const load = async () => {
try {
const current = await requestJson<SetupStatus>(
"/setup/status",
{ signal: controller.signal, cache: "no-store" },
authFetch,
);
setStatus(current);
if (current.needs_admin) return;
const response = await authFetch(apiUrl("/auth/me"), { signal: controller.signal });
if (!response.ok) return;
const user = await response.json();
if (user.role !== "admin") {
setForbidden(true);
return;
}
const [progress, config] = await Promise.all([
requestJson<SetupState>("/setup/state", { signal: controller.signal }),
requestJson<{ settings: Setting[] }>("/admin/settings", { signal: controller.signal }),
]);
setAdmin(true);
setState(progress);
setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
setSettings(config.settings);
} catch (failure) {
if (!controller.signal.aborted) setError(message(failure));
} finally {
if (!controller.signal.aborted) setReady(true);
}
};
void load();
return () => controller.abort();
}, []);
useEffect(() => {
if (!Object.keys(draft).length) return;
const warn = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [draft]);
const run = async (name: string, action: () => Promise<void>) => {
if (busy) return;
setBusy(name);
setError("");
setNotice("");
try {
await action();
} catch (failure) {
if (failure instanceof UnauthorizedError) {
setAdmin(false);
setForbidden(false);
setAccepted(false);
setPassword("");
setError("Your session expired. Sign in to continue; your unsaved changes are still here.");
} else if (failure instanceof ForbiddenError) {
setAdmin(false);
setForbidden(true);
setAccepted(false);
} else setError(message(failure));
} finally {
setBusy("");
}
};
const signIn = async (loginPassword = password) => {
const result = await requestJson<{ authenticated: boolean; user?: { role: string } }>(
"/auth/login",
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: username.trim(), password: loginPassword }),
},
authFetch,
);
if (!result.authenticated) throw new Error("Could not sign in. Try your administrator credentials again.");
setToken("cookie");
setPassword("");
setConfirmation("");
const user = await requestJson<{ role: string }>("/auth/me");
if (user.role !== "admin") {
setForbidden(true);
return;
}
const [progress, config] = await Promise.all([
requestJson<SetupState>("/setup/state"),
requestJson<{ settings: Setting[] }>("/admin/settings"),
]);
setAdmin(true);
setForbidden(false);
setNotice("");
setState(progress);
setSettings(config.settings);
setStep(progress.completed || progress.step === "administrator" ? "apps" : progress.step);
};
const authenticate = (event: FormEvent) => {
event.preventDefault();
void run("account", async () => {
let loginPassword = password;
if (status?.needs_admin) {
const confirmedApplicationUrl = bootstrapApplicationUrl(applicationUrl, window.location.origin);
if (password !== confirmation) throw new Error("The passwords do not match.");
loginPassword = password.trim();
if (loginPassword.length < 12)
throw new Error("Password must be at least 12 characters, excluding leading and trailing spaces.");
await requestJson(
"/setup/bootstrap",
json({
setup_token: setupToken,
username: username.trim(),
password,
application_url: confirmedApplicationUrl,
}),
authFetch,
);
setPassword(loginPassword);
setSetupToken("");
setStatus({ setup_required: true, needs_admin: false });
setNotice("Administrator created. Signing in...");
}
await signIn(loginPassword);
});
};
const switchAccount = () =>
void run("switch-account", async () => {
await logout();
setAdmin(false);
setForbidden(false);
setAccepted(false);
setUsername("");
setPassword("");
setConfirmation("");
setDraft({});
setSettings([]);
setChecks({});
setOptions({});
setNotice("Sign in with a Magent administrator account to continue setup.");
});
const save = async (fields: Field[] = ALL_FIELDS) => {
const payload = settingsPayload(draft, fields);
if (!Object.keys(payload).length) return;
if (values.site_login_show_local_login === false && values.site_login_show_jellyfin_login === false) {
throw new Error("Keep at least one sign-in method enabled.");
}
if (values.magent_notify_email_use_tls === true && values.magent_notify_email_use_ssl === true) {
throw new Error("Choose STARTTLS or implicit TLS, not both.");
}
await requestJson("/admin/settings", json(payload, "PUT"));
const config = await requestJson<{ settings: Setting[] }>("/admin/settings");
setSettings(config.settings);
setDraft((previous) =>
Object.fromEntries(Object.entries(previous).filter(([key]) => !fields.some((field) => field.key === key))),
);
};
const go = (next: SetupStep) =>
void run("save", async () => {
await save();
if (!state?.completed) setState(await requestJson<SetupState>("/setup/state", json({ step: next }, "PUT")));
setStep(next);
setNotice("Settings saved. You can return to finish setup later.");
});
const test = (app: AppDefinition) =>
void run(app.id, async () => {
await save(app.fields);
const check = await requestJson<Check>(`/status/services/${app.id}/test`, { method: "POST" });
setChecks((previous) => ({ ...previous, [app.id]: check }));
setNotice(`${app.name}: ${serviceStatusLabel(check.status)}${check.message ? `${check.message}` : ""}`);
if ((app.id === "sonarr" || app.id === "radarr") && check.status === "up") {
const choices = await requestJson<CollectorOptions>(`/admin/${app.id}/options`);
setOptions((previous) => ({ ...previous, [app.id]: choices }));
}
});
const update = (field: Field, value: string | boolean) => {
setDraft((previous) => ({ ...previous, [field.key]: value }));
setAccepted(false);
setNotice("");
const app = APPS.find((candidate) => candidate.fields.some((item) => item.key === field.key));
if (app) setChecks((previous) => ({ ...previous, [app.id]: { status: "unchecked" } }));
};
const fieldControl = (field: Field) => {
const saved = settings.some((setting) => setting.key === field.key && setting.isSet);
const collectorId = field.key.startsWith("sonarr_") ? "sonarr" : "radarr";
const choices = options[collectorId];
const profile = field.key.endsWith("_quality_profile_id") && choices?.qualityProfiles.length;
const folders = field.key.endsWith("_root_folder") && choices?.rootFolders.length;
return (
<div key={field.key} className={`${styles.field} ${field.type === "checkbox" ? styles.toggle : ""}`}>
<label htmlFor={`setup-${field.key}`}>
{field.label}
{field.type === "password" && saved && <small>Saved securely</small>}
</label>
{field.type === "checkbox" ? (
<input
id={`setup-${field.key}`}
type="checkbox"
checked={values[field.key] === true}
onChange={(event) => update(field, event.target.checked)}
disabled={!!busy}
/>
) : field.type === "textarea" ? (
<textarea
id={`setup-${field.key}`}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
rows={3}
/>
) : profile ? (
<select
id={`setup-${field.key}`}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
>
<option value="">Choose a profile</option>
{choices.qualityProfiles.map((choice) => (
<option key={choice.id} value={choice.id}>
{choice.name}
</option>
))}
</select>
) : (
<input
id={`setup-${field.key}`}
type={field.type || "text"}
value={String(values[field.key] ?? "")}
onChange={(event) => update(field, event.target.value)}
disabled={!!busy}
autoComplete={field.type === "password" ? "new-password" : "off"}
min={field.min}
max={field.max}
placeholder={
field.type === "password" && saved ? "Leave blank to keep saved credential" : field.placeholder
}
list={folders ? `options-${field.key}` : undefined}
/>
)}
{folders ? (
<datalist id={`options-${field.key}`}>
{choices.rootFolders.map((folder) => (
<option key={folder.path} value={folder.path} />
))}
</datalist>
) : null}
{field.hint && <p>{field.hint}</p>}
</div>
);
};
return (
<main className={styles.setup}>
<header className={styles.heading}>
<div className={styles.brand}>
<MagentMark />
<span>Magent / Installation</span>
</div>
<h1>Set up Magent</h1>
<p>Connect your media apps, choose your settings and make yourself at home.</p>
</header>
{!ready ? (
<p role="status">Checking installation...</p>
) : (
<>
{error && (
<p className={styles.error} role="alert">
{error}
</p>
)}
{notice && (
<p className={styles.notice} role="status">
{notice}
</p>
)}
{!status ? (
<button type="button" onClick={() => window.location.reload()}>
Retry
</button>
) : forbidden ? (
<section className={styles.panel}>
<h2>Administrator access required</h2>
<p>Ask an administrator to finish installation.</p>
<button type="button" disabled={!!busy} onClick={switchAccount}>
{busy === "switch-account" ? "Signing out..." : "Sign in with an administrator account"}
</button>
</section>
) : !admin ? (
<section className={styles.panel}>
<h2>{status.needs_admin ? "Create your administrator" : "Sign in to continue"}</h2>
<p>
{status.needs_admin ? (
<>
Enter the SETUP_TOKEN from your deployment environment. For a managed Portainer install, open the
Magent container console and run <code>python -m app.container_bootstrap setup-token</code> to
retrieve it. Only the server operator can create the first administrator; the console command stops
returning the token after that account exists.
</>
) : (
"Use your local Magent administrator account. Settings are never available to unauthenticated visitors."
)}
</p>
<form onSubmit={authenticate} className={styles.account}>
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-application-url">Public Magent URL</label>
<input
id="setup-application-url"
type="url"
autoComplete="url"
required
maxLength={2048}
value={applicationUrl}
onChange={(event) => setApplicationUrl(event.target.value)}
aria-describedby="setup-application-url-hint"
disabled={!!busy}
/>
<p id="setup-application-url-hint">
Confirm the address your users will open. Use HTTPS for internet-facing installs. This must match
the address currently open in your browser; if you plan to use another domain, open Magent there
before creating your administrator. Managed installs use this address for links and sign-in
security.
</p>
</div>
)}
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-token">Setup token</label>
<input
id="setup-token"
type="password"
autoComplete="off"
required
minLength={32}
maxLength={1024}
value={setupToken}
onChange={(event) => setSetupToken(event.target.value)}
disabled={!!busy}
/>
</div>
)}
<div className={styles.field}>
<label htmlFor="setup-username">Username</label>
<input
id="setup-username"
autoComplete="username"
required
maxLength={100}
value={username}
onChange={(event) => setUsername(event.target.value)}
disabled={!!busy}
/>
</div>
<div className={styles.field}>
<label htmlFor="setup-password">Password</label>
<input
id="setup-password"
type="password"
autoComplete={status.needs_admin ? "new-password" : "current-password"}
required
minLength={status.needs_admin ? 12 : undefined}
maxLength={1024}
value={password}
onChange={(event) => setPassword(event.target.value)}
disabled={!!busy}
/>
</div>
{status.needs_admin && (
<div className={styles.field}>
<label htmlFor="setup-confirm">Confirm password</label>
<input
id="setup-confirm"
type="password"
autoComplete="new-password"
required
minLength={12}
maxLength={1024}
value={confirmation}
onChange={(event) => setConfirmation(event.target.value)}
disabled={!!busy}
/>
<p>Use at least 12 characters and a unique password.</p>
</div>
)}
<button type="submit" disabled={!!busy}>
{busy ? "Working..." : status.needs_admin ? "Create administrator" : "Sign in to continue"}
</button>
</form>
{!status.setup_required && <a href="/login?next=/setup">Use Jellyfin sign-in instead</a>}
</section>
) : (
<>
{state?.completed ? (
<p className={styles.notice}>
This installation is already set up. You can use this guide to update its connections.{" "}
<a href="/admin">Back to settings</a>
</p>
) : (
<p className={styles.notice}>
Your administrator is ready. Background imports and automation are paused until you finish. Already
have a backup? <a href="/admin/backups">Restore it here</a>.
</p>
)}
<nav aria-label="Setup steps" className={styles.steps}>
{steps.map((item, index) => (
<button
key={item.id}
type="button"
aria-current={step === item.id ? "step" : undefined}
disabled={!!busy || item.id === "administrator"}
onClick={() => go(item.id)}
>
<span>{index + 1}</span>
{item.id === "administrator" ? "Administrator ready" : item.label}
</button>
))}
</nav>
<form
onSubmit={(event) => {
event.preventDefault();
go(step === "apps" ? "preferences" : "review");
}}
>
{step === "apps" && (
<section aria-labelledby="apps-title">
<h2 id="apps-title">Connect your apps</h2>
<p>
Each app is optional. Expand the apps you use, save and test their connections, then continue. In
Docker, localhost means the Magent container itself.
</p>
<div className={styles.apps}>
{APPS.map((app) => (
<details key={app.id} className={styles.panel}>
<summary>
<span>
<strong>{app.name}</strong>
<small>{app.description}</small>
</span>
<span className={styles.badge}>
{checks[app.id]
? serviceStatusLabel(checks[app.id].status)
: configuredApp(app, settings)
? "Configured"
: "Optional / not set up"}
</span>
</summary>
<div className={styles.fields}>{app.fields.map(fieldControl)}</div>
<button type="button" disabled={!!busy} onClick={() => test(app)}>
{busy === app.id ? "Testing..." : `Save & test ${app.name}`}
</button>
{checks[app.id]?.message && <p role="status">{checks[app.id].message}</p>}
</details>
))}
</div>
</section>
)}
{step === "preferences" && (
<section aria-labelledby="preferences-title">
<h2 id="preferences-title">Choose your preferences</h2>
<p>
Defaults are loaded from your installation. Advanced notification channels, branding and invite
policies are available in Settings afterwards.
</p>
{PREFERENCES.map((group) => (
<section key={group.title} className={styles.panel}>
<h3>{group.title}</h3>
<div className={styles.fields}>{group.fields.map(fieldControl)}</div>
</section>
))}
</section>
)}
{step === "review" && (
<section className={styles.panel} aria-labelledby="review-title">
<h2 id="review-title">Ready to finish?</h2>
<p>Unconfigured apps remain disconnected. You can change every connection later in Settings.</p>
<ul className={styles.review}>
{APPS.map((app) => (
<li key={app.id}>
<span>{app.name}</span>
<span>
{checks[app.id]
? serviceStatusLabel(checks[app.id].status)
: configuredApp(app, settings)
? "Configured (not tested this session)"
: "Not configured"}
</span>
</li>
))}
</ul>
<p>
Finishing starts the configured background imports and automation, unless disabled in your
deployment. Save an encrypted backup once you have checked the installation.
</p>
<label className={styles.confirm}>
<input
type="checkbox"
checked={accepted}
onChange={(event) => setAccepted(event.target.checked)}
disabled={!!busy}
/>
I have reviewed the connections and want to finish setup.
</label>
<p className={styles.hint}>
You may remove a manually configured SETUP_TOKEN from your environment after completion. Managed
installs keep their generated keys in the data volume; do not remove that file or volume. Existing
users and invites are preserved.
</p>
</section>
)}
<div className={styles.actions}>
{step !== "apps" && (
<button
type="button"
className="ghost-button"
disabled={!!busy}
onClick={() => go(step === "review" ? "preferences" : "apps")}
>
Back
</button>
)}
<span>{Object.keys(draft).length ? "Unsaved changes" : "Progress is saved"}</span>
{step !== "review" ? (
<button type="submit" disabled={!!busy}>
{busy === "save" ? "Saving..." : "Save & continue"}
</button>
) : (
<button
type="button"
disabled={!!busy || !accepted}
onClick={() =>
void run("finish", async () => {
await save();
await requestJson("/setup/complete", { method: "POST" });
window.location.assign("/admin");
})
}
>
{busy === "finish" ? "Finishing..." : "Finish setup"}
</button>
)}
</div>
</form>
</>
)}
</>
)}
</main>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { describe, expect, it } from "vitest";
import { APPS, bootstrapApplicationUrl, configuredApp, settingsPayload, settingsValues } from "./setup-model";
describe("first administrator application URL confirmation", () => {
it.each([
["https://magent.example.com", "https://magent.example.com"],
[" https://MAGENT.example.com:443/ ", "https://magent.example.com"],
["http://192.0.2.10:3000", "http://192.0.2.10:3000"],
["http://[fd00::10]:3000/", "http://[fd00::10]:3000"],
])("confirms a canonical same-origin address %s", (value, browserOrigin) => {
expect(bootstrapApplicationUrl(value, browserOrigin)).toBe(browserOrigin);
});
it.each([
"",
"magent.example.com",
"//magent.example.com",
"https:/magent.example.com",
"ftp://magent.example.com",
"javascript:alert(1)",
"https://user:password@magent.example.com",
"https://magent.example.com/setup",
"https://magent.example.com/../",
"https://magent.example.com?query=1",
"https://magent.example.com?",
"https://magent.example.com#fragment",
"https://magent.example.com#",
"https://magent.example.com\\path",
"https://magent.\texample.com",
])("rejects a non-origin or unsafe URL %j", (value) => {
expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow("Public Magent URL must");
});
it.each(["https://other.example.com", "http://magent.example.com", "https://magent.example.com:8443"])(
"requires the intended browser origin before claiming %s",
(value) => {
expect(() => bootstrapApplicationUrl(value, "https://magent.example.com")).toThrow(
"Open Magent at your intended address",
);
},
);
});
describe("installation settings", () => {
it("offers every supported media integration", () => {
expect(APPS.map((app) => app.id).sort()).toEqual([
"bazarr",
"jellyfin",
"jellystat",
"prowlarr",
"qbittorrent",
"radarr",
"seerr",
"sonarr",
]);
});
it("never copies saved secrets into the form or overwrites them with a blank", () => {
expect(settingsValues([{ key: "sonarr_api_key", value: "secret", sensitive: true, isSet: true }])).toEqual({
sonarr_api_key: "",
});
expect(settingsPayload({ sonarr_api_key: "", sonarr_base_url: "http://sonarr:8989" })).toEqual({
sonarr_base_url: "http://sonarr:8989",
});
});
it("sends only editable fields and validates numeric settings", () => {
expect(
settingsPayload({ jwt_secret: "no", requests_cleanup_days: "90", site_login_show_signup_link: false }),
).toEqual({ requests_cleanup_days: 90, site_login_show_signup_link: false });
expect(() => settingsPayload({ requests_cleanup_days: "-1" })).toThrow("whole number");
expect(() => settingsPayload({ sonarr_quality_profile_id: "1.5" })).toThrow("whole number");
});
it("can save just one app without accidentally saving another draft", () => {
expect(
settingsPayload(
{ sonarr_base_url: "http://sonarr:8989", radarr_api_key: "draft-secret" },
APPS.find((app) => app.id === "sonarr")?.fields,
),
).toEqual({ sonarr_base_url: "http://sonarr:8989" });
});
it("validates URL drafts even when app testing bypasses browser form validation", () => {
for (const value of [
"sonarr:8989",
"/sonarr",
"ftp://sonarr:8989",
"javascript:alert(1)",
"http://sonarr/my library",
]) {
expect(() => settingsPayload({ sonarr_base_url: value })).toThrow("HTTP or HTTPS URL");
}
expect(() => settingsPayload({ sonarr_base_url: "https://user:secret@sonarr.test" })).toThrow("credential fields");
expect(
settingsPayload({
sonarr_base_url: " http://sonarr:8989 ",
magent_application_url: "https://magent.example.test",
}),
).toEqual({ sonarr_base_url: "http://sonarr:8989", magent_application_url: "https://magent.example.test" });
expect(settingsPayload({ sonarr_base_url: "" })).toEqual({ sonarr_base_url: "" });
});
it("validates sender email and sync time before step navigation saves", () => {
for (const value of ["not-an-email", "two@@example.test", "name@example test", "Name <name@example.test>"]) {
expect(() => settingsPayload({ magent_notify_email_from_address: value })).toThrow("valid email address");
}
for (const value of ["24:00", "12:60", "2:30", "02:30:00"]) {
expect(() => settingsPayload({ requests_full_sync_time: value })).toThrow("HH:MM");
}
expect(
settingsPayload({
magent_notify_email_from_address: " alerts+admin@example.test ",
requests_full_sync_time: "23:59",
}),
).toEqual({ magent_notify_email_from_address: "alerts+admin@example.test", requests_full_sync_time: "23:59" });
expect(settingsPayload({ magent_notify_email_from_address: "", requests_full_sync_time: "" })).toEqual({
magent_notify_email_from_address: "",
requests_full_sync_time: "",
});
});
it("does not call a URL-only app configured", () => {
const app = APPS[0];
const url = { key: "jellyfin_base_url", value: "http://jellyfin:8096", sensitive: false, isSet: true };
expect(configuredApp(app, [url])).toBe(false);
expect(configuredApp(app, [url, { key: "jellyfin_api_key", value: null, sensitive: true, isSet: true }])).toBe(
true,
);
});
});
+294
View File
@@ -0,0 +1,294 @@
export type SetupStep = "administrator" | "apps" | "preferences" | "review";
export type SetupState = { completed: boolean; step: SetupStep; completed_at: string | null };
export type SetupStatus = { setup_required: boolean; needs_admin: boolean };
export type Setting = { key: string; value: unknown; sensitive: boolean; isSet: boolean };
export type Values = Record<string, string | boolean>;
export type Field = {
key: string;
label: string;
type?: "password" | "url" | "number" | "checkbox" | "email" | "time" | "textarea";
hint?: string;
placeholder?: string;
min?: number;
max?: number;
};
export type AppDefinition = { id: string; name: string; description: string; fields: Field[] };
const connection = (prefix: string, placeholder: string): Field[] => [
{
key: `${prefix}_base_url`,
label: "Server URL",
type: "url",
placeholder,
hint: "Use an address reachable from the Magent server, not your browser.",
},
{ key: `${prefix}_api_key`, label: "API key", type: "password" },
];
const collector = (prefix: string): Field[] => [
{
key: `${prefix}_quality_profile_id`,
label: "Quality profile ID",
type: "number",
min: 1,
hint: "Save and test the connection to load available profiles.",
},
{
key: `${prefix}_root_folder`,
label: "Root folder",
hint: "The library path as seen by this app, for example /tv or /movies.",
},
{
key: `${prefix}_qbittorrent_category`,
label: "Download category",
hint: "Match the category configured in the app's download client.",
},
];
export const APPS: AppDefinition[] = [
{
id: "jellyfin",
name: "Jellyfin",
description: "Playback, library availability and Jellyfin sign-in.",
fields: [
...connection("jellyfin", "http://jellyfin:8096"),
{
key: "jellyfin_public_url",
label: "Public playback URL",
type: "url",
hint: "The address your users open to watch media.",
},
{
key: "jellyfin_sync_to_arr",
label: "Sync Jellyfin library into Sonarr / Radarr",
type: "checkbox",
hint: "Optional automation. Only enable if you want Magent to reconcile these libraries.",
},
],
},
{
id: "seerr",
name: "Seerr",
description: "Requests, approvals and request history (including Jellyseerr).",
fields: connection("jellyseerr", "http://seerr:5055"),
},
{
id: "sonarr",
name: "Sonarr",
description: "TV requests, seasons and collection progress.",
fields: [...connection("sonarr", "http://sonarr:8989"), ...collector("sonarr")],
},
{
id: "radarr",
name: "Radarr",
description: "Movie requests and collection progress.",
fields: [...connection("radarr", "http://radarr:7878"), ...collector("radarr")],
},
{
id: "prowlarr",
name: "Prowlarr",
description: "Indexer searches and release discovery.",
fields: connection("prowlarr", "http://prowlarr:9696"),
},
{
id: "qbittorrent",
name: "qBittorrent",
description: "Download progress and recovery actions.",
fields: [
{ key: "qbittorrent_base_url", label: "Web UI URL", type: "url", placeholder: "http://qbittorrent:8080" },
{ key: "qbittorrent_username", label: "Username" },
{ key: "qbittorrent_password", label: "Password", type: "password" },
],
},
{
id: "bazarr",
name: "Bazarr",
description: "Optional subtitle searches and repairs.",
fields: [
...connection("bazarr", "http://bazarr:6767"),
{ key: "bazarr_default_language", label: "Default subtitle language", placeholder: "en" },
],
},
{
id: "jellystat",
name: "Jellystat",
description: "Optional personal viewing statistics.",
fields: connection("jellystat", "http://jellystat:3000"),
},
];
export const PREFERENCES: { title: string; fields: Field[] }[] = [
{
title: "Site & access",
fields: [
{
key: "magent_application_url",
label: "Public Magent URL",
type: "url",
hint: "Used in invite and notification links. Managed installs also use this address for CORS and sign-in; changing it changes the allowed browser origin. Manual installs keep their environment-configured CORS policy.",
},
{ key: "site_login_message", label: "Login page message", type: "textarea" },
{
key: "site_login_show_local_login",
label: "Show Magent account sign-in",
type: "checkbox",
hint: "Keep this enabled for local administrator access.",
},
{ key: "site_login_show_jellyfin_login", label: "Show Jellyfin sign-in", type: "checkbox" },
{
key: "site_login_show_signup_link",
label: "Show invite signup link",
type: "checkbox",
hint: "Account creation still requires a valid invite. This does not open public registration.",
},
],
},
{
title: "Request updates",
fields: [
{ key: "requests_poll_interval_seconds", label: "Request polling interval (seconds)", type: "number", min: 1 },
{
key: "requests_delta_sync_interval_minutes",
label: "Incremental sync interval (minutes)",
type: "number",
min: 1,
},
{ key: "requests_full_sync_time", label: "Daily full sync time (server timezone)", type: "time" },
{ key: "requests_cleanup_days", label: "History retention (days)", type: "number", min: 1 },
],
},
{
title: "Email (optional)",
fields: [
{ key: "magent_notify_enabled", label: "Enable notifications", type: "checkbox" },
{
key: "magent_notify_email_enabled",
label: "Enable email delivery",
type: "checkbox",
hint: "Used for invites, password resets and issue updates. Configure SMTP before enabling.",
},
{ key: "magent_notify_email_smtp_host", label: "SMTP hostname" },
{ key: "magent_notify_email_smtp_port", label: "SMTP port", type: "number", min: 1, max: 65535 },
{ key: "magent_notify_email_smtp_username", label: "SMTP username" },
{ key: "magent_notify_email_smtp_password", label: "SMTP password", type: "password" },
{ key: "magent_notify_email_from_address", label: "Sender email", type: "email" },
{ key: "magent_notify_email_from_name", label: "Sender name" },
{ key: "magent_notify_email_use_tls", label: "Use STARTTLS (usually port 587)", type: "checkbox" },
{
key: "magent_notify_email_use_ssl",
label: "Use implicit TLS (usually port 465)",
type: "checkbox",
hint: "Choose either STARTTLS or implicit TLS, not both.",
},
],
},
];
export const ALL_FIELDS = [...APPS.flatMap((app) => app.fields), ...PREFERENCES.flatMap((group) => group.fields)];
export function bootstrapApplicationUrl(value: string, browserOrigin: string): string {
const configured = value.trim();
let url: URL;
try {
url = new URL(configured);
} catch {
throw new Error("Public Magent URL must be a full HTTP or HTTPS origin, for example https://magent.example.com.");
}
if (
!/^https?:\/\/[^/?#]+\/?$/i.test(configured) ||
/[\s\\]/.test(configured) ||
!["http:", "https:"].includes(url.protocol) ||
!url.hostname ||
url.username ||
url.password ||
url.pathname !== "/" ||
url.search ||
url.hash
) {
throw new Error(
"Public Magent URL must be an HTTP or HTTPS origin without credentials, a path, query or fragment.",
);
}
if (url.origin !== browserOrigin) {
throw new Error(
"Public Magent URL must match the address open in this browser. Open Magent at your intended address, then confirm it and create the administrator there.",
);
}
return url.origin;
}
export function settingsValues(settings: Setting[]): Values {
const values: Values = {};
for (const field of ALL_FIELDS) {
const setting = settings.find((candidate) => candidate.key === field.key);
if (!setting) continue;
values[field.key] =
field.type === "password" || setting.sensitive
? ""
: field.type === "checkbox"
? setting.value === true || setting.value === "true" || setting.value === "1"
: String(setting.value ?? "");
}
return values;
}
// Only explicitly edited fields are sent. A blank password never clears a saved
// secret (masked values from the settings endpoint are not actual credentials).
export function settingsPayload(
draft: Values,
fields: Field[] = ALL_FIELDS,
): Record<string, string | boolean | number> {
const payload: Record<string, string | boolean | number> = {};
for (const field of fields) {
const value = draft[field.key];
if (value === undefined || (field.type === "password" && !String(value).trim())) continue;
if (field.type === "url" || field.type === "email" || field.type === "time") {
const text = String(value).trim();
if (text && field.type === "url") {
let url: URL;
try {
url = new URL(text);
} catch {
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
}
if (
!/^https?:\/\//i.test(text) ||
!["http:", "https:"].includes(url.protocol) ||
!url.hostname ||
/\s/.test(text)
) {
throw new Error(`${field.label} must be a full HTTP or HTTPS URL.`);
}
if (url.username || url.password)
throw new Error(`${field.label} must not include a username or password. Use the credential fields instead.`);
}
if (
text &&
field.type === "email" &&
!/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(
text,
)
) {
throw new Error(`${field.label} must be a valid email address.`);
}
if (text && field.type === "time" && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(text)) {
throw new Error(`${field.label} must be a valid time in HH:MM format.`);
}
payload[field.key] = text;
} else if (field.type === "number" && value !== "") {
const number = Number(value);
if (!Number.isInteger(number) || number < (field.min ?? 0) || number > (field.max ?? Number.MAX_SAFE_INTEGER)) {
throw new Error(
`${field.label} must be a whole number between ${field.min ?? 0} and ${field.max ?? Number.MAX_SAFE_INTEGER}.`,
);
}
payload[field.key] = number;
} else payload[field.key] = value;
}
return payload;
}
export function configuredApp(app: AppDefinition, settings: Setting[]): boolean {
return app.fields
.filter((field) => field.key.endsWith("_base_url") || field.type === "password")
.every((field) => settings.some((setting) => setting.key === field.key && setting.isSet));
}
+49
View File
@@ -0,0 +1,49 @@
.setup { max-width: 1020px; margin: 36px auto 72px; padding: 0 20px; color: var(--ops-text); }
.heading { margin-bottom: 30px; }
.heading h1 { font-size: clamp(28px, 4vw, 42px); margin: 18px 0 10px; }
.setup p { color: var(--ops-muted); line-height: 1.6; }
.brand { display: flex; align-items: center; gap: 12px; color: var(--ops-primary-2); font-size: 13px; }
.brand svg { width: 38px; height: 38px; }
.panel { padding: 24px; margin: 16px 0; border: 1px solid var(--ops-line); border-radius: 12px; background: var(--ops-panel); min-width: 0; }
.panel h2, .panel h3 { margin-top: 0; }
.panel summary { display: flex; align-items: center; justify-content: space-between; gap: 16px; cursor: pointer; list-style: none; }
.panel summary::after { content: "+"; color: var(--ops-primary-2); }
.panel[open] summary::after { content: ""; }
.panel summary > span:first-child { flex: 1; }
.panel summary strong { display: block; font-size: 17px; }
.panel summary small { display: block; margin-top: 6px; color: var(--ops-muted); line-height: 1.5; }
.panel[open] summary { margin-bottom: 24px; }
.badge { font-size: 12px; color: var(--ops-primary-2); }
.fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; margin-bottom: 24px; }
.field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.field label { color: var(--ops-text); font-size: 13px; }
.field label small { margin-left: 8px; color: var(--ops-green); }
.field p, .hint { font-size: 12px; margin: 0; }
.field input:not([type=checkbox]), .field textarea, .field select { width: 100%; min-width: 0; padding: 11px 12px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); border-radius: 8px; font: inherit; font-size: 14px; }
.field textarea { resize: vertical; }
.toggle { display: grid; grid-template-columns: 1fr auto; align-content: start; align-items: center; }
.toggle p { grid-column: 1 / -1; }
.toggle input, .confirm input { width: 18px; height: 18px; accent-color: var(--ops-primary-2); flex-shrink: 0; }
.account { display: grid; gap: 20px; max-width: 440px; margin: 24px 0; }
.steps { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 30px; }
.steps button { flex: 1; display: flex; align-items: center; gap: 10px; padding: 14px; background: var(--ops-panel); color: var(--ops-muted); border: 1px solid var(--ops-line); box-shadow: none; }
.steps button[aria-current=step] { border-color: var(--ops-primary-2); color: var(--ops-primary-2); }
.steps button span { font-size: 12px; }
.actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 28px; padding-top: 20px; border-top: 1px solid var(--ops-line); }
.actions > span { flex: 1; color: var(--ops-muted); font-size: 12px; }
.error, .notice { padding: 16px 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-bg-2); overflow-wrap: anywhere; }
.setup .error { border-color: var(--ops-red); color: var(--ops-red); }
.review { list-style: none; padding: 0; margin: 24px 0; }
.review li { display: flex; justify-content: space-between; gap: 20px; padding: 12px 0; border-bottom: 1px solid var(--ops-line); }
.review li span:last-child { font-size: 13px; color: var(--ops-muted); text-align: right; }
.confirm { display: flex; align-items: center; gap: 12px; margin: 24px 0; }
.setup :is(button, input, textarea, select, a, summary):focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 3px; }
.setup button:disabled { opacity: .6; cursor: not-allowed; }
@media (max-width: 640px) {
.setup { margin-top: 20px; padding: 0 4px; }
.fields { grid-template-columns: 1fr; gap: 20px; }
.panel { padding: 18px; }
.steps button { flex-basis: 42%; font-size: 12px; }
.badge { max-width: 100px; text-align: right; }
.panel summary { gap: 10px; }
}
+265
View File
@@ -0,0 +1,265 @@
"use client";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import AuthLayout from "../ui/AuthLayout";
import { clearToken, getApiBase, setToken } from "../lib/auth";
type InviteInfo = {
code: string;
email_bound?: boolean;
label?: string | null;
description?: string | null;
enabled: boolean;
is_expired?: boolean;
is_usable?: boolean;
expires_at?: string | null;
max_uses?: number | null;
use_count?: number | null;
remaining_uses?: number | null;
profile?: {
id: number;
name: string;
description?: string | null;
} | null;
};
const formatDate = (value?: string | null) => {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString();
};
function SignupPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [inviteCode, setInviteCode] = useState(searchParams.get("code") ?? "");
const [invite, setInvite] = useState<InviteInfo | null>(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const canSubmit = useMemo(() => {
return Boolean(
invite?.is_usable &&
(invite.email_bound || email.trim()) &&
username.trim() &&
password &&
!loading &&
!inviteLoading,
);
}, [invite, email, username, password, loading, inviteLoading]);
const lookupInvite = useCallback(async (code: string) => {
const trimmed = code.trim();
if (!trimmed) {
setInvite(null);
return;
}
setInviteLoading(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`);
if (!response.ok) {
const text = await response.text();
throw new Error(text || "Invite not found");
}
const data = await response.json();
setInvite(data?.invite ?? null);
setStatus("Invite loaded.");
} catch (err) {
console.error(err);
setInvite(null);
setError("Invite code not found or unavailable.");
} finally {
setInviteLoading(false);
}
}, []);
useEffect(() => {
const initialCode = searchParams.get("code") ?? "";
if (initialCode) {
setInviteCode(initialCode);
void lookupInvite(initialCode);
}
}, [lookupInvite, searchParams]);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
if (!inviteCode.trim()) {
setError("Invite code is required.");
return;
}
if (!invite?.is_usable) {
setError("Invite is not usable. Refresh invite details or ask an admin for a new code.");
return;
}
setLoading(true);
setError(null);
setStatus(null);
try {
clearToken();
const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
invite_code: inviteCode,
username: username.trim(),
...(!invite.email_bound ? { email: email.trim() } : {}),
password,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || "Sign-up failed");
}
const data = await response.json();
if (data?.authenticated) {
setToken("cookie");
window.location.href = "/welcome";
return;
}
throw new Error("Sign-up did not complete");
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Unable to create account.");
} finally {
setLoading(false);
}
};
return (
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
<form onSubmit={submit} className="account-form login-form auth-flow-form">
<label>
Invite code
<div className="invite-lookup-row">
<input
value={inviteCode}
onChange={(e) => {
setInviteCode(e.target.value);
setInvite(null);
setEmail("");
}}
placeholder="Paste your invite code"
autoCapitalize="characters"
/>
<button
type="button"
className="ghost-button"
disabled={inviteLoading}
onClick={() => void lookupInvite(inviteCode)}
>
{inviteLoading ? "Checking…" : "Check invite"}
</button>
</div>
</label>
{invite && (
<div className={`invite-summary ${invite.is_usable ? "" : "is-disabled"}`}>
<div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? "" : "is-muted"}`}>
{invite.is_usable ? "Ready" : "Unavailable"}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<details className="auth-invite-details">
<summary>Invite details</summary>
<div className="admin-meta-row">
<span>Code: {invite.code}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Remaining uses: {invite.remaining_uses ?? "Unlimited"}</span>
<span>Profile: {invite.profile?.name || "None"}</span>
</div>
</details>
</div>
)}
{invite?.email_bound ? (
<p className="account-hint">
Your account will use the email address this invitation was sent to. This invitation can be used once.
</p>
) : (
<label>
Email address
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
placeholder="you@example.com"
/>
</label>
)}
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
{error && (
<div className="account-notice is-error" role="alert">
{error}
</div>
)}
{status && (
<div className="account-notice is-status" role="status">
{status}
</div>
)}
<div className="auth-actions">
<button type="submit" className="account-primary" disabled={!canSubmit}>
{loading ? "Creating account…" : "Create account"}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push("/login")}>
Back to sign in
</button>
</form>
</AuthLayout>
);
}
export default function SignupPage() {
return (
<Suspense
fallback={
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
<p role="status">Loading sign-up</p>
</AuthLayout>
}
>
<SignupPageContent />
</Suspense>
);
}
+48
View File
@@ -0,0 +1,48 @@
:root,
[data-theme='dark'],
[data-theme='light'] {
color-scheme: dark;
--ops-bg: #131315;
--ops-bg-2: #0e0e10;
--ops-panel: #1c1b1d;
--ops-panel-2: #201f21;
--ops-panel-3: #2a2a2c;
--ops-line: #46464d;
--ops-line-soft: rgba(145, 144, 152, 0.24);
--ops-text: #e5e1e4;
--ops-muted: #c7c5ce;
--ops-faint: #919098;
--ops-primary: #090d25;
--ops-primary-2: #c2c4e5;
--ops-cyan: #22d3ee;
--ops-cyan-2: #3b82f6;
--ops-coral: #ffb5a0;
--ops-green: #14b8a6;
--ops-red: #ef4444;
--ops-warn: #f59e0b;
--ops-radius-sm: 4px;
--ops-radius: 8px;
--ops-radius-lg: 12px;
--workspace-width: 1440px;
--workspace-gutter: 32px;
--workspace-gap: 24px;
--ink: var(--ops-text);
--ink-muted: var(--ops-muted);
--paper: var(--ops-bg);
--paper-strong: var(--ops-panel);
--accent: var(--ops-coral);
--accent-2: var(--ops-primary);
--accent-3: var(--ops-cyan);
--border: var(--ops-line-soft);
--shadow: transparent;
--glow: 0 0 0 1px rgba(126, 215, 255, 0.16);
--input-bg: #0e0e10;
--input-ink: var(--ops-text);
--line: var(--ops-line-soft);
--panel: var(--ops-panel);
--panel-soft: rgba(255, 255, 255, 0.035);
--text: var(--ops-text);
--muted: var(--ops-muted);
--error-bg: rgba(122, 36, 53, 0.44);
--error-ink: #ffd6d6;
}
+530
View File
@@ -0,0 +1,530 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
type DiagnosticCatalogItem = {
key: string;
label: string;
category: string;
description: string;
live_safe: boolean;
target: string | null;
configured: boolean;
config_status: string;
config_detail: string;
};
type DiagnosticResult = {
key: string;
label: string;
category: string;
description: string;
target: string | null;
live_safe: boolean;
configured: boolean;
status: string;
message: string;
detail?: unknown;
checked_at?: string;
duration_ms?: number;
};
type DiagnosticsResponse = {
checks: DiagnosticCatalogItem[];
categories: string[];
generated_at: string;
};
type RunDiagnosticsResponse = {
results: DiagnosticResult[];
summary: {
total: number;
up: number;
down: number;
degraded: number;
not_configured: number;
disabled: number;
};
checked_at: string;
};
type RunMode = "safe" | "all" | "single";
type AdminDiagnosticsPanelProps = {
embedded?: boolean;
};
type DatabaseDiagnosticDetail = {
integrity_check?: string;
database_path?: string;
database_size_bytes?: number;
wal_size_bytes?: number;
shm_size_bytes?: number;
page_size_bytes?: number;
page_count?: number;
freelist_pages?: number;
allocated_bytes?: number;
free_bytes?: number;
row_counts?: Record<string, number>;
timings_ms?: Record<string, number>;
};
const REFRESH_INTERVAL_MS = 30000;
const STATUS_LABELS: Record<string, string> = {
idle: "Ready",
up: "Up",
down: "Down",
degraded: "Degraded",
disabled: "Disabled",
not_configured: "Not configured",
};
function formatCheckedAt(value?: string) {
if (!value) return "Not yet run";
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
function formatDuration(value?: number) {
if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
return "Pending";
}
return `${value.toFixed(1)} ms`;
}
function statusLabel(status: string) {
return STATUS_LABELS[status] ?? status;
}
function formatBytes(value?: number) {
if (typeof value !== "number" || Number.isNaN(value) || value < 0) {
return "0 B";
}
if (value >= 1024 * 1024 * 1024) {
return `${(value / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(2)} MB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KB`;
}
return `${value} B`;
}
function formatDetailLabel(value: string) {
return value.replace(/_/g, " ").replace(/\b\w/g, (character) => character.toUpperCase());
}
function asDatabaseDiagnosticDetail(detail: unknown): DatabaseDiagnosticDetail | null {
if (!detail || typeof detail !== "object" || Array.isArray(detail)) {
return null;
}
return detail as DatabaseDiagnosticDetail;
}
function renderDatabaseMetricGroup(title: string, values: Array<[string, string]>) {
if (values.length === 0) {
return null;
}
return (
<div className="diagnostic-detail-group">
<h4>{title}</h4>
<div className="diagnostic-detail-grid">
{values.map(([label, value]) => (
<div key={`${title}-${label}`} className="diagnostic-detail-item">
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</div>
);
}
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
const router = useRouter();
const [loading, setLoading] = useState(true);
const [authorized, setAuthorized] = useState(false);
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([]);
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({});
const [runningKeys, setRunningKeys] = useState<string[]>([]);
const [autoRefresh, setAutoRefresh] = useState(true);
const [pageError, setPageError] = useState("");
const [lastRunAt, setLastRunAt] = useState<string | null>(null);
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null);
const [emailRecipient, setEmailRecipient] = useState("");
const liveSafeKeys = useMemo(() => checks.filter((check) => check.live_safe).map((check) => check.key), [checks]);
const runDiagnostics = useCallback(
async (keys?: string[], mode: RunMode = "single") => {
const baseUrl = getApiBase();
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key);
if (effectiveKeys.length === 0) {
return;
}
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])));
setPageError("");
try {
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
keys: effectiveKeys,
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
}),
});
if (response.status === 401) {
clearToken();
router.push("/login");
return;
}
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Diagnostics run failed: ${response.status}`);
}
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse;
const nextResults: Record<string, DiagnosticResult> = {};
for (const result of data.results ?? []) {
nextResults[result.key] = result;
}
setResultsByKey((current) => ({ ...current, ...nextResults }));
setLastRunAt(data.checked_at ?? new Date().toISOString());
setLastRunMode(mode);
} catch (error) {
console.error(error);
setPageError(error instanceof Error ? error.message : "Diagnostics run failed.");
} finally {
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)));
}
},
[checks, emailRecipient, router],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: Authorization bootstrap runs once for each router instance.
useEffect(() => {
let active = true;
const loadPage = async () => {
if (!getToken()) {
router.push("/login");
return;
}
try {
const baseUrl = getApiBase();
const authResponse = await authFetch(`${baseUrl}/auth/me`);
if (!authResponse.ok) {
if (authResponse.status === 401) {
clearToken();
router.push("/login");
return;
}
router.push("/");
return;
}
const me = await authResponse.json();
if (!active) return;
if (me?.role !== "admin") {
router.push("/");
return;
}
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`);
if (!diagnosticsResponse.ok) {
const text = await diagnosticsResponse.text();
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`);
}
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse;
if (!active) return;
setChecks(data.checks ?? []);
setAuthorized(true);
setLoading(false);
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key);
if (safeKeys.length > 0) {
void runDiagnostics(safeKeys, "safe");
}
} catch (error) {
console.error(error);
if (!active) return;
setPageError(error instanceof Error ? error.message : "Unable to load diagnostics.");
setLoading(false);
}
};
void loadPage();
return () => {
active = false;
};
}, [router]);
useEffect(() => {
if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
return;
}
const interval = window.setInterval(() => {
void runDiagnostics(liveSafeKeys, "safe");
}, REFRESH_INTERVAL_MS);
return () => {
window.clearInterval(interval);
};
}, [authorized, autoRefresh, liveSafeKeys, runDiagnostics]);
if (loading) {
return <div className="admin-panel">Loading diagnostics...</div>;
}
if (!authorized) {
return null;
}
const orderedCategories: string[] = [];
for (const check of checks) {
if (!orderedCategories.includes(check.category)) {
orderedCategories.push(check.category);
}
}
const mergedResults = checks.map((check) => {
const result = resultsByKey[check.key];
if (result) {
return result;
}
return {
key: check.key,
label: check.label,
category: check.category,
description: check.description,
target: check.target,
live_safe: check.live_safe,
configured: check.configured,
status: check.configured ? "idle" : check.config_status,
message: check.configured ? "Ready to test." : check.config_detail,
checked_at: undefined,
duration_ms: undefined,
} satisfies DiagnosticResult;
});
const summary = {
total: mergedResults.length,
up: 0,
down: 0,
degraded: 0,
disabled: 0,
not_configured: 0,
idle: 0,
};
for (const result of mergedResults) {
const key = result.status as keyof typeof summary;
if (key in summary) {
summary[key] += 1;
}
}
return (
<div className={`diagnostics-page${embedded ? " diagnostics-page-embedded" : ""}`}>
<div className="admin-panel diagnostics-control-panel">
<div className="diagnostics-control-copy">
<h2>{embedded ? "Connectivity diagnostics" : "Control center"}</h2>
<p className="lede">
Check Magent and your connected services. Automatic refresh runs health checks only. Test messages are
managed in Notifications below.
</p>
</div>
<div className="diagnostics-control-actions">
<button
type="button"
className={autoRefresh ? "is-active" : ""}
onClick={() => setAutoRefresh((current) => !current)}
>
{autoRefresh ? "Disable auto refresh" : "Enable auto refresh"}
</button>
<button
type="button"
onClick={() => {
void runDiagnostics(liveSafeKeys, "safe");
}}
disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
>
Run live checks
</button>
<span className={`small-pill ${autoRefresh ? "is-positive" : ""}`}>
{autoRefresh ? "Auto refresh on" : "Auto refresh off"}
</span>
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : "No run yet"}</span>
</div>
</div>
<div className="admin-panel diagnostics-inline-summary">
<div className="diagnostics-inline-metric">
<span>Total</span>
<strong>{summary.total}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Up</span>
<strong>{summary.up}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Degraded</span>
<strong>{summary.degraded}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Down</span>
<strong>{summary.down}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Disabled</span>
<strong>{summary.disabled}</strong>
</div>
<div className="diagnostics-inline-metric">
<span>Not configured</span>
<strong>{summary.not_configured}</strong>
</div>
<div className="diagnostics-inline-last-run">Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}</div>
</div>
{pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null}
{orderedCategories.map((category) => {
const categoryChecks = mergedResults.filter((check) => check.category === category);
return (
<div key={category} className="admin-panel diagnostics-category-panel">
<div className="diagnostics-category-header">
<div>
<h2>{category}</h2>
<p>
{category === "Notifications" ? "These tests can emit real messages." : "Safe live health checks."}
</p>
</div>
<span className="small-pill">{categoryChecks.length} checks</span>
</div>
{category === "Notifications" && (
<div className="diagnostics-notification-controls">
<label className="diagnostics-email-recipient">
<span>Test email recipient</span>
<input
type="email"
placeholder="Leave blank to use configured sender"
value={emailRecipient}
onChange={(event) => setEmailRecipient(event.target.value)}
/>
</label>
<p>Choose where the test email goes. Other channels use their configured destinations.</p>
<button
type="button"
disabled={runningKeys.length > 0 || categoryChecks.length === 0}
onClick={() =>
void runDiagnostics(
categoryChecks.map((check) => check.key),
"all",
)
}
>
Test all notification channels
</button>
</div>
)}
<div className="diagnostics-grid">
{categoryChecks.map((check) => {
const isRunning = runningKeys.includes(check.key);
return (
<article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}>
<div className="diagnostic-card-top">
<div className="diagnostic-card-copy">
<div className="diagnostic-card-title-row">
<h3>{check.label}</h3>
<span className={`system-pill system-pill-${check.status}`}>{statusLabel(check.status)}</span>
</div>
<p>{check.description}</p>
</div>
<button
type="button"
className="system-test"
onClick={() => {
void runDiagnostics([check.key], "single");
}}
disabled={isRunning}
>
{check.live_safe ? "Ping" : "Send test"}
</button>
</div>
<div className="diagnostic-meta-grid">
<div className="diagnostic-meta-item">
<span>Target</span>
<strong>{check.target || "Not set"}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Latency</span>
<strong>{formatDuration(check.duration_ms)}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Mode</span>
<strong>{check.live_safe ? "Live safe" : "Manual only"}</strong>
</div>
<div className="diagnostic-meta-item">
<span>Last checked</span>
<strong>{formatCheckedAt(check.checked_at)}</strong>
</div>
</div>
<div className={`diagnostic-message diagnostic-message-${check.status}`}>
<span className="system-dot" />
<span>{isRunning ? "Running diagnostic..." : check.message}</span>
</div>
{check.key === "database"
? (() => {
const detail = asDatabaseDiagnosticDetail(check.detail);
if (!detail) {
return null;
}
return (
<details className="diagnostic-detail-panel">
<summary>Database storage, tables and timings</summary>
{renderDatabaseMetricGroup("Storage", [
["Database file", formatBytes(detail.database_size_bytes)],
["WAL file", formatBytes(detail.wal_size_bytes)],
["Shared memory", formatBytes(detail.shm_size_bytes)],
["Allocated bytes", formatBytes(detail.allocated_bytes)],
["Free bytes", formatBytes(detail.free_bytes)],
["Page size", formatBytes(detail.page_size_bytes)],
["Page count", `${detail.page_count?.toLocaleString() ?? 0}`],
["Freelist pages", `${detail.freelist_pages?.toLocaleString() ?? 0}`],
])}
{renderDatabaseMetricGroup(
"Tables",
Object.entries(detail.row_counts ?? {}).map(([key, value]) => [
formatDetailLabel(key),
value.toLocaleString(),
]),
)}
{renderDatabaseMetricGroup(
"Timings",
Object.entries(detail.timings_ms ?? {}).map(([key, value]) => [
formatDetailLabel(key),
`${value.toFixed(1)} ms`,
]),
)}
</details>
);
})()
: null}
</article>
);
})}
</div>
</div>
);
})}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import type { ReactNode } from "react";
import SettingsNavigation from "./SettingsNavigation";
import PageHeading from "./PageHeading";
type AdminShellProps = {
title: string;
subtitle?: string;
actions?: ReactNode;
rail?: ReactNode;
children: ReactNode;
};
export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
return (
<div className="admin-shell admin-shell--top-nav">
<SettingsNavigation />
<main className="card admin-card">
<PageHeading title={title} description={subtitle} actions={actions} />
{children}
{rail && (
<details className="admin-supplemental">
<summary>Additional information</summary>
{rail}
</details>
)}
</main>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { usePathname } from "next/navigation";
import type { ReactNode } from "react";
import { isAdminPage } from "../lib/user-view-policy";
import { setUserViewPreview, useUserViewState } from "../lib/viewMode";
export default function AdminViewGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { enabled, ready } = useUserViewState();
if (!isAdminPage(pathname)) return children;
if (!ready)
return (
<main className="card" role="status">
Checking view mode...
</main>
);
if (!enabled) return children;
return (
<main className="card">
<h1>Administrator tools are hidden</h1>
<p>Configuration, user management and other admin tools are unavailable while previewing user view.</p>
<p>Your account is unchanged. Exit the preview to return to this page.</p>
<div className="config-inline-controls">
<a href="/">Go to My Requests</a>
<button type="button" onClick={() => setUserViewPreview(false)}>
Exit user view
</button>
</div>
</main>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { usePathname } from "next/navigation";
import BrandingLogo from "./BrandingLogo";
import HeaderActions from "./HeaderActions";
import HeaderIdentity from "./HeaderIdentity";
import GlobalSearch from "./GlobalSearch";
import SiteStatus from "./SiteStatus";
import UserViewBanner from "./UserViewBanner";
import WorkspaceNavigation from "./WorkspaceNavigation";
export default function ApplicationChrome() {
const pathname = usePathname();
if (
[
"/welcome",
"/coming-soon",
"/login",
"/setup",
"/forgot-password",
"/reset-password",
"/signup",
"/email-recaps",
"/newsletter-subscription",
].includes(pathname)
)
return null;
return (
<>
<header className="header">
<div className="header-left">
<a className="brand-link" href="/">
<BrandingLogo className="brand-logo brand-logo--header" />
<div className="brand-stack">
<div className="brand">Magent</div>
<div className="tagline">Your media operations</div>
</div>
</a>
</div>
<div className="header-right">
<HeaderIdentity />
</div>
<div className="header-nav">
<GlobalSearch />
<HeaderActions />
</div>
</header>
<WorkspaceNavigation />
<UserViewBanner />
<SiteStatus />
</>
);
}
+34
View File
@@ -0,0 +1,34 @@
import type { ReactNode } from "react";
import MagentMark from "./MagentMark";
export default function AuthLayout({
title,
description,
children,
footer,
}: {
title: string;
description: string;
children: ReactNode;
footer?: ReactNode;
}) {
return (
<main className="login-page">
<section className="login-card" aria-labelledby="login-title">
<div className="login-brand">
<a href="/login" aria-label="Magent sign in">
<MagentMark />
<span>Magent</span>
</a>
</div>
<header>
<h1 id="login-title">{title}</h1>
<p>{description}</p>
</header>
{children}
{footer && <footer>{footer}</footer>}
</section>
<p className="login-credit">Magent · Request. Watch. Enjoy.</p>
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
export default function BrandingFavicon() {
useEffect(() => {
const href = "/api/branding/favicon.ico";
let link = document.querySelector("link[rel='icon']") as HTMLLinkElement | null;
if (!link) {
link = document.createElement("link");
link.rel = "icon";
document.head.appendChild(link);
}
link.href = href;
}, []);
return null;
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
type BrandingLogoProps = {
className?: string;
alt?: string;
};
export default function BrandingLogo({ className, alt = "Magent logo" }: BrandingLogoProps) {
const [loaded, setLoaded] = useState(false);
const [failed, setFailed] = useState(false);
return (
<span className={`${className ?? ""} branding-logo-shell`} role="img" aria-label={alt}>
{!failed ? (
<img
className={loaded ? "is-loaded" : undefined}
src="/api/branding/logo.png"
alt=""
aria-hidden="true"
onLoad={() => setLoaded(true)}
onError={() => setFailed(true)}
/>
) : null}
{!loaded ? (
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
<defs>
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#7ed7ff" />
<stop offset="100%" stopColor="#c6c1ff" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="#0b1328" />
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
<path d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z" fill="url(#magentLogoGlow)" />
</svg>
) : null}
</span>
);
}
+71
View File
@@ -0,0 +1,71 @@
"use client";
import { usePathname } from "next/navigation";
import { useEffect, useState, type ReactNode } from "react";
import { authFetch, getApiBase, getToken } from "../lib/auth";
import { canAccess, featureForPath, type FeatureAccess } from "../lib/features";
import { useEffectiveRole } from "../lib/viewMode";
import { isAdminPage } from "../lib/user-view-policy";
export function useFeatureUser() {
const pathname = usePathname();
const [state, setState] = useState<{
path: string;
user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null;
}>({ path: "", user: null });
const role = useEffectiveRole(state.user?.role);
useEffect(() => {
let active = true;
const load = async () => {
if (!getToken()) {
if (active) setState({ path: pathname, user: null });
return;
}
try {
const response = await authFetch(`${getApiBase()}/auth/me`);
const user = response.ok ? await response.json() : null;
if (active) setState({ path: pathname, user });
} catch {
if (active) setState({ path: pathname, user: null });
}
};
void load();
window.addEventListener("focus", load);
return () => {
active = false;
window.removeEventListener("focus", load);
};
}, [pathname]);
return { user: state.user ? { ...state.user, role: role ?? undefined } : null, ready: state.path === pathname };
}
export default function FeatureGate({ children }: { children: ReactNode }) {
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const feature = featureForPath(pathname);
if (isAdminPage(pathname, false)) {
if (!ready) return <main className="card">Checking administrator access...</main>;
if (user?.role !== "admin") {
return (
<main className="card">
<h1>Administrator access required</h1>
<p>Sign in with an administrator account to use configuration and administration tools.</p>
<a href="/login">Sign in</a>
</main>
);
}
return children;
}
if (!feature) return children;
if (!ready) return <main className="card">Loading account access...</main>;
if (!getToken()) return children;
if (!canAccess(user, feature))
return (
<main className="card">
<h1>Feature unavailable</h1>
<p>Your account does not have access to this feature. Ask an administrator if you need it enabled.</p>
<a href="/profile">Go to my profile</a>
</main>
);
return children;
}
+179
View File
@@ -0,0 +1,179 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { authFetch, getApiBase } from "../lib/auth";
import { canAccess } from "../lib/features";
import { useFeatureUser } from "./FeatureGate";
type SearchResult = {
title: string;
year?: number | null;
type: "movie" | "tv";
tmdbId: number;
requestId?: number | null;
statusLabel?: string | null;
};
export default function GlobalSearch() {
const router = useRouter();
const { user, ready } = useFeatureUser();
const root = useRef<HTMLDivElement>(null);
const requestVersion = useRef(0);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [open, setOpen] = useState(false);
const [searching, setSearching] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const canSearch = canAccess(user, "new_requests") || canAccess(user, "issues");
const canOpenRequests = canAccess(user, "requests");
const canCreateRequests = canAccess(user, "new_requests");
useEffect(() => {
const close = (event: PointerEvent) => {
if (!root.current?.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", close);
return () => document.removeEventListener("pointerdown", close);
}, []);
useEffect(() => {
const term = query.trim();
requestVersion.current += 1;
const version = requestVersion.current;
if (term.length < 2 || !canSearch) {
setResults([]);
setSearching(false);
setMessage(null);
return;
}
setSearching(true);
setMessage(null);
const controller = new AbortController();
const timer = window.setTimeout(async () => {
try {
const params = new URLSearchParams({ query: term });
const response = await authFetch(`${getApiBase()}/requests/search?${params.toString()}`, {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) throw new Error("Search is unavailable right now.");
const payload = await response.json();
if (version !== requestVersion.current) return;
const mapped = (Array.isArray(payload?.results) ? payload.results : [])
.filter(
(item: Record<string, unknown>) =>
typeof item.type === "string" && ["movie", "tv"].includes(item.type) && Number(item.tmdbId) > 0,
)
.slice(0, 7)
.map(
(item: Record<string, unknown>): SearchResult => ({
title: String(item?.title || "Untitled"),
year: typeof item?.year === "number" ? item.year : null,
type: item.type === "tv" ? "tv" : "movie",
tmdbId: Number(item.tmdbId),
requestId: typeof item?.requestId === "number" ? item.requestId : null,
statusLabel: typeof item?.statusLabel === "string" ? item.statusLabel : null,
}),
);
setResults(mapped);
setMessage(mapped.length ? null : "No matching titles found.");
} catch (error) {
if (controller.signal.aborted || version !== requestVersion.current) return;
console.error(error);
setResults([]);
setMessage("Search is unavailable right now.");
} finally {
if (version === requestVersion.current) setSearching(false);
}
}, 280);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [query, canSearch]);
if (!ready || !user || !canSearch) return null;
const openResult = (result: SearchResult) => {
setOpen(false);
setQuery("");
if (result.requestId && canOpenRequests) {
router.push(`/requests/${result.requestId}`);
return;
}
if (canCreateRequests) {
const params = new URLSearchParams({ type: result.type, query: result.title });
router.push(`/new-requests?${params.toString()}`);
return;
}
router.push("/portal/issues");
};
const showResults = open && query.trim().length >= 2;
return (
<div className="global-search" ref={root}>
<search>
<form
onSubmit={(event) => {
event.preventDefault();
if (results[0]) openResult(results[0]);
}}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="m16 16 4 4" />
</svg>
<input
type="search"
role="combobox"
value={query}
placeholder="Search titles or requests"
aria-label="Search titles or requests"
aria-autocomplete="list"
aria-expanded={showResults}
aria-controls="global-search-results"
onFocus={() => setOpen(true)}
onKeyDown={(event) => {
if (event.key === "Escape") setOpen(false);
}}
onChange={(event) => {
setQuery(event.target.value);
setOpen(true);
}}
/>
{searching && <i className="global-search-spinner" aria-hidden="true" />}
</form>
</search>
{showResults && (
<div className="global-search-results" id="global-search-results" role="listbox">
{results.map((result) => (
<button
type="button"
role="option"
aria-selected="false"
key={`${result.type}:${result.tmdbId}`}
onClick={() => openResult(result)}
>
<span>
<strong>{result.title}</strong>
<small>
{result.type === "tv" ? "TV show" : "Movie"}
{result.year ? ` · ${result.year}` : ""}
</small>
</span>
<b>
{result.requestId && canOpenRequests
? result.statusLabel || "View request"
: canCreateRequests
? "New request"
: "Report issue"}
</b>
</button>
))}
{!searching && message && <p role="status">{message}</p>}
</div>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { usePathname } from "next/navigation";
import { canAccess, featureForPath } from "../lib/features";
import { useFeatureUser } from "./FeatureGate";
export default function HeaderActions() {
const pathname = usePathname();
const { user, ready } = useFeatureUser();
const role = user?.role ?? null;
const showRequestsNav = canAccess(user, "new_requests");
if (!ready || !user) return null;
const roleItems =
role === null
? []
: role === "admin"
? [
{
href: "/profile/invites",
label: "Invites",
match: (path: string) => path.startsWith("/profile/invites"),
},
{
href: "/admin",
label: "Config",
match: (path: string) => path.startsWith("/admin") || path.startsWith("/users"),
},
]
: [
{
href: "/profile/invites",
label: "Invites",
match: (path: string) => path.startsWith("/profile/invites"),
},
];
const commonItems = [
...(showRequestsNav
? [
{
href: "/new-requests",
label: "New Requests",
match: (path: string) => path === "/new-requests",
},
]
: []),
{
href: "/",
label: "My Requests",
match: (path: string) => path === "/" || path.startsWith("/requests/"),
},
{
href: "/insights",
label: "My Stats",
match: (path: string) => path === "/insights" || path.startsWith("/insights/"),
},
{
href: "/portal/issues",
label: "Issues",
match: (path: string) => path === "/portal/issues" || path === "/admin/issues",
},
];
const items = [...commonItems, ...roleItems].filter((item) => canAccess(user, featureForPath(item.href)));
return (
<nav className="header-actions" aria-label="Primary">
{items.map((item) => {
const active = item.match(pathname);
return (
<a key={item.href} href={item.href} className={active ? "is-active" : undefined}>
{item.label}
</a>
);
})}
</nav>
);
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
import { useEffect, useState } from "react";
import { authFetch, clearToken, getApiBase, getToken, logout } from "../lib/auth";
import { setUserViewPreview, useEffectiveRole, useUserViewPreview } from "../lib/viewMode";
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null);
const [buildNumber, setBuildNumber] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const viewAsUser = useUserViewPreview();
const visibleRole = useEffectiveRole(identity?.role);
useEffect(() => {
const token = getToken();
if (!token) {
setIdentity(null);
setBuildNumber(null);
return;
}
const load = async () => {
try {
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/auth/me`);
if (!response.ok) {
clearToken();
setIdentity(null);
return;
}
const data = await response.json();
if (data?.username) {
setIdentity({ username: data.username, role: data.role });
if (data.role !== "admin") {
setUserViewPreview(false);
}
}
const siteResponse = await fetch(`${baseUrl}/site/public`);
if (siteResponse.ok) {
const siteInfo = await siteResponse.json();
if (siteInfo?.buildNumber) {
setBuildNumber(siteInfo.buildNumber);
}
}
} catch (err) {
console.error(err);
setIdentity(null);
}
};
void load();
}, []);
if (!identity) {
return null;
}
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ""}`;
const initial = identity.username.slice(0, 1).toUpperCase();
const signOut = async () => {
setUserViewPreview(false);
await logout().catch(() => undefined);
clearToken();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
};
return (
<div className="signed-in-context">
{identity.role === "admin" ? (
<button
type="button"
className={`user-view-toggle ${viewAsUser ? "is-active" : ""}`}
aria-pressed={viewAsUser}
onClick={() => setUserViewPreview(!viewAsUser)}
>
{viewAsUser ? "Exit user view" : "View as user"}
</button>
) : null}
<div className="signed-in-menu">
<button
type="button"
className="avatar-button"
onClick={() => setOpen((prev) => !prev)}
aria-haspopup="true"
aria-expanded={open}
title={label}
>
{initial}
</button>
{open && (
<div className="signed-in-dropdown">
<div className="signed-in-header">
Signed in as {label}
{viewAsUser ? <span>Previewing user view</span> : null}
</div>
<div className="signed-in-actions">
<a href="/welcome" onClick={() => setOpen(false)}>
Welcome page
</a>
<a href="/how-it-works" onClick={() => setOpen(false)}>
How it works
</a>
<a href="/profile" onClick={() => setOpen(false)}>
My profile
</a>
{visibleRole === "admin" ? (
<a href="/admin" onClick={() => setOpen(false)}>
Settings
</a>
) : null}
<button type="button" className="signed-in-signout" onClick={() => void signOut()}>
Sign out
</button>
</div>
{buildNumber ? <div className="signed-in-build">Build {buildNumber}</div> : null}
</div>
)}
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import "./invite-delivery.css";
export default function InviteDeliveryChoice({
value,
onChange,
}: {
value: "manual" | "email" | "" | null;
onChange: (method: "manual" | "email") => void;
}) {
return (
<fieldset className="invite-delivery-options">
<legend className="sr-only">Invite delivery method</legend>
{(["manual", "email"] as const).map((method) => (
<button key={method} type="button" aria-pressed={value === method} onClick={() => onChange(method)}>
<span className="delivery-choice-icon" aria-hidden="true">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
>
{method === "manual" ? (
<>
<path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-2 2" />
<path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l2-2" />
</>
) : (
<>
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="m3 7 9 6 9-6" />
</>
)}
</svg>
</span>
<span className="delivery-choice-copy">
<strong>{method === "manual" ? "Copy a link" : "Send an email"}</strong>
<small>
{method === "manual"
? "They add their email when signing up."
: "One use, tied to the recipients email. You get the link too."}
</small>
</span>
<span className="delivery-choice-check" aria-hidden="true">
{value === method ? "✓" : ""}
</span>
</button>
))}
</fieldset>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function MagentMark() {
return (
<svg className="magent-mark" viewBox="0 0 40 40" fill="none" aria-hidden="true">
<rect x=".5" y=".5" width="39" height="39" rx="11" fill="#242329" stroke="#45434f" />
<path d="M10 29V11h4l6 9 6-9h4v18h-4V18l-6 8-6-8v11h-4Z" fill="#dedaff" />
</svg>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from "react";
type PageHeadingProps = {
title: string;
description?: string;
eyebrow?: string;
leading?: ReactNode;
actions?: ReactNode;
};
/** A flat, shared page title. Panels belong to the content below it. */
export default function PageHeading({ title, description, eyebrow, leading, actions }: PageHeadingProps) {
return (
<header className="page-heading">
<div className="page-heading-main">
{leading && <div className="page-heading-leading">{leading}</div>}
<div className="page-heading-copy">
{eyebrow && <span className="page-heading-eyebrow">{eyebrow}</span>}
<h1>{title}</h1>
{description && <p>{description}</p>}
</div>
</div>
{actions && <div className="page-heading-actions">{actions}</div>}
</header>
);
}
+33
View File
@@ -0,0 +1,33 @@
export type RequestStage = "all" | "pending" | "in_progress" | "working" | "ready";
const REQUEST_STAGE_OPTIONS: ReadonlyArray<{ value: RequestStage; label: string }> = [
{ value: "all", label: "All" },
{ value: "pending", label: "Waiting" },
{ value: "in_progress", label: "In progress" },
{ value: "working", label: "Working" },
{ value: "ready", label: "Ready" },
];
export default function RequestStageFilter({
value,
onChange,
}: {
value: RequestStage;
onChange: (stage: RequestStage) => void;
}) {
return (
<nav className="request-filter-chips" aria-label="Filter requests by stage">
{REQUEST_STAGE_OPTIONS.map((option) => (
<button
type="button"
key={option.value}
className={value === option.value ? "is-active" : undefined}
onClick={() => onChange(option.value)}
>
{option.value === "working" ? <i aria-hidden="true" /> : null}
{option.label}
</button>
))}
</nav>
);
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import "./resolution-choice.css";
export default function ResolutionChoice({
title,
busy,
onAnswer,
}: {
title: string;
busy: boolean;
onAnswer: (resolved: boolean) => void;
}) {
return (
<section className="resolution-choice" aria-labelledby="resolution-question" aria-busy={busy}>
<span className="section-kicker">Your answer is needed</span>
<h2 id="resolution-question">Is it fixed?</h2>
<p>{title}</p>
<p>Try the affected content in Jellyfin, then choose:</p>
<div className="resolution-choice-buttons">
<button id="yes" type="button" className="resolution-yes" disabled={busy} onClick={() => onAnswer(true)}>
<strong>YES</strong>
<span>It works close this issue</span>
</button>
<button id="no" type="button" className="resolution-no" disabled={busy} onClick={() => onAnswer(false)}>
<strong>NO</strong>
<span>Still broken keep it open</span>
</button>
</div>
{busy && <p role="status">Saving your answer</p>}
</section>
);
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { CONFIG_GROUPS } from "../admin/configNavigation";
export default function SettingsNavigation() {
const pathname = usePathname();
const router = useRouter();
const current = CONFIG_GROUPS.flatMap((group) => group.items).find((item) => item.href === pathname);
if (pathname === "/admin") return null;
return (
<nav className="settings-top-navigation" aria-label="Settings navigation">
<a href="/admin"> All settings</a>
<label>
<span>Jump to</span>
<select
aria-label="Settings section"
value={current?.href ?? "/admin"}
onChange={(event) => router.push(event.target.value)}
>
<option value="/admin">Settings overview</option>
{CONFIG_GROUPS.map((group) => (
<optgroup label={group.title} key={group.title}>
{group.items.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</optgroup>
))}
</select>
</label>
</nav>
);
}

Some files were not shown because too many files have changed in this diff Show More