Invitation Flow
UNuxt provides a complete invitation system for adding members to organizations with beautiful email notifications and seamless acceptance flow.
How invitations work
The invitation flow consists of three main steps:
- Admin invites a user by email and assigns a role
- Email is sent with an invitation link
- User accepts the invitation and joins the organization
Send an invitation
Organization owners and admins can invite new members:
const { inviteMember } = useOrganization()
async function sendInvite(email: string, role: 'member' | 'admin') {
try {
await inviteMember(email, role)
toast.add({
title: 'Success',
description: 'Invitation sent successfully',
color: 'success',
})
} catch (error) {
toast.add({
title: 'Error',
description: error.message,
color: 'error',
})
}
}
From the UI
The organization page includes a built-in invitation modal:
<UButton @click="showInviteModal = true">
<UIcon name="i-lucide-user-plus" class="mr-2" />
Invite Member
</UButton>
Accept an invitation
Users receive an email with an invitation link pointing to /auth/accept-invite/[token].
User experience
When users click the invitation link:
If not logged in:
- Prompted to sign in or create an account
- Redirected back to accept invitation after authentication
- Seamlessly joins the organization
If logged in:
- Automatically accepts the invitation
- Redirected to the organization page
- See success message
Acceptance page
The acceptance page handles all edge cases:
<script setup lang="ts">
const { $authClient } = useNuxtApp()
const token = computed(() => route.params.token as string)
async function acceptInvitation() {
const result = await $authClient.organization.acceptInvitation({
invitationId: token.value,
})
if (result.error) {
// Handle errors: expired, invalid, already accepted
error.value = result.error.message
} else {
// Success! Redirect to organization
navigateTo(`/org/${result.data.organization.id}`)
}
}
</script>
Handle edge cases
The invitation system handles common scenarios gracefully:
Expired invitations
Invitations expire based on the expiresAt field in the database:
if (errorMessage.includes('expired')) {
error.value = 'This invitation has expired. Please request a new invitation.'
}
Already accepted
Users can't accept the same invitation twice:
if (errorMessage.includes('already accepted') || errorMessage.includes('already a member')) {
error.value = 'You are already a member of this organization.'
}
Invalid invitations
Cancelled or non-existent invitations are rejected:
if (errorMessage.includes('not found')) {
error.value = 'This invitation is invalid or has been cancelled.'
}
Manage invitations
List pending invitations
Admins can view all pending invitations for an organization:
const invitations = await db
.select()
.from(invitations)
.where(
and(
eq(invitations.organizationId, orgId),
eq(invitations.status, 'pending')
)
)
Cancel an invitation
Admins can cancel pending invitations before they're accepted:
const { cancelInvitation } = useOrganization()
async function cancel(invitationId: string) {
await cancelInvitation(invitationId)
// Invitation is deleted from database
}
Customize invitation emails
Invitation emails are sent automatically using the email system. Customize the template in packages/email/src/templates/organization-invitation.ts:
const content = `
<p>Hi there,</p>
<p><strong>${invitedBy}</strong> has invited you to join <strong>${organizationName}</strong>.</p>
<p>You've been invited as a <strong>${role}</strong>.</p>
<p>Click the button below to accept the invitation:</p>
`
Configure invitation URLs
Invitation URLs are generated automatically and point to your frontend:
sendInvitationEmail: async ({ email, organization, invitedBy, invitation }) => {
const baseURL = process.env.BETTER_AUTH_URL || "http://localhost:3000"
const inviteUrl = `${baseURL}/auth/accept-invite/${invitation.id}`
await sendEmail(
organizationInvitationEmail({
email,
organizationName: organization.name,
invitedBy: invitedBy.name || invitedBy.email,
inviteUrl,
role: invitation.role,
})
)
}
Set invitation expiration
Control how long invitations remain valid by setting the expiration when creating invitations:
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + 7) // 7 days from now
await db.insert(invitations).values({
id: generateId(),
organizationId: org.id,
email: 'user@example.com',
role: 'member',
status: 'pending',
expiresAt,
inviterId: currentUser.id,
})
Check invitation status
Query the invitation status programmatically:
export default defineEventHandler(async (event) => {
const invitationId = getRouterParam(event, 'id')
const invitation = await db.query.invitations.findFirst({
where: eq(invitations.id, invitationId),
})
if (!invitation) {
return { status: 'not_found' }
}
if (invitation.status === 'accepted') {
return { status: 'accepted' }
}
if (new Date() > invitation.expiresAt) {
return { status: 'expired' }
}
return { status: 'pending' }
})
Invitation database schema
Invitations are stored in the invitations table:
export const invitations = pgTable("invitations", {
id: text("id").primaryKey(),
organizationId: text("organization_id")
.references(() => organizations.id, { onDelete: "cascade" })
.notNull(),
email: text("email").notNull(),
role: text("role").notNull().default("member"),
status: text("status").notNull().default("pending"),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
inviterId: text("inviter_id")
.references(() => users.id)
.notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
})
Testing invitations
Manual testing
- Create an organization as User A
- Invite a new email address
- Check console for invitation URL (if SMTP not configured)
- Open URL in incognito window
- Create account or sign in
- Verify user joins organization
E2E tests
UNuxt includes E2E tests for the invitation flow:
test('should show not logged in state', async ({ page }) => {
await page.goto('/auth/accept-invite/mock-token-123')
await expect(page.getByText("You've Been Invited!")).toBeVisible()
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible()
})
Resend invitations
To resend an invitation, delete the old one and create a new one:
async function resendInvitation(invitationId: string) {
// Get the old invitation details
const oldInvitation = await db.query.invitations.findFirst({
where: eq(invitations.id, invitationId),
})
// Delete the old invitation
await cancelInvitation(invitationId)
// Send a new invitation
await inviteMember(oldInvitation.email, oldInvitation.role)
}