diff --git a/.build_number b/.build_number index 4f3dbff..7f7a2e9 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602261605 +2602261636 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 79f59ff..e6dfb07 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2602261605" +BUILD_NUMBER = "2602261636" 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/backend/app/routers/auth.py b/backend/app/routers/auth.py index 00826cb..33299a3 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,4 +1,6 @@ from datetime import datetime, timedelta, timezone +import secrets +import string import httpx from fastapi import APIRouter, HTTPException, status, Depends @@ -14,6 +16,11 @@ from ..db import ( set_jellyfin_auth_cache, set_user_jellyseerr_id, get_signup_invite_by_code, + get_signup_invite_by_id, + list_signup_invites, + create_signup_invite, + update_signup_invite, + delete_signup_invite, increment_signup_invite_use, get_user_profile, get_user_activity, @@ -156,6 +163,118 @@ def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict: } +def _parse_optional_positive_int(value: object, field_name: str) -> int | None: + if value is None or value == "": + return None + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"{field_name} must be a number") from exc + if parsed <= 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"{field_name} must be greater than 0", + ) + return parsed + + +def _parse_optional_expires_at(value: object) -> str | None: + if value is None or value == "": + return None + if not isinstance(value, str): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="expires_at must be an ISO datetime string", + ) + candidate = value.strip() + if not candidate: + return None + try: + parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="expires_at must be a valid ISO datetime", + ) from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.isoformat() + + +def _normalize_invite_code(value: str | None) -> str: + raw = (value or "").strip().upper() + filtered = "".join(ch for ch in raw if ch.isalnum()) + if len(filtered) < 6: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invite code must be at least 6 letters/numbers.", + ) + return filtered + + +def _generate_invite_code(length: int = 12) -> str: + alphabet = string.ascii_uppercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def _same_username(a: object, b: object) -> bool: + if not isinstance(a, str) or not isinstance(b, str): + return False + return a.strip().lower() == b.strip().lower() + + +def _serialize_self_invite(invite: dict) -> dict: + profile = None + profile_id = invite.get("profile_id") + if profile_id is not None: + try: + profile = get_user_profile(int(profile_id)) + except Exception: + profile = None + return { + "id": invite.get("id"), + "code": invite.get("code"), + "label": invite.get("label"), + "description": invite.get("description"), + "profile_id": invite.get("profile_id"), + "profile": ( + {"id": profile.get("id"), "name": profile.get("name")} + if isinstance(profile, dict) + else None + ), + "role": invite.get("role"), + "max_uses": invite.get("max_uses"), + "use_count": invite.get("use_count", 0), + "remaining_uses": invite.get("remaining_uses"), + "enabled": bool(invite.get("enabled")), + "expires_at": invite.get("expires_at"), + "is_expired": bool(invite.get("is_expired")), + "is_usable": bool(invite.get("is_usable")), + "created_at": invite.get("created_at"), + "updated_at": invite.get("updated_at"), + "created_by": invite.get("created_by"), + } + + +def _current_user_invites(username: str) -> list[dict]: + owned = [ + invite + for invite in list_signup_invites() + if _same_username(invite.get("created_by"), username) + ] + owned.sort(key=lambda item: (str(item.get("created_at") or ""), int(item.get("id") or 0)), reverse=True) + return owned + + +def _get_owned_invite(invite_id: int, current_user: dict) -> dict: + invite = get_signup_invite_by_id(invite_id) + if not invite: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") + if not _same_username(invite.get("created_by"), current_user.get("username")): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only manage your own invites") + return invite + + @router.post("/login") async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: user = verify_user_password(form_data.username, form_data.password) @@ -444,6 +563,124 @@ async def profile(current_user: dict = Depends(get_current_user)) -> dict: } +@router.get("/profile/invites") +async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict: + username = str(current_user.get("username") or "").strip() + if not username: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") + invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)] + return {"invites": invites, "count": len(invites)} + + +@router.post("/profile/invites") +async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: + if not isinstance(payload, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + username = str(current_user.get("username") or "").strip() + if not username: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") + + requested_code = payload.get("code") + if isinstance(requested_code, str) and requested_code.strip(): + code = _normalize_invite_code(requested_code) + existing = get_signup_invite_by_code(code) + if existing: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists") + else: + code = "" + for _ in range(20): + candidate = _generate_invite_code() + if not get_signup_invite_by_code(candidate): + code = candidate + break + if not code: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code") + + label = payload.get("label") + description = payload.get("description") + if label is not None: + label = str(label).strip() or None + if description is not None: + description = str(description).strip() or None + + max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") + expires_at = _parse_optional_expires_at(payload.get("expires_at")) + enabled = bool(payload.get("enabled", True)) + + profile_id = current_user.get("profile_id") + if not isinstance(profile_id, int) or profile_id <= 0: + profile_id = None + + invite = create_signup_invite( + code=code, + label=label, + description=description, + profile_id=profile_id, + role="user", + max_uses=max_uses, + enabled=enabled, + expires_at=expires_at, + created_by=username, + ) + return {"status": "ok", "invite": _serialize_self_invite(invite)} + + +@router.put("/profile/invites/{invite_id}") +async def update_profile_invite( + invite_id: int, payload: dict, current_user: dict = Depends(get_current_user) +) -> dict: + if not isinstance(payload, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + existing = _get_owned_invite(invite_id, current_user) + + requested_code = payload.get("code", existing.get("code")) + if isinstance(requested_code, str) and requested_code.strip(): + code = _normalize_invite_code(requested_code) + else: + code = str(existing.get("code") or "").strip() + if not code: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required") + duplicate = get_signup_invite_by_code(code) + if duplicate and int(duplicate.get("id") or 0) != int(existing.get("id") or 0): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Invite code already exists") + + label = payload.get("label", existing.get("label")) + description = payload.get("description", existing.get("description")) + if label is not None: + label = str(label).strip() or None + if description is not None: + description = str(description).strip() or None + + max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses") + expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at"))) + enabled_raw = payload.get("enabled", existing.get("enabled")) + enabled = bool(enabled_raw) + + invite = update_signup_invite( + invite_id, + code=code, + label=label, + description=description, + profile_id=existing.get("profile_id"), + role=existing.get("role"), + max_uses=max_uses, + enabled=enabled, + expires_at=expires_at, + ) + if not invite: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") + return {"status": "ok", "invite": _serialize_self_invite(invite)} + + +@router.delete("/profile/invites/{invite_id}") +async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict: + _get_owned_invite(invite_id, current_user) + deleted = delete_signup_invite(invite_id) + if not deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") + return {"status": "ok"} + + @router.post("/password") async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: if current_user.get("auth_provider") != "local": diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 250ae0d..3eb22cb 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -102,6 +102,7 @@ export default function AdminInviteManagementPage() { const [invites, setInvites] = useState([]) const [profiles, setProfiles] = useState([]) const [users, setUsers] = useState([]) + const [jellyfinUsersCount, setJellyfinUsersCount] = useState(null) const [loading, setLoading] = useState(true) const [inviteSaving, setInviteSaving] = useState(false) @@ -175,6 +176,20 @@ export default function AdminInviteManagementPage() { setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : []) setUsers(Array.isArray(usersData?.users) ? usersData.users : []) + try { + const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`) + if (jellyfinRes.ok) { + const jellyfinData = await jellyfinRes.json() + setJellyfinUsersCount(Array.isArray(jellyfinData?.users) ? jellyfinData.users.length : 0) + } else if (jellyfinRes.status === 401 || jellyfinRes.status === 403) { + if (handleAuthResponse(jellyfinRes)) return + } else { + setJellyfinUsersCount(null) + } + } catch (jellyfinErr) { + console.warn('Could not load Jellyfin user count for invite overview', jellyfinErr) + setJellyfinUsersCount(null) + } } catch (err) { console.error(err) setError('Could not load invite management data.') @@ -635,12 +650,19 @@ export default function AdminInviteManagementPage() {
- Non-admin users + Local non-admin accounts
{nonAdminUsers.length} {profiledUsers} with profile
+
+ Jellyfin users +
+ {jellyfinUsersCount ?? '—'} + {jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'} +
+
Expiry rules
@@ -724,12 +746,13 @@ export default function AdminInviteManagementPage() {

Blanket controls

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

- Non-admin users: {nonAdminUsers.length} + Local non-admin users: {nonAdminUsers.length} + Jellyfin users: {jellyfinUsersCount ?? '—'} Profile assigned: {profiledUsers} Custom expiry set: {expiringUsers}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 28cfdff..4026f25 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -4897,3 +4897,96 @@ textarea { grid-template-columns: 1fr; } } + +/* Profile self-service invite management */ +.profile-invites-section { + display: grid; + gap: 12px; +} + +.profile-invites-layout { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr); + gap: 14px; + align-items: start; +} + +.profile-invites-list { + display: grid; + gap: 10px; + min-width: 0; +} + +.profile-invite-form-card { + border: 1px solid rgba(255, 255, 255, 0.05); + background: rgba(255, 255, 255, 0.018); + border-radius: 6px; + padding: 12px; + display: grid; + gap: 10px; + min-width: 0; +} + +.profile-invite-form-card h3 { + font-size: 1rem; + color: #edf2f8; +} + +.profile-invite-form-lede, +.profile-invite-hint { + color: #9ea7b6; +} + +.profile-invite-hint code { + color: #d8e2ef; +} + +@media (max-width: 980px) { + .profile-invites-layout { + grid-template-columns: 1fr; + } +} + +/* Final header account menu stacking override (must be last) */ +.page, +.header, +.header-left, +.header-right, +.header-nav, +.header-actions, +.signed-in-menu { + overflow: visible !important; +} + +.header { + position: relative !important; + isolation: isolate; + z-index: 20 !important; +} + +.header-nav, +.header-actions { + position: relative; + z-index: 1 !important; +} + +.header-actions a, +.header-actions .header-link { + position: relative; + z-index: 1; +} + +.header-right { + position: relative !important; + z-index: 4000 !important; +} + +.signed-in-menu { + position: relative !important; + z-index: 4500 !important; +} + +.signed-in-dropdown { + position: absolute !important; + z-index: 5000 !important; +} diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index e71758d..a8351bc 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' @@ -47,6 +47,45 @@ type ProfileResponse = { activity: ProfileActivity } +type OwnedInvite = { + id: number + code: string + label?: string | null + description?: 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 +} + +type OwnedInviteForm = { + code: string + label: string + description: string + max_uses: string + expires_at: string + enabled: boolean +} + +const defaultOwnedInviteForm = (): OwnedInviteForm => ({ + code: '', + label: '', + description: '', + max_uses: '', + expires_at: '', + enabled: true, +}) + const formatDate = (value?: string | null) => { if (!value) return 'Never' const date = new Date(value) @@ -72,8 +111,19 @@ export default function ProfilePage() { const [currentPassword, setCurrentPassword] = useState('') const [newPassword, setNewPassword] = useState('') const [status, setStatus] = 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 [loading, setLoading] = useState(true) + const signupBaseUrl = useMemo(() => { + if (typeof window === 'undefined') return '/signup' + return `${window.location.origin}/signup` + }, []) + useEffect(() => { if (!getToken()) { router.push('/login') @@ -82,13 +132,19 @@ export default function ProfilePage() { const load = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/profile`) - if (!response.ok) { + const [profileResponse, invitesResponse] = await Promise.all([ + authFetch(`${baseUrl}/auth/profile`), + authFetch(`${baseUrl}/auth/profile/invites`), + ]) + if (!profileResponse.ok || !invitesResponse.ok) { clearToken() router.push('/login') return } - const data = await response.json() + const [data, inviteData] = (await Promise.all([ + profileResponse.json(), + invitesResponse.json(), + ])) as [ProfileResponse, OwnedInvitesResponse] const user = data?.user ?? {} setProfile({ username: user?.username ?? 'Unknown', @@ -97,6 +153,7 @@ export default function ProfilePage() { }) setStats(data?.stats ?? null) setActivity(data?.activity ?? null) + setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) } catch (err) { console.error(err) setStatus('Could not load your profile.') @@ -137,6 +194,128 @@ export default function ProfilePage() { } } + 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 ?? '', + max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '', + expires_at: invite.expires_at ?? '', + enabled: invite.enabled !== false, + }) + } + + 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 : []) + } + + 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, + max_uses: inviteForm.max_uses || null, + expires_at: inviteForm.expires_at || null, + enabled: inviteForm.enabled, + }), + } + ) + if (!response.ok) { + if (response.status === 401) { + clearToken() + router.push('/login') + return + } + const text = await response.text() + throw new Error(text || 'Invite save failed') + } + 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) + } + } + if (loading) { return
Loading profile...
} @@ -222,6 +401,201 @@ export default function ProfilePage() {
+
+
+
+

My invites

+

+ Create and manage invite links you’ve issued. New invites use your account defaults. +

+
+
+ {inviteError &&
{inviteError}
} + {inviteStatus &&
{inviteStatus}
} +
+
+ {invites.length === 0 ? ( +
You haven’t created any invites yet.
+ ) : ( +
+ {invites.map((invite) => ( +
+
+
+ {invite.code} + + {invite.is_usable ? 'Usable' : 'Unavailable'} + + + {invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`} + +
+ {invite.label &&

{invite.label}

} + {invite.description && ( +

+ {invite.description} +

+ )} +
+ + Uses: {invite.use_count} + {typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''} + + Expires: {formatDate(invite.expires_at)} + Created: {formatDate(invite.created_at)} +
+
+
+ + + +
+
+ ))} +
+ )} +
+
+

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

+

+ Share the generated signup link with the person you want to invite. +

+
+
+
+ Identity + Optional code and label for easier tracking. +
+
+ + +
+
+ +
+
+ Description + Optional note shown on the signup page. +
+
+