Inviting Members
Growing a team
So far, memberships are created via seed data. A real app needs an invite flow: an owner invites a person by email, that person accepts, and a membership is created with the assigned role.
The invites table
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
email TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer',
invited_by TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (org_id) REFERENCES organizations(id),
FOREIGN KEY (invited_by) REFERENCES users(id),
UNIQUE(org_id, email)
); Add this to src/db.ts. The UNIQUE(org_id, email) constraint prevents duplicate invites to the same person in the same org.
Creating an invite
// In src/routes/orgs.ts
route.post("/orgs/:orgId/invites", {
resolve: async (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const perm = requirePermission(user, c.params.orgId, "members:invite");
if (perm instanceof Response) return perm;
const body = await c.request.json();
const { email, role } = body;
if (!email || !role) {
return Response.json({ error: "Email and role required" }, { status: 400 });
}
// Prevent inviting someone who is already a member
const existingUser = db.prepare("SELECT id FROM users WHERE email = ?").get(email) as any;
if (existingUser) {
const existingMembership = getMembership(existingUser.id, c.params.orgId);
if (existingMembership) {
return Response.json({ error: "User is already a member" }, { status: 409 });
}
}
// Prevent an editor from inviting someone as owner
const inviterMembership = getMembership(user.id, c.params.orgId);
if (!inviterMembership) {
return Response.json({ error: "Not found" }, { status: 404 });
}
const ROLE_LEVELS: Record<string, number> = { viewer: 1, editor: 2, owner: 3 };
if ((ROLE_LEVELS[role] ?? 0) > (ROLE_LEVELS[inviterMembership.role] ?? 0)) {
return Response.json(
{ error: "Cannot invite someone with a higher role than your own" },
{ status: 403 },
);
}
const id = crypto.randomUUID();
try {
db.prepare("INSERT INTO invites (id, org_id, email, role, invited_by) VALUES (?, ?, ?, ?, ?)")
.run(id, c.params.orgId, email, role, user.id);
} catch (err: any) {
if (err.message?.includes("UNIQUE")) {
return Response.json({ error: "Invite already sent" }, { status: 409 });
}
throw err;
}
// In production, send an email with an invite link
console.log(`\n📧 Invite for ${email} to org ${c.params.orgId} as ${role}\n`);
return Response.json({ id, email, role }, { status: 201 });
},
}), Key security checks:
Permission check: Only users with members:invite (owners) can create invites.
Role escalation prevention: An editor cannot invite someone as an owner. The invited role must be at or below the inviter’s role. This prevents privilege escalation.
Duplicate prevention: The UNIQUE constraint prevents duplicate invites. We also check for existing memberships.
Accepting an invite
The invited person logs in and accepts:
route.post("/invites/:inviteId/accept", {
resolve: (c) => {
const user = authenticate(c.request);
if (user instanceof Response) return user;
const invite = db.prepare("SELECT * FROM invites WHERE id = ?").get(c.params.inviteId) as any;
if (!invite) return Response.json({ error: "Invite not found" }, { status: 404 });
// Verify the invite is for this user's email
if (invite.email !== user.email) {
return Response.json({ error: "This invite is not for you" }, { status: 403 });
}
// Check if already a member
const existing = getMembership(user.id, invite.org_id);
if (existing) {
// Already a member — delete the invite and return
db.prepare("DELETE FROM invites WHERE id = ?").run(c.params.inviteId);
return Response.json({ error: "Already a member" }, { status: 409 });
}
// Create the membership
const memId = crypto.randomUUID();
db.prepare("INSERT INTO memberships (id, user_id, org_id, role) VALUES (?, ?, ?, ?)")
.run(memId, user.id, invite.org_id, invite.role);
// Delete the invite (single-use)
db.prepare("DELETE FROM invites WHERE id = ?").run(c.params.inviteId);
return Response.json({
message: "Invite accepted",
orgId: invite.org_id,
role: invite.role,
});
},
}), The acceptance flow:
- Look up the invite by ID
- Verify the invite email matches the logged-in user’s email
- Create a membership with the invited role
- Delete the invite (single-use)
The email check is critical: it prevents user A from accepting an invite meant for user B.
Exercises
Exercise 1: As Alice (owner of Acme), invite [email protected] as a viewer. Create a user account for Dave, log in, and accept the invite. Verify Dave now has a membership in Acme.
Exercise 2: As Bob (editor at Acme), try to invite someone as an owner. It should fail with 403 because Bob’s role is lower than owner.
Exercise 3: Try accepting an invite twice. The second attempt should fail because the invite was deleted after the first acceptance.
Exercise 4: Try accepting an invite meant for a different email. It should fail with 403.
Why do we prevent an editor from inviting someone as an owner?