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
+16
View File
@@ -14,6 +14,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
public DbSet<ShoppingListItem> ShoppingListItems => Set<ShoppingListItem>();
public DbSet<Recipe> Recipes => Set<Recipe>();
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
public DbSet<Invite> Invites => Set<Invite>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -36,6 +37,21 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
{
e.HasIndex(u => u.Name).IsUnique();
e.Property(u => u.Name).HasMaxLength(100);
e.Property(u => u.Email).HasMaxLength(254);
// Partial unique index: only enforced where Email is set so legacy
// rows (and any future user without an email) don't collide on null.
e.HasIndex(u => u.Email).IsUnique().HasFilter("\"Email\" IS NOT NULL");
});
modelBuilder.Entity<Invite>(e =>
{
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(i => i.IssuedByUser).WithMany().HasForeignKey(i => i.IssuedByUserId).OnDelete(DeleteBehavior.Restrict);
e.HasOne(i => i.ConsumedByUser).WithMany().HasForeignKey(i => i.ConsumedByUserId).OnDelete(DeleteBehavior.SetNull);
e.Property(i => i.Email).HasMaxLength(254);
e.Property(i => i.TokenHash).HasMaxLength(64);
e.HasIndex(i => i.TokenHash).IsUnique();
e.HasIndex(i => new { i.FamilyId, i.ConsumedAt });
});
modelBuilder.Entity<Store>(e =>