Rework invite creation as guided flow
Magent CI/CD / verify (push) Canceled after 8m52s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-01 21:53:27 +12:00
parent 3aac40ba0f
commit 2dbe11e6bc
4 changed files with 707 additions and 218 deletions
+343 -216
View File
@@ -71,10 +71,11 @@ type InviteForm = {
enabled: boolean
expires_at: string
recipient_email: string
send_email: boolean
message: string
}
type InviteDeliveryMethod = '' | 'manual' | 'email'
type ProfileForm = {
name: string
description: string
@@ -145,7 +146,6 @@ const defaultInviteForm = (profileId = '', safeDefaults = false): InviteForm =>
enabled: true,
expires_at: safeDefaults ? futureInviteExpiry(7) : '',
recipient_email: '',
send_email: false,
message: '',
})
@@ -206,6 +206,10 @@ export default function AdminInviteManagementPage() {
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
const [inviteFlowStep, setInviteFlowStep] = useState(1)
const [useCustomInviteCode, setUseCustomInviteCode] = useState(false)
const [inviteDeliveryMethod, setInviteDeliveryMethod] = useState<InviteDeliveryMethod>('')
const [createdInvite, setCreatedInvite] = useState<Invite | null>(null)
const [inviteSummary, setInviteSummary] = useState<InviteSummary | null>(null)
const [inviteView, setInviteView] = useState<InviteView>('all')
@@ -363,13 +367,9 @@ export default function AdminInviteManagementPage() {
setInviteEditingId(null)
const defaultProfile = profiles.find((profile) => profile.is_active !== false)
setInviteForm(defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true))
}
const setInviteExpiryPreset = (days: number | null) => {
setInviteForm((current) => ({
...current,
expires_at: days == null ? '' : futureInviteExpiry(days),
}))
setInviteFlowStep(1)
setUseCustomInviteCode(false)
setInviteDeliveryMethod('')
}
const editInvite = (invite: Invite) => {
@@ -387,22 +387,31 @@ export default function AdminInviteManagementPage() {
enabled: invite.enabled !== false,
expires_at: invite.expires_at ?? '',
recipient_email: invite.recipient_email ?? '',
send_email: false,
message: '',
})
setInviteFlowStep(4)
setUseCustomInviteCode(true)
setInviteDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
setCreatedInvite(null)
setStatus(null)
setError(null)
}
const saveInvite = async (event: React.FormEvent) => {
event.preventDefault()
const recipientEmail = inviteForm.recipient_email.trim()
if (!recipientEmail) {
setError('Recipient email is required.')
const inviteName = inviteForm.label.trim()
if (!inviteName) {
setError('Give this invite a name so you can recognise it later.')
setStatus(null)
return
}
if (!isValidEmail(recipientEmail)) {
const recipientEmail = inviteForm.recipient_email.trim()
if (!inviteDeliveryMethod) {
setError('Choose whether to copy the invite link yourself or send it by email.')
setStatus(null)
return
}
if (inviteDeliveryMethod === 'email' && !isValidEmail(recipientEmail)) {
setError('Recipient email must be valid.')
setStatus(null)
return
@@ -413,16 +422,16 @@ export default function AdminInviteManagementPage() {
try {
const baseUrl = getApiBase()
const payload = {
code: inviteForm.code || null,
label: inviteForm.label || null,
code: useCustomInviteCode ? inviteForm.code || null : null,
label: inviteName,
description: inviteForm.description || null,
profile_id: inviteForm.profile_id || null,
role: inviteForm.role || null,
max_uses: inviteForm.max_uses || null,
enabled: inviteForm.enabled,
expires_at: inviteForm.expires_at || null,
recipient_email: recipientEmail,
send_email: inviteForm.send_email,
recipient_email: inviteDeliveryMethod === 'email' ? recipientEmail : null,
send_email: inviteEditingId == null && inviteDeliveryMethod === 'email',
message: inviteForm.message || null,
}
const url =
@@ -439,8 +448,10 @@ export default function AdminInviteManagementPage() {
const text = await response.text()
throw new Error(text || 'Save failed')
}
resetInviteEditor()
const data = await response.json()
const savedInvite = (data?.invite ?? null) as Invite | null
setCreatedInvite(savedInvite)
resetInviteEditor()
if (data?.email?.status === 'ok') {
setStatus(
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
@@ -450,7 +461,13 @@ export default function AdminInviteManagementPage() {
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
)
} else {
setStatus(inviteEditingId == null ? 'Invite created.' : 'Invite updated.')
setStatus(
inviteEditingId == null
? inviteDeliveryMethod === 'manual'
? 'Invite created. Copy the link below when you are ready to share it.'
: 'Invite created.'
: 'Invite updated.'
)
}
await loadData()
} catch (err) {
@@ -891,6 +908,13 @@ export default function AdminInviteManagementPage() {
const masterInvite = invitePolicy?.master_invite ?? null
const selectedTemplate =
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
const inviteCodeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '')
const inviteIdentityReady = Boolean(
inviteForm.label.trim() && (!useCustomInviteCode || inviteCodeCharacters.length >= 6)
)
const createdInviteUrl = createdInvite
? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}`
: ''
const inviteAttentionCount = inviteSummary?.attention ?? invites.filter((invite) => !isInviteOperationallyReady(invite)).length
const filteredInvites = useMemo(() => {
if (inviteView === 'ready') return invites.filter(isInviteOperationallyReady)
@@ -1258,6 +1282,7 @@ export default function AdminInviteManagementPage() {
type="button"
className="ghost-button"
onClick={() => {
setCreatedInvite(null)
resetInviteEditor()
setActiveTab('invites')
}}
@@ -1615,14 +1640,14 @@ export default function AdminInviteManagementPage() {
<div key={invite.id} className={`admin-list-item invite-list-item is-${invite.operational_state ?? 'ready'}`}>
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<strong>{invite.label || 'Unnamed invite'}</strong>
<span className={`small-pill invite-state-pill is-${invite.operational_state ?? 'ready'}`}>
{invite.state_label ?? (invite.is_usable ? 'Ready to use' : 'Unavailable')}
</span>
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
</div>
{invite.attention_reason ? <p className="invite-attention-reason">{invite.attention_reason}</p> : null}
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
<p className="admin-list-item-text"><code className="invite-code">{invite.code}</code></p>
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
@@ -1667,208 +1692,310 @@ export default function AdminInviteManagementPage() {
)}
</div>
<div className="admin-panel invite-admin-form-panel">
<h2>{inviteEditingId == 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 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 className="invite-flow-heading">
<div>
<span className="eyebrow">Invite flow</span>
<h2>{inviteEditingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}</h2>
<p className="lede">Set up the invite one decision at a time.</p>
</div>
<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, description: e.target.value }))
}
placeholder="Optional note shown on the signup page"
/>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>Cancel edit</button>
)}
</div>
{createdInvite && inviteEditingId == null ? (
<div className="invite-created-card" role="status">
<span className="eyebrow">Invite ready</span>
<h3>{createdInvite.label || 'Your invite'}</h3>
<p>
{createdInvite.recipient_email
? `The invitation was emailed to ${createdInvite.recipient_email}. You can also copy the link below.`
: 'Copy this link and send it to the person you are inviting.'}
</p>
<div className="invite-created-link">
<input value={createdInviteUrl} readOnly aria-label="Created invite link" />
<button type="button" onClick={() => void copyInviteLink(createdInvite)}>Copy link</button>
</div>
<button
type="button"
className="ghost-button"
onClick={() => {
setCreatedInvite(null)
resetInviteEditor()
}}
>
Create another invite
</button>
</div>
) : (
<form onSubmit={saveInvite} className="invite-flow-form">
<ol className="invite-flow-route" aria-label="Invite creation progress">
{['Identity', 'Description', 'Access', 'Delivery'].map((label, index) => {
const step = index + 1
return (
<li
key={label}
className={step === inviteFlowStep ? 'is-active' : step < inviteFlowStep ? 'is-complete' : ''}
>
<span>{String(step).padStart(2, '0')}</span>
<strong>{label}</strong>
</li>
)
})}
</ol>
<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>
<select
value={inviteForm.max_uses}
onChange={(e) =>
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
}
>
<option value="">Unlimited</option>
<option value="1">One person</option>
<option value="2">Two people</option>
<option value="5">Five people</option>
<option value="10">Ten people</option>
<option value="25">Twenty-five people</option>
{inviteForm.max_uses && !['1', '2', '5', '10', '25'].includes(inviteForm.max_uses) ? (
<option value={inviteForm.max_uses}>{inviteForm.max_uses} uses</option>
) : null}
</select>
</label>
<div className="invite-expiry-presets">
<span>Invite lifetime</span>
<section className={`invite-flow-step ${inviteFlowStep > 1 ? 'is-complete' : 'is-active'}`}>
<header>
<span className="invite-flow-number">01</span>
<div>
<button type="button" onClick={() => setInviteExpiryPreset(1)}>24 hours</button>
<button type="button" onClick={() => setInviteExpiryPreset(7)}>7 days</button>
<button type="button" onClick={() => setInviteExpiryPreset(30)}>30 days</button>
<button type="button" onClick={() => setInviteExpiryPreset(90)}>90 days</button>
<button type="button" className="ghost-button" onClick={() => setInviteExpiryPreset(null)}>No expiry</button>
<span className="eyebrow">Identity</span>
<h3>Who is this invite for?</h3>
<p>Give it a name that will make sense when you return later.</p>
</div>
<small>{inviteForm.expires_at ? `Expires ${formatDate(inviteForm.expires_at)}` : 'This invite will not expire.'}</small>
</div>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Delivery</span>
<small>Recipient email is required. You can optionally send the invite immediately after saving.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label>
<span>Recipient email (required)</span>
<input
type="email"
required
value={inviteForm.recipient_email}
onChange={(e) =>
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
}
placeholder="Required recipient email"
/>
</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>
<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>
</header>
<div className="invite-flow-fields">
<label>
<span>Invite name</span>
<input
value={inviteForm.label}
onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))}
placeholder="Family, that guy from work, the neighbour"
/>
</label>
<label className="invite-flow-choice-line">
<input
type="checkbox"
checked={useCustomInviteCode}
onChange={(event) => {
setUseCustomInviteCode(event.target.checked)
if (!event.target.checked) {
setInviteForm((current) => ({ ...current, code: '' }))
}
}}
disabled={inviteEditingId != null}
/>
<span>
<strong>Choose a custom invite code</strong>
<small>The code appears at the end of the sign-up link. Leave this off and Magent will create a secure code for you.</small>
</span>
</label>
{useCustomInviteCode && (
<label>
<span>Custom code</span>
<input
value={inviteForm.code}
onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))}
placeholder="At least 6 letters or numbers"
disabled={inviteEditingId != null}
/>
<small>This becomes <code>/signup?code={inviteForm.code || 'YOUR-CODE'}</code>.</small>
</label>
)}
{inviteFlowStep === 1 && (
<div className="invite-flow-actions">
<button type="button" disabled={!inviteIdentityReady} onClick={() => setInviteFlowStep(2)}>
Continue to description
</button>
</div>
)}
</div>
</div>
</div>
</form>
</section>
{inviteFlowStep >= 2 && (
<section className={`invite-flow-step ${inviteFlowStep > 2 ? 'is-complete' : 'is-active'}`}>
<header>
<span className="invite-flow-number">02</span>
<div>
<span className="eyebrow">Description</span>
<h3>Add a welcome note</h3>
<p>This optional message is shown to the person on the sign-up page.</p>
</div>
</header>
<div className="invite-flow-fields">
<label>
<span>Welcome note (optional)</span>
<textarea
rows={3}
value={inviteForm.description}
onChange={(event) => setInviteForm((current) => ({ ...current, description: event.target.value }))}
placeholder="Welcome to Grizzlyflix. Use this link to create your account."
/>
</label>
{inviteFlowStep === 2 && (
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(1)}>Back</button>
<button type="button" className="ghost-button" onClick={() => {
setInviteForm((current) => ({ ...current, description: '' }))
setInviteFlowStep(3)
}}>Skip</button>
<button type="button" onClick={() => setInviteFlowStep(3)}>Continue</button>
</div>
)}
</div>
</section>
)}
{inviteFlowStep >= 3 && (
<section className={`invite-flow-step ${inviteFlowStep > 3 ? 'is-complete' : 'is-active'}`}>
<header>
<span className="invite-flow-number">03</span>
<div>
<span className="eyebrow">Access</span>
<h3>Choose their account access</h3>
<p>The selected profile controls their defaults when they sign up.</p>
</div>
</header>
<div className="invite-flow-fields">
<div className="invite-flow-field-grid">
<label>
<span>Profile</span>
<select
value={inviteForm.profile_id}
onChange={(event) => setInviteForm((current) => ({ ...current, profile_id: event.target.value }))}
>
<option value="">Use system defaults</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<label>
<span>Permission</span>
<select
value={inviteForm.role}
onChange={(event) => setInviteForm((current) => ({
...current,
role: event.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="invite-policy-note">
<strong>Invite limits are automatic</strong>
<span>Magent applies the configured usage and expiry rules. Those controls will live in Config.</span>
</div>
{inviteFlowStep === 3 && (
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(2)}>Back</button>
<button type="button" onClick={() => setInviteFlowStep(4)}>Continue to delivery</button>
</div>
)}
</div>
</section>
)}
{inviteFlowStep >= 4 && (
<section className="invite-flow-step is-active">
<header>
<span className="invite-flow-number">04</span>
<div>
<span className="eyebrow">Delivery</span>
<h3>How will they receive it?</h3>
<p>Copy the link yourself, or let Magent email it directly.</p>
</div>
</header>
<div className="invite-flow-fields">
<div className="invite-delivery-grid">
<button
type="button"
className={inviteDeliveryMethod === 'manual' ? 'is-selected' : ''}
onClick={() => {
setInviteDeliveryMethod('manual')
setInviteForm((current) => ({ ...current, recipient_email: '', message: '' }))
}}
>
<span className="eyebrow">Manual</span>
<strong>Give me a link</strong>
<small>Magent creates the URL. You copy and share it yourself.</small>
</button>
<button
type="button"
className={inviteDeliveryMethod === 'email' ? 'is-selected' : ''}
onClick={() => setInviteDeliveryMethod('email')}
>
<span className="eyebrow">Email</span>
<strong>Send it for me</strong>
<small>Magent emails the invitation and still gives you a copyable URL.</small>
</button>
</div>
{inviteDeliveryMethod === 'manual' && (
<div className="invite-delivery-summary">
<strong>Your link will appear as soon as the invite is created.</strong>
<span>No email address is required and Magent will not send a message.</span>
</div>
)}
{inviteDeliveryMethod === 'email' && (
<div className="invite-flow-field-grid">
<label>
<span>Recipient email</span>
<input
type="email"
value={inviteForm.recipient_email}
onChange={(event) => setInviteForm((current) => ({ ...current, recipient_email: event.target.value }))}
placeholder="person@example.com"
/>
</label>
<label>
<span>Email note (optional)</span>
<textarea
rows={3}
value={inviteForm.message}
onChange={(event) => setInviteForm((current) => ({ ...current, message: event.target.value }))}
placeholder="A short personal message"
/>
</label>
</div>
)}
{inviteEditingId != null && (
<label className="invite-status-control">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(event) => setInviteForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
<span>
<strong>{inviteForm.enabled ? 'Invite enabled' : 'Invite disabled'}</strong>
<small>Disable this existing invite to stop its link from accepting sign-ups.</small>
</span>
</label>
)}
{inviteDeliveryMethod === 'email' && emailConfigured?.configured === false && (
<div className="status-banner">{emailConfigured.detail || 'Configure SMTP before sending an invite by email.'}</div>
)}
<div className="invite-flow-actions">
<button type="button" className="ghost-button" onClick={() => setInviteFlowStep(3)}>Back</button>
<button
type="submit"
disabled={
inviteSaving ||
!inviteDeliveryMethod ||
(inviteDeliveryMethod === 'email' && (!isValidEmail(inviteForm.recipient_email) || emailConfigured?.configured === false))
}
>
{inviteSaving
? 'Creating invite…'
: inviteEditingId != null
? 'Save invite'
: inviteDeliveryMethod === 'email'
? 'Create and email invite'
: 'Create invite link'}
</button>
</div>
</div>
</section>
)}
</form>
)}
</div>
</div>
)}
+323
View File
@@ -5066,6 +5066,302 @@ button:hover:not(:disabled) {
font-size: 0.7rem;
}
.invite-flow-heading {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
margin-bottom: 16px;
}
.invite-flow-heading h2,
.invite-created-card h3,
.invite-flow-step h3 {
margin: 0;
}
.invite-flow-heading .lede {
margin: 4px 0 0;
}
.invite-flow-form {
display: grid;
gap: 12px;
}
.invite-flow-route {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0 0 4px;
padding: 0;
list-style: none;
}
.invite-flow-route li {
position: relative;
display: flex;
gap: 9px;
align-items: center;
min-width: 0;
padding: 10px 12px;
border: 1px solid rgba(138, 155, 185, 0.18);
background: rgba(255, 255, 255, 0.015);
color: #77849a;
}
.invite-flow-route li span {
color: #718095;
font-family: var(--font-mono), monospace;
font-size: 0.68rem;
}
.invite-flow-route li strong {
overflow: hidden;
font-size: 0.76rem;
text-overflow: ellipsis;
}
.invite-flow-route li.is-active {
border-color: rgba(126, 215, 255, 0.5);
background: linear-gradient(135deg, rgba(14, 165, 233, 0.14), rgba(35, 74, 119, 0.11));
color: #edf8ff;
box-shadow: inset 0 2px 0 rgba(126, 215, 255, 0.75);
}
.invite-flow-route li.is-active span,
.invite-flow-route li.is-complete span {
color: #7ed7ff;
}
.invite-flow-route li.is-complete {
border-color: rgba(72, 224, 178, 0.28);
color: #c9d7e5;
}
.invite-flow-step {
overflow: hidden;
border: 1px solid rgba(138, 155, 185, 0.18);
background: linear-gradient(145deg, rgba(31, 43, 65, 0.82), rgba(20, 29, 45, 0.82));
}
.invite-flow-step.is-active {
border-color: rgba(126, 215, 255, 0.38);
box-shadow: inset 0 2px 0 rgba(126, 215, 255, 0.7);
}
.invite-flow-step.is-complete {
border-color: rgba(72, 224, 178, 0.24);
}
.invite-flow-step > header {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 16px;
border-bottom: 1px solid rgba(138, 155, 185, 0.14);
}
.invite-flow-step > header > div {
display: grid;
gap: 3px;
}
.invite-flow-step > header p {
margin: 0;
color: #aeb8c7;
font-size: 0.78rem;
}
.invite-flow-number {
display: grid;
flex: 0 0 34px;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid rgba(126, 215, 255, 0.4);
border-radius: 50%;
color: #7ed7ff;
font-family: var(--font-mono), monospace;
font-size: 0.7rem;
font-weight: 700;
}
.invite-flow-fields {
display: grid;
gap: 12px;
padding: 16px;
}
.invite-flow-fields > label,
.invite-flow-field-grid > label {
display: grid;
gap: 6px;
}
.invite-flow-fields label > span:first-child,
.invite-flow-field-grid label > span:first-child {
color: #cbd5e3;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.035em;
text-transform: uppercase;
}
.invite-flow-fields input:not([type='checkbox']),
.invite-flow-fields select,
.invite-flow-fields textarea {
width: 100%;
}
.invite-flow-fields label > small {
color: #8f9caf;
font-size: 0.7rem;
line-height: 1.4;
}
.invite-flow-field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.invite-flow-choice-line,
.invite-status-control {
grid-template-columns: auto minmax(0, 1fr);
gap: 10px !important;
align-items: flex-start;
padding: 12px;
border: 1px solid rgba(138, 155, 185, 0.2);
background: rgba(255, 255, 255, 0.02);
cursor: pointer;
}
.invite-flow-choice-line > input,
.invite-status-control > input {
margin-top: 3px;
}
.invite-flow-choice-line > span,
.invite-status-control > span {
display: grid;
gap: 3px;
text-transform: none !important;
}
.invite-flow-choice-line strong,
.invite-status-control strong {
color: #e8edf5;
font-size: 0.8rem;
}
.invite-flow-choice-line small,
.invite-status-control small {
color: #9ba8ba;
font-size: 0.72rem;
font-weight: 400;
letter-spacing: normal;
line-height: 1.4;
}
.invite-flow-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
padding-top: 2px;
}
.invite-policy-note,
.invite-delivery-summary {
display: grid;
gap: 4px;
padding: 12px;
border: 1px solid rgba(72, 224, 178, 0.2);
background: rgba(72, 224, 178, 0.055);
}
.invite-policy-note strong,
.invite-delivery-summary strong {
color: #dffaf1;
font-size: 0.78rem;
}
.invite-policy-note span,
.invite-delivery-summary span {
color: #9fb4b5;
font-size: 0.72rem;
}
.invite-delivery-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.invite-delivery-grid > button {
display: grid;
gap: 5px;
min-height: 112px;
padding: 14px;
border-color: rgba(138, 155, 185, 0.2);
background: rgba(255, 255, 255, 0.018);
color: #cbd5e3;
text-align: left;
}
.invite-delivery-grid > button strong {
color: #f2f6fb;
font-size: 0.92rem;
}
.invite-delivery-grid > button small {
color: #98a5b7;
font-size: 0.72rem;
font-weight: 400;
line-height: 1.4;
}
.invite-delivery-grid > button.is-selected {
border-color: rgba(126, 215, 255, 0.58);
background: linear-gradient(145deg, rgba(14, 165, 233, 0.16), rgba(35, 74, 119, 0.13));
box-shadow: inset 0 2px 0 #7ed7ff;
}
.invite-status-control {
border-color: rgba(255, 197, 109, 0.27);
background: rgba(255, 197, 109, 0.045);
}
.invite-created-card {
display: grid;
gap: 12px;
padding: 18px;
border: 1px solid rgba(72, 224, 178, 0.38);
background: linear-gradient(145deg, rgba(72, 224, 178, 0.1), rgba(20, 29, 45, 0.72));
box-shadow: inset 0 2px 0 rgba(72, 224, 178, 0.72);
}
.invite-created-card p {
margin: 0;
color: #b7c5d4;
}
.invite-created-card > .ghost-button {
justify-self: start;
}
.invite-created-link {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
}
.invite-created-link input {
min-width: 0;
font-family: var(--font-mono), monospace;
}
.invite-email-template-picker {
display: flex;
flex-wrap: wrap;
@@ -5140,6 +5436,10 @@ button:hover:not(:disabled) {
.invite-form-row-grid {
grid-template-columns: 1fr;
}
.invite-flow-field-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 980px) {
@@ -5210,6 +5510,29 @@ button:hover:not(:disabled) {
}
}
@media (max-width: 680px) {
.invite-flow-heading {
display: grid;
}
.invite-flow-route {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invite-delivery-grid,
.invite-created-link {
grid-template-columns: 1fr;
}
.invite-flow-actions {
justify-content: stretch;
}
.invite-flow-actions button {
flex: 1 1 auto;
}
}
/* Enterprise UI tightening pass */
.admin-panel,
.user-detail-panel,