Skip to main content

Organization Invitations

Enterprise Ready

Invite team members to your organization via email, magic links, or bulk import. Handle pending invitations, expirations, and resends.

Overview

The invitation system enables secure onboarding of new organization members. Invitations include role assignment, expiration handling, and automatic cleanup.

Email Invitations

Send branded email invites with one-click accept

Magic Links

Generate shareable invite links for quick onboarding

Role Assignment

Assign roles at invitation time

Expiration Handling

Auto-expire invites after configurable period

Bulk Import

Import multiple members via CSV

Domain Allowlist

Auto-approve users from verified domains

Invitation Flow

Understanding the invitation lifecycle helps build better member onboarding experiences.

1
Admin sends invitation

Email sent with unique token and role assignment

2
Recipient clicks link

Token validated, redirect to signup/login

3
Account created/linked

New account created or existing account linked

4
Membership activated

User added to organization with assigned role

Basic Invitation Flow
import { useOrganization } from '@sylphx/sdk/react'

function InviteForm() {
  const { inviteMember } = useOrganization()
  const [email, setEmail] = useState('')
  const [role, setRole] = useState<OrganizationRole>('developer')
  const [isLoading, setIsLoading] = useState(false)

  const handleInvite = async (e: FormEvent) => {
    e.preventDefault()
    setIsLoading(true)

    try {
      await inviteMember(email, role)
      toast.success(`Invitation sent to ${email}`)
      setEmail('')
    } catch (error) {
      if (error.code === 'ALREADY_MEMBER') {
        toast.error('This user is already a member')
      } else if (error.code === 'INVITATION_EXISTS') {
        toast.error('An invitation already exists for this email')
      } else if (error.code === 'INVALID_EMAIL') {
        toast.error('Please enter a valid email address')
      } else {
        toast.error('Failed to send invitation')
      }
    } finally {
      setIsLoading(false)
    }
  }

  return (
    <form onSubmit={handleInvite} className="space-y-4">
      <Input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="colleague@company.com"
        required
      />

      <Select value={role} onValueChange={setRole}>
        <SelectItem value="admin">Admin</SelectItem>
        <SelectItem value="developer">Developer</SelectItem>
        <SelectItem value="viewer">Viewer</SelectItem>
      </Select>

      <Button type="submit" disabled={isLoading}>
        {isLoading ? 'Sending...' : 'Send Invitation'}
      </Button>
    </form>
  )
}

Invitation States

Invitations progress through several states. Handle each appropriately in your UI.

pending

Invitation sent, awaiting acceptance

accepted

User accepted and joined organization

expired

Invitation expired (default: 7 days)

revoked

Admin manually revoked invitation

PropertyTypeDescription
idstringUnique invitation identifier
emailstringInvited email address
roleOrganizationRoleRole assigned upon acceptance
status"pending" | "accepted" | "expired" | "revoked"Current invitation status
invitedBystringUser ID who sent the invitation
invitedAtDateWhen invitation was sent
expiresAtDateWhen invitation expires
acceptedAtDate | nullWhen invitation was accepted

Managing Invitations

Admins can view, resend, and revoke pending invitations.

import { useOrganization } from '@sylphx/sdk/react'

function InvitationList() {
  const { organization } = useOrganization()

  const pendingInvites = organization?.invitations?.filter(
    inv => inv.status === 'pending'
  ) || []

  if (pendingInvites.length === 0) {
    return <p className="text-muted-foreground">No pending invitations</p>
  }

  return (
    <div className="space-y-2">
      {pendingInvites.map((invite) => (
        <div key={invite.id} className="flex items-center justify-between p-3 border rounded-lg">
          <div>
            <p className="font-medium">{invite.email}</p>
            <p className="text-sm text-muted-foreground">
              Role: {invite.role}Expires {formatDate(invite.expiresAt)}
            </p>
          </div>
          <div className="flex gap-2">
            <Button variant="outline" size="sm" onClick={() => resend(invite.id)}>
              Resend
            </Button>
            <Button variant="ghost" size="sm" onClick={() => revoke(invite.id)}>
              Revoke
            </Button>
          </div>
        </div>
      ))}
    </div>
  )
}

Bulk Import

Import multiple members at once via CSV upload or programmatic API.

import { useOrganization } from '@sylphx/sdk/react'

