LogoBoring Template

Invitations

Learn about the invitation system for managing workspace members and collaboration.

The invitation system provides a secure way to add new members to workspaces with specific roles and permissions.

Invitation Schema

Database Definition

The invitation system uses a PostgreSQL table with the following structure:

server/db/schema/invitations.ts
export const invitations = pgTable("invitation", {
  // Primary identifier
  id: text("id")
    .primaryKey()
    .$defaultFn(() => crypto.randomUUID()),
 
  // Invitation details
  email: text("email").notNull(),
  invitedBy: text("invited_by").notNull(),
  invitedByProfileImage: text("invited_by_profile_image").notNull(),
 
  // Workspace reference
  workspaceId: text("workspace_id")
    .notNull()
    .references(() => workspaces.id, { onDelete: "cascade" }),
 
  // Role assignment
  role: text("role").notNull().$type<RoleTypesType>().default("member"),
 
  // Status flags
  pending: boolean("pending").notNull().default(true),
  expired: boolean("expired").notNull().default(false),
 
  // Security
  token: text("token").notNull(),
 
  // Timestamps
  acceptedAt: timestamp("accepted_at"),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").defaultNow(),
});

Invitation Flow

The invitation process is designed to be secure, trackable, and user-friendly for both administrators and invitees.

1. Send Invitation

Workspace admin sends invitation with specified role

2. Email Delivery

Invitation email sent with secure token

3. Accept Invitation

Recipient accepts via email link before expiration

4. Member Creation

New workspace member created with assigned role

Key Features

Security

🔒 Secure tokens and expiration for invitations

Role Assignment

👑 Specify member roles during invitation

Cascade Deletion

🗑️ Automatic cleanup with workspace deletion

Status Tracking

📊 Monitor pending and expired invitations

Invitation States

Invitations can exist in multiple states throughout their lifecycle. Understanding these states is key to managing the invitation process.

Pending

Invitation sent but not yet accepted:

{
  pending: true,
  expired: false,
  acceptedAt: null
}

Accepted

Successfully accepted invitation:

{
  pending: false,
  expired: false,
  acceptedAt: timestamp
}

Expired

Invitation past expiration date:

{
  pending: true,
  expired: true,
  acceptedAt: null
}

Completed

Member added to workspace:

{
  pending: false,
  expired: false,
  acceptedAt: timestamp
}

Invitations automatically expire after the specified duration. Expired invitations cannot be accepted and require a new invitation to be sent.

Implementation

Creating Invitations

Send invitations to new workspace members:

Example: Sending invitations
import { trpc } from "@/trpc/client";
import { toast } from "sonner";
 
// Inside your component
const utils = trpc.useUtils();
const { mutate, isPending } = trpc.invitations.create.useMutation({
  onSuccess: (data) => {
    toast.success(data.message);
    // Optional: Reset form
  },
  onError: (error) => {
    toast.error(error.message);
  },
  onSettled: () => {
    // Refetch invitations list to update UI
    utils.invitations.getAll.invalidate({ slug: workspace.slug });
  },
});
 
// Send invitations to multiple email addresses
mutate({
  emails: ["user@example.com", "team@example.com"],
  role: "member", // Default role for invited users
  slug: workspace.slug,
});

Listing Invitations

View all pending invitations for a workspace:

Example: Listing invitations
import { trpc } from "@/trpc/client";
 
// Inside your component
const { data: invitations, isLoading } = trpc.invitations.getAll.useQuery({
  slug: workspace.slug,
});
 
// Render invitations list
return (
  <div>
    {invitations?.map((invitation) => (
      <div key={invitation.id} className="flex items-center justify-between">
        <div>
          <p>{invitation.email}</p>
          <p className="text-sm text-gray-500">
            Invited by: {invitation.invitedBy}
          </p>
          <p className="text-sm text-gray-500">Role: {invitation.role}</p>
        </div>
        <div>
          {invitation.pending && !invitation.expired ? (
            <Badge variant="outline">Pending</Badge>
          ) : invitation.expired ? (
            <Badge variant="destructive">Expired</Badge>
          ) : (
            <Badge variant="success">Accepted</Badge>
          )}
        </div>
      </div>
    ))}
  </div>
);

Resending Invitations

Resend an expired or pending invitation:

Example: Resending invitations
import { trpc } from "@/trpc/client";
import { toast } from "sonner";
 
