Show current workspace members with role badges, joined date, and admin-only actions like role change and removal.
Project — Workspace Invite System
Build a token-based invite system with Edge Function, accept_workspace_invite RPC, and all error states.
What You Are Building
A workspace invite system. Admins invite people by email, assigning them a role. The invitee receives an email with a unique link. Clicking the link accepts the invite and adds the person as a workspace member. Admins can view pending invites, copy the invite link, resend the email, and revoke pending invites.
- Admin / OwnerSend invites, view pending invites, resend or revoke invites, and manage the workspace member roster.
- MemberCan use the members tab, but cannot access the pending invites tab or invite-management actions.
- InviteeOpens the invite link, passes token validation, and joins the workspace through the accept flow.
How this product should work
If someone reads only this section, they should understand the full invite lifecycle: who manages members, how invites are sent, how they are accepted, and what remains admin-only.
An admin opens the invite modal, enters email and role, and the Edge Function creates the invite record and sends the email.
The invitee opens the link and the RPC validates token, status, and expiry before creating the workspace membership.
Admins review pending invites, copy links, resend emails, and revoke invites using status updates instead of hard deletes.
Features to Build
| # | Feature | Est. Time |
|---|---|---|
| 1 | workspace_invites table + RLS |
1h |
| 2 | Edge Function: send-workspace-invite |
1h 30m |
| 3 | RPC: accept_workspace_invite |
1h |
| 4 | React: Members + Invites page (tabs) | 1h |
| 5 | React: Send invite form | 45m |
| 6 | React: Accept invite page | 45m |
| 7 | Invite management (copy, resend, revoke) | 1h |
1. Database — `workspace_invites` Table
Schema
| Column | Type | Rules |
|---|---|---|
| id | uuid | PK, default gen_random_uuid() |
| workspace_id | uuid | FK → workspaces.id, ON DELETE CASCADE |
| text | NOT NULL | |
| role | text | NOT NULL, one of: 'admin', 'member', default 'member' |
| token | text | NOT NULL, UNIQUE — a secure random token (gen_random_uuid()::text) |
| status | text | NOT NULL, default 'pending', one of: 'pending', 'accepted', 'revoked', 'expired' |
| invited_by | uuid | FK → auth.users.id |
| expires_at | timestamptz | NOT NULL, default: now() + interval '7 days' |
| accepted_at | timestamptz | nullable |
| created_at | timestamptz | default now() |
Constraints
UNIQUE (workspace_id, email, status) -- prevents duplicate pending invites for same email
CHECK (role IN ('admin', 'member'))
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired'))
Indexes
CREATE UNIQUE INDEX idx_invites_token ON workspace_invites (token);
CREATE INDEX idx_invites_workspace_status ON workspace_invites (workspace_id, status);
CREATE INDEX idx_invites_email ON workspace_invites (email);
RLS Policies
SELECT:
— Admins/owners of the workspace can see all invites for their workspace
— A user can see their own pending invite (WHERE email = auth.email())
INSERT:
— Only workspace admins/owners can create invites
UPDATE:
— Only workspace admins/owners can update (for revoke)
— The Edge Function and RPC use SECURITY DEFINER to bypass RLS
DELETE:
— Not allowed — use status = 'revoked' instead (soft delete pattern)
Run checklist: New Table · RLS Policies
2. Edge Function: `send-workspace-invite`
Endpoint: POST /functions/v1/send-workspace-invite
Logic (10 steps)
1. Validate JWT — reject if no valid auth token
2. Parse body: { workspace_id, email, role }
3. Validate: email is valid format
4. Validate: role is 'admin' or 'member'
5. Check caller is admin/owner of workspace (query workspace_members)
6. Check email is not already an active member of the workspace
7. Check: no pending invite for this email in this workspace already exists
(if exists: return 409 with existing invite details so frontend can offer "Resend")
8. Create invite record: insert into workspace_invites with a generated token + 7-day expiry
9. Send invite email via Resend:
Subject: "[Admin Name] invited you to join [Workspace Name]"
Body: includes workspace name, role, and the invite link:
https://yourapp.com/accept-invite?token={token}
10. Return: { "data": { invite_id, email, role, expires_at } }
Environment Variables Required
RESEND_API_KEY
APP_BASE_URL
Error Responses
| Scenario | HTTP | Code |
|---|---|---|
| No auth | 401 | AUTH_REQUIRED |
| Not admin | 403 | FORBIDDEN |
| Already a member | 409 | DUPLICATE |
| Invite already pending | 409 | DUPLICATE |
| Invalid email | 400 | VALIDATION_ERROR |
Run checklist: Edge Function
3. RPC: `accept_workspace_invite`
CREATE OR REPLACE FUNCTION accept_workspace_invite(
p_token text
)
RETURNS json
LANGUAGE plpgsql
SECURITY DEFINER -- needs to write workspace_members even if RLS blocks it
AS $$
DECLARE
v_user_id uuid := auth.uid();
v_invite workspace_invites%ROWTYPE;
BEGIN
-- 1. Auth check
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'AUTH_REQUIRED: authentication required';
END IF;
-- 2. Find invite by token
SELECT * INTO v_invite FROM workspace_invites WHERE token = p_token;
IF NOT FOUND THEN
RAISE EXCEPTION 'NOT_FOUND: invite not found or already used';
END IF;
-- 3. Check status is pending
IF v_invite.status != 'pending' THEN
RAISE EXCEPTION 'BUSINESS_RULE_VIOLATION: this invite has already been % ', v_invite.status;
END IF;
-- 4. Check not expired
IF v_invite.expires_at < now() THEN
UPDATE workspace_invites SET status = 'expired' WHERE id = v_invite.id;
RAISE EXCEPTION 'BUSINESS_RULE_VIOLATION: this invite has expired';
END IF;
-- 5. Check caller email matches invite email
IF (SELECT email FROM auth.users WHERE id = v_user_id) != v_invite.email THEN
RAISE EXCEPTION 'FORBIDDEN: this invite was sent to a different email address';
END IF;
-- 6. Check not already a member
IF EXISTS (SELECT 1 FROM workspace_members WHERE workspace_id = v_invite.workspace_id AND user_id = v_user_id) THEN
RAISE EXCEPTION 'DUPLICATE: you are already a member of this workspace';
END IF;
-- 7. Create workspace_member record
INSERT INTO workspace_members (workspace_id, user_id, role, status)
VALUES (v_invite.workspace_id, v_user_id, v_invite.role, 'active');
-- 8. Mark invite as accepted
UPDATE workspace_invites
SET status = 'accepted', accepted_at = now()
WHERE id = v_invite.id;
RETURN json_build_object(
'data', json_build_object('workspace_id', v_invite.workspace_id, 'role', v_invite.role),
'message', 'Invite accepted. Welcome to the workspace!'
);
END;
$$;
Run checklist: RPC Function
4. React: Members & Invites Page
URL: /workspaces/{id}/members
Two Tabs
Tab 1: Members
Tab 2: Pending Invites (admin/owner only — tab hidden for members)
5. React: Send Invite Modal
Fields:
[ ] Email: email input, required
[ ] Role: dropdown — Member / Admin
Behavior:
[ ] Submit "Send Invite" → calls Edge Function
[ ] Loading state during call
[ ] On success: "Invite sent to [email]." toast, invites list refreshes
[ ] If already pending: "An invite to this email is already pending. Resend it?" with Resend button
[ ] If already a member: "This email is already a member of this workspace."
[ ] API error: banner above submit
6. React: Accept Invite Page
URL: /accept-invite?token={token}
LOADING STATE:
[ ] Page loads, token extracted from URL
[ ] API call to validate the invite (GET /invites?token=xxx or check via RPC)
[ ] While loading: spinner + "Verifying your invite..."
VALID INVITE:
[ ] Show workspace name + invited role
[ ] Show invited email (user must be logged in with this email)
[ ] "Accept Invite" button → calls accept_workspace_invite RPC
[ ] On success: redirect to workspace dashboard with "Welcome to [Workspace]!" toast
[ ] If user not logged in: "Please sign in with [email] to accept this invite." + sign-in link
INVALID INVITE:
[ ] Token not found: "This invite link is invalid or has already been used."
[ ] Expired: "This invite has expired. Ask your admin to send a new one."
[ ] Wrong email: "This invite was sent to a different email address."
[ ] Already a member: "You are already a member of this workspace." + link to workspace
What You Should NOT Do
× Store the invite token as a sequential ID (use UUID for tokens)
× Use SECURITY INVOKER for accept_workspace_invite — it needs to write workspace_members
× Let the Edge Function skip the "already a member" check
× Let accept_workspace_invite succeed for an expired or already-accepted token
× Show the Pending Invites tab to member-role users
× Generate signed URLs for the invite link — it should be a plain token URL
× Skip the email match check (user must accept with the same email the invite was sent to)