Files
Magent/frontend/app/admin/invites/page.tsx

420 lines
14 KiB
TypeScript

'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type ProfileOption = {
id: number
name: string
}
type Invite = {
id: number
code: string
label?: string | null
description?: string | null
profile_id?: number | null
profile?: ProfileOption | null
role?: 'user' | 'admin' | 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
}
type InviteForm = {
code: string
label: string
description: string
profile_id: string
role: '' | 'user' | 'admin'
max_uses: string
enabled: boolean
expires_at: string
}
const defaultForm = (): InviteForm => ({
code: '',
label: '',
description: '',
profile_id: '',
role: '',
max_uses: '',
enabled: true,
expires_at: '',
})
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
export default function AdminInvitesPage() {
const router = useRouter()
const [invites, setInvites] = useState<Invite[]>([])
const [profiles, setProfiles] = useState<ProfileOption[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<InviteForm>(defaultForm())
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
return `${window.location.origin}/signup`
}, [])
const handleAuthResponse = (response: Response) => {
if (response.status === 401) {
clearToken()
router.push('/login')
return true
}
if (response.status === 403) {
router.push('/')
return true
}
return false
}
const loadData = async () => {
if (!getToken()) {
router.push('/login')
return
}
setLoading(true)
setError(null)
try {
const baseUrl = getApiBase()
const [inviteRes, profileRes] = await Promise.all([
authFetch(`${baseUrl}/admin/invites`),
authFetch(`${baseUrl}/admin/profiles`),
])
if (!inviteRes.ok) {
if (handleAuthResponse(inviteRes)) return
throw new Error(`Failed to load invites (${inviteRes.status})`)
}
if (!profileRes.ok) {
if (handleAuthResponse(profileRes)) return
throw new Error(`Failed to load profiles (${profileRes.status})`)
}
const [inviteData, profileData] = await Promise.all([inviteRes.json(), profileRes.json()])
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
const profileRows = Array.isArray(profileData?.profiles) ? profileData.profiles : []
setProfiles(
profileRows.map((profile: any) => ({
id: Number(profile.id ?? 0),
name: String(profile.name ?? 'Unnamed'),
}))
)
} catch (err) {
console.error(err)
setError('Could not load invites.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadData()
}, [])
const resetEditor = () => {
setEditingId(null)
setForm(defaultForm())
}
const editInvite = (invite: Invite) => {
setEditingId(invite.id)
setForm({
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
profile_id:
typeof invite.profile_id === 'number' && invite.profile_id > 0
? String(invite.profile_id)
: '',
role: (invite.role ?? '') as '' | 'user' | 'admin',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
enabled: invite.enabled !== false,
expires_at: invite.expires_at ?? '',
})
setStatus(null)
setError(null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
setSaving(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const payload = {
code: form.code || null,
label: form.label || null,
description: form.description || null,
profile_id: form.profile_id || null,
role: form.role || null,
max_uses: form.max_uses || null,
enabled: form.enabled,
expires_at: form.expires_at || null,
}
const url =
editingId == null ? `${baseUrl}/admin/invites` : `${baseUrl}/admin/invites/${editingId}`
const response = await authFetch(url, {
method: editingId == null ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Save failed')
}
setStatus(editingId == null ? 'Invite created.' : 'Invite updated.')
resetEditor()
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not save invite.')
} finally {
setSaving(false)
}
}
const deleteInvite = async (invite: Invite) => {
if (!window.confirm(`Delete invite "${invite.code}"?`)) return
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/${invite.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Delete failed')
}
if (editingId === invite.id) resetEditor()
setStatus(`Deleted invite ${invite.code}.`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not delete invite.')
}
}
const copyInviteLink = async (invite: Invite) => {
const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}`
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
setStatus(`Copied invite link for ${invite.code}.`)
} else {
window.prompt('Copy invite link', url)
}
} catch (err) {
console.error(err)
window.prompt('Copy invite link', url)
}
}
return (
<AdminShell
title="Invites"
subtitle="Create invite-based sign-up links for Magent accounts."
actions={
<div className="admin-inline-actions">
<button type="button" onClick={loadData} disabled={loading}>
{loading ? 'Loading…' : 'Reload'}
</button>
<button type="button" className="ghost-button" onClick={resetEditor}>
New invite
</button>
</div>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="admin-split-grid">
<div className="admin-panel">
<h2>{editingId == null ? 'Create invite' : 'Edit invite'}</h2>
<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={form.code}
onChange={(e) => setForm((current) => ({ ...current, code: e.target.value }))}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
Label
<input
value={form.label}
onChange={(e) => setForm((current) => ({ ...current, label: e.target.value }))}
placeholder="Staff invite batch"
/>
</label>
</div>
<label>
Description
<textarea
rows={3}
value={form.description}
onChange={(e) =>
setForm((current) => ({ ...current, description: e.target.value }))
}
placeholder="Optional note shown on the signup page"
/>
</label>
<div className="admin-fields-grid">
<label>
Profile
<select
value={form.profile_id}
onChange={(e) =>
setForm((current) => ({ ...current, profile_id: e.target.value }))
}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
</label>
<label>
Role override
<select
value={form.role}
onChange={(e) =>
setForm((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={form.max_uses}
onChange={(e) =>
setForm((current) => ({ ...current, max_uses: e.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
/>
</label>
<label>
Invite expiry (ISO datetime)
<input
value={form.expires_at}
onChange={(e) =>
setForm((current) => ({ ...current, expires_at: e.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
/>
</label>
</div>
<label className="inline-checkbox">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) =>
setForm((current) => ({ ...current, enabled: e.target.checked }))
}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : editingId == null ? 'Create invite' : 'Save invite'}
</button>
{editingId != null && (
<button type="button" className="ghost-button" onClick={resetEditor}>
Cancel edit
</button>
)}
</div>
</form>
</div>
<div className="admin-panel">
<h2>Existing invites</h2>
<p className="lede">Each invite can be copied as a direct sign-up link.</p>
{loading ? (
<div className="status-banner">Loading invites</div>
) : invites.length === 0 ? (
<div className="status-banner">No invites created 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>
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</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>Remaining: {invite.remaining_uses ?? 'Unlimited'}</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>
</section>
</AdminShell>
)
}