// Inside your component
const utils = trpc.useUtils();
const { mutate, isPending } = trpc.invitations.resend.useMutation({
  onSuccess: (data) => {
    toast.success(data.message);
  },
  onError: (error) => {
    toast.error(error.message);
  },
  onSettled: () => {
    utils.invitations.getAll.invalidate({ slug: workspace.slug });
  },
});
 
// Resend invitation by ID
mutate({
  invitationId: invitation.id,
  slug: workspace.slug,
});

Revoking Invitations

Cancel a pending invitation:

Example: Revoking invitations
import { trpc } from "@/trpc/client";
import { toast } from "sonner";
 
// Inside your component
const utils = trpc.useUtils();
const { mutate, isPending } = trpc.invitations.revoke.useMutation({
  onSuccess: (data) => {
    toast.success(data.message);
  },
  onError: (error) => {
    toast.error(error.message);
  },
  onSettled: () => {
    utils.invitations.getAll.invalidate({ slug: workspace.slug });
  },
});
 
// Revoke invitation by ID
mutate({
  invitationId: invitation.id,
  slug: workspace.slug,
});

Accepting Invitations

The invitation email contains a secure link with the following format:

https://your-app.com/invite?token=abcdef123456&workspaceId=xyz789

Acceptance Page

Create a dedicated page for accepting invitations:

app/invite/page.tsx
import { acceptInvitation } from "@/app/actions/accept-invitation";
 
export default function InvitePage({
  searchParams,
}: {
  searchParams: { token: string; workspaceId: string };
}) {
  const { token, workspaceId } = searchParams;
 
  const handleAccept = async () => {
    try {
      await acceptInvitation({ token, workspaceId });
      // Redirect to workspace
      router.push(`/workspace/${data.workspace.slug}`);
    } catch (error) {
      // Handle error
    }
  };
 
  return (
    <div>
      <h1>You've been invited to join a workspace</h1>
      <Button onClick={handleAccept}>Accept Invitation</Button>
    </div>
  );
}

Server Action for Accepting

Create a server action to process the invitation acceptance:

app/actions/accept-invitation.ts
"use server";
 
import { db } from "@/db";
import { invitations, members } from "@/db/schema";
import { auth } from "@/auth";
import { eq, and } from "drizzle-orm";
 
export async function acceptInvitation({
  token,
  workspaceId,
}: {
  token: string;
  workspaceId: string;
}) {
  const session = await auth();
  if (!session?.user) {
    throw new Error("You must be logged in to accept an invitation");
  }
 
  // Find the invitation
  const [invitation] = await db
    .select()
    .from(invitations)
    .where(
      and(
        eq(invitations.token, token),
        eq(invitations.workspaceId, workspaceId),
        eq(invitations.pending, true),
        eq(invitations.expired, false)
      )
    )
    .limit(1);
 
  if (!invitation) {
    throw new Error("Invalid or expired invitation");
  }
 
  // Verify the email matches the invitation
  if (invitation.email !== session.user.email) {
    throw new Error("This invitation was sent to a different email address");
  }
 
  // Begin transaction
  const result = await db.transaction(async (tx) => {
    // Update invitation status
    await tx
      .update(invitations)
      .set({
        pending: false,
        acceptedAt: new Date(),
      })
      .where(eq(invitations.id, invitation.id));
 
    // Create member record
    await tx.insert(members).values({
      workspaceId: invitation.workspaceId,
      userId: session.user.id,
      role: invitation.role,
    });
 
    // Get workspace details for redirect
    const [workspace] = await tx
      .select()
      .from(workspaces)
      .where(eq(workspaces.id, invitation.workspaceId));
 
    return { workspace };
  });
 
  return result;
}

Best Practices

Security

  • Generate cryptographically secure tokens
  • Set reasonable expiration times (24-72 hours)
  • Validate email ownership before accepting
  • Use HTTPS for all invitation links

User Experience

  • Send clear, branded invitation emails
  • Include information about who sent the invitation
  • Allow resending of expired invitations
  • Provide clear status updates for pending invitations

Implementation

  • Use database transactions for invitation acceptance
  • Implement rate limiting for invitation sending
  • Add domain restrictions for enterprise workspaces
  • Provide bulk invitation options for large teams

Additional Resources

Multi-Workspace Management

See the Multi-Workspace documentation for more information on workspace management.

Email Service

Refer to the Email Service documentation for details on sending invitation emails.