From 5dfe614d1594d9b4e0e4e32e6fe595cf9fca9ec7 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Thu, 26 Feb 2026 14:42:49 +1300 Subject: [PATCH] Build 2602261442: tidy users and invite layouts --- .build_number | 2 +- backend/app/build_info.py | 2 +- frontend/app/admin/invites/page.tsx | 236 +++++++++++++----- frontend/app/globals.css | 371 ++++++++++++++++++++++++++++ frontend/app/users/[id]/page.tsx | 310 +++++++++++++---------- frontend/app/users/page.tsx | 262 ++++++++++++-------- 6 files changed, 887 insertions(+), 296 deletions(-) diff --git a/.build_number b/.build_number index 29250bb..d617434 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602261409 +2602261442 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 08ed842..a558e8e 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2602261409" +BUILD_NUMBER = "2602261442" CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 4732ade..98f5e2b 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -63,6 +63,8 @@ type ProfileForm = { is_active: boolean } +type InviteManagementTab = 'bulk' | 'profiles' | 'invites' + const defaultInviteForm = (): InviteForm => ({ code: '', label: '', @@ -113,6 +115,7 @@ export default function AdminInviteManagementPage() { const [bulkProfileId, setBulkProfileId] = useState('') const [bulkExpiryDays, setBulkExpiryDays] = useState('') + const [activeTab, setActiveTab] = useState('bulk') const signupBaseUrl = useMemo(() => { if (typeof window === 'undefined') return '/signup' @@ -461,6 +464,9 @@ export default function AdminInviteManagementPage() { const nonAdminUsers = users.filter((user) => user.role !== 'admin') const profiledUsers = nonAdminUsers.filter((user) => user.profile_id != null).length const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length + const usableInvites = invites.filter((invite) => invite.is_usable !== false).length + const disabledInvites = invites.filter((invite) => invite.enabled === false).length + const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length return ( {loading ? 'Loading…' : 'Reload'} - - @@ -484,63 +504,165 @@ export default function AdminInviteManagementPage() { {error &&
{error}
} {status &&
{status}
} -
-

Blanket controls

-

- Apply invite profile defaults or expiry to all non-admin users. Individual users can still be edited from their user page. -

