Skip to main content

Invitations

Manage user invitations for multi-tenant organizations. Invite users by email, assign roles, resend or cancel pending invitations, and accept invitations via token.

Invitation Object​

src/types.ts
interface Invitation {
id: number;
email: string;
role_id: number;
role?: { id: number; name: string };
status: 'pending' | 'accepted' | 'expired' | 'cancelled';
invited_by?: { id: number; name: string };
expires_at: string;
created_at: string;
updated_at: string;
}

useInvitations(status?)​

Fetch invitations for the current organization. Optionally filter by status.

src/hooks/useInvitations.ts
import { useInvitations } from '@rhino-dev/rhino-react';

// All invitations
const { data: invitations } = useInvitations();

// Only pending invitations
const { data: pending } = useInvitations('pending');

// Only expired invitations
const { data: expired } = useInvitations('expired');

Status filter values: 'all', 'pending', 'accepted', 'expired', 'cancelled'

API Request: GET /api/{organization}/invitations?status=pending

useInviteUser()​

Send a new invitation to an email address with a specific role.

src/hooks/useInviteUser.ts
import { useInviteUser } from '@rhino-dev/rhino-react';

const invite = useInviteUser();

invite.mutate(
{ email: 'newuser@example.com', role_id: 2 },
{
onSuccess: (invitation) => {
console.log('Invitation sent:', invitation);
},
onError: (error) => {
if (error.response?.status === 422) {
// User already invited or already a member
console.log(error.response.data.errors);
}
},
}
);

API Request: POST /api/{organization}/invitations

Inviting into a route group​

When the backend treats route groups as access boundaries (group-aware auth), an invitation can record which group the invitee joins. Pass an optional route_group in the payload — it is only included in the request when provided, so existing calls are unaffected.

const invite = useInviteUser();

// Invite a driver — the accepted membership is scoped to the 'driver' group
invite.mutate({ email: 'driver@example.com', role_id: 2, route_group: 'driver' });

See the server's Route Groups → Invitations carry the group for the backend behavior (membership population, afterRegister hook, and the inviter-must-be-a-member rule when enforcement is on).

Duplicate Prevention

The server prevents sending duplicate invitations. If a pending invitation already exists for the email, or if the user is already a member of the organization, the request will return a validation error.

useResendInvitation()​

Resend an invitation email. This also refreshes the expiration date.

src/hooks/useResendInvitation.ts
import { useResendInvitation } from '@rhino-dev/rhino-react';

const resend = useResendInvitation();

resend.mutate(invitationId, {
onSuccess: () => {
alert('Invitation resent!');
},
});

API Request: POST /api/{organization}/invitations/{id}/resend

useCancelInvitation()​

Cancel a pending invitation. Only pending invitations can be cancelled.

src/hooks/useCancelInvitation.ts
import { useCancelInvitation } from '@rhino-dev/rhino-react';

const cancel = useCancelInvitation();

cancel.mutate(invitationId, {
onSuccess: () => {
console.log('Invitation cancelled');
},
});

API Request: DELETE /api/{organization}/invitations/{id}

useAcceptInvitation()​

Accept an invitation using the token from the invitation link. This is a public route — no authentication or organization context is required.

src/hooks/useAcceptInvitation.ts
import { useAcceptInvitation } from '@rhino-dev/rhino-react';

const accept = useAcceptInvitation();

accept.mutate(token, {
onSuccess: (result) => {
// User is now a member of the organization
console.log('Welcome to the organization!');
},
onError: (error) => {
// Token invalid, expired, or already accepted
console.error('Failed to accept:', error);
},
});

API Request: POST /api/invitations/accept with { token: '...' }

mutate accepts either a bare token string (back-compat) or an object that also carries the group: { token, route_group }. When the backend reports the joined group in its response, the client persists it to the route_group storage key so useRouteGroup() reflects it.

const accept = useAcceptInvitation();

accept.mutate(token); // bare token (default group)
accept.mutate({ token, route_group: 'driver' }); // join a specific group

Complete Invitation Manager​

A full invitation management component with all hooks:

src/components/InvitationManager.tsx
import { useState } from 'react';
import {
useInvitations,
useInviteUser,
useResendInvitation,
useCancelInvitation,
} from '@rhino-dev/rhino-react';

