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