Add invite email templates and delivery workflow

This commit is contained in:
2026-03-01 15:44:46 +13:00
parent c205df4367
commit 12d3777e76
10 changed files with 1383 additions and 23 deletions

View File

@@ -43,6 +43,7 @@ type Invite = {
remaining_uses?: number | null
enabled: boolean
expires_at?: string | null
recipient_email?: string | null
is_expired?: boolean
is_usable?: boolean
created_at?: string | null
@@ -58,6 +59,9 @@ type InviteForm = {
max_uses: string
enabled: boolean
expires_at: string
recipient_email: string
send_email: boolean
message: string
}
type ProfileForm = {
@@ -69,10 +73,30 @@ type ProfileForm = {
is_active: boolean
}
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
type InviteEmailTemplateKey = 'invited' | 'welcome' | 'warning' | 'banned'
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' | 'emails'
type InviteTraceScope = 'all' | 'invited' | 'direct'
type InviteTraceView = 'list' | 'graph'
type InviteEmailTemplate = {
key: InviteEmailTemplateKey
label: string
description: string
placeholders: string[]
subject: string
body_text: string
body_html: string
}
type InviteEmailSendForm = {
template_key: InviteEmailTemplateKey
recipient_email: string
invite_id: string
username: string
message: string
reason: string
}
type InviteTraceRow = {
username: string
role: string
@@ -102,6 +126,18 @@ const defaultInviteForm = (): InviteForm => ({
max_uses: '',
enabled: true,
expires_at: '',
recipient_email: '',
send_email: false,
message: '',
})
const defaultInviteEmailSendForm = (): InviteEmailSendForm => ({
template_key: 'invited',
recipient_email: '',
invite_id: '',
username: '',
message: '',
reason: '',
})
const defaultProfileForm = (): ProfileForm => ({
@@ -137,6 +173,9 @@ export default function AdminInviteManagementPage() {
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false)
const [invitePolicySaving, setInvitePolicySaving] = useState(false)
const [templateSaving, setTemplateSaving] = useState(false)
const [templateResetting, setTemplateResetting] = useState(false)
const [emailSending, setEmailSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
@@ -152,6 +191,15 @@ export default function AdminInviteManagementPage() {
const [masterInviteSelection, setMasterInviteSelection] = useState('')
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [emailTemplates, setEmailTemplates] = useState<InviteEmailTemplate[]>([])
const [emailConfigured, setEmailConfigured] = useState<{ configured: boolean; detail: string } | null>(null)
const [selectedTemplateKey, setSelectedTemplateKey] = useState<InviteEmailTemplateKey>('invited')
const [templateForm, setTemplateForm] = useState({
subject: '',
body_text: '',
body_html: '',
})
const [emailSendForm, setEmailSendForm] = useState<InviteEmailSendForm>(defaultInviteEmailSendForm())
const [traceFilter, setTraceFilter] = useState('')
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
const [traceView, setTraceView] = useState<InviteTraceView>('graph')
@@ -161,6 +209,23 @@ export default function AdminInviteManagementPage() {
return `${window.location.origin}/signup`
}, [])
const loadTemplateEditor = (
templateKey: InviteEmailTemplateKey,
templates: InviteEmailTemplate[]
) => {
const template = templates.find((item) => item.key === templateKey) ?? templates[0] ?? null
if (!template) {
setTemplateForm({ subject: '', body_text: '', body_html: '' })
return
}
setSelectedTemplateKey(template.key)
setTemplateForm({
subject: template.subject ?? '',
body_text: template.body_text ?? '',
body_html: template.body_html ?? '',
})
}
const handleAuthResponse = (response: Response) => {
if (response.status === 401) {
clearToken()
@@ -183,11 +248,12 @@ export default function AdminInviteManagementPage() {
setError(null)
try {
const baseUrl = getApiBase()
const [inviteRes, profileRes, usersRes, policyRes] = await Promise.all([
const [inviteRes, profileRes, usersRes, policyRes, emailTemplateRes] = await Promise.all([
authFetch(`${baseUrl}/admin/invites`),
authFetch(`${baseUrl}/admin/profiles`),
authFetch(`${baseUrl}/admin/users`),
authFetch(`${baseUrl}/admin/invites/policy`),
authFetch(`${baseUrl}/admin/invites/email/templates`),
])
if (!inviteRes.ok) {
if (handleAuthResponse(inviteRes)) return
@@ -205,11 +271,16 @@ export default function AdminInviteManagementPage() {
if (handleAuthResponse(policyRes)) return
throw new Error(`Failed to load invite policy (${policyRes.status})`)
}
const [inviteData, profileData, usersData, policyData] = await Promise.all([
if (!emailTemplateRes.ok) {
if (handleAuthResponse(emailTemplateRes)) return
throw new Error(`Failed to load email templates (${emailTemplateRes.status})`)
}
const [inviteData, profileData, usersData, policyData, emailTemplateData] = await Promise.all([
inviteRes.json(),
profileRes.json(),
usersRes.json(),
policyRes.json(),
emailTemplateRes.json(),
])
const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null
setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : [])
@@ -219,6 +290,10 @@ export default function AdminInviteManagementPage() {
setMasterInviteSelection(
nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id)
)
const nextTemplates = Array.isArray(emailTemplateData?.templates) ? emailTemplateData.templates : []
setEmailTemplates(nextTemplates)
setEmailConfigured(emailTemplateData?.email ?? null)
loadTemplateEditor(selectedTemplateKey, nextTemplates)
try {
const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`)
if (jellyfinRes.ok) {
@@ -264,6 +339,9 @@ export default function AdminInviteManagementPage() {
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
enabled: invite.enabled !== false,
expires_at: invite.expires_at ?? '',
recipient_email: invite.recipient_email ?? '',
send_email: false,
message: '',
})
setStatus(null)
setError(null)
@@ -285,6 +363,9 @@ export default function AdminInviteManagementPage() {
max_uses: inviteForm.max_uses || null,
enabled: inviteForm.enabled,
expires_at: inviteForm.expires_at || null,
recipient_email: inviteForm.recipient_email || null,
send_email: inviteForm.send_email,
message: inviteForm.message || null,
}
const url =
inviteEditingId == null
@@ -300,8 +381,19 @@ export default function AdminInviteManagementPage() {
const text = await response.text()
throw new Error(text || 'Save failed')
}
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
resetInviteEditor()
const data = await response.json()
if (data?.email?.status === 'ok') {
setStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
)
} else if (data?.email?.status === 'error') {
setStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
}
await loadData()
} catch (err) {
console.error(err)
@@ -349,6 +441,117 @@ export default function AdminInviteManagementPage() {
}
}
const prepareInviteEmail = (invite: Invite) => {
setEmailSendForm({
template_key: 'invited',
recipient_email: invite.recipient_email ?? '',
invite_id: String(invite.id),
username: '',
message: '',
reason: '',
})
setActiveTab('emails')
setStatus(
invite.recipient_email
? `Invite ${invite.code} is ready to email to ${invite.recipient_email}.`
: `Invite ${invite.code} does not have a saved recipient yet. Add one and send from the email panel.`
)
setError(null)
}
const selectEmailTemplate = (templateKey: InviteEmailTemplateKey) => {
setSelectedTemplateKey(templateKey)
loadTemplateEditor(templateKey, emailTemplates)
}
const saveEmailTemplate = async () => {
setTemplateSaving(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(templateForm),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Template save failed')
}
setStatus(`Saved ${selectedTemplateKey} email template.`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not save email template.')
} finally {
setTemplateSaving(false)
}
}
const resetEmailTemplate = async () => {
if (!window.confirm(`Reset the ${selectedTemplateKey} template to its default content?`)) return
setTemplateResetting(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/email/templates/${selectedTemplateKey}`, {
method: 'DELETE',
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Template reset failed')
}
setStatus(`Reset ${selectedTemplateKey} template to default.`)
await loadData()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not reset email template.')
} finally {
setTemplateResetting(false)
}
}
const sendEmailTemplate = async (event: React.FormEvent) => {
event.preventDefault()
setEmailSending(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/invites/email/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_key: emailSendForm.template_key,
recipient_email: emailSendForm.recipient_email || null,
invite_id: emailSendForm.invite_id || null,
username: emailSendForm.username || null,
message: emailSendForm.message || null,
reason: emailSendForm.reason || null,
}),
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Email send failed')
}
const data = await response.json()
setStatus(`Sent ${emailSendForm.template_key} email to ${data?.recipient_email ?? 'recipient'}.`)
if (emailSendForm.template_key === 'invited') {
await loadData()
}
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not send email.')
} finally {
setEmailSending(false)
}
}
const resetProfileEditor = () => {
setProfileEditingId(null)
setProfileForm(defaultProfileForm())
@@ -588,8 +791,11 @@ export default function AdminInviteManagementPage() {
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 invitesWithRecipient = invites.filter((invite) => Boolean(String(invite.recipient_email || '').trim())).length
const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length
const masterInvite = invitePolicy?.master_invite ?? null
const selectedTemplate =
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
const inviteTraceRows = useMemo(() => {
const inviteByCode = new Map<string, Invite>()
@@ -813,6 +1019,20 @@ export default function AdminInviteManagementPage() {
<span>users with custom expiry</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Email templates</span>
<div className="invite-admin-summary-row__value">
<strong>{emailTemplates.length}</strong>
<span>{invitesWithRecipient} invites with recipient email</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">SMTP email</span>
<div className="invite-admin-summary-row__value">
<strong>{emailConfigured?.configured ? 'Ready' : 'Needs setup'}</strong>
<span>{emailConfigured?.detail ?? 'Email settings unavailable'}</span>
</div>
</div>
</div>
</div>
</div>
@@ -866,6 +1086,15 @@ export default function AdminInviteManagementPage() {
>
Trace map
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'emails'}
className={activeTab === 'emails' ? 'is-active' : ''}
onClick={() => setActiveTab('emails')}
>
Email
</button>
</div>
<div className="admin-inline-actions invite-admin-tab-actions">
<button type="button" className="ghost-button" onClick={loadData} disabled={loading}>
@@ -1229,6 +1458,7 @@ export default function AdminInviteManagementPage() {
</span>
<span>Remaining: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
@@ -1236,6 +1466,9 @@ export default function AdminInviteManagementPage() {
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
Copy link
</button>
<button type="button" className="ghost-button" onClick={() => prepareInviteEmail(invite)}>
Email invite
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit
</button>
@@ -1371,6 +1604,47 @@ export default function AdminInviteManagementPage() {
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
}
placeholder="person@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(e) =>
setInviteForm((current) => ({ ...current, message: e.target.value }))
}
placeholder="Optional message appended to the invite email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, send_email: e.target.checked }))
}
/>
Send You have been invited email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
@@ -1404,6 +1678,249 @@ export default function AdminInviteManagementPage() {
</div>
)}
{activeTab === 'emails' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Email templates</h2>
<p className="lede">
Edit the invite lifecycle emails and keep the SMTP-driven messaging flow in one place.
</p>
</div>
</div>
{!emailConfigured?.configured && (
<div className="status-banner">
{emailConfigured?.detail ?? 'Configure SMTP under Notifications before sending invite emails.'}
</div>
)}
<div className="invite-email-template-picker" role="tablist" aria-label="Email templates">
{emailTemplates.map((template) => (
<button
key={template.key}
type="button"
className={selectedTemplateKey === template.key ? 'is-active' : ''}
onClick={() => selectEmailTemplate(template.key)}
>
{template.label}
</button>
))}
</div>
{selectedTemplate ? (
<div className="invite-email-template-meta">
<h3>{selectedTemplate.label}</h3>
<p className="lede">{selectedTemplate.description}</p>
</div>
) : null}
<form
className="admin-form compact-form invite-form-layout invite-email-template-form"
onSubmit={(event) => {
event.preventDefault()
void saveEmailTemplate()
}}
>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Subject</span>
<small>Rendered with the same placeholder variables as the body.</small>
</div>
<div className="invite-form-row-control">
<input
value={templateForm.subject}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, subject: event.target.value }))
}
placeholder="Email subject"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Plain text body</span>
<small>Used for mail clients that prefer text only.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={12}
value={templateForm.body_text}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_text: event.target.value }))
}
placeholder="Plain text email body"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>HTML body</span>
<small>Optional rich HTML version. Basic HTML is supported.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={14}
value={templateForm.body_html}
onChange={(event) =>
setTemplateForm((current) => ({ ...current, body_html: event.target.value }))
}
placeholder="<h1>Hello</h1><p>HTML email body</p>"
/>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Placeholders</span>
<small>Use these anywhere in the subject or body.</small>
</div>
<div className="invite-form-row-control invite-email-placeholder-list">
{(selectedTemplate?.placeholders ?? []).map((placeholder) => (
<code key={placeholder}>{`{{${placeholder}}}`}</code>
))}
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={templateSaving}>
{templateSaving ? 'Saving…' : 'Save template'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void resetEmailTemplate()}
disabled={templateResetting}
>
{templateResetting ? 'Resetting…' : 'Reset to default'}
</button>
</div>
</form>
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>Send email</h2>
<p className="lede">
Send invite, welcome, warning, or banned emails using a saved invite, a username, or a manual email address.
</p>
<form onSubmit={sendEmailTemplate} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Template</span>
<small>Select which lifecycle email to send.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Template</span>
<select
value={emailSendForm.template_key}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
template_key: event.target.value as InviteEmailTemplateKey,
}))
}
>
{emailTemplates.map((template) => (
<option key={template.key} value={template.key}>
{template.label}
</option>
))}
</select>
</label>
<label>
<span>Recipient email</span>
<input
type="email"
value={emailSendForm.recipient_email}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="Optional if invite/user already has one"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Context</span>
<small>Link the email to an invite or username to fill placeholders automatically.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Invite</span>
<select
value={emailSendForm.invite_id}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
invite_id: event.target.value,
}))
}
>
<option value="">None</option>
{invites.map((invite) => (
<option key={invite.id} value={invite.id}>
{invite.code}
{invite.label ? ` - ${invite.label}` : ''}
</option>
))}
</select>
</label>
<label>
<span>Username</span>
<input
value={emailSendForm.username}
onChange={(event) =>
setEmailSendForm((current) => ({
...current,
username: event.target.value,
}))
}
placeholder="Optional user lookup"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Reason / note</span>
<small>Used by warning and banned templates, and appended to other emails.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Reason</span>
<input
value={emailSendForm.reason}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, reason: event.target.value }))
}
placeholder="Optional reason"
/>
</label>
<label>
<span>Message</span>
<textarea
rows={6}
value={emailSendForm.message}
onChange={(event) =>
setEmailSendForm((current) => ({ ...current, message: event.target.value }))
}
placeholder="Optional message"
/>
</label>
</div>
</div>
<div className="admin-inline-actions">
<button type="submit" disabled={emailSending || !emailConfigured?.configured}>
{emailSending ? 'Sending…' : 'Send email'}
</button>
</div>
</form>
</div>
</div>
)}
{activeTab === 'trace' && (
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-list-panel">

View File

@@ -4641,6 +4641,48 @@ button:hover:not(:disabled) {
gap: 10px;
}
.invite-email-template-picker {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.invite-email-template-picker button {
border-radius: 8px;
}
.invite-email-template-picker button.is-active {
border-color: rgba(135, 182, 255, 0.4);
background: rgba(86, 132, 220, 0.14);
color: #eef2f7;
}
.invite-email-template-meta {
display: grid;
gap: 4px;
margin-bottom: 10px;
}
.invite-email-template-meta h3 {
margin: 0;
}
.invite-email-placeholder-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.invite-email-placeholder-list code {
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #d6dde8;
font-size: 0.78rem;
}
.admin-panel > h2 + .lede {
margin-top: -2px;
}

View File

@@ -53,6 +53,7 @@ type OwnedInvite = {
code: string
label?: string | null
description?: string | null
recipient_email?: string | null
max_uses?: number | null
use_count: number
remaining_uses?: number | null
@@ -87,9 +88,12 @@ type OwnedInviteForm = {
code: string
label: string
description: string
recipient_email: string
max_uses: string
expires_at: string
enabled: boolean
send_email: boolean
message: string
}
type ProfileTab = 'overview' | 'activity' | 'invites' | 'security'
@@ -98,9 +102,12 @@ const defaultOwnedInviteForm = (): OwnedInviteForm => ({
code: '',
label: '',
description: '',
recipient_email: '',
max_uses: '',
expires_at: '',
enabled: true,
send_email: false,
message: '',
})
const formatDate = (value?: string | null) => {
@@ -250,9 +257,12 @@ export default function ProfilePage() {
code: invite.code ?? '',
label: invite.label ?? '',
description: invite.description ?? '',
recipient_email: invite.recipient_email ?? '',
max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '',
expires_at: invite.expires_at ?? '',
enabled: invite.enabled !== false,
send_email: false,
message: '',
})
}
@@ -292,9 +302,12 @@ export default function ProfilePage() {
code: inviteForm.code || null,
label: inviteForm.label || null,
description: inviteForm.description || null,
recipient_email: inviteForm.recipient_email || null,
max_uses: inviteForm.max_uses || null,
expires_at: inviteForm.expires_at || null,
enabled: inviteForm.enabled,
send_email: inviteForm.send_email,
message: inviteForm.message || null,
}),
}
)
@@ -307,7 +320,18 @@ export default function ProfilePage() {
const text = await response.text()
throw new Error(text || 'Invite save failed')
}
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
const data = await response.json().catch(() => ({}))
if (data?.email?.status === 'ok') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
)
} else if (data?.email?.status === 'error') {
setInviteStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setInviteStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
}
resetInviteEditor()
await reloadInvites()
} catch (err) {
@@ -603,6 +627,56 @@ export default function ProfilePage() {
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Save a recipient email and optionally send the invite immediately.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
recipient_email: event.target.value,
}))
}
placeholder="friend@example.com"
/>
</label>
<label>
<span>Delivery note</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) =>
setInviteForm((current) => ({
...current,
message: event.target.value,
}))
}
placeholder="Optional note to include in the email"
/>
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.send_email}
onChange={(event) =>
setInviteForm((current) => ({
...current,
send_email: event.target.checked,
}))
}
/>
Send "You have been invited" email after saving
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
@@ -700,6 +774,7 @@ export default function ProfilePage() {
</p>
)}
<div className="admin-meta-row">
<span>Recipient: {invite.recipient_email || 'Not set'}</span>
<span>
Uses: {invite.use_count}
{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}