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
@@ -0,0 +1,244 @@
using System.Net;
using System.Net.Http.Json;
using YesChef.Api.Auth;
using YesChef.Api.Features.Families;
using YesChef.Api.IntegrationTests.Infrastructure;
namespace YesChef.Api.IntegrationTests.Features;
/// <summary>
/// Covers the full email-invite lifecycle: admin issues invite, recipient
/// looks it up, registers with it (consuming + confirming email in one step),
/// plus the unhappy paths for non-admin callers, expired/consumed/wrong tokens,
/// and tampered registration payloads.
/// </summary>
public class InviteEndpointsTests : AuthenticatedIntegrationTest
{
[Test]
public async Task Admin_creates_invite_and_email_is_sent()
{
var response = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("alice@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var invite = await response.Content.ReadFromJsonAsync<InviteEndpoints.InviteDto>();
await Assert.That(invite!.Email).IsEqualTo("alice@example.com");
await Assert.That(App.Emails.Messages.Count).IsEqualTo(1);
var message = App.Emails.Messages.Single();
await Assert.That(message.ToAddress).IsEqualTo("alice@example.com");
await Assert.That(message.TextBody).Contains("/register?invite=");
}
[Test]
public async Task Member_cannot_create_invite()
{
using var member = await Data.RegisterAsync("rank-and-file");
var response = await member.Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("alice@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
}
[Test]
public async Task Lookup_returns_family_name_and_email()
{
var token = await IssueInviteAndExtractTokenAsync("bob@example.com");
var lookup = await AnonymousClient.GetFromJsonAsync<AuthEndpoints.InviteLookupResponse>($"/api/auth/invite/{token}");
await Assert.That(lookup!.Email).IsEqualTo("bob@example.com");
await Assert.That(lookup.FamilyName).IsEqualTo("Family");
}
[Test]
public async Task Lookup_404s_for_unknown_token()
{
var response = await AnonymousClient.GetAsync("/api/auth/invite/not-a-real-token");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Register_with_invite_consumes_it_and_confirms_email()
{
var token = await IssueInviteAndExtractTokenAsync("carol@example.com");
var register = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("carol", "pw-1234", null, token));
await Assert.That(register.StatusCode).IsEqualTo(HttpStatusCode.OK);
var user = await UseDbAsync(db => db.Users.SingleAsync(u => u.Name == "carol"));
await Assert.That(user.Email).IsEqualTo("carol@example.com");
await Assert.That(user.EmailConfirmedAt).IsNotNull();
var invite = await UseDbAsync(db => db.Invites.SingleAsync(i => i.Email == "carol@example.com"));
await Assert.That(invite.ConsumedAt).IsNotNull();
await Assert.That(invite.ConsumedByUserId).IsEqualTo(user.Id);
}
[Test]
public async Task Register_with_invite_joins_correct_family_as_member()
{
var token = await IssueInviteAndExtractTokenAsync("dave@example.com");
await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("dave", "pw-1234", null, token));
var (familyId, role) = await UseDbAsync(db =>
(from u in db.Users
join m in db.FamilyMemberships on u.Id equals m.UserId
where u.Name == "dave"
select new ValueTuple<int, Entities.FamilyRole>(m.FamilyId, m.Role)).SingleAsync());
// The default family from the bootstrap is the only one — the new
// user must land in it as a Member (admin already exists: that's User).
var bootstrapFamilyId = await UseDbAsync(db => db.Families.Select(f => f.Id).SingleAsync());
await Assert.That(familyId).IsEqualTo(bootstrapFamilyId);
await Assert.That(role).IsEqualTo(Entities.FamilyRole.Member);
}
[Test]
public async Task Register_with_consumed_invite_is_rejected()
{
var token = await IssueInviteAndExtractTokenAsync("eve@example.com");
var first = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("eve", "pw-1234", null, token));
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.OK);
var second = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("eve-2", "pw-1234", null, token));
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Register_with_unknown_token_is_rejected()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("fran", "pw-1234", null, "this-token-does-not-exist"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Register_with_expired_invite_is_rejected()
{
var token = await IssueInviteAndExtractTokenAsync("greg@example.com");
// Force the invite into the past directly via the DB.
await UseDbAsync(async db =>
{
var invite = await db.Invites.SingleAsync(i => i.Email == "greg@example.com");
invite.ExpiresAt = DateTime.UtcNow.AddMinutes(-1);
await db.SaveChangesAsync();
});
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("greg", "pw-1234", null, token));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Register_without_invite_or_family_code_is_rejected()
{
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
new AuthEndpoints.RegisterRequest("hank", "pw-1234"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
}
[Test]
public async Task Resend_rotates_token_and_invalidates_previous()
{
var inviteResponse = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("ivy@example.com"));
var invite = await inviteResponse.Content.ReadFromJsonAsync<InviteEndpoints.InviteDto>();
var firstToken = ExtractTokenFromMostRecentEmail();
var resend = await Client.PostAsync($"/api/family/invites/{invite!.Id}/resend", content: null);
await Assert.That(resend.StatusCode).IsEqualTo(HttpStatusCode.OK);
var secondToken = ExtractTokenFromMostRecentEmail();
await Assert.That(secondToken).IsNotEqualTo(firstToken);
// Old token must no longer resolve.
var oldLookup = await AnonymousClient.GetAsync($"/api/auth/invite/{firstToken}");
await Assert.That(oldLookup.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
// New token still works.
var newLookup = await AnonymousClient.GetAsync($"/api/auth/invite/{secondToken}");
await Assert.That(newLookup.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
[Test]
public async Task Revoke_makes_invite_unusable()
{
var inviteResponse = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("jack@example.com"));
var invite = await inviteResponse.Content.ReadFromJsonAsync<InviteEndpoints.InviteDto>();
var token = ExtractTokenFromMostRecentEmail();
var revoke = await Client.DeleteAsync($"/api/family/invites/{invite!.Id}");
await Assert.That(revoke.StatusCode).IsEqualTo(HttpStatusCode.NoContent);
var lookup = await AnonymousClient.GetAsync($"/api/auth/invite/{token}");
await Assert.That(lookup.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task List_invites_returns_pending_only()
{
await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("kate@example.com"));
var lenInvite = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("len@example.com"));
var consumed = await lenInvite.Content.ReadFromJsonAsync<InviteEndpoints.InviteDto>();
await Client.DeleteAsync($"/api/family/invites/{consumed!.Id}");
var pending = await Client.GetFromJsonAsync<List<InviteEndpoints.InviteDto>>("/api/family/invites");
await Assert.That(pending!.Count).IsEqualTo(1);
await Assert.That(pending[0].Email).IsEqualTo("kate@example.com");
}
[Test]
public async Task Invite_for_existing_member_returns_conflict()
{
// Make the admin's email match the invite target so the same-email
// member check trips. (Direct DB set — there's no API to attach an
// email to an existing user yet.)
await UseDbAsync(async db =>
{
var me = await db.Users.SingleAsync(u => u.Id == User.Id);
me.Email = "already@example.com";
await db.SaveChangesAsync();
});
var response = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest("already@example.com"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
private async Task<string> IssueInviteAndExtractTokenAsync(string email)
{
var response = await Client.PostAsJsonAsync("/api/family/invites",
new InviteEndpoints.CreateInviteRequest(email));
response.EnsureSuccessStatusCode();
return ExtractTokenFromMostRecentEmail();
}
private string ExtractTokenFromMostRecentEmail()
{
var message = App.Emails.Last;
const string marker = "/register?invite=";
var start = message.TextBody.IndexOf(marker, StringComparison.Ordinal);
if (start < 0) throw new InvalidOperationException("No invite link in email body.");
start += marker.Length;
var end = message.TextBody.IndexOfAny(['\r', '\n', ' '], start);
if (end < 0) end = message.TextBody.Length;
return Uri.UnescapeDataString(message.TextBody[start..end]);
}
}
@@ -0,0 +1,36 @@
using YesChef.Api.Email;
namespace YesChef.Api.IntegrationTests.Infrastructure;
/// <summary>
/// Test double for IEmailSender that records every dispatched message in
/// memory (preserving send order). Tests assert on the captured list rather
/// than hitting a real SMTP server.
/// </summary>
public sealed class RecordingEmailSender : IEmailSender
{
private readonly List<EmailMessage> _messages = new();
private readonly Lock _gate = new();
public IReadOnlyList<EmailMessage> Messages
{
get
{
lock (_gate) return _messages.ToArray();
}
}
public EmailMessage Last
{
get
{
lock (_gate) return _messages[^1];
}
}
public Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default)
{
lock (_gate) _messages.Add(message);
return Task.CompletedTask;
}
}
@@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using YesChef.Api.Data;
using YesChef.Api.Email;
namespace YesChef.Api.IntegrationTests.Infrastructure;
@@ -19,6 +20,13 @@ public sealed class YesChefAppFactory : WebApplicationFactory<Program>
public required string ConnectionString { get; init; }
/// <summary>
/// In-memory email sink used for assertions. Replaces the production
/// IEmailSender registration so tests can inspect dispatched messages
/// without standing up an SMTP server.
/// </summary>
public RecordingEmailSender Emails { get; } = new();
/// <summary>
/// Hosting environment override. Defaults to "Testing", which the API
/// uses to disable rate limits. Tests that exercise rate-limit behavior
@@ -49,6 +57,10 @@ public sealed class YesChefAppFactory : WebApplicationFactory<Program>
var descriptor = services.Single(d => d.ServiceType == typeof(DbContextOptions<YesChefDb>));
services.Remove(descriptor);
services.AddDbContext<YesChefDb>(options => options.UseNpgsql(ConnectionString));
var emailDescriptor = services.Single(d => d.ServiceType == typeof(IEmailSender));
services.Remove(emailDescriptor);
services.AddSingleton<IEmailSender>(Emails);
});
}
}
+65 -9
View File
@@ -8,9 +8,15 @@ namespace YesChef.Api.Auth;
public static class AuthEndpoints
{
public record RegisterRequest(string Name, string Password, string FamilyCode);
/// <summary>
/// Registration request. Either <see cref="FamilyCode"/> or
/// <see cref="InviteToken"/> must be supplied — invite tokens take precedence
/// when both are present.
/// </summary>
public record RegisterRequest(string Name, string Password, string? FamilyCode = null, string? InviteToken = null);
public record LoginRequest(string Name, string Password);
public record AuthResponse(string Token, string Name, string Role);
public record InviteLookupResponse(string Email, string FamilyName);
public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
{
@@ -18,16 +24,44 @@ public static class AuthEndpoints
group.MapPost("/register", async (RegisterRequest request, YesChefDb db, JwtTokenService jwt) =>
{
var family = await db.Families.FirstOrDefaultAsync(f => f.InviteCode == request.FamilyCode);
if (family is null)
return Results.BadRequest(new { error = "Invalid family code." });
if (await db.Users.AnyAsync(u => u.Name == request.Name))
return Results.Conflict(new { error = "Name already taken." });
Family family;
Invite? invite = null;
if (!string.IsNullOrWhiteSpace(request.InviteToken))
{
var hash = Auth.InviteToken.Hash(request.InviteToken);
invite = await db.Invites.FirstOrDefaultAsync(i => i.TokenHash == hash);
if (invite is null || invite.ConsumedAt is not null || invite.ExpiresAt < DateTime.UtcNow)
return Results.BadRequest(new { error = "Invitation is invalid or has expired." });
family = await db.Families.FirstAsync(f => f.Id == invite.FamilyId);
}
else if (!string.IsNullOrWhiteSpace(request.FamilyCode))
{
var match = await db.Families.FirstOrDefaultAsync(f => f.InviteCode == request.FamilyCode);
if (match is null)
return Results.BadRequest(new { error = "Invalid family code." });
family = match;
}
else
{
return Results.BadRequest(new { error = "An invite link or family code is required." });
}
var user = new User { Name = request.Name, PasswordHash = "" };
user.PasswordHash = hasher.HashPassword(user, request.Password);
// The invite vouches for the email; trust it over anything the client
// might attach (no client-supplied email field today, but be explicit).
if (invite is not null)
{
user.Email = invite.Email;
user.EmailConfirmedAt = DateTime.UtcNow;
}
db.Users.Add(user);
await db.SaveChangesAsync();
@@ -42,6 +76,13 @@ public static class AuthEndpoints
FamilyId = family.Id,
Role = role,
});
if (invite is not null)
{
invite.ConsumedAt = DateTime.UtcNow;
invite.ConsumedByUserId = user.Id;
}
await db.SaveChangesAsync();
var token = jwt.GenerateToken(user, family.Id, role);
@@ -71,6 +112,18 @@ public static class AuthEndpoints
return Results.Ok(new AuthResponse(token, user.Name, membership.Role.ToString()));
}).RequireRateLimiting(AuthRateLimits.LoginPolicy);
group.MapGet("/invite/{token}", async (string token, YesChefDb db) =>
{
var hash = Auth.InviteToken.Hash(token);
var invite = await db.Invites
.AsNoTracking()
.Where(i => i.TokenHash == hash && i.ConsumedAt == null && i.ExpiresAt > DateTime.UtcNow)
.Select(i => new { i.Email, FamilyName = i.Family.Name })
.FirstOrDefaultAsync();
if (invite is null) return Results.NotFound();
return Results.Ok(new InviteLookupResponse(invite.Email, invite.FamilyName));
});
group.MapGet("/me", async (HttpContext http, YesChefDb db) =>
{
var userId = http.User.GetUserId();
@@ -78,17 +131,20 @@ public static class AuthEndpoints
var familyId = http.User.GetFamilyId();
// Pull role from DB, not the claim, so a user demoted mid-token-lifetime
// sees their actual current role on the next /me poll.
var membership = await db.FamilyMemberships
var row = await db.FamilyMemberships
.Include(m => m.Family)
.Include(m => m.User)
.FirstOrDefaultAsync(m => m.UserId == userId && m.FamilyId == familyId);
if (membership is null) return Results.Unauthorized();
if (row is null) return Results.Unauthorized();
return Results.Ok(new
{
id = userId,
name,
email = row.User.Email,
emailConfirmed = row.User.EmailConfirmedAt is not null,
familyId,
familyName = membership.Family.Name,
role = membership.Role.ToString(),
familyName = row.Family.Name,
role = row.Role.ToString(),
});
}).RequireAuthorization();
@@ -0,0 +1,29 @@
using System.Security.Cryptography;
namespace YesChef.Api.Auth;
/// <summary>
/// Helpers for issuing single-use invite/reset tokens. The raw token is shown
/// once (in the email link); only its SHA-256 hash is persisted.
/// </summary>
public static class InviteToken
{
public const int TokenByteLength = 32;
public static string Generate()
{
var bytes = RandomNumberGenerator.GetBytes(TokenByteLength);
// URL-safe base64 (no '+', '/', '=') — drops cleanly into a query string.
return Convert.ToBase64String(bytes)
.Replace('+', '-')
.Replace('/', '_')
.TrimEnd('=');
}
public static string Hash(string token)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(token);
var digest = SHA256.HashData(bytes);
return Convert.ToHexString(digest);
}
}
+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 =>
@@ -0,0 +1,7 @@
namespace YesChef.Api.Email;
public class AppUrlOptions
{
/// <summary>Public-facing base URL used when building links in emails.</summary>
public string BaseUrl { get; set; } = "http://localhost:5173";
}
@@ -0,0 +1,32 @@
using System.Net;
namespace YesChef.Api.Email;
public static class EmailTemplates
{
public static EmailMessage Invite(string toAddress, string familyName, string inviterName, string joinUrl)
{
var subject = $"You're invited to join {familyName} on YesChef";
var safeFamily = WebUtility.HtmlEncode(familyName);
var safeInviter = WebUtility.HtmlEncode(inviterName);
var safeUrl = WebUtility.HtmlEncode(joinUrl);
var html = $"""
<p>Hi,</p>
<p>{safeInviter} invited you to join <strong>{safeFamily}</strong> on YesChef — a shared shopping-list and recipe app.</p>
<p><a href="{safeUrl}">Accept the invitation</a></p>
<p>If you weren't expecting this, you can ignore this email the link will expire.</p>
""";
var text = $"""
{inviterName} invited you to join {familyName} on YesChef.
Accept the invitation:
{joinUrl}
If you weren't expecting this, you can ignore this email — the link will expire.
""";
return new EmailMessage(toAddress, subject, html, text);
}
}
@@ -0,0 +1,25 @@
namespace YesChef.Api.Entities;
/// <summary>
/// Single-use, time-limited invitation to join a family. The raw token is sent
/// to the recipient by email; only its SHA-256 hash is stored, so a database
/// leak doesn't yield active invites.
/// </summary>
public class Invite
{
public int Id { get; set; }
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public required string Email { get; set; }
public required string TokenHash { get; set; }
public int IssuedByUserId { get; set; }
public User IssuedByUser { get; set; } = null!;
public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
public DateTime ExpiresAt { get; set; }
public DateTime? ConsumedAt { get; set; }
public int? ConsumedByUserId { get; set; }
public User? ConsumedByUser { get; set; }
}
+2
View File
@@ -5,5 +5,7 @@ public class User
public int Id { get; set; }
public required string Name { get; set; }
public required string PasswordHash { get; set; }
public string? Email { get; set; }
public DateTime? EmailConfirmedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,149 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using YesChef.Api.Auth;
using YesChef.Api.Data;
using YesChef.Api.Email;
using YesChef.Api.Entities;
namespace YesChef.Api.Features.Families;
public static class InviteEndpoints
{
public static readonly TimeSpan InviteLifetime = TimeSpan.FromDays(7);
public record CreateInviteRequest(string Email);
public record InviteDto(int Id, string Email, DateTime IssuedAt, DateTime ExpiresAt);
public static RouteGroupBuilder MapInviteEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", async (YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var now = DateTime.UtcNow;
var invites = await db.Invites
.AsNoTracking()
.Where(i => i.FamilyId == familyId && i.ConsumedAt == null && i.ExpiresAt > now)
.OrderByDescending(i => i.IssuedAt)
.Select(i => new InviteDto(i.Id, i.Email, i.IssuedAt, i.ExpiresAt))
.ToListAsync();
return Results.Ok(invites);
});
group.MapPost("/", async (
CreateInviteRequest request,
YesChefDb db,
HttpContext http,
IEmailSender email,
IOptions<AppUrlOptions> urlOptions) =>
{
if (string.IsNullOrWhiteSpace(request.Email) || !LooksLikeEmail(request.Email))
return Results.BadRequest(new { error = "A valid email address is required." });
var familyId = http.User.GetFamilyId();
var actingUserId = http.User.GetUserId();
var normalizedEmail = request.Email.Trim().ToLowerInvariant();
var family = await db.Families.AsNoTracking().FirstOrDefaultAsync(f => f.Id == familyId);
if (family is null) return Results.NotFound();
// If the email already belongs to a member of this family, there's
// nothing to invite — surface that explicitly so admins notice.
var alreadyMember = await db.Users
.Where(u => u.Email != null && u.Email.ToLower() == normalizedEmail)
.Join(db.FamilyMemberships,
u => u.Id,
m => m.UserId,
(u, m) => new { m.FamilyId })
.AnyAsync(x => x.FamilyId == familyId);
if (alreadyMember)
return Results.Conflict(new { error = "That email is already a member of this family." });
var rawToken = InviteToken.Generate();
var invite = new Invite
{
FamilyId = familyId,
Email = normalizedEmail,
TokenHash = InviteToken.Hash(rawToken),
IssuedByUserId = actingUserId,
IssuedAt = DateTime.UtcNow,
ExpiresAt = DateTime.UtcNow.Add(InviteLifetime),
};
db.Invites.Add(invite);
await db.SaveChangesAsync();
await SendInviteEmailAsync(email, urlOptions.Value, rawToken, normalizedEmail, family.Name, http.User.GetUserName());
return Results.Created($"/api/family/invites/{invite.Id}",
new InviteDto(invite.Id, invite.Email, invite.IssuedAt, invite.ExpiresAt));
});
group.MapPost("/{id:int}/resend", async (
int id,
YesChefDb db,
HttpContext http,
IEmailSender email,
IOptions<AppUrlOptions> urlOptions) =>
{
var familyId = http.User.GetFamilyId();
var invite = await db.Invites.FirstOrDefaultAsync(i => i.Id == id && i.FamilyId == familyId);
if (invite is null) return Results.NotFound();
if (invite.ConsumedAt is not null)
return Results.Conflict(new { error = "Invite has already been used." });
// Always rotate the token on resend: simpler than tracking whether
// the previous email was actually delivered, and ensures only the
// most recent link is valid (defense against stale leaked links).
var rawToken = InviteToken.Generate();
invite.TokenHash = InviteToken.Hash(rawToken);
invite.IssuedAt = DateTime.UtcNow;
invite.ExpiresAt = DateTime.UtcNow.Add(InviteLifetime);
await db.SaveChangesAsync();
var family = await db.Families.AsNoTracking().FirstAsync(f => f.Id == familyId);
await SendInviteEmailAsync(email, urlOptions.Value, rawToken, invite.Email, family.Name, http.User.GetUserName());
return Results.Ok(new InviteDto(invite.Id, invite.Email, invite.IssuedAt, invite.ExpiresAt));
});
group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var actingUserId = http.User.GetUserId();
var invite = await db.Invites.FirstOrDefaultAsync(i => i.Id == id && i.FamilyId == familyId);
if (invite is null) return Results.NotFound();
if (invite.ConsumedAt is not null)
return Results.NoContent();
// Tombstone rather than delete so the audit trail (who issued / when /
// who revoked) sticks around. Setting ConsumedByUserId to the actor
// is intentional — distinguishes a revoke from an actual signup.
invite.ConsumedAt = DateTime.UtcNow;
invite.ConsumedByUserId = actingUserId;
// Burn the hash so the link in any cached email becomes useless.
invite.TokenHash = string.Empty;
await db.SaveChangesAsync();
return Results.NoContent();
});
return group;
}
private static async Task SendInviteEmailAsync(
IEmailSender email,
AppUrlOptions urlOptions,
string rawToken,
string toAddress,
string familyName,
string inviterName)
{
var joinUrl = $"{urlOptions.BaseUrl.TrimEnd('/')}/register?invite={Uri.EscapeDataString(rawToken)}";
await email.SendAsync(EmailTemplates.Invite(toAddress, familyName, inviterName, joinUrl));
}
private static bool LooksLikeEmail(string value)
{
var trimmed = value.Trim();
var at = trimmed.IndexOf('@');
return at > 0 && at < trimmed.Length - 1 && trimmed.IndexOf('.', at) > at;
}
}
@@ -0,0 +1,611 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YesChef.Api.Data;
#nullable disable
namespace YesChef.Api.Migrations
{
[DbContext(typeof(YesChefDb))]
[Migration("20260509033654_AddEmailAndInvites")]
partial class AddEmailAndInvites
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YesChef.Api.Entities.Family", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("InviteCode")
.IsUnique();
b.ToTable("Families");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("UserId", "FamilyId");
b.HasIndex("FamilyId");
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ConsumedByUserId")
.HasColumnType("integer");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("IssuedByUserId")
.HasColumnType("integer");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("ConsumedByUserId");
b.HasIndex("IssuedByUserId");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("FamilyId", "ConsumedAt");
b.ToTable("Invites");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Instructions")
.HasColumnType("text");
b.Property<int?>("Servings")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FamilyId");
b.ToTable("Recipes");
});
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FamilyId");
b.HasIndex("StoreId");
b.ToTable("ShoppingLists");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CheckedByUserId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
b.Property<DateTime?>("RemovedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("RemovedByUserId")
.HasColumnType("integer");
b.Property<int?>("SectionId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CheckedByUserId");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.HasIndex("RemovedByUserId");
b.HasIndex("SectionId");
b.HasIndex("ShoppingListId");
b.ToTable("ShoppingListItems");
});
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("Stores");
});
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("StoreId", "Name")
.IsUnique();
b.ToTable("StoreSections");
});
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique()
.HasFilter("\"Email\" IS NOT NULL");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("User");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser")
.WithMany()
.HasForeignKey("ConsumedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.User", "IssuedByUser")
.WithMany()
.HasForeignKey("IssuedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ConsumedByUser");
b.Navigation("Family");
b.Navigation("IssuedByUser");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Family");
});
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany("Ingredients")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("Recipe");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Family");
b.Navigation("Store");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CheckedByUser")
.WithMany()
.HasForeignKey("CheckedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany()
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.User", "RemovedByUser")
.WithMany()
.HasForeignKey("RemovedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.StoreSection", "Section")
.WithMany()
.HasForeignKey("SectionId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList")
.WithMany("Items")
.HasForeignKey("ShoppingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CheckedByUser");
b.Navigation("Family");
b.Navigation("Recipe");
b.Navigation("RemovedByUser");
b.Navigation("Section");
b.Navigation("ShoppingList");
});
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
});
modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("Store");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Navigation("Ingredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,114 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddEmailAndInvites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Email",
table: "Users",
type: "character varying(254)",
maxLength: 254,
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EmailConfirmedAt",
table: "Users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.CreateTable(
name: "Invites",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
FamilyId = table.Column<int>(type: "integer", nullable: false),
Email = table.Column<string>(type: "character varying(254)", maxLength: 254, nullable: false),
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: false),
IssuedByUserId = table.Column<int>(type: "integer", nullable: false),
IssuedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
ConsumedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
ConsumedByUserId = table.Column<int>(type: "integer", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Invites", x => x.Id);
table.ForeignKey(
name: "FK_Invites_Families_FamilyId",
column: x => x.FamilyId,
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Invites_Users_ConsumedByUserId",
column: x => x.ConsumedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Invites_Users_IssuedByUserId",
column: x => x.IssuedByUserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_Users_Email",
table: "Users",
column: "Email",
unique: true,
filter: "\"Email\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Invites_ConsumedByUserId",
table: "Invites",
column: "ConsumedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Invites_FamilyId_ConsumedAt",
table: "Invites",
columns: new[] { "FamilyId", "ConsumedAt" });
migrationBuilder.CreateIndex(
name: "IX_Invites_IssuedByUserId",
table: "Invites",
column: "IssuedByUserId");
migrationBuilder.CreateIndex(
name: "IX_Invites_TokenHash",
table: "Invites",
column: "TokenHash",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Invites");
migrationBuilder.DropIndex(
name: "IX_Users_Email",
table: "Users");
migrationBuilder.DropColumn(
name: "Email",
table: "Users");
migrationBuilder.DropColumn(
name: "EmailConfirmedAt",
table: "Users");
}
}
}
@@ -72,6 +72,56 @@ namespace YesChef.Api.Migrations
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ConsumedByUserId")
.HasColumnType("integer");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("IssuedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("IssuedByUserId")
.HasColumnType("integer");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.HasKey("Id");
b.HasIndex("ConsumedByUserId");
b.HasIndex("IssuedByUserId");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("FamilyId", "ConsumedAt");
b.ToTable("Invites");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
@@ -328,6 +378,13 @@ namespace YesChef.Api.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.HasMaxLength(254)
.HasColumnType("character varying(254)");
b.Property<DateTime?>("EmailConfirmedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@@ -339,6 +396,10 @@ namespace YesChef.Api.Migrations
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique()
.HasFilter("\"Email\" IS NOT NULL");
b.HasIndex("Name")
.IsUnique();
@@ -364,6 +425,32 @@ namespace YesChef.Api.Migrations
b.Navigation("User");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser")
.WithMany()
.HasForeignKey("ConsumedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.User", "IssuedByUser")
.WithMany()
.HasForeignKey("IssuedByUserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("ConsumedByUser");
b.Navigation("Family");
b.Navigation("IssuedByUser");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
+6 -1
View File
@@ -23,6 +23,10 @@ builder.Services.AddDbContext<YesChefDb>(options =>
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<AppUrlOptions>(options =>
{
options.BaseUrl = builder.Configuration["AppBaseUrl"] ?? options.BaseUrl;
});
builder.Services.AddSingleton<IEmailSender>(sp =>
{
var options = sp.GetRequiredService<IOptions<EmailOptions>>().Value;
@@ -177,7 +181,8 @@ app.MapGet("/health", async (YesChefDb db) =>
});
app.MapGroup("/api/auth").MapAuthEndpoints();
app.MapGroup("/api/family").MapFamilyEndpoints().RequireAuthorization();
var familyGroup = app.MapGroup("/api/family").MapFamilyEndpoints().RequireAuthorization();
familyGroup.MapGroup("/invites").MapInviteEndpoints().RequireAuthorization("FamilyAdmin");
var storesGroup = app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthorization();
storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints();
app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization();
+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>