function BulkImport() {
  const { bulkInvite } = useOrganization()
  const [file, setFile] = useState<File | null>(null)
  const [results, setResults] = useState<BulkInviteResult | null>(null)

  const handleImport = async () => {
    if (!file) return

    const text = await file.text()
    const rows = text.split('\n').slice(1) // Skip header

    const invites = rows
      .filter(row => row.trim())
      .map(row => {
        const [email, role] = row.split(',')
        return {
          email: email.trim(),
          role: (role?.trim() || 'developer') as OrganizationRole
        }
      })

    try {
      const result = await bulkInvite(invites)
      setResults(result)

      toast.success(`Sent ${result.sent} invitations`)
      if (result.failed.length > 0) {
        toast.warning(`${result.failed.length} failed`)
      }
    } catch (error) {
      toast.error('Bulk import failed')
    }
  }

  return (
    <div className="space-y-4">
      <div className="border-2 border-dashed rounded-lg p-8 text-center">
        <input
          type="file"
          accept=".csv"
          onChange={(e) => setFile(e.target.files?.[0] || null)}
          className="hidden"
          id="csv-upload"
        />
        <label htmlFor="csv-upload" className="cursor-pointer">
          <Upload className="w-8 h-8 mx-auto text-muted-foreground mb-2" />
          <p className="font-medium">Upload CSV file</p>
          <p className="text-sm text-muted-foreground">
            Format: email,role (one per line)
          </p>
        </label>
      </div>

      {file && (
        <div className="flex items-center justify-between p-3 border rounded-lg">
          <span>{file.name}</span>
          <Button onClick={handleImport}>Import</Button>
        </div>
      )}

      {results && (
        <div className="p-4 border rounded-lg space-y-2">
          <p className="text-success">{results.sent} invitations sent</p>
          {results.skipped > 0 && (
            <p className="text-warning">{results.skipped} skipped (already members)</p>
          )}
          {results.failed.length > 0 && (
            <div className="text-destructive">
              <p>{results.failed.length} failed:</p>
              <ul className="text-sm ml-4">
                {results.failed.map((f, i) => (
                  <li key={i}>{f.email}: {f.reason}</li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  )
}

Domain Allowlist

Allow users with verified email domains to join automatically without explicit invitation.

Configure Domain Allowlist
import { useOrganization } from '@sylphx/sdk/react'

function DomainSettings() {
  const { organization, updateSettings } = useOrganization()
  const [domains, setDomains] = useState<string[]>(
    organization?.settings.allowedDomains || []
  )
  const [newDomain, setNewDomain] = useState('')

  const addDomain = async () => {
    if (!newDomain || domains.includes(newDomain)) return

    const updated = [...domains, newDomain.toLowerCase()]

    try {
      await updateSettings({
        allowedDomains: updated,
        autoJoinRole: 'developer', // Default role for auto-joined users
      })
      setDomains(updated)
      setNewDomain('')
      toast.success(`${newDomain} added to allowlist`)
    } catch (error) {
      toast.error('Failed to update domain settings')
    }
  }

  const removeDomain = async (domain: string) => {
    const updated = domains.filter(d => d !== domain)

    try {
      await updateSettings({ allowedDomains: updated })
      setDomains(updated)
      toast.success(`${domain} removed from allowlist`)
    } catch (error) {
      toast.error('Failed to update domain settings')
    }
  }

  return (
    <div className="space-y-4">
      <div>
        <h3 className="font-medium mb-2">Allowed Domains</h3>
        <p className="text-sm text-muted-foreground mb-4">
          Users with these email domains can join automatically
        </p>
      </div>

      <div className="flex gap-2">
        <Input
          placeholder="example.com"
          value={newDomain}
          onChange={(e) => setNewDomain(e.target.value)}
        />
        <Button onClick={addDomain}>Add Domain</Button>
      </div>

      <div className="space-y-2">
        {domains.map((domain) => (
          <div key={domain} className="flex items-center justify-between p-3 border rounded-lg">
            <div className="flex items-center gap-2">
              <CheckCircle2 className="w-4 h-4 text-success" />
              <span className="font-mono">@{domain}</span>
            </div>
            <Button
              variant="ghost"
              size="sm"
              onClick={() => removeDomain(domain)}
            >
              Remove
            </Button>
          </div>
        ))}
      </div>
    </div>
  )
}

Domain Verification

For security, consider requiring domain verification before enabling auto-join. This confirms ownership of the email domain.

Server-Side Invitations

Handle invitation acceptance in API routes and server components.

// app/invite/[token]/page.tsx
import { acceptInvitation, auth } from '@sylphx/sdk/nextjs'
import { redirect } from 'next/navigation'

export default async function AcceptInvitePage({
  params,
}: {
  params: { token: string }
}) {
  const session = await auth()

  // Must be logged in to accept
  if (!session) {
    // Redirect to login, then back here
    redirect(`/login?redirect=/invite/${params.token}`)
  }

  try {
    // Accept the invitation
    const result = await acceptInvitation(params.token)

    // Redirect to organization dashboard
    redirect(`/org/${result.organization.slug}/dashboard`)
  } catch (error) {
    if (error.code === 'INVITATION_EXPIRED') {
      redirect('/invite/expired')
    } else if (error.code === 'INVITATION_REVOKED') {
      redirect('/invite/revoked')
    } else if (error.code === 'EMAIL_MISMATCH') {
      // Invitation was for different email
      redirect('/invite/wrong-email')
    }
    throw error
  }
}

Invitation Emails

Customize the invitation email template and branding.

Custom Email Template
// Configure in SylphxProvider
<SylphxProvider
  config={{
    organization: {
      invitation: {
        // Email customization
        emailFrom: 'invites@yourapp.com',
        emailSubject: 'You've been invited to join {organization_name}',

        // Email template variables
        // Available: {organization_name}, {inviter_name}, {role}, {accept_url}
        emailBody: `
          Hi there!

          {inviter_name} has invited you to join {organization_name} as a {role}.

          Click the link below to accept:
          {accept_url}

          This invitation expires in 7 days.
        `,

        // Expiration
        expiresInDays: 7,

        // URLs
        acceptUrl: '/invite/{token}',

        // Callbacks
        onInviteSent: (invitation) => {
          analytics.track('invitation_sent', {
            organizationId: invitation.organizationId,
            role: invitation.role,
          })
        },
        onInviteAccepted: (invitation, user) => {
          analytics.track('invitation_accepted', {
            organizationId: invitation.organizationId,
            userId: user.id,
          })
        },
      },
    },
  }}
>
  {children}
</SylphxProvider>