Invitation flow

Email invite + accept + role assign.

9 minmedium

Team invitations: admin types an email + role, system emails a one-time link, recipient clicks, sets a password, lands in your tenant with the right role. Grit ships this end-to-end. This lesson covers what's wired and the extension points.

The flow

Admin clicks "Invite member" → modal with email + role
→ POST /api/invitations { email, role }
→ Grit creates an invitation row (UUID token, 7-day expiry)
→ Job worker sends an email with /accept/<token>
Invitee opens link → /accept/abc123
→ /api/invitations/abc123 returns { email, role, tenant_name }
→ Frontend shows "Join Acme as Staff" + password form
→ POST /api/invitations/abc123/accept { password }
→ Grit creates the user (tenant + role from the invitation)
→ Marks invitation as accepted
→ Returns access + refresh tokens — user is signed in

The model

apps/api/internal/models/invitation.go
type Invitation struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
Token string `gorm:"uniqueIndex;not null" json:"-"` // never serialise
Email string `gorm:"index;not null" json:"email"`
Role string `gorm:"not null" json:"role"`
TenantID uuid.UUID `gorm:"type:uuid;not null" json:"tenant_id"`
InvitedBy uuid.UUID `gorm:"type:uuid;not null" json:"invited_by"`
AcceptedAt *time.Time `json:"accepted_at"`
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}

Single-use (set AcceptedAt on accept; reject if already set), time-bound (default 7 days), bound to email + role + tenant.

The invite UI

apps/web/components/team/invite-modal.tsx
'use client'
import { useState } from 'react'
export function InviteMemberModal({ onClose }) {
const [email, setEmail] = useState('')
const [role, setRole] = useState<'user' | 'staff' | 'admin'>('staff')
async function onSubmit() {
await fetch('/api/invitations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, role }),
})
toast.success('Invitation sent')
onClose()
}
return (
<Dialog open onOpenChange={onClose}>
<DialogContent>
<DialogTitle>Invite a teammate</DialogTitle>
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="alex@example.com" />
<select value={role} onChange={e => setRole(e.target.value as any)}>
<option value="user">User</option>
<option value="staff">Staff</option>
<option value="admin">Admin</option>
</select>
<Button onClick={onSubmit}>Send invitation</Button>
</DialogContent>
</Dialog>
)
}

The accept page

apps/web/app/(auth)/accept/[token]/page.tsx
export default async function AcceptPage({ params }) {
const inv = await fetch(`${API}/api/invitations/${params.token}`).then(r => r.json())
if (!inv?.data) return <p>This invitation is invalid or expired.</p>
return (
<AcceptForm
token={params.token}
email={inv.data.email}
tenantName={inv.data.tenant_name}
role={inv.data.role}
/>
)
}

Show context — "Join Acme as Staff" — so the recipient knows what they're accepting. Then a password form. Submit creates the user atomically with the right tenant + role.

Atomic creation: the API creates the user and marks the invitation accepted in a single transaction. If anything fails, both roll back. No half-created users with no invitation record.

Re-sending + revoking

Sensible affordances:

  • Re-send — generate a new token + email. The old one is invalidated.
  • Revoke — delete the invitation. Even if the recipient still has the email, the link returns 404.
  • Expired view — show pending invitations in the admin so admins can clean up stale ones.

Edge cases worth handling

  • User already exists — if alex@example.com is already in the system (different tenant), how do you handle joining a second tenant? Usually: create a tenant-membership row, let the user switch between tenants.
  • Email mismatch on accept — invitation says alex@example.com; recipient is logged in as a different account. Force them to log out first.
  • Email goes to spam — show admins a "Copy invite link" button as a fallback.

Quick check

Bob's invitation expired (7-day window passed). He clicks the link anyway. What should happen?

Try it

For chapter 5's final assignment, end-to-end invitation:

  1. As an admin user, POST to /api/invitations with another email + role=staff.
  2. Check Mailhog (localhost:8025) — the invitation email should be there.
  3. Click the accept link. Fill in the password form.
  4. Verify you land signed in as the new user, with the staff role, in the inviting admin's tenant.
  5. As that staff user, visit a page that gates on admin role — should be hidden (from previous lesson).

You finished Building Web with Next.js + Go API 🎉

Five chapters, 13 lessons. You can now scaffold the triple kit, build a marketing landing page with proper SEO, wire signup + dashboard widgets, customise an admin panel via defineResource(), and handle multi-tenancy + roles + invitations cleanly.

From here: try Building Web + Desktop + Mobile to add native surfaces, or go deeper on the API with the Grit plugins (Stripe, WebSockets, OAuth).

Spot a typo? Have an idea?

Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled — suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.

Suggest an improvement on GitHub