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.Identity;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using YesChef.Api.Data;
|
using YesChef.Api.Data;
|
||||||
|
using YesChef.Api.Email;
|
||||||
using YesChef.Api.Entities;
|
using YesChef.Api.Entities;
|
||||||
|
|
||||||
namespace YesChef.Api.Auth;
|
namespace YesChef.Api.Auth;
|
||||||
@@ -17,6 +19,8 @@ public static class AuthEndpoints
|
|||||||
public record LoginRequest(string Name, string Password);
|
public record LoginRequest(string Name, string Password);
|
||||||
public record AuthResponse(string Token, string Name, string Role);
|
public record AuthResponse(string Token, string Name, string Role);
|
||||||
public record InviteLookupResponse(string Email, string FamilyName);
|
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)
|
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()));
|
return Results.Ok(new AuthResponse(token, user.Name, membership.Role.ToString()));
|
||||||
}).RequireRateLimiting(AuthRateLimits.LoginPolicy);
|
}).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) =>
|
group.MapGet("/invite/{token}", async (string token, YesChefDb db) =>
|
||||||
{
|
{
|
||||||
var hash = Auth.InviteToken.Hash(token);
|
var hash = Auth.InviteToken.Hash(token);
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ public static class AuthRateLimits
|
|||||||
{
|
{
|
||||||
public const string LoginPolicy = "auth-login";
|
public const string LoginPolicy = "auth-login";
|
||||||
public const string RegisterPolicy = "auth-register";
|
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<Recipe> Recipes => Set<Recipe>();
|
||||||
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
|
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
|
||||||
public DbSet<Invite> Invites => Set<Invite>();
|
public DbSet<Invite> Invites => Set<Invite>();
|
||||||
|
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
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");
|
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 =>
|
modelBuilder.Entity<Invite>(e =>
|
||||||
{
|
{
|
||||||
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
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);
|
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");
|
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 =>
|
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -451,6 +486,17 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Navigation("IssuedByUser");
|
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 =>
|
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
|
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
|
||||||
|
|||||||
@@ -134,6 +134,32 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
QueueLimit = 0,
|
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();
|
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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</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