SearchCtrlK

  • ↑↓navigate
  • ↵open
  • escclose

Can’t find it? Contact us

Invite a teammate

Let a team's admins invite someone by email with a single-use link whose token only the invitee ever sees.

After reading this guide, you will know

  • What guides build a team invitation, and in what order to follow them
  • How to mint, store and email an invitation link from one guarded action
  • Why the database keeps only the invitation token's digest

By the end of this recipe a team admin types an address, the invitee receives a link, and following it adds them to the team. The link carries a random token, the database keeps only its digest, and the policy decides who may send one.

Terminal
npm add @sdxc/authz @sdxc/http @sdxc/result @sdxc/validate remix

Decide who may invite

The guard comes first, so everything after it runs only for someone allowed to invite. Follow Decide who can do what through Guard a route, adding team: { invite: ability({ description: "Invite people to the team" }) } to the catalog, which the admin role's allow("*") grants and a member never holds.

Mint the invitation

Follow Mint secrets with randomToken for issueInvitation, and add an email column to the invitation's table so you know who it was for.

Follow Send email through Deliver queued email, declaring teams.invite with team and url props. The invite action joins the stages: the guard refuses anyone else, and the token leaves the request only inside the email:

app/http/controllers/teams/invite.ts
import { requireAbility } from "@sdxc/authz/middleware/router";
import { redirect } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import * as s from "remix/data-schema";
import { email } from "remix/data-schema/checks";
import { createAction } from "remix/router";

import emails from "~/app/emails";
import { teamAccess } from "~/app/http/middleware/access";
import { createInvitation } from "~/app/models/invitations";
import abilities from "~/app/policies/abilities";
import { issueInvitation } from "~/app/services/invitations";
import { SITE_URL } from "~/config/constants";
import routes from "~/routes/web";

const INVITE = s.object({ email: s.string().pipe(email()) });

export default createAction(routes.teams.invite, {
	middleware: [teamAccess, requireAbility(abilities.team.invite)],
	handler: async (ctx) => {
		let form = await validate(ctx.formData, INVITE);
		if (isFailure(form))
			return new Response("Enter an email address.", { status: 400 });

		let issued = await issueInvitation(ctx.team.id);
		if (isFailure(issued)) {
			ctx.log.fail(issued.error);
			return new Response("Something went wrong.", { status: 500 });
		}

		let { invitation, token } = issued.data;
		await createInvitation(ctx.db, { ...invitation, email: form.data.email });

		let url = new URL(routes.invitations.accept.href({ token }), SITE_URL);
		let queued = await ctx.email.queue(emails.teams.invite, {
			to: form.data.email,
			props: { team: ctx.team.name, url: url.href },
		});
		if (isFailure(queued))
			ctx.log.warn("invitation.not_queued", { id: invitation.id });

		return redirect(routes.teams.members.href(), {
			status: redirect.Status.SeeOther,
		});
	},
});

createInvitation is your own model function over the invitation's table.

Accept the invitation

The accept route hashes the token from its URL with hashInvitationToken, finds the row by tokenHash, adds the signed-in person to invitation.teamId and deletes the row, so the link works once. A token that finds no row answers the same not-found page as any unknown URL.

Test it

Test each stage as its guide does: Test the policy as a table checks that only an admin holds team.invite, and Test with the memory transport checks the email that carries the link.

Where to go next

Documentation

Can’t find it? Contact us