Add email-based invites and email confirmation in one flow

Family admins can now invite new members by email. The recipient gets a
templated email with a single-use, time-limited join link; clicking it
opens the registration form bound to the invite, and submitting the
form simultaneously consumes the invite and marks the email confirmed.
Self-registration via the shareable family code remains available.

Backend
- New Invite entity (token hash only — raw token never stored), with
  per-family uniqueness on the active hash.
- User gains nullable Email and EmailConfirmedAt; partial unique index
  so legacy rows with no email do not collide.
- /api/family/invites — admin endpoints to list pending, issue, resend
  (rotates the token), and revoke.
- /api/auth/invite/{token} — public lookup returning email + family name
  so the registration form can show "you have been invited to X".
- /api/auth/register accepts InviteToken; the invite vouches for the
  email, so any client-supplied email field is ignored. Falls back to
  FamilyCode when no invite token is present.
- AppUrlOptions / AppBaseUrl plumbed through so emails build absolute
  links to the deployed frontend.

Frontend
- /login reads ?invite=<token>, looks it up, and switches the form into
  invite-registration mode (pre-binding to the invited email + family).
- /family admin section gains an invite-by-email form, a pending list,
  and resend/revoke actions with a confirmation modal.

Tests
- 14 new integration tests covering: admin issue, member-forbidden,
  lookup, valid/expired/consumed/unknown-token registration, resend
  rotation, revocation, pending-only list filter, and conflict for
  inviting an existing member. RecordingEmailSender captures dispatched
  messages so tests can assert on the link without standing up SMTP.
