From 2dbe11e6bc5ec6207160ac20e2761d00595c708e Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Tue, 1 Sep 2026 21:53:27 +1200 Subject: [PATCH] Rework invite creation as guided flow --- backend/app/routers/admin.py | 17 +- backend/tests/test_backend_quality.py | 26 ++ frontend/app/admin/invites/page.tsx | 559 ++++++++++++++++---------- frontend/app/globals.css | 323 +++++++++++++++ 4 files changed, 707 insertions(+), 218 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index ec2da5f..953fe24 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -122,6 +122,15 @@ def _require_recipient_email(value: object) -> str: 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 = { "magent_ssl_certificate_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")) max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") 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")) + 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")) try: 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")) max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") 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")) + 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")) try: invite = update_signup_invite( diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 372ece0..2ccce25 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1793,6 +1793,32 @@ class PortalMediaStatusTests(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: 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") diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index c87234a..5a2f17e 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -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(null) const [inviteForm, setInviteForm] = useState(defaultInviteForm()) + const [inviteFlowStep, setInviteFlowStep] = useState(1) + const [useCustomInviteCode, setUseCustomInviteCode] = useState(false) + const [inviteDeliveryMethod, setInviteDeliveryMethod] = useState('') + const [createdInvite, setCreatedInvite] = useState(null) const [inviteSummary, setInviteSummary] = useState(null) const [inviteView, setInviteView] = useState('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() {
- {invite.code} + {invite.label || 'Unnamed invite'} {invite.state_label ?? (invite.is_usable ? 'Ready to use' : 'Unavailable')} {invite.profile?.name && {invite.profile.name}}
{invite.attention_reason ?

{invite.attention_reason}

: null} - {invite.label &&

{invite.label}

} +

{invite.code}

{invite.description && (

{invite.description} @@ -1667,208 +1692,310 @@ export default function AdminInviteManagementPage() { )}

-

{inviteEditingId == null ? 'Create invite' : 'Edit invite'}

-

- Link an invite to a profile to apply account defaults at sign-up. -

-
-
-
- Identity - Code and label used to identify the invite link. -
-
- - -
+
+
+ Invite flow +

{inviteEditingId == null ? 'Create an invite' : `Edit ${inviteForm.label || 'invite'}`}

+

Set up the invite one decision at a time.

- -
-
- Description - Optional note shown on the signup page. -
-
-