Build 2602261717: master invite policy and self-service invite controls
This commit is contained in:
@@ -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 user’s profile/defaults.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="user-bulk-group">
|
||||
<label className="admin-select">
|
||||
<span>Profile</span>
|
||||
|
||||
Reference in New Issue
Block a user