Add password reset flow
Users with a confirmed email can now reset a forgotten password via emailed single-use links. Backend - New PasswordResetToken entity (hash-only, 15-minute TTL). - POST /api/auth/forgot-password always returns 200, never disclosing whether an email is registered. Internally only emits a reset email when a user exists with EmailConfirmedAt set, and burns any existing outstanding tokens for that user before issuing a new one. - POST /api/auth/reset-password validates the token, rotates the password hash, and consumes the token. Single-use, expiry-checked. - Both endpoints are rate-limited (forgot 5/hr, reset 10/15min) per the same partitioning the login/register endpoints already use. - Reset email template added; uses AppBaseUrl plumbed in the previous PR. Frontend - /forgot-password page (email field, generic confirmation message regardless of whether the email is registered). - /reset-password page reads ?token=, validates the new password client-side, posts to the API, then redirects to /login. - "Forgot your password?" link added under the login form. Tests - 9 new integration tests cover the happy path, single-use enforcement, expired/unknown tokens, short-password rejection, silent 200 for unknown email, no email for unconfirmed users, and outstanding-token invalidation when a fresh request is made.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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 forgot/reset password flow. Each test sets up a user with a
|
||||
/// confirmed email by going through the invite registration path so the
|
||||
/// fixtures match production behavior (only confirmed-email users are
|
||||
/// eligible for password reset).
|
||||
/// </summary>
|
||||
public class PasswordResetTests : AuthenticatedIntegrationTest
|
||||
{
|
||||
[Test]
|
||||
public async Task Forgot_password_sends_email_for_known_confirmed_user()
|
||||
{
|
||||
var (_, email) = await RegisterViaInviteAsync("alice", "alice@example.com");
|
||||
// Drain the invite email so assertions don't pick it up.
|
||||
var beforeCount = App.Emails.Messages.Count;
|
||||
|
||||
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest(email));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||
await Assert.That(App.Emails.Messages.Count).IsEqualTo(beforeCount + 1);
|
||||
var sent = App.Emails.Last;
|
||||
await Assert.That(sent.ToAddress).IsEqualTo(email);
|
||||
await Assert.That(sent.TextBody).Contains("/reset-password?token=");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Forgot_password_returns_200_silently_for_unknown_email()
|
||||
{
|
||||
var beforeCount = App.Emails.Messages.Count;
|
||||
|
||||
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest("nobody@example.com"));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||
await Assert.That(App.Emails.Messages.Count).IsEqualTo(beforeCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Forgot_password_does_not_email_unconfirmed_user()
|
||||
{
|
||||
// The default authed user (registered via family code) has no email,
|
||||
// so they're ineligible. Attach an unconfirmed email directly to
|
||||
// verify the EmailConfirmedAt requirement fires.
|
||||
await UseDbAsync(async db =>
|
||||
{
|
||||
var u = await db.Users.SingleAsync(x => x.Id == User.Id);
|
||||
u.Email = "unconfirmed@example.com";
|
||||
u.EmailConfirmedAt = null;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
var beforeCount = App.Emails.Messages.Count;
|
||||
|
||||
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest("unconfirmed@example.com"));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||
await Assert.That(App.Emails.Messages.Count).IsEqualTo(beforeCount);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reset_password_changes_password_and_consumes_token()
|
||||
{
|
||||
var (name, email) = await RegisterViaInviteAsync("bob", "bob@example.com");
|
||||
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest(email));
|
||||
var token = ExtractResetToken();
|
||||
|
||||
var reset = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
|
||||
new AuthEndpoints.ResetPasswordRequest(token, "new-password-123"));
|
||||
await Assert.That(reset.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||
|
||||
// Old password no longer works, new one does.
|
||||
var oldLogin = await AnonymousClient.PostAsJsonAsync("/api/auth/login",
|
||||
new AuthEndpoints.LoginRequest(name, "pw-1234"));
|
||||
await Assert.That(oldLogin.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
|
||||
|
||||
var newLogin = await AnonymousClient.PostAsJsonAsync("/api/auth/login",
|
||||
new AuthEndpoints.LoginRequest(name, "new-password-123"));
|
||||
await Assert.That(newLogin.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reset_token_is_single_use()
|
||||
{
|
||||
var (_, email) = await RegisterViaInviteAsync("carol", "carol@example.com");
|
||||
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest(email));
|
||||
var token = ExtractResetToken();
|
||||
|
||||
var first = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
|
||||
new AuthEndpoints.ResetPasswordRequest(token, "first-new-pw"));
|
||||
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||
|
||||
var second = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
|
||||
new AuthEndpoints.ResetPasswordRequest(token, "second-attempt"));
|
||||
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reset_password_rejects_unknown_token()
|
||||
{
|
||||
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
|
||||
new AuthEndpoints.ResetPasswordRequest("not-a-real-token", "any-password"));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reset_password_rejects_expired_token()
|
||||
{
|
||||
var (_, email) = await RegisterViaInviteAsync("dave", "dave@example.com");
|
||||
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest(email));
|
||||
var token = ExtractResetToken();
|
||||
|
||||
await UseDbAsync(async db =>
|
||||
{
|
||||
var t = await db.PasswordResetTokens.OrderByDescending(x => x.IssuedAt).FirstAsync();
|
||||
t.ExpiresAt = DateTime.UtcNow.AddMinutes(-1);
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
|
||||
new AuthEndpoints.ResetPasswordRequest(token, "anything-1234"));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Reset_password_rejects_short_password()
|
||||
{
|
||||
var (_, email) = await RegisterViaInviteAsync("eve", "eve@example.com");
|
||||
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest(email));
|
||||
var token = ExtractResetToken();
|
||||
|
||||
var response = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
|
||||
new AuthEndpoints.ResetPasswordRequest(token, "abc"));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task New_forgot_request_invalidates_outstanding_token()
|
||||
{
|
||||
var (_, email) = await RegisterViaInviteAsync("frank", "frank@example.com");
|
||||
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest(email));
|
||||
var firstToken = ExtractResetToken();
|
||||
|
||||
// A second forgot request must invalidate the first token.
|
||||
await AnonymousClient.PostAsJsonAsync("/api/auth/forgot-password",
|
||||
new AuthEndpoints.ForgotPasswordRequest(email));
|
||||
var secondToken = ExtractResetToken();
|
||||
|
||||
await Assert.That(secondToken).IsNotEqualTo(firstToken);
|
||||
|
||||
var oldAttempt = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
|
||||
new AuthEndpoints.ResetPasswordRequest(firstToken, "ignored-1234"));
|
||||
await Assert.That(oldAttempt.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
|
||||
var newAttempt = await AnonymousClient.PostAsJsonAsync("/api/auth/reset-password",
|
||||
new AuthEndpoints.ResetPasswordRequest(secondToken, "shiny-new-pw"));
|
||||
await Assert.That(newAttempt.StatusCode).IsEqualTo(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues an invite as the existing admin (User), registers a new user
|
||||
/// against it (which sets Email + EmailConfirmedAt in one step), and
|
||||
/// returns the new user's name + email so tests can assert against them.
|
||||
/// </summary>
|
||||
private async Task<(string Name, string Email)> RegisterViaInviteAsync(string name, string email)
|
||||
{
|
||||
var inviteResponse = await Client.PostAsJsonAsync("/api/family/invites",
|
||||
new InviteEndpoints.CreateInviteRequest(email));
|
||||
inviteResponse.EnsureSuccessStatusCode();
|
||||
var inviteToken = ExtractInviteToken();
|
||||
|
||||
var register = await AnonymousClient.PostAsJsonAsync("/api/auth/register",
|
||||
new AuthEndpoints.RegisterRequest(name, "pw-1234", null, inviteToken));
|
||||
register.EnsureSuccessStatusCode();
|
||||
return (name, email);
|
||||
}
|
||||
|
||||
private string ExtractInviteToken() => ExtractTokenFromLatest("/register?invite=");
|
||||
private string ExtractResetToken() => ExtractTokenFromLatest("/reset-password?token=");
|
||||
|
||||
private string ExtractTokenFromLatest(string marker)
|
||||
{
|
||||
var body = App.Emails.Last.TextBody;
|
||||
var start = body.IndexOf(marker, StringComparison.Ordinal);
|
||||
if (start < 0) throw new InvalidOperationException($"Marker {marker} not found in latest email.");
|
||||
start += marker.Length;
|
||||
var end = body.IndexOfAny(['\r', '\n', ' '], start);
|
||||
if (end < 0) end = body.Length;
|
||||
return Uri.UnescapeDataString(body[start..end]);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Email;
|
||||
using YesChef.Api.Entities;
|
||||
|
||||
namespace YesChef.Api.Auth;
|
||||
@@ -17,6 +19,8 @@ public static class AuthEndpoints
|
||||
public record LoginRequest(string Name, string Password);
|
||||
public record AuthResponse(string Token, string Name, string Role);
|
||||
public record InviteLookupResponse(string Email, string FamilyName);
|
||||
public record ForgotPasswordRequest(string Email);
|
||||
public record ResetPasswordRequest(string Token, string NewPassword);
|
||||
|
||||
public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
|
||||
{
|
||||
@@ -112,6 +116,69 @@ public static class AuthEndpoints
|
||||
return Results.Ok(new AuthResponse(token, user.Name, membership.Role.ToString()));
|
||||
}).RequireRateLimiting(AuthRateLimits.LoginPolicy);
|
||||
|
||||
group.MapPost("/forgot-password", async (
|
||||
ForgotPasswordRequest request,
|
||||
YesChefDb db,
|
||||
IEmailSender email,
|
||||
IOptions<AppUrlOptions> urlOptions) =>
|
||||
{
|
||||
// Always return 200 — never reveal whether an email is registered.
|
||||
// Probing this endpoint to enumerate users is what the rate limit
|
||||
// is for; the response shape is what stops casual enumeration.
|
||||
if (string.IsNullOrWhiteSpace(request.Email))
|
||||
return Results.Ok();
|
||||
|
||||
var normalized = request.Email.Trim().ToLowerInvariant();
|
||||
var user = await db.Users
|
||||
.Where(u => u.Email != null && u.Email.ToLower() == normalized && u.EmailConfirmedAt != null)
|
||||
.FirstOrDefaultAsync();
|
||||
if (user is null)
|
||||
return Results.Ok();
|
||||
|
||||
// Burn any outstanding tokens so a stolen-but-unused link can't be
|
||||
// reused after a fresh request goes out.
|
||||
var outstanding = await db.PasswordResetTokens
|
||||
.Where(t => t.UserId == user.Id && t.ConsumedAt == null)
|
||||
.ToListAsync();
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var t in outstanding) t.ConsumedAt = now;
|
||||
|
||||
var rawToken = InviteToken.Generate();
|
||||
db.PasswordResetTokens.Add(new PasswordResetToken
|
||||
{
|
||||
UserId = user.Id,
|
||||
TokenHash = InviteToken.Hash(rawToken),
|
||||
IssuedAt = now,
|
||||
ExpiresAt = now.AddMinutes(15),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var resetUrl = $"{urlOptions.Value.BaseUrl.TrimEnd('/')}/reset-password?token={Uri.EscapeDataString(rawToken)}";
|
||||
await email.SendAsync(EmailTemplates.PasswordReset(user.Email!, resetUrl));
|
||||
|
||||
return Results.Ok();
|
||||
}).RequireRateLimiting(AuthRateLimits.ForgotPasswordPolicy);
|
||||
|
||||
group.MapPost("/reset-password", async (ResetPasswordRequest request, YesChefDb db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token) || string.IsNullOrWhiteSpace(request.NewPassword))
|
||||
return Results.BadRequest(new { error = "Token and new password are required." });
|
||||
if (request.NewPassword.Length < 6)
|
||||
return Results.BadRequest(new { error = "Password must be at least 6 characters." });
|
||||
|
||||
var hashedToken = InviteToken.Hash(request.Token);
|
||||
var token = await db.PasswordResetTokens
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.TokenHash == hashedToken);
|
||||
if (token is null || token.ConsumedAt is not null || token.ExpiresAt < DateTime.UtcNow)
|
||||
return Results.BadRequest(new { error = "Reset link is invalid or has expired." });
|
||||
|
||||
token.User.PasswordHash = hasher.HashPassword(token.User, request.NewPassword);
|
||||
token.ConsumedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok();
|
||||
}).RequireRateLimiting(AuthRateLimits.ResetPasswordPolicy);
|
||||
|
||||
group.MapGet("/invite/{token}", async (string token, YesChefDb db) =>
|
||||
{
|
||||
var hash = Auth.InviteToken.Hash(token);
|
||||
|
||||
@@ -4,4 +4,6 @@ public static class AuthRateLimits
|
||||
{
|
||||
public const string LoginPolicy = "auth-login";
|
||||
public const string RegisterPolicy = "auth-register";
|
||||
public const string ForgotPasswordPolicy = "auth-forgot-password";
|
||||
public const string ResetPasswordPolicy = "auth-reset-password";
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
||||
public DbSet<Recipe> Recipes => Set<Recipe>();
|
||||
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
|
||||
public DbSet<Invite> Invites => Set<Invite>();
|
||||
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -43,6 +44,14 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
||||
e.HasIndex(u => u.Email).IsUnique().HasFilter("\"Email\" IS NOT NULL");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PasswordResetToken>(e =>
|
||||
{
|
||||
e.HasOne(t => t.User).WithMany().HasForeignKey(t => t.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.Property(t => t.TokenHash).HasMaxLength(64);
|
||||
e.HasIndex(t => t.TokenHash).IsUnique();
|
||||
e.HasIndex(t => new { t.UserId, t.ConsumedAt });
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Invite>(e =>
|
||||
{
|
||||
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
@@ -29,4 +29,28 @@ public static class EmailTemplates
|
||||
|
||||
return new EmailMessage(toAddress, subject, html, text);
|
||||
}
|
||||
|
||||
public static EmailMessage PasswordReset(string toAddress, string resetUrl)
|
||||
{
|
||||
const string subject = "Reset your YesChef password";
|
||||
var safeUrl = WebUtility.HtmlEncode(resetUrl);
|
||||
|
||||
var html = $"""
|
||||
<p>Hi,</p>
|
||||
<p>We received a request to reset your YesChef password. Click the link below to choose a new one:</p>
|
||||
<p><a href="{safeUrl}">Reset your password</a></p>
|
||||
<p>This link expires in 15 minutes. If you didn't request a reset, you can ignore this email — your password won't change.</p>
|
||||
""";
|
||||
|
||||
var text = $"""
|
||||
We received a request to reset your YesChef password.
|
||||
|
||||
Reset link (expires in 15 minutes):
|
||||
{resetUrl}
|
||||
|
||||
If you didn't request a reset, you can ignore this email — your password won't change.
|
||||
""";
|
||||
|
||||
return new EmailMessage(toAddress, subject, html, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace YesChef.Api.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Single-use, short-lived token used by the password-reset flow. Only the
|
||||
/// SHA-256 hash of the raw token is stored; the raw value goes out in the
|
||||
/// reset email and is never persisted.
|
||||
/// </summary>
|
||||
public class PasswordResetToken
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int UserId { get; set; }
|
||||
public User User { get; set; } = null!;
|
||||
|
||||
public required string TokenHash { get; set; }
|
||||
public DateTime IssuedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public DateTime? ConsumedAt { get; set; }
|
||||
}
|
||||
+657
@@ -0,0 +1,657 @@
|
||||
// <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("20260509034418_AddPasswordResetTokens")]
|
||||
partial class AddPasswordResetTokens
|
||||
{
|
||||
/// <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.PasswordResetToken", 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<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("IssuedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "ConsumedAt");
|
||||
|
||||
b.ToTable("PasswordResetTokens");
|
||||
});
|
||||
|
||||
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.PasswordResetToken", b =>
|
||||
{
|
||||
b.HasOne("YesChef.Api.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
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,57 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace YesChef.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPasswordResetTokens : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PasswordResetTokens",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "integer", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
UserId = table.Column<int>(type: "integer", nullable: false),
|
||||
TokenHash = table.Column<string>(type: "character varying(64)", maxLength: 64, 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)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PasswordResetTokens", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PasswordResetTokens_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PasswordResetTokens_TokenHash",
|
||||
table: "PasswordResetTokens",
|
||||
column: "TokenHash",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PasswordResetTokens_UserId_ConsumedAt",
|
||||
table: "PasswordResetTokens",
|
||||
columns: new[] { "UserId", "ConsumedAt" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PasswordResetTokens");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,41 @@ namespace YesChef.Api.Migrations
|
||||
b.ToTable("Invites");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", 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<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("IssuedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("TokenHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("UserId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TokenHash")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId", "ConsumedAt");
|
||||
|
||||
b.ToTable("PasswordResetTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -451,6 +486,17 @@ namespace YesChef.Api.Migrations
|
||||
b.Navigation("IssuedByUser");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b =>
|
||||
{
|
||||
b.HasOne("YesChef.Api.Entities.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
|
||||
{
|
||||
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
|
||||
|
||||
@@ -134,6 +134,32 @@ builder.Services.AddRateLimiter(options =>
|
||||
QueueLimit = 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Both reset endpoints share the login limit's blast radius — keep them
|
||||
// tight to slow down both email-enumeration probing and token guessing.
|
||||
options.AddPolicy(AuthRateLimits.ForgotPasswordPolicy, context =>
|
||||
{
|
||||
if (bypassLimits) return RateLimitPartition.GetNoLimiter("test");
|
||||
var key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromHours(1),
|
||||
QueueLimit = 0,
|
||||
});
|
||||
});
|
||||
|
||||
options.AddPolicy(AuthRateLimits.ResetPasswordPolicy, context =>
|
||||
{
|
||||
if (bypassLimits) return RateLimitPartition.GetNoLimiter("test");
|
||||
var key = context.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 10,
|
||||
Window = TimeSpan.FromMinutes(15),
|
||||
QueueLimit = 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let email = $state('');
|
||||
let submitted = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
async function handleSubmit() {
|
||||
error = '';
|
||||
loading = true;
|
||||
try {
|
||||
await api('/api/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
submitted = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Something went wrong';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-dvh items-center justify-center bg-gray-50 px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-4xl font-bold text-primary">YesChef</h1>
|
||||
<p class="mt-2 text-gray-500">Reset your password</p>
|
||||
</div>
|
||||
|
||||
{#if submitted}
|
||||
<div class="rounded-lg bg-primary/5 p-4 text-sm text-gray-700">
|
||||
<p class="font-medium text-primary">Check your inbox</p>
|
||||
<p class="mt-1">
|
||||
If an account with that email exists, we've sent a link to reset your password.
|
||||
The link expires in 15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-6 text-center text-sm">
|
||||
<a href="/login" class="font-medium text-primary">Back to sign in</a>
|
||||
</p>
|
||||
{:else}
|
||||
<form onsubmit={e => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={email}
|
||||
placeholder="Email"
|
||||
required
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-danger">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full rounded-lg bg-primary py-3 text-lg font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Send reset link'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
Remembered it?
|
||||
<a href="/login" class="font-medium text-primary">Sign in</a>
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,6 +150,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
</p>
|
||||
{#if mode === 'login'}
|
||||
<p class="mt-2 text-center text-sm">
|
||||
<a href="/forgot-password" class="text-gray-500 hover:text-primary">Forgot your password?</a>
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let token = $state<string | null>(null);
|
||||
let newPassword = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
let done = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
token = page.url.searchParams.get('token');
|
||||
if (!token) {
|
||||
error = 'This link is missing its reset token. Use the link from your email.';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
error = '';
|
||||
if (!token) return;
|
||||
if (newPassword.length < 6) {
|
||||
error = 'Password must be at least 6 characters.';
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
error = 'Passwords do not match.';
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
await api('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token, newPassword })
|
||||
});
|
||||
done = true;
|
||||
setTimeout(() => goto('/login'), 1500);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Could not reset password';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-dvh items-center justify-center bg-gray-50 px-4">
|
||||
<div class="w-full max-w-sm">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-4xl font-bold text-primary">YesChef</h1>
|
||||
<p class="mt-2 text-gray-500">Choose a new password</p>
|
||||
</div>
|
||||
|
||||
{#if done}
|
||||
<div class="rounded-lg bg-primary/5 p-4 text-sm">
|
||||
<p class="font-medium text-primary">Password updated</p>
|
||||
<p class="mt-1 text-gray-600">Redirecting you to sign in…</p>
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={e => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
bind:value={newPassword}
|
||||
placeholder="New password"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm new password"
|
||||
required
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-primary focus:ring-2 focus:ring-primary/20 focus:outline-none"
|
||||
/>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-danger">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !token}
|
||||
class="w-full rounded-lg bg-primary py-3 text-lg font-semibold text-white transition-colors hover:bg-primary-dark disabled:opacity-50"
|
||||
>
|
||||
{loading ? '...' : 'Set new password'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user