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