Skip to main content
Urbicon UI
Back to Recipes

Invitation-Gated Registration

Admin-minted invitations gate sign-up: the RegisterPage only succeeds for an invited email.

Built with RegisterPage InvitationManager

Live preview

RegisterPage.svelte

The form is live, but the docs site mints no invitations and runs no auth routes, so submitting ends in its error message.

Create account

  • At least 8 characters
Already have an account? Sign in
// 1. src/routes/api/auth/register/+server.ts — the bundled register handler
import { createRegisterHandler } from '@urbicon-ui/auth/server';
import { authDeps } from '$lib/server/auth-setup';
export const { POST } = createRegisterHandler(authDeps);

// 2. src/lib/server/invitations.ts — the bundled invitation handlers.
//    You only supply the authorization gate (who may manage invitations)
//    and the roles an invite may carry; validation, the invitedBy scoping
//    (session user, never the body) and {error,code} responses ship with it.
import { createInvitationHandlers } from '@urbicon-ui/auth/server';
import { authDeps } from './auth-setup';

export const invitations = createInvitationHandlers(authDeps, {
  authorize: (user) => user.role === 'ADMIN',
  roles: ['ADMIN', 'USER']
});

// src/routes/api/invitations/+server.ts — create + list
import { invitations } from '$lib/server/invitations';
export const POST = invitations.POST;
export const GET = invitations.GET;

// src/routes/api/invitations/[id]/+server.ts — revoke (InvitationManager's delete)
import { invitations } from '$lib/server/invitations';
export const DELETE = invitations.DELETE;

// 3. src/routes/auth/register/+page.svelte
<script lang="ts">
  import { RegisterPage } from '@urbicon-ui/auth';
  import { en } from '@urbicon-ui/auth/i18n/en';
  import { goto } from '$app/navigation';
  import { page } from '$app/state';
</script>

<!-- The invite link is /auth/register?token=<secret>&email=<invitee>. The token
     IS the proof of invitation — without it the request is refused. -->
<RegisterPage
  t={en}
  token={page.url.searchParams.get('token') ?? ''}
  defaultEmail={page.url.searchParams.get('email') ?? ''}
  onSuccess={() => goto('/')}
/>

// 4. src/routes/admin/invitations/+page.svelte — admin panel
<script lang="ts">
  import { InvitationManager } from '@urbicon-ui/auth';
  import { en } from '@urbicon-ui/auth/i18n/en';

  const roles = [
    { value: 'ADMIN', label: 'Admin' },
    { value: 'USER', label: 'User' }
  ];
</script>

<InvitationManager t={en} {roles} apiPath="/api/invitations" />

Two decisions

The token is the gate, not the address

Registration is gated on possession of the invitation token and on nothing else. Without a valid one the handler answers the same invitation_required 403 for every address, registered or not, so registration status never leaks. The email in the body is not trusted either: the invitation names the invitee, and a body naming a different address gets that same 403 rather than a way to redirect the invite.

The account exists before the invite burns

The handler creates the user first and claims the invitation second (markUsedIfUnused, an atomic flip). The email unique-constraint on create is the serialization point, so two tabs racing the same invite yield one account; and a create that fails never consumes the invitation, so the invitee retries instead of landing in "invite spent, no account".