function InvitationManager({ roles }) {
const [email, setEmail] = useState('');
const [roleId, setRoleId] = useState(roles[0]?.id || 1);
const [statusFilter, setStatusFilter] = useState('all');

// Fetch invitations
const { data: invitations, isLoading, refetch } = useInvitations(statusFilter);

// Mutations
const invite = useInviteUser();
const resend = useResendInvitation();
const cancel = useCancelInvitation();

const handleInvite = (e) => {
e.preventDefault();
invite.mutate(
{ email, role_id: roleId },
{
onSuccess: () => {
setEmail('');
refetch();
},
}
);
};

const handleResend = (id) => {
resend.mutate(id, { onSuccess: () => refetch() });
};

const handleCancel = (id) => {
if (window.confirm('Cancel this invitation?')) {
cancel.mutate(id, { onSuccess: () => refetch() });
}
};

return (
<div>
<h2>Invite Team Members</h2>

{/* Invite Form */}
<form onSubmit={handleInvite} style={{ display: 'flex', gap: '0.5rem', marginBottom: '2rem' }}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email address"
required
/>
<select value={roleId} onChange={(e) => setRoleId(Number(e.target.value))}>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<button type="submit" disabled={invite.isPending}>
{invite.isPending ? 'Sending...' : 'Send Invite'}
</button>
</form>

{invite.error && (
<p style={{ color: 'red' }}>
{invite.error.response?.data?.message || 'Failed to send invitation'}
</p>
)}

{/* Filter Tabs */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
{['all', 'pending', 'accepted', 'expired', 'cancelled'].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
style={{
fontWeight: statusFilter === status ? 'bold' : 'normal',
textTransform: 'capitalize',
}}
>
{status}
</button>
))}
</div>

{/* Invitations List */}
{isLoading ? (
<p>Loading invitations...</p>
) : invitations?.length === 0 ? (
<p>No invitations found.</p>
) : (
<table>
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Invited By</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{invitations?.map((inv) => (
<tr key={inv.id}>
<td>{inv.email}</td>
<td>{inv.role?.name || `Role #${inv.role_id}`}</td>
<td>
<span style={{
color: inv.status === 'pending' ? 'orange'
: inv.status === 'accepted' ? 'green'
: inv.status === 'expired' ? 'gray'
: 'red'
}}>
{inv.status}
</span>
</td>
<td>{inv.invited_by?.name || '—'}</td>
<td>{new Date(inv.expires_at).toLocaleDateString()}</td>
<td>
{inv.status === 'pending' && (
<>
<button
onClick={() => handleResend(inv.id)}
disabled={resend.isPending}
>
Resend
</button>
<button
onClick={() => handleCancel(inv.id)}
disabled={cancel.isPending}
style={{ marginLeft: '4px', color: 'red' }}
>
Cancel
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}

Accept Invitation Page​

For handling invitation acceptance (linked from email):

src/pages/AcceptInvitationPage.tsx
import { useEffect, useState } from 'react';
import { useAcceptInvitation } from '@rhino-dev/rhino-react';
import { useSearchParams, useNavigate } from 'react-router-dom';

function AcceptInvitationPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get('token');

const accept = useAcceptInvitation();
const [status, setStatus] = useState('loading');

useEffect(() => {
if (!token) {
setStatus('invalid');
return;
}

accept.mutate(token, {
onSuccess: (result) => {
setStatus('success');
// Redirect to the organization after a delay
setTimeout(() => {
navigate(`/orgs/${result.organization_slug}/dashboard`);
}, 2000);
},
onError: (error) => {
setStatus('error');
},
});
}, [token]);

if (status === 'loading') return <div>Accepting invitation...</div>;
if (status === 'invalid') return <div>Invalid invitation link.</div>;
if (status === 'error') return <div>This invitation has expired or was already used.</div>;

return (
<div>
<h1>Welcome!</h1>
<p>You've joined the organization. Redirecting to your dashboard...</p>
</div>
);
}
Invitation Expiration

Invitations expire after the configured number of days (default: 7). Resending an invitation refreshes the expiration date.