Add invite email templates and delivery workflow
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}` : ''}
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"version": "2802262051",
|
||||
"version": "0103261543",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "magent-frontend",
|
||||
"version": "2802262051",
|
||||
"version": "0103261543",
|
||||
"dependencies": {
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "magent-frontend",
|
||||
"private": true,
|
||||
"version": "2802262051",
|
||||
"version": "0103261543",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
|
||||
Reference in New Issue
Block a user