Invitation flow
Email invite + accept + role assign.
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
type Invitation struct {ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`Token string `gorm:"uniqueIndex;not null" json:"-"` // never serialiseEmail 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
'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
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 (<AcceptFormtoken={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.
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
Try it
For chapter 5's final assignment, end-to-end invitation:
- As an admin user, POST to
/api/invitationswith another email +role=staff. - Check Mailhog (
localhost:8025) — the invitation email should be there. - Click the accept link. Fill in the password form.
- Verify you land signed in as the new user, with the staff role, in the inviting admin's tenant.
- As that staff user, visit a page that gates on
adminrole — 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