feat(release): publish minimal self-contained Magent source
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { authFetch, getApiBase } from "../lib/auth";
|
||||
import { FEATURES, type Feature, type FeatureAccess } from "../lib/features";
|
||||
|
||||
type Account = { username: string; role: string; features: FeatureAccess };
|
||||
|
||||
export default function FeatureControls({ username, onSaved }: { username?: string; onSaved: () => void }) {
|
||||
const [accounts, setAccounts] = useState<Account[] | null>(null);
|
||||
const [changes, setChanges] = useState<Partial<FeatureAccess>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const load = useCallback(async () => {
|
||||
const response = await authFetch(
|
||||
`${getApiBase()}/admin/users/${username ? encodeURIComponent(username) : "summary"}`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Could not load feature permissions.");
|
||||
const data = await response.json();
|
||||
setAccounts(username ? [data.user] : data.users.filter((user: Account) => user.role !== "admin"));
|
||||
}, [username]);
|
||||
useEffect(() => {
|
||||
void load().catch((err) => setError(err.message));
|
||||
}, [load]);
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await authFetch(
|
||||
`${getApiBase()}/admin/users/${username ? `${encodeURIComponent(username)}/features` : "features/bulk"}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(changes),
|
||||
},
|
||||
);
|
||||
if (!response.ok) throw new Error((await response.json()).detail || "Could not save permissions.");
|
||||
const result = await response.json();
|
||||
setChanges({});
|
||||
setMessage(username ? "Feature access saved." : `Feature access saved for ${result.updated} non-admin accounts.`);
|
||||
await load();
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Could not save permissions.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const admin = accounts?.some((account) => account.role === "admin");
|
||||
return (
|
||||
<section className="user-management-panel feature-controls">
|
||||
<h3>Feature access</h3>
|
||||
<p>
|
||||
{username
|
||||
? "Choose which features this person can use in Magent."
|
||||
: "Apply feature access to every existing non-admin account, including users outside the current search. Only the checkboxes you change will be applied."}
|
||||
</p>
|
||||
<p>
|
||||
{admin
|
||||
? "Administrators always have access to all features."
|
||||
: "Changes take effect on the next page or API request. These permissions control Magent access; linked services keep their own permissions."}
|
||||
</p>
|
||||
{!accounts && !error && <p>Loading permissions...</p>}
|
||||
{FEATURES.map(({ key, label, description }) => {
|
||||
const enabled = accounts?.filter((account) => account.features?.[key]).length ?? 0;
|
||||
const mixed = !!accounts?.length && enabled > 0 && enabled < accounts.length;
|
||||
const changed = Object.hasOwn(changes, key);
|
||||
return (
|
||||
<label key={key} className="feature-access-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
ref={(input) => {
|
||||
if (input) input.indeterminate = !changed && mixed;
|
||||
}}
|
||||
checked={changes[key] ?? (!!accounts?.length && enabled === accounts.length)}
|
||||
disabled={busy || !accounts?.length || admin}
|
||||
onChange={(event) => setChanges((previous) => ({ ...previous, [key as Feature]: event.target.checked }))}
|
||||
/>
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<small>{description}</small>
|
||||
{!username && (
|
||||
<small>
|
||||
{enabled} of {accounts?.length ?? 0} enabled{mixed && !changed ? " · Mixed access" : ""}
|
||||
{changed ? ` · Will ${changes[key] ? "enable" : "disable"} for everyone` : ""}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p className="status-banner" role="status">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" disabled={busy || !Object.keys(changes).length} onClick={() => void save()}>
|
||||
{busy ? "Saving..." : username ? "Save feature access" : "Apply changed features to all users"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
disabled={busy || !Object.keys(changes).length}
|
||||
onClick={() => setChanges({})}
|
||||
>
|
||||
Reset changes
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,809 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { authFetch, clearToken, getApiBase, getToken } from "../../lib/auth";
|
||||
import FeatureControls from "../FeatureControls";
|
||||
import "../users.css";
|
||||
import AdminShell from "../../ui/AdminShell";
|
||||
|
||||
type UserStats = {
|
||||
total: number;
|
||||
ready: number;
|
||||
pending: number;
|
||||
approved: number;
|
||||
working: number;
|
||||
partial: number;
|
||||
declined: number;
|
||||
in_progress: number;
|
||||
last_request_at?: string | null;
|
||||
};
|
||||
|
||||
type AdminUser = {
|
||||
id?: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
role: string;
|
||||
auth_provider?: string | null;
|
||||
last_login_at?: string | null;
|
||||
is_blocked?: boolean;
|
||||
auto_search_enabled?: boolean;
|
||||
invite_management_enabled?: boolean;
|
||||
jellyseerr_user_id?: number | null;
|
||||
profile_id?: number | null;
|
||||
expires_at?: string | null;
|
||||
is_expired?: boolean;
|
||||
invited_by_code?: string | null;
|
||||
invited_at?: string | null;
|
||||
};
|
||||
|
||||
type UserLineage = {
|
||||
invite_code?: string | null;
|
||||
invited_by?: string | null;
|
||||
invite?: {
|
||||
id?: number;
|
||||
code?: string;
|
||||
label?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at?: string | null;
|
||||
enabled?: boolean;
|
||||
is_usable?: boolean;
|
||||
} | null;
|
||||
} | null;
|
||||
|
||||
type UserProfileOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
is_active?: boolean;
|
||||
};
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return "Never";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.valueOf())) return value;
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const toLocalDateTimeInput = (value?: string | null) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.valueOf())) return "";
|
||||
const offsetMs = date.getTimezoneOffset() * 60_000;
|
||||
const local = new Date(date.getTime() - offsetMs);
|
||||
return local.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const fromLocalDateTimeInput = (value: string) => {
|
||||
if (!value.trim()) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.valueOf())) return null;
|
||||
return date.toISOString();
|
||||
};
|
||||
|
||||
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 UserDetailPage() {
|
||||
const [manageOpen, setManageOpen] = useState(false);
|
||||
const managementDialog = useRef<HTMLDialogElement>(null);
|
||||
const manageTrigger = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!manageOpen) return;
|
||||
const dialog = managementDialog.current;
|
||||
if (!dialog) return;
|
||||
const overflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
dialog.showModal();
|
||||
return () => {
|
||||
dialog.close();
|
||||
document.body.style.overflow = overflow;
|
||||
manageTrigger.current?.focus();
|
||||
};
|
||||
}, [manageOpen]);
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id;
|
||||
const [user, setUser] = useState<AdminUser | null>(null);
|
||||
const [stats, setStats] = useState<UserStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profiles, setProfiles] = useState<UserProfileOption[]>([]);
|
||||
const [profileSelection, setProfileSelection] = useState("");
|
||||
const [expiryInput, setExpiryInput] = useState("");
|
||||
const [emailInput, setEmailInput] = useState("");
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingExpiry, setSavingExpiry] = useState(false);
|
||||
const [savingEmail, setSavingEmail] = useState(false);
|
||||
const [systemActionBusy, setSystemActionBusy] = useState(false);
|
||||
const [actionStatus, setActionStatus] = useState<string | null>(null);
|
||||
const [lineage, setLineage] = useState<UserLineage>(null);
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/admin/profiles`);
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data?.profiles)) {
|
||||
setProfiles([]);
|
||||
return;
|
||||
}
|
||||
setProfiles(
|
||||
data.profiles.map((profile: Record<string, unknown>) => ({
|
||||
id: Number(profile.id ?? 0),
|
||||
name: String(profile.name ?? "Unnamed profile"),
|
||||
is_active: Boolean(profile.is_active ?? true),
|
||||
})),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUser = useCallback(async () => {
|
||||
if (!idParam) return;
|
||||
try {
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`);
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
router.push("/");
|
||||
return;
|
||||
}
|
||||
if (response.status === 404) {
|
||||
setError("User not found.");
|
||||
return;
|
||||
}
|
||||
throw new Error("Could not load user.");
|
||||
}
|
||||
const data = await response.json();
|
||||
const nextUser = data?.user ?? null;
|
||||
setUser(nextUser);
|
||||
setStats(normalizeStats(data?.stats));
|
||||
setLineage((data?.lineage ?? null) as UserLineage);
|
||||
setProfileSelection(
|
||||
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id)) ? "" : String(nextUser.profile_id),
|
||||
);
|
||||
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at));
|
||||
setEmailInput(nextUser?.email ?? "");
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Could not load user.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [idParam, router]);
|
||||
|
||||
const toggleUserBlock = async (blocked: boolean) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
setActionStatus(null);
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(
|
||||
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? "block" : "unblock"}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Update failed");
|
||||
}
|
||||
await loadUser();
|
||||
setActionStatus(blocked ? "User blocked." : "User unblocked.");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Could not update user access.");
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserRole = async (role: string) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
setActionStatus(null);
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Update failed");
|
||||
}
|
||||
await loadUser();
|
||||
setActionStatus(`Role updated to ${role}.`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Could not update user role.");
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserEmail = async (clear = false) => {
|
||||
if (!user) return;
|
||||
const email = clear ? "" : emailInput.trim();
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
setError("Enter a valid email address.");
|
||||
setActionStatus(null);
|
||||
return;
|
||||
}
|
||||
setSavingEmail(true);
|
||||
setError(null);
|
||||
setActionStatus(null);
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/admin/users/${encodeURIComponent(user.username)}/email`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email || null }),
|
||||
});
|
||||
const text = await response.text();
|
||||
let data: { detail?: string; user?: { email?: string | null } } | null = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.detail || text || "Email update failed");
|
||||
}
|
||||
setEmailInput(data?.user?.email ?? "");
|
||||
await loadUser();
|
||||
setActionStatus(email ? "Contact email saved." : "Contact email removed.");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Could not update the contact email.");
|
||||
} finally {
|
||||
setSavingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAutoSearchEnabled = async (enabled: boolean) => {
|
||||
if (!user) return;
|
||||
try {
|
||||
setActionStatus(null);
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Update failed");
|
||||
}
|
||||
await loadUser();
|
||||
setActionStatus(`Auto search/download ${enabled ? "enabled" : "disabled"}.`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Could not update auto search access.");
|
||||
}
|
||||
};
|
||||
|
||||
const applyProfileToUser = async (profileOverride?: string | null) => {
|
||||
if (!user) return;
|
||||
const profileValue = profileOverride ?? profileSelection;
|
||||
setSavingProfile(true);
|
||||
setError(null);
|
||||
setActionStatus(null);
|
||||
try {
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ profile_id: profileValue || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Profile update failed");
|
||||
}
|
||||
await loadUser();
|
||||
setActionStatus(profileValue ? "Profile applied to user." : "Profile assignment cleared.");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Could not update user profile.");
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserExpiry = async () => {
|
||||
if (!user) return;
|
||||
const expiresAt = fromLocalDateTimeInput(expiryInput);
|
||||
if (expiryInput.trim() && !expiresAt) {
|
||||
setError("Invalid expiry date/time.");
|
||||
return;
|
||||
}
|
||||
setSavingExpiry(true);
|
||||
setError(null);
|
||||
setActionStatus(null);
|
||||
try {
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ expires_at: expiresAt }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Expiry update failed");
|
||||
}
|
||||
await loadUser();
|
||||
setActionStatus(expiresAt ? "User expiry updated." : "User expiry cleared.");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Could not update user expiry.");
|
||||
} finally {
|
||||
setSavingExpiry(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearUserExpiry = async () => {
|
||||
if (!user) return;
|
||||
setSavingExpiry(true);
|
||||
setError(null);
|
||||
setActionStatus(null);
|
||||
try {
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ clear: true }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || "Expiry clear failed");
|
||||
}
|
||||
setExpiryInput("");
|
||||
await loadUser();
|
||||
setActionStatus("User expiry cleared.");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError("Could not clear user expiry.");
|
||||
} finally {
|
||||
setSavingExpiry(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runSystemAction = async (action: "ban" | "unban" | "remove") => {
|
||||
if (!user) return;
|
||||
if (action === "remove") {
|
||||
const confirmed = window.confirm(
|
||||
`Permanently delete ${user.username} from Magent, the same-name Jellyfin account and linked Seerr account, disable their invitations and attempt a notification email? This cannot be undone. Media files and Jellystat history are kept.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
if (action === "ban") {
|
||||
const confirmed = window.confirm(
|
||||
`Block ${user.username} in Magent, disable their same-name Jellyfin account and issued invitations, and attempt a notification email? Seerr relies on Jellyfin sign-in and is not directly banned.`,
|
||||
);
|
||||
if (!confirmed) return;
|
||||
}
|
||||
setSystemActionBusy(true);
|
||||
setError(null);
|
||||
setActionStatus(null);
|
||||
try {
|
||||
const baseUrl = getApiBase();
|
||||
const response = await authFetch(`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/system-action`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
const text = await response.text();
|
||||
let data: { detail?: string; status?: string } | null = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.detail || text || "Cross-system action failed");
|
||||
}
|
||||
const state = data?.status === "partial" ? "partial" : "complete";
|
||||
if (action === "remove") {
|
||||
setActionStatus(`User removed (${state}).`);
|
||||
router.push("/users");
|
||||
return;
|
||||
}
|
||||
await loadUser();
|
||||
setActionStatus(`${action === "ban" ? "Ban" : "Unban"} completed (${state}).`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : "Could not run cross-system action.");
|
||||
} finally {
|
||||
setSystemActionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push("/login");
|
||||
return;
|
||||
}
|
||||
void loadUser();
|
||||
void loadProfiles();
|
||||
}, [loadProfiles, loadUser, router]);
|
||||
|
||||
if (loading) {
|
||||
return <main className="card">Loading user...</main>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title={user?.username || "User"}
|
||||
subtitle="User overview and request stats."
|
||||
actions={
|
||||
<>
|
||||
<button type="button" onClick={() => router.push("/users")}>
|
||||
Back to users
|
||||
</button>
|
||||
<button
|
||||
ref={manageTrigger}
|
||||
type="button"
|
||||
disabled={!user}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setManageOpen(true)}
|
||||
>
|
||||
Manage this user
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<section className="admin-section">
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{actionStatus && <div className="status-banner">{actionStatus}</div>}
|
||||
{!user ? (
|
||||
<div className="status-banner">No user data found.</div>
|
||||
) : (
|
||||
<div className="user-detail-page-grid user-detail-centered">
|
||||
<div className="user-detail-main-column">
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<div className="user-detail-title-row">
|
||||
<strong className="user-detail-name">{user.username}</strong>
|
||||
<span className={`user-grid-pill ${user.is_blocked ? "is-blocked" : ""}`}>
|
||||
{user.is_blocked ? "Blocked" : "Active"}
|
||||
</span>
|
||||
<span className={`user-grid-pill ${user.is_expired ? "is-blocked" : ""}`}>
|
||||
{user.is_expired ? "Expired" : user.expires_at ? "Expiry set" : "No expiry"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="lede">User identity, access state, and request history for this account.</p>
|
||||
</div>
|
||||
<div className="user-detail-meta-grid">
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Email</span>
|
||||
<strong>{user.email || "Not set"}</strong>
|
||||
</div>
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Seerr ID</span>
|
||||
<strong>{user.jellyseerr_user_id ?? "Not linked"}</strong>
|
||||
</div>
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Role</span>
|
||||
<strong>{user.role}</strong>
|
||||
</div>
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Login type</span>
|
||||
<strong>{user.auth_provider || "local"}</strong>
|
||||
</div>
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Assigned profile</span>
|
||||
<strong>{user.profile_id ?? "None"}</strong>
|
||||
</div>
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Invited by</span>
|
||||
<strong>{lineage?.invited_by || "Direct / unknown"}</strong>
|
||||
</div>
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Invite code used</span>
|
||||
<strong>{lineage?.invite_code || user.invited_by_code || "None"}</strong>
|
||||
</div>
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Last login</span>
|
||||
<strong>{formatDateTime(user.last_login_at)}</strong>
|
||||
</div>
|
||||
<div className="user-detail-meta-item">
|
||||
<span className="label">Account expiry</span>
|
||||
<strong>{user.expires_at ? formatDateTime(user.expires_at) : "Never"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Request statistics</h2>
|
||||
<p className="lede">Snapshot of request states and recent activity for this user.</p>
|
||||
</div>
|
||||
<div className="user-detail-grid">
|
||||
<div className="user-detail-stat">
|
||||
<span className="label">Total</span>
|
||||
<span className="value">{stats?.total ?? 0}</span>
|
||||
</div>
|
||||
<div className="user-detail-stat">
|
||||
<span className="label">Ready</span>
|
||||
<span className="value">{stats?.ready ?? 0}</span>
|
||||
</div>
|
||||
<div className="user-detail-stat">
|
||||
<span className="label">Pending</span>
|
||||
<span className="value">{stats?.pending ?? 0}</span>
|
||||
</div>
|
||||
<div className="user-detail-stat">
|
||||
<span className="label">Approved</span>
|
||||
<span className="value">{stats?.approved ?? 0}</span>
|
||||
</div>
|
||||
<div className="user-detail-stat">
|
||||
<span className="label">Working</span>
|
||||
<span className="value">{stats?.working ?? 0}</span>
|
||||
</div>
|
||||
<div className="user-detail-stat">
|
||||
<span className="label">Partial</span>
|
||||
<span className="value">{stats?.partial ?? 0}</span>
|
||||
</div>
|
||||
<div className="user-detail-stat">
|
||||
<span className="label">Declined</span>
|
||||
<span className="value">{stats?.declined ?? 0}</span>
|
||||
</div>
|
||||
<div className="user-detail-stat">
|
||||
<span className="label">In progress</span>
|
||||
<span className="value">{stats?.in_progress ?? 0}</span>
|
||||
</div>
|
||||
<div className="user-detail-stat user-detail-stat--wide">
|
||||
<span className="label">Last request</span>
|
||||
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
ref={managementDialog}
|
||||
className="user-management-dialog"
|
||||
aria-labelledby="manage-this-user-title"
|
||||
onCancel={() => setManageOpen(false)}
|
||||
onClose={() => setManageOpen(false)}
|
||||
>
|
||||
<div className="user-management-content">
|
||||
<header className="user-management-heading">
|
||||
<div>
|
||||
<h2 id="manage-this-user-title">Manage {user.username}</h2>
|
||||
<p>Feature access, account settings and account restrictions.</p>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={() => setManageOpen(false)}>
|
||||
Close
|
||||
</button>
|
||||
</header>
|
||||
{error && (
|
||||
<p className="error-banner" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{actionStatus && (
|
||||
<p className="status-banner" role="status">
|
||||
{actionStatus}
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
<a className="ghost-button" href={`/users?view=identities&user=${encodeURIComponent(user.username)}`}>
|
||||
Review service links & duplicate accounts
|
||||
</a>
|
||||
</p>
|
||||
{manageOpen && (
|
||||
<FeatureControls key={user.role} username={user.username} onSaved={() => void loadUser()} />
|
||||
)}
|
||||
<div className="user-management-grid">
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Contact email</h2>
|
||||
<p className="lede">Used by Magent for account recovery and issue updates.</p>
|
||||
</div>
|
||||
<form
|
||||
className="user-detail-actions user-detail-actions--stacked"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void saveUserEmail();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span className="user-bulk-label">Email address</span>
|
||||
<input
|
||||
type="email"
|
||||
value={emailInput}
|
||||
onChange={(event) => setEmailInput(event.target.value)}
|
||||
placeholder="person@example.com"
|
||||
autoComplete="off"
|
||||
disabled={savingEmail}
|
||||
/>
|
||||
</label>
|
||||
<div className="user-detail-helper">
|
||||
This updates Magent only. It does not change the user's Jellyfin or Seerr account.
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={savingEmail || !emailInput.trim() || emailInput.trim() === (user.email ?? "")}
|
||||
>
|
||||
{savingEmail ? "Saving..." : user.email ? "Save email" : "Add email"}
|
||||
</button>
|
||||
{user.email && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => void saveUserEmail(true)}
|
||||
disabled={savingEmail}
|
||||
>
|
||||
Remove email
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Access controls</h2>
|
||||
<p className="lede">Role, login access, and auto-download behavior.</p>
|
||||
</div>
|
||||
<div className="user-detail-control-stack">
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={user.role === "admin"}
|
||||
onChange={(event) => updateUserRole(event.target.checked ? "admin" : "user")}
|
||||
/>
|
||||
<span>Make admin</span>
|
||||
</label>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(user.auto_search_enabled ?? true)}
|
||||
disabled={user.role === "admin"}
|
||||
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>Allow auto search/download</span>
|
||||
</label>
|
||||
{user.role === "admin" && (
|
||||
<div className="user-detail-helper">
|
||||
Admins always have automatic search/download and all features.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Profile defaults</h2>
|
||||
<p className="lede">Assign or clear an invite profile for this user.</p>
|
||||
</div>
|
||||
<div className="user-detail-actions user-detail-actions--stacked">
|
||||
<label className="admin-select">
|
||||
<span>Assigned profile</span>
|
||||
<select
|
||||
value={profileSelection}
|
||||
onChange={(event) => setProfileSelection(event.target.value)}
|
||||
disabled={savingProfile}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}
|
||||
{profile.is_active === false ? " (disabled)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" onClick={() => void applyProfileToUser()} disabled={savingProfile}>
|
||||
{savingProfile ? "Applying..." : "Apply profile defaults"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
setProfileSelection("");
|
||||
void applyProfileToUser("");
|
||||
}}
|
||||
disabled={savingProfile}
|
||||
>
|
||||
Clear profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel user-detail-panel">
|
||||
<div className="user-detail-panel-header">
|
||||
<h2>Account expiry</h2>
|
||||
<p className="lede">Set a specific expiry date/time for this user account.</p>
|
||||
</div>
|
||||
<div className="user-detail-actions user-detail-actions--stacked">
|
||||
<label>
|
||||
<span className="user-bulk-label">Account expiry</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={expiryInput}
|
||||
onChange={(event) => setExpiryInput(event.target.value)}
|
||||
disabled={savingExpiry}
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="button" onClick={saveUserExpiry} disabled={savingExpiry}>
|
||||
{savingExpiry ? "Saving..." : "Save expiry"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={clearUserExpiry}
|
||||
disabled={savingExpiry}
|
||||
>
|
||||
Clear expiry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section className="user-management-panel user-management-danger">
|
||||
<h3>Restrict access or delete accounts</h3>
|
||||
<p>
|
||||
Blocking Magent prevents sign-in here and keeps the account. It does not block Jellyfin or Seerr.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => toggleUserBlock(!user.is_blocked)}
|
||||
disabled={systemActionBusy || user.role === "admin"}
|
||||
>
|
||||
{user.is_blocked ? "Restore Magent access" : "Block Magent access"}
|
||||
</button>
|
||||
<p>
|
||||
Disable access also disables invitations this user created and attempts an account notification
|
||||
email. Jellyfin is matched by username. Seerr relies on Jellyfin sign-in; its account is not
|
||||
directly banned. Restoring access does not reactivate invitations.
|
||||
</p>
|
||||
<div className="admin-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => void runSystemAction(user.is_blocked ? "unban" : "ban")}
|
||||
disabled={systemActionBusy || user.role === "admin"}
|
||||
>
|
||||
{systemActionBusy
|
||||
? "Working..."
|
||||
: user.is_blocked
|
||||
? "Restore Magent and Jellyfin access"
|
||||
: "Disable Magent and Jellyfin access"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => void runSystemAction("remove")}
|
||||
disabled={systemActionBusy || user.role === "admin"}
|
||||
>
|
||||
Delete Magent, Jellyfin and Seerr accounts
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Deletion removes the Magent account and local login activity, attempts to delete the same-name
|
||||
Jellyfin account and linked Seerr account, and disables issued invitations. It cannot be undone
|
||||
here. Media files and Jellystat history are not deleted. External actions can partially fail.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
.users-directory-centered { width: 100%; max-width: 1280px; margin: 0 auto; min-width: 0; }
|
||||
.user-management-dialog { position: fixed; inset: 0; width: min(980px, calc(100vw - 32px)); max-height: calc(100dvh - 48px); overflow: auto; padding: 0; margin: auto; border: 1px solid var(--ops-line); border-radius: 16px; background: var(--ops-panel, #1c1b1d); color: var(--ops-text, #eee8f2); box-shadow: 0 24px 90px #0009; }
|
||||
.user-management-dialog::backdrop { background: #000a; backdrop-filter: blur(4px); }
|
||||
.user-management-content { padding: 28px; }
|
||||
.user-management-heading { position: sticky; top: 0; z-index: 1; display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 24px; padding-bottom: 12px; background: var(--ops-panel, #1c1b1d); box-shadow: 0 -28px 0 var(--ops-panel, #1c1b1d); }
|
||||
.user-management-heading h2 { margin: 8px 0; font-size: 26px; }
|
||||
.user-management-heading > button { flex-shrink: 0; }
|
||||
.user-management-dialog p { color: var(--ops-muted, #bdb6c3); font-size: 13px; line-height: 1.7; margin: 8px 0 16px; }
|
||||
.user-management-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
|
||||
.user-management-panel { min-width: 0; padding: 22px; border: 1px solid var(--ops-line); border-radius: 12px; background: #ffffff02; }
|
||||
.user-management-panel h3 { margin: 0 0 10px; font-size: 17px; }
|
||||
.user-management-action + .user-management-action { border-top: 1px solid var(--ops-line); padding-top: 16px; }
|
||||
.user-management-action p { margin-top: 10px; font-size: 12px; }
|
||||
.user-management-action a { display: inline-flex; text-decoration: none; }
|
||||
.user-management-buttons { display: flex; flex-wrap: wrap; gap: 10px; }
|
||||
.user-management-dialog button, .user-management-dialog a.ghost-button { max-width: 100%; white-space: normal; line-height: 1.5; }
|
||||
.user-management-count { display: block; font-size: 12px; color: #c7bdff; margin: 14px 0; }
|
||||
.user-management-advanced { margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--ops-line); }
|
||||
.user-management-advanced summary, .user-management-summary > summary { cursor: pointer; font-size: 13px; color: var(--ops-text); padding: 8px 0; }
|
||||
.user-management-advanced button { border-color: #d4a38f70; color: #edc7b8; }
|
||||
.user-management-summary { border-top: 1px solid var(--ops-line); margin-top: 24px; padding-top: 12px; }
|
||||
.user-management-summary .admin-rail-stack { margin-top: 14px; }
|
||||
@media (min-width: 1000px) {
|
||||
.users-directory-centered .user-directory-header, .users-directory-centered .user-directory-row { grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) minmax(0, .9fr) minmax(0, 1.1fr) 64px; }
|
||||
.users-directory-centered .user-directory-row-chevron { justify-self: end; }
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.user-management-dialog { width: calc(100vw - 20px); max-height: calc(100dvh - 24px); }
|
||||
.user-management-content { padding: 20px 16px; }
|
||||
.user-management-grid { grid-template-columns: 1fr; }
|
||||
.user-management-panel { padding: 18px; }
|
||||
.user-management-heading h2 { font-size: 22px; }
|
||||
}
|
||||
|
||||
/* Individual profiles use the directory's modal management pattern. */
|
||||
.user-detail-page-grid.user-detail-centered { display: block; width: min(100%, 1100px); margin-inline: auto; }
|
||||
.user-detail-centered .user-detail-main-column { display: flex; flex-direction: column; gap: 24px; }
|
||||
.user-detail-centered .user-detail-main-column > :nth-child(2) { order: -1; }
|
||||
.user-detail-centered .user-detail-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.feature-controls { margin-bottom: 20px; }
|
||||
.feature-access-row { display: flex; align-items: flex-start; gap: 14px; padding: 15px 0; border-bottom: 1px solid var(--border, #34343c); cursor: pointer; }
|
||||
.feature-access-row input { flex: 0 0 auto; margin-top: 4px; width: 18px; height: 18px; accent-color: #c4b5fd; }
|
||||
.feature-access-row span { display: grid; gap: 5px; }
|
||||
.feature-access-row small { color: var(--text-muted, #a9a9ba); line-height: 1.5; }
|
||||
.feature-controls .admin-inline-actions { margin-top: 20px; }
|
||||
.user-management-panel.user-management-danger { margin-top: 24px; border: 1px solid #a84049; background: #321b2080; }
|
||||
.user-management-danger h3 { color: #ff9ca6; }
|
||||
.user-management-danger button { border-color: #a84049; color: #ffb6bd; background: #441e27; }
|
||||
.user-management-danger p { margin-block: 16px; line-height: 1.6; }
|
||||
@media (max-width: 640px) {
|
||||
.user-detail-centered .user-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
Reference in New Issue
Block a user