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.
Email sent with unique token and role assignment
Token validated, redirect to signup/login
New account created or existing account linked
User added to organization with assigned role
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.
pendingInvitation sent, awaiting acceptance
acceptedUser accepted and joined organization
expiredInvitation expired (default: 7 days)
revokedAdmin manually revoked invitation
| Property | Type | Description |
|---|---|---|
id | string | Unique invitation identifier |
email | string | Invited email address |
role | OrganizationRole | Role assigned upon acceptance |
status | "pending" | "accepted" | "expired" | "revoked" | Current invitation status |
invitedBy | string | User ID who sent the invitation |
invitedAt | Date | When invitation was sent |
expiresAt | Date | When invitation expires |
acceptedAt | Date | null | When 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>
)
}Magic Links
Generate shareable invitation links for quick team onboarding without requiring individual email invites.
import { useOrganization } from '@sylphx/sdk/react'
function MagicLinkGenerator() {
const { generateInviteLink } = useOrganization()
const [link, setLink] = useState<string | null>(null)
const [role, setRole] = useState<OrganizationRole>('developer')
const [maxUses, setMaxUses] = useState(10)
const [expiresIn, setExpiresIn] = useState('7d')
const handleGenerate = async () => {
try {
const result = await generateInviteLink({
role,
maxUses, // Maximum number of uses (null = unlimited)
expiresIn, // '1d', '7d', '30d', or null for no expiry
})
setLink(result.url)
toast.success('Invite link generated!')
} catch (error) {
toast.error('Failed to generate link')
}
}
const copyLink = () => {
navigator.clipboard.writeText(link!)
toast.success('Link copied to clipboard!')
}
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<Select value={role} onValueChange={setRole}>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="developer">Developer</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</Select>
<Input
type="number"
value={maxUses}
onChange={(e) => setMaxUses(parseInt(e.target.value))}
placeholder="Max uses"
min={1}
/>
<Select value={expiresIn} onValueChange={setExpiresIn}>
<SelectItem value="1d">1 day</SelectItem>
<SelectItem value="7d">7 days</SelectItem>
<SelectItem value="30d">30 days</SelectItem>
</Select>
</div>
<Button onClick={handleGenerate}>Generate Link</Button>
{link && (
<div className="flex items-center gap-2 p-3 bg-muted rounded-lg">
<Input value={link} readOnly className="flex-1" />
<Button variant="outline" onClick={copyLink}>
Copy
</Button>
</div>
)}
</div>
)
}Security Consideration
maxUses and expiresIn limits.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.
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
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.
// 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>