Build 2602261717: master invite policy and self-service invite controls
This commit is contained in:
@@ -8,6 +8,7 @@ type ProfileInfo = {
|
||||
username: string
|
||||
role: string
|
||||
auth_provider: string
|
||||
invite_management_enabled?: boolean
|
||||
}
|
||||
|
||||
type ProfileStats = {
|
||||
@@ -66,6 +67,20 @@ type OwnedInvite = {
|
||||
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 = {
|
||||
@@ -77,6 +92,8 @@ type OwnedInviteForm = {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
|
||||
|
||||
const defaultOwnedInviteForm = (): OwnedInviteForm => ({
|
||||
code: '',
|
||||
label: '',
|
||||
@@ -117,6 +134,10 @@ export default function ProfilePage() {
|
||||
const [inviteSaving, setInviteSaving] = useState(false)
|
||||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||
const [inviteForm, setInviteForm] = useState<OwnedInviteForm>(defaultOwnedInviteForm())
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview')
|
||||
const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false)
|
||||
const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false)
|
||||
const [masterInviteTemplate, setMasterInviteTemplate] = useState<OwnedInvitesResponse['master_invite']>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const signupBaseUrl = useMemo(() => {
|
||||
@@ -150,10 +171,14 @@ export default function ProfilePage() {
|
||||
username: user?.username ?? 'Unknown',
|
||||
role: user?.role ?? 'user',
|
||||
auth_provider: user?.auth_provider ?? 'local',
|
||||
invite_management_enabled: Boolean(user?.invite_management_enabled ?? false),
|
||||
})
|
||||
setStats(data?.stats ?? null)
|
||||
setActivity(data?.activity ?? null)
|
||||
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)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('Could not load your profile.')
|
||||
@@ -182,15 +207,33 @@ export default function ProfilePage() {
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Update failed')
|
||||
let detail = 'Update failed'
|
||||
try {
|
||||
const payload = await response.json()
|
||||
if (typeof payload?.detail === 'string' && payload.detail.trim()) {
|
||||
detail = payload.detail
|
||||
}
|
||||
} catch {
|
||||
const text = await response.text().catch(() => '')
|
||||
if (text?.trim()) detail = text
|
||||
}
|
||||
throw new Error(detail)
|
||||
}
|
||||
const data = await response.json().catch(() => ({}))
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setStatus('Password updated.')
|
||||
setStatus(
|
||||
data?.provider === 'jellyfin'
|
||||
? 'Password updated in Jellyfin (and Magent cache).'
|
||||
: 'Password updated.'
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setStatus('Could not update password. Check your current password.')
|
||||
if (err instanceof Error && err.message) {
|
||||
setStatus(`Could not update password. ${err.message}`)
|
||||
} else {
|
||||
setStatus('Could not update password. Check your current password.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,6 +269,9 @@ export default function ProfilePage() {
|
||||
}
|
||||
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) => {
|
||||
@@ -316,6 +362,22 @@ export default function ProfilePage() {
|
||||
}
|
||||
}
|
||||
|
||||
const authProvider = profile?.auth_provider ?? 'local'
|
||||
const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled
|
||||
const canChangePassword = authProvider === 'local' || authProvider === 'jellyfin'
|
||||
const securityHelpText =
|
||||
authProvider === 'jellyfin'
|
||||
? 'Changing your password here updates your Jellyfin account and refreshes Magent’s cached sign-in.'
|
||||
: authProvider === 'local'
|
||||
? 'Change your Magent account password.'
|
||||
: 'Password changes are not available for this sign-in provider.'
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'invites' && !canManageInvites) {
|
||||
setActiveTab('overview')
|
||||
}
|
||||
}, [activeTab, canManageInvites])
|
||||
|
||||
if (loading) {
|
||||
return <main className="card">Loading profile...</main>
|
||||
}
|
||||
@@ -329,8 +391,51 @@ export default function ProfilePage() {
|
||||
{profile.auth_provider}.
|
||||
</div>
|
||||
)}
|
||||
<div className="profile-grid">
|
||||
<section className="profile-section">
|
||||
<div className="profile-tabbar">
|
||||
<div className="admin-segmented" role="tablist" aria-label="Profile sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'overview'}
|
||||
className={activeTab === 'overview' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('overview')}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'activity'}
|
||||
className={activeTab === 'activity' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('activity')}
|
||||
>
|
||||
Activity
|
||||
</button>
|
||||
{canManageInvites ? (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'invites'}
|
||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('invites')}
|
||||
>
|
||||
My invites
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'security'}
|
||||
className={activeTab === 'security' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('security')}
|
||||
>
|
||||
Security
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'overview' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>Account stats</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
@@ -353,6 +458,18 @@ export default function ProfilePage() {
|
||||
<div className="stat-label">Declined</div>
|
||||
<div className="stat-value">{stats?.declined ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Working</div>
|
||||
<div className="stat-value">{stats?.working ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Partial</div>
|
||||
<div className="stat-value">{stats?.partial ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Approved</div>
|
||||
<div className="stat-value">{stats?.approved ?? 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Last request</div>
|
||||
<div className="stat-value stat-value--small">
|
||||
@@ -367,6 +484,10 @@ export default function ProfilePage() {
|
||||
: '0%'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Total requests (global)</div>
|
||||
<div className="stat-value">{stats?.global_total ?? 0}</div>
|
||||
</div>
|
||||
{profile?.role === 'admin' ? (
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">Most active user</div>
|
||||
@@ -379,7 +500,10 @@ export default function ProfilePage() {
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="profile-section">
|
||||
)}
|
||||
|
||||
{activeTab === 'activity' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>Connection history</h2>
|
||||
<div className="status-banner">
|
||||
Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}.
|
||||
@@ -390,6 +514,7 @@ export default function ProfilePage() {
|
||||
<div>
|
||||
<div className="connection-label">{parseBrowser(entry.user_agent)}</div>
|
||||
<div className="meta">IP: {entry.ip}</div>
|
||||
<div className="meta">First seen: {formatDate(entry.first_seen_at)}</div>
|
||||
<div className="meta">Last seen: {formatDate(entry.last_seen_at)}</div>
|
||||
</div>
|
||||
<div className="connection-count">{entry.hit_count} visits</div>
|
||||
@@ -400,80 +525,34 @@ export default function ProfilePage() {
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section className="profile-section profile-invites-section">
|
||||
)}
|
||||
|
||||
{activeTab === 'invites' && (
|
||||
<section className="profile-section profile-invites-section profile-tab-panel">
|
||||
<div className="user-directory-panel-header">
|
||||
<div>
|
||||
<h2>My invites</h2>
|
||||
<p className="lede">
|
||||
Create and manage invite links you’ve issued. New invites use your account defaults.
|
||||
{inviteManagedByMaster
|
||||
? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.'
|
||||
: 'Create and manage invite links you’ve issued. New invites use your account defaults.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{inviteError && <div className="error-banner">{inviteError}</div>}
|
||||
{inviteStatus && <div className="status-banner">{inviteStatus}</div>}
|
||||
<div className="profile-invites-layout">
|
||||
<div className="profile-invites-list">
|
||||
{invites.length === 0 ? (
|
||||
<div className="status-banner">You haven’t created any invites yet.</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="admin-list-item">
|
||||
<div className="admin-list-item-main">
|
||||
<div className="admin-list-item-title-row">
|
||||
<code className="invite-code">{invite.code}</code>
|
||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||
</span>
|
||||
<span className="small-pill is-muted">
|
||||
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||||
</span>
|
||||
</div>
|
||||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||
{invite.description && (
|
||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||
{invite.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="admin-meta-row">
|
||||
<span>
|
||||
Uses: {invite.use_count}
|
||||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||
</span>
|
||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||
<span>Created: {formatDate(invite.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => copyInviteLink(invite)}
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => editInvite(invite)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => deleteInvite(invite)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="profile-invite-form-card">
|
||||
<h3>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h3>
|
||||
<p className="meta profile-invite-form-lede">
|
||||
Share the generated signup link with the person you want to invite.
|
||||
</p>
|
||||
{inviteManagedByMaster && masterInviteTemplate ? (
|
||||
<div className="status-banner profile-invite-master-banner">
|
||||
Using master invite rule <code>{masterInviteTemplate.code}</code>
|
||||
{masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin.
|
||||
</div>
|
||||
) : null}
|
||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
@@ -539,6 +618,7 @@ export default function ProfilePage() {
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="Blank = unlimited"
|
||||
disabled={inviteManagedByMaster}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
@@ -549,6 +629,7 @@ export default function ProfilePage() {
|
||||
setInviteForm((current) => ({ ...current, expires_at: event.target.value }))
|
||||
}
|
||||
placeholder="2026-03-01T12:00:00+00:00"
|
||||
disabled={inviteManagedByMaster}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -570,6 +651,7 @@ export default function ProfilePage() {
|
||||
enabled: event.target.checked,
|
||||
}))
|
||||
}
|
||||
disabled={inviteManagedByMaster}
|
||||
/>
|
||||
Invite is enabled
|
||||
</label>
|
||||
@@ -594,37 +676,103 @@ export default function ProfilePage() {
|
||||
Invite URL format: <code>{signupBaseUrl}?code=INVITECODE</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{profile?.auth_provider !== 'local' ? (
|
||||
<div className="status-banner">
|
||||
Password changes are only available for local Magent accounts.
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={submit} className="auth-form">
|
||||
<label>
|
||||
Current password
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit">Update password</button>
|
||||
<div className="profile-invites-list">
|
||||
{invites.length === 0 ? (
|
||||
<div className="status-banner">You haven’t created any invites yet.</div>
|
||||
) : (
|
||||
<div className="admin-list">
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="admin-list-item">
|
||||
<div className="admin-list-item-main">
|
||||
<div className="admin-list-item-title-row">
|
||||
<code className="invite-code">{invite.code}</code>
|
||||
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
|
||||
{invite.is_usable ? 'Usable' : 'Unavailable'}
|
||||
</span>
|
||||
<span className="small-pill is-muted">
|
||||
{invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`}
|
||||
</span>
|
||||
</div>
|
||||
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
|
||||
{invite.description && (
|
||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||
{invite.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="admin-meta-row">
|
||||
<span>
|
||||
Uses: {invite.use_count}
|
||||
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}
|
||||
</span>
|
||||
<span>Expires: {formatDate(invite.expires_at)}</span>
|
||||
<span>Created: {formatDate(invite.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-inline-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => copyInviteLink(invite)}
|
||||
>
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => editInvite(invite)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" onClick={() => deleteInvite(invite)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<section className="profile-section profile-tab-panel">
|
||||
<h2>Security</h2>
|
||||
<div className="status-banner">{securityHelpText}</div>
|
||||
{canChangePassword ? (
|
||||
<form onSubmit={submit} className="auth-form profile-security-form">
|
||||
<label>
|
||||
Current password
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(event) => setCurrentPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
New password
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(event) => setNewPassword(event.target.value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
<div className="auth-actions">
|
||||
<button type="submit">
|
||||
{authProvider === 'jellyfin' ? 'Update Jellyfin password' : 'Update password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="status-banner">
|
||||
Password changes are not available for {authProvider} sign-in accounts from Magent.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user