From ec408df2a16f4043049056ecc0e3f2c4c5e4e687 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Thu, 26 Feb 2026 14:10:18 +1300 Subject: [PATCH] Build 2602261409: unify invite management controls --- .build_number | 2 +- backend/app/build_info.py | 2 +- frontend/app/admin/invites/page.tsx | 576 ++++++++++++++++++++++++--- frontend/app/admin/profiles/page.tsx | 335 +--------------- frontend/app/ui/AdminSidebar.tsx | 3 +- frontend/app/users/page.tsx | 184 --------- 6 files changed, 516 insertions(+), 586 deletions(-) diff --git a/.build_number b/.build_number index e6152fe..29250bb 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602260214 +2602261409 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index ba863fd..08ed842 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2602260214" +BUILD_NUMBER = "2602261409" 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 781b361..4732ade 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -5,9 +5,24 @@ import { useRouter } from 'next/navigation' import AdminShell from '../../ui/AdminShell' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' -type ProfileOption = { +type AdminUserLite = { + id: number + username: string + role: string + profile_id?: number | null + expires_at?: string | null +} + +type Profile = { id: number name: string + description?: string | null + role: 'user' | 'admin' + auto_search_enabled: boolean + account_expires_days?: number | null + is_active: boolean + assigned_users?: number + assigned_invites?: number } type Invite = { @@ -16,7 +31,7 @@ type Invite = { label?: string | null description?: string | null profile_id?: number | null - profile?: ProfileOption | null + profile?: { id: number; name: string } | null role?: 'user' | 'admin' | null max_uses?: number | null use_count: number @@ -39,7 +54,16 @@ type InviteForm = { expires_at: string } -const defaultForm = (): InviteForm => ({ +type ProfileForm = { + name: string + description: string + role: 'user' | 'admin' + auto_search_enabled: boolean + account_expires_days: string + is_active: boolean +} + +const defaultInviteForm = (): InviteForm => ({ code: '', label: '', description: '', @@ -50,6 +74,15 @@ const defaultForm = (): InviteForm => ({ expires_at: '', }) +const defaultProfileForm = (): ProfileForm => ({ + name: '', + description: '', + role: 'user', + auto_search_enabled: true, + account_expires_days: '', + is_active: true, +}) + const formatDate = (value?: string | null) => { if (!value) return 'Never' const date = new Date(value) @@ -57,16 +90,29 @@ const formatDate = (value?: string | null) => { return date.toLocaleString() } -export default function AdminInvitesPage() { +export default function AdminInviteManagementPage() { const router = useRouter() const [invites, setInvites] = useState([]) - const [profiles, setProfiles] = useState([]) + const [profiles, setProfiles] = useState([]) + const [users, setUsers] = useState([]) const [loading, setLoading] = useState(true) - const [saving, setSaving] = useState(false) + + const [inviteSaving, setInviteSaving] = useState(false) + const [profileSaving, setProfileSaving] = useState(false) + const [bulkProfileBusy, setBulkProfileBusy] = useState(false) + const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false) + const [error, setError] = useState(null) const [status, setStatus] = useState(null) - const [editingId, setEditingId] = useState(null) - const [form, setForm] = useState(defaultForm()) + + const [inviteEditingId, setInviteEditingId] = useState(null) + const [inviteForm, setInviteForm] = useState(defaultInviteForm()) + + const [profileEditingId, setProfileEditingId] = useState(null) + const [profileForm, setProfileForm] = useState(defaultProfileForm()) + + const [bulkProfileId, setBulkProfileId] = useState('') + const [bulkExpiryDays, setBulkExpiryDays] = useState('') const signupBaseUrl = useMemo(() => { if (typeof window === 'undefined') return '/signup' @@ -95,9 +141,10 @@ export default function AdminInvitesPage() { setError(null) try { const baseUrl = getApiBase() - const [inviteRes, profileRes] = await Promise.all([ + const [inviteRes, profileRes, usersRes] = await Promise.all([ authFetch(`${baseUrl}/admin/invites`), authFetch(`${baseUrl}/admin/profiles`), + authFetch(`${baseUrl}/admin/users`), ]) if (!inviteRes.ok) { if (handleAuthResponse(inviteRes)) return @@ -107,18 +154,21 @@ export default function AdminInvitesPage() { if (handleAuthResponse(profileRes)) return throw new Error(`Failed to load profiles (${profileRes.status})`) } - const [inviteData, profileData] = await Promise.all([inviteRes.json(), profileRes.json()]) + if (!usersRes.ok) { + if (handleAuthResponse(usersRes)) return + throw new Error(`Failed to load users (${usersRes.status})`) + } + const [inviteData, profileData, usersData] = await Promise.all([ + inviteRes.json(), + profileRes.json(), + usersRes.json(), + ]) setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) - const profileRows = Array.isArray(profileData?.profiles) ? profileData.profiles : [] - setProfiles( - profileRows.map((profile: any) => ({ - id: Number(profile.id ?? 0), - name: String(profile.name ?? 'Unnamed'), - })) - ) + setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : []) + setUsers(Array.isArray(usersData?.users) ? usersData.users : []) } catch (err) { console.error(err) - setError('Could not load invites.') + setError('Could not load invite management data.') } finally { setLoading(false) } @@ -128,14 +178,14 @@ export default function AdminInvitesPage() { void loadData() }, []) - const resetEditor = () => { - setEditingId(null) - setForm(defaultForm()) + const resetInviteEditor = () => { + setInviteEditingId(null) + setInviteForm(defaultInviteForm()) } const editInvite = (invite: Invite) => { - setEditingId(invite.id) - setForm({ + setInviteEditingId(invite.id) + setInviteForm({ code: invite.code ?? '', label: invite.label ?? '', description: invite.description ?? '', @@ -154,25 +204,27 @@ export default function AdminInvitesPage() { const saveInvite = async (event: React.FormEvent) => { event.preventDefault() - setSaving(true) + setInviteSaving(true) setError(null) setStatus(null) try { const baseUrl = getApiBase() const payload = { - code: form.code || null, - label: form.label || null, - description: form.description || null, - profile_id: form.profile_id || null, - role: form.role || null, - max_uses: form.max_uses || null, - enabled: form.enabled, - expires_at: form.expires_at || null, + code: inviteForm.code || null, + label: inviteForm.label || null, + description: inviteForm.description || null, + profile_id: inviteForm.profile_id || null, + role: inviteForm.role || null, + max_uses: inviteForm.max_uses || null, + enabled: inviteForm.enabled, + expires_at: inviteForm.expires_at || null, } const url = - editingId == null ? `${baseUrl}/admin/invites` : `${baseUrl}/admin/invites/${editingId}` + inviteEditingId == null + ? `${baseUrl}/admin/invites` + : `${baseUrl}/admin/invites/${inviteEditingId}` const response = await authFetch(url, { - method: editingId == null ? 'POST' : 'PUT', + method: inviteEditingId == null ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) @@ -181,14 +233,14 @@ export default function AdminInvitesPage() { const text = await response.text() throw new Error(text || 'Save failed') } - setStatus(editingId == null ? 'Invite created.' : 'Invite updated.') - resetEditor() + setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.') + resetInviteEditor() await loadData() } catch (err) { console.error(err) setError(err instanceof Error ? err.message : 'Could not save invite.') } finally { - setSaving(false) + setInviteSaving(false) } } @@ -206,7 +258,7 @@ export default function AdminInvitesPage() { const text = await response.text() throw new Error(text || 'Delete failed') } - if (editingId === invite.id) resetEditor() + if (inviteEditingId === invite.id) resetInviteEditor() setStatus(`Deleted invite ${invite.code}.`) await loadData() } catch (err) { @@ -230,27 +282,412 @@ export default function AdminInvitesPage() { } } + const resetProfileEditor = () => { + setProfileEditingId(null) + setProfileForm(defaultProfileForm()) + } + + const editProfile = (profile: Profile) => { + setProfileEditingId(profile.id) + setProfileForm({ + name: profile.name ?? '', + description: profile.description ?? '', + role: profile.role ?? 'user', + auto_search_enabled: Boolean(profile.auto_search_enabled), + account_expires_days: + typeof profile.account_expires_days === 'number' ? String(profile.account_expires_days) : '', + is_active: profile.is_active !== false, + }) + setStatus(null) + setError(null) + } + + const saveProfile = async (event: React.FormEvent) => { + event.preventDefault() + setProfileSaving(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const payload = { + name: profileForm.name, + description: profileForm.description || null, + role: profileForm.role, + auto_search_enabled: profileForm.auto_search_enabled, + account_expires_days: profileForm.account_expires_days || null, + is_active: profileForm.is_active, + } + const url = + profileEditingId == null + ? `${baseUrl}/admin/profiles` + : `${baseUrl}/admin/profiles/${profileEditingId}` + const response = await authFetch(url, { + method: profileEditingId == null ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Save failed') + } + setStatus(profileEditingId == null ? 'Profile created.' : 'Profile updated.') + resetProfileEditor() + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not save profile.') + } finally { + setProfileSaving(false) + } + } + + const deleteProfile = async (profile: Profile) => { + if (!window.confirm(`Delete profile "${profile.name}"?`)) return + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/profiles/${profile.id}`, { + method: 'DELETE', + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Delete failed') + } + if (profileEditingId === profile.id) resetProfileEditor() + if (bulkProfileId === String(profile.id)) setBulkProfileId('') + setStatus(`Deleted profile "${profile.name}".`) + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not delete profile.') + } + } + + const bulkApplyProfile = async () => { + setBulkProfileBusy(true) + setStatus(null) + setError(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/users/profile/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + profile_id: bulkProfileId || null, + scope: 'non-admin-users', + }), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Bulk profile update failed') + } + const data = await response.json() + setStatus( + bulkProfileId + ? `Applied profile ${bulkProfileId} to ${data?.updated ?? 0} non-admin users.` + : `Cleared profile assignment for ${data?.updated ?? 0} non-admin users.` + ) + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not apply profile to all users.') + } finally { + setBulkProfileBusy(false) + } + } + + const bulkSetExpiryDays = async () => { + if (!bulkExpiryDays.trim()) { + setError('Enter expiry days before applying bulk expiry.') + return + } + setBulkExpiryBusy(true) + setStatus(null) + setError(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ days: bulkExpiryDays, scope: 'non-admin-users' }), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Bulk expiry update failed') + } + const data = await response.json() + setStatus(`Set expiry for ${data?.updated ?? 0} non-admin users (${bulkExpiryDays} days).`) + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not set expiry for all users.') + } finally { + setBulkExpiryBusy(false) + } + } + + const bulkClearExpiry = async () => { + setBulkExpiryBusy(true) + setStatus(null) + setError(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clear: true, scope: 'non-admin-users' }), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Bulk expiry clear failed') + } + const data = await response.json() + setStatus(`Cleared expiry for ${data?.updated ?? 0} non-admin users.`) + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not clear expiry for all users.') + } finally { + setBulkExpiryBusy(false) + } + } + + 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 + return ( - + } >
{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} +
+
+
+ + +
+
+ + + +
+
+
+
-

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

+

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

+

+ Profiles define defaults applied when a user signs up using an invite. +

+
+ +