Build 2602261523: live updates, invite cleanup and nuclear resync
This commit is contained in:
@@ -477,26 +477,6 @@ export default function AdminInviteManagementPage() {
|
||||
<button type="button" onClick={loadData} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Reload'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
resetInviteEditor()
|
||||
setActiveTab('invites')
|
||||
}}
|
||||
>
|
||||
New invite
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
resetProfileEditor()
|
||||
setActiveTab('profiles')
|
||||
}}
|
||||
>
|
||||
New profile
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -504,57 +484,99 @@ export default function AdminInviteManagementPage() {
|
||||
{error && <div className="error-banner">{error}</div>}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
|
||||
<div className="invite-admin-summary-grid">
|
||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
||||
<span className="label">Invites</span>
|
||||
<strong>{invites.length}</strong>
|
||||
<small>{usableInvites} usable • {disabledInvites} disabled</small>
|
||||
<div className="admin-panel invite-admin-summary-panel">
|
||||
<div className="invite-admin-summary-header">
|
||||
<div>
|
||||
<h2>Overview</h2>
|
||||
<p className="lede">
|
||||
Quick counts for invite links, profiles, and managed user defaults.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
||||
<span className="label">Profiles</span>
|
||||
<strong>{profiles.length}</strong>
|
||||
<small>{activeProfiles} active profiles</small>
|
||||
</div>
|
||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
||||
<span className="label">Non-admin users</span>
|
||||
<strong>{nonAdminUsers.length}</strong>
|
||||
<small>{profiledUsers} with profile</small>
|
||||
</div>
|
||||
<div className="admin-summary-tile invite-admin-summary-tile">
|
||||
<span className="label">Expiry rules</span>
|
||||
<strong>{expiringUsers}</strong>
|
||||
<small>users with custom expiry</small>
|
||||
<div className="invite-admin-summary-list">
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Invites</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{invites.length}</strong>
|
||||
<span>{usableInvites} usable • {disabledInvites} disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Profiles</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{profiles.length}</strong>
|
||||
<span>{activeProfiles} active profiles</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Non-admin users</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{nonAdminUsers.length}</strong>
|
||||
<span>{profiledUsers} with profile</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="invite-admin-summary-row">
|
||||
<span className="label">Expiry rules</span>
|
||||
<div className="invite-admin-summary-row__value">
|
||||
<strong>{expiringUsers}</strong>
|
||||
<span>users with custom expiry</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'bulk'}
|
||||
className={activeTab === 'bulk' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('bulk')}
|
||||
>
|
||||
Blanket controls
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'profiles'}
|
||||
className={activeTab === 'profiles' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('profiles')}
|
||||
>
|
||||
Profiles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'invites'}
|
||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('invites')}
|
||||
>
|
||||
Invites
|
||||
</button>
|
||||
<div className="invite-admin-tabbar">
|
||||
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'bulk'}
|
||||
className={activeTab === 'bulk' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('bulk')}
|
||||
>
|
||||
Blanket controls
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'profiles'}
|
||||
className={activeTab === 'profiles' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('profiles')}
|
||||
>
|
||||
Profiles
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === 'invites'}
|
||||
className={activeTab === 'invites' ? 'is-active' : ''}
|
||||
onClick={() => setActiveTab('invites')}
|
||||
>
|
||||
Invites
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-inline-actions invite-admin-tab-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
resetInviteEditor()
|
||||
setActiveTab('invites')
|
||||
}}
|
||||
>
|
||||
New invite
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
resetProfileEditor()
|
||||
setActiveTab('profiles')
|
||||
}}
|
||||
>
|
||||
New profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'bulk' && (
|
||||
@@ -815,116 +837,151 @@ export default function AdminInviteManagementPage() {
|
||||
<p className="lede">
|
||||
Link an invite to a profile to apply account defaults at sign-up.
|
||||
</p>
|
||||
<form onSubmit={saveInvite} className="admin-form compact-form">
|
||||
<div className="admin-fields-grid">
|
||||
<label>
|
||||
Code (optional)
|
||||
<input
|
||||
value={inviteForm.code}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, code: e.target.value }))
|
||||
}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Label
|
||||
<input
|
||||
value={inviteForm.label}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, label: e.target.value }))
|
||||
}
|
||||
placeholder="Staff invite batch"
|
||||
/>
|
||||
</label>
|
||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Identity</span>
|
||||
<small>Code and label used to identify the invite link.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Code (optional)</span>
|
||||
<input
|
||||
value={inviteForm.code}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, code: e.target.value }))
|
||||
}
|
||||
placeholder="Leave blank to auto-generate"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Label</span>
|
||||
<input
|
||||
value={inviteForm.label}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, label: e.target.value }))
|
||||
}
|
||||
placeholder="Staff invite batch"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label>
|
||||
Description
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.description}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, description: e.target.value }))
|
||||
}
|
||||
placeholder="Optional note shown on the signup page"
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-fields-grid">
|
||||
<label>
|
||||
Profile
|
||||
<select
|
||||
value={inviteForm.profile_id}
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Description</span>
|
||||
<small>Optional note shown on the signup page.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control">
|
||||
<textarea
|
||||
rows={3}
|
||||
value={inviteForm.description}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
|
||||
setInviteForm((current) => ({ ...current, description: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Role override
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
role: e.target.value as '' | 'user' | 'admin',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Use profile/default</option>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="admin-fields-grid">
|
||||
<label>
|
||||
Max uses
|
||||
<input
|
||||
value={inviteForm.max_uses}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="Blank = unlimited"
|
||||
placeholder="Optional note shown on the signup page"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Invite expiry (ISO datetime)
|
||||
<input
|
||||
value={inviteForm.expires_at}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
|
||||
}
|
||||
placeholder="2026-03-01T12:00:00+00:00"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.enabled}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Invite is enabled
|
||||
</label>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={inviteSaving}>
|
||||
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
|
||||
</button>
|
||||
{inviteEditingId != null && (
|
||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||
Cancel edit
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Defaults</span>
|
||||
<small>Choose a profile and optional role override for sign-up.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Profile</span>
|
||||
<select
|
||||
value={inviteForm.profile_id}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">None</option>
|
||||
{profiles.map((profile) => (
|
||||
<option key={profile.id} value={profile.id}>
|
||||
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Role override</span>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({
|
||||
...current,
|
||||
role: e.target.value as '' | 'user' | 'admin',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="">Use profile/default</option>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Limits</span>
|
||||
<small>Usage cap and optional expiry date/time for the invite.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-grid">
|
||||
<label>
|
||||
<span>Max uses</span>
|
||||
<input
|
||||
value={inviteForm.max_uses}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
|
||||
}
|
||||
inputMode="numeric"
|
||||
placeholder="Blank = unlimited"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Invite expiry (ISO datetime)</span>
|
||||
<input
|
||||
value={inviteForm.expires_at}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
|
||||
}
|
||||
placeholder="2026-03-01T12:00:00+00:00"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-form-row">
|
||||
<div className="invite-form-row-label">
|
||||
<span>Status</span>
|
||||
<small>Enable or disable the invite before sharing.</small>
|
||||
</div>
|
||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={inviteForm.enabled}
|
||||
onChange={(e) =>
|
||||
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
Invite is enabled
|
||||
</label>
|
||||
<div className="admin-inline-actions">
|
||||
<button type="submit" disabled={inviteSaving}>
|
||||
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
|
||||
</button>
|
||||
{inviteEditingId != null && (
|
||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
||||
Cancel edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user