diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 018636e..2482a92 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -102,6 +102,12 @@ def _require_recipient_email(value: object) -> str: ) +def _optional_recipient_email(value: object) -> str | None: + if value is None or not str(value).strip(): + return None + return _require_recipient_email(value) + + def _auth_client_ip(request: Request) -> str: direct_host = request.client.host if request.client else None if request_trusts_forwarded_headers(direct_host): @@ -1210,8 +1216,10 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_ label = str(label).strip() or None if description is not None: description = str(description).strip() or None - recipient_email = _require_recipient_email(recipient_email) send_email = bool(payload.get("send_email")) + recipient_email = _optional_recipient_email(recipient_email) + if send_email and not recipient_email: + recipient_email = _require_recipient_email(recipient_email) delivery_message = str(payload.get("message") or "").strip() or None master_invite = _get_self_service_master_invite() @@ -1300,8 +1308,10 @@ async def update_profile_invite( label = str(label).strip() or None if description is not None: description = str(description).strip() or None - recipient_email = _require_recipient_email(recipient_email) send_email = bool(payload.get("send_email")) + recipient_email = _optional_recipient_email(recipient_email) + if send_email and not recipient_email: + recipient_email = _require_recipient_email(recipient_email) delivery_message = str(payload.get("message") or "").strip() or None master_invite = _get_self_service_master_invite() diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 44eb950..d09068a 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1424,7 +1424,23 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): send_email.assert_awaited_once() self.assertEqual(send_email.await_args.kwargs["recipient_email"], "local@example.com") - async def test_profile_invite_requires_recipient_email(self) -> None: + async def test_profile_manual_invite_does_not_require_recipient_email(self) -> None: + current_user = { + "username": "invite-owner", + "role": "user", + "invite_management_enabled": True, + "profile_id": None, + } + result = await auth_router.create_profile_invite( + {"label": "Family", "recipient_email": None, "send_email": False}, + current_user, + ) + + self.assertEqual(result["status"], "ok") + self.assertIsNone(result["invite"]["recipient_email"]) + self.assertIsNone(result["email"]) + + async def test_profile_email_delivery_requires_recipient_email(self) -> None: current_user = { "username": "invite-owner", "role": "user", @@ -1432,13 +1448,13 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase): "profile_id": None, } with self.assertRaises(HTTPException) as context: - await auth_router.create_profile_invite({"label": "Missing email"}, current_user) + await auth_router.create_profile_invite( + {"label": "Missing email", "send_email": True}, + current_user, + ) self.assertEqual(context.exception.status_code, 400) - self.assertEqual( - context.exception.detail, - "recipient_email is required and must be a valid email address.", - ) + self.assertEqual(context.exception.detail, "recipient_email is required and must be a valid email address.") class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 5a2f17e..00437a0 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -209,7 +209,6 @@ export default function AdminInviteManagementPage() { 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') @@ -392,7 +391,6 @@ export default function AdminInviteManagementPage() { setInviteFlowStep(4) setUseCustomInviteCode(true) setInviteDeliveryMethod(invite.recipient_email ? 'email' : 'manual') - setCreatedInvite(null) setStatus(null) setError(null) } @@ -449,8 +447,6 @@ export default function AdminInviteManagementPage() { throw new Error(text || 'Save failed') } const data = await response.json() - const savedInvite = (data?.invite ?? null) as Invite | null - setCreatedInvite(savedInvite) resetInviteEditor() if (data?.email?.status === 'ok') { setStatus( @@ -912,9 +908,6 @@ export default function AdminInviteManagementPage() { 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) @@ -1281,13 +1274,9 @@ export default function AdminInviteManagementPage() { )} - {createdInvite && inviteEditingId == null ? ( -
- Invite ready -

{createdInvite.label || 'Your invite'}

-

- {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.'} -

-
- - -
- + {inviteEditingId == null ? ( +
+ Option 04 · Invites +

Create invites from the client workspace

+

The guided invite flow now lives in the main navigation where users can create and manage their own invitations.

+
) : (
diff --git a/frontend/app/profile/invites/page.tsx b/frontend/app/profile/invites/page.tsx index 7fb0a29..6ac9507 100644 --- a/frontend/app/profile/invites/page.tsx +++ b/frontend/app/profile/invites/page.tsx @@ -4,134 +4,72 @@ import { useRouter } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' -type ProfileInfo = { - username: string - role: string - auth_provider: string - invite_management_enabled?: boolean -} - -type ProfileResponse = { - user: ProfileInfo -} - +type ProfileInfo = { username: string; role: string; invite_management_enabled?: boolean } type OwnedInvite = { - id: number - code: string - label?: string | null - description?: string | null - recipient_email?: string | null - max_uses?: number | null - use_count: number - remaining_uses?: number | null - enabled: boolean - expires_at?: string | null - is_expired?: boolean - is_usable?: boolean - created_at?: string | null - updated_at?: string | null + id: number; code: string; label?: string | null; description?: string | null + recipient_email?: string | null; max_uses?: number | null; use_count: number + remaining_uses?: number | null; enabled: boolean; expires_at?: string | null + is_usable?: boolean; created_at?: string | null } - type OwnedInvitesResponse = { invites?: OwnedInvite[] - count?: number - invite_access?: { - enabled?: boolean - managed_by_master?: boolean - } - master_invite?: { - id: number - code: string - label?: string | null - description?: string | null - max_uses?: number | null - enabled?: boolean - expires_at?: string | null - is_usable?: boolean - } | null + invite_access?: { enabled?: boolean; managed_by_master?: boolean } + master_invite?: { id: number; code: string; label?: string | null; max_uses?: number | null; expires_at?: string | null } | null } - -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 InviteForm = { + code: string; label: string; description: string; recipient_email: string + enabled: boolean; message: string } +type DeliveryMethod = '' | 'manual' | 'email' -const defaultOwnedInviteForm = (): OwnedInviteForm => ({ - code: '', - label: '', - description: '', - recipient_email: '', - max_uses: '', - expires_at: '', - enabled: true, - send_email: false, - message: '', +const defaultInviteForm = (): InviteForm => ({ + code: '', label: '', description: '', recipient_email: '', enabled: true, message: '', }) - const formatDate = (value?: string | null) => { if (!value) return 'Never' const date = new Date(value) - if (Number.isNaN(date.valueOf())) return value - return date.toLocaleString() + return Number.isNaN(date.valueOf()) ? value : date.toLocaleString() } - const isValidEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) export default function ProfileInvitesPage() { const router = useRouter() const [profile, setProfile] = useState(null) - const [inviteStatus, setInviteStatus] = useState(null) - const [inviteError, setInviteError] = useState(null) const [invites, setInvites] = useState([]) - const [inviteSaving, setInviteSaving] = useState(false) - const [inviteEditingId, setInviteEditingId] = useState(null) - const [inviteForm, setInviteForm] = useState(defaultOwnedInviteForm()) const [inviteAccessEnabled, setInviteAccessEnabled] = useState(false) const [inviteManagedByMaster, setInviteManagedByMaster] = useState(false) - const [masterInviteTemplate, setMasterInviteTemplate] = useState(null) + const [masterInvite, setMasterInvite] = useState(null) const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [status, setStatus] = useState(null) + const [editingId, setEditingId] = useState(null) + const [flowStep, setFlowStep] = useState(1) + const [useCustomCode, setUseCustomCode] = useState(false) + const [deliveryMethod, setDeliveryMethod] = useState('') + const [inviteForm, setInviteForm] = useState(defaultInviteForm()) + const [createdInvite, setCreatedInvite] = useState(null) const signupBaseUrl = useMemo(() => { if (typeof window === 'undefined') return '/signup' return `${window.location.origin}/signup` }, []) - const loadPage = async () => { - const baseUrl = getApiBase() - const [profileResponse, invitesResponse] = await Promise.all([ - authFetch(`${baseUrl}/auth/profile`), - authFetch(`${baseUrl}/auth/profile/invites`), - ]) - if (!profileResponse.ok || !invitesResponse.ok) { - if (profileResponse.status === 401 || invitesResponse.status === 401) { + const loadInvites = async () => { + const response = await authFetch(`${getApiBase()}/auth/profile/invites`) + if (!response.ok) { + if (response.status === 401) { clearToken() router.push('/login') return } - throw new Error('Could not load invite tools.') + throw new Error('Could not load your invite workspace.') } - const [profileData, inviteData] = (await Promise.all([ - profileResponse.json(), - invitesResponse.json(), - ])) as [ProfileResponse, OwnedInvitesResponse] - const user = profileData?.user ?? {} - setProfile({ - username: user?.username ?? 'Unknown', - role: user?.role ?? 'user', - auth_provider: user?.auth_provider ?? 'local', - invite_management_enabled: Boolean(user?.invite_management_enabled ?? false), - }) - setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) - setInviteAccessEnabled(Boolean(inviteData?.invite_access?.enabled ?? false)) - setInviteManagedByMaster(Boolean(inviteData?.invite_access?.managed_by_master ?? false)) - setMasterInviteTemplate(inviteData?.master_invite ?? null) + const data = (await response.json()) as OwnedInvitesResponse + setInvites(Array.isArray(data.invites) ? data.invites : []) + setInviteAccessEnabled(Boolean(data.invite_access?.enabled)) + setInviteManagedByMaster(Boolean(data.invite_access?.managed_by_master)) + setMasterInvite(data.master_invite ?? null) } useEffect(() => { @@ -141,10 +79,21 @@ export default function ProfileInvitesPage() { } const load = async () => { try { - await loadPage() + const profileResponse = await authFetch(`${getApiBase()}/auth/profile`) + if (!profileResponse.ok) { + if (profileResponse.status === 401) { + clearToken() + router.push('/login') + return + } + throw new Error('Could not load your profile.') + } + const profileData = await profileResponse.json() + setProfile(profileData?.user ?? null) + await loadInvites() } catch (err) { console.error(err) - setInviteError(err instanceof Error ? err.message : 'Could not load invite tools.') + setError(err instanceof Error ? err.message : 'Could not load your invite workspace.') } finally { setLoading(false) } @@ -152,80 +101,65 @@ export default function ProfileInvitesPage() { void load() }, [router]) - const resetInviteEditor = () => { - setInviteEditingId(null) - setInviteForm(defaultOwnedInviteForm()) + const resetFlow = () => { + setEditingId(null) + setFlowStep(1) + setUseCustomCode(false) + setDeliveryMethod('') + setInviteForm(defaultInviteForm()) } const editInvite = (invite: OwnedInvite) => { - setInviteEditingId(invite.id) - setInviteError(null) - setInviteStatus(null) + setEditingId(invite.id) + setCreatedInvite(null) + setFlowStep(4) + setUseCustomCode(true) + setDeliveryMethod(invite.recipient_email ? 'email' : 'manual') setInviteForm({ - code: invite.code ?? '', + 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: '', }) - } - - const reloadInvites = async () => { - const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/profile/invites`) - if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } - throw new Error(`Invite refresh failed: ${response.status}`) - } - const data = (await response.json()) as OwnedInvitesResponse - setInvites(Array.isArray(data?.invites) ? data.invites : []) - setInviteAccessEnabled(Boolean(data?.invite_access?.enabled ?? false)) - setInviteManagedByMaster(Boolean(data?.invite_access?.managed_by_master ?? false)) - setMasterInviteTemplate(data?.master_invite ?? null) + setError(null) + setStatus(null) + window.scrollTo({ top: 0, behavior: 'smooth' }) } const saveInvite = async (event: React.FormEvent) => { event.preventDefault() + const inviteName = inviteForm.label.trim() const recipientEmail = inviteForm.recipient_email.trim() - if (!recipientEmail) { - setInviteError('Recipient email is required.') - setInviteStatus(null) + if (!inviteName) { + setError('Give this invite a name so you can recognise it later.') return } - if (!isValidEmail(recipientEmail)) { - setInviteError('Recipient email must be valid.') - setInviteStatus(null) + if (!deliveryMethod) { + setError('Choose how you want to deliver the invite.') return } - setInviteSaving(true) - setInviteError(null) - setInviteStatus(null) + if (deliveryMethod === 'email' && !isValidEmail(recipientEmail)) { + setError('Enter a valid recipient email address.') + return + } + setSaving(true) + setError(null) + setStatus(null) try { - const baseUrl = getApiBase() const response = await authFetch( - inviteEditingId == null - ? `${baseUrl}/auth/profile/invites` - : `${baseUrl}/auth/profile/invites/${inviteEditingId}`, + editingId == null ? `${getApiBase()}/auth/profile/invites` : `${getApiBase()}/auth/profile/invites/${editingId}`, { - method: inviteEditingId == null ? 'POST' : 'PUT', + method: editingId == null ? 'POST' : 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - code: inviteForm.code || null, - label: inviteForm.label || null, + code: useCustomCode ? inviteForm.code || null : null, + label: inviteName, description: inviteForm.description || null, - recipient_email: recipientEmail, - max_uses: inviteForm.max_uses || null, - expires_at: inviteForm.expires_at || null, + recipient_email: deliveryMethod === 'email' ? recipientEmail : null, enabled: inviteForm.enabled, - send_email: inviteForm.send_email, + send_email: editingId == null && deliveryMethod === 'email', message: inviteForm.message || null, }), } @@ -236,362 +170,136 @@ export default function ProfileInvitesPage() { router.push('/login') return } - const text = await response.text() - throw new Error(text || 'Invite save failed') + throw new Error((await response.text()) || 'Could not save the invite.') } - 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() + const data = await response.json() + const savedInvite = data?.invite as OwnedInvite | undefined + setStatus( + data?.email?.status === 'ok' + ? `Invite created and emailed to ${data.email.recipient_email}.` + : data?.email?.status === 'error' + ? `Invite created, but the email could not be sent: ${data.email.detail}` + : editingId == null ? 'Invite link created and ready to share.' : 'Invite updated.' + ) + resetFlow() + if (editingId == null && savedInvite) setCreatedInvite(savedInvite) + await loadInvites() } catch (err) { console.error(err) - setInviteError(err instanceof Error ? err.message : 'Could not save invite.') + setError(err instanceof Error ? err.message : 'Could not save the invite.') } finally { - setInviteSaving(false) + setSaving(false) } } const deleteInvite = async (invite: OwnedInvite) => { - if (!window.confirm(`Delete invite "${invite.code}"?`)) return - setInviteError(null) - setInviteStatus(null) + if (!window.confirm(`Delete invite “${invite.label || invite.code}”?`)) return + setError(null) try { - const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/profile/invites/${invite.id}`, { - method: 'DELETE', - }) - if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } - const text = await response.text() - throw new Error(text || 'Invite delete failed') - } - if (inviteEditingId === invite.id) { - resetInviteEditor() - } - setInviteStatus(`Deleted invite ${invite.code}.`) - await reloadInvites() + const response = await authFetch(`${getApiBase()}/auth/profile/invites/${invite.id}`, { method: 'DELETE' }) + if (!response.ok) throw new Error((await response.text()) || 'Could not delete the invite.') + if (editingId === invite.id) resetFlow() + setStatus(`Deleted ${invite.label || invite.code}.`) + await loadInvites() } catch (err) { console.error(err) - setInviteError(err instanceof Error ? err.message : 'Could not delete invite.') + setError(err instanceof Error ? err.message : 'Could not delete the invite.') } } const copyInviteLink = async (invite: OwnedInvite) => { const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}` try { - if (navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(url) - setInviteStatus(`Copied invite link for ${invite.code}.`) - } else { - window.prompt('Copy invite link', url) - } - } catch (err) { - console.error(err) + await navigator.clipboard.writeText(url) + setStatus(`Copied the link for ${invite.label || invite.code}.`) + } catch { window.prompt('Copy invite link', url) } } + const codeCharacters = inviteForm.code.replace(/[^a-z0-9]/gi, '') + const identityReady = Boolean(inviteForm.label.trim() && (!useCustomCode || codeCharacters.length >= 6)) const canManageInvites = profile?.role === 'admin' || inviteAccessEnabled + const createdInviteUrl = createdInvite ? `${signupBaseUrl}?code=${encodeURIComponent(createdInvite.code)}` : '' - if (loading) { - return
Loading invite tools...
- } + if (loading) return
Loading invite workspace…
return (
-

Invites

-

Create invite links, email them directly, and track who you have invited.

+ 04 · Invites +

Invite someone to Grizzlyflix

+

Create a secure invitation one simple decision at a time.

- - {inviteError &&
{inviteError}
} - {inviteStatus &&
{inviteStatus}
} + {error &&
{error}
} + {status &&
{status}
} {!canManageInvites ? (
-

Invite access is disabled

-

- Your account is not currently allowed to create self-service invites. Ask an administrator to enable invite access for your profile. -

-
- -
+

Invites are not enabled for your account

+

Ask an administrator if you need permission to invite someone.

) : (
-
-
-

Invite workspace

-

- {inviteManagedByMaster - ? 'Create and manage invite links you have issued. New invites use the admin master invite rule.' - : 'Create and manage invite links you have issued. New invites use your account defaults.'} -

-
+
+
Invite flow

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

Set up the invite one decision at a time.

+ {editingId != null && }
-
-
-

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

-

- Save a recipient email, send the invite immediately, and keep the generated link ready to copy. -

- {inviteManagedByMaster && masterInviteTemplate ? ( -
- Using master invite rule {masterInviteTemplate.code} - {masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits and status are managed by admin. -
- ) : null} - -
-
- Identity - Optional code and label for easier tracking. -
-
- - -
-
- -
-
- Description - Optional note shown on the signup page. -
-
-