chore: standardize security and quality foundations
Magent CI/CD / verify (push) Failing after 9m34s
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-17 20:03:47 +12:00
parent 5639dbcb83
commit f852e7c941
127 changed files with 17928 additions and 10741 deletions
+112 -51
View File
@@ -1,58 +1,119 @@
'use client'
"use client";
import { useEffect, useState } from 'react'
import { authFetch, getApiBase } from '../lib/auth'
import { FEATURES, type Feature, type FeatureAccess } from '../lib/features'
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 }
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 = 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'))
}
useEffect(() => { void load().catch((err) => setError(err.message)) }, [username])
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('')
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>
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>
);
}
File diff suppressed because it is too large Load Diff
+441 -304
View File
@@ -1,76 +1,64 @@
'use client'
"use client";
import { 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'
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
}
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
}
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()
}
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()
}
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()
}
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString();
};
const emptyStats: UserStats = {
total: 0,
ready: 0,
pending: 0,
approved: 0,
working: 0,
partial: 0,
declined: 0,
in_progress: 0,
last_request_at: null,
}
const normalizeStats = (stats: any): UserStats => ({
const normalizeStats = (stats: Record<string, unknown> | null | undefined): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
@@ -79,215 +67,216 @@ const normalizeStats = (stats: any): UserStats => ({
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
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')
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 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)
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 = async () => {
setRefreshing(true)
const loadUsers = useCallback(async () => {
setRefreshing(true);
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/summary`)
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/users/summary`);
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('Could not load users.')
throw new Error("Could not load users.");
}
const data = await response.json()
const data = await response.json();
if (Array.isArray(data?.users)) {
setUsers(
data.users.map((user: any) => ({
username: user.username ?? 'Unknown',
email: user.email ?? null,
role: user.role ?? 'user',
authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null,
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: user.expires_at ?? null,
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 ?? emptyStats),
}))
)
stats: normalizeStats(
user.stats && typeof user.stats === "object" ? (user.stats as Record<string, unknown>) : null,
),
})),
);
} else {
setUsers([])
setUsers([]);
}
setError(null)
setError(null);
} catch (err) {
console.error(err)
setError('Could not load user list.')
console.error(err);
setError("Could not load user list.");
} finally {
setLoading(false)
setRefreshing(false)
setLoading(false);
setRefreshing(false);
}
}
}, [router]);
const syncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null)
setJellyseerrSyncBusy(true)
setJellyseerrSyncStatus(null);
setJellyseerrSyncBusy(true);
try {
const baseUrl = getApiBase()
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
method: 'POST',
})
method: "POST",
});
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sync failed')
const text = await response.text();
throw new Error(text || "Sync failed");
}
const data = await response.json()
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()
`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.')
console.error(err);
setJellyseerrSyncStatus("Could not sync Seerr users.");
} finally {
setJellyseerrSyncBusy(false)
setJellyseerrSyncBusy(false);
}
}
};
const resyncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null)
setJellyseerrResyncBusy(true)
setJellyseerrSyncStatus(null);
setJellyseerrResyncBusy(true);
try {
const baseUrl = getApiBase()
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
method: "POST",
});
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Resync failed')
const text = await response.text();
throw new Error(text || "Resync failed");
}
const data = await response.json()
const data = await response.json();
setJellyseerrSyncStatus(
`Reconciled service identities. Added ${data?.imported ?? 0} new users; existing accounts and settings were retained.`
)
await loadUsers()
`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.')
console.error(err);
setJellyseerrSyncStatus("Could not resync Seerr users.");
} finally {
setJellyseerrResyncBusy(false)
setJellyseerrResyncBusy(false);
}
}
};
const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null)
setBulkAutoSearchBusy(true);
setJellyseerrSyncStatus(null);
try {
const baseUrl = getApiBase()
const baseUrl = getApiBase();
const response = await authFetch(`${baseUrl}/admin/users/auto-search/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 text = await response.text();
throw new Error(text || "Bulk update failed");
}
const data = await response.json()
const data = await response.json();
setJellyseerrSyncStatus(
`${enabled ? 'Enabled' : 'Disabled'} auto search/download for ${data?.updated ?? 0} non-admin users.`
)
await loadUsers()
`${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.')
console.error(err);
setError("Could not update auto search/download for all users.");
} finally {
setBulkAutoSearchBusy(false)
setBulkAutoSearchBusy(false);
}
}
};
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
router.push("/login");
return;
}
void loadUsers()
}, [router])
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'
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])
dialog?.close();
document.body.style.overflow = previous;
controlsTrigger.current?.focus();
};
}, [controlsOpen]);
const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy
const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy;
if (loading) {
return <main className="card">Loading users...</main>
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 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.email || "",
user.role,
user.authProvider || '',
user.profileId != null ? String(user.profileId) : '',
]
return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
user.authProvider || "",
user.profileId != null ? String(user.profileId) : "",
];
return fields.some((field) => field.toLowerCase().includes(normalizedQuery));
})
: users
: users;
const filteredCountLabel =
filteredUsers.length === users.length
? `${users.length} users`
: `${filteredUsers.length} of ${users.length} users`
: `${filteredUsers.length} of ${users.length} users`;
const usersRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card users-rail-summary">
@@ -317,151 +306,299 @@ export default function UsersPage() {
<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>
<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>
<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>}
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)}>
<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>}
<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 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 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 &amp; 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>
<section className="user-management-panel"><h3>Automatic search &amp; 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>
<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 &amp; repairs</button>
<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 &amp; 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>
{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>
<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 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>
</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>
{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>
{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 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-subtext">
{user.email || 'No email on file'}
<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-subtext">
Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'}
<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>
<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 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-subtext">
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'}
<div className="user-directory-row-chevron" aria-hidden="true">
Open
</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>}
</Link>
))}
</div>
)}
</section>
)}
</AdminShell>
)
);
}