diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index ccab4e8..43e2c49 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -1,10 +1,11 @@ 'use client' -import { useEffect, useState } from 'react' +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' type AdminUser = { id: number @@ -81,6 +82,11 @@ const normalizeStats = (stats: any): UserStats => ({ export default function UsersPage() { const router = useRouter() + const [controlsOpen, setControlsOpen] = useState(false) + const controlsDialog = useRef(null) + const controlsTrigger = useRef(null) + const controlsClose = useRef(null) + const [refreshing, setRefreshing] = useState(false) const [users, setUsers] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) @@ -93,6 +99,7 @@ export default function UsersPage() { const [inviteStatus, setInviteStatus] = useState(null) const loadUsers = async () => { + setRefreshing(true) try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/users/summary`) @@ -139,6 +146,7 @@ export default function UsersPage() { setError('Could not load user list.') } finally { setLoading(false) + setRefreshing(false) } } @@ -169,7 +177,7 @@ export default function UsersPage() { const resyncJellyseerrUsers = async () => { const confirmed = window.confirm( - 'This will remove all non-admin users and re-import from Seerr. Continue?' + 'Rebuild the Magent directory from Seerr? This deletes all existing non-admin Magent accounts and creates accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Continue?' ) if (!confirmed) return setJellyseerrSyncStatus(null) @@ -250,6 +258,22 @@ export default function UsersPage() { void 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 || bulkInvitesBusy + if (loading) { return
Loading users...
} @@ -328,104 +352,40 @@ export default function UsersPage() { setControlsOpen(true)}>Manage users} > -
-
-
-
- Directory actions -
- - - -
-
-
- Seerr sync -
- - -
-
+ setControlsOpen(false)} onClose={() => setControlsOpen(false)}> +
+
Directory tools

Manage users

Account links, service sync and permissions for your user directory.

+ {error &&

{error}

} + {jellyseerrSyncStatus &&

{jellyseerrSyncStatus}

} + {inviteStatus &&

{inviteStatus}

} +
+

Directory actions

Review linked accounts, manage invitations or refresh the list.

+
Review account links ↗

Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.

+
Manage invitations ↗

Create invitations, review issued links and set invitation defaults.

+

Reload account status and request totals from Magent. Your search stays in place.

+
+

Seerr sync

Connect existing Magent accounts to their Seerr request accounts.

+

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.

+
Advanced: rebuild from Seerr

Deletes all non-admin Magent accounts, then imports accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Use this only when you intend to replace the directory.

+
+

Automatic search & download

Allow users to trigger automatic searches and downloads for their requests.

{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled

Applies to every existing non-admin account, including accounts outside your search results. Use an individual user's page to change just their access.

+

Invite access

Let users create and manage invitations for other people to join.

{inviteEnabledCount} of {nonAdminUsers.length} non-admin users enabled

Grants invite access to every existing non-admin account. Each user's invite limits still apply, and account blocks and expiry dates stay in effect. Administrators already have access.

+
Directory totals{usersRail}
- {error &&
{error}
} - {jellyseerrSyncStatus &&
{jellyseerrSyncStatus}
} -
-
-
-

Bulk controls

-

- Manage permissions for all existing non-admin users, not just the current search results. -

-
-
-
-
- Auto search/download - - {autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled - -
-
- - -
-
-
-
-
-
- Invite access - {inviteEnabledCount} of {nonAdminUsers.length} non-admin users enabled - Admins already have access. Existing invite limits, blocked accounts and expiry dates stay unchanged. -
-
- -
-
- {inviteStatus &&

{inviteStatus}

} -
+
+
+ {!controlsOpen && error &&

{error}

} + {!controlsOpen && jellyseerrSyncStatus &&

{jellyseerrSyncStatus}

} + {!controlsOpen && inviteStatus &&

{inviteStatus}

}

Directory search

- Filter by username, role, login provider, or assigned profile. + Find an account by username, email, role, login provider or profile ID. Select a user to manage their access.

{filteredCountLabel} @@ -437,14 +397,14 @@ export default function UsersPage() { setQuery(event.target.value)} - placeholder="Search username, login type, role, profile…" + placeholder="Search username, email, role, login provider or profile ID…" />
{filteredUsers.length === 0 ? ( -
No users found yet.
+
{normalizedQuery ? 'No users match your search. Try another name or email.' : 'No users found yet. Open Manage users to review the directory tools.'}
) : (
diff --git a/frontend/app/users/users.css b/frontend/app/users/users.css new file mode 100644 index 0000000..757137f --- /dev/null +++ b/frontend/app/users/users.css @@ -0,0 +1,33 @@ +.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) auto; } + .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; } +}