diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/InviteEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/InviteEndpointsTests.cs new file mode 100644 index 0000000..49658e2 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/InviteEndpointsTests.cs @@ -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; + +/// +/// 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. +/// +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(); + 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($"/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(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(); + 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(); + 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(); + await Client.DeleteAsync($"/api/family/invites/{consumed!.Id}"); + + var pending = await Client.GetFromJsonAsync>("/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 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]); + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/RecordingEmailSender.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/RecordingEmailSender.cs new file mode 100644 index 0000000..d49e7ed --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/RecordingEmailSender.cs @@ -0,0 +1,36 @@ +using YesChef.Api.Email; + +namespace YesChef.Api.IntegrationTests.Infrastructure; + +/// +/// 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. +/// +public sealed class RecordingEmailSender : IEmailSender +{ + private readonly List _messages = new(); + private readonly Lock _gate = new(); + + public IReadOnlyList 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; + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs index 42dfe33..a307269 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Infrastructure/YesChefAppFactory.cs @@ -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 public required string ConnectionString { get; init; } + /// + /// In-memory email sink used for assertions. Replaces the production + /// IEmailSender registration so tests can inspect dispatched messages + /// without standing up an SMTP server. + /// + public RecordingEmailSender Emails { get; } = new(); + /// /// 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 var descriptor = services.Single(d => d.ServiceType == typeof(DbContextOptions)); services.Remove(descriptor); services.AddDbContext(options => options.UseNpgsql(ConnectionString)); + + var emailDescriptor = services.Single(d => d.ServiceType == typeof(IEmailSender)); + services.Remove(emailDescriptor); + services.AddSingleton(Emails); }); } } diff --git a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs index 4a9d533..2196acc 100644 --- a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs +++ b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs @@ -8,9 +8,15 @@ namespace YesChef.Api.Auth; public static class AuthEndpoints { - public record RegisterRequest(string Name, string Password, string FamilyCode); + /// + /// Registration request. Either or + /// must be supplied — invite tokens take precedence + /// when both are present. + /// + 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(); diff --git a/src/backend/YesChef.Api/Auth/InviteToken.cs b/src/backend/YesChef.Api/Auth/InviteToken.cs new file mode 100644 index 0000000..ea4050b --- /dev/null +++ b/src/backend/YesChef.Api/Auth/InviteToken.cs @@ -0,0 +1,29 @@ +using System.Security.Cryptography; + +namespace YesChef.Api.Auth; + +/// +/// 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. +/// +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); + } +} diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 2bffe08..38e66c0 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -14,6 +14,7 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) public DbSet ShoppingListItems => Set(); public DbSet Recipes => Set(); public DbSet RecipeIngredients => Set(); + public DbSet Invites => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -36,6 +37,21 @@ public class YesChefDb(DbContextOptions 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(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(e => diff --git a/src/backend/YesChef.Api/Email/AppUrlOptions.cs b/src/backend/YesChef.Api/Email/AppUrlOptions.cs new file mode 100644 index 0000000..f3d8907 --- /dev/null +++ b/src/backend/YesChef.Api/Email/AppUrlOptions.cs @@ -0,0 +1,7 @@ +namespace YesChef.Api.Email; + +public class AppUrlOptions +{ + /// Public-facing base URL used when building links in emails. + public string BaseUrl { get; set; } = "http://localhost:5173"; +} diff --git a/src/backend/YesChef.Api/Email/EmailTemplates.cs b/src/backend/YesChef.Api/Email/EmailTemplates.cs new file mode 100644 index 0000000..980834b --- /dev/null +++ b/src/backend/YesChef.Api/Email/EmailTemplates.cs @@ -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 = $""" +

Hi,

+

{safeInviter} invited you to join {safeFamily} on YesChef — a shared shopping-list and recipe app.

+

Accept the invitation

+

If you weren't expecting this, you can ignore this email — the link will expire.

