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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user