Build 2602261717: master invite policy and self-service invite controls

This commit is contained in:
2026-02-26 17:18:40 +13:00
parent 23c57da3cc
commit 6a5d2c4310
10 changed files with 844 additions and 157 deletions

View File

@@ -10,6 +10,7 @@ type AdminUserLite = {
username: string
role: string
auth_provider?: string | null
invite_management_enabled?: boolean
profile_id?: number | null
expires_at?: string | null
created_at?: string | null
@@ -70,6 +71,13 @@ type ProfileForm = {
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
type InvitePolicy = {
master_invite_id?: number | null
master_invite?: Invite | null
non_admin_users?: number
invite_access_enabled_users?: number
}
const defaultInviteForm = (): InviteForm => ({
code: '',
label: '',
@@ -109,6 +117,8 @@ export default function AdminInviteManagementPage() {
const [profileSaving, setProfileSaving] = useState(false)
const [bulkProfileBusy, setBulkProfileBusy] = useState(false)
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false)
const [invitePolicySaving, setInvitePolicySaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
@@ -121,6 +131,8 @@ export default function AdminInviteManagementPage() {
const [bulkProfileId, setBulkProfileId] = useState('')
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
const [masterInviteSelection, setMasterInviteSelection] = useState('')
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [traceFilter, setTraceFilter] = useState('')
@@ -151,10 +163,11 @@ export default function AdminInviteManagementPage() {
setError(null)
try {
const baseUrl = getApiBase()
const [inviteRes, profileRes, usersRes] = await Promise.all([
const [inviteRes, profileRes, usersRes, policyRes] = await Promise.all([
authFetch(`${baseUrl}/admin/invites`),
authFetch(`${baseUrl}/admin/profiles`),
authFetch(`${baseUrl}/admin/users`),
authFetch(`${baseUrl}/admin/invites/policy`),
])
if (!inviteRes.ok) {
if (handleAuthResponse(inviteRes)) return
@@ -168,14 +181,24 @@ export default function AdminInviteManagementPage() {
if (handleAuthResponse(usersRes)) return
throw new Error(`Failed to load users (${usersRes.status})`)
}
const [inviteData, profileData, usersData] = await Promise.all([
if (!policyRes.ok) {
if (handleAuthResponse(policyRes)) return
throw new Error(`Failed to load invite policy (${policyRes.status})`)
}
const [inviteData, profileData, usersData, policyData] = await Promise.all([
inviteRes.json(),
profileRes.json(),
usersRes.json(),
policyRes.json(),
])
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : [])
setUsers(Array.isArray(usersData?.users) ? usersData.users : [])
setInvitePolicy(nextPolicy)
setMasterInviteSelection(
nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id)
)
try {
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
if (jellyfinRes.ok) {
@@ -482,12 +505,71 @@ export default function AdminInviteManagementPage() {
}
}
const bulkSetInviteAccess = async (enabled: boolean) => {
setBulkInviteAccessBusy(true)
setStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/invite-access/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Bulk invite access update failed')
}
const data = await response.json()
setStatus(
`${enabled ? 'Enabled' : 'Disabled'} self-service invites for ${data?.updated ?? 0} non-admin users.`
)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not update invite access for all users.')
} finally {
setBulkInviteAccessBusy(false)
}
}
const saveMasterInvitePolicy = async (nextMasterInviteId?: string | null) => {
const selectedValue =
nextMasterInviteId === undefined ? masterInviteSelection : nextMasterInviteId || ''
setInvitePolicySaving(true)
setStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/policy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ master_invite_id: selectedValue || null }),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Invite policy update failed')
}
setStatus(selectedValue ? 'Master invite template updated.' : 'Master invite template cleared.')
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not update invite policy.')
} finally {
setInvitePolicySaving(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
const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).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
const masterInvite = invitePolicy?.master_invite ?? null
const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>()
@@ -663,6 +745,17 @@ export default function AdminInviteManagementPage() {
<span>{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: 'users enabled • no master template set'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
@@ -746,17 +839,83 @@ export default function AdminInviteManagementPage() {
<div>
<h2>Blanket controls</h2>
<p className="lede">
Apply invite profile defaults or expiry to all local non-admin accounts. Individual users can still be edited from their user page.
Apply invite access, master invite template rules, profile defaults, or expiry to all local non-admin accounts. Individual users can still be edited from their user page.
</p>
</div>
</div>
<div className="admin-meta-row">
<span>Local non-admin users: {nonAdminUsers.length}</span>
<span>Jellyfin users: {jellyfinUsersCount ?? '—'}</span>
<span>Invite access enabled: {inviteAccessEnabledUsers}</span>
<span>Profile assigned: {profiledUsers}</span>
<span>Custom expiry set: {expiringUsers}</span>
</div>
<div className="user-bulk-groups">
<div className="user-bulk-group">
<div className="user-bulk-group-meta">
<strong>Self-service invites</strong>
<span className="meta">
Enable or disable the My invites tab for all non-admin users.
</span>
</div>
<div className="admin-inline-actions">
<button
type="button"
onClick={() => void bulkSetInviteAccess(true)}
disabled={bulkInviteAccessBusy}
>
{bulkInviteAccessBusy ? 'Working…' : 'Enable for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void bulkSetInviteAccess(false)}
disabled={bulkInviteAccessBusy}
>
{bulkInviteAccessBusy ? 'Working…' : 'Disable for all users'}
</button>
</div>
</div>
<div className="user-bulk-group">
<label className="admin-select">
<span>Master invite template</span>
<select
value={masterInviteSelection}
onChange={(e) => setMasterInviteSelection(e.target.value)}
disabled={invitePolicySaving}
>
<option value="">None (users use their own defaults)</option>
{invites.map((invite) => (
<option key={invite.id} value={invite.id}>
{invite.code}
{invite.label ? ` - ${invite.label}` : ''}
{invite.enabled === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={() => void saveMasterInvitePolicy()} disabled={invitePolicySaving}>
{invitePolicySaving ? 'Saving…' : 'Save master template'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setMasterInviteSelection('')
void saveMasterInvitePolicy('')
}}
disabled={invitePolicySaving}
>
{invitePolicySaving ? 'Saving…' : 'Clear master template'}
</button>
</div>
<div className="user-detail-helper">
{masterInvite
? `Current master template: ${masterInvite.code}${masterInvite.label ? ` (${masterInvite.label})` : ''}. Self-service invites inherit its limits/status/profile.`
: 'No master template set. Self-service invites use each users profile/defaults.'}
</div>
</div>
<div className="user-bulk-group">
<label className="admin-select">
<span>Profile</span>