This commit is contained in:
Josh Rogers
2026-05-08 22:42:55 -05:00
parent a1635218a8
commit d9ffe18b21
17 changed files with 1658 additions and 30 deletions
+152
View File
@@ -28,11 +28,22 @@
role: Role;
}
interface Invite {
id: number;
email: string;
issuedAt: string;
expiresAt: string;
}
let family = $state<FamilyView | null>(null);
let me = $state<Me | null>(null);
let loading = $state(true);
let pendingRemove = $state<Member | null>(null);
let pendingRegenerate = $state(false);
let pendingRevoke = $state<Invite | null>(null);
let invites = $state<Invite[]>([]);
let inviteEmail = $state('');
let invitingEmail = $state(false);
const isAdmin = $derived(family?.myRole === 'Admin');
@@ -46,6 +57,53 @@
api<FamilyView>('/api/family'),
api<Me>('/api/auth/me')
]);
if (family?.myRole === 'Admin') {
invites = await api<Invite[]>('/api/family/invites');
} else {
invites = [];
}
}
async function sendInvite() {
const email = inviteEmail.trim();
if (!email) return;
invitingEmail = true;
try {
await api('/api/family/invites', {
method: 'POST',
body: JSON.stringify({ email })
});
inviteEmail = '';
invites = await api<Invite[]>('/api/family/invites');
toast.success(`Invitation sent to ${email}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to send invitation');
} finally {
invitingEmail = false;
}
}
async function resendInvite(invite: Invite) {
try {
await api(`/api/family/invites/${invite.id}/resend`, { method: 'POST' });
invites = await api<Invite[]>('/api/family/invites');
toast.success(`Invitation resent to ${invite.email}`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to resend invitation');
}
}
async function confirmRevokeInvite() {
if (!pendingRevoke) return;
const target = pendingRevoke;
pendingRevoke = null;
try {
await api(`/api/family/invites/${target.id}`, { method: 'DELETE' });
invites = await api<Invite[]>('/api/family/invites');
toast.success(`Invitation to ${target.email} revoked`);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to revoke invitation');
}
}
async function copyInviteCode() {
@@ -142,6 +200,68 @@
</section>
{/if}
{#if isAdmin}
<section class="mb-6 rounded-lg bg-white p-4 shadow-sm">
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">
Invite by email
</h3>
<form
onsubmit={e => { e.preventDefault(); sendInvite(); }}
class="flex flex-col gap-2 sm:flex-row"
>
<input
type="email"
bind:value={inviteEmail}
placeholder="person@example.com"
required
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
/>
<button
type="submit"
disabled={invitingEmail}
class="rounded-lg bg-primary px-4 py-2 font-semibold text-white disabled:opacity-50"
>
{invitingEmail ? '...' : 'Send invite'}
</button>
</form>
<p class="mt-2 text-xs text-gray-500">
We'll email a join link that expires in 7 days. The recipient's email is automatically confirmed when they sign up.
</p>
{#if invites.length > 0}
<h4 class="mt-4 mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">
Pending invitations
</h4>
<ul class="divide-y divide-gray-100">
{#each invites as invite (invite.id)}
<li class="flex items-center gap-3 py-2">
<div class="flex-1 truncate">
<div class="truncate font-medium">{invite.email}</div>
<div class="text-xs text-gray-500">
expires {new Date(invite.expiresAt).toLocaleDateString()}
</div>
</div>
<button
type="button"
onclick={() => resendInvite(invite)}
class="text-sm font-medium text-primary"
>
Resend
</button>
<button
type="button"
onclick={() => (pendingRevoke = invite)}
class="text-sm font-medium text-danger"
>
Revoke
</button>
</li>
{/each}
</ul>
{/if}
</section>
{/if}
<section>
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-gray-500">Members</h3>
<ul class="space-y-2">
@@ -233,6 +353,38 @@
</div>
{/if}
{#if pendingRevoke}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="revoke-invite-title"
>
<div class="w-full max-w-sm rounded-lg bg-white p-5 shadow-xl">
<h3 id="revoke-invite-title" class="mb-2 text-lg font-semibold">Revoke invitation?</h3>
<p class="mb-5 text-sm text-gray-600">
The link sent to <span class="font-medium">{pendingRevoke.email}</span> will stop working immediately.
</p>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (pendingRevoke = null)}
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700"
>
Cancel
</button>
<button
type="button"
onclick={confirmRevokeInvite}
class="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white"
>
Revoke
</button>
</div>
</div>
</div>
{/if}
{#if pendingRegenerate}
<div
class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4"
+71 -20
View File
@@ -1,7 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { api, setToken } from '$lib/api';
interface InviteLookup {
email: string;
familyName: string;
}
let mode = $state<'login' | 'register'>('login');
let name = $state('');
let password = $state('');
@@ -9,15 +16,43 @@
let error = $state('');
let loading = $state(false);
let inviteToken = $state<string | null>(null);
let invite = $state<InviteLookup | null>(null);
let inviteError = $state('');
let inviteLoading = $state(false);
onMount(async () => {
const token = page.url.searchParams.get('invite');
if (!token) return;
inviteToken = token;
mode = 'register';
inviteLoading = true;
try {
invite = await api<InviteLookup>(`/api/auth/invite/${encodeURIComponent(token)}`);
} catch {
// Surface a clear message rather than the generic API error — the
// recipient probably arrived from a stale or revoked email link.
inviteError = 'This invitation link is invalid or has expired. Ask the person who invited you for a new one.';
inviteToken = null;
invite = null;
} finally {
inviteLoading = false;
}
});
async function handleSubmit() {
error = '';
loading = true;
try {
const endpoint = mode === 'login' ? '/api/auth/login' : '/api/auth/register';
const body =
mode === 'login'
? { name, password }
: { name, password, familyCode };
let body: Record<string, unknown>;
if (mode === 'login') {
body = { name, password };
} else if (inviteToken) {
body = { name, password, inviteToken };
} else {
body = { name, password, familyCode };
}
const res = await api<{ token: string }>(endpoint, {
method: 'POST',
@@ -26,8 +61,8 @@
setToken(res.token);
goto('/lists');
} catch (e: any) {
error = e.message || 'Something went wrong';
} catch (e) {
error = e instanceof Error ? e.message : 'Something went wrong';
} finally {
loading = false;
}
@@ -41,6 +76,20 @@
<p class="mt-2 text-gray-500">Family shopping & recipes</p>
</div>
{#if inviteLoading}
<p class="mb-4 text-center text-sm text-gray-500">Looking up your invitation…</p>
{:else if invite}
<div class="mb-4 rounded-lg bg-primary/5 p-4 text-sm">
<p class="font-medium text-primary">You've been invited to {invite.familyName}</p>
<p class="mt-1 text-gray-600">
Set a name and password below to finish creating your account
(<span class="font-medium">{invite.email}</span>).
</p>
</div>
{:else if inviteError}
<div class="mb-4 rounded-lg bg-danger/10 p-4 text-sm text-danger">{inviteError}</div>
{/if}
<form onsubmit={e => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
<div>
<input
@@ -62,7 +111,7 @@
/>
</div>
{#if mode === 'register'}
{#if mode === 'register' && !inviteToken}
<div>
<input
type="text"
@@ -87,18 +136,20 @@
</button>
</form>
<p class="mt-6 text-center text-sm text-gray-500">
{#if mode === 'login'}
New here?
<button onclick={() => (mode = 'register')} class="font-medium text-primary">
Create account
</button>
{:else}
Already have an account?
<button onclick={() => (mode = 'login')} class="font-medium text-primary">
Sign in
</button>
{/if}
</p>
{#if !inviteToken}
<p class="mt-6 text-center text-sm text-gray-500">
{#if mode === 'login'}
New here?
<button onclick={() => (mode = 'register')} class="font-medium text-primary">
Create account
</button>
{:else}
Already have an account?
<button onclick={() => (mode = 'login')} class="font-medium text-primary">
Sign in
</button>
{/if}
</p>
{/if}
</div>
</div>