605 lines
24 KiB
TypeScript
605 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { authFetch, clearToken, getApiBase, getToken } from "../lib/auth";
|
|
import AdminShell from "../ui/AdminShell";
|
|
import "./users.css";
|
|
import FeatureControls from "./FeatureControls";
|
|
import IdentityReviewPanel from "../admin/identities/IdentityReviewPanel";
|
|
|
|
type AdminUser = {
|
|
id: number;
|
|
username: string;
|
|
email?: string | null;
|
|
role: string;
|
|
authProvider?: string | null;
|
|
lastLoginAt?: string | null;
|
|
isBlocked?: boolean;
|
|
autoSearchEnabled?: boolean;
|
|
inviteManagementEnabled?: boolean;
|
|
profileId?: number | null;
|
|
expiresAt?: string | null;
|
|
isExpired?: boolean;
|
|
stats?: UserStats;
|
|
};
|
|
|
|
type UserStats = {
|
|
total: number;
|
|
ready: number;
|
|
pending: number;
|
|
approved: number;
|
|
working: number;
|
|
partial: number;
|
|
declined: number;
|
|
in_progress: number;
|
|
last_request_at?: string | null;
|
|
};
|
|
|
|
const formatLastLogin = (value?: string | null) => {
|
|
if (!value) return "Never";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.valueOf())) return value;
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
const formatLastRequest = (value?: string | null) => {
|
|
if (!value) return "—";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.valueOf())) return value;
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
const formatExpiry = (value?: string | null) => {
|
|
if (!value) return "Never";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.valueOf())) return value;
|
|
return date.toLocaleString();
|
|
};
|
|
|
|
const normalizeStats = (stats: Record<string, unknown> | null | undefined): UserStats => ({
|
|
total: Number(stats?.total ?? 0),
|
|
ready: Number(stats?.ready ?? 0),
|
|
pending: Number(stats?.pending ?? 0),
|
|
approved: Number(stats?.approved ?? 0),
|
|
working: Number(stats?.working ?? 0),
|
|
partial: Number(stats?.partial ?? 0),
|
|
declined: Number(stats?.declined ?? 0),
|
|
in_progress: Number(stats?.in_progress ?? 0),
|
|
last_request_at: typeof stats?.last_request_at === "string" ? stats.last_request_at : null,
|
|
});
|
|
|
|
export default function UsersPage() {
|
|
const router = useRouter();
|
|
const [view, setView] = useState("directory");
|
|
useEffect(() => {
|
|
const update = () =>
|
|
setView(new URLSearchParams(window.location.search).get("view") === "identities" ? "identities" : "directory");
|
|
update();
|
|
window.addEventListener("popstate", update);
|
|
return () => window.removeEventListener("popstate", update);
|
|
}, []);
|
|
const changeView = (next: string) => {
|
|
setView(next);
|
|
window.history.pushState(null, "", next === "identities" ? "/users?view=identities" : "/users");
|
|
};
|
|
const [controlsOpen, setControlsOpen] = useState(false);
|
|
const controlsDialog = useRef<HTMLDialogElement>(null);
|
|
const controlsTrigger = useRef<HTMLButtonElement>(null);
|
|
const controlsClose = useRef<HTMLButtonElement>(null);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [users, setUsers] = useState<AdminUser[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [query, setQuery] = useState("");
|
|
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null);
|
|
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false);
|
|
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false);
|
|
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false);
|
|
|
|
const loadUsers = useCallback(async () => {
|
|
setRefreshing(true);
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/summary`);
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
clearToken();
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
router.push("/");
|
|
return;
|
|
}
|
|
throw new Error("Could not load users.");
|
|
}
|
|
const data = await response.json();
|
|
if (Array.isArray(data?.users)) {
|
|
setUsers(
|
|
data.users.map((user: Record<string, unknown>) => ({
|
|
username: typeof user.username === "string" ? user.username : "Unknown",
|
|
email: typeof user.email === "string" ? user.email : null,
|
|
role: typeof user.role === "string" ? user.role : "user",
|
|
authProvider: typeof user.auth_provider === "string" ? user.auth_provider : "local",
|
|
lastLoginAt: typeof user.last_login_at === "string" ? user.last_login_at : null,
|
|
isBlocked: Boolean(user.is_blocked),
|
|
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
|
|
inviteManagementEnabled: Boolean(user.invite_management_enabled),
|
|
profileId:
|
|
user.profile_id == null || Number.isNaN(Number(user.profile_id)) ? null : Number(user.profile_id),
|
|
expiresAt: typeof user.expires_at === "string" ? user.expires_at : null,
|
|
isExpired: Boolean(user.is_expired),
|
|
id: Number(user.id ?? 0),
|
|
stats: normalizeStats(
|
|
user.stats && typeof user.stats === "object" ? (user.stats as Record<string, unknown>) : null,
|
|
),
|
|
})),
|
|
);
|
|
} else {
|
|
setUsers([]);
|
|
}
|
|
setError(null);
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not load user list.");
|
|
} finally {
|
|
setLoading(false);
|
|
setRefreshing(false);
|
|
}
|
|
}, [router]);
|
|
|
|
const syncJellyseerrUsers = async () => {
|
|
setJellyseerrSyncStatus(null);
|
|
setJellyseerrSyncBusy(true);
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
|
|
method: "POST",
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || "Sync failed");
|
|
}
|
|
const data = await response.json();
|
|
setJellyseerrSyncStatus(
|
|
`Checked ${data?.total ?? 0} Seerr records against Jellyfin IDs. Added ${data?.imported ?? 0} users; existing settings retained.`,
|
|
);
|
|
await loadUsers();
|
|
} catch (err) {
|
|
console.error(err);
|
|
setJellyseerrSyncStatus("Could not sync Seerr users.");
|
|
} finally {
|
|
setJellyseerrSyncBusy(false);
|
|
}
|
|
};
|
|
|
|
const resyncJellyseerrUsers = async () => {
|
|
setJellyseerrSyncStatus(null);
|
|
setJellyseerrResyncBusy(true);
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
|
|
method: "POST",
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || "Resync failed");
|
|
}
|
|
const data = await response.json();
|
|
setJellyseerrSyncStatus(
|
|
`Reconciled service identities. Added ${data?.imported ?? 0} new users; existing accounts and settings were retained.`,
|
|
);
|
|
await loadUsers();
|
|
} catch (err) {
|
|
console.error(err);
|
|
setJellyseerrSyncStatus("Could not resync Seerr users.");
|
|
} finally {
|
|
setJellyseerrResyncBusy(false);
|
|
}
|
|
};
|
|
|
|
const bulkUpdateAutoSearch = async (enabled: boolean) => {
|
|
setBulkAutoSearchBusy(true);
|
|
setJellyseerrSyncStatus(null);
|
|
try {
|
|
const baseUrl = getApiBase();
|
|
const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled }),
|
|
});
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(text || "Bulk update failed");
|
|
}
|
|
const data = await response.json();
|
|
setJellyseerrSyncStatus(
|
|
`${enabled ? "Enabled" : "Disabled"} auto search/download for ${data?.updated ?? 0} non-admin users.`,
|
|
);
|
|
await loadUsers();
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError("Could not update auto search/download for all users.");
|
|
} finally {
|
|
setBulkAutoSearchBusy(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!getToken()) {
|
|
router.push("/login");
|
|
return;
|
|
}
|
|
void loadUsers();
|
|
}, [loadUsers, router]);
|
|
|
|
useEffect(() => {
|
|
if (!controlsOpen) return;
|
|
const dialog = controlsDialog.current;
|
|
dialog?.showModal();
|
|
controlsClose.current?.focus();
|
|
const previous = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
dialog?.close();
|
|
document.body.style.overflow = previous;
|
|
controlsTrigger.current?.focus();
|
|
};
|
|
}, [controlsOpen]);
|
|
|
|
const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy;
|
|
|
|
if (loading) {
|
|
return <main className="card">Loading users...</main>;
|
|
}
|
|
|
|
const nonAdminUsers = users.filter((user) => user.role !== "admin");
|
|
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length;
|
|
const blockedCount = users.filter((user) => user.isBlocked).length;
|
|
const expiredCount = users.filter((user) => user.isExpired).length;
|
|
const adminCount = users.filter((user) => user.role === "admin").length;
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const filteredUsers = normalizedQuery
|
|
? users.filter((user) => {
|
|
const fields = [
|
|
user.username,
|
|
user.email || "",
|
|
user.role,
|
|
user.authProvider || "",
|
|
user.profileId != null ? String(user.profileId) : "",
|
|
];
|
|
return fields.some((field) => field.toLowerCase().includes(normalizedQuery));
|
|
})
|
|
: users;
|
|
const filteredCountLabel =
|
|
filteredUsers.length === users.length
|
|
? `${users.length} users`
|
|
: `${filteredUsers.length} of ${users.length} users`;
|
|
const usersRail = (
|
|
<div className="admin-rail-stack">
|
|
<div className="admin-rail-card users-rail-summary">
|
|
<div className="user-directory-panel-header">
|
|
<div>
|
|
<h2>Directory summary</h2>
|
|
<p className="lede">A quick view of user access and account state.</p>
|
|
</div>
|
|
</div>
|
|
<div className="users-summary-grid">
|
|
<div className="users-summary-card">
|
|
<div className="users-summary-row">
|
|
<span className="users-summary-label">Total users</span>
|
|
<strong className="users-summary-value">{users.length}</strong>
|
|
</div>
|
|
<p className="users-summary-meta">{adminCount} admin accounts</p>
|
|
</div>
|
|
<div className="users-summary-card">
|
|
<div className="users-summary-row">
|
|
<span className="users-summary-label">Auto search</span>
|
|
<strong className="users-summary-value">{autoSearchEnabledCount}</strong>
|
|
</div>
|
|
<p className="users-summary-meta">of {nonAdminUsers.length} non-admin users enabled</p>
|
|
</div>
|
|
<div className="users-summary-card">
|
|
<div className="users-summary-row">
|
|
<span className="users-summary-label">Blocked</span>
|
|
<strong className="users-summary-value">{blockedCount}</strong>
|
|
</div>
|
|
<p className="users-summary-meta">{blockedCount ? "Accounts currently blocked" : "No blocked users"}</p>
|
|
</div>
|
|
<div className="users-summary-card">
|
|
<div className="users-summary-row">
|
|
<span className="users-summary-label">Expired</span>
|
|
<strong className="users-summary-value">{expiredCount}</strong>
|
|
</div>
|
|
<p className="users-summary-meta">{expiredCount ? "Accounts with expired access" : "No expiries"}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<AdminShell
|
|
title="User management"
|
|
subtitle="Accounts, access, request activity and verified service links."
|
|
actions={
|
|
<button
|
|
ref={controlsTrigger}
|
|
type="button"
|
|
className="ghost-button"
|
|
aria-haspopup="dialog"
|
|
aria-expanded={controlsOpen}
|
|
aria-controls="user-management-dialog"
|
|
onClick={() => setControlsOpen(true)}
|
|
>
|
|
Manage users
|
|
</button>
|
|
}
|
|
>
|
|
<dialog
|
|
id="user-management-dialog"
|
|
ref={controlsDialog}
|
|
className="user-management-dialog"
|
|
aria-labelledby="user-management-title"
|
|
onCancel={() => setControlsOpen(false)}
|
|
onClose={() => setControlsOpen(false)}
|
|
>
|
|
<div className="user-management-content">
|
|
<header className="user-management-heading">
|
|
<div>
|
|
<span className="users-page-toolbar-label">Directory tools</span>
|
|
<h2 id="user-management-title">Manage users</h2>
|
|
<p>Account links, service sync and permissions for your user directory.</p>
|
|
</div>
|
|
<button ref={controlsClose} type="button" className="ghost-button" onClick={() => setControlsOpen(false)}>
|
|
Close
|
|
</button>
|
|
</header>
|
|
{error && (
|
|
<p className="error-banner" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{jellyseerrSyncStatus && (
|
|
<p className="status-banner" role="status">
|
|
{jellyseerrSyncStatus}
|
|
</p>
|
|
)}
|
|
<div className="user-management-grid">
|
|
<section className="user-management-panel">
|
|
<h3>Directory actions</h3>
|
|
<p>Review linked accounts, manage invitations or refresh the list.</p>
|
|
<div className="user-management-action">
|
|
<Link className="ghost-button" href="/users?view=identities" aria-describedby="identity-help">
|
|
Review account links ↗
|
|
</Link>
|
|
<p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p>
|
|
</div>
|
|
<div className="user-management-action">
|
|
<Link className="ghost-button" href="/admin/invites" aria-describedby="invitation-help">
|
|
Manage invitations ↗
|
|
</Link>
|
|
<p id="invitation-help">Create invitations, review issued links and set invitation defaults.</p>
|
|
</div>
|
|
<div className="user-management-action">
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => void loadUsers()}
|
|
disabled={controlsBusy}
|
|
aria-describedby="reload-help"
|
|
>
|
|
{refreshing ? "Refreshing…" : "Refresh user list"}
|
|
</button>
|
|
<p id="reload-help">
|
|
Reload account status and request totals from Magent. Your search stays in place.
|
|
</p>
|
|
</div>
|
|
</section>
|
|
<section className="user-management-panel">
|
|
<h3>Seerr sync</h3>
|
|
<p>Connect existing Magent accounts to their Seerr request accounts.</p>
|
|
<div className="user-management-action">
|
|
<button
|
|
type="button"
|
|
onClick={() => void syncJellyseerrUsers()}
|
|
disabled={controlsBusy}
|
|
aria-describedby="sync-help"
|
|
>
|
|
{jellyseerrSyncBusy ? "Matching accounts…" : "Match unlinked Seerr accounts"}
|
|
</button>
|
|
<p id="sync-help">
|
|
Match users without a saved Seerr ID by their account name, then copy the matching Seerr ID and
|
|
available email into Magent. Already-linked users are skipped.
|
|
</p>
|
|
</div>
|
|
<details className="user-management-advanced">
|
|
<summary>Reconcile service identities</summary>
|
|
<p id="resync-help">
|
|
Refreshes Jellyfin and Seerr accounts using their shared Jellyfin ID. Preserves account settings and
|
|
history. Duplicate or conflicting links stay available for reviewed repair.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => void resyncJellyseerrUsers()}
|
|
disabled={controlsBusy}
|
|
aria-describedby="resync-help"
|
|
>
|
|
{jellyseerrResyncBusy ? "Reconciling identities…" : "Reconcile Jellyfin and Seerr"}
|
|
</button>
|
|
</details>
|
|
</section>
|
|
<section className="user-management-panel">
|
|
<h3>Automatic search & download</h3>
|
|
<p>Allow users to trigger automatic searches and downloads for their requests.</p>
|
|
<span className="user-management-count">
|
|
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
|
|
</span>
|
|
<p id="auto-search-help">
|
|
Applies to every existing non-admin account, including accounts outside your search results. Use an
|
|
individual user's page to change just their access.
|
|
</p>
|
|
<div className="user-management-buttons">
|
|
<button
|
|
type="button"
|
|
onClick={() => void bulkUpdateAutoSearch(true)}
|
|
disabled={controlsBusy || !nonAdminUsers.length || autoSearchEnabledCount === nonAdminUsers.length}
|
|
aria-describedby="auto-search-help"
|
|
>
|
|
Enable for all non-admin users
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
onClick={() => void bulkUpdateAutoSearch(false)}
|
|
disabled={controlsBusy || !autoSearchEnabledCount}
|
|
aria-describedby="auto-search-help"
|
|
>
|
|
Disable for all non-admin users
|
|
</button>
|
|
</div>
|
|
</section>
|
|
<FeatureControls onSaved={() => void loadUsers()} />
|
|
</div>
|
|
<details className="user-management-summary">
|
|
<summary>Directory totals</summary>
|
|
{usersRail}
|
|
</details>
|
|
</div>
|
|
</dialog>
|
|
<nav className="identity-selection" aria-label="User management sections">
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
aria-pressed={view === "directory"}
|
|
onClick={() => changeView("directory")}
|
|
>
|
|
User directory
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ghost-button"
|
|
aria-pressed={view === "identities"}
|
|
onClick={() => changeView("identities")}
|
|
>
|
|
Account links & repairs
|
|
</button>
|
|
</nav>
|
|
{view === "identities" ? (
|
|
<IdentityReviewPanel />
|
|
) : (
|
|
<section className="admin-section users-directory-centered">
|
|
{!controlsOpen && error && (
|
|
<p className="error-banner" role="alert">
|
|
{error}
|
|
</p>
|
|
)}
|
|
{!controlsOpen && jellyseerrSyncStatus && (
|
|
<p className="status-banner" role="status">
|
|
{jellyseerrSyncStatus}
|
|
</p>
|
|
)}
|
|
<div className="admin-panel user-directory-search-panel">
|
|
<div className="user-directory-panel-header">
|
|
<div>
|
|
<h2>Directory search</h2>
|
|
<p className="lede">
|
|
Find an account by username, email, role, login provider or profile ID. Select a user to manage their
|
|
access.
|
|
</p>
|
|
</div>
|
|
<span className="small-pill">{filteredCountLabel}</span>
|
|
</div>
|
|
<div className="user-directory-toolbar">
|
|
<div className="user-directory-search">
|
|
<label>
|
|
<span className="user-bulk-label">Search users</span>
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search username, email, role, login provider or profile ID…"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{filteredUsers.length === 0 ? (
|
|
<div className="status-banner" role="status">
|
|
{normalizedQuery
|
|
? "No users match your search. Try another name or email."
|
|
: "No users found yet. Open Manage users to review the directory tools."}
|
|
</div>
|
|
) : (
|
|
<div className="user-directory-list">
|
|
<div className="user-directory-header">
|
|
<span>User</span>
|
|
<span>Access</span>
|
|
<span>Requests</span>
|
|
<span>Activity</span>
|
|
</div>
|
|
{filteredUsers.map((user) => (
|
|
<Link key={user.username} className="user-directory-row" href={`/users/${user.id}`}>
|
|
<div className="user-directory-cell user-directory-cell--identity">
|
|
<div className="user-directory-title-row">
|
|
<strong>{user.username}</strong>
|
|
<span className="user-grid-meta">{user.role}</span>
|
|
</div>
|
|
<div className="user-directory-subtext">{user.email || "No email on file"}</div>
|
|
<div className="user-directory-subtext">
|
|
Login: {user.authProvider || "local"} • Profile: {user.profileId ?? "None"}
|
|
</div>
|
|
</div>
|
|
<div className="user-directory-cell">
|
|
<div className="user-directory-pill-row">
|
|
<span className={`user-grid-pill ${user.isBlocked ? "is-blocked" : ""}`}>
|
|
{user.isBlocked ? "Blocked" : "Active"}
|
|
</span>
|
|
<span className={`user-grid-pill ${user.autoSearchEnabled === false ? "is-disabled" : ""}`}>
|
|
Auto {user.autoSearchEnabled === false ? "Off" : "On"}
|
|
</span>
|
|
<span className={`user-grid-pill ${user.isExpired ? "is-blocked" : ""}`}>
|
|
{user.expiresAt ? (user.isExpired ? "Expired" : "Expiry set") : "No expiry"}
|
|
</span>
|
|
</div>
|
|
<div className="user-directory-subtext">
|
|
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : "No account expiry"}
|
|
</div>
|
|
</div>
|
|
<div className="user-directory-cell">
|
|
<div className="user-directory-stats-inline">
|
|
<span>
|
|
<strong>{user.stats?.total ?? 0}</strong> total
|
|
</span>
|
|
<span>
|
|
<strong>{user.stats?.ready ?? 0}</strong> ready
|
|
</span>
|
|
<span>
|
|
<strong>{user.stats?.pending ?? 0}</strong> pending
|
|
</span>
|
|
<span>
|
|
<strong>{user.stats?.in_progress ?? 0}</strong> in progress
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="user-directory-cell">
|
|
<div className="user-directory-subtext">Last login: {formatLastLogin(user.lastLoginAt)}</div>
|
|
<div className="user-directory-subtext">
|
|
Last request: {formatLastRequest(user.stats?.last_request_at)}
|
|
</div>
|
|
</div>
|
|
<div className="user-directory-row-chevron" aria-hidden="true">
|
|
Open
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
</AdminShell>
|
|
);
|
|
}
|