chore: standardize security and quality foundations
This commit is contained in:
@@ -1,64 +1,105 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean }
|
||||
type Option = { value: string; label: string }
|
||||
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
|
||||
}
|
||||
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' }],
|
||||
}
|
||||
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',
|
||||
}
|
||||
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 }
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<select {...aria} disabled value={value}>
|
||||
<option value={value}>Save the connection, then reload available options</option>
|
||||
</select>
|
||||
) : colorDefault ? (
|
||||
<div className="setting-color-control">
|
||||
<input
|
||||
@@ -78,23 +119,44 @@ export default function SettingField(props: Props) {
|
||||
spellCheck={false}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
{value ? <button type="button" className="ghost-button" onClick={() => onChange('')}>Use tone default</button> : null}
|
||||
{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>)}
|
||||
{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)} />
|
||||
<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)} />
|
||||
<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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
+1631
-1580
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,38 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
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)
|
||||
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 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,36 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import SettingsPage from '../SettingsPage'
|
||||
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',
|
||||
])
|
||||
"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 }>
|
||||
}
|
||||
params: Promise<{ section: string }>;
|
||||
};
|
||||
|
||||
export default async function AdminSectionPage({ params }: PageProps) {
|
||||
const { section } = await params
|
||||
const { section } = await params;
|
||||
if (!ALLOWED_SECTIONS.has(section)) {
|
||||
notFound()
|
||||
notFound();
|
||||
}
|
||||
return <SettingsPage section={section} />
|
||||
return <SettingsPage section={section} />;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,100 @@
|
||||
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string }
|
||||
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] }
|
||||
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: '/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' },
|
||||
]},
|
||||
]
|
||||
{
|
||||
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: "/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')
|
||||
export const serviceStatusLabel = (status?: string) =>
|
||||
({
|
||||
up: "Connected",
|
||||
down: "Unavailable",
|
||||
degraded: "Needs attention",
|
||||
not_configured: "Not set up",
|
||||
})[status ?? ""] ?? "Not checked";
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import AdminShell from '../../ui/AdminShell'
|
||||
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
|
||||
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."
|
||||
>
|
||||
<AdminShell title="Diagnostics" subtitle="Check connections and investigate service problems.">
|
||||
<AdminDiagnosticsPanel />
|
||||
</AdminShell>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,232 @@
|
||||
'use client'
|
||||
"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'
|
||||
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 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 }
|
||||
}
|
||||
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('')
|
||||
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)
|
||||
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) } }
|
||||
}
|
||||
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>
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,171 +1,506 @@
|
||||
'use client'
|
||||
"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'
|
||||
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 }
|
||||
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
|
||||
}
|
||||
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: 'Seerr’s 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' }
|
||||
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: "Seerr’s 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)
|
||||
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])
|
||||
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])
|
||||
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
|
||||
}
|
||||
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)
|
||||
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)
|
||||
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) }
|
||||
}
|
||||
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('')
|
||||
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.`)
|
||||
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)
|
||||
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) }
|
||||
}
|
||||
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]) }
|
||||
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>}
|
||||
{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>Jellyfin’s 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>)}
|
||||
{ready && (
|
||||
<>
|
||||
<section className="identity-intro admin-panel">
|
||||
<div>
|
||||
<h2>Confirm user IDs</h2>
|
||||
<p>
|
||||
Jellyfin’s 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>
|
||||
{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.')
|
||||
}} />}
|
||||
{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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,290 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { authFetch, getApiBase } from '../../lib/auth'
|
||||
import type { Row } from './IdentityReviewPanel'
|
||||
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
|
||||
}
|
||||
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
|
||||
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('')
|
||||
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()
|
||||
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()
|
||||
}
|
||||
}, [])
|
||||
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) }
|
||||
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.')
|
||||
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)
|
||||
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)
|
||||
setError(err instanceof Error ? err.message : "Could not resolve the link.");
|
||||
setPreview(null);
|
||||
}
|
||||
} finally { if (!abort.signal.aborted) { setBusy(false); setSaving(false) } }
|
||||
}
|
||||
} 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 user’s 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>
|
||||
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 user’s 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function IdentityReviewPage() {
|
||||
redirect('/users?view=identities')
|
||||
redirect("/users?view=identities");
|
||||
}
|
||||
|
||||
+1409
-1346
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import PortalClient from '../../portal/PortalClient'
|
||||
import PortalClient from "../../portal/PortalClient";
|
||||
|
||||
export default function AdminIssuesPage() {
|
||||
return <PortalClient workspace="issue" />
|
||||
return <PortalClient workspace="issue" />;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+95
-46
@@ -1,77 +1,126 @@
|
||||
'use client'
|
||||
"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'
|
||||
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 }
|
||||
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('')
|
||||
const router = useRouter();
|
||||
const [services, setServices] = useState<ServiceState[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
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.')
|
||||
if (!getToken()) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
}
|
||||
void load()
|
||||
return () => { active = false }
|
||||
}, [router])
|
||||
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> : (
|
||||
{!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>}
|
||||
{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>
|
||||
<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())
|
||||
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>
|
||||
{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>
|
||||
<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>
|
||||
))}
|
||||
{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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminProfilesRedirectPage() {
|
||||
redirect('/admin/invites')
|
||||
redirect("/admin/invites");
|
||||
}
|
||||
|
||||
|
||||
+506
-112
@@ -1,132 +1,526 @@
|
||||
'use client'
|
||||
"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 { 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' }
|
||||
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 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])
|
||||
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(() => {
|
||||
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])
|
||||
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(), [])
|
||||
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 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('')
|
||||
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 { 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)
|
||||
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 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('')
|
||||
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('') }
|
||||
}
|
||||
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 you’re 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 month’s 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 & 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>
|
||||
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 you’re 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 month’s 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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,115 +1,111 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
|
||||
import AdminShell from '../../ui/AdminShell'
|
||||
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
|
||||
}
|
||||
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' },
|
||||
]
|
||||
{ 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()
|
||||
}
|
||||
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 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])
|
||||
if (!total || pageSize <= 0) return 1;
|
||||
return Math.max(1, Math.ceil(total / pageSize));
|
||||
}, [total, pageSize]);
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
return
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const skip = (page - 1) * pageSize
|
||||
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)
|
||||
});
|
||||
if (stage !== "all") {
|
||||
params.set("stage", stage);
|
||||
}
|
||||
const response = await authFetch(
|
||||
`${baseUrl}/admin/requests/all?${params.toString()}`
|
||||
)
|
||||
const response = await authFetch(`${baseUrl}/admin/requests/all?${params.toString()}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
clearToken();
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
router.push('/')
|
||||
return
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
throw new Error(`Load failed: ${response.status}`)
|
||||
throw new Error(`Load failed: ${response.status}`);
|
||||
}
|
||||
const data = await response.json()
|
||||
setRows(Array.isArray(data?.results) ? data.results : [])
|
||||
setTotal(Number(data?.total ?? 0))
|
||||
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.')
|
||||
console.error(err);
|
||||
setError("Unable to load requests.");
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [page, pageSize, router, stage]);
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [page, pageSize, stage])
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page > pageCount) {
|
||||
setPage(pageCount)
|
||||
setPage(pageCount);
|
||||
}
|
||||
}, [pageCount, page])
|
||||
}, [pageCount, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [stage])
|
||||
void stage;
|
||||
setPage(1);
|
||||
}, [stage]);
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="All requests"
|
||||
subtitle="Paginated view of every cached request."
|
||||
>
|
||||
<AdminShell title="All requests" subtitle="Paginated view of every cached request.">
|
||||
<section className="admin-section">
|
||||
<div className="admin-toolbar">
|
||||
<div className="admin-toolbar-info">
|
||||
@@ -160,10 +156,10 @@ export default function AdminRequestsAllPage() {
|
||||
>
|
||||
<span>
|
||||
{row.title || `Request #${row.id}`}
|
||||
{row.year ? ` (${row.year})` : ''}
|
||||
{row.year ? ` (${row.year})` : ""}
|
||||
</span>
|
||||
<span>{row.statusLabel || 'Unknown'}</span>
|
||||
<span>{row.requestedBy || 'Unknown'}</span>
|
||||
<span>{row.statusLabel || "Unknown"}</span>
|
||||
<span>{row.requestedBy || "Unknown"}</span>
|
||||
<span>{formatDateTime(row.createdAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
@@ -179,22 +175,14 @@ export default function AdminRequestsAllPage() {
|
||||
<span>
|
||||
Page {page} of {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= pageCount}
|
||||
>
|
||||
<button type="button" onClick={() => setPage(page + 1)} disabled={page >= pageCount}>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(pageCount)}
|
||||
disabled={page >= pageCount}
|
||||
>
|
||||
<button type="button" onClick={() => setPage(pageCount)} disabled={page >= pageCount}>
|
||||
Last
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,106 @@
|
||||
'use client'
|
||||
"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'
|
||||
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
|
||||
}
|
||||
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: "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: "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: "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: "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: "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',
|
||||
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)
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authorized, setAuthorized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
let active = true;
|
||||
const load = async () => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
return
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/auth/me`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
clearToken();
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
router.push('/')
|
||||
return
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
const me = await response.json()
|
||||
if (!active) return
|
||||
if (me?.role !== 'admin') {
|
||||
router.push('/')
|
||||
return
|
||||
const me = await response.json();
|
||||
if (!active) return;
|
||||
if (me?.role !== "admin") {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
setAuthorized(true)
|
||||
setAuthorized(true);
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
router.push('/')
|
||||
console.error(error);
|
||||
router.push("/");
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
if (active) setLoading(false);
|
||||
}
|
||||
}
|
||||
void load()
|
||||
};
|
||||
void load();
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [router])
|
||||
active = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
if (loading) {
|
||||
return <main className="card">Loading system guide...</main>
|
||||
return <main className="card">Loading system guide...</main>;
|
||||
}
|
||||
|
||||
if (!authorized) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const rail = (
|
||||
@@ -112,26 +112,23 @@ export default function AdminSystemGuidePage() {
|
||||
<span className="small-pill">Admin only</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="System guide"
|
||||
subtitle="Service connections, controls, and recovery paths."
|
||||
rail={rail}
|
||||
>
|
||||
<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.
|
||||
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-title">
|
||||
{index + 1}. {stage.title}
|
||||
</div>
|
||||
<div className="system-flow-card-row">
|
||||
<span>Input</span>
|
||||
<strong>{stage.input}</strong>
|
||||
@@ -145,7 +142,11 @@ export default function AdminSystemGuidePage() {
|
||||
<strong>{stage.output}</strong>
|
||||
</div>
|
||||
</article>
|
||||
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true">→</div>}
|
||||
{index < REQUEST_FLOW.length - 1 && (
|
||||
<div className="system-flow-arrow" aria-hidden="true">
|
||||
→
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -157,30 +158,23 @@ export default function AdminSystemGuidePage() {
|
||||
<article className="system-guide-card">
|
||||
<h3>Magent</h3>
|
||||
<p>
|
||||
Handles authentication, request pages, live event updates, invite workflows,
|
||||
diagnostics, notifications, and admin operations.
|
||||
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.
|
||||
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>
|
||||
<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>
|
||||
<p>Control queue placement, quality-profile decisions, import handling, and release monitoring.</p>
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>Prowlarr</h3>
|
||||
@@ -188,10 +182,7 @@ export default function AdminSystemGuidePage() {
|
||||
</article>
|
||||
<article className="system-guide-card">
|
||||
<h3>qBittorrent</h3>
|
||||
<p>
|
||||
Executes the download and exposes live progress, paused states, and queue
|
||||
visibility.
|
||||
</p>
|
||||
<p>Executes the download and exposes live progress, paused states, and queue visibility.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,10 +204,7 @@ export default function AdminSystemGuidePage() {
|
||||
</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>
|
||||
<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>
|
||||
@@ -225,8 +213,8 @@ export default function AdminSystemGuidePage() {
|
||||
<article className="system-guide-card">
|
||||
<h3>Maintenance + diagnostics</h3>
|
||||
<p>
|
||||
Connectivity checks, live diagnostics, database repair, cleanup, log review, and
|
||||
nuclear flush/resync operations.
|
||||
Connectivity checks, live diagnostics, database repair, cleanup, log review, and nuclear flush/resync
|
||||
operations.
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
@@ -235,23 +223,11 @@ export default function AdminSystemGuidePage() {
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -265,7 +241,8 @@ export default function AdminSystemGuidePage() {
|
||||
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>.
|
||||
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>.
|
||||
@@ -295,5 +272,5 @@ export default function AdminSystemGuidePage() {
|
||||
</div>
|
||||
</section>
|
||||
</AdminShell>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user