-
- Non-admin users: {nonAdminUsers.length} - Profile assigned: {profiledUsers} - Custom expiry set: {expiringUsers} +
+
+ Invites + {invites.length} + {usableInvites} usable • {disabledInvites} disabled
-
-
- - -
-
- - - -
+
+ Profiles + {profiles.length} + {activeProfiles} active profiles +
+
+ Non-admin users + {nonAdminUsers.length} + {profiledUsers} with profile +
+
+ Expiry rules + {expiringUsers} + users with custom expiry
+
+ + + +
+ + {activeTab === 'bulk' && ( +
+
+
+
+

Blanket controls

+

+ Apply invite profile defaults or expiry to all non-admin users. Individual users can still be edited from their user page. +

+
+
+
+ Non-admin users: {nonAdminUsers.length} + Profile assigned: {profiledUsers} + Custom expiry set: {expiringUsers} +
+
+
+ + +
+
+ + + +
+
+
+
+
+
+

How this page is organized

+

Use tabs to switch between blanket controls, reusable profiles, and invite links.

+
+
+
+
+
+
+ Profiles +
+

+ Create reusable account defaults and apply them to invite links or existing users. +

+
+
+ +
+
+
+
+
+ Invites +
+

+ Create and manage signup links, assign profiles, and copy shareable URLs. +

+
+
+ +
+
+
+
+
+ )} + + {activeTab === 'profiles' && (

{profileEditingId == null ? 'Create profile' : 'Edit profile'}

@@ -684,7 +806,9 @@ export default function AdminInviteManagementPage() { )}
+ )} + {activeTab === 'invites' && (

{inviteEditingId == null ? 'Create invite' : 'Edit invite'}

@@ -856,8 +980,8 @@ export default function AdminInviteManagementPage() { )}
+ )} ) } - diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 62a4f8f..686c05d 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -4129,3 +4129,374 @@ button:hover:not(:disabled) { flex-basis: 100%; } } + +/* 1.3 UI layout cleanup: users + invite management */ +.user-directory-control-grid { + display: grid; + grid-template-columns: 1.2fr minmax(320px, 0.8fr); + gap: 12px; + margin-top: 12px; + margin-bottom: 12px; + align-items: start; +} + +.user-directory-search-panel, +.user-directory-bulk-panel { + display: grid; + gap: 10px; +} + +.user-directory-panel-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.user-directory-panel-header h2 { + margin: 0; + font-size: 0.98rem; + letter-spacing: 0.01em; +} + +.user-directory-panel-header .lede { + margin: 4px 0 0; + max-width: 46ch; +} + +.user-directory-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 10px; + align-items: end; +} + +.user-directory-search { + width: 100%; +} + +.user-directory-search > label { + display: grid; + gap: 6px; +} + +.user-directory-search input { + width: 100%; +} + +.user-bulk-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} + +.user-bulk-summary { + display: grid; + gap: 4px; +} + +.user-bulk-summary strong { + font-size: 0.92rem; +} + +.user-bulk-summary span { + color: #9ea7b6; + font-size: 0.82rem; +} + +.user-bulk-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.user-directory-list { + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.015); + border-radius: 12px; + overflow: hidden; +} + +.user-directory-header { + display: grid; + grid-template-columns: 1.3fr 1.35fr 1fr 1.05fr auto; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + color: #9ea7b6; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.user-directory-row { + display: grid; + grid-template-columns: 1.3fr 1.35fr 1fr 1.05fr auto; + gap: 10px; + align-items: center; + padding: 13px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.04); + color: inherit; + text-decoration: none; + transition: background-color 120ms ease, border-color 120ms ease; +} + +.user-directory-row:first-of-type { + border-top: 0; +} + +.user-directory-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.user-directory-cell { + min-width: 0; + display: grid; + gap: 5px; +} + +.user-directory-cell--identity .user-directory-title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.user-directory-cell--identity strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.user-directory-subtext { + color: #98a1af; + font-size: 0.78rem; + line-height: 1.3; +} + +.user-directory-pill-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.user-directory-stats-inline { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px 10px; + color: #b8c0cc; + font-size: 0.78rem; +} + +.user-directory-stats-inline strong { + color: #eef2f7; + font-weight: 700; +} + +.user-directory-row-chevron { + color: #a9b2c2; + font-size: 0.76rem; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 999px; + padding: 4px 10px; + white-space: nowrap; +} + +.user-detail-page-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); + gap: 14px; + align-items: start; +} + +.user-detail-main-column, +.user-detail-side-column { + display: grid; + gap: 14px; +} + +.user-detail-panel { + display: grid; + gap: 12px; +} + +.user-detail-panel-header { + display: grid; + gap: 6px; +} + +.user-detail-panel-header h2 { + margin: 0; + font-size: 0.98rem; +} + +.user-detail-panel-header .lede { + margin: 0; +} + +.user-detail-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.user-detail-meta-item { + display: grid; + gap: 4px; + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.015); + border-radius: 10px; + padding: 10px 11px; +} + +.user-detail-meta-item .label { + color: #9ea7b6; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.user-detail-meta-item strong { + font-size: 0.9rem; + color: #e7ebf1; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.user-detail-control-stack { + display: grid; + gap: 8px; +} + +.user-detail-control-stack .toggle { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.015); +} + +.user-detail-control-stack > button { + justify-self: start; +} + +.user-detail-grid { + gap: 10px; +} + +.user-detail-stat { + border-radius: 10px; +} + +.invite-admin-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 12px; +} + +.invite-admin-summary-tile { + min-height: 96px; +} + +.admin-segmented { + display: inline-flex; + flex-wrap: wrap; + gap: 6px; + padding: 5px; + border: 1px solid rgba(255, 255, 255, 0.06); + background: rgba(255, 255, 255, 0.02); + border-radius: 12px; + margin-bottom: 12px; +} + +.admin-segmented button { + border: 1px solid transparent; + background: transparent; + color: #b6bfcb; + padding: 8px 12px; + border-radius: 9px; + font-size: 0.84rem; + font-weight: 600; +} + +.admin-segmented button:hover { + background: rgba(255, 255, 255, 0.03); + color: #e3e8ef; +} + +.admin-segmented button.is-active { + background: rgba(96, 132, 179, 0.14); + border-color: rgba(96, 132, 179, 0.25); + color: #e7eef9; +} + +.invite-admin-bulk-grid { + grid-template-columns: minmax(360px, 1.2fr) minmax(300px, 0.8fr); +} + +.admin-panel > h2 + .lede { + margin-top: -2px; +} + +@media (max-width: 1180px) { + .user-directory-control-grid { + grid-template-columns: 1fr; + } + + .user-detail-page-grid { + grid-template-columns: 1fr; + } + + .invite-admin-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .invite-admin-bulk-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 980px) { + .user-bulk-toolbar { + grid-template-columns: 1fr; + align-items: stretch; + } + + .user-bulk-actions { + justify-content: flex-start; + } + + .user-directory-header { + display: none; + } + + .user-directory-row { + grid-template-columns: 1fr; + gap: 8px; + align-items: start; + } + + .user-directory-cell { + gap: 6px; + } + + .user-directory-row-chevron { + justify-self: start; + } + + .user-detail-meta-grid { + grid-template-columns: 1fr; + } + + .invite-admin-summary-grid { + grid-template-columns: 1fr; + } +} diff --git a/frontend/app/users/[id]/page.tsx b/frontend/app/users/[id]/page.tsx index 8881be5..dfc9a72 100644 --- a/frontend/app/users/[id]/page.tsx +++ b/frontend/app/users/[id]/page.tsx @@ -344,114 +344,128 @@ export default function UserDetailPage() { {!user ? (
No user data found.
) : ( - <> -
-
-
+
+
+
+
{user.username} {user.is_blocked ? 'Blocked' : 'Active'} + + {user.is_expired ? 'Expired' : user.expires_at ? 'Expiry set' : 'No expiry'} +
-
- - Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'} - - Role: {user.role} - Login type: {user.auth_provider || 'local'} - Profile: {user.profile_id ?? 'None'} - - Expiry: {user.expires_at ? formatDateTime(user.expires_at) : 'Never'} - - Last login: {formatDateTime(user.last_login_at)} +

+ User identity, access state, and request history for this account. +

+
+
+
+ Jellyseerr ID + {user.jellyseerr_user_id ?? user.id ?? 'Unknown'} +
+
+ Role + {user.role} +
+
+ Login type + {user.auth_provider || 'local'} +
+
+ Assigned profile + {user.profile_id ?? 'None'} +
+
+ Last login + {formatDateTime(user.last_login_at)} +
+
+ Account expiry + {user.expires_at ? formatDateTime(user.expires_at) : 'Never'}
-
-
User controls
-
- - - +
+ +
+
+

Request statistics

+

Snapshot of request states and recent activity for this user.

+
+
+
+ Total + {stats?.total ?? 0}
-
- -
- - -
+
+ Ready + {stats?.ready ?? 0}
-
- -
- - -
+
+ Pending + {stats?.pending ?? 0}
+
+ Approved + {stats?.approved ?? 0} +
+
+ Working + {stats?.working ?? 0} +
+
+ Partial + {stats?.partial ?? 0} +
+
+ Declined + {stats?.declined ?? 0} +
+
+ In progress + {stats?.in_progress ?? 0} +
+
+ Last request + {formatDateTime(stats?.last_request_at)} +
+
+
+
+ +
+
+
+

Access controls

+

Role, login access, and auto-download behavior.

+
+
+ + + {user.role === 'admin' && (
Admins always have auto search/download access. @@ -459,46 +473,80 @@ export default function UserDetailPage() { )}
-
-
- Total - {stats?.total ?? 0} + +
+
+

Profile defaults

+

Assign or clear an invite profile for this user.

-
- Ready - {stats?.ready ?? 0} +
+ +
+ + +
-
- Pending - {stats?.pending ?? 0} +
+ +
+
+

Account expiry

+

Set a specific expiry date/time for this user account.

-
- Approved - {stats?.approved ?? 0} -
-
- Working - {stats?.working ?? 0} -
-
- Partial - {stats?.partial ?? 0} -
-
- Declined - {stats?.declined ?? 0} -
-
- In progress - {stats?.in_progress ?? 0} -
-
- Last request - {formatDateTime(stats?.last_request_at)} +
+ +
+ + +
- +
)} diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index f3005b3..762179c 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -82,6 +82,7 @@ export default function UsersPage() { const [users, setUsers] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const [query, setQuery] = useState('') const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState(null) const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false) const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false) @@ -135,44 +136,6 @@ export default function UsersPage() { } } - const toggleUserBlock = async (username: string, blocked: boolean) => { - try { - const baseUrl = getApiBase() - const response = await authFetch( - `${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`, - { method: 'POST' } - ) - if (!response.ok) { - throw new Error('Update failed') - } - await loadUsers() - } catch (err) { - console.error(err) - setError('Could not update user access.') - } - } - - const updateUserRole = async (username: string, role: string) => { - try { - const baseUrl = getApiBase() - const response = await authFetch( - `${baseUrl}/admin/users/${encodeURIComponent(username)}/role`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ role }), - } - ) - if (!response.ok) { - throw new Error('Update failed') - } - await loadUsers() - } catch (err) { - console.error(err) - setError('Could not update user role.') - } - } - const syncJellyseerrUsers = async () => { setJellyseerrSyncStatus(null) setJellyseerrSyncBusy(true) @@ -268,13 +231,35 @@ export default function UsersPage() { 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.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` return ( +
+ @@ -284,93 +269,156 @@ export default function UsersPage() { - +
} >
{error &&
{error}
} {jellyseerrSyncStatus &&
{jellyseerrSyncStatus}
} -
-
- Auto search/download - - {autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled - +
+
+ Total users + {users.length} + {adminCount} admin
-
- - +
+ Auto search + {autoSearchEnabledCount} + of {nonAdminUsers.length} non-admin users +
+
+ Blocked + {blockedCount} + {blockedCount ? 'Needs review' : 'No blocked users'} +
+
+ Expired + {expiredCount} + {expiredCount ? 'Access expired' : 'No expiries'}
- {users.length === 0 ? ( + +
+
+
+
+

Directory search

+

+ Filter by username, role, login provider, or assigned profile. +

+
+ {filteredCountLabel} +
+
+
+ +
+
+
+
+
+
+

Bulk controls

+

+ Auto search/download can be enabled or disabled for all non-admin users. +

+
+
+
+
+ Auto search/download + + {autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled + +
+
+ + +
+
+
+
+ {filteredUsers.length === 0 ? (
No users found yet.
) : ( -
- {users.map((user) => ( +
+
+ User + Access + Requests + Activity +
+ {filteredUsers.map((user) => ( -
-
+
+
{user.username} {user.role}
- - {user.isBlocked ? 'Blocked' : 'Active'} - -
-
- - Auto search {user.autoSearchEnabled === false ? 'Off' : 'On'} - - - {user.expiresAt - ? `Expiry ${user.isExpired ? 'expired' : formatExpiry(user.expiresAt)}` - : 'Expiry Never'} - - - Profile {user.profileId ?? 'None'} - -
-
-
- Total - {user.stats?.total ?? 0} -
-
- Ready - {user.stats?.ready ?? 0} -
-
- Pending - {user.stats?.pending ?? 0} -
-
- In progress - {user.stats?.in_progress ?? 0} +
+ Login: {user.authProvider || 'local'} • Profile: {user.profileId ?? 'None'}
-
- Login: {user.authProvider || 'local'} - Last login: {formatLastLogin(user.lastLoginAt)} - +
+
+ + {user.isBlocked ? 'Blocked' : 'Active'} + + + Auto {user.autoSearchEnabled === false ? 'Off' : 'On'} + + + {user.expiresAt ? (user.isExpired ? 'Expired' : 'Expiry set') : 'No expiry'} + +
+
+ {user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'} +
+
+
+
+ {user.stats?.total ?? 0} total + {user.stats?.ready ?? 0} ready + {user.stats?.pending ?? 0} pending + {user.stats?.in_progress ?? 0} in progress +
+
+
+
+ Last login: {formatLastLogin(user.lastLoginAt)} +
+
Last request: {formatLastRequest(user.stats?.last_request_at)} - +
+
+ ))}