Rework invite creation as guided flow
This commit is contained in:
@@ -122,6 +122,15 @@ def _require_recipient_email(value: object) -> str:
|
|||||||
detail="recipient_email is required and must be a valid email address",
|
detail="recipient_email is required and must be a valid email address",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_recipient_email(value: object) -> Optional[str]:
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
return None
|
||||||
|
normalized = normalize_delivery_email(value)
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
raise HTTPException(status_code=400, detail="recipient_email must be a valid email address")
|
||||||
|
|
||||||
SENSITIVE_KEYS = {
|
SENSITIVE_KEYS = {
|
||||||
"magent_ssl_certificate_pem",
|
"magent_ssl_certificate_pem",
|
||||||
"magent_ssl_private_key_pem",
|
"magent_ssl_private_key_pem",
|
||||||
@@ -1927,8 +1936,10 @@ async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] =
|
|||||||
role = _normalize_role_or_none(payload.get("role"))
|
role = _normalize_role_or_none(payload.get("role"))
|
||||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||||
recipient_email = _require_recipient_email(payload.get("recipient_email"))
|
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
|
||||||
send_email = bool(payload.get("send_email"))
|
send_email = bool(payload.get("send_email"))
|
||||||
|
if send_email and not recipient_email:
|
||||||
|
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
|
||||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||||
try:
|
try:
|
||||||
invite = create_signup_invite(
|
invite = create_signup_invite(
|
||||||
@@ -1998,8 +2009,10 @@ async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]
|
|||||||
role = _normalize_role_or_none(payload.get("role"))
|
role = _normalize_role_or_none(payload.get("role"))
|
||||||
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
||||||
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
||||||
recipient_email = _normalize_optional_text(payload.get("recipient_email"))
|
recipient_email = _optional_recipient_email(payload.get("recipient_email"))
|
||||||
send_email = bool(payload.get("send_email"))
|
send_email = bool(payload.get("send_email"))
|
||||||
|
if send_email and not recipient_email:
|
||||||
|
raise HTTPException(status_code=400, detail="recipient_email is required for email delivery")
|
||||||
delivery_message = _normalize_optional_text(payload.get("message"))
|
delivery_message = _normalize_optional_text(payload.get("message"))
|
||||||
try:
|
try:
|
||||||
invite = update_signup_invite(
|
invite = update_signup_invite(
|
||||||
|
|||||||
@@ -1793,6 +1793,32 @@ class PortalMediaStatusTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
|
|
||||||
|
|
||||||
class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
class InviteOperationalStateTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_manual_invite_can_be_created_without_recipient_email(self) -> None:
|
||||||
|
payload = await admin_router.create_invite(
|
||||||
|
{
|
||||||
|
"label": "The neighbour",
|
||||||
|
"recipient_email": None,
|
||||||
|
"send_email": False,
|
||||||
|
"max_uses": 1,
|
||||||
|
},
|
||||||
|
{"username": "admin", "role": "admin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(payload["status"], "ok")
|
||||||
|
self.assertEqual(payload["invite"]["label"], "The neighbour")
|
||||||
|
self.assertIsNone(payload["invite"]["recipient_email"])
|
||||||
|
self.assertTrue(payload["invite"]["enabled"])
|
||||||
|
|
||||||
|
async def test_email_delivery_still_requires_valid_recipient(self) -> None:
|
||||||
|
with self.assertRaises(HTTPException) as context:
|
||||||
|
await admin_router.create_invite(
|
||||||
|
{"label": "Family", "send_email": True},
|
||||||
|
{"username": "admin", "role": "admin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(context.exception.status_code, 400)
|
||||||
|
self.assertIn("required for email delivery", str(context.exception.detail))
|
||||||
|
|
||||||
async def test_invite_list_reports_automatic_operational_states(self) -> None:
|
async def test_invite_list_reports_automatic_operational_states(self) -> None:
|
||||||
ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
|
ready = db.create_signup_invite(code="READY", recipient_email="ready@example.com")
|
||||||
db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
|
db.create_signup_invite(code="DISABLED", enabled=False, recipient_email="off@example.com")
|
||||||
|
|||||||
+343
-216
@@ -71,10 +71,11 @@ type InviteForm = {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
expires_at: string
|
expires_at: string
|
||||||
recipient_email: string
|
recipient_email: string
|
||||||
send_email: boolean
|
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InviteDeliveryMethod = '' | 'manual' | 'email'
|
||||||
|
|
||||||
type ProfileForm = {
|
type ProfileForm = {
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
@@ -145,7 +146,6 @@ const defaultInviteForm = (profileId = '', safeDefaults = false): InviteForm =>
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
expires_at: safeDefaults ? futureInviteExpiry(7) : '',
|
expires_at: safeDefaults ? futureInviteExpiry(7) : '',
|
||||||
recipient_email: '',
|
recipient_email: '',
|
||||||
send_email: false,
|
|
||||||
message: '',
|
message: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -206,6 +206,10 @@ export default function AdminInviteManagementPage() {
|
|||||||
|
|
||||||
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
const [inviteEditingId, setInviteEditingId] = useState<number | null>(null)
|
||||||
const [inviteForm, setInviteForm] = useState<InviteForm>(defaultInviteForm())
|
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 [inviteSummary, setInviteSummary] = useState<InviteSummary | null>(null)
|
||||||
const [inviteView, setInviteView] = useState<InviteView>('all')
|
const [inviteView, setInviteView] = useState<InviteView>('all')
|
||||||
|
|
||||||
@@ -363,13 +367,9 @@ export default function AdminInviteManagementPage() {
|
|||||||
setInviteEditingId(null)
|
setInviteEditingId(null)
|
||||||
const defaultProfile = profiles.find((profile) => profile.is_active !== false)
|
const defaultProfile = profiles.find((profile) => profile.is_active !== false)
|
||||||
setInviteForm(defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true))
|
setInviteForm(defaultInviteForm(defaultProfile ? String(defaultProfile.id) : '', true))
|
||||||
}
|
setInviteFlowStep(1)
|
||||||
|
setUseCustomInviteCode(false)
|
||||||
const setInviteExpiryPreset = (days: number | null) => {
|
setInviteDeliveryMethod('')
|
||||||
setInviteForm((current) => ({
|
|
||||||
...current,
|
|
||||||
expires_at: days == null ? '' : futureInviteExpiry(days),
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const editInvite = (invite: Invite) => {
|
const editInvite = (invite: Invite) => {
|
||||||
@@ -387,22 +387,31 @@ export default function AdminInviteManagementPage() {
|
|||||||
enabled: invite.enabled !== false,
|
enabled: invite.enabled !== false,
|
||||||
expires_at: invite.expires_at ?? '',
|
expires_at: invite.expires_at ?? '',
|
||||||
recipient_email: invite.recipient_email ?? '',
|
recipient_email: invite.recipient_email ?? '',
|
||||||
send_email: false,
|
|
||||||
message: '',
|
message: '',
|
||||||
})
|
})
|
||||||
|
setInviteFlowStep(4)
|
||||||
|
setUseCustomInviteCode(true)
|
||||||
|
setInviteDeliveryMethod(invite.recipient_email ? 'email' : 'manual')
|
||||||
|
setCreatedInvite(null)
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveInvite = async (event: React.FormEvent) => {
|
const saveInvite = async (event: React.FormEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const recipientEmail = inviteForm.recipient_email.trim()
|
const inviteName = inviteForm.label.trim()
|
||||||
if (!recipientEmail) {
|
if (!inviteName) {
|
||||||
setError('Recipient email is required.')
|
setError('Give this invite a name so you can recognise it later.')
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
return
|
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.')
|
setError('Recipient email must be valid.')
|
||||||
setStatus(null)
|
setStatus(null)
|
||||||
return
|
return
|
||||||
@@ -413,16 +422,16 @@ export default function AdminInviteManagementPage() {
|
|||||||
try {
|
try {
|
||||||
const baseUrl = getApiBase()
|
const baseUrl = getApiBase()
|
||||||
const payload = {
|
const payload = {
|
||||||
code: inviteForm.code || null,
|
code: useCustomInviteCode ? inviteForm.code || null : null,
|
||||||
label: inviteForm.label || null,
|
label: inviteName,
|
||||||
description: inviteForm.description || null,
|
description: inviteForm.description || null,
|
||||||
profile_id: inviteForm.profile_id || null,
|
profile_id: inviteForm.profile_id || null,
|
||||||
role: inviteForm.role || null,
|
role: inviteForm.role || null,
|
||||||
max_uses: inviteForm.max_uses || null,
|
max_uses: inviteForm.max_uses || null,
|
||||||
enabled: inviteForm.enabled,
|
enabled: inviteForm.enabled,
|
||||||
expires_at: inviteForm.expires_at || null,
|
expires_at: inviteForm.expires_at || null,
|
||||||
recipient_email: recipientEmail,
|
recipient_email: inviteDeliveryMethod === 'email' ? recipientEmail : null,
|
||||||
send_email: inviteForm.send_email,
|
send_email: inviteEditingId == null && inviteDeliveryMethod === 'email',
|
||||||
message: inviteForm.message || null,
|
message: inviteForm.message || null,
|
||||||
}
|
}
|
||||||
const url =
|
const url =
|
||||||
@@ -439,8 +448,10 @@ export default function AdminInviteManagementPage() {
|
|||||||
const text = await response.text()
|
const text = await response.text()
|
||||||
throw new Error(text || 'Save failed')
|
throw new Error(text || 'Save failed')
|
||||||
}
|
}
|
||||||
resetInviteEditor()
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
const savedInvite = (data?.invite ?? null) as Invite | null
|
||||||
|
setCreatedInvite(savedInvite)
|
||||||
|
resetInviteEditor()
|
||||||
if (data?.email?.status === 'ok') {
|
if (data?.email?.status === 'ok') {
|
||||||
setStatus(
|
setStatus(
|
||||||
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'} and email sent to ${data.email.recipient_email}.`
|
`${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}`
|
`${inviteEditingId == null ? 'Invite created' : 'Invite updated'}, but email failed: ${data.email.detail}`
|
||||||
)
|
)
|
||||||
} else {
|
} 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()
|
await loadData()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -891,6 +908,13 @@ export default function AdminInviteManagementPage() {
|
|||||||
const masterInvite = invitePolicy?.master_invite ?? null
|
const masterInvite = invitePolicy?.master_invite ?? null
|
||||||
const selectedTemplate =
|
const selectedTemplate =
|
||||||
emailTemplates.find((template) => template.key === selectedTemplateKey) ?? emailTemplates[0] ?? null
|
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 inviteAttentionCount = inviteSummary?.attention ?? invites.filter((invite) => !isInviteOperationallyReady(invite)).length
|
||||||
const filteredInvites = useMemo(() => {
|
const filteredInvites = useMemo(() => {
|
||||||
if (inviteView === 'ready') return invites.filter(isInviteOperationallyReady)
|
if (inviteView === 'ready') return invites.filter(isInviteOperationallyReady)
|
||||||
@@ -1258,6 +1282,7 @@ export default function AdminInviteManagementPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="ghost-button"
|
className="ghost-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setCreatedInvite(null)
|
||||||
resetInviteEditor()
|
resetInviteEditor()
|
||||||
setActiveTab('invites')
|
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 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-main">
|
||||||
<div className="admin-list-item-title-row">
|
<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'}`}>
|
<span className={`small-pill invite-state-pill is-${invite.operational_state ?? 'ready'}`}>
|
||||||
{invite.state_label ?? (invite.is_usable ? 'Ready to use' : 'Unavailable')}
|
{invite.state_label ?? (invite.is_usable ? 'Ready to use' : 'Unavailable')}
|
||||||
</span>
|
</span>
|
||||||
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
|
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{invite.attention_reason ? <p className="invite-attention-reason">{invite.attention_reason}</p> : null}
|
{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 && (
|
{invite.description && (
|
||||||
<p className="admin-list-item-text admin-list-item-text--muted">
|
<p className="admin-list-item-text admin-list-item-text--muted">
|
||||||
{invite.description}
|
{invite.description}
|
||||||
@@ -1667,208 +1692,310 @@ export default function AdminInviteManagementPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-panel invite-admin-form-panel">
|
<div className="admin-panel invite-admin-form-panel">
|
||||||
<h2>{inviteEditingId == null ? 'Create invite' : 'Edit invite'}</h2>
|
<div className="invite-flow-heading">
|
||||||
<p className="lede">
|
<div>
|
||||||
Link an invite to a profile to apply account defaults at sign-up.
|
<span className="eyebrow">Invite flow</span>
|
||||||
</p>
|
<h2>{inviteEditingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}</h2>
|
||||||
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
|
<p className="lede">Set up the invite one decision at a time.</p>
|
||||||
<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>
|
</div>
|
||||||
|
{inviteEditingId != null && (
|
||||||
<div className="invite-form-row">
|
<button type="button" className="ghost-button" onClick={resetInviteEditor}>Cancel edit</button>
|
||||||
<div className="invite-form-row-label">
|
)}
|
||||||
<span>Description</span>
|
</div>
|
||||||
<small>Optional note shown on the signup page.</small>
|
{createdInvite && inviteEditingId == null ? (
|
||||||
</div>
|
<div className="invite-created-card" role="status">
|
||||||
<div className="invite-form-row-control">
|
<span className="eyebrow">Invite ready</span>
|
||||||
<textarea
|
<h3>{createdInvite.label || 'Your invite'}</h3>
|
||||||
rows={3}
|
<p>
|
||||||
value={inviteForm.description}
|
{createdInvite.recipient_email
|
||||||
onChange={(e) =>
|
? `The invitation was emailed to ${createdInvite.recipient_email}. You can also copy the link below.`
|
||||||
setInviteForm((current) => ({ ...current, description: e.target.value }))
|
: 'Copy this link and send it to the person you are inviting.'}
|
||||||
}
|
</p>
|
||||||
placeholder="Optional note shown on the signup page"
|
<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>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-button"
|
||||||
|
onClick={() => {
|
||||||
|
setCreatedInvite(null)
|
||||||
|
resetInviteEditor()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create another invite
|
||||||
|
</button>
|
||||||
</div>
|
</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">
|
<section className={`invite-flow-step ${inviteFlowStep > 1 ? 'is-complete' : 'is-active'}`}>
|
||||||
<div className="invite-form-row-label">
|
<header>
|
||||||
<span>Defaults</span>
|
<span className="invite-flow-number">01</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>
|
|
||||||
<div>
|
<div>
|
||||||
<button type="button" onClick={() => setInviteExpiryPreset(1)}>24 hours</button>
|
<span className="eyebrow">Identity</span>
|
||||||
<button type="button" onClick={() => setInviteExpiryPreset(7)}>7 days</button>
|
<h3>Who is this invite for?</h3>
|
||||||
<button type="button" onClick={() => setInviteExpiryPreset(30)}>30 days</button>
|
<p>Give it a name that will make sense when you return later.</p>
|
||||||
<button type="button" onClick={() => setInviteExpiryPreset(90)}>90 days</button>
|
|
||||||
<button type="button" className="ghost-button" onClick={() => setInviteExpiryPreset(null)}>No expiry</button>
|
|
||||||
</div>
|
</div>
|
||||||
<small>{inviteForm.expires_at ? `Expires ${formatDate(inviteForm.expires_at)}` : 'This invite will not expire.'}</small>
|
</header>
|
||||||
</div>
|
<div className="invite-flow-fields">
|
||||||
</div>
|
<label>
|
||||||
</div>
|
<span>Invite name</span>
|
||||||
|
<input
|
||||||
<div className="invite-form-row">
|
value={inviteForm.label}
|
||||||
<div className="invite-form-row-label">
|
onChange={(event) => setInviteForm((current) => ({ ...current, label: event.target.value }))}
|
||||||
<span>Delivery</span>
|
placeholder="Family, that guy from work, the neighbour"
|
||||||
<small>Recipient email is required. You can optionally send the invite immediately after saving.</small>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
<label className="invite-flow-choice-line">
|
||||||
<label>
|
<input
|
||||||
<span>Recipient email (required)</span>
|
type="checkbox"
|
||||||
<input
|
checked={useCustomInviteCode}
|
||||||
type="email"
|
onChange={(event) => {
|
||||||
required
|
setUseCustomInviteCode(event.target.checked)
|
||||||
value={inviteForm.recipient_email}
|
if (!event.target.checked) {
|
||||||
onChange={(e) =>
|
setInviteForm((current) => ({ ...current, code: '' }))
|
||||||
setInviteForm((current) => ({ ...current, recipient_email: e.target.value }))
|
}
|
||||||
}
|
}}
|
||||||
placeholder="Required recipient email"
|
disabled={inviteEditingId != null}
|
||||||
/>
|
/>
|
||||||
</label>
|
<span>
|
||||||
<label>
|
<strong>Choose a custom invite code</strong>
|
||||||
<span>Delivery note</span>
|
<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>
|
||||||
<textarea
|
</span>
|
||||||
rows={3}
|
</label>
|
||||||
value={inviteForm.message}
|
{useCustomInviteCode && (
|
||||||
onChange={(e) =>
|
<label>
|
||||||
setInviteForm((current) => ({ ...current, message: e.target.value }))
|
<span>Custom code</span>
|
||||||
}
|
<input
|
||||||
placeholder="Optional message appended to the invite email"
|
value={inviteForm.code}
|
||||||
/>
|
onChange={(event) => setInviteForm((current) => ({ ...current, code: event.target.value }))}
|
||||||
</label>
|
placeholder="At least 6 letters or numbers"
|
||||||
<label className="inline-checkbox">
|
disabled={inviteEditingId != null}
|
||||||
<input
|
/>
|
||||||
type="checkbox"
|
<small>This becomes <code>/signup?code={inviteForm.code || 'YOUR-CODE'}</code>.</small>
|
||||||
checked={inviteForm.send_email}
|
</label>
|
||||||
onChange={(e) =>
|
)}
|
||||||
setInviteForm((current) => ({ ...current, send_email: e.target.checked }))
|
{inviteFlowStep === 1 && (
|
||||||
}
|
<div className="invite-flow-actions">
|
||||||
/>
|
<button type="button" disabled={!inviteIdentityReady} onClick={() => setInviteFlowStep(2)}>
|
||||||
Send “You have been invited” email after saving
|
Continue to description
|
||||||
</label>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="invite-form-row">
|
|
||||||
<div className="invite-form-row-label">
|
|
||||||
<span>Status</span>
|
|
||||||
<small>Enable or disable the invite before sharing.</small>
|
|
||||||
</div>
|
|
||||||
<div className="invite-form-row-control invite-form-row-control--stacked">
|
|
||||||
<label className="inline-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={inviteForm.enabled}
|
|
||||||
onChange={(e) =>
|
|
||||||
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
Invite is enabled
|
|
||||||
</label>
|
|
||||||
<div className="admin-inline-actions">
|
|
||||||
<button type="submit" disabled={inviteSaving}>
|
|
||||||
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
|
|
||||||
</button>
|
|
||||||
{inviteEditingId != null && (
|
|
||||||
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
|
|
||||||
Cancel edit
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</form>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5066,6 +5066,302 @@ button:hover:not(:disabled) {
|
|||||||
font-size: 0.7rem;
|
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 {
|
.invite-email-template-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -5140,6 +5436,10 @@ button:hover:not(:disabled) {
|
|||||||
.invite-form-row-grid {
|
.invite-form-row-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-flow-field-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@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 */
|
/* Enterprise UI tightening pass */
|
||||||
.admin-panel,
|
.admin-panel,
|
||||||
.user-detail-panel,
|
.user-detail-panel,
|
||||||
|
|||||||
Reference in New Issue
Block a user