+ """; + + 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); + } +} diff --git a/src/backend/YesChef.Api/Entities/Invite.cs b/src/backend/YesChef.Api/Entities/Invite.cs new file mode 100644 index 0000000..bba425f --- /dev/null +++ b/src/backend/YesChef.Api/Entities/Invite.cs @@ -0,0 +1,25 @@ +namespace YesChef.Api.Entities; + +/// +/// 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. +/// +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; } +} diff --git a/src/backend/YesChef.Api/Entities/User.cs b/src/backend/YesChef.Api/Entities/User.cs index 49f4a9a..2317a22 100644 --- a/src/backend/YesChef.Api/Entities/User.cs +++ b/src/backend/YesChef.Api/Entities/User.cs @@ -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; } diff --git a/src/backend/YesChef.Api/Features/Families/InviteEndpoints.cs b/src/backend/YesChef.Api/Features/Families/InviteEndpoints.cs new file mode 100644 index 0000000..77b8b7d --- /dev/null +++ b/src/backend/YesChef.Api/Features/Families/InviteEndpoints.cs @@ -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 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 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; + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260509033654_AddEmailAndInvites.Designer.cs b/src/backend/YesChef.Api/Migrations/20260509033654_AddEmailAndInvites.Designer.cs new file mode 100644 index 0000000..1993200 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260509033654_AddEmailAndInvites.Designer.cs @@ -0,0 +1,611 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260509033654_AddEmailAndInvites.cs b/src/backend/YesChef.Api/Migrations/20260509033654_AddEmailAndInvites.cs new file mode 100644 index 0000000..39d1257 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260509033654_AddEmailAndInvites.cs @@ -0,0 +1,114 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddEmailAndInvites : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Email", + table: "Users", + type: "character varying(254)", + maxLength: 254, + nullable: true); + + migrationBuilder.AddColumn( + name: "EmailConfirmedAt", + table: "Users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.CreateTable( + name: "Invites", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FamilyId = table.Column(type: "integer", nullable: false), + Email = table.Column(type: "character varying(254)", maxLength: 254, nullable: false), + TokenHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + IssuedByUserId = table.Column(type: "integer", nullable: false), + IssuedAt = table.Column(type: "timestamp with time zone", nullable: false), + ExpiresAt = table.Column(type: "timestamp with time zone", nullable: false), + ConsumedAt = table.Column(type: "timestamp with time zone", nullable: true), + ConsumedByUserId = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index 1b3d993..3448604 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -72,6 +72,56 @@ namespace YesChef.Api.Migrations b.ToTable("FamilyMemberships"); }); + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("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("Id") @@ -328,6 +378,13 @@ namespace YesChef.Api.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("Email") + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + b.Property("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") diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index c42469b..700ae7b 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -23,6 +23,10 @@ builder.Services.AddDbContext(options => builder.Services.AddSingleton(); builder.Services.Configure(builder.Configuration.GetSection("Smtp")); +builder.Services.Configure(options => +{ + options.BaseUrl = builder.Configuration["AppBaseUrl"] ?? options.BaseUrl; +}); builder.Services.AddSingleton(sp => { var options = sp.GetRequiredService>().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(); diff --git a/src/frontend/src/routes/family/+page.svelte b/src/frontend/src/routes/family/+page.svelte index 448f9a7..9912692 100644 --- a/src/frontend/src/routes/family/+page.svelte +++ b/src/frontend/src/routes/family/+page.svelte @@ -28,11 +28,22 @@ role: Role; } + interface Invite { + id: number; + email: string; + issuedAt: string; + expiresAt: string; + } + let family = $state(null); let me = $state(null); let loading = $state(true); let pendingRemove = $state(null); let pendingRegenerate = $state(false); + let pendingRevoke = $state(null); + let invites = $state([]); + let inviteEmail = $state(''); + let invitingEmail = $state(false); const isAdmin = $derived(family?.myRole === 'Admin'); @@ -46,6 +57,53 @@ api('/api/family'), api('/api/auth/me') ]); + if (family?.myRole === 'Admin') { + invites = await api('/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('/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('/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('/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 @@ {/if} + {#if isAdmin} +
+

+ Invite by email +

+
{ e.preventDefault(); sendInvite(); }} + class="flex flex-col gap-2 sm:flex-row" + > + + +
+

+ We'll email a join link that expires in 7 days. The recipient's email is automatically confirmed when they sign up. +

+ + {#if invites.length > 0} +

+ Pending invitations +

+
    + {#each invites as invite (invite.id)} +
  • +
    +
    {invite.email}
    +
    + expires {new Date(invite.expiresAt).toLocaleDateString()} +
    +
    + + +
  • + {/each} +
+ {/if} +
+ {/if} +

Members

    @@ -233,6 +353,38 @@ {/if} +{#if pendingRevoke} + +{/if} + {#if pendingRegenerate}
    + 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(null); + let invite = $state(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(`/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; + 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 @@

    Family shopping & recipes

    + {#if inviteLoading} +

    Looking up your invitation…

    + {:else if invite} +
    +

    You've been invited to {invite.familyName}

    +

    + Set a name and password below to finish creating your account + ({invite.email}). +

    +
    + {:else if inviteError} +
    {inviteError}
    + {/if} +
    { e.preventDefault(); handleSubmit(); }} class="space-y-4">
    - {#if mode === 'register'} + {#if mode === 'register' && !inviteToken}
    -

    - {#if mode === 'login'} - New here? - - {:else} - Already have an account? - - {/if} -

    + {#if !inviteToken} +

    + {#if mode === 'login'} + New here? + + {:else} + Already have an account? + + {/if} +

    + {/if}