mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 18:14:44 +00:00
Compare commits
10 Commits
9f7c4e3571
...
1621313daa
| Author | SHA1 | Date | |
|---|---|---|---|
| 1621313daa | |||
|
|
c514eb9814 | ||
|
|
9e9fc98d33 | ||
|
|
7a981bc331 | ||
|
|
c055982066 | ||
|
|
6b9c14c14b | ||
|
|
2e5f02fb38 | ||
|
|
781d6bb2e6 | ||
|
|
885b2a9570 | ||
|
|
7793129284 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -16,11 +16,11 @@
|
||||
/build
|
||||
|
||||
# project-specific build artifacts
|
||||
/src/Website/build/
|
||||
/src/Website/storybook-static/
|
||||
/src/Website/.react-router/
|
||||
/src/Website/playwright-report/
|
||||
/src/Website/test-results/
|
||||
/web/frontend/build/
|
||||
/web/frontend/storybook-static/
|
||||
/web/frontend/.react-router/
|
||||
/web/frontend/playwright-report/
|
||||
/web/frontend/test-results/
|
||||
/test-results/
|
||||
|
||||
# misc
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# Testing
|
||||
|
||||
This document describes the testing strategy and how to run tests for The
|
||||
@@ -125,6 +126,25 @@ npm run test:storybook:playwright
|
||||
- The command will start or reuse the Storybook server defined in
|
||||
`playwright.storybook.config.ts`
|
||||
|
||||
### Running Storybook tests with a visible browser
|
||||
|
||||
Both of the above run headless by default. To watch the browser while
|
||||
debugging a failure:
|
||||
|
||||
```bash
|
||||
# Playwright Storybook suite
|
||||
npx playwright test -c playwright.storybook.config.ts --headed
|
||||
# or the interactive step-through runner
|
||||
npx playwright test -c playwright.storybook.config.ts --ui
|
||||
|
||||
# Vitest/story play-function suite
|
||||
npx vitest run --project storybook --browser.headless=false
|
||||
# or Vitest's own UI (drop `run` for watch mode)
|
||||
npx vitest --project storybook --browser.headless=false --ui
|
||||
```
|
||||
|
||||
The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
|
||||
|
||||
## Test coverage
|
||||
|
||||
### Current coverage
|
||||
|
||||
@@ -32,7 +32,7 @@ public class JwtAuthenticationHandler(
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||
string? secret = configuration["ACCESS_TOKEN_SECRET"];
|
||||
if (string.IsNullOrWhiteSpace(secret))
|
||||
secret = configuration["Jwt:SecretKey"];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Shared.Contracts;
|
||||
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
||||
|
||||
namespace API.Core.Controllers;
|
||||
|
||||
@@ -20,8 +20,8 @@ public class ProtectedController : ControllerBase
|
||||
[HttpGet]
|
||||
public ActionResult<ResponseBody<object>> Get()
|
||||
{
|
||||
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||
string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||
|
||||
return Ok(
|
||||
new ResponseBody<object>
|
||||
|
||||
@@ -12,9 +12,9 @@ WORKDIR /src
|
||||
# Copy the solution file and every project file first (preserving their real relative paths, since
|
||||
# ProjectReference paths are resolved relative to the referencing .csproj's location), so the
|
||||
# `dotnet restore` layer stays cached as long as no project file or package reference changes,
|
||||
# independent of source-only edits. Restoring against Core.slnx avoids hand-maintaining a per-project
|
||||
# independent of source-only edits. Restoring against Biergarten.API.slnx avoids hand-maintaining a per-project
|
||||
# dependency list here that silently drifts out of sync as ProjectReferences change.
|
||||
COPY ["Core.slnx", "./"]
|
||||
COPY ["Biergarten.API.slnx", "./"]
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
|
||||
@@ -38,7 +38,7 @@ COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHash
|
||||
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
|
||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||
RUN dotnet restore "Core.slnx"
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
COPY . .
|
||||
WORKDIR "/src/API/API.Core"
|
||||
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
@@ -12,6 +12,7 @@ using FluentValidation;
|
||||
using FluentValidation.AspNetCore;
|
||||
using Infrastructure.Jwt;
|
||||
using Infrastructure.Sql.DependencyInjection;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Shared.Application.Behaviors;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
@@ -27,7 +28,37 @@ builder
|
||||
.AddApplicationPart(typeof(AuthController).Assembly);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.AddSecurityDefinition(
|
||||
"Bearer",
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Type = SecuritySchemeType.Http,
|
||||
Scheme = "Bearer",
|
||||
BearerFormat = "JWT",
|
||||
In = ParameterLocation.Header,
|
||||
Description = "Enter a JWT access token.",
|
||||
}
|
||||
);
|
||||
options.AddSecurityRequirement(
|
||||
new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference
|
||||
{
|
||||
Type = ReferenceType.SecurityScheme,
|
||||
Id = "Bearer",
|
||||
},
|
||||
},
|
||||
[]
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
// Add FluentValidation
|
||||
|
||||
@@ -3,8 +3,8 @@ ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
|
||||
# See API.Core/Dockerfile for why this copies every project file (with real relative paths) and
|
||||
# restores against Core.slnx rather than hand-listing API.Specs' transitive dependencies.
|
||||
COPY ["Core.slnx", "./"]
|
||||
# restores against Biergarten.API.slnx rather than hand-listing API.Specs' transitive dependencies.
|
||||
COPY ["Biergarten.API.slnx", "./"]
|
||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
|
||||
@@ -28,7 +28,7 @@ COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHash
|
||||
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
|
||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||
RUN dotnet restore "Core.slnx"
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
COPY . .
|
||||
WORKDIR "/src/API/API.Specs"
|
||||
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||
|
||||
@@ -2,6 +2,8 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Reqnroll;
|
||||
|
||||
namespace API.Specs.Steps;
|
||||
@@ -19,11 +21,8 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
private const string PreviousAccessTokenKey = "previousAccessToken";
|
||||
private const string PreviousRefreshTokenKey = "previousRefreshToken";
|
||||
|
||||
private HttpClient GetClient()
|
||||
private TestApiFactory GetFactory()
|
||||
{
|
||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
|
||||
return client;
|
||||
|
||||
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
|
||||
FactoryKey,
|
||||
out TestApiFactory? f
|
||||
@@ -31,16 +30,28 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
? f
|
||||
: new TestApiFactory();
|
||||
scenario[FactoryKey] = factory;
|
||||
return factory;
|
||||
}
|
||||
|
||||
client = factory.CreateClient();
|
||||
private HttpClient GetClient()
|
||||
{
|
||||
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
|
||||
return client;
|
||||
|
||||
client = GetFactory().CreateClient();
|
||||
scenario[ClientKey] = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
private static string GetRequiredEnvVar(string name)
|
||||
/// <summary>
|
||||
/// Reads a required configuration value from the running <see cref="TestApiFactory" />'s own
|
||||
/// <see cref="IConfiguration" />, so forged tokens are always signed with the exact secret the
|
||||
/// app under test resolved -- rather than assuming this process's environment variables match it.
|
||||
/// </summary>
|
||||
private string GetRequiredConfigValue(string key)
|
||||
{
|
||||
return Environment.GetEnvironmentVariable(name)
|
||||
?? throw new InvalidOperationException($"{name} environment variable is not set");
|
||||
return GetFactory().Services.GetRequiredService<IConfiguration>()[key]
|
||||
?? throw new InvalidOperationException($"{key} configuration value is not set");
|
||||
}
|
||||
|
||||
private static string GenerateJwtToken(
|
||||
@@ -420,7 +431,7 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
? user
|
||||
: throw new InvalidOperationException("registered username not found in scenario");
|
||||
|
||||
string secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
|
||||
string secret = GetRequiredConfigValue("ACCESS_TOKEN_SECRET");
|
||||
scenario["accessToken"] = GenerateJwtToken(
|
||||
userId,
|
||||
username,
|
||||
@@ -439,7 +450,7 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
? user
|
||||
: throw new InvalidOperationException("registered username not found in scenario");
|
||||
|
||||
string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
|
||||
string secret = GetRequiredConfigValue("CONFIRMATION_TOKEN_SECRET");
|
||||
scenario["confirmationToken"] = GenerateJwtToken(
|
||||
userId,
|
||||
username,
|
||||
@@ -458,7 +469,7 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
? user
|
||||
: throw new InvalidOperationException("registered username not found in scenario");
|
||||
|
||||
string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
|
||||
string secret = GetRequiredConfigValue("CONFIRMATION_TOKEN_SECRET");
|
||||
scenario["confirmationToken"] = GenerateJwtToken(
|
||||
userId,
|
||||
username,
|
||||
@@ -602,12 +613,9 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
|
||||
? t
|
||||
: "valid-refresh-token";
|
||||
string body = JsonSerializer.Serialize(new { refreshToken = token });
|
||||
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
|
||||
requestMessage.Headers.Add("X-Refresh-Token", token);
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
@@ -619,12 +627,9 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
string body = JsonSerializer.Serialize(new { refreshToken = "invalid-refresh-token" });
|
||||
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
|
||||
requestMessage.Headers.Add("X-Refresh-Token", "invalid-refresh-token");
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
@@ -636,13 +641,9 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken()
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
// Use an expired token
|
||||
string body = JsonSerializer.Serialize(new { refreshToken = "expired-refresh-token" });
|
||||
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
|
||||
requestMessage.Headers.Add("X-Refresh-Token", "expired-refresh-token");
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
@@ -654,12 +655,8 @@ public class AuthSteps(ScenarioContext scenario)
|
||||
public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken()
|
||||
{
|
||||
HttpClient client = GetClient();
|
||||
string body = JsonSerializer.Serialize(new { });
|
||||
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||
};
|
||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
|
||||
|
||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||
string responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
@@ -11,6 +11,11 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="dbup" Version="5.0.41" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
|
||||
Version="9.0.0"
|
||||
/>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||
|
||||
@@ -4,13 +4,14 @@ using DbUp;
|
||||
using DbUp.Engine;
|
||||
using Infrastructure.Sql;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Database.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for the database migration runner. Reads connection details from environment
|
||||
/// variables, optionally drops and recreates the target database, and applies all SQL
|
||||
/// migration scripts embedded in this assembly using DbUp.
|
||||
/// Entry point for the database migration runner. Reads connection details from configuration
|
||||
/// (backed by environment variables), optionally drops and recreates the target database, and
|
||||
/// applies all SQL migration scripts embedded in this assembly using DbUp.
|
||||
/// </summary>
|
||||
public static class Program
|
||||
{
|
||||
@@ -27,7 +28,10 @@ public static class Program
|
||||
|
||||
try
|
||||
{
|
||||
DatabaseSettings settings = DatabaseSettings.FromEnvironment();
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
DatabaseSettings settings = DatabaseSettings.FromConfiguration(configuration);
|
||||
|
||||
if (settings.ClearDatabase)
|
||||
{
|
||||
@@ -159,28 +163,28 @@ public static class Program
|
||||
/// <summary>
|
||||
/// Builds the settings from the <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>,
|
||||
/// <c>DB_PASSWORD</c>, <c>DB_TRUST_SERVER_CERTIFICATE</c>, and <c>CLEAR_DATABASE</c>
|
||||
/// environment variables.
|
||||
/// configuration values.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when a required environment variable is not set.
|
||||
/// Thrown when a required configuration value is not set.
|
||||
/// </exception>
|
||||
public static DatabaseSettings FromEnvironment()
|
||||
public static DatabaseSettings FromConfiguration(IConfiguration configuration)
|
||||
{
|
||||
string databaseName =
|
||||
Environment.GetEnvironmentVariable("DB_NAME")
|
||||
configuration["DB_NAME"]
|
||||
?? throw new InvalidOperationException(
|
||||
"The DB_NAME environment variable is not set."
|
||||
);
|
||||
|
||||
bool clearDatabase = string.Equals(
|
||||
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
|
||||
configuration["CLEAR_DATABASE"],
|
||||
"true",
|
||||
StringComparison.OrdinalIgnoreCase
|
||||
);
|
||||
|
||||
return new DatabaseSettings(
|
||||
SqlConnectionStringHelper.BuildConnectionString(databaseName),
|
||||
SqlConnectionStringHelper.BuildMasterConnectionString(),
|
||||
SqlConnectionStringHelper.BuildConnectionString(configuration, databaseName),
|
||||
SqlConnectionStringHelper.BuildMasterConnectionString(configuration),
|
||||
databaseName,
|
||||
clearDatabase
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#region
|
||||
|
||||
using Infrastructure.Sql;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -8,7 +9,9 @@ namespace Database.Seed.DatabaseHelpers;
|
||||
|
||||
public class ConnectionStrings
|
||||
{
|
||||
public static string SqlServerConnectionString => SqlConnectionStringHelper.BuildConnectionString();
|
||||
public static string GetSqlServerConnectionString(IConfiguration configuration) =>
|
||||
SqlConnectionStringHelper.BuildConnectionString(configuration);
|
||||
|
||||
public static string SqliteConnectionString =>
|
||||
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ using Database.Seed.PipelineData;
|
||||
using Database.Seed.Sqlite;
|
||||
using Domain.Entities;
|
||||
using Features.Auth.DependencyInjection;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Breweries.DependencyInjection;
|
||||
using Features.Breweries.Repository;
|
||||
using Features.Locations.DependencyInjection;
|
||||
using Features.Locations.Dtos;
|
||||
using Features.Locations.Repository;
|
||||
using idunno.Password;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Infrastructure.Sql.DependencyInjection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Spectre.Console;
|
||||
@@ -22,9 +22,12 @@ static async Task<int> RunAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
ServiceCollection services = [];
|
||||
services.AddSingleton<IConfiguration>(
|
||||
new ConfigurationBuilder().Build());
|
||||
services.AddSingleton(configuration);
|
||||
services.AddInfrastructureSql();
|
||||
services.AddFeaturesBreweries();
|
||||
services.AddFeaturesLocations();
|
||||
@@ -36,10 +39,8 @@ static async Task<int> RunAsync()
|
||||
provider.GetRequiredService<IBreweryRepository>();
|
||||
ILocationRepository locationRepository =
|
||||
provider.GetRequiredService<ILocationRepository>();
|
||||
IAuthRepository authRepository =
|
||||
provider.GetRequiredService<IAuthRepository>();
|
||||
IPasswordInfrastructure passwordInfrastructure =
|
||||
provider.GetRequiredService<IPasswordInfrastructure>();
|
||||
UserManager<ApplicationUser> userManager =
|
||||
provider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||
|
||||
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]")
|
||||
.LeftJustified());
|
||||
@@ -94,11 +95,7 @@ static async Task<int> RunAsync()
|
||||
"Loading user data into target database...",
|
||||
async ctx =>
|
||||
{
|
||||
postedByIds = await LoadUsersIntoDatabaseAsync(
|
||||
authRepository,
|
||||
passwordInfrastructure,
|
||||
seedData.Users
|
||||
);
|
||||
postedByIds = await LoadUsersIntoDatabaseAsync(userManager, seedData.Users);
|
||||
ctx.Status("User data loaded into target database.");
|
||||
}
|
||||
);
|
||||
@@ -132,8 +129,7 @@ static async Task<int> RunAsync()
|
||||
}
|
||||
|
||||
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||
IAuthRepository authRepository,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IReadOnlyList<UserRecord> users
|
||||
)
|
||||
{
|
||||
@@ -141,22 +137,29 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||
|
||||
foreach (UserRecord userRecord in users)
|
||||
{
|
||||
UserAccount userAccount = await authRepository.RegisterUserAsync(
|
||||
new UserAccount()
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
FirstName = userRecord.User.FirstName,
|
||||
LastName = userRecord.User.LastName,
|
||||
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
|
||||
Username = userRecord.User.Username,
|
||||
UserName = userRecord.User.Username,
|
||||
Email = userRecord.Email,
|
||||
UserCredential = new UserCredential
|
||||
{
|
||||
Hash = passwordInfrastructure.Hash(
|
||||
password: PasswordGenerator.Generate(64, 12, 12))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
|
||||
// digit characters (0-9), which idunno.Password rejects outright.
|
||||
IdentityResult result = await userManager.CreateAsync(
|
||||
user,
|
||||
PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true)
|
||||
);
|
||||
userAccountIds.Add(userAccount.UserAccountId);
|
||||
|
||||
if (!result.Succeeded)
|
||||
throw new InvalidOperationException(
|
||||
$"Failed to seed user '{userRecord.User.Username}': "
|
||||
+ string.Join("; ", result.Errors.Select(e => e.Description))
|
||||
);
|
||||
|
||||
userAccountIds.Add(user.Id);
|
||||
}
|
||||
|
||||
return userAccountIds;
|
||||
|
||||
@@ -2,8 +2,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore "Core.slnx"
|
||||
RUN dotnet build "Core.slnx" -c $BUILD_CONFIGURATION
|
||||
RUN dotnet restore "Biergarten.API.slnx"
|
||||
RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION
|
||||
|
||||
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is
|
||||
# excluded here since it's a separate BDD/integration suite, run by its own compose service
|
||||
|
||||
@@ -1,27 +1,34 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.ConfirmUser;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class ConfirmUserHandlerTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<IUserEmailStore<ApplicationUser>> _emailStoreMock;
|
||||
private readonly ConfirmUserHandler _handler;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public ConfirmUserHandlerTests()
|
||||
{
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_emailStoreMock = new Mock<IUserEmailStore<ApplicationUser>>();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_handler = new ConfirmUserHandler(_authRepositoryMock.Object, _tokenServiceMock.Object);
|
||||
_handler = new ConfirmUserHandler(
|
||||
_userManagerMock.Object,
|
||||
_emailStoreMock.Object,
|
||||
_tokenServiceMock.Object
|
||||
);
|
||||
}
|
||||
|
||||
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
|
||||
@@ -44,12 +51,13 @@ public class ConfirmUserHandlerTests
|
||||
const string confirmationToken = "valid-confirmation-token";
|
||||
|
||||
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
|
||||
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
|
||||
ApplicationUser user = new() { Id = userId, UserName = username };
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(validatedToken);
|
||||
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
ConfirmationPayload result = await _handler.Handle(
|
||||
new ConfirmUserCommand(confirmationToken),
|
||||
@@ -59,6 +67,12 @@ public class ConfirmUserHandlerTests
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||
|
||||
_emailStoreMock.Verify(
|
||||
x => x.SetEmailConfirmedAsync(user, true, It.IsAny<CancellationToken>()),
|
||||
Times.Once
|
||||
);
|
||||
_userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -85,9 +99,9 @@ public class ConfirmUserHandlerTests
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||
.ReturnsAsync(MakeValidatedToken(userId, username));
|
||||
_authRepositoryMock
|
||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<ConfirmationPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.DeleteAccount;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class DeleteAccountHandlerTests
|
||||
{
|
||||
private readonly DeleteAccountHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public DeleteAccountHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new DeleteAccountHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithExistingUser_DeletesAccount()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.DeleteAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
|
||||
|
||||
_userManagerMock.Verify(x => x.DeleteAsync(user), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task> act = async () =>
|
||||
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithDependentRecords_ThrowsConflictException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.DeleteAsync(user))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError
|
||||
{
|
||||
Code = "AccountHasDependentRecords",
|
||||
Description = "still referenced",
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task> act = async () =>
|
||||
await _handler.Handle(new DeleteAccountCommand(userId), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Commands.RefreshToken;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Services;
|
||||
@@ -15,11 +14,12 @@ public class RefreshTokenHandlerTests
|
||||
Mock<ITokenService> tokenServiceMock = new();
|
||||
RefreshTokenHandler handler = new(tokenServiceMock.Object);
|
||||
Guid userId = Guid.NewGuid();
|
||||
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
|
||||
|
||||
tokenServiceMock
|
||||
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
||||
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
|
||||
.ReturnsAsync(
|
||||
new RefreshTokenResult(userId, "testuser", "new-refresh-token", "new-access-token")
|
||||
);
|
||||
|
||||
LoginPayload result = await handler.Handle(
|
||||
new RefreshTokenCommand("old-refresh-token"),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.RegisterUser;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
@@ -14,22 +14,19 @@ namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class RegisterUserHandlerTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||
private readonly RegisterUserHandler _handler;
|
||||
private readonly Mock<IMediator> _mediatorMock;
|
||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public RegisterUserHandlerTests()
|
||||
{
|
||||
_authRepoMock = new Mock<IAuthRepository>();
|
||||
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_mediatorMock = new Mock<IMediator>();
|
||||
|
||||
_handler = new RegisterUserHandler(
|
||||
_authRepoMock.Object,
|
||||
_passwordInfraMock.Object,
|
||||
_userManagerMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_mediatorMock.Object
|
||||
);
|
||||
@@ -54,51 +51,32 @@ public class RegisterUserHandlerTests
|
||||
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
const string hashedPassword = "hashed_password_value";
|
||||
Guid expectedUserId = Guid.NewGuid();
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||
|
||||
_authRepoMock
|
||||
_userManagerMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.Is<UserAccount>(ua =>
|
||||
ua.FirstName == command.FirstName
|
||||
&& ua.LastName == command.LastName
|
||||
&& ua.Username == command.Username
|
||||
&& ua.Email == command.Email
|
||||
&& ua.DateOfBirth == command.DateOfBirth
|
||||
&& ua.UserCredential!.Hash == hashedPassword
|
||||
x.CreateAsync(
|
||||
It.Is<ApplicationUser>(u =>
|
||||
u.FirstName == command.FirstName
|
||||
&& u.LastName == command.LastName
|
||||
&& u.UserName == command.Username
|
||||
&& u.Email == command.Email
|
||||
&& u.DateOfBirth == command.DateOfBirth
|
||||
),
|
||||
command.Password
|
||||
)
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = expectedUserId,
|
||||
Username = command.Username,
|
||||
FirstName = command.FirstName,
|
||||
LastName = command.LastName,
|
||||
Email = command.Email,
|
||||
DateOfBirth = command.DateOfBirth,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}
|
||||
);
|
||||
.Callback<ApplicationUser, string>((u, _) => u.Id = expectedUserId)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("refresh-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateConfirmationToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("confirmation-token");
|
||||
|
||||
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||
@@ -120,18 +98,14 @@ public class RegisterUserHandlerTests
|
||||
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand("existinguser");
|
||||
UserAccount existingUser = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "existinguser",
|
||||
};
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync(existingUser);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_userManagerMock
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "DuplicateUserName", Description = "Username taken." }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<RegistrationPayload>> act = async () =>
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
@@ -139,27 +113,20 @@ public class RegisterUserHandlerTests
|
||||
await act.Should()
|
||||
.ThrowAsync<ConflictException>()
|
||||
.WithMessage("Username or email already exists");
|
||||
|
||||
_authRepoMock.Verify(
|
||||
x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
|
||||
UserAccount existingUser = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Email = "existing@example.com",
|
||||
};
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
|
||||
_userManagerMock
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "DuplicateEmail", Description = "Email taken." }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<RegistrationPayload>> act = async () =>
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
@@ -173,45 +140,34 @@ public class RegisterUserHandlerTests
|
||||
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
const string hashedPassword = "hashed_secure_password";
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x =>
|
||||
x.RegisterUserAsync(
|
||||
It.Is<UserAccount>(ua => ua.UserCredential!.Hash == hashedPassword)
|
||||
)
|
||||
)
|
||||
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
|
||||
_userManagerMock
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
|
||||
_authRepoMock.Verify(
|
||||
// UserManager.CreateAsync receives the plaintext password and hashes it internally via
|
||||
// IPasswordHasher<ApplicationUser> -- the handler never sees or stores a hash directly.
|
||||
_userManagerMock.Verify(
|
||||
x =>
|
||||
x.RegisterUserAsync(
|
||||
It.Is<UserAccount>(ua =>
|
||||
ua.FirstName == command.FirstName
|
||||
&& ua.LastName == command.LastName
|
||||
&& ua.Username == command.Username
|
||||
&& ua.DateOfBirth == command.DateOfBirth
|
||||
&& ua.Email == command.Email
|
||||
&& ua.UserCredential!.Hash == hashedPassword
|
||||
)
|
||||
x.CreateAsync(
|
||||
It.Is<ApplicationUser>(u =>
|
||||
u.FirstName == command.FirstName
|
||||
&& u.LastName == command.LastName
|
||||
&& u.UserName == command.Username
|
||||
&& u.DateOfBirth == command.DateOfBirth
|
||||
&& u.Email == command.Email
|
||||
),
|
||||
command.Password
|
||||
),
|
||||
Times.Once
|
||||
);
|
||||
@@ -222,29 +178,16 @@ public class RegisterUserHandlerTests
|
||||
{
|
||||
RegisterUserCommand command = ValidCommand();
|
||||
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
|
||||
_authRepoMock
|
||||
.Setup(x => x.RegisterUserAsync(It.IsAny<UserAccount>()))
|
||||
.ReturnsAsync(
|
||||
new UserAccount
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = command.Username,
|
||||
Email = command.Email,
|
||||
}
|
||||
);
|
||||
_userManagerMock
|
||||
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("refresh-token");
|
||||
_mediatorMock
|
||||
.Setup(x =>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
@@ -10,15 +11,15 @@ namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class ResendConfirmationEmailHandlerTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
|
||||
private readonly ResendConfirmationEmailHandler _handler;
|
||||
private readonly Mock<IMediator> _mediatorMock = new();
|
||||
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock = UserManagerMockFactory.Create();
|
||||
|
||||
public ResendConfirmationEmailHandlerTests()
|
||||
{
|
||||
_handler = new ResendConfirmationEmailHandler(
|
||||
_authRepositoryMock.Object,
|
||||
_userManagerMock.Object,
|
||||
_tokenServiceMock.Object,
|
||||
_mediatorMock.Object
|
||||
);
|
||||
@@ -28,16 +29,18 @@ public class ResendConfirmationEmailHandlerTests
|
||||
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
UserAccount user = new()
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
UserAccountId = userId,
|
||||
Id = userId,
|
||||
FirstName = "Aaron",
|
||||
Email = "aaron@example.com",
|
||||
};
|
||||
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
|
||||
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user)).Returns("fresh-token");
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(false);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateConfirmationToken(user.Id, user.UserName))
|
||||
.Returns("fresh-token");
|
||||
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
@@ -59,7 +62,9 @@ public class ResendConfirmationEmailHandlerTests
|
||||
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
@@ -77,10 +82,10 @@ public class ResendConfirmationEmailHandlerTests
|
||||
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
UserAccount user = new() { UserAccountId = userId };
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(true);
|
||||
|
||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateEmail;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class UpdateEmailHandlerTests
|
||||
{
|
||||
private readonly UpdateEmailHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public UpdateEmailHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new UpdateEmailHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_UpdatesEmailAndResetsConfirmation()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
Id = userId,
|
||||
Email = "old@example.com",
|
||||
EmailConfirmed = true,
|
||||
};
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.SetEmailAsync(user, "new@example.com"))
|
||||
.Callback<ApplicationUser, string>(
|
||||
(u, e) =>
|
||||
{
|
||||
u.Email = e;
|
||||
u.EmailConfirmed = false;
|
||||
}
|
||||
)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
UpdateEmailPayload result = await _handler.Handle(
|
||||
new UpdateEmailCommand(userId, "new@example.com"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.Email.Should().Be("new@example.com");
|
||||
result.EmailConfirmed.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<UpdateEmailPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateEmailCommand(userId, "new@example.com"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithDuplicateEmail_ThrowsConflictException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId, Email = "old@example.com" };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.SetEmailAsync(user, "taken@example.com"))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "DuplicateEmail", Description = "taken" }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<UpdateEmailPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateEmailCommand(userId, "taken@example.com"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdatePassword;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class UpdatePasswordHandlerTests
|
||||
{
|
||||
private readonly UpdatePasswordHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public UpdatePasswordHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new UpdatePasswordHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_ChangesPassword()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.ChangePasswordAsync(user, "OldPassword1!", "NewPassword1!"))
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
UpdatePasswordPayload result = await _handler.Handle(
|
||||
new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.ChangedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<UpdatePasswordPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdatePasswordCommand(userId, "OldPassword1!", "NewPassword1!"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithWrongCurrentPassword_ThrowsUnauthorizedException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.ChangePasswordAsync(user, "WrongPassword1!", "NewPassword1!"))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "PasswordMismatch", Description = "mismatch" }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<UpdatePasswordPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdatePasswordCommand(userId, "WrongPassword1!", "NewPassword1!"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateProfile;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class UpdateProfileHandlerTests
|
||||
{
|
||||
private readonly UpdateProfileHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public UpdateProfileHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new UpdateProfileHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_UpdatesProfileFields()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
Id = userId,
|
||||
FirstName = "Old",
|
||||
LastName = "Name",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
DateTime newDob = new(1991, 2, 3);
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
UpdateProfilePayload result = await _handler.Handle(
|
||||
new UpdateProfileCommand(userId, "New", "Person", newDob),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.FirstName.Should().Be("New");
|
||||
result.LastName.Should().Be("Person");
|
||||
result.DateOfBirth.Should().Be(newDob);
|
||||
_userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<UpdateProfilePayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateProfileCommand(userId, "New", "Person", DateTime.Today),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.UpdateUsername;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Commands;
|
||||
|
||||
public class UpdateUsernameHandlerTests
|
||||
{
|
||||
private readonly UpdateUsernameHandler _handler;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public UpdateUsernameHandlerTests()
|
||||
{
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_handler = new UpdateUsernameHandler(_userManagerMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidRequest_UpdatesUsername()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId, UserName = "old-name" };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.SetUserNameAsync(user, "new-name"))
|
||||
.Callback<ApplicationUser, string>((u, n) => u.UserName = n)
|
||||
.ReturnsAsync(IdentityResult.Success);
|
||||
|
||||
UpdateUsernamePayload result = await _handler.Handle(
|
||||
new UpdateUsernameCommand(userId, "new-name"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result.UserAccountId.Should().Be(userId);
|
||||
result.Username.Should().Be("new-name");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithNonExistentUser_ThrowsNotFoundException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<UpdateUsernamePayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateUsernameCommand(userId, "new-name"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithDuplicateUsername_ThrowsConflictException()
|
||||
{
|
||||
Guid userId = Guid.NewGuid();
|
||||
ApplicationUser user = new() { Id = userId, UserName = "old-name" };
|
||||
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.SetUserNameAsync(user, "taken-name"))
|
||||
.ReturnsAsync(
|
||||
IdentityResult.Failed(
|
||||
new IdentityError { Code = "DuplicateUserName", Description = "taken" }
|
||||
)
|
||||
);
|
||||
|
||||
Func<Task<UpdateUsernamePayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
new UpdateUsernameCommand(userId, "taken-name"),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
await act.Should().ThrowAsync<ConflictException>();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,26 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.Login;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Queries;
|
||||
|
||||
public class LoginHandlerTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
||||
private readonly LoginHandler _handler;
|
||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public LoginHandlerTests()
|
||||
{
|
||||
_authRepoMock = new Mock<IAuthRepository>();
|
||||
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
_tokenServiceMock = new Mock<ITokenService>();
|
||||
_handler = new LoginHandler(
|
||||
_authRepoMock.Object,
|
||||
_passwordInfraMock.Object,
|
||||
_tokenServiceMock.Object
|
||||
);
|
||||
_handler = new LoginHandler(_userManagerMock.Object, _tokenServiceMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -35,36 +29,25 @@ public class LoginHandlerTests
|
||||
const string username = "CogitoErgoSum";
|
||||
Guid userAccountId = Guid.NewGuid();
|
||||
|
||||
UserAccount userAccount = new()
|
||||
ApplicationUser user = new()
|
||||
{
|
||||
UserAccountId = userAccountId,
|
||||
Username = username,
|
||||
Id = userAccountId,
|
||||
UserName = username,
|
||||
FirstName = "René",
|
||||
LastName = "Descartes",
|
||||
Email = "r.descartes@example.com",
|
||||
DateOfBirth = new DateTime(1596, 03, 31),
|
||||
};
|
||||
|
||||
UserCredential userCredential = new()
|
||||
{
|
||||
UserCredentialId = Guid.NewGuid(),
|
||||
UserAccountId = userAccountId,
|
||||
Hash = "some-hash",
|
||||
Expiry = DateTime.MaxValue,
|
||||
};
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync(userCredential);
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(true);
|
||||
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||
_userManagerMock
|
||||
.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("access-token");
|
||||
_tokenServiceMock
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||
.Returns("refresh-token");
|
||||
|
||||
LoginPayload result = await _handler.Handle(
|
||||
@@ -83,16 +66,16 @@ public class LoginHandlerTests
|
||||
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "de_beauvoir";
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetUserByUsernameAsync(username))
|
||||
.ReturnsAsync((UserAccount?)null);
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByNameAsync(username))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||
_authRepoMock.Verify(
|
||||
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
|
||||
_userManagerMock.Verify(
|
||||
x => x.CheckPasswordAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
@@ -101,13 +84,10 @@ public class LoginHandlerTests
|
||||
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "BRussell";
|
||||
Guid userAccountId = Guid.NewGuid();
|
||||
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||
ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync((UserCredential?)null);
|
||||
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
||||
@@ -115,32 +95,16 @@ public class LoginHandlerTests
|
||||
await act.Should()
|
||||
.ThrowAsync<UnauthorizedException>()
|
||||
.WithMessage("Invalid username or password.");
|
||||
_passwordInfraMock.Verify(
|
||||
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
||||
Times.Never
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
|
||||
{
|
||||
const string username = "RCarnap";
|
||||
Guid userAccountId = Guid.NewGuid();
|
||||
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
||||
UserCredential userCredential = new()
|
||||
{
|
||||
UserCredentialId = Guid.NewGuid(),
|
||||
UserAccountId = userAccountId,
|
||||
Hash = "hashed-password",
|
||||
};
|
||||
ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
|
||||
|
||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
||||
_authRepoMock
|
||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
||||
.ReturnsAsync(userCredential);
|
||||
_passwordInfraMock
|
||||
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.Returns(false);
|
||||
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||
_userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
|
||||
|
||||
Func<Task<LoginPayload>> act = async () =>
|
||||
await _handler.Handle(
|
||||
|
||||
@@ -1,41 +1,45 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Services;
|
||||
|
||||
public class TokenServiceRefreshTests
|
||||
{
|
||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||
private readonly TokenService _tokenService;
|
||||
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||
|
||||
public TokenServiceRefreshTests()
|
||||
{
|
||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||
_userManagerMock = UserManagerMockFactory.Create();
|
||||
|
||||
// Set environment variables for tokens
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ACCESS_TOKEN_SECRET",
|
||||
"test-access-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"REFRESH_TOKEN_SECRET",
|
||||
"test-refresh-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"CONFIRMATION_TOKEN_SECRET",
|
||||
"test-confirmation-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["ACCESS_TOKEN_SECRET"] = "test-access-secret-that-is-very-long-1234567890",
|
||||
["REFRESH_TOKEN_SECRET"] = "test-refresh-secret-that-is-very-long-1234567890",
|
||||
["CONFIRMATION_TOKEN_SECRET"] =
|
||||
"test-confirmation-secret-that-is-very-long-1234567890",
|
||||
}
|
||||
)
|
||||
.Build();
|
||||
|
||||
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
_userManagerMock.Object,
|
||||
configuration
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -56,15 +60,7 @@ public class TokenServiceRefreshTests
|
||||
ClaimsIdentity claimsIdentity = new(claims);
|
||||
ClaimsPrincipal principal = new(claimsIdentity);
|
||||
|
||||
UserAccount userAccount = new()
|
||||
{
|
||||
UserAccountId = userId,
|
||||
Username = username,
|
||||
FirstName = "Test",
|
||||
LastName = "User",
|
||||
Email = "test@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
};
|
||||
ApplicationUser user = new() { Id = userId, UserName = username };
|
||||
|
||||
// Mock the validation of refresh token
|
||||
_tokenInfraMock
|
||||
@@ -78,19 +74,19 @@ public class TokenServiceRefreshTests
|
||||
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
|
||||
);
|
||||
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
|
||||
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||
|
||||
// Act
|
||||
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.UserAccount.UserAccountId.Should().Be(userId);
|
||||
result.UserAccount.Username.Should().Be(username);
|
||||
result.UserId.Should().Be(userId);
|
||||
result.Username.Should().Be(username);
|
||||
result.AccessToken.Should().NotBeEmpty();
|
||||
result.RefreshToken.Should().NotBeEmpty();
|
||||
|
||||
_authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
|
||||
_userManagerMock.Verify(x => x.FindByIdAsync(userId.ToString()), Times.Once);
|
||||
|
||||
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||
_tokenInfraMock.Verify(
|
||||
@@ -161,7 +157,9 @@ public class TokenServiceRefreshTests
|
||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||
.ReturnsAsync(principal);
|
||||
|
||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
||||
_userManagerMock
|
||||
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||
.ReturnsAsync((ApplicationUser?)null);
|
||||
|
||||
// Act & Assert
|
||||
await FluentActions
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Services;
|
||||
using Features.Auth.Tests.TestSupport;
|
||||
using FluentAssertions;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.Services;
|
||||
@@ -18,21 +19,23 @@ public class TokenServiceValidationTests
|
||||
{
|
||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||
|
||||
// Set environment variables for tokens
|
||||
Environment.SetEnvironmentVariable(
|
||||
"ACCESS_TOKEN_SECRET",
|
||||
"test-access-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"REFRESH_TOKEN_SECRET",
|
||||
"test-refresh-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
Environment.SetEnvironmentVariable(
|
||||
"CONFIRMATION_TOKEN_SECRET",
|
||||
"test-confirmation-secret-that-is-very-long-1234567890"
|
||||
);
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(
|
||||
new Dictionary<string, string?>
|
||||
{
|
||||
["ACCESS_TOKEN_SECRET"] = "test-access-secret-that-is-very-long-1234567890",
|
||||
["REFRESH_TOKEN_SECRET"] = "test-refresh-secret-that-is-very-long-1234567890",
|
||||
["CONFIRMATION_TOKEN_SECRET"] =
|
||||
"test-confirmation-secret-that-is-very-long-1234567890",
|
||||
}
|
||||
)
|
||||
.Build();
|
||||
|
||||
_tokenService = new TokenService(_tokenInfraMock.Object, new Mock<IAuthRepository>().Object);
|
||||
_tokenService = new TokenService(
|
||||
_tokenInfraMock.Object,
|
||||
UserManagerMockFactory.Create().Object,
|
||||
configuration
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
using Features.Auth.Identity;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Auth.Tests.TestSupport;
|
||||
|
||||
public static class UserManagerMockFactory
|
||||
{
|
||||
public static Mock<UserManager<ApplicationUser>> Create()
|
||||
{
|
||||
Mock<IUserStore<ApplicationUser>> storeMock = new();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
storeMock.Object,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!,
|
||||
null!
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.ConfirmUser;
|
||||
|
||||
@@ -11,8 +11,11 @@ namespace Features.Auth.Commands.ConfirmUser;
|
||||
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||
/// corresponding user account as confirmed.
|
||||
/// </summary>
|
||||
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
|
||||
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||
public class ConfirmUserHandler(
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IUserEmailStore<ApplicationUser> emailStore,
|
||||
ITokenService tokenService
|
||||
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||
{
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
||||
@@ -26,11 +29,15 @@ public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService to
|
||||
request.Token
|
||||
);
|
||||
|
||||
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
||||
|
||||
ApplicationUser? user = await userManager.FindByIdAsync(
|
||||
validatedToken.UserId.ToString()
|
||||
);
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
|
||||
await emailStore.SetEmailConfirmedAsync(user, true, cancellationToken);
|
||||
await userManager.UpdateAsync(user);
|
||||
|
||||
return new ConfirmationPayload(user.Id, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.DeleteAccount;
|
||||
|
||||
/// <summary>Permanently deletes the given user account.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record DeleteAccountCommand(Guid UserAccountId) : IRequest;
|
||||
@@ -0,0 +1,33 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.DeleteAccount;
|
||||
|
||||
/// <summary>Handles <see cref="DeleteAccountCommand" /> by deleting the user via <see cref="UserManager{TUser}" />.</summary>
|
||||
public class DeleteAccountHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<DeleteAccountCommand>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when the account cannot be deleted because other records (posts, comments, photos,
|
||||
/// follows) still reference it.
|
||||
/// </exception>
|
||||
public async Task Handle(DeleteAccountCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.DeleteAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "AccountHasDependentRecords"))
|
||||
throw new ConflictException(
|
||||
"Account cannot be deleted while it still has associated posts, comments, photos, or follows."
|
||||
);
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,35 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.Login;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="LoginCommand" /> by verifying credentials and issuing access/refresh tokens.
|
||||
/// Handles <see cref="LoginCommand" /> by verifying credentials via
|
||||
/// <see cref="UserManager{TUser}" /> and issuing access/refresh tokens.
|
||||
/// </summary>
|
||||
public class LoginHandler(
|
||||
IAuthRepository authRepo,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
ITokenService tokenService
|
||||
) : IRequestHandler<LoginCommand, LoginPayload>
|
||||
public class LoginHandler(UserManager<ApplicationUser> userManager, ITokenService tokenService)
|
||||
: IRequestHandler<LoginCommand, LoginPayload>
|
||||
{
|
||||
/// <exception cref="UnauthorizedException">
|
||||
/// Thrown when the username does not match any account, the account has no active credential,
|
||||
/// or the supplied password does not match the stored hash.
|
||||
/// Thrown when the username does not match any account or the supplied password does not match
|
||||
/// the stored hash.
|
||||
/// </exception>
|
||||
public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
UserAccount user =
|
||||
await authRepo.GetUserByUsernameAsync(request.Username)
|
||||
ApplicationUser user =
|
||||
await userManager.FindByNameAsync(request.Username)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
// @todo handle expired passwords
|
||||
UserCredential activeCred =
|
||||
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
|
||||
?? throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
|
||||
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||
throw new UnauthorizedException("Invalid username or password.");
|
||||
|
||||
string accessToken = tokenService.GenerateAccessToken(user);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(user);
|
||||
string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
|
||||
|
||||
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
|
||||
return new LoginPayload(user.Id, user.UserName, refreshToken, accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ using MediatR;
|
||||
namespace Features.Auth.Commands.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
||||
/// request body of <c>POST /api/auth/refresh</c>.
|
||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the
|
||||
/// <c>X-Refresh-Token</c> header of <c>POST /api/auth/refresh</c>.
|
||||
/// </summary>
|
||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||
|
||||
@@ -22,8 +22,8 @@ public class RefreshTokenHandler(ITokenService tokenService)
|
||||
{
|
||||
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||
return new LoginPayload(
|
||||
result.UserAccount.UserAccountId,
|
||||
result.UserAccount.Username,
|
||||
result.UserId,
|
||||
result.Username,
|
||||
result.RefreshToken,
|
||||
result.AccessToken
|
||||
);
|
||||
|
||||
@@ -1,75 +1,63 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
namespace Features.Auth.Commands.RegisterUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
|
||||
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
||||
/// confirmation email via Features.Emails.
|
||||
/// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" />
|
||||
/// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and
|
||||
/// attempts to send the registration confirmation email via Features.Emails.
|
||||
/// </summary>
|
||||
public class RegisterUserHandler(
|
||||
IAuthRepository authRepo,
|
||||
IPasswordInfrastructure passwordInfrastructure,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService,
|
||||
IMediator mediator
|
||||
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
|
||||
{
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// Thrown when an existing account already has the same username or email address, or when user
|
||||
/// creation otherwise fails.
|
||||
/// </exception>
|
||||
public async Task<RegistrationPayload> Handle(
|
||||
RegisterUserCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await ValidateUserDoesNotExist(request.Username, request.Email);
|
||||
|
||||
string hashed = passwordInfrastructure.Hash(request.Password);
|
||||
|
||||
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
||||
new UserAccount
|
||||
ApplicationUser user = new UserAccount
|
||||
{
|
||||
Username = request.Username,
|
||||
Email = request.Email,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
DateOfBirth = request.DateOfBirth,
|
||||
Email = request.Email,
|
||||
UserCredential = new UserCredential
|
||||
{
|
||||
Hash = hashed,
|
||||
}
|
||||
}
|
||||
);
|
||||
}.ToApplicationUser();
|
||||
|
||||
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||
IdentityResult result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code is "DuplicateUserName" or "DuplicateEmail"))
|
||||
throw new ConflictException("Username or email already exists");
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
|
||||
string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
|
||||
|
||||
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||
return new RegistrationPayload(
|
||||
createdUser.UserAccountId,
|
||||
createdUser.Username,
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
false
|
||||
);
|
||||
return new RegistrationPayload(user.Id, user.UserName, string.Empty, string.Empty, false);
|
||||
|
||||
bool emailSent = false;
|
||||
try
|
||||
{
|
||||
await mediator.Send(
|
||||
new SendRegistrationEmailCommand(
|
||||
createdUser.FirstName,
|
||||
createdUser.Email,
|
||||
confirmationToken
|
||||
),
|
||||
new SendRegistrationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||
cancellationToken
|
||||
);
|
||||
emailSent = true;
|
||||
@@ -81,24 +69,6 @@ public class RegisterUserHandler(
|
||||
// ignored
|
||||
}
|
||||
|
||||
return new RegistrationPayload(
|
||||
createdUser.UserAccountId,
|
||||
createdUser.Username,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
emailSent
|
||||
);
|
||||
}
|
||||
|
||||
/// <exception cref="ConflictException">
|
||||
/// Thrown when an existing account already has the same username or email address.
|
||||
/// </exception>
|
||||
private async Task ValidateUserDoesNotExist(string username, string email)
|
||||
{
|
||||
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
|
||||
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
|
||||
|
||||
if (existingUsername != null || existingEmail != null)
|
||||
throw new ConflictException("Username or email already exists");
|
||||
return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Domain.Entities;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Shared.Application.Emails;
|
||||
|
||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
@@ -15,7 +15,7 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||
/// or if the user's account is already verified.
|
||||
/// </remarks>
|
||||
public class ResendConfirmationEmailHandler(
|
||||
IAuthRepository authRepository,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ITokenService tokenService,
|
||||
IMediator mediator
|
||||
) : IRequestHandler<ResendConfirmationEmailCommand>
|
||||
@@ -25,14 +25,14 @@ public class ResendConfirmationEmailHandler(
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
|
||||
ApplicationUser? user = await userManager.FindByIdAsync(request.UserId.ToString());
|
||||
if (user == null)
|
||||
return; // Silent return to prevent user enumeration
|
||||
|
||||
if (await authRepository.IsUserVerifiedAsync(request.UserId))
|
||||
if (await userManager.IsEmailConfirmedAsync(user))
|
||||
return; // Already confirmed, no-op
|
||||
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
|
||||
await mediator.Send(
|
||||
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||
cancellationToken
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Changes the email address of the given user account. The account's <c>EmailConfirmed</c> status
|
||||
/// is reset to unconfirmed as a result.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateEmailCommand(Guid UserAccountId, string NewEmail) : IRequest<UpdateEmailPayload>;
|
||||
@@ -0,0 +1,39 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateEmailCommand" /> via <see cref="UserManager{TUser}" />.
|
||||
/// <see cref="UserManager{TUser}.SetEmailAsync" /> also resets <c>EmailConfirmed</c> to
|
||||
/// <see langword="false" />, so the user must re-confirm the new address (see
|
||||
/// <c>Features.Auth.Commands.ResendConfirmationEmail</c>).
|
||||
/// </summary>
|
||||
public class UpdateEmailHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateEmailCommand, UpdateEmailPayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the requested email address is already in use.</exception>
|
||||
public async Task<UpdateEmailPayload> Handle(
|
||||
UpdateEmailCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.SetEmailAsync(user, request.NewEmail);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "DuplicateEmail"))
|
||||
throw new ConflictException("Email address already exists");
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return new UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateEmail;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateEmailCommand" />, mirroring the email rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateEmailValidator : AbstractValidator<UpdateEmailCommand>
|
||||
{
|
||||
public UpdateEmailValidator()
|
||||
{
|
||||
RuleFor(x => x.NewEmail)
|
||||
.NotEmpty()
|
||||
.WithMessage("Email is required")
|
||||
.EmailAddress()
|
||||
.WithMessage("Invalid email format")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Email cannot exceed 128 characters");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>Changes the password of the given user account, verifying the current password first.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdatePasswordCommand(Guid UserAccountId, string CurrentPassword, string NewPassword)
|
||||
: IRequest<UpdatePasswordPayload>;
|
||||
@@ -0,0 +1,42 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdatePasswordCommand" /> via <see cref="UserManager{TUser}.ChangePasswordAsync" />,
|
||||
/// which verifies the current password before hashing and persisting the new one.
|
||||
/// </summary>
|
||||
public class UpdatePasswordHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdatePasswordCommand, UpdatePasswordPayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="UnauthorizedException">Thrown when the current password does not match.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
|
||||
public async Task<UpdatePasswordPayload> Handle(
|
||||
UpdatePasswordCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.ChangePasswordAsync(
|
||||
user,
|
||||
request.CurrentPassword,
|
||||
request.NewPassword
|
||||
);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "PasswordMismatch"))
|
||||
throw new UnauthorizedException("Current password is incorrect");
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return new UpdatePasswordPayload(user.Id, DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdatePassword;
|
||||
|
||||
/// <summary>Validates <see cref="UpdatePasswordCommand" />, mirroring the password rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdatePasswordValidator : AbstractValidator<UpdatePasswordCommand>
|
||||
{
|
||||
public UpdatePasswordValidator()
|
||||
{
|
||||
RuleFor(x => x.CurrentPassword).NotEmpty().WithMessage("Current password is required");
|
||||
|
||||
RuleFor(x => x.NewPassword)
|
||||
.NotEmpty()
|
||||
.WithMessage("New password is required")
|
||||
.MinimumLength(8)
|
||||
.WithMessage("Password must be at least 8 characters")
|
||||
.Matches("[A-Z]")
|
||||
.WithMessage("Password must contain at least one uppercase letter")
|
||||
.Matches("[a-z]")
|
||||
.WithMessage("Password must contain at least one lowercase letter")
|
||||
.Matches("[0-9]")
|
||||
.WithMessage("Password must contain at least one number")
|
||||
.Matches("[^a-zA-Z0-9]")
|
||||
.WithMessage("Password must contain at least one special character")
|
||||
.NotEqual(x => x.CurrentPassword)
|
||||
.WithMessage("New password must be different from the current password");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Updates the non-credential profile fields (first name, last name, date of birth) of the given
|
||||
/// user account.
|
||||
/// </summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateProfileCommand(
|
||||
Guid UserAccountId,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime DateOfBirth
|
||||
) : IRequest<UpdateProfilePayload>;
|
||||
@@ -0,0 +1,38 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateProfileCommand" /> via <see cref="UserManager{TUser}" />. First/last name
|
||||
/// and date of birth aren't tracked by any Identity-specific store interface, so the mutated
|
||||
/// <see cref="ApplicationUser" /> is saved directly through <see cref="UserManager{TUser}.UpdateAsync" />.
|
||||
/// </summary>
|
||||
public class UpdateProfileHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateProfileCommand, UpdateProfilePayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the update otherwise fails.</exception>
|
||||
public async Task<UpdateProfilePayload> Handle(
|
||||
UpdateProfileCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
user.FirstName = request.FirstName;
|
||||
user.LastName = request.LastName;
|
||||
user.DateOfBirth = request.DateOfBirth;
|
||||
|
||||
IdentityResult result = await userManager.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
|
||||
return new UpdateProfilePayload(user.Id, user.FirstName, user.LastName, user.DateOfBirth);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateProfile;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateProfileCommand" />, mirroring the profile rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateProfileValidator : AbstractValidator<UpdateProfileCommand>
|
||||
{
|
||||
public UpdateProfileValidator()
|
||||
{
|
||||
RuleFor(x => x.FirstName)
|
||||
.NotEmpty()
|
||||
.WithMessage("First name is required")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("First name cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.LastName)
|
||||
.NotEmpty()
|
||||
.WithMessage("Last name is required")
|
||||
.MaximumLength(128)
|
||||
.WithMessage("Last name cannot exceed 128 characters");
|
||||
|
||||
RuleFor(x => x.DateOfBirth)
|
||||
.NotEmpty()
|
||||
.WithMessage("Date of birth is required")
|
||||
.LessThan(DateTime.Today.AddYears(-19))
|
||||
.WithMessage("You must be at least 19 years old");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Changes the username of the given user account.</summary>
|
||||
/// <param name="UserAccountId">
|
||||
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
|
||||
/// client-supplied input.
|
||||
/// </param>
|
||||
public record UpdateUsernameCommand(Guid UserAccountId, string NewUsername)
|
||||
: IRequest<UpdateUsernamePayload>;
|
||||
@@ -0,0 +1,34 @@
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Dtos;
|
||||
using Features.Auth.Identity;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Handles <see cref="UpdateUsernameCommand" /> via <see cref="UserManager{TUser}" />.</summary>
|
||||
public class UpdateUsernameHandler(UserManager<ApplicationUser> userManager)
|
||||
: IRequestHandler<UpdateUsernameCommand, UpdateUsernamePayload>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when the account does not exist.</exception>
|
||||
/// <exception cref="ConflictException">Thrown when the requested username is already taken.</exception>
|
||||
public async Task<UpdateUsernamePayload> Handle(
|
||||
UpdateUsernameCommand request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ApplicationUser user =
|
||||
await userManager.FindByIdAsync(request.UserAccountId.ToString())
|
||||
?? throw new NotFoundException("User account not found");
|
||||
|
||||
IdentityResult result = await userManager.SetUserNameAsync(user, request.NewUsername);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
if (result.Errors.Any(e => e.Code == "DuplicateUserName"))
|
||||
throw new ConflictException("Username already exists");
|
||||
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
return new UpdateUsernamePayload(user.Id, user.UserName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace Features.Auth.Commands.UpdateUsername;
|
||||
|
||||
/// <summary>Validates <see cref="UpdateUsernameCommand" />, mirroring the username rules in <c>RegisterUserValidator</c>.</summary>
|
||||
public class UpdateUsernameValidator : AbstractValidator<UpdateUsernameCommand>
|
||||
{
|
||||
public UpdateUsernameValidator()
|
||||
{
|
||||
RuleFor(x => x.NewUsername)
|
||||
.NotEmpty()
|
||||
.WithMessage("Username is required")
|
||||
.Length(3, 64)
|
||||
.WithMessage("Username must be between 3 and 64 characters")
|
||||
.Matches("^[a-zA-Z0-9._-]+$")
|
||||
.WithMessage(
|
||||
"Username can only contain letters, numbers, dots, underscores, and hyphens"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Commands.ConfirmUser;
|
||||
using Features.Auth.Commands.DeleteAccount;
|
||||
using Features.Auth.Commands.Login;
|
||||
using Features.Auth.Commands.RefreshToken;
|
||||
using Features.Auth.Commands.RegisterUser;
|
||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||
using Features.Auth.Commands.UpdateEmail;
|
||||
using Features.Auth.Commands.UpdatePassword;
|
||||
using Features.Auth.Commands.UpdateProfile;
|
||||
using Features.Auth.Commands.UpdateUsername;
|
||||
using Features.Auth.Dtos;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
@@ -12,7 +19,8 @@ using Shared.Contracts;
|
||||
namespace Features.Auth.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
|
||||
/// Handles user authentication concerns: registration, login, email confirmation, token refresh, and
|
||||
/// self-service account management.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
|
||||
@@ -23,6 +31,18 @@ namespace Features.Auth.Controllers;
|
||||
[Authorize(AuthenticationSchemes = "JWT")]
|
||||
public class AuthController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts the authenticated caller's user ID from the validated access token's <c>sub</c> claim.
|
||||
/// </summary>
|
||||
/// <exception cref="UnauthorizedException">Thrown when the claim is missing or malformed.</exception>
|
||||
private Guid GetAuthenticatedUserId()
|
||||
{
|
||||
string? userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||
if (!Guid.TryParse(userIdClaim, out Guid userId))
|
||||
throw new UnauthorizedException("Access token is missing a valid user ID claim");
|
||||
return userId;
|
||||
}
|
||||
|
||||
/// <summary>Registers a new user account.</summary>
|
||||
/// <remarks>
|
||||
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
|
||||
@@ -98,16 +118,19 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
|
||||
/// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary>
|
||||
/// <remarks>
|
||||
/// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is
|
||||
/// invalid or expired.
|
||||
/// Anonymous access. The refresh token is supplied via the <c>X-Refresh-Token</c> header, not the
|
||||
/// request body. Returns 200 OK with the new tokens, 400 Bad Request if the header is missing, or
|
||||
/// 401 Unauthorized if the refresh token is invalid or expired.
|
||||
/// </remarks>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("refresh")]
|
||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
|
||||
[FromBody] RefreshTokenCommand command
|
||||
[FromHeader(Name = "X-Refresh-Token")] string? refreshToken
|
||||
)
|
||||
{
|
||||
LoginPayload payload = await mediator.Send(command);
|
||||
LoginPayload payload = await mediator.Send(
|
||||
new RefreshTokenCommand(refreshToken ?? string.Empty)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<LoginPayload>
|
||||
{
|
||||
@@ -116,4 +139,107 @@ public class AuthController(IMediator mediator) : ControllerBase
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Changes the authenticated user's username.</summary>
|
||||
/// <remarks>Returns 200 OK with the updated username, or 409 Conflict if it is already taken.</remarks>
|
||||
[HttpPatch("username")]
|
||||
public async Task<ActionResult<ResponseBody<UpdateUsernamePayload>>> UpdateUsername(
|
||||
[FromBody] UpdateUsernameRequest request
|
||||
)
|
||||
{
|
||||
UpdateUsernamePayload payload = await mediator.Send(
|
||||
new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<UpdateUsernamePayload>
|
||||
{
|
||||
Message = "Username updated successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Changes the authenticated user's email address.</summary>
|
||||
/// <remarks>
|
||||
/// Returns 200 OK with the updated (now unconfirmed) email address, or 409 Conflict if it is
|
||||
/// already in use. The caller should use <c>POST /api/auth/confirm/resend</c> to re-confirm.
|
||||
/// </remarks>
|
||||
[HttpPatch("email")]
|
||||
public async Task<ActionResult<ResponseBody<UpdateEmailPayload>>> UpdateEmail(
|
||||
[FromBody] UpdateEmailRequest request
|
||||
)
|
||||
{
|
||||
UpdateEmailPayload payload = await mediator.Send(
|
||||
new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<UpdateEmailPayload>
|
||||
{
|
||||
Message = "Email updated successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Changes the authenticated user's password.</summary>
|
||||
/// <remarks>
|
||||
/// Returns 200 OK on success, or 401 Unauthorized if <c>CurrentPassword</c> does not match the
|
||||
/// account's current password.
|
||||
/// </remarks>
|
||||
[HttpPatch("password")]
|
||||
public async Task<ActionResult<ResponseBody<UpdatePasswordPayload>>> UpdatePassword(
|
||||
[FromBody] UpdatePasswordRequest request
|
||||
)
|
||||
{
|
||||
UpdatePasswordPayload payload = await mediator.Send(
|
||||
new UpdatePasswordCommand(
|
||||
GetAuthenticatedUserId(),
|
||||
request.CurrentPassword,
|
||||
request.NewPassword
|
||||
)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<UpdatePasswordPayload>
|
||||
{
|
||||
Message = "Password updated successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Updates the authenticated user's profile fields (first name, last name, date of birth).</summary>
|
||||
/// <remarks>Returns 200 OK with the updated profile.</remarks>
|
||||
[HttpPatch("profile")]
|
||||
public async Task<ActionResult<ResponseBody<UpdateProfilePayload>>> UpdateProfile(
|
||||
[FromBody] UpdateProfileRequest request
|
||||
)
|
||||
{
|
||||
UpdateProfilePayload payload = await mediator.Send(
|
||||
new UpdateProfileCommand(
|
||||
GetAuthenticatedUserId(),
|
||||
request.FirstName,
|
||||
request.LastName,
|
||||
request.DateOfBirth
|
||||
)
|
||||
);
|
||||
return Ok(
|
||||
new ResponseBody<UpdateProfilePayload>
|
||||
{
|
||||
Message = "Profile updated successfully.",
|
||||
Payload = payload,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Permanently deletes the authenticated user's account.</summary>
|
||||
/// <remarks>
|
||||
/// Returns 200 OK on success, or 409 Conflict if the account still has associated posts, comments,
|
||||
/// photos, or follows.
|
||||
/// </remarks>
|
||||
[HttpDelete("account")]
|
||||
public async Task<ActionResult<ResponseBody>> DeleteAccount()
|
||||
{
|
||||
await mediator.Send(new DeleteAccountCommand(GetAuthenticatedUserId()));
|
||||
return Ok(new ResponseBody { Message = "Account deleted successfully." });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Features.Auth.Services;
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Features.Auth.DependencyInjection;
|
||||
@@ -9,14 +10,44 @@ namespace Features.Auth.DependencyInjection;
|
||||
public static class FeaturesAuthServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds <see cref="IAuthRepository" />, <see cref="ITokenService" />, and the Argon2 password hashing
|
||||
/// infrastructure as scoped services.
|
||||
/// Adds <see cref="ITokenService" /> and ASP.NET Core Identity (<see cref="UserManager{TUser}" />
|
||||
/// backed by <see cref="DapperUserStore" /> and <see cref="Argon2PasswordHasher" />) as scoped services.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IAuthRepository, AuthRepository>();
|
||||
services.AddScoped<ITokenService, TokenService>();
|
||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
||||
|
||||
// AddDefaultTokenProviders() is deliberately not called: it registers
|
||||
// DataProtectorTokenProvider<TUser>, which UserManager's constructor resolves eagerly and which
|
||||
// requires IDataProtectionProvider -- not registered in the plain-console-app DI containers this
|
||||
// solution builds (e.g. Database.Seed). Identity's own token providers are unused here anyway;
|
||||
// account confirmation keeps using its own JWT-based tokens (see ITokenService).
|
||||
services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = true;
|
||||
|
||||
// Password/username complexity is already enforced by RegisterUserValidator
|
||||
// (FluentValidation); disable Identity's own checks to avoid duplicated/drifting rules.
|
||||
options.Password.RequireDigit = false;
|
||||
options.Password.RequireLowercase = false;
|
||||
options.Password.RequireUppercase = false;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
options.Password.RequiredLength = 1;
|
||||
});
|
||||
|
||||
services.AddScoped<DapperUserStore>();
|
||||
services.AddScoped<IUserStore<ApplicationUser>>(sp =>
|
||||
sp.GetRequiredService<DapperUserStore>()
|
||||
);
|
||||
services.AddScoped<IUserEmailStore<ApplicationUser>>(sp =>
|
||||
sp.GetRequiredService<DapperUserStore>()
|
||||
);
|
||||
services.AddScoped<IUserPasswordStore<ApplicationUser>>(sp =>
|
||||
sp.GetRequiredService<DapperUserStore>()
|
||||
);
|
||||
services.AddScoped<IPasswordHasher<ApplicationUser>, Argon2PasswordHasher>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,48 @@ public record RegistrationPayload(
|
||||
|
||||
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
|
||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||
|
||||
/// <summary>Request body for changing the authenticated user's username.</summary>
|
||||
/// <param name="NewUsername">
|
||||
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
|
||||
/// underscores, and hyphens.
|
||||
/// </param>
|
||||
public record UpdateUsernameRequest(string NewUsername);
|
||||
|
||||
/// <summary>Payload returned to the client after a username change.</summary>
|
||||
public record UpdateUsernamePayload(Guid UserAccountId, string Username);
|
||||
|
||||
/// <summary>Request body for changing the authenticated user's email address.</summary>
|
||||
/// <param name="NewEmail">The new email address; up to 128 characters and must be a valid email format.</param>
|
||||
public record UpdateEmailRequest(string NewEmail);
|
||||
|
||||
/// <summary>
|
||||
/// Payload returned to the client after an email change. The new address is unconfirmed until the
|
||||
/// user completes the confirmation flow again.
|
||||
/// </summary>
|
||||
public record UpdateEmailPayload(Guid UserAccountId, string Email, bool EmailConfirmed);
|
||||
|
||||
/// <summary>Request body for updating the authenticated user's profile fields.</summary>
|
||||
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
|
||||
/// <param name="LastName">The user's last name; up to 128 characters.</param>
|
||||
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
|
||||
public record UpdateProfileRequest(string FirstName, string LastName, DateTime DateOfBirth);
|
||||
|
||||
/// <summary>Payload returned to the client after a profile update.</summary>
|
||||
public record UpdateProfilePayload(
|
||||
Guid UserAccountId,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime DateOfBirth
|
||||
);
|
||||
|
||||
/// <summary>Request body for changing the authenticated user's password.</summary>
|
||||
/// <param name="CurrentPassword">The user's current plaintext password, verified before the change is applied.</param>
|
||||
/// <param name="NewPassword">
|
||||
/// The desired new plaintext password; must be at least 8 characters and contain an uppercase letter, a
|
||||
/// lowercase letter, a number, and a special character.
|
||||
/// </param>
|
||||
public record UpdatePasswordRequest(string CurrentPassword, string NewPassword);
|
||||
|
||||
/// <summary>Payload returned to the client after a password change.</summary>
|
||||
public record UpdatePasswordPayload(Guid UserAccountId, DateTime ChangedAt);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The user type driving <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" /> for
|
||||
/// Features.Auth. A plain POCO (not <c>IdentityUser<TKey></c>) backed by <see cref="DapperUserStore" />
|
||||
/// rather than EF Core, matching the rest of the solution's Dapper-only persistence.
|
||||
/// </summary>
|
||||
public sealed class ApplicationUser
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string? NormalizedUserName { get; set; }
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string? NormalizedEmail { get; set; }
|
||||
public bool EmailConfirmed { get; set; }
|
||||
|
||||
/// <summary>The current Argon2 hash, or <see langword="null" /> if the account has no password set.</summary>
|
||||
public string? PasswordHash { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
public DateTime DateOfBirth { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Infrastructure.PasswordHashing;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>Adapts <see cref="IPasswordInfrastructure" />'s Argon2 hashing to <see cref="IPasswordHasher{TUser}" />.</summary>
|
||||
public sealed class Argon2PasswordHasher(IPasswordInfrastructure passwordInfrastructure)
|
||||
: IPasswordHasher<ApplicationUser>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string HashPassword(ApplicationUser user, string password) =>
|
||||
passwordInfrastructure.Hash(password);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Never returns <see cref="PasswordVerificationResult.SuccessRehashNeeded" />: Argon2 parameters are
|
||||
/// fixed by <see cref="IPasswordInfrastructure" />, so <see cref="UserManager{TUser}" />'s automatic
|
||||
/// rehash-on-verify path never triggers.
|
||||
/// </remarks>
|
||||
public PasswordVerificationResult VerifyHashedPassword(
|
||||
ApplicationUser user,
|
||||
string hashedPassword,
|
||||
string providedPassword
|
||||
) =>
|
||||
passwordInfrastructure.Verify(providedPassword, hashedPassword)
|
||||
? PasswordVerificationResult.Success
|
||||
: PasswordVerificationResult.Failed;
|
||||
}
|
||||
509
web/backend/Features/Features.Auth/Identity/DapperUserStore.cs
Normal file
509
web/backend/Features/Features.Auth/Identity/DapperUserStore.cs
Normal file
@@ -0,0 +1,509 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Infrastructure.Sql;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of the ASP.NET Core Identity store interfaces consumed by
|
||||
/// <see cref="UserManager{TUser}" />, persisting against the existing <c>dbo.UserAccount</c>,
|
||||
/// <c>dbo.UserCredential</c>, and <c>dbo.UserVerification</c> tables. No EF Core is used, and no schema
|
||||
/// changes were required: normalized name/email are computed on the fly rather than persisted (the
|
||||
/// existing unique indexes are already case-insensitive under SQL Server's default collation).
|
||||
/// </summary>
|
||||
public sealed class DapperUserStore(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<ApplicationUser>(connectionFactory),
|
||||
IUserStore<ApplicationUser>,
|
||||
IUserPasswordStore<ApplicationUser>,
|
||||
IUserEmailStore<ApplicationUser>
|
||||
{
|
||||
private const string SelectUserSql =
|
||||
"""
|
||||
SELECT UserAccountID AS Id, Username AS UserName, FirstName, LastName, Email, DateOfBirth
|
||||
FROM dbo.UserAccount
|
||||
""";
|
||||
|
||||
/// <remarks>No unmanaged resources to release; each method opens and disposes its own connection.</remarks>
|
||||
public void Dispose() { }
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> GetUserIdAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(user.Id.ToString());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetUserNameAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<string?>(user.UserName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetUserNameAsync(
|
||||
ApplicationUser user,
|
||||
string? userName,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.UserName = userName ?? string.Empty;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetNormalizedUserNameAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.NormalizedUserName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetNormalizedUserNameAsync(
|
||||
ApplicationUser user,
|
||||
string? normalizedName,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.NormalizedUserName = normalizedName;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Inserts the account and its initial credential within a single database transaction, mirroring the
|
||||
/// previous <c>AuthRepository.RegisterUserAsync</c> behavior. <see cref="UserManager{TUser}" /> already
|
||||
/// checks username/email uniqueness before calling this method, so the duplicate-key branch here only
|
||||
/// guards against a genuine race between two concurrent registrations.
|
||||
/// </remarks>
|
||||
public async Task<IdentityResult> CreateAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync(
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
Guid userAccountId = await connection.ExecuteScalarAsync<Guid>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email)
|
||||
OUTPUT INSERTED.UserAccountID
|
||||
VALUES (@UserName, @FirstName, @LastName, @DateOfBirth, @Email);
|
||||
""",
|
||||
new
|
||||
{
|
||||
user.UserName,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.DateOfBirth,
|
||||
user.Email,
|
||||
},
|
||||
transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||
VALUES (@UserAccountId, @Hash);
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Hash = user.PasswordHash },
|
||||
transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
user.Id = userAccountId;
|
||||
}
|
||||
catch (SqlException ex) when (IsDuplicateKeyViolation(ex))
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
return IdentityResult.Failed(
|
||||
new IdentityError
|
||||
{
|
||||
Code = "DuplicateUserName",
|
||||
Description = "Username or email already exists.",
|
||||
}
|
||||
);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Persists profile field changes and keeps <c>dbo.UserVerification</c> in sync with
|
||||
/// <see cref="ApplicationUser.EmailConfirmed" />: a row is inserted (idempotently, matching the
|
||||
/// previous <c>AuthRepository.ConfirmUserAccountAsync</c> behavior) when
|
||||
/// <see langword="true" />, and removed when <see langword="false" /> -- notably after
|
||||
/// <see cref="UserManager{TUser}.SetEmailAsync" /> resets confirmation on an email change.
|
||||
/// </remarks>
|
||||
public async Task<IdentityResult> UpdateAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserAccount
|
||||
SET Username = @UserName, FirstName = @FirstName, LastName = @LastName,
|
||||
Email = @Email, DateOfBirth = @DateOfBirth, UpdatedAt = GETDATE()
|
||||
WHERE UserAccountID = @Id
|
||||
""",
|
||||
new
|
||||
{
|
||||
user.Id,
|
||||
user.UserName,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Email,
|
||||
user.DateOfBirth,
|
||||
},
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (user.EmailConfirmed)
|
||||
await EnsureVerificationRowAsync(connection, user.Id, cancellationToken);
|
||||
else
|
||||
await RemoveVerificationRowAsync(connection, user.Id, cancellationToken);
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A plain <c>DELETE</c> against <c>dbo.UserAccount</c>: its foreign keys from
|
||||
/// <c>dbo.UserCredential</c>, <c>dbo.UserVerification</c>, and <c>dbo.UserAvatar</c> cascade, so no
|
||||
/// manual child cleanup is needed. Other tables (posts, comments, photos, follows) reference the
|
||||
/// account with <c>ON DELETE NO ACTION</c> by design; deleting an account that still has any of
|
||||
/// those fails with a foreign key violation, which is surfaced as a failed
|
||||
/// <see cref="IdentityResult" /> rather than an unhandled <see cref="SqlException" />.
|
||||
/// </remarks>
|
||||
public async Task<IdentityResult> DeleteAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"DELETE FROM dbo.UserAccount WHERE UserAccountID = @Id",
|
||||
new { user.Id },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (SqlException ex) when (IsForeignKeyViolation(ex))
|
||||
{
|
||||
return IdentityResult.Failed(
|
||||
new IdentityError
|
||||
{
|
||||
Code = "AccountHasDependentRecords",
|
||||
Description =
|
||||
"Account cannot be deleted because other records still reference it.",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return IdentityResult.Success;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByIdAsync(
|
||||
string userId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
if (!Guid.TryParse(userId, out Guid id))
|
||||
return null;
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync<ApplicationUser>(
|
||||
new CommandDefinition(
|
||||
$"{SelectUserSql} WHERE UserAccountID = @Id",
|
||||
new { Id = id },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (user != null)
|
||||
await HydrateAsync(connection, user, cancellationToken);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByNameAsync(
|
||||
string normalizedUserName,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync<ApplicationUser>(
|
||||
new CommandDefinition(
|
||||
$"{SelectUserSql} WHERE Username = @UserName",
|
||||
new { UserName = normalizedUserName },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (user != null)
|
||||
await HydrateAsync(connection, user, cancellationToken);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// For an existing account (<see cref="ApplicationUser.Id" /> already assigned), rotates the
|
||||
/// credential transactionally -- revoking the previous <c>dbo.UserCredential</c> row(s) and inserting
|
||||
/// the new hash -- mirroring the previous <c>AuthRepository.RotateCredentialAsync</c> behavior. During
|
||||
/// registration <see cref="UserManager{TUser}" /> calls this before the account has an ID (i.e. before
|
||||
/// <see cref="CreateAsync" /> runs); in that case only the in-memory hash is set, and
|
||||
/// <see cref="CreateAsync" /> writes the initial credential itself as part of its own transaction.
|
||||
/// </remarks>
|
||||
public async Task SetPasswordHashAsync(
|
||||
ApplicationUser user,
|
||||
string? passwordHash,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
string? previousHash = user.PasswordHash;
|
||||
user.PasswordHash = passwordHash;
|
||||
|
||||
if (user.Id == Guid.Empty || passwordHash == null || passwordHash == previousHash)
|
||||
return;
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync(
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserCredential
|
||||
SET IsRevoked = 1, RevokedAt = GETDATE()
|
||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
|
||||
""",
|
||||
new { UserAccountId = user.Id },
|
||||
transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||
VALUES (@UserAccountId, @Hash);
|
||||
""",
|
||||
new { UserAccountId = user.Id, Hash = passwordHash },
|
||||
transaction,
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetPasswordHashAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.PasswordHash);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> HasPasswordAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult(user.PasswordHash != null);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetEmailAsync(
|
||||
ApplicationUser user,
|
||||
string? email,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.Email = email ?? string.Empty;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetEmailAsync(ApplicationUser user, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<string?>(user.Email);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> GetEmailConfirmedAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.EmailConfirmed);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetEmailConfirmedAsync(
|
||||
ApplicationUser user,
|
||||
bool confirmed,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.EmailConfirmed = confirmed;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApplicationUser?> FindByEmailAsync(
|
||||
string normalizedEmail,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
ApplicationUser? user = await connection.QueryFirstOrDefaultAsync<ApplicationUser>(
|
||||
new CommandDefinition(
|
||||
$"{SelectUserSql} WHERE Email = @Email",
|
||||
new { Email = normalizedEmail },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (user != null)
|
||||
await HydrateAsync(connection, user, cancellationToken);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string?> GetNormalizedEmailAsync(
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
) => Task.FromResult(user.NormalizedEmail);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetNormalizedEmailAsync(
|
||||
ApplicationUser user,
|
||||
string? normalizedEmail,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.NormalizedEmail = normalizedEmail;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Loads the active credential hash and verification status onto a freshly queried user.</summary>
|
||||
private static async Task HydrateAsync(
|
||||
DbConnection connection,
|
||||
ApplicationUser user,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
user.PasswordHash = await connection.ExecuteScalarAsync<string?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT Hash
|
||||
FROM dbo.UserCredential
|
||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
|
||||
""",
|
||||
new { UserAccountId = user.Id },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
int? verified = await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT TOP 1 1
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = user.Id },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
user.EmailConfirmed = verified.HasValue;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// If a concurrent request verifies the same user first, the resulting duplicate-key SQL exception
|
||||
/// (error 2601/2627) is swallowed to keep the operation idempotent.
|
||||
/// </remarks>
|
||||
private static async Task EnsureVerificationRowAsync(
|
||||
DbConnection connection,
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
int? alreadyVerified = await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT TOP 1 1
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
if (alreadyVerified.HasValue)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime)
|
||||
VALUES (@UserAccountId, GETDATE())
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (SqlException ex) when (IsDuplicateKeyViolation(ex))
|
||||
{
|
||||
// A concurrent request verified this user first. Keep behavior idempotent.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes the <c>dbo.UserVerification</c> row for a user, if one exists.</summary>
|
||||
private static Task RemoveVerificationRowAsync(
|
||||
DbConnection connection,
|
||||
Guid userAccountId,
|
||||
CancellationToken cancellationToken
|
||||
) =>
|
||||
connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"DELETE FROM dbo.UserVerification WHERE UserAccountID = @UserAccountId",
|
||||
new { UserAccountId = userAccountId },
|
||||
cancellationToken: cancellationToken
|
||||
)
|
||||
);
|
||||
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
||||
private static bool IsDuplicateKeyViolation(SqlException ex) => ex.Number is 2601 or 2627;
|
||||
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a foreign key constraint violation (SQL Server error 547).</summary>
|
||||
private static bool IsForeignKeyViolation(SqlException ex) => ex.Number == 547;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Converts the persistence-agnostic <see cref="UserAccount" /> domain entity into the
|
||||
/// <see cref="ApplicationUser" /> shape required by <see cref="Microsoft.AspNetCore.Identity.UserManager{TUser}" />.
|
||||
/// Kept here, in Features.Auth, rather than on <see cref="UserAccount" /> itself so the domain layer has
|
||||
/// no dependency on ASP.NET Core Identity.
|
||||
/// </summary>
|
||||
public static class UserAccountExtensions
|
||||
{
|
||||
public static ApplicationUser ToApplicationUser(this UserAccount userAccount) =>
|
||||
new()
|
||||
{
|
||||
Id = userAccount.UserAccountId,
|
||||
UserName = userAccount.Username,
|
||||
Email = userAccount.Email,
|
||||
FirstName = userAccount.FirstName,
|
||||
LastName = userAccount.LastName,
|
||||
DateOfBirth = userAccount.DateOfBirth,
|
||||
};
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
using System.Data.Common;
|
||||
using Dapper;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Infrastructure.Sql;
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Dapper-based implementation of <see cref="IAuthRepository" />, handling user registration,
|
||||
/// credential lookup/rotation, and account verification.
|
||||
/// </summary>
|
||||
public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
||||
: Repository<UserAccount>(connectionFactory),
|
||||
IAuthRepository
|
||||
{
|
||||
/// <inheritdoc />
|
||||
/// <remarks>The account and credential are inserted within a single database transaction.</remarks>
|
||||
public async Task<UserAccount> RegisterUserAsync(UserAccount ua)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
|
||||
Guid userAccountId;
|
||||
|
||||
try
|
||||
{
|
||||
userAccountId = await connection.ExecuteScalarAsync<Guid>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserAccount (Username, FirstName, LastName, DateOfBirth, Email)
|
||||
OUTPUT INSERTED.UserAccountID
|
||||
VALUES (@Username, @FirstName, @LastName, @DateOfBirth, @Email);
|
||||
""",
|
||||
new { ua.Username, ua.FirstName, ua.LastName, ua.DateOfBirth, ua.Email},
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
int credentialRows = await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||
VALUES (@UserAccountId, @Hash);
|
||||
""",
|
||||
new { UserAccountId = userAccountId, ua.UserCredential?.Hash },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
if (credentialRows == 0)
|
||||
throw new InvalidOperationException("Failed to create user credential.");
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
|
||||
// perform round trip to get new User entity
|
||||
return await GetUserByIdAsync(userAccountId)
|
||||
?? throw new Exception("Failed to retrieve newly registered user.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByEmailAsync(string email)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE Email = @Email
|
||||
""",
|
||||
new { Email = email }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByUsernameAsync(string username)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE Username = @Username
|
||||
""",
|
||||
new { Username = username }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserCredential>(
|
||||
"""
|
||||
SELECT UserCredentialId, UserAccountId, Hash, CreatedAt, RowVersion
|
||||
FROM dbo.UserCredential
|
||||
WHERE UserAccountId = @UserAccountId AND IsRevoked = 0
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
await using DbTransaction transaction = await connection.BeginTransactionAsync();
|
||||
|
||||
try
|
||||
{
|
||||
bool exists =
|
||||
await connection.ExecuteScalarAsync<int?>(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
transaction
|
||||
)
|
||||
) is not null;
|
||||
|
||||
if (!exists)
|
||||
throw new NotFoundException("User account not found.");
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
UPDATE dbo.UserCredential
|
||||
SET IsRevoked = 1, RevokedAt = GETDATE()
|
||||
WHERE UserAccountId = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
await connection.ExecuteAsync(
|
||||
new CommandDefinition(
|
||||
"""
|
||||
INSERT INTO dbo.UserCredential (UserAccountId, Hash)
|
||||
VALUES (@UserAccountId, @Hash)
|
||||
""",
|
||||
new { UserAccountId = userAccountId, Hash = newPasswordHash },
|
||||
transaction
|
||||
)
|
||||
);
|
||||
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount?> GetUserByIdAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
|
||||
"""
|
||||
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
|
||||
FROM dbo.UserAccount
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Confirmation is recorded by inserting a row into <c>dbo.UserVerification</c>. If a concurrent
|
||||
/// request verifies the same user first, the resulting duplicate-key SQL exception (error 2601/2627)
|
||||
/// is swallowed to keep the operation idempotent.
|
||||
/// </remarks>
|
||||
/// <exception cref="Microsoft.Data.SqlClient.SqlException">
|
||||
/// Thrown when the database command fails for a reason other than a duplicate verification record.
|
||||
/// </exception>
|
||||
public async Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId)
|
||||
{
|
||||
UserAccount? user = await GetUserByIdAsync(userAccountId);
|
||||
if (user == null)
|
||||
return null;
|
||||
|
||||
// Idempotency: if already verified, treat as successful confirmation.
|
||||
if (await IsUserVerifiedAsync(userAccountId))
|
||||
return user;
|
||||
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
|
||||
try
|
||||
{
|
||||
await connection.ExecuteAsync(
|
||||
"""
|
||||
INSERT INTO dbo.UserVerification (UserAccountId, VerificationDateTime)
|
||||
VALUES (@UserAccountId, GETDATE())
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
}
|
||||
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
|
||||
{
|
||||
// A concurrent request verified this user first. Keep behavior idempotent.
|
||||
}
|
||||
|
||||
// Fetch and return the updated user
|
||||
return await GetUserByIdAsync(userAccountId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||
{
|
||||
await using DbConnection connection = await CreateConnection();
|
||||
int? result = await connection.ExecuteScalarAsync<int?>(
|
||||
"""
|
||||
SELECT TOP 1 1
|
||||
FROM dbo.UserVerification
|
||||
WHERE UserAccountID = @UserAccountId
|
||||
""",
|
||||
new { UserAccountId = userAccountId }
|
||||
);
|
||||
return result.HasValue;
|
||||
}
|
||||
|
||||
/// <summary>Determines whether a <see cref="SqlException" /> is a duplicate key violation (SQL Server error 2601 or 2627).</summary>
|
||||
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||
{
|
||||
// 2601/2627 are duplicate key violations in SQL Server.
|
||||
return ex.Number == 2601 || ex.Number == 2627;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Repository;
|
||||
|
||||
/// <summary>
|
||||
/// Repository for authentication-related database operations including user registration and credential management.
|
||||
/// </summary>
|
||||
public interface IAuthRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers a new user account and its initial credential as a single atomic operation, then
|
||||
/// returns the created account.
|
||||
/// </summary>
|
||||
/// <exception cref="Exception">Thrown when the newly registered user cannot be retrieved after registration.</exception>
|
||||
Task<UserAccount> RegisterUserAsync(UserAccount ua);
|
||||
|
||||
/// <summary>Retrieves a user account by email, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByEmailAsync(string email);
|
||||
|
||||
/// <summary>Retrieves a user account by username, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByUsernameAsync(string username);
|
||||
|
||||
/// <summary>Retrieves the active (non-revoked) credential for a user account, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserCredential?> GetActiveCredentialByUserAccountIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Rotates a user's credential by invalidating all existing credentials and creating a new one.</summary>
|
||||
/// <exception cref="Domain.Exceptions.NotFoundException">Thrown when no account with <paramref name="userAccountId" /> exists.</exception>
|
||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||
|
||||
/// <summary>Marks a user account as confirmed. Idempotent if the account is already verified.</summary>
|
||||
Task<UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Retrieves a user account by ID, or <see langword="null" /> if none exists.</summary>
|
||||
Task<UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||
|
||||
/// <summary>Determines whether a user account has been verified.</summary>
|
||||
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Auth.Services;
|
||||
|
||||
@@ -22,7 +21,7 @@ public enum TokenType
|
||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||
|
||||
/// <summary>Represents the result of refreshing a user's session.</summary>
|
||||
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
|
||||
public record RefreshTokenResult(Guid UserId, string Username, string RefreshToken, string AccessToken);
|
||||
|
||||
/// <summary>
|
||||
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
|
||||
@@ -48,19 +47,19 @@ public interface ITokenService
|
||||
/// Generates a signed access token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateAccessToken(UserAccount user);
|
||||
string GenerateAccessToken(Guid userId, string username);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed refresh token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateRefreshToken(UserAccount user);
|
||||
string GenerateRefreshToken(Guid userId, string username);
|
||||
|
||||
/// <summary>
|
||||
/// Generates a signed confirmation token for the given user, expiring after
|
||||
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||
/// </summary>
|
||||
string GenerateConfirmationToken(UserAccount user);
|
||||
string GenerateConfirmationToken(Guid userId, string username);
|
||||
|
||||
|
||||
/// <summary>Validates an access token's signature and expiration and extracts the caller's identity.</summary>
|
||||
|
||||
@@ -1,88 +1,83 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Auth.Repository;
|
||||
using Features.Auth.Identity;
|
||||
using Infrastructure.Jwt;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Features.Auth.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
|
||||
/// for access, refresh, and confirmation flows using secrets read from environment variables.
|
||||
/// for access, refresh, and confirmation flows using secrets read from configuration.
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
{
|
||||
private readonly string _accessTokenSecret;
|
||||
private readonly IAuthRepository _authRepository;
|
||||
private readonly string _confirmationTokenSecret;
|
||||
private readonly string _refreshTokenSecret;
|
||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
|
||||
/// <c>CONFIRMATION_TOKEN_SECRET</c> configuration values are not set.
|
||||
/// </exception>
|
||||
public TokenService(ITokenInfrastructure tokenInfrastructure, IAuthRepository authRepository)
|
||||
public TokenService(
|
||||
ITokenInfrastructure tokenInfrastructure,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IConfiguration configuration
|
||||
)
|
||||
{
|
||||
_tokenInfrastructure = tokenInfrastructure;
|
||||
_authRepository = authRepository;
|
||||
_userManager = userManager;
|
||||
|
||||
_accessTokenSecret =
|
||||
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||
configuration["ACCESS_TOKEN_SECRET"]
|
||||
?? throw new InvalidOperationException(
|
||||
"ACCESS_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
|
||||
_refreshTokenSecret =
|
||||
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||
configuration["REFRESH_TOKEN_SECRET"]
|
||||
?? throw new InvalidOperationException(
|
||||
"REFRESH_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
|
||||
_confirmationTokenSecret =
|
||||
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||
configuration["CONFIRMATION_TOKEN_SECRET"]
|
||||
?? throw new InvalidOperationException(
|
||||
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateAccessToken(UserAccount user)
|
||||
public string GenerateAccessToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
expiresAt,
|
||||
_accessTokenSecret
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _accessTokenSecret);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateRefreshToken(UserAccount user)
|
||||
public string GenerateRefreshToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.RefreshTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
expiresAt,
|
||||
_refreshTokenSecret
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _refreshTokenSecret);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string GenerateConfirmationToken(UserAccount user)
|
||||
public string GenerateConfirmationToken(Guid userId, string username)
|
||||
{
|
||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||
TokenServiceExpirationHours.ConfirmationTokenHours
|
||||
);
|
||||
return _tokenInfrastructure.GenerateJwt(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
userId,
|
||||
username,
|
||||
expiresAt,
|
||||
_confirmationTokenSecret
|
||||
);
|
||||
@@ -110,14 +105,14 @@ public class TokenService : ITokenService
|
||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||
{
|
||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||
UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||
ApplicationUser? user = await _userManager.FindByIdAsync(validated.UserId.ToString());
|
||||
if (user == null)
|
||||
throw new UnauthorizedException("User account not found");
|
||||
|
||||
string newAccess = GenerateAccessToken(user);
|
||||
string newRefresh = GenerateRefreshToken(user);
|
||||
string newAccess = GenerateAccessToken(user.Id, user.UserName);
|
||||
string newRefresh = GenerateRefreshToken(user.Id, user.UserName);
|
||||
|
||||
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,6 +6,13 @@
|
||||
<RootNamespace>Features.Emails</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.Abstractions"
|
||||
Version="10.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Infrastructure.Email;
|
||||
using Infrastructure.Email.Templates.Rendering;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Features.Emails.Services;
|
||||
|
||||
@@ -8,18 +9,19 @@ namespace Features.Emails.Services;
|
||||
/// </summary>
|
||||
public class EmailDispatcher(
|
||||
IEmailProvider emailProvider,
|
||||
IEmailTemplateProvider emailTemplateProvider
|
||||
IEmailTemplateProvider emailTemplateProvider,
|
||||
IConfiguration configuration
|
||||
) : IEmailDispatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// The base URL of the website, used to build confirmation links. Read from the
|
||||
/// <c>WEBSITE_BASE_URL</c> environment variable.
|
||||
/// <c>WEBSITE_BASE_URL</c> configuration value.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown at type initialization time when the <c>WEBSITE_BASE_URL</c> environment variable is not set.
|
||||
/// Thrown when the <c>WEBSITE_BASE_URL</c> configuration value is not set.
|
||||
/// </exception>
|
||||
private static readonly string WebsiteBaseUrl =
|
||||
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
||||
private string WebsiteBaseUrl =>
|
||||
configuration["WEBSITE_BASE_URL"]
|
||||
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
<PackageReference
|
||||
Include="Microsoft.Extensions.Configuration.Abstractions"
|
||||
Version="10.0.1"
|
||||
/>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MimeKit;
|
||||
|
||||
namespace Infrastructure.Email;
|
||||
|
||||
/// <summary>
|
||||
/// SMTP email service implementation using MailKit.
|
||||
/// Configured via environment variables.
|
||||
/// Configured via application configuration (backed by environment variables).
|
||||
/// </summary>
|
||||
public class SmtpEmailProvider : IEmailProvider
|
||||
{
|
||||
@@ -20,35 +21,35 @@ public class SmtpEmailProvider : IEmailProvider
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SmtpEmailProvider" /> class, reading SMTP configuration
|
||||
/// from environment variables (<c>SMTP_HOST</c>, <c>SMTP_PORT</c>, <c>SMTP_USERNAME</c>,
|
||||
/// <c>SMTP_PASSWORD</c>, <c>SMTP_USE_SSL</c>, <c>SMTP_FROM_EMAIL</c>, <c>SMTP_FROM_NAME</c>).
|
||||
/// (<c>SMTP_HOST</c>, <c>SMTP_PORT</c>, <c>SMTP_USERNAME</c>, <c>SMTP_PASSWORD</c>, <c>SMTP_USE_SSL</c>,
|
||||
/// <c>SMTP_FROM_EMAIL</c>, <c>SMTP_FROM_NAME</c>) from <paramref name="configuration" />.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// Thrown when <c>SMTP_HOST</c> or <c>SMTP_FROM_EMAIL</c> is not set, or when <c>SMTP_PORT</c> is not a valid integer.
|
||||
/// </exception>
|
||||
public SmtpEmailProvider()
|
||||
public SmtpEmailProvider(IConfiguration configuration)
|
||||
{
|
||||
_host =
|
||||
Environment.GetEnvironmentVariable("SMTP_HOST")
|
||||
configuration["SMTP_HOST"]
|
||||
?? throw new InvalidOperationException("SMTP_HOST environment variable is not set");
|
||||
|
||||
string portString = Environment.GetEnvironmentVariable("SMTP_PORT") ?? "587";
|
||||
string portString = configuration["SMTP_PORT"] ?? "587";
|
||||
if (!int.TryParse(portString, out _port))
|
||||
throw new InvalidOperationException($"SMTP_PORT '{portString}' is not a valid integer");
|
||||
|
||||
_username = Environment.GetEnvironmentVariable("SMTP_USERNAME");
|
||||
_password = Environment.GetEnvironmentVariable("SMTP_PASSWORD");
|
||||
_username = configuration["SMTP_USERNAME"];
|
||||
_password = configuration["SMTP_PASSWORD"];
|
||||
|
||||
string useSslString = Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true";
|
||||
string useSslString = configuration["SMTP_USE_SSL"] ?? "true";
|
||||
_useSsl = bool.Parse(useSslString);
|
||||
|
||||
_fromEmail =
|
||||
Environment.GetEnvironmentVariable("SMTP_FROM_EMAIL")
|
||||
configuration["SMTP_FROM_EMAIL"]
|
||||
?? throw new InvalidOperationException(
|
||||
"SMTP_FROM_EMAIL environment variable is not set"
|
||||
);
|
||||
|
||||
_fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten";
|
||||
_fromName = configuration["SMTP_FROM_NAME"] ?? "The Biergarten";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -20,21 +20,21 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlCon
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the SQL Server connection string, preferring (in order): the <c>DB_CONNECTION_STRING</c>
|
||||
/// environment variable, a connection string built from individual <c>DB_*</c> environment variables,
|
||||
/// configuration value, a connection string built from individual <c>DB_*</c> configuration values,
|
||||
/// and finally the <c>"Default"</c> connection string from configuration.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
|
||||
private static string GetConnectionString(IConfiguration configuration)
|
||||
{
|
||||
// Check for full connection string first
|
||||
string? fullConnectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
|
||||
string? fullConnectionString = configuration["DB_CONNECTION_STRING"];
|
||||
if (!string.IsNullOrEmpty(fullConnectionString))
|
||||
return fullConnectionString;
|
||||
|
||||
// Try to build from individual environment variables (preferred method for Docker)
|
||||
// Try to build from individual configuration values (preferred method for Docker)
|
||||
try
|
||||
{
|
||||
return SqlConnectionStringHelper.BuildConnectionString();
|
||||
return SqlConnectionStringHelper.BuildConnectionString(configuration);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace Infrastructure.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for building SQL Server connection strings from environment variables.
|
||||
/// Helper for building SQL Server connection strings from configuration
|
||||
/// (backed by the <c>DB_*</c> environment variables).
|
||||
/// </summary>
|
||||
public static class SqlConnectionStringHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a SQL Server connection string from environment variables.
|
||||
/// Builds a SQL Server connection string from configuration.
|
||||
/// </summary>
|
||||
/// <remarks>Throws <see cref="InvalidOperationException" /> if DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD is unset.</remarks>
|
||||
/// <param name="databaseName">If null, falls back to the DB_NAME environment variable.</param>
|
||||
public static string BuildConnectionString(string? databaseName = null)
|
||||
/// <param name="configuration">The configuration to read the <c>DB_*</c> values from.</param>
|
||||
/// <param name="databaseName">If null, falls back to the DB_NAME configuration value.</param>
|
||||
public static string BuildConnectionString(
|
||||
IConfiguration configuration,
|
||||
string? databaseName = null
|
||||
)
|
||||
{
|
||||
string server = Required("DB_SERVER");
|
||||
string dbName = databaseName ?? Required("DB_NAME");
|
||||
string user = Required("DB_USER");
|
||||
string password = Required("DB_PASSWORD");
|
||||
string server = Required(configuration, "DB_SERVER");
|
||||
string dbName = databaseName ?? Required(configuration, "DB_NAME");
|
||||
string user = Required(configuration, "DB_USER");
|
||||
string password = Required(configuration, "DB_PASSWORD");
|
||||
|
||||
SqlConnectionStringBuilder builder = new()
|
||||
{
|
||||
@@ -25,7 +31,7 @@ public static class SqlConnectionStringHelper
|
||||
InitialCatalog = dbName,
|
||||
UserID = user,
|
||||
Password = password,
|
||||
TrustServerCertificate = ResolveTrustServerCertificate(),
|
||||
TrustServerCertificate = ResolveTrustServerCertificate(configuration),
|
||||
Encrypt = true,
|
||||
};
|
||||
|
||||
@@ -33,29 +39,27 @@ public static class SqlConnectionStringHelper
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a connection string to the master database using environment variables.
|
||||
/// Builds a connection string to the master database using configuration.
|
||||
/// </summary>
|
||||
public static string BuildMasterConnectionString()
|
||||
public static string BuildMasterConnectionString(IConfiguration configuration)
|
||||
{
|
||||
return BuildConnectionString("master");
|
||||
return BuildConnectionString(configuration, "master");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>DB_TRUST_SERVER_CERTIFICATE</c> environment variable, defaulting to
|
||||
/// Resolves the <c>DB_TRUST_SERVER_CERTIFICATE</c> configuration value, defaulting to
|
||||
/// <c>true</c> when it is unset or not a valid boolean.
|
||||
/// </summary>
|
||||
private static bool ResolveTrustServerCertificate()
|
||||
private static bool ResolveTrustServerCertificate(IConfiguration configuration)
|
||||
{
|
||||
return !bool.TryParse(
|
||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"),
|
||||
configuration["DB_TRUST_SERVER_CERTIFICATE"],
|
||||
out bool trustServerCertificate
|
||||
)
|
||||
|| trustServerCertificate;
|
||||
}
|
||||
|
||||
private static string Required(string variableName) =>
|
||||
Environment.GetEnvironmentVariable(variableName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"The {variableName} environment variable is not set."
|
||||
);
|
||||
private static string Required(IConfiguration configuration, string key) =>
|
||||
configuration[key]
|
||||
?? throw new InvalidOperationException($"The {key} environment variable is not set.");
|
||||
}
|
||||
|
||||
3
web/frontend/.gitignore
vendored
Normal file
3
web/frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.features-gen/
|
||||
.react-router
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"semi": true,
|
||||
"tabWidth": 3,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router";
|
||||
|
||||
declare module "react-router" {
|
||||
interface Future {
|
||||
v8_middleware: false
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import "react-router"
|
||||
|
||||
declare module "react-router" {
|
||||
interface Register {
|
||||
pages: Pages
|
||||
routeFiles: RouteFiles
|
||||
routeModules: RouteModules
|
||||
}
|
||||
}
|
||||
|
||||
type Pages = {
|
||||
"/": {
|
||||
params: {};
|
||||
};
|
||||
"/theme": {
|
||||
params: {};
|
||||
};
|
||||
"/login": {
|
||||
params: {};
|
||||
};
|
||||
"/register": {
|
||||
params: {};
|
||||
};
|
||||
"/logout": {
|
||||
params: {};
|
||||
};
|
||||
"/dashboard": {
|
||||
params: {};
|
||||
};
|
||||
"/confirm": {
|
||||
params: {};
|
||||
};
|
||||
"/beers": {
|
||||
params: {};
|
||||
};
|
||||
"/breweries": {
|
||||
params: {};
|
||||
};
|
||||
"/beer-styles": {
|
||||
params: {};
|
||||
};
|
||||
};
|
||||
|
||||
type RouteFiles = {
|
||||
"root.tsx": {
|
||||
id: "root";
|
||||
page: "/" | "/theme" | "/login" | "/register" | "/logout" | "/dashboard" | "/confirm" | "/beers" | "/breweries" | "/beer-styles";
|
||||
};
|
||||
"routes/home.tsx": {
|
||||
id: "routes/home";
|
||||
page: "/";
|
||||
};
|
||||
"routes/theme.tsx": {
|
||||
id: "routes/theme";
|
||||
page: "/theme";
|
||||
};
|
||||
"routes/login.tsx": {
|
||||
id: "routes/login";
|
||||
page: "/login";
|
||||
};
|
||||
"routes/register.tsx": {
|
||||
id: "routes/register";
|
||||
page: "/register";
|
||||
};
|
||||
"routes/logout.tsx": {
|
||||
id: "routes/logout";
|
||||
page: "/logout";
|
||||
};
|
||||
"routes/dashboard.tsx": {
|
||||
id: "routes/dashboard";
|
||||
page: "/dashboard";
|
||||
};
|
||||
"routes/confirm.tsx": {
|
||||
id: "routes/confirm";
|
||||
page: "/confirm";
|
||||
};
|
||||
"routes/beers.tsx": {
|
||||
id: "routes/beers";
|
||||
page: "/beers";
|
||||
};
|
||||
"routes/breweries.tsx": {
|
||||
id: "routes/breweries";
|
||||
page: "/breweries";
|
||||
};
|
||||
"routes/beer-styles.tsx": {
|
||||
id: "routes/beer-styles";
|
||||
page: "/beer-styles";
|
||||
};
|
||||
};
|
||||
|
||||
type RouteModules = {
|
||||
"root": typeof import("./app/root.tsx");
|
||||
"routes/home": typeof import("./app/routes/home.tsx");
|
||||
"routes/theme": typeof import("./app/routes/theme.tsx");
|
||||
"routes/login": typeof import("./app/routes/login.tsx");
|
||||
"routes/register": typeof import("./app/routes/register.tsx");
|
||||
"routes/logout": typeof import("./app/routes/logout.tsx");
|
||||
"routes/dashboard": typeof import("./app/routes/dashboard.tsx");
|
||||
"routes/confirm": typeof import("./app/routes/confirm.tsx");
|
||||
"routes/beers": typeof import("./app/routes/beers.tsx");
|
||||
"routes/breweries": typeof import("./app/routes/breweries.tsx");
|
||||
"routes/beer-styles": typeof import("./app/routes/beer-styles.tsx");
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
declare module "virtual:react-router/server-build" {
|
||||
import { ServerBuild } from "react-router";
|
||||
export const assets: ServerBuild["assets"];
|
||||
export const assetsBuildDirectory: ServerBuild["assetsBuildDirectory"];
|
||||
export const basename: ServerBuild["basename"];
|
||||
export const entry: ServerBuild["entry"];
|
||||
export const future: ServerBuild["future"];
|
||||
export const isSpaMode: ServerBuild["isSpaMode"];
|
||||
export const prerender: ServerBuild["prerender"];
|
||||
export const publicPath: ServerBuild["publicPath"];
|
||||
export const routeDiscovery: ServerBuild["routeDiscovery"];
|
||||
export const routes: ServerBuild["routes"];
|
||||
export const ssr: ServerBuild["ssr"];
|
||||
export const allowedActionOrigins: ServerBuild["allowedActionOrigins"];
|
||||
export const unstable_getCriticalCss: ServerBuild["unstable_getCriticalCss"];
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../root.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "root.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../root.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../confirm.js")
|
||||
type Module = typeof import("../account.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/confirm.tsx",
|
||||
file: "routes/account.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
@@ -13,11 +13,11 @@ type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/confirm";
|
||||
module: typeof import("../confirm.js");
|
||||
id: "routes/account";
|
||||
module: typeof import("../account.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
@@ -54,9 +54,18 @@ export namespace Route {
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// ServerHydrateFallback
|
||||
export type ServerHydrateFallbackProps = Annotations["ServerHydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ServerComponent
|
||||
export type ServerComponentProps = Annotations["ServerComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
|
||||
// ServerErrorBoundary
|
||||
export type ServerErrorBoundaryProps = Annotations["ServerErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../beer-styles.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/beer-styles.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/beer-styles";
|
||||
module: typeof import("../beer-styles.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../breweries.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/breweries.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/breweries";
|
||||
module: typeof import("../breweries.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../beers.js")
|
||||
type Module = typeof import("../brewery-detail.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/beers.tsx",
|
||||
file: "routes/brewery-detail.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
@@ -13,11 +13,11 @@ type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/beers";
|
||||
module: typeof import("../beers.js");
|
||||
id: "routes/brewery-detail";
|
||||
module: typeof import("../brewery-detail.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
@@ -54,9 +54,18 @@ export namespace Route {
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// ServerHydrateFallback
|
||||
export type ServerHydrateFallbackProps = Annotations["ServerHydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ServerComponent
|
||||
export type ServerComponentProps = Annotations["ServerComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
|
||||
// ServerErrorBoundary
|
||||
export type ServerErrorBoundaryProps = Annotations["ServerErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../dashboard.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/dashboard.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/dashboard";
|
||||
module: typeof import("../dashboard.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../home.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/home.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/home";
|
||||
module: typeof import("../home.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../login.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/login.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/login";
|
||||
module: typeof import("../login.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../logout.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/logout.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/logout";
|
||||
module: typeof import("../logout.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../register.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/register.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/register";
|
||||
module: typeof import("../register.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Generated by React Router
|
||||
|
||||
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||
|
||||
type Module = typeof import("../theme.js")
|
||||
|
||||
type Info = GetInfo<{
|
||||
file: "routes/theme.tsx",
|
||||
module: Module
|
||||
}>
|
||||
|
||||
type Matches = [{
|
||||
id: "root";
|
||||
module: typeof import("../../root.js");
|
||||
}, {
|
||||
id: "routes/theme";
|
||||
module: typeof import("../theme.js");
|
||||
}];
|
||||
|
||||
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
|
||||
|
||||
export namespace Route {
|
||||
// links
|
||||
export type LinkDescriptors = Annotations["LinkDescriptors"];
|
||||
export type LinksFunction = Annotations["LinksFunction"];
|
||||
|
||||
// meta
|
||||
export type MetaArgs = Annotations["MetaArgs"];
|
||||
export type MetaDescriptors = Annotations["MetaDescriptors"];
|
||||
export type MetaFunction = Annotations["MetaFunction"];
|
||||
|
||||
// headers
|
||||
export type HeadersArgs = Annotations["HeadersArgs"];
|
||||
export type HeadersFunction = Annotations["HeadersFunction"];
|
||||
|
||||
// middleware
|
||||
export type MiddlewareFunction = Annotations["MiddlewareFunction"];
|
||||
|
||||
// clientMiddleware
|
||||
export type ClientMiddlewareFunction = Annotations["ClientMiddlewareFunction"];
|
||||
|
||||
// loader
|
||||
export type LoaderArgs = Annotations["LoaderArgs"];
|
||||
|
||||
// clientLoader
|
||||
export type ClientLoaderArgs = Annotations["ClientLoaderArgs"];
|
||||
|
||||
// action
|
||||
export type ActionArgs = Annotations["ActionArgs"];
|
||||
|
||||
// clientAction
|
||||
export type ClientActionArgs = Annotations["ClientActionArgs"];
|
||||
|
||||
// HydrateFallback
|
||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||
|
||||
// Component
|
||||
export type ComponentProps = Annotations["ComponentProps"];
|
||||
|
||||
// ErrorBoundary
|
||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
||||
}
|
||||
@@ -5,6 +5,11 @@ const config: StorybookConfig = {
|
||||
'../stories/Configure.mdx',
|
||||
'../stories/SubmitButton.stories.tsx',
|
||||
'../stories/FormField.stories.tsx',
|
||||
'../stories/SectionCard.stories.tsx',
|
||||
'../stories/UsernameSection.stories.tsx',
|
||||
'../stories/PasswordSection.stories.tsx',
|
||||
'../stories/LoginForm.stories.tsx',
|
||||
'../stories/RegisterForm.stories.tsx',
|
||||
'../stories/Navbar.stories.tsx',
|
||||
'../stories/Toast.stories.tsx',
|
||||
'../stories/Themes.stories.tsx',
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { Preview } from '@storybook/react-vite';
|
||||
import { createElement } from 'react';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
import '../app/app.css';
|
||||
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes';
|
||||
import {
|
||||
biergartenThemes,
|
||||
defaultThemeName,
|
||||
isBiergartenTheme,
|
||||
} from '../app/features/theme/themes';
|
||||
|
||||
const preview: Preview = {
|
||||
globalTypes: {
|
||||
@@ -28,18 +32,23 @@ const preview: Preview = {
|
||||
? context.globals.theme
|
||||
: defaultThemeName;
|
||||
|
||||
return createElement(
|
||||
MemoryRouter,
|
||||
undefined,
|
||||
createElement(
|
||||
const content = createElement(
|
||||
'div',
|
||||
{
|
||||
'data-theme': theme,
|
||||
className: 'bg-base-200 p-6 text-base-content',
|
||||
},
|
||||
createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)),
|
||||
),
|
||||
);
|
||||
|
||||
// Stories driving `useSubmit`/`useNavigation` bring their own data
|
||||
// router (createMemoryRouter + RouterProvider), which can't nest
|
||||
// inside another <Router>.
|
||||
if (context.parameters.usesDataRouter) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return createElement(MemoryRouter, undefined, content);
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import {
|
||||
Disclosure,
|
||||
DisclosureButton,
|
||||
DisclosurePanel,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
MenuItems,
|
||||
} from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface NavbarProps {
|
||||
auth: {
|
||||
username: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export default function Navbar({ auth }: NavbarProps) {
|
||||
const navLinks = [
|
||||
{ to: '/theme', label: 'Theme' },
|
||||
{ to: '/beers', label: 'Beers' },
|
||||
{ to: '/breweries', label: 'Breweries' },
|
||||
{ to: '/beer-styles', label: 'Beer Styles' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="navbar-center hidden lg:flex gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{!auth && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm hidden sm:inline-flex">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="btn btn-ghost btn-sm">
|
||||
{auth.username}
|
||||
</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
className="btn btn-ghost btn-sm justify-start"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
{!auth && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Description, Field, Label } from '@headlessui/react';
|
||||
|
||||
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
labelClassName?: string;
|
||||
inputClassName?: string;
|
||||
hintClassName?: string;
|
||||
};
|
||||
|
||||
export default function FormField({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
labelClassName,
|
||||
inputClassName,
|
||||
hintClassName,
|
||||
...inputProps
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<Field className={className ?? 'space-y-1'}>
|
||||
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||
{label}
|
||||
</Label>
|
||||
|
||||
<input
|
||||
{...inputProps}
|
||||
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||
) : hint ? (
|
||||
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isSubmitting: boolean;
|
||||
idleText: string;
|
||||
submittingText: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SubmitButton({
|
||||
isSubmitting,
|
||||
idleText,
|
||||
submittingText,
|
||||
className,
|
||||
}: SubmitButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={className ?? 'btn btn-primary w-full mt-2'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" /> {submittingText}
|
||||
</>
|
||||
) : (
|
||||
idleText
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
export default function ToastProvider() {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3500,
|
||||
className: 'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-success)',
|
||||
secondary: 'var(--color-success-content)',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-error)',
|
||||
secondary: 'var(--color-error-content)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
web/frontend/app/components/ui/forms/FormField.tsx
Normal file
42
web/frontend/app/components/ui/forms/FormField.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Description, Field, Label } from '@headlessui/react';
|
||||
|
||||
type FormFieldProps = React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
labelClassName?: string;
|
||||
inputClassName?: string;
|
||||
hintClassName?: string;
|
||||
};
|
||||
|
||||
const FormField = ({
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
className,
|
||||
labelClassName,
|
||||
inputClassName,
|
||||
hintClassName,
|
||||
...inputProps
|
||||
}: FormFieldProps) => {
|
||||
return (
|
||||
<Field className={className ?? 'space-y-1'}>
|
||||
<Label htmlFor={inputProps.id} className={labelClassName ?? 'label font-medium'}>
|
||||
{label}
|
||||
</Label>
|
||||
|
||||
<input
|
||||
{...inputProps}
|
||||
className={inputClassName ?? `input w-full ${error ? 'input-error' : ''}`}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Description className={hintClassName ?? 'label text-error'}>{error}</Description>
|
||||
) : hint ? (
|
||||
<Description className={hintClassName ?? 'label'}>{hint}</Description>
|
||||
) : null}
|
||||
</Field>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormField;
|
||||
28
web/frontend/app/components/ui/forms/SubmitButton.tsx
Normal file
28
web/frontend/app/components/ui/forms/SubmitButton.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Button } from '@headlessui/react';
|
||||
|
||||
interface SubmitButtonProps {
|
||||
isSubmitting: boolean;
|
||||
idleText: string;
|
||||
submittingText: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const SubmitButton = ({ isSubmitting, idleText, submittingText, className }: SubmitButtonProps) => {
|
||||
return (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={className ?? 'btn btn-primary w-full mt-2'}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" /> {submittingText}
|
||||
</>
|
||||
) : (
|
||||
idleText
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmitButton;
|
||||
44
web/frontend/app/components/ui/navbar/NavLinks.tsx
Normal file
44
web/frontend/app/components/ui/navbar/NavLinks.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/theme', label: 'Theme' },
|
||||
{ to: '/beers', label: 'Beers' },
|
||||
{ to: '/breweries', label: 'Breweries' },
|
||||
{ to: '/beer-styles', label: 'Beer Styles' },
|
||||
];
|
||||
|
||||
interface NavLinksProps {
|
||||
variant: 'desktop' | 'mobile';
|
||||
showRegister: boolean;
|
||||
}
|
||||
|
||||
const NavLinks = ({ variant, showRegister }: NavLinksProps) => {
|
||||
if (variant === 'desktop') {
|
||||
return (
|
||||
<div className="navbar-center hidden lg:flex gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link key={link.to} to={link.to} className="btn btn-ghost btn-sm justify-start">
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
{showRegister && (
|
||||
<Link to="/register" className="btn btn-ghost btn-sm justify-start">
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavLinks;
|
||||
93
web/frontend/app/components/ui/navbar/Navbar.tsx
Normal file
93
web/frontend/app/components/ui/navbar/Navbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
import NavLinks from './NavLinks';
|
||||
import UserMenu from './UserMenu';
|
||||
|
||||
interface NavbarProps {
|
||||
auth: {
|
||||
username: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
userAccountId: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const Navbar = ({ auth }: NavbarProps) => (
|
||||
<Disclosure
|
||||
as="nav"
|
||||
className="sticky top-0 z-50 border-b border-base-300 bg-base-100 shadow-md"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<div className="navbar mx-auto max-w-7xl px-2 sm:px-4">
|
||||
<div className="navbar-start gap-2">
|
||||
<DisclosureButton
|
||||
className="btn btn-ghost btn-square lg:hidden"
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 stroke-current"
|
||||
>
|
||||
{open ? (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
) : (
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</DisclosureButton>
|
||||
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
🍺 The Biergarten App
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<NavLinks variant="desktop" showRegister={!auth} />
|
||||
|
||||
<div className="navbar-end gap-2">
|
||||
{!auth && (
|
||||
<Link
|
||||
to="/register"
|
||||
className="btn btn-ghost btn-sm hidden sm:inline-flex"
|
||||
>
|
||||
Register User
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{auth ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="btn btn-primary btn-sm">
|
||||
Dashboard
|
||||
</Link>
|
||||
|
||||
<UserMenu username={auth.username} />
|
||||
</>
|
||||
) : (
|
||||
<Link to="/login" className="btn btn-primary btn-sm">
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisclosurePanel className="border-t border-base-300 bg-base-100 px-4 py-3 lg:hidden">
|
||||
<NavLinks variant="mobile" showRegister={!auth} />
|
||||
</DisclosurePanel>
|
||||
</>
|
||||
)}
|
||||
</Disclosure>
|
||||
);
|
||||
|
||||
export default Navbar;
|
||||
30
web/frontend/app/components/ui/navbar/UserMenu.tsx
Normal file
30
web/frontend/app/components/ui/navbar/UserMenu.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface UserMenuProps {
|
||||
username: string;
|
||||
}
|
||||
|
||||
const UserMenu = ({ username }: UserMenuProps) => (
|
||||
<Menu as="div" className="relative">
|
||||
<MenuButton className="btn btn-ghost btn-sm">{username}</MenuButton>
|
||||
<MenuItems className="menu absolute right-0 z-60 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-xl focus:outline-none">
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/dashboard" className={focus ? 'active' : ''}>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<Link to="/logout" className={focus ? 'active' : ''}>
|
||||
Logout
|
||||
</Link>
|
||||
)}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
export default UserMenu;
|
||||
28
web/frontend/app/components/ui/toast/ToastProvider.tsx
Normal file
28
web/frontend/app/components/ui/toast/ToastProvider.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
const ToastProvider = () => {
|
||||
return (
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 3500,
|
||||
className:
|
||||
'rounded-box border border-base-300 bg-base-100 text-base-content shadow-lg',
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-success)',
|
||||
secondary: 'var(--color-success-content)',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: 'var(--color-error)',
|
||||
secondary: 'var(--color-error-content)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastProvider;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const AuthFlowDemoCard = () => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title">Auth Flow Demo</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This demo showcases the following authentication features:
|
||||
</p>
|
||||
<ul className="list">
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Login</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/login</code> with
|
||||
username & password
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Register</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/register</code> with full
|
||||
user details
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Session</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
JWT access & refresh tokens stored in an HTTP-only cookie
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Protected Routes</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
This dashboard requires authentication via{' '}
|
||||
<code className="kbd kbd-sm">requireAuth()</code>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Token Refresh</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
POST to <code className="kbd kbd-sm">/api/auth/refresh</code> with the
|
||||
refresh token in an <code className="kbd kbd-sm">X-Refresh-Token</code>{' '}
|
||||
header
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="list-row">
|
||||
<div>
|
||||
<p className="font-semibold">Account Management</p>
|
||||
<p className="text-sm text-base-content/60">
|
||||
PATCH <code className="kbd kbd-sm">/api/auth/username</code>,{' '}
|
||||
<code className="kbd kbd-sm">/email</code>,{' '}
|
||||
<code className="kbd kbd-sm">/password</code>,{' '}
|
||||
<code className="kbd kbd-sm">/profile</code>, and DELETE{' '}
|
||||
<code className="kbd kbd-sm">/account</code> — see{' '}
|
||||
<Link to="/account" className="link">
|
||||
Account Settings
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AuthFlowDemoCard;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { Dialog, DialogPanel, DialogTitle } from '@headlessui/react';
|
||||
import { Trash, WarningTriangle } from 'iconoir-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import type { ActionResult } from '../types';
|
||||
|
||||
interface DeleteAccountSectionProps {
|
||||
userAccountId: string;
|
||||
result: ActionResult | undefined;
|
||||
}
|
||||
|
||||
const DeleteAccountSection = ({ userAccountId, result }: DeleteAccountSectionProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'delete';
|
||||
|
||||
useSectionError(result, 'delete');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card bg-error/5 border border-error/30 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex items-center justify-between gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-error mt-1">
|
||||
<Trash className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h2 className="card-title text-lg">Delete Account</h2>
|
||||
<p className="text-sm text-base-content/70">
|
||||
Permanently delete your account. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-outline btn-sm"
|
||||
onClick={() => setDialogOpen(true)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
className="relative z-50"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||
<DialogPanel className="card w-full max-w-sm bg-base-100 shadow-xl">
|
||||
<div className="card-body items-center text-center gap-3">
|
||||
<WarningTriangle className="size-10 text-error" aria-hidden="true" />
|
||||
<DialogTitle className="text-lg font-bold">
|
||||
Delete your account?
|
||||
</DialogTitle>
|
||||
<p className="text-sm text-base-content/70">
|
||||
This action is permanent and cannot be reversed. Account ID{' '}
|
||||
<span className="font-mono text-xs">{userAccountId}</span> and all
|
||||
associated data will be removed.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 w-full pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-sm w-full"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => submit({ intent: 'delete' }, { method: 'post' })}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm" />{' '}
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Yes, delete my account'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm w-full"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteAccountSection;
|
||||
@@ -0,0 +1,57 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Mail } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateEmailSchema, type UpdateEmailSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const EmailSection = ({ email, open, onToggle, result }: SectionProps & { email: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'email';
|
||||
|
||||
const emailForm = useForm<UpdateEmailSchema>({
|
||||
resolver: zodResolver(updateEmailSchema),
|
||||
defaultValues: { newEmail: email },
|
||||
});
|
||||
|
||||
useSectionError(result, 'email');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<Mail className="size-5" aria-hidden="true" />}
|
||||
title="Email Address"
|
||||
description="Change your email. You'll need to re-confirm the new address."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={emailForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'email' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="newEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="jane@example.com"
|
||||
label="New Email"
|
||||
error={emailForm.formState.errors.newEmail?.message}
|
||||
{...emailForm.register('newEmail')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Email"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailSection;
|
||||
@@ -0,0 +1,75 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Lock } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updatePasswordSchema, type UpdatePasswordSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const PasswordSection = ({ open, onToggle, result }: SectionProps) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'password';
|
||||
|
||||
const passwordForm = useForm<UpdatePasswordSchema>({
|
||||
resolver: zodResolver(updatePasswordSchema),
|
||||
});
|
||||
|
||||
useSectionError(result, 'password');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<Lock className="size-5" aria-hidden="true" />}
|
||||
title="Password"
|
||||
description="Change the password used to sign in."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={passwordForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'password' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
label="Current Password"
|
||||
error={passwordForm.formState.errors.currentPassword?.message}
|
||||
{...passwordForm.register('currentPassword')}
|
||||
/>
|
||||
<FormField
|
||||
id="newPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="New Password"
|
||||
hint="8+ chars: uppercase, lowercase, digit, special character"
|
||||
error={passwordForm.formState.errors.newPassword?.message}
|
||||
{...passwordForm.register('newPassword')}
|
||||
/>
|
||||
<FormField
|
||||
id="confirmNewPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder="••••••••"
|
||||
label="Confirm New Password"
|
||||
error={passwordForm.formState.errors.confirmNewPassword?.message}
|
||||
{...passwordForm.register('confirmNewPassword')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Change Password"
|
||||
submittingText="Changing..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordSection;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateProfileSchema, type UpdateProfileSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const ProfileSection = ({
|
||||
firstName,
|
||||
lastName,
|
||||
dateOfBirth,
|
||||
open,
|
||||
onToggle,
|
||||
result,
|
||||
}: SectionProps & { firstName: string; lastName: string; dateOfBirth: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'profile';
|
||||
|
||||
const profileForm = useForm<UpdateProfileSchema>({
|
||||
resolver: zodResolver(updateProfileSchema),
|
||||
defaultValues: { firstName, lastName, dateOfBirth },
|
||||
});
|
||||
|
||||
useSectionError(result, 'profile');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<User className="size-5" aria-hidden="true" />}
|
||||
title="Profile"
|
||||
description="Update your name and date of birth."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={profileForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'profile' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Jane"
|
||||
label="First Name"
|
||||
error={profileForm.formState.errors.firstName?.message}
|
||||
{...profileForm.register('firstName')}
|
||||
/>
|
||||
<FormField
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
placeholder="Doe"
|
||||
label="Last Name"
|
||||
error={profileForm.formState.errors.lastName?.message}
|
||||
{...profileForm.register('lastName')}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
id="dateOfBirth"
|
||||
type="date"
|
||||
label="Date of Birth"
|
||||
error={profileForm.formState.errors.dateOfBirth?.message}
|
||||
{...profileForm.register('dateOfBirth')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Profile"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSection;
|
||||
36
web/frontend/app/features/account/components/SectionCard.tsx
Normal file
36
web/frontend/app/features/account/components/SectionCard.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface SectionCardProps {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SectionCard = ({ icon, title, description, open, onToggle, children }: SectionCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex w-full items-center justify-between gap-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-base-content/60 mt-1">{icon}</span>
|
||||
<div>
|
||||
<h2 className="card-title text-lg">{title}</h2>
|
||||
<p className="text-sm text-base-content/70">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-primary"
|
||||
checked={open}
|
||||
onChange={onToggle}
|
||||
aria-label={`Toggle ${title.toLowerCase()} form`}
|
||||
/>
|
||||
</div>
|
||||
{open && <div className="mt-4">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SectionCard;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
interface SessionInfoCardProps {
|
||||
username: string;
|
||||
userAccountId: string;
|
||||
}
|
||||
|
||||
const SessionInfoCard = ({ username, userAccountId }: SessionInfoCardProps) => (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl">Welcome, {username}!</h2>
|
||||
<p className="text-base-content/70">
|
||||
You are successfully authenticated. This is a protected page that requires a valid
|
||||
session.
|
||||
</p>
|
||||
|
||||
<div className="bg-base-200 rounded-box p-4 mt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-widest text-base-content/50 mb-3">
|
||||
Session Info
|
||||
</p>
|
||||
<div className="stats stats-vertical w-full">
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">Username</div>
|
||||
<div className="stat-value text-lg font-mono">{username}</div>
|
||||
</div>
|
||||
<div className="stat py-2">
|
||||
<div className="stat-title">User ID</div>
|
||||
<div className="stat-desc font-mono text-xs mt-1">{userAccountId}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card-actions mt-4">
|
||||
<Link to="/account" className="btn btn-outline btn-sm">
|
||||
Account Settings
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default SessionInfoCard;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { User } from 'iconoir-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useNavigation, useSubmit } from 'react-router';
|
||||
import FormField from '../../../components/ui/forms/FormField';
|
||||
import SubmitButton from '../../../components/ui/forms/SubmitButton';
|
||||
import { useSectionError } from '../hooks/useSectionError';
|
||||
import { updateUsernameSchema, type UpdateUsernameSchema } from '../schemas';
|
||||
import type { SectionProps } from '../types';
|
||||
import SectionCard from './SectionCard';
|
||||
|
||||
const UsernameSection = ({
|
||||
username,
|
||||
open,
|
||||
onToggle,
|
||||
result,
|
||||
}: SectionProps & { username: string }) => {
|
||||
const navigation = useNavigation();
|
||||
const submit = useSubmit();
|
||||
const isSubmitting = navigation.formData?.get('intent') === 'username';
|
||||
|
||||
const usernameForm = useForm<UpdateUsernameSchema>({
|
||||
resolver: zodResolver(updateUsernameSchema),
|
||||
defaultValues: { newUsername: username },
|
||||
});
|
||||
|
||||
useSectionError(result, 'username');
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={<User className="size-5" aria-hidden="true" />}
|
||||
title="Username"
|
||||
description="Change the username you sign in with."
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={usernameForm.handleSubmit((values) =>
|
||||
submit({ ...values, intent: 'username' }, { method: 'post' }),
|
||||
)}
|
||||
>
|
||||
<FormField
|
||||
id="newUsername"
|
||||
type="text"
|
||||
placeholder={username}
|
||||
label="New Username"
|
||||
error={usernameForm.formState.errors.newUsername?.message}
|
||||
{...usernameForm.register('newUsername')}
|
||||
/>
|
||||
<SubmitButton
|
||||
isSubmitting={isSubmitting}
|
||||
idleText="Update Username"
|
||||
submittingText="Updating..."
|
||||
/>
|
||||
</form>
|
||||
</SectionCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsernameSection;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user