From eeba143b41862da7261ed50e0bb4fc30e74113d1 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Mon, 2 Mar 2026 15:12:38 +1300 Subject: [PATCH] Add dedicated profile invites page and fix mobile admin layout --- .build_number | 2 +- backend/app/build_info.py | 2 +- frontend/app/globals.css | 163 +++++++ frontend/app/profile/invites/page.tsx | 624 ++++++++++++++++++++++++++ frontend/app/profile/page.tsx | 566 +++-------------------- frontend/app/ui/HeaderIdentity.tsx | 3 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- 8 files changed, 855 insertions(+), 511 deletions(-) create mode 100644 frontend/app/profile/invites/page.tsx diff --git a/.build_number b/.build_number index d0b75b7..3a3390b 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0103262251 +0203261511 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 6282e6f..2cbf598 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,4 +1,4 @@ -BUILD_NUMBER = "0103262251" +BUILD_NUMBER = "0203261511" 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 Seerr 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 Seerr (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/globals.css b/frontend/app/globals.css index 849fa4f..a0aafad 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -5221,6 +5221,26 @@ textarea { margin-top: 10px; } +.profile-quick-link-card { + display: flex; + justify-content: space-between; + align-items: start; + gap: 14px; + padding: 12px; + margin-bottom: 12px; + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.018); + border-radius: 6px; +} + +.profile-quick-link-card h2 { + margin: 0 0 4px; +} + +.profile-quick-link-card .lede { + margin: 0; +} + .profile-invites-section { display: grid; gap: 12px; @@ -5391,6 +5411,10 @@ textarea { } @media (max-width: 980px) { + .profile-quick-link-card { + display: grid; + } + .profile-invites-layout { grid-template-columns: 1fr; } @@ -6070,3 +6094,142 @@ textarea { grid-template-columns: 1fr; } } + +/* Final responsive admin shell stabilization */ +.admin-shell, +.admin-shell-nav, +.admin-card, +.admin-shell-rail, +.admin-sidebar, +.admin-panel { + min-width: 0; +} + +@media (max-width: 1280px) { + .admin-shell { + grid-template-columns: minmax(220px, 250px) minmax(0, 1fr); + grid-template-areas: + "nav main" + "nav rail"; + align-items: start; + } + + .admin-shell-nav { + grid-area: nav; + } + + .admin-card { + grid-area: main; + grid-column: auto; + } + + .admin-shell-rail { + grid-area: rail; + grid-column: auto; + position: static; + top: auto; + width: 100%; + } +} + +@media (max-width: 1080px) { + .page { + width: min(100%, calc(100vw - 12px)); + max-width: none; + padding-inline: 6px; + } + + .card, + .admin-card { + padding: 20px; + } + + .admin-shell { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "nav" + "main" + "rail"; + gap: 16px; + } + + .admin-shell-nav, + .admin-card, + .admin-shell-rail { + grid-column: auto; + width: 100%; + } + + .admin-shell-nav { + position: static; + top: auto; + } + + .admin-sidebar, + .admin-rail-stack, + .admin-rail-card, + .maintenance-layout, + .maintenance-tools-panel, + .cache-table { + width: 100%; + } + + .admin-grid, + .users-page-toolbar-grid, + .users-summary-grid, + .users-page-overview-grid, + .maintenance-action-grid, + .schedule-grid, + .diagnostics-inline-summary, + .diagnostics-grid { + grid-template-columns: 1fr; + } + + .settings-nav, + .settings-links { + display: grid; + grid-template-columns: 1fr; + } + + .settings-group { + min-width: 0; + } + + .settings-links a { + justify-content: flex-start; + } + + .settings-section-actions, + .diagnostics-control-panel, + .diagnostics-control-actions, + .log-actions { + display: grid; + width: 100%; + justify-content: stretch; + } + + .settings-section-actions > *, + .diagnostics-control-actions > *, + .log-actions > * { + width: 100%; + } + + .sync-meta, + .diagnostic-card-top, + .diagnostics-category-header, + .users-summary-row { + flex-direction: column; + align-items: stretch; + } + + .cache-row { + grid-template-columns: 1fr; + } + + .cache-row span { + white-space: normal; + overflow: visible; + text-overflow: clip; + overflow-wrap: anywhere; + } +} diff --git a/frontend/app/profile/invites/page.tsx b/frontend/app/profile/invites/page.tsx new file mode 100644 index 0000000..9f399a6 --- /dev/null +++ b/frontend/app/profile/invites/page.tsx @@ -0,0 +1,624 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' +import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' + +type ProfileInfo = { + username: string + role: string + auth_provider: string + invite_management_enabled?: boolean +} + +type ProfileResponse = { + user: ProfileInfo +} + +type OwnedInvite = { + id: number + code: string + label?: string | null + description?: string | null + recipient_email?: string | null + max_uses?: number | null + use_count: number + remaining_uses?: number | null + enabled: boolean + expires_at?: string | null + is_expired?: boolean + is_usable?: boolean + created_at?: string | null + updated_at?: string | null +} + +type OwnedInvitesResponse = { + invites?: OwnedInvite[] + count?: number + invite_access?: { + enabled?: boolean + managed_by_master?: boolean + } + master_invite?: { + id: number + code: string + label?: string | null + description?: string | null + max_uses?: number | null + enabled?: boolean + expires_at?: string | null + is_usable?: boolean + } | null +} + +type OwnedInviteForm = { + code: string + label: string + description: string + recipient_email: string + max_uses: string + expires_at: string + enabled: boolean + send_email: boolean + message: string +} + +const defaultOwnedInviteForm = (): OwnedInviteForm => ({ + code: '', + label: '', + description: '', + recipient_email: '', + max_uses: '', + expires_at: '', + enabled: true, + send_email: false, + message: '', +}) + +const formatDate = (value?: string | null) => { + if (!value) return 'Never' + const date = new Date(value) + if (Number.isNaN(date.valueOf())) return value + return date.toLocaleString() +} + +export default function ProfileInvitesPage() { + const router = useRouter() + const [profile, setProfile] = useState(null) + const [inviteStatus, setInviteStatus] = useState(null) + const [inviteError, setInviteError] = useState(null) + const [invites, setInvites] = useState([]) + const [inviteSaving, setInviteSaving] = useState(false) + const [inviteEditingId, setInviteEditingId] = useState(null) + const [inviteForm, setInviteForm] = useState(defaultOwnedInviteForm()) + const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false) + const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false) + const [masterInviteTemplate, setMasterInviteTemplate] = useState(null) + const [loading, setLoading] = useState(true) + + const signupBaseUrl = useMemo(() => { + if (typeof window === 'undefined') return '/signup' + return `${window.location.origin}/signup` + }, []) + + const loadPage = async () => { + const baseUrl = getApiBase() + const [profileResponse, invitesResponse] = await Promise.all([ + authFetch(`${baseUrl}/auth/profile`), + authFetch(`${baseUrl}/auth/profile/invites`), + ]) + if (!profileResponse.ok || !invitesResponse.ok) { + if (profileResponse.status === 401 || invitesResponse.status === 401) { + clearToken() + router.push('/login') + return + } + throw new Error('Could not load invite tools.') + } + const [profileData, inviteData] = (await Promise.all([ + profileResponse.json(), + invitesResponse.json(), + ])) as [ProfileResponse, OwnedInvitesResponse] + const user = profileData?.user ?? {} + setProfile({ + username: user?.username ?? 'Unknown', + role: user?.role ?? 'user', + auth_provider: user?.auth_provider ?? 'local', + invite_management_enabled: Boolean(user?.invite_management_enabled ?? false), + }) + setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) + setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false)) + setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false)) + setMasterInviteTemplate(inviteData?.master_invite ?? null) + } + + useEffect(() => { + if (!getToken()) { + router.push('/login') + return + } + const load = async () => { + try { + await loadPage() + } catch (err) { + console.error(err) + setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.') + } finally { + setLoading(false) + } + } + void load() + }, [router]) + + const resetInviteEditor = () => { + setInviteEditingId(null) + setInviteForm(defaultOwnedInviteForm()) + } + + const editInvite = (invite: OwnedInvite) => { + setInviteEditingId(invite.id) + setInviteError(null) + setInviteStatus(null) + setInviteForm({ + code: invite.code ?? '', + label: invite.label ?? '', + description: invite.description ?? '', + recipient_email: invite.recipient_email ?? '', + max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '', + expires_at: invite.expires_at ?? '', + enabled: invite.enabled !== false, + send_email: false, + message: '', + }) + } + + const reloadInvites = async () => { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/auth/profile/invites`) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + throw new Error(`Invite refresh failed: ${response.status}`) + } + const data = (await response.json()) as OwnedInvitesResponse + setInvites(Array.isArray(data?.invites) ? data.invites : []) + setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false)) + setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false)) + setMasterInviteTemplate(data?.master_invite ?? null) + } + + const saveInvite = async (event: React.FormEvent) => { + event.preventDefault() + setInviteSaving(true) + setInviteError(null) + setInviteStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch( + inviteEditingId == null + ? `${baseUrl}/auth/profile/invites` + : `${baseUrl}/auth/profile/invites/${inviteEditingId}`, + { + method: inviteEditingId == null ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: inviteForm.code || null, + label: inviteForm.label || null, + description: inviteForm.description || null, + recipient_email: inviteForm.recipient_email || null, + max_uses: inviteForm.max_uses || null, + expires_at: inviteForm.expires_at || null, + enabled: inviteForm.enabled, + send_email: inviteForm.send_email, + message: inviteForm.message || null, + }), + } + ) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || 'Invite save failed') + } + const data = await response.json().catch(() => ({})) + if (data?.email?.status === 'ok') { + setInviteStatus( + `${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.` + ) + } else if (data?.email?.status === 'error') { + setInviteStatus( + `${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}` + ) + } else { + setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.') + } + resetInviteEditor() + await reloadInvites() + } catch (err) { + console.error(err) + setInviteError(err instanceof Error ? err.message : 'Could not save invite.') + } finally { + setInviteSaving(false) + } + } + + const deleteInvite = async (invite: OwnedInvite) => { + if (!window.confirm(`Delete invite "${invite.code}"?`)) return + setInviteError(null) + setInviteStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, { + method: 'DELETE', + }) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || 'Invite delete failed') + } + if (inviteEditingId === invite.id) { + resetInviteEditor() + } + setInviteStatus(`Deleted invite ${invite.code}.`) + await reloadInvites() + } catch (err) { + console.error(err) + setInviteError(err instanceof Error ? err.message : 'Could not delete invite.') + } + } + + const copyInviteLink = async (invite: OwnedInvite) => { + const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}` + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(url) + setInviteStatus(`Copied invite link for ${invite.code}.`) + } else { + window.prompt('Copy invite link', url) + } + } catch (err) { + console.error(err) + window.prompt('Copy invite link', url) + } + } + + const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled + + if (loading) { + return
Loading invite tools...
+ } + + return ( +
+
+
+

My invites

+

Create invite links, email them directly, and track who you have invited.

+
+
+ +
+
+ + {profile ? ( +
+ Signed in as {profile.username} ({profile.role}). +
+ ) : null} + +
+
+ + + + +
+
+ + {inviteError &&
{inviteError}
} + {inviteStatus &&
{inviteStatus}
} + + {!canManageInvites ? ( +
+

Invite access is disabled

+

+ Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile. +

+
+ +
+
+ ) : ( +
+
+
+

Invite workspace

+

+ {inviteManagedByMaster + ? 'Create and manage invite links you have issued. New invites use the admin master invite rule.' + : 'Create and manage invite links you have issued. New invites use your account defaults.'} +

+
+
+ +
+
+

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

+

+ Save a recipient email, send the invite immediately, and keep the generated link ready to copy. +

+ {inviteManagedByMaster && masterInviteTemplate ? ( +
+ Using master invite rule {masterInviteTemplate.code} + {masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin. +
+ ) : null} +
+
+
+ Identity + Optional code and label for easier tracking. +
+
+ + +
+
+ +
+
+ Description + Optional note shown on the signup page. +
+
+