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
|
/build
|
||||||
|
|
||||||
# project-specific build artifacts
|
# project-specific build artifacts
|
||||||
/src/Website/build/
|
/web/frontend/build/
|
||||||
/src/Website/storybook-static/
|
/web/frontend/storybook-static/
|
||||||
/src/Website/.react-router/
|
/web/frontend/.react-router/
|
||||||
/src/Website/playwright-report/
|
/web/frontend/playwright-report/
|
||||||
/src/Website/test-results/
|
/web/frontend/test-results/
|
||||||
/test-results/
|
/test-results/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
This document describes the testing strategy and how to run tests for The
|
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
|
- The command will start or reuse the Storybook server defined in
|
||||||
`playwright.storybook.config.ts`
|
`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
|
## Test coverage
|
||||||
|
|
||||||
### Current coverage
|
### Current coverage
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ public class JwtAuthenticationHandler(
|
|||||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
{
|
{
|
||||||
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
// 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))
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
secret = configuration["Jwt:SecretKey"];
|
secret = configuration["Jwt:SecretKey"];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using System.Security.Claims;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Shared.Contracts;
|
using Shared.Contracts;
|
||||||
|
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
||||||
|
|
||||||
namespace API.Core.Controllers;
|
namespace API.Core.Controllers;
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ public class ProtectedController : ControllerBase
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public ActionResult<ResponseBody<object>> Get()
|
public ActionResult<ResponseBody<object>> Get()
|
||||||
{
|
{
|
||||||
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
|
string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<object>
|
new ResponseBody<object>
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ WORKDIR /src
|
|||||||
# Copy the solution file and every project file first (preserving their real relative paths, since
|
# 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
|
# 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,
|
# `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.
|
# 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.Core/API.Core.csproj", "API/API.Core/"]
|
||||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||||
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
|
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 ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
|
||||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||||
RUN dotnet restore "Core.slnx"
|
RUN dotnet restore "Biergarten.API.slnx"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/API/API.Core"
|
WORKDIR "/src/API/API.Core"
|
||||||
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using FluentValidation;
|
|||||||
using FluentValidation.AspNetCore;
|
using FluentValidation.AspNetCore;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
using Infrastructure.Sql.DependencyInjection;
|
using Infrastructure.Sql.DependencyInjection;
|
||||||
|
using Microsoft.OpenApi.Models;
|
||||||
using Shared.Application.Behaviors;
|
using Shared.Application.Behaviors;
|
||||||
|
|
||||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||||
@@ -27,7 +28,37 @@ builder
|
|||||||
.AddApplicationPart(typeof(AuthController).Assembly);
|
.AddApplicationPart(typeof(AuthController).Assembly);
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
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();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
// Add FluentValidation
|
// Add FluentValidation
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ ARG BUILD_CONFIGURATION=Release
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# See API.Core/Dockerfile for why this copies every project file (with real relative paths) and
|
# 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.
|
# restores against Biergarten.API.slnx rather than hand-listing API.Specs' transitive dependencies.
|
||||||
COPY ["Core.slnx", "./"]
|
COPY ["Biergarten.API.slnx", "./"]
|
||||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||||
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
|
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 ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
|
||||||
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
|
||||||
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
|
||||||
RUN dotnet restore "Core.slnx"
|
RUN dotnet restore "Biergarten.API.slnx"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/API/API.Specs"
|
WORKDIR "/src/API/API.Specs"
|
||||||
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Reqnroll;
|
using Reqnroll;
|
||||||
|
|
||||||
namespace API.Specs.Steps;
|
namespace API.Specs.Steps;
|
||||||
@@ -19,11 +21,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
private const string PreviousAccessTokenKey = "previousAccessToken";
|
private const string PreviousAccessTokenKey = "previousAccessToken";
|
||||||
private const string PreviousRefreshTokenKey = "previousRefreshToken";
|
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>(
|
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
|
||||||
FactoryKey,
|
FactoryKey,
|
||||||
out TestApiFactory? f
|
out TestApiFactory? f
|
||||||
@@ -31,16 +30,28 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
? f
|
? f
|
||||||
: new TestApiFactory();
|
: new TestApiFactory();
|
||||||
scenario[FactoryKey] = factory;
|
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;
|
scenario[ClientKey] = client;
|
||||||
return 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)
|
return GetFactory().Services.GetRequiredService<IConfiguration>()[key]
|
||||||
?? throw new InvalidOperationException($"{name} environment variable is not set");
|
?? throw new InvalidOperationException($"{key} configuration value is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string GenerateJwtToken(
|
private static string GenerateJwtToken(
|
||||||
@@ -420,7 +431,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
? user
|
? user
|
||||||
: throw new InvalidOperationException("registered username not found in scenario");
|
: throw new InvalidOperationException("registered username not found in scenario");
|
||||||
|
|
||||||
string secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
|
string secret = GetRequiredConfigValue("ACCESS_TOKEN_SECRET");
|
||||||
scenario["accessToken"] = GenerateJwtToken(
|
scenario["accessToken"] = GenerateJwtToken(
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
@@ -439,7 +450,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
? user
|
? user
|
||||||
: throw new InvalidOperationException("registered username not found in scenario");
|
: throw new InvalidOperationException("registered username not found in scenario");
|
||||||
|
|
||||||
string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
|
string secret = GetRequiredConfigValue("CONFIRMATION_TOKEN_SECRET");
|
||||||
scenario["confirmationToken"] = GenerateJwtToken(
|
scenario["confirmationToken"] = GenerateJwtToken(
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
@@ -458,7 +469,7 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
? user
|
? user
|
||||||
: throw new InvalidOperationException("registered username not found in scenario");
|
: throw new InvalidOperationException("registered username not found in scenario");
|
||||||
|
|
||||||
string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
|
string secret = GetRequiredConfigValue("CONFIRMATION_TOKEN_SECRET");
|
||||||
scenario["confirmationToken"] = GenerateJwtToken(
|
scenario["confirmationToken"] = GenerateJwtToken(
|
||||||
userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
@@ -602,12 +613,9 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
|
string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
|
||||||
? t
|
? t
|
||||||
: "valid-refresh-token";
|
: "valid-refresh-token";
|
||||||
string body = JsonSerializer.Serialize(new { refreshToken = token });
|
|
||||||
|
|
||||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
|
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
|
||||||
{
|
requestMessage.Headers.Add("X-Refresh-Token", token);
|
||||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
||||||
};
|
|
||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
@@ -619,12 +627,9 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
|
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string body = JsonSerializer.Serialize(new { refreshToken = "invalid-refresh-token" });
|
|
||||||
|
|
||||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
|
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
|
||||||
{
|
requestMessage.Headers.Add("X-Refresh-Token", "invalid-refresh-token");
|
||||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
||||||
};
|
|
||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
@@ -636,13 +641,9 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken()
|
public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
// Use an expired token
|
|
||||||
string body = JsonSerializer.Serialize(new { refreshToken = "expired-refresh-token" });
|
|
||||||
|
|
||||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
|
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
|
||||||
{
|
requestMessage.Headers.Add("X-Refresh-Token", "expired-refresh-token");
|
||||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
||||||
};
|
|
||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
@@ -654,12 +655,8 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken()
|
public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
string body = JsonSerializer.Serialize(new { });
|
|
||||||
|
|
||||||
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
|
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
|
||||||
{
|
|
||||||
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
|
||||||
};
|
|
||||||
|
|
||||||
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
HttpResponseMessage response = await client.SendAsync(requestMessage);
|
||||||
string responseBody = await response.Content.ReadAsStringAsync();
|
string responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|||||||
@@ -11,6 +11,11 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
|
<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>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ using DbUp;
|
|||||||
using DbUp.Engine;
|
using DbUp.Engine;
|
||||||
using Infrastructure.Sql;
|
using Infrastructure.Sql;
|
||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Database.Migrations;
|
namespace Database.Migrations;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point for the database migration runner. Reads connection details from environment
|
/// Entry point for the database migration runner. Reads connection details from configuration
|
||||||
/// variables, optionally drops and recreates the target database, and applies all SQL
|
/// (backed by environment variables), optionally drops and recreates the target database, and
|
||||||
/// migration scripts embedded in this assembly using DbUp.
|
/// applies all SQL migration scripts embedded in this assembly using DbUp.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class Program
|
public static class Program
|
||||||
{
|
{
|
||||||
@@ -27,7 +28,10 @@ public static class Program
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DatabaseSettings settings = DatabaseSettings.FromEnvironment();
|
IConfiguration configuration = new ConfigurationBuilder()
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
DatabaseSettings settings = DatabaseSettings.FromConfiguration(configuration);
|
||||||
|
|
||||||
if (settings.ClearDatabase)
|
if (settings.ClearDatabase)
|
||||||
{
|
{
|
||||||
@@ -159,28 +163,28 @@ public static class Program
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the settings from the <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>,
|
/// 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>
|
/// <c>DB_PASSWORD</c>, <c>DB_TRUST_SERVER_CERTIFICATE</c>, and <c>CLEAR_DATABASE</c>
|
||||||
/// environment variables.
|
/// configuration values.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when a required environment variable is not set.
|
/// Thrown when a required configuration value is not set.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public static DatabaseSettings FromEnvironment()
|
public static DatabaseSettings FromConfiguration(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
string databaseName =
|
string databaseName =
|
||||||
Environment.GetEnvironmentVariable("DB_NAME")
|
configuration["DB_NAME"]
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The DB_NAME environment variable is not set."
|
"The DB_NAME environment variable is not set."
|
||||||
);
|
);
|
||||||
|
|
||||||
bool clearDatabase = string.Equals(
|
bool clearDatabase = string.Equals(
|
||||||
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
|
configuration["CLEAR_DATABASE"],
|
||||||
"true",
|
"true",
|
||||||
StringComparison.OrdinalIgnoreCase
|
StringComparison.OrdinalIgnoreCase
|
||||||
);
|
);
|
||||||
|
|
||||||
return new DatabaseSettings(
|
return new DatabaseSettings(
|
||||||
SqlConnectionStringHelper.BuildConnectionString(databaseName),
|
SqlConnectionStringHelper.BuildConnectionString(configuration, databaseName),
|
||||||
SqlConnectionStringHelper.BuildMasterConnectionString(),
|
SqlConnectionStringHelper.BuildMasterConnectionString(configuration),
|
||||||
databaseName,
|
databaseName,
|
||||||
clearDatabase
|
clearDatabase
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#region
|
#region
|
||||||
|
|
||||||
using Infrastructure.Sql;
|
using Infrastructure.Sql;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -8,7 +9,9 @@ namespace Database.Seed.DatabaseHelpers;
|
|||||||
|
|
||||||
public class ConnectionStrings
|
public class ConnectionStrings
|
||||||
{
|
{
|
||||||
public static string SqlServerConnectionString => SqlConnectionStringHelper.BuildConnectionString();
|
public static string GetSqlServerConnectionString(IConfiguration configuration) =>
|
||||||
|
SqlConnectionStringHelper.BuildConnectionString(configuration);
|
||||||
|
|
||||||
public static string SqliteConnectionString =>
|
public static string SqliteConnectionString =>
|
||||||
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
|
"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 Database.Seed.Sqlite;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Features.Auth.DependencyInjection;
|
using Features.Auth.DependencyInjection;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Breweries.DependencyInjection;
|
using Features.Breweries.DependencyInjection;
|
||||||
using Features.Breweries.Repository;
|
using Features.Breweries.Repository;
|
||||||
using Features.Locations.DependencyInjection;
|
using Features.Locations.DependencyInjection;
|
||||||
using Features.Locations.Dtos;
|
using Features.Locations.Dtos;
|
||||||
using Features.Locations.Repository;
|
using Features.Locations.Repository;
|
||||||
using idunno.Password;
|
using idunno.Password;
|
||||||
using Infrastructure.PasswordHashing;
|
|
||||||
using Infrastructure.Sql.DependencyInjection;
|
using Infrastructure.Sql.DependencyInjection;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Spectre.Console;
|
using Spectre.Console;
|
||||||
@@ -22,9 +22,12 @@ static async Task<int> RunAsync()
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
IConfiguration configuration = new ConfigurationBuilder()
|
||||||
|
.AddEnvironmentVariables()
|
||||||
|
.Build();
|
||||||
|
|
||||||
ServiceCollection services = [];
|
ServiceCollection services = [];
|
||||||
services.AddSingleton<IConfiguration>(
|
services.AddSingleton(configuration);
|
||||||
new ConfigurationBuilder().Build());
|
|
||||||
services.AddInfrastructureSql();
|
services.AddInfrastructureSql();
|
||||||
services.AddFeaturesBreweries();
|
services.AddFeaturesBreweries();
|
||||||
services.AddFeaturesLocations();
|
services.AddFeaturesLocations();
|
||||||
@@ -36,10 +39,8 @@ static async Task<int> RunAsync()
|
|||||||
provider.GetRequiredService<IBreweryRepository>();
|
provider.GetRequiredService<IBreweryRepository>();
|
||||||
ILocationRepository locationRepository =
|
ILocationRepository locationRepository =
|
||||||
provider.GetRequiredService<ILocationRepository>();
|
provider.GetRequiredService<ILocationRepository>();
|
||||||
IAuthRepository authRepository =
|
UserManager<ApplicationUser> userManager =
|
||||||
provider.GetRequiredService<IAuthRepository>();
|
provider.GetRequiredService<UserManager<ApplicationUser>>();
|
||||||
IPasswordInfrastructure passwordInfrastructure =
|
|
||||||
provider.GetRequiredService<IPasswordInfrastructure>();
|
|
||||||
|
|
||||||
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]")
|
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]")
|
||||||
.LeftJustified());
|
.LeftJustified());
|
||||||
@@ -94,11 +95,7 @@ static async Task<int> RunAsync()
|
|||||||
"Loading user data into target database...",
|
"Loading user data into target database...",
|
||||||
async ctx =>
|
async ctx =>
|
||||||
{
|
{
|
||||||
postedByIds = await LoadUsersIntoDatabaseAsync(
|
postedByIds = await LoadUsersIntoDatabaseAsync(userManager, seedData.Users);
|
||||||
authRepository,
|
|
||||||
passwordInfrastructure,
|
|
||||||
seedData.Users
|
|
||||||
);
|
|
||||||
ctx.Status("User data loaded into target database.");
|
ctx.Status("User data loaded into target database.");
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -132,8 +129,7 @@ static async Task<int> RunAsync()
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
||||||
IAuthRepository authRepository,
|
UserManager<ApplicationUser> userManager,
|
||||||
IPasswordInfrastructure passwordInfrastructure,
|
|
||||||
IReadOnlyList<UserRecord> users
|
IReadOnlyList<UserRecord> users
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
@@ -141,22 +137,29 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
|
|||||||
|
|
||||||
foreach (UserRecord userRecord in users)
|
foreach (UserRecord userRecord in users)
|
||||||
{
|
{
|
||||||
UserAccount userAccount = await authRepository.RegisterUserAsync(
|
ApplicationUser user = new()
|
||||||
new UserAccount()
|
|
||||||
{
|
{
|
||||||
FirstName = userRecord.User.FirstName,
|
FirstName = userRecord.User.FirstName,
|
||||||
LastName = userRecord.User.LastName,
|
LastName = userRecord.User.LastName,
|
||||||
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
|
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
|
||||||
Username = userRecord.User.Username,
|
UserName = userRecord.User.Username,
|
||||||
Email = userRecord.Email,
|
Email = userRecord.Email,
|
||||||
UserCredential = new UserCredential
|
};
|
||||||
{
|
|
||||||
Hash = passwordInfrastructure.Hash(
|
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
|
||||||
password: PasswordGenerator.Generate(64, 12, 12))
|
// 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;
|
return userAccountIds;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN dotnet restore "Core.slnx"
|
RUN dotnet restore "Biergarten.API.slnx"
|
||||||
RUN dotnet build "Core.slnx" -c $BUILD_CONFIGURATION
|
RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION
|
||||||
|
|
||||||
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is
|
# 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
|
# 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.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Domain.Entities;
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Commands.ConfirmUser;
|
using Features.Auth.Commands.ConfirmUser;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using Features.Auth.Tests.TestSupport;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Commands;
|
namespace Features.Auth.Tests.Commands;
|
||||||
|
|
||||||
public class ConfirmUserHandlerTests
|
public class ConfirmUserHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
private readonly Mock<IUserEmailStore<ApplicationUser>> _emailStoreMock;
|
||||||
private readonly ConfirmUserHandler _handler;
|
private readonly ConfirmUserHandler _handler;
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||||
|
|
||||||
public ConfirmUserHandlerTests()
|
public ConfirmUserHandlerTests()
|
||||||
{
|
{
|
||||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
_userManagerMock = UserManagerMockFactory.Create();
|
||||||
|
_emailStoreMock = new Mock<IUserEmailStore<ApplicationUser>>();
|
||||||
_tokenServiceMock = new Mock<ITokenService>();
|
_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)
|
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
|
||||||
@@ -44,12 +51,13 @@ public class ConfirmUserHandlerTests
|
|||||||
const string confirmationToken = "valid-confirmation-token";
|
const string confirmationToken = "valid-confirmation-token";
|
||||||
|
|
||||||
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
|
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
|
||||||
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
|
ApplicationUser user = new() { Id = userId, UserName = username };
|
||||||
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||||
.ReturnsAsync(validatedToken);
|
.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(
|
ConfirmationPayload result = await _handler.Handle(
|
||||||
new ConfirmUserCommand(confirmationToken),
|
new ConfirmUserCommand(confirmationToken),
|
||||||
@@ -59,6 +67,12 @@ public class ConfirmUserHandlerTests
|
|||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserAccountId.Should().Be(userId);
|
result.UserAccountId.Should().Be(userId);
|
||||||
result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
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]
|
[Fact]
|
||||||
@@ -85,9 +99,9 @@ public class ConfirmUserHandlerTests
|
|||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||||
.ReturnsAsync(MakeValidatedToken(userId, username));
|
.ReturnsAsync(MakeValidatedToken(userId, username));
|
||||||
_authRepositoryMock
|
_userManagerMock
|
||||||
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||||
.ReturnsAsync((UserAccount?)null);
|
.ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
||||||
Func<Task<ConfirmationPayload>> act = async () =>
|
Func<Task<ConfirmationPayload>> act = async () =>
|
||||||
await _handler.Handle(
|
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.Commands.RefreshToken;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
@@ -15,11 +14,12 @@ public class RefreshTokenHandlerTests
|
|||||||
Mock<ITokenService> tokenServiceMock = new();
|
Mock<ITokenService> tokenServiceMock = new();
|
||||||
RefreshTokenHandler handler = new(tokenServiceMock.Object);
|
RefreshTokenHandler handler = new(tokenServiceMock.Object);
|
||||||
Guid userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
|
|
||||||
|
|
||||||
tokenServiceMock
|
tokenServiceMock
|
||||||
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
|
.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(
|
LoginPayload result = await handler.Handle(
|
||||||
new RefreshTokenCommand("old-refresh-token"),
|
new RefreshTokenCommand("old-refresh-token"),
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
using Domain.Entities;
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Commands.RegisterUser;
|
using Features.Auth.Commands.RegisterUser;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using Features.Auth.Tests.TestSupport;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.PasswordHashing;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Shared.Application.Emails;
|
using Shared.Application.Emails;
|
||||||
|
|
||||||
@@ -14,22 +14,19 @@ namespace Features.Auth.Tests.Commands;
|
|||||||
|
|
||||||
public class RegisterUserHandlerTests
|
public class RegisterUserHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
|
||||||
private readonly RegisterUserHandler _handler;
|
private readonly RegisterUserHandler _handler;
|
||||||
private readonly Mock<IMediator> _mediatorMock;
|
private readonly Mock<IMediator> _mediatorMock;
|
||||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||||
|
|
||||||
public RegisterUserHandlerTests()
|
public RegisterUserHandlerTests()
|
||||||
{
|
{
|
||||||
_authRepoMock = new Mock<IAuthRepository>();
|
_userManagerMock = UserManagerMockFactory.Create();
|
||||||
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
|
||||||
_tokenServiceMock = new Mock<ITokenService>();
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
_mediatorMock = new Mock<IMediator>();
|
_mediatorMock = new Mock<IMediator>();
|
||||||
|
|
||||||
_handler = new RegisterUserHandler(
|
_handler = new RegisterUserHandler(
|
||||||
_authRepoMock.Object,
|
_userManagerMock.Object,
|
||||||
_passwordInfraMock.Object,
|
|
||||||
_tokenServiceMock.Object,
|
_tokenServiceMock.Object,
|
||||||
_mediatorMock.Object
|
_mediatorMock.Object
|
||||||
);
|
);
|
||||||
@@ -54,51 +51,32 @@ public class RegisterUserHandlerTests
|
|||||||
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
|
||||||
{
|
{
|
||||||
RegisterUserCommand command = ValidCommand();
|
RegisterUserCommand command = ValidCommand();
|
||||||
const string hashedPassword = "hashed_password_value";
|
|
||||||
Guid expectedUserId = Guid.NewGuid();
|
Guid expectedUserId = Guid.NewGuid();
|
||||||
|
|
||||||
_authRepoMock
|
_userManagerMock
|
||||||
.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
|
|
||||||
.Setup(x =>
|
.Setup(x =>
|
||||||
x.RegisterUserAsync(
|
x.CreateAsync(
|
||||||
It.Is<UserAccount>(ua =>
|
It.Is<ApplicationUser>(u =>
|
||||||
ua.FirstName == command.FirstName
|
u.FirstName == command.FirstName
|
||||||
&& ua.LastName == command.LastName
|
&& u.LastName == command.LastName
|
||||||
&& ua.Username == command.Username
|
&& u.UserName == command.Username
|
||||||
&& ua.Email == command.Email
|
&& u.Email == command.Email
|
||||||
&& ua.DateOfBirth == command.DateOfBirth
|
&& u.DateOfBirth == command.DateOfBirth
|
||||||
&& ua.UserCredential!.Hash == hashedPassword
|
),
|
||||||
|
command.Password
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
.Callback<ApplicationUser, string>((u, _) => u.Id = expectedUserId)
|
||||||
.ReturnsAsync(
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
new UserAccount
|
|
||||||
{
|
|
||||||
UserAccountId = expectedUserId,
|
|
||||||
Username = command.Username,
|
|
||||||
FirstName = command.FirstName,
|
|
||||||
LastName = command.LastName,
|
|
||||||
Email = command.Email,
|
|
||||||
DateOfBirth = command.DateOfBirth,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("access-token");
|
.Returns("access-token");
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("refresh-token");
|
.Returns("refresh-token");
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateConfirmationToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("confirmation-token");
|
.Returns("confirmation-token");
|
||||||
|
|
||||||
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -120,18 +98,14 @@ public class RegisterUserHandlerTests
|
|||||||
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
public async Task Handle_WithExistingUsername_ThrowsConflictException()
|
||||||
{
|
{
|
||||||
RegisterUserCommand command = ValidCommand("existinguser");
|
RegisterUserCommand command = ValidCommand("existinguser");
|
||||||
UserAccount existingUser = new()
|
|
||||||
{
|
|
||||||
UserAccountId = Guid.NewGuid(),
|
|
||||||
Username = "existinguser",
|
|
||||||
};
|
|
||||||
|
|
||||||
_authRepoMock
|
_userManagerMock
|
||||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||||
.ReturnsAsync(existingUser);
|
.ReturnsAsync(
|
||||||
_authRepoMock
|
IdentityResult.Failed(
|
||||||
.Setup(x => x.GetUserByEmailAsync(command.Email))
|
new IdentityError { Code = "DuplicateUserName", Description = "Username taken." }
|
||||||
.ReturnsAsync((UserAccount?)null);
|
)
|
||||||
|
);
|
||||||
|
|
||||||
Func<Task<RegistrationPayload>> act = async () =>
|
Func<Task<RegistrationPayload>> act = async () =>
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -139,27 +113,20 @@ public class RegisterUserHandlerTests
|
|||||||
await act.Should()
|
await act.Should()
|
||||||
.ThrowAsync<ConflictException>()
|
.ThrowAsync<ConflictException>()
|
||||||
.WithMessage("Username or email already exists");
|
.WithMessage("Username or email already exists");
|
||||||
|
|
||||||
_authRepoMock.Verify(
|
|
||||||
x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
|
|
||||||
Times.Never
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
public async Task Handle_WithExistingEmail_ThrowsConflictException()
|
||||||
{
|
{
|
||||||
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
|
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
|
||||||
UserAccount existingUser = new()
|
|
||||||
{
|
|
||||||
UserAccountId = Guid.NewGuid(),
|
|
||||||
Email = "existing@example.com",
|
|
||||||
};
|
|
||||||
|
|
||||||
_authRepoMock
|
_userManagerMock
|
||||||
.Setup(x => x.GetUserByUsernameAsync(command.Username))
|
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||||
.ReturnsAsync((UserAccount?)null);
|
.ReturnsAsync(
|
||||||
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
|
IdentityResult.Failed(
|
||||||
|
new IdentityError { Code = "DuplicateEmail", Description = "Email taken." }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
Func<Task<RegistrationPayload>> act = async () =>
|
Func<Task<RegistrationPayload>> act = async () =>
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
@@ -173,45 +140,34 @@ public class RegisterUserHandlerTests
|
|||||||
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
|
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
|
||||||
{
|
{
|
||||||
RegisterUserCommand command = ValidCommand();
|
RegisterUserCommand command = ValidCommand();
|
||||||
const string hashedPassword = "hashed_secure_password";
|
|
||||||
|
|
||||||
_authRepoMock
|
_userManagerMock
|
||||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||||
.ReturnsAsync((UserAccount?)null);
|
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
|
||||||
_authRepoMock
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
.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() });
|
|
||||||
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("access-token");
|
.Returns("access-token");
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("refresh-token");
|
.Returns("refresh-token");
|
||||||
|
|
||||||
await _handler.Handle(command, CancellationToken.None);
|
await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
|
// UserManager.CreateAsync receives the plaintext password and hashes it internally via
|
||||||
_authRepoMock.Verify(
|
// IPasswordHasher<ApplicationUser> -- the handler never sees or stores a hash directly.
|
||||||
|
_userManagerMock.Verify(
|
||||||
x =>
|
x =>
|
||||||
x.RegisterUserAsync(
|
x.CreateAsync(
|
||||||
It.Is<UserAccount>(ua =>
|
It.Is<ApplicationUser>(u =>
|
||||||
ua.FirstName == command.FirstName
|
u.FirstName == command.FirstName
|
||||||
&& ua.LastName == command.LastName
|
&& u.LastName == command.LastName
|
||||||
&& ua.Username == command.Username
|
&& u.UserName == command.Username
|
||||||
&& ua.DateOfBirth == command.DateOfBirth
|
&& u.DateOfBirth == command.DateOfBirth
|
||||||
&& ua.Email == command.Email
|
&& u.Email == command.Email
|
||||||
&& ua.UserCredential!.Hash == hashedPassword
|
),
|
||||||
)
|
command.Password
|
||||||
),
|
),
|
||||||
Times.Once
|
Times.Once
|
||||||
);
|
);
|
||||||
@@ -222,29 +178,16 @@ public class RegisterUserHandlerTests
|
|||||||
{
|
{
|
||||||
RegisterUserCommand command = ValidCommand();
|
RegisterUserCommand command = ValidCommand();
|
||||||
|
|
||||||
_authRepoMock
|
_userManagerMock
|
||||||
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
|
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
|
||||||
.ReturnsAsync((UserAccount?)null);
|
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
|
||||||
_authRepoMock
|
.ReturnsAsync(IdentityResult.Success);
|
||||||
.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,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("access-token");
|
.Returns("access-token");
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("refresh-token");
|
.Returns("refresh-token");
|
||||||
_mediatorMock
|
_mediatorMock
|
||||||
.Setup(x =>
|
.Setup(x =>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
using Domain.Entities;
|
|
||||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
using Features.Auth.Commands.ResendConfirmationEmail;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using Features.Auth.Tests.TestSupport;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Shared.Application.Emails;
|
using Shared.Application.Emails;
|
||||||
|
|
||||||
@@ -10,15 +11,15 @@ namespace Features.Auth.Tests.Commands;
|
|||||||
|
|
||||||
public class ResendConfirmationEmailHandlerTests
|
public class ResendConfirmationEmailHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
|
|
||||||
private readonly ResendConfirmationEmailHandler _handler;
|
private readonly ResendConfirmationEmailHandler _handler;
|
||||||
private readonly Mock<IMediator> _mediatorMock = new();
|
private readonly Mock<IMediator> _mediatorMock = new();
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
private readonly Mock<ITokenService> _tokenServiceMock = new();
|
||||||
|
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock = UserManagerMockFactory.Create();
|
||||||
|
|
||||||
public ResendConfirmationEmailHandlerTests()
|
public ResendConfirmationEmailHandlerTests()
|
||||||
{
|
{
|
||||||
_handler = new ResendConfirmationEmailHandler(
|
_handler = new ResendConfirmationEmailHandler(
|
||||||
_authRepositoryMock.Object,
|
_userManagerMock.Object,
|
||||||
_tokenServiceMock.Object,
|
_tokenServiceMock.Object,
|
||||||
_mediatorMock.Object
|
_mediatorMock.Object
|
||||||
);
|
);
|
||||||
@@ -28,16 +29,18 @@ public class ResendConfirmationEmailHandlerTests
|
|||||||
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
|
||||||
{
|
{
|
||||||
Guid userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
UserAccount user = new()
|
ApplicationUser user = new()
|
||||||
{
|
{
|
||||||
UserAccountId = userId,
|
Id = userId,
|
||||||
FirstName = "Aaron",
|
FirstName = "Aaron",
|
||||||
Email = "aaron@example.com",
|
Email = "aaron@example.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
|
_userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(false);
|
||||||
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user)).Returns("fresh-token");
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.GenerateConfirmationToken(user.Id, user.UserName))
|
||||||
|
.Returns("fresh-token");
|
||||||
|
|
||||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
@@ -59,7 +62,9 @@ public class ResendConfirmationEmailHandlerTests
|
|||||||
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
|
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
|
||||||
{
|
{
|
||||||
Guid userId = Guid.NewGuid();
|
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);
|
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
||||||
|
|
||||||
@@ -77,10 +82,10 @@ public class ResendConfirmationEmailHandlerTests
|
|||||||
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
|
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
|
||||||
{
|
{
|
||||||
Guid userId = Guid.NewGuid();
|
Guid userId = Guid.NewGuid();
|
||||||
UserAccount user = new() { UserAccountId = userId };
|
ApplicationUser user = new() { Id = userId };
|
||||||
|
|
||||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
|
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
|
||||||
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
|
_userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(true);
|
||||||
|
|
||||||
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
|
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 Domain.Exceptions;
|
||||||
using Features.Auth.Commands.Login;
|
using Features.Auth.Commands.Login;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using Features.Auth.Tests.TestSupport;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.PasswordHashing;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Queries;
|
namespace Features.Auth.Tests.Queries;
|
||||||
|
|
||||||
public class LoginHandlerTests
|
public class LoginHandlerTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepoMock;
|
|
||||||
private readonly LoginHandler _handler;
|
private readonly LoginHandler _handler;
|
||||||
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
|
|
||||||
private readonly Mock<ITokenService> _tokenServiceMock;
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||||
|
|
||||||
public LoginHandlerTests()
|
public LoginHandlerTests()
|
||||||
{
|
{
|
||||||
_authRepoMock = new Mock<IAuthRepository>();
|
_userManagerMock = UserManagerMockFactory.Create();
|
||||||
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
|
|
||||||
_tokenServiceMock = new Mock<ITokenService>();
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
_handler = new LoginHandler(
|
_handler = new LoginHandler(_userManagerMock.Object, _tokenServiceMock.Object);
|
||||||
_authRepoMock.Object,
|
|
||||||
_passwordInfraMock.Object,
|
|
||||||
_tokenServiceMock.Object
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -35,36 +29,25 @@ public class LoginHandlerTests
|
|||||||
const string username = "CogitoErgoSum";
|
const string username = "CogitoErgoSum";
|
||||||
Guid userAccountId = Guid.NewGuid();
|
Guid userAccountId = Guid.NewGuid();
|
||||||
|
|
||||||
UserAccount userAccount = new()
|
ApplicationUser user = new()
|
||||||
{
|
{
|
||||||
UserAccountId = userAccountId,
|
Id = userAccountId,
|
||||||
Username = username,
|
UserName = username,
|
||||||
FirstName = "René",
|
FirstName = "René",
|
||||||
LastName = "Descartes",
|
LastName = "Descartes",
|
||||||
Email = "r.descartes@example.com",
|
Email = "r.descartes@example.com",
|
||||||
DateOfBirth = new DateTime(1596, 03, 31),
|
DateOfBirth = new DateTime(1596, 03, 31),
|
||||||
};
|
};
|
||||||
|
|
||||||
UserCredential userCredential = new()
|
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||||
{
|
_userManagerMock
|
||||||
UserCredentialId = Guid.NewGuid(),
|
.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>()))
|
||||||
UserAccountId = userAccountId,
|
.ReturnsAsync(true);
|
||||||
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);
|
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("access-token");
|
.Returns("access-token");
|
||||||
_tokenServiceMock
|
_tokenServiceMock
|
||||||
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
|
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
|
||||||
.Returns("refresh-token");
|
.Returns("refresh-token");
|
||||||
|
|
||||||
LoginPayload result = await _handler.Handle(
|
LoginPayload result = await _handler.Handle(
|
||||||
@@ -83,16 +66,16 @@ public class LoginHandlerTests
|
|||||||
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
|
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
const string username = "de_beauvoir";
|
const string username = "de_beauvoir";
|
||||||
_authRepoMock
|
_userManagerMock
|
||||||
.Setup(x => x.GetUserByUsernameAsync(username))
|
.Setup(x => x.FindByNameAsync(username))
|
||||||
.ReturnsAsync((UserAccount?)null);
|
.ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
||||||
Func<Task<LoginPayload>> act = async () =>
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
||||||
|
|
||||||
await act.Should().ThrowAsync<UnauthorizedException>();
|
await act.Should().ThrowAsync<UnauthorizedException>();
|
||||||
_authRepoMock.Verify(
|
_userManagerMock.Verify(
|
||||||
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
|
x => x.CheckPasswordAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()),
|
||||||
Times.Never
|
Times.Never
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -101,13 +84,10 @@ public class LoginHandlerTests
|
|||||||
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
|
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
const string username = "BRussell";
|
const string username = "BRussell";
|
||||||
Guid userAccountId = Guid.NewGuid();
|
ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
|
||||||
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||||
_authRepoMock
|
_userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
|
||||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
|
||||||
.ReturnsAsync((UserCredential?)null);
|
|
||||||
|
|
||||||
Func<Task<LoginPayload>> act = async () =>
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
|
||||||
@@ -115,32 +95,16 @@ public class LoginHandlerTests
|
|||||||
await act.Should()
|
await act.Should()
|
||||||
.ThrowAsync<UnauthorizedException>()
|
.ThrowAsync<UnauthorizedException>()
|
||||||
.WithMessage("Invalid username or password.");
|
.WithMessage("Invalid username or password.");
|
||||||
_passwordInfraMock.Verify(
|
|
||||||
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
|
|
||||||
Times.Never
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
|
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
|
||||||
{
|
{
|
||||||
const string username = "RCarnap";
|
const string username = "RCarnap";
|
||||||
Guid userAccountId = Guid.NewGuid();
|
ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
|
||||||
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
|
|
||||||
UserCredential userCredential = new()
|
|
||||||
{
|
|
||||||
UserCredentialId = Guid.NewGuid(),
|
|
||||||
UserAccountId = userAccountId,
|
|
||||||
Hash = "hashed-password",
|
|
||||||
};
|
|
||||||
|
|
||||||
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
|
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
|
||||||
_authRepoMock
|
_userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
|
||||||
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
|
|
||||||
.ReturnsAsync(userCredential);
|
|
||||||
_passwordInfraMock
|
|
||||||
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
|
|
||||||
.Returns(false);
|
|
||||||
|
|
||||||
Func<Task<LoginPayload>> act = async () =>
|
Func<Task<LoginPayload>> act = async () =>
|
||||||
await _handler.Handle(
|
await _handler.Handle(
|
||||||
|
|||||||
@@ -1,41 +1,45 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Domain.Entities;
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using Features.Auth.Tests.TestSupport;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Services;
|
namespace Features.Auth.Tests.Services;
|
||||||
|
|
||||||
public class TokenServiceRefreshTests
|
public class TokenServiceRefreshTests
|
||||||
{
|
{
|
||||||
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
|
||||||
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
private readonly TokenService _tokenService;
|
private readonly TokenService _tokenService;
|
||||||
|
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
|
||||||
|
|
||||||
public TokenServiceRefreshTests()
|
public TokenServiceRefreshTests()
|
||||||
{
|
{
|
||||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
_authRepositoryMock = new Mock<IAuthRepository>();
|
_userManagerMock = UserManagerMockFactory.Create();
|
||||||
|
|
||||||
// Set environment variables for tokens
|
IConfiguration configuration = new ConfigurationBuilder()
|
||||||
Environment.SetEnvironmentVariable(
|
.AddInMemoryCollection(
|
||||||
"ACCESS_TOKEN_SECRET",
|
new Dictionary<string, string?>
|
||||||
"test-access-secret-that-is-very-long-1234567890"
|
{
|
||||||
);
|
["ACCESS_TOKEN_SECRET"] = "test-access-secret-that-is-very-long-1234567890",
|
||||||
Environment.SetEnvironmentVariable(
|
["REFRESH_TOKEN_SECRET"] = "test-refresh-secret-that-is-very-long-1234567890",
|
||||||
"REFRESH_TOKEN_SECRET",
|
["CONFIRMATION_TOKEN_SECRET"] =
|
||||||
"test-refresh-secret-that-is-very-long-1234567890"
|
"test-confirmation-secret-that-is-very-long-1234567890",
|
||||||
);
|
}
|
||||||
Environment.SetEnvironmentVariable(
|
)
|
||||||
"CONFIRMATION_TOKEN_SECRET",
|
.Build();
|
||||||
"test-confirmation-secret-that-is-very-long-1234567890"
|
|
||||||
);
|
|
||||||
|
|
||||||
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
|
_tokenService = new TokenService(
|
||||||
|
_tokenInfraMock.Object,
|
||||||
|
_userManagerMock.Object,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -56,15 +60,7 @@ public class TokenServiceRefreshTests
|
|||||||
ClaimsIdentity claimsIdentity = new(claims);
|
ClaimsIdentity claimsIdentity = new(claims);
|
||||||
ClaimsPrincipal principal = new(claimsIdentity);
|
ClaimsPrincipal principal = new(claimsIdentity);
|
||||||
|
|
||||||
UserAccount userAccount = new()
|
ApplicationUser user = new() { Id = userId, UserName = username };
|
||||||
{
|
|
||||||
UserAccountId = userId,
|
|
||||||
Username = username,
|
|
||||||
FirstName = "Test",
|
|
||||||
LastName = "User",
|
|
||||||
Email = "test@example.com",
|
|
||||||
DateOfBirth = new DateTime(1990, 1, 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock the validation of refresh token
|
// Mock the validation of refresh token
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
@@ -78,19 +74,19 @@ public class TokenServiceRefreshTests
|
|||||||
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
|
(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
|
// Act
|
||||||
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
|
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
result.Should().NotBeNull();
|
result.Should().NotBeNull();
|
||||||
result.UserAccount.UserAccountId.Should().Be(userId);
|
result.UserId.Should().Be(userId);
|
||||||
result.UserAccount.Username.Should().Be(username);
|
result.Username.Should().Be(username);
|
||||||
result.AccessToken.Should().NotBeEmpty();
|
result.AccessToken.Should().NotBeEmpty();
|
||||||
result.RefreshToken.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)
|
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||||
_tokenInfraMock.Verify(
|
_tokenInfraMock.Verify(
|
||||||
@@ -161,7 +157,9 @@ public class TokenServiceRefreshTests
|
|||||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
.ReturnsAsync(principal);
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
|
_userManagerMock
|
||||||
|
.Setup(x => x.FindByIdAsync(userId.ToString()))
|
||||||
|
.ReturnsAsync((ApplicationUser?)null);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
await FluentActions
|
await FluentActions
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using System.IdentityModel.Tokens.Jwt;
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Repository;
|
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
|
using Features.Auth.Tests.TestSupport;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
|
||||||
namespace Features.Auth.Tests.Services;
|
namespace Features.Auth.Tests.Services;
|
||||||
@@ -18,21 +19,23 @@ public class TokenServiceValidationTests
|
|||||||
{
|
{
|
||||||
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
|
|
||||||
// Set environment variables for tokens
|
IConfiguration configuration = new ConfigurationBuilder()
|
||||||
Environment.SetEnvironmentVariable(
|
.AddInMemoryCollection(
|
||||||
"ACCESS_TOKEN_SECRET",
|
new Dictionary<string, string?>
|
||||||
"test-access-secret-that-is-very-long-1234567890"
|
{
|
||||||
);
|
["ACCESS_TOKEN_SECRET"] = "test-access-secret-that-is-very-long-1234567890",
|
||||||
Environment.SetEnvironmentVariable(
|
["REFRESH_TOKEN_SECRET"] = "test-refresh-secret-that-is-very-long-1234567890",
|
||||||
"REFRESH_TOKEN_SECRET",
|
["CONFIRMATION_TOKEN_SECRET"] =
|
||||||
"test-refresh-secret-that-is-very-long-1234567890"
|
"test-confirmation-secret-that-is-very-long-1234567890",
|
||||||
);
|
}
|
||||||
Environment.SetEnvironmentVariable(
|
)
|
||||||
"CONFIRMATION_TOKEN_SECRET",
|
.Build();
|
||||||
"test-confirmation-secret-that-is-very-long-1234567890"
|
|
||||||
);
|
|
||||||
|
|
||||||
_tokenService = new TokenService(_tokenInfraMock.Object, new Mock<IAuthRepository>().Object);
|
_tokenService = new TokenService(
|
||||||
|
_tokenInfraMock.Object,
|
||||||
|
UserManagerMockFactory.Create().Object,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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 Domain.Exceptions;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
namespace Features.Auth.Commands.ConfirmUser;
|
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
|
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
|
||||||
/// corresponding user account as confirmed.
|
/// corresponding user account as confirmed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
|
public class ConfirmUserHandler(
|
||||||
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
UserManager<ApplicationUser> userManager,
|
||||||
|
IUserEmailStore<ApplicationUser> emailStore,
|
||||||
|
ITokenService tokenService
|
||||||
|
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
|
||||||
{
|
{
|
||||||
/// <exception cref="UnauthorizedException">
|
/// <exception cref="UnauthorizedException">
|
||||||
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
|
/// 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
|
request.Token
|
||||||
);
|
);
|
||||||
|
|
||||||
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
|
ApplicationUser? user = await userManager.FindByIdAsync(
|
||||||
|
validatedToken.UserId.ToString()
|
||||||
|
);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
throw new UnauthorizedException("User account not found");
|
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 Domain.Exceptions;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
using Infrastructure.PasswordHashing;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
|
||||||
namespace Features.Auth.Commands.Login;
|
namespace Features.Auth.Commands.Login;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public class LoginHandler(
|
public class LoginHandler(UserManager<ApplicationUser> userManager, ITokenService tokenService)
|
||||||
IAuthRepository authRepo,
|
: IRequestHandler<LoginCommand, LoginPayload>
|
||||||
IPasswordInfrastructure passwordInfrastructure,
|
|
||||||
ITokenService tokenService
|
|
||||||
) : IRequestHandler<LoginCommand, LoginPayload>
|
|
||||||
{
|
{
|
||||||
/// <exception cref="UnauthorizedException">
|
/// <exception cref="UnauthorizedException">
|
||||||
/// Thrown when the username does not match any account, the account has no active credential,
|
/// Thrown when the username does not match any account or the supplied password does not match
|
||||||
/// or the supplied password does not match the stored hash.
|
/// the stored hash.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
|
public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
UserAccount user =
|
ApplicationUser user =
|
||||||
await authRepo.GetUserByUsernameAsync(request.Username)
|
await userManager.FindByNameAsync(request.Username)
|
||||||
?? throw new UnauthorizedException("Invalid username or password.");
|
?? throw new UnauthorizedException("Invalid username or password.");
|
||||||
|
|
||||||
// @todo handle expired passwords
|
if (!await userManager.CheckPasswordAsync(user, request.Password))
|
||||||
UserCredential activeCred =
|
|
||||||
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
|
|
||||||
?? throw new UnauthorizedException("Invalid username or password.");
|
|
||||||
|
|
||||||
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
|
|
||||||
throw new UnauthorizedException("Invalid username or password.");
|
throw new UnauthorizedException("Invalid username or password.");
|
||||||
|
|
||||||
string accessToken = tokenService.GenerateAccessToken(user);
|
string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
|
||||||
string refreshToken = tokenService.GenerateRefreshToken(user);
|
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;
|
namespace Features.Auth.Commands.RefreshToken;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
|
/// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the
|
||||||
/// request body of <c>POST /api/auth/refresh</c>.
|
/// <c>X-Refresh-Token</c> header of <c>POST /api/auth/refresh</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ public class RefreshTokenHandler(ITokenService tokenService)
|
|||||||
{
|
{
|
||||||
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
|
||||||
return new LoginPayload(
|
return new LoginPayload(
|
||||||
result.UserAccount.UserAccountId,
|
result.UserId,
|
||||||
result.UserAccount.Username,
|
result.Username,
|
||||||
result.RefreshToken,
|
result.RefreshToken,
|
||||||
result.AccessToken
|
result.AccessToken
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,75 +1,63 @@
|
|||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Dtos;
|
using Features.Auth.Dtos;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
using Infrastructure.PasswordHashing;
|
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Shared.Application.Emails;
|
using Shared.Application.Emails;
|
||||||
|
|
||||||
namespace Features.Auth.Commands.RegisterUser;
|
namespace Features.Auth.Commands.RegisterUser;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
|
/// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" />
|
||||||
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
|
/// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and
|
||||||
/// confirmation email via Features.Emails.
|
/// attempts to send the registration confirmation email via Features.Emails.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class RegisterUserHandler(
|
public class RegisterUserHandler(
|
||||||
IAuthRepository authRepo,
|
UserManager<ApplicationUser> userManager,
|
||||||
IPasswordInfrastructure passwordInfrastructure,
|
|
||||||
ITokenService tokenService,
|
ITokenService tokenService,
|
||||||
IMediator mediator
|
IMediator mediator
|
||||||
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
|
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
|
||||||
{
|
{
|
||||||
/// <exception cref="ConflictException">
|
/// <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>
|
/// </exception>
|
||||||
public async Task<RegistrationPayload> Handle(
|
public async Task<RegistrationPayload> Handle(
|
||||||
RegisterUserCommand request,
|
RegisterUserCommand request,
|
||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
await ValidateUserDoesNotExist(request.Username, request.Email);
|
ApplicationUser user = new UserAccount
|
||||||
|
|
||||||
string hashed = passwordInfrastructure.Hash(request.Password);
|
|
||||||
|
|
||||||
UserAccount createdUser = await authRepo.RegisterUserAsync(
|
|
||||||
new UserAccount
|
|
||||||
{
|
{
|
||||||
Username = request.Username,
|
Username = request.Username,
|
||||||
|
Email = request.Email,
|
||||||
FirstName = request.FirstName,
|
FirstName = request.FirstName,
|
||||||
LastName = request.LastName,
|
LastName = request.LastName,
|
||||||
DateOfBirth = request.DateOfBirth,
|
DateOfBirth = request.DateOfBirth,
|
||||||
Email = request.Email,
|
}.ToApplicationUser();
|
||||||
UserCredential = new UserCredential
|
|
||||||
{
|
|
||||||
Hash = hashed,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
string accessToken = tokenService.GenerateAccessToken(createdUser);
|
IdentityResult result = await userManager.CreateAsync(user, request.Password);
|
||||||
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
if (!result.Succeeded)
|
||||||
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
{
|
||||||
|
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))
|
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
|
||||||
return new RegistrationPayload(
|
return new RegistrationPayload(user.Id, user.UserName, string.Empty, string.Empty, false);
|
||||||
createdUser.UserAccountId,
|
|
||||||
createdUser.Username,
|
|
||||||
string.Empty,
|
|
||||||
string.Empty,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
|
|
||||||
bool emailSent = false;
|
bool emailSent = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await mediator.Send(
|
await mediator.Send(
|
||||||
new SendRegistrationEmailCommand(
|
new SendRegistrationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||||
createdUser.FirstName,
|
|
||||||
createdUser.Email,
|
|
||||||
confirmationToken
|
|
||||||
),
|
|
||||||
cancellationToken
|
cancellationToken
|
||||||
);
|
);
|
||||||
emailSent = true;
|
emailSent = true;
|
||||||
@@ -81,24 +69,6 @@ public class RegisterUserHandler(
|
|||||||
// ignored
|
// ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RegistrationPayload(
|
return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent);
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
using Domain.Entities;
|
using Features.Auth.Identity;
|
||||||
using Features.Auth.Repository;
|
|
||||||
using Features.Auth.Services;
|
using Features.Auth.Services;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Shared.Application.Emails;
|
using Shared.Application.Emails;
|
||||||
|
|
||||||
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
namespace Features.Auth.Commands.ResendConfirmationEmail;
|
||||||
@@ -15,7 +15,7 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
|
|||||||
/// or if the user's account is already verified.
|
/// or if the user's account is already verified.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public class ResendConfirmationEmailHandler(
|
public class ResendConfirmationEmailHandler(
|
||||||
IAuthRepository authRepository,
|
UserManager<ApplicationUser> userManager,
|
||||||
ITokenService tokenService,
|
ITokenService tokenService,
|
||||||
IMediator mediator
|
IMediator mediator
|
||||||
) : IRequestHandler<ResendConfirmationEmailCommand>
|
) : IRequestHandler<ResendConfirmationEmailCommand>
|
||||||
@@ -25,14 +25,14 @@ public class ResendConfirmationEmailHandler(
|
|||||||
CancellationToken cancellationToken
|
CancellationToken cancellationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
|
ApplicationUser? user = await userManager.FindByIdAsync(request.UserId.ToString());
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return; // Silent return to prevent user enumeration
|
return; // Silent return to prevent user enumeration
|
||||||
|
|
||||||
if (await authRepository.IsUserVerifiedAsync(request.UserId))
|
if (await userManager.IsEmailConfirmedAsync(user))
|
||||||
return; // Already confirmed, no-op
|
return; // Already confirmed, no-op
|
||||||
|
|
||||||
string confirmationToken = tokenService.GenerateConfirmationToken(user);
|
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
|
||||||
await mediator.Send(
|
await mediator.Send(
|
||||||
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
|
||||||
cancellationToken
|
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.ConfirmUser;
|
||||||
|
using Features.Auth.Commands.DeleteAccount;
|
||||||
using Features.Auth.Commands.Login;
|
using Features.Auth.Commands.Login;
|
||||||
using Features.Auth.Commands.RefreshToken;
|
using Features.Auth.Commands.RefreshToken;
|
||||||
using Features.Auth.Commands.RegisterUser;
|
using Features.Auth.Commands.RegisterUser;
|
||||||
using Features.Auth.Commands.ResendConfirmationEmail;
|
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 Features.Auth.Dtos;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -12,7 +19,8 @@ using Shared.Contracts;
|
|||||||
namespace Features.Auth.Controllers;
|
namespace Features.Auth.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
|
/// 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")]
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
public class AuthController(IMediator mediator) : ControllerBase
|
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>
|
/// <summary>Registers a new user account.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
|
/// 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>
|
/// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is
|
/// Anonymous access. The refresh token is supplied via the <c>X-Refresh-Token</c> header, not the
|
||||||
/// invalid or expired.
|
/// 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>
|
/// </remarks>
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpPost("refresh")]
|
[HttpPost("refresh")]
|
||||||
public async Task<ActionResult<ResponseBody<LoginPayload>>> 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(
|
return Ok(
|
||||||
new ResponseBody<LoginPayload>
|
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 Features.Auth.Services;
|
||||||
using Infrastructure.PasswordHashing;
|
using Infrastructure.PasswordHashing;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Features.Auth.DependencyInjection;
|
namespace Features.Auth.DependencyInjection;
|
||||||
@@ -9,14 +10,44 @@ namespace Features.Auth.DependencyInjection;
|
|||||||
public static class FeaturesAuthServiceCollectionExtensions
|
public static class FeaturesAuthServiceCollectionExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds <see cref="IAuthRepository" />, <see cref="ITokenService" />, and the Argon2 password hashing
|
/// Adds <see cref="ITokenService" /> and ASP.NET Core Identity (<see cref="UserManager{TUser}" />
|
||||||
/// infrastructure as scoped services.
|
/// backed by <see cref="DapperUserStore" /> and <see cref="Argon2PasswordHasher" />) as scoped services.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddScoped<IAuthRepository, AuthRepository>();
|
|
||||||
services.AddScoped<ITokenService, TokenService>();
|
services.AddScoped<ITokenService, TokenService>();
|
||||||
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
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;
|
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>
|
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
|
||||||
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
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 System.Security.Claims;
|
||||||
using Domain.Entities;
|
|
||||||
|
|
||||||
namespace Features.Auth.Services;
|
namespace Features.Auth.Services;
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ public enum TokenType
|
|||||||
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||||
|
|
||||||
/// <summary>Represents the result of refreshing a user's session.</summary>
|
/// <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>
|
/// <summary>
|
||||||
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
|
/// 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
|
/// Generates a signed access token for the given user, expiring after
|
||||||
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
string GenerateAccessToken(UserAccount user);
|
string GenerateAccessToken(Guid userId, string username);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a signed refresh token for the given user, expiring after
|
/// Generates a signed refresh token for the given user, expiring after
|
||||||
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
string GenerateRefreshToken(UserAccount user);
|
string GenerateRefreshToken(Guid userId, string username);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generates a signed confirmation token for the given user, expiring after
|
/// Generates a signed confirmation token for the given user, expiring after
|
||||||
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
|
||||||
/// </summary>
|
/// </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>
|
/// <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.IdentityModel.Tokens.Jwt;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using Domain.Entities;
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Features.Auth.Repository;
|
using Features.Auth.Identity;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Features.Auth.Services;
|
namespace Features.Auth.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
|
/// 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>
|
/// </summary>
|
||||||
public class TokenService : ITokenService
|
public class TokenService : ITokenService
|
||||||
{
|
{
|
||||||
private readonly string _accessTokenSecret;
|
private readonly string _accessTokenSecret;
|
||||||
private readonly IAuthRepository _authRepository;
|
|
||||||
private readonly string _confirmationTokenSecret;
|
private readonly string _confirmationTokenSecret;
|
||||||
private readonly string _refreshTokenSecret;
|
private readonly string _refreshTokenSecret;
|
||||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||||
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
|
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
/// 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>
|
/// </exception>
|
||||||
public TokenService(ITokenInfrastructure tokenInfrastructure, IAuthRepository authRepository)
|
public TokenService(
|
||||||
|
ITokenInfrastructure tokenInfrastructure,
|
||||||
|
UserManager<ApplicationUser> userManager,
|
||||||
|
IConfiguration configuration
|
||||||
|
)
|
||||||
{
|
{
|
||||||
_tokenInfrastructure = tokenInfrastructure;
|
_tokenInfrastructure = tokenInfrastructure;
|
||||||
_authRepository = authRepository;
|
_userManager = userManager;
|
||||||
|
|
||||||
_accessTokenSecret =
|
_accessTokenSecret =
|
||||||
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
configuration["ACCESS_TOKEN_SECRET"]
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"ACCESS_TOKEN_SECRET environment variable is not set"
|
"ACCESS_TOKEN_SECRET environment variable is not set"
|
||||||
);
|
);
|
||||||
|
|
||||||
_refreshTokenSecret =
|
_refreshTokenSecret =
|
||||||
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
configuration["REFRESH_TOKEN_SECRET"]
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"REFRESH_TOKEN_SECRET environment variable is not set"
|
"REFRESH_TOKEN_SECRET environment variable is not set"
|
||||||
);
|
);
|
||||||
|
|
||||||
_confirmationTokenSecret =
|
_confirmationTokenSecret =
|
||||||
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
configuration["CONFIRMATION_TOKEN_SECRET"]
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
|
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GenerateAccessToken(UserAccount user)
|
public string GenerateAccessToken(Guid userId, string username)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
return _tokenInfrastructure.GenerateJwt(
|
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _accessTokenSecret);
|
||||||
user.UserAccountId,
|
|
||||||
user.Username,
|
|
||||||
expiresAt,
|
|
||||||
_accessTokenSecret
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GenerateRefreshToken(UserAccount user)
|
public string GenerateRefreshToken(Guid userId, string username)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||||
TokenServiceExpirationHours.RefreshTokenHours
|
TokenServiceExpirationHours.RefreshTokenHours
|
||||||
);
|
);
|
||||||
return _tokenInfrastructure.GenerateJwt(
|
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _refreshTokenSecret);
|
||||||
user.UserAccountId,
|
|
||||||
user.Username,
|
|
||||||
expiresAt,
|
|
||||||
_refreshTokenSecret
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string GenerateConfirmationToken(UserAccount user)
|
public string GenerateConfirmationToken(Guid userId, string username)
|
||||||
{
|
{
|
||||||
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
DateTime expiresAt = DateTime.UtcNow.AddHours(
|
||||||
TokenServiceExpirationHours.ConfirmationTokenHours
|
TokenServiceExpirationHours.ConfirmationTokenHours
|
||||||
);
|
);
|
||||||
return _tokenInfrastructure.GenerateJwt(
|
return _tokenInfrastructure.GenerateJwt(
|
||||||
user.UserAccountId,
|
userId,
|
||||||
user.Username,
|
username,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
_confirmationTokenSecret
|
_confirmationTokenSecret
|
||||||
);
|
);
|
||||||
@@ -110,14 +105,14 @@ public class TokenService : ITokenService
|
|||||||
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||||
{
|
{
|
||||||
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||||
UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
ApplicationUser? user = await _userManager.FindByIdAsync(validated.UserId.ToString());
|
||||||
if (user == null)
|
if (user == null)
|
||||||
throw new UnauthorizedException("User account not found");
|
throw new UnauthorizedException("User account not found");
|
||||||
|
|
||||||
string newAccess = GenerateAccessToken(user);
|
string newAccess = GenerateAccessToken(user.Id, user.UserName);
|
||||||
string newRefresh = GenerateRefreshToken(user);
|
string newRefresh = GenerateRefreshToken(user.Id, user.UserName);
|
||||||
|
|
||||||
return new RefreshTokenResult(user, newRefresh, newAccess);
|
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
<RootNamespace>Features.Emails</RootNamespace>
|
<RootNamespace>Features.Emails</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference
|
||||||
|
Include="Microsoft.Extensions.Configuration.Abstractions"
|
||||||
|
Version="10.0.1"
|
||||||
|
/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Infrastructure.Email;
|
using Infrastructure.Email;
|
||||||
using Infrastructure.Email.Templates.Rendering;
|
using Infrastructure.Email.Templates.Rendering;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Features.Emails.Services;
|
namespace Features.Emails.Services;
|
||||||
|
|
||||||
@@ -8,18 +9,19 @@ namespace Features.Emails.Services;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class EmailDispatcher(
|
public class EmailDispatcher(
|
||||||
IEmailProvider emailProvider,
|
IEmailProvider emailProvider,
|
||||||
IEmailTemplateProvider emailTemplateProvider
|
IEmailTemplateProvider emailTemplateProvider,
|
||||||
|
IConfiguration configuration
|
||||||
) : IEmailDispatcher
|
) : IEmailDispatcher
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The base URL of the website, used to build confirmation links. Read from the
|
/// 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>
|
/// </summary>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <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>
|
/// </exception>
|
||||||
private static readonly string WebsiteBaseUrl =
|
private string WebsiteBaseUrl =>
|
||||||
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
configuration["WEBSITE_BASE_URL"]
|
||||||
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -8,5 +8,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||||
|
<PackageReference
|
||||||
|
Include="Microsoft.Extensions.Configuration.Abstractions"
|
||||||
|
Version="10.0.1"
|
||||||
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
using MailKit.Net.Smtp;
|
using MailKit.Net.Smtp;
|
||||||
using MailKit.Security;
|
using MailKit.Security;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
|
||||||
namespace Infrastructure.Email;
|
namespace Infrastructure.Email;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SMTP email service implementation using MailKit.
|
/// SMTP email service implementation using MailKit.
|
||||||
/// Configured via environment variables.
|
/// Configured via application configuration (backed by environment variables).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SmtpEmailProvider : IEmailProvider
|
public class SmtpEmailProvider : IEmailProvider
|
||||||
{
|
{
|
||||||
@@ -20,35 +21,35 @@ public class SmtpEmailProvider : IEmailProvider
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="SmtpEmailProvider" /> class, reading SMTP configuration
|
/// 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_HOST</c>, <c>SMTP_PORT</c>, <c>SMTP_USERNAME</c>, <c>SMTP_PASSWORD</c>, <c>SMTP_USE_SSL</c>,
|
||||||
/// <c>SMTP_PASSWORD</c>, <c>SMTP_USE_SSL</c>, <c>SMTP_FROM_EMAIL</c>, <c>SMTP_FROM_NAME</c>).
|
/// <c>SMTP_FROM_EMAIL</c>, <c>SMTP_FROM_NAME</c>) from <paramref name="configuration" />.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <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.
|
/// 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>
|
/// </exception>
|
||||||
public SmtpEmailProvider()
|
public SmtpEmailProvider(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_host =
|
_host =
|
||||||
Environment.GetEnvironmentVariable("SMTP_HOST")
|
configuration["SMTP_HOST"]
|
||||||
?? throw new InvalidOperationException("SMTP_HOST environment variable is not set");
|
?? 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))
|
if (!int.TryParse(portString, out _port))
|
||||||
throw new InvalidOperationException($"SMTP_PORT '{portString}' is not a valid integer");
|
throw new InvalidOperationException($"SMTP_PORT '{portString}' is not a valid integer");
|
||||||
|
|
||||||
_username = Environment.GetEnvironmentVariable("SMTP_USERNAME");
|
_username = configuration["SMTP_USERNAME"];
|
||||||
_password = Environment.GetEnvironmentVariable("SMTP_PASSWORD");
|
_password = configuration["SMTP_PASSWORD"];
|
||||||
|
|
||||||
string useSslString = Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true";
|
string useSslString = configuration["SMTP_USE_SSL"] ?? "true";
|
||||||
_useSsl = bool.Parse(useSslString);
|
_useSsl = bool.Parse(useSslString);
|
||||||
|
|
||||||
_fromEmail =
|
_fromEmail =
|
||||||
Environment.GetEnvironmentVariable("SMTP_FROM_EMAIL")
|
configuration["SMTP_FROM_EMAIL"]
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"SMTP_FROM_EMAIL environment variable is not set"
|
"SMTP_FROM_EMAIL environment variable is not set"
|
||||||
);
|
);
|
||||||
|
|
||||||
_fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten";
|
_fromName = configuration["SMTP_FROM_NAME"] ?? "The Biergarten";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -20,21 +20,21 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlCon
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the SQL Server connection string, preferring (in order): the <c>DB_CONNECTION_STRING</c>
|
/// 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.
|
/// and finally the <c>"Default"</c> connection string from configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
|
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
|
||||||
private static string GetConnectionString(IConfiguration configuration)
|
private static string GetConnectionString(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Check for full connection string first
|
// Check for full connection string first
|
||||||
string? fullConnectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
|
string? fullConnectionString = configuration["DB_CONNECTION_STRING"];
|
||||||
if (!string.IsNullOrEmpty(fullConnectionString))
|
if (!string.IsNullOrEmpty(fullConnectionString))
|
||||||
return 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
|
try
|
||||||
{
|
{
|
||||||
return SqlConnectionStringHelper.BuildConnectionString();
|
return SqlConnectionStringHelper.BuildConnectionString(configuration);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException)
|
catch (InvalidOperationException)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,23 +1,29 @@
|
|||||||
using Microsoft.Data.SqlClient;
|
using Microsoft.Data.SqlClient;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Infrastructure.Sql;
|
namespace Infrastructure.Sql;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public static class SqlConnectionStringHelper
|
public static class SqlConnectionStringHelper
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a SQL Server connection string from environment variables.
|
/// Builds a SQL Server connection string from configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>Throws <see cref="InvalidOperationException" /> if DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD is unset.</remarks>
|
/// <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>
|
/// <param name="configuration">The configuration to read the <c>DB_*</c> values from.</param>
|
||||||
public static string BuildConnectionString(string? databaseName = null)
|
/// <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 server = Required(configuration, "DB_SERVER");
|
||||||
string dbName = databaseName ?? Required("DB_NAME");
|
string dbName = databaseName ?? Required(configuration, "DB_NAME");
|
||||||
string user = Required("DB_USER");
|
string user = Required(configuration, "DB_USER");
|
||||||
string password = Required("DB_PASSWORD");
|
string password = Required(configuration, "DB_PASSWORD");
|
||||||
|
|
||||||
SqlConnectionStringBuilder builder = new()
|
SqlConnectionStringBuilder builder = new()
|
||||||
{
|
{
|
||||||
@@ -25,7 +31,7 @@ public static class SqlConnectionStringHelper
|
|||||||
InitialCatalog = dbName,
|
InitialCatalog = dbName,
|
||||||
UserID = user,
|
UserID = user,
|
||||||
Password = password,
|
Password = password,
|
||||||
TrustServerCertificate = ResolveTrustServerCertificate(),
|
TrustServerCertificate = ResolveTrustServerCertificate(configuration),
|
||||||
Encrypt = true,
|
Encrypt = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -33,29 +39,27 @@ public static class SqlConnectionStringHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a connection string to the master database using environment variables.
|
/// Builds a connection string to the master database using configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string BuildMasterConnectionString()
|
public static string BuildMasterConnectionString(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
return BuildConnectionString("master");
|
return BuildConnectionString(configuration, "master");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// <c>true</c> when it is unset or not a valid boolean.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool ResolveTrustServerCertificate()
|
private static bool ResolveTrustServerCertificate(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
return !bool.TryParse(
|
return !bool.TryParse(
|
||||||
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"),
|
configuration["DB_TRUST_SERVER_CERTIFICATE"],
|
||||||
out bool trustServerCertificate
|
out bool trustServerCertificate
|
||||||
)
|
)
|
||||||
|| trustServerCertificate;
|
|| trustServerCertificate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Required(string variableName) =>
|
private static string Required(IConfiguration configuration, string key) =>
|
||||||
Environment.GetEnvironmentVariable(variableName)
|
configuration[key]
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException($"The {key} environment variable is not set.");
|
||||||
$"The {variableName} 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,
|
"singleQuote": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"tabWidth": 3,
|
"tabWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"arrowParens": "always",
|
"arrowParens": "always",
|
||||||
"endOfLine": "lf"
|
"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";
|
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||||
|
|
||||||
type Module = typeof import("../confirm.js")
|
type Module = typeof import("../account.js")
|
||||||
|
|
||||||
type Info = GetInfo<{
|
type Info = GetInfo<{
|
||||||
file: "routes/confirm.tsx",
|
file: "routes/account.tsx",
|
||||||
module: Module
|
module: Module
|
||||||
}>
|
}>
|
||||||
|
|
||||||
@@ -13,11 +13,11 @@ type Matches = [{
|
|||||||
id: "root";
|
id: "root";
|
||||||
module: typeof import("../../root.js");
|
module: typeof import("../../root.js");
|
||||||
}, {
|
}, {
|
||||||
id: "routes/confirm";
|
id: "routes/account";
|
||||||
module: typeof import("../confirm.js");
|
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 {
|
export namespace Route {
|
||||||
// links
|
// links
|
||||||
@@ -54,9 +54,18 @@ export namespace Route {
|
|||||||
// HydrateFallback
|
// HydrateFallback
|
||||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||||
|
|
||||||
|
// ServerHydrateFallback
|
||||||
|
export type ServerHydrateFallbackProps = Annotations["ServerHydrateFallbackProps"];
|
||||||
|
|
||||||
// Component
|
// Component
|
||||||
export type ComponentProps = Annotations["ComponentProps"];
|
export type ComponentProps = Annotations["ComponentProps"];
|
||||||
|
|
||||||
|
// ServerComponent
|
||||||
|
export type ServerComponentProps = Annotations["ServerComponentProps"];
|
||||||
|
|
||||||
// ErrorBoundary
|
// ErrorBoundary
|
||||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
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";
|
import type { GetInfo, GetAnnotations } from "react-router/internal";
|
||||||
|
|
||||||
type Module = typeof import("../beers.js")
|
type Module = typeof import("../brewery-detail.js")
|
||||||
|
|
||||||
type Info = GetInfo<{
|
type Info = GetInfo<{
|
||||||
file: "routes/beers.tsx",
|
file: "routes/brewery-detail.tsx",
|
||||||
module: Module
|
module: Module
|
||||||
}>
|
}>
|
||||||
|
|
||||||
@@ -13,11 +13,11 @@ type Matches = [{
|
|||||||
id: "root";
|
id: "root";
|
||||||
module: typeof import("../../root.js");
|
module: typeof import("../../root.js");
|
||||||
}, {
|
}, {
|
||||||
id: "routes/beers";
|
id: "routes/brewery-detail";
|
||||||
module: typeof import("../beers.js");
|
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 {
|
export namespace Route {
|
||||||
// links
|
// links
|
||||||
@@ -54,9 +54,18 @@ export namespace Route {
|
|||||||
// HydrateFallback
|
// HydrateFallback
|
||||||
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
|
||||||
|
|
||||||
|
// ServerHydrateFallback
|
||||||
|
export type ServerHydrateFallbackProps = Annotations["ServerHydrateFallbackProps"];
|
||||||
|
|
||||||
// Component
|
// Component
|
||||||
export type ComponentProps = Annotations["ComponentProps"];
|
export type ComponentProps = Annotations["ComponentProps"];
|
||||||
|
|
||||||
|
// ServerComponent
|
||||||
|
export type ServerComponentProps = Annotations["ServerComponentProps"];
|
||||||
|
|
||||||
// ErrorBoundary
|
// ErrorBoundary
|
||||||
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
|
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/Configure.mdx',
|
||||||
'../stories/SubmitButton.stories.tsx',
|
'../stories/SubmitButton.stories.tsx',
|
||||||
'../stories/FormField.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/Navbar.stories.tsx',
|
||||||
'../stories/Toast.stories.tsx',
|
'../stories/Toast.stories.tsx',
|
||||||
'../stories/Themes.stories.tsx',
|
'../stories/Themes.stories.tsx',
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ import type { Preview } from '@storybook/react-vite';
|
|||||||
import { createElement } from 'react';
|
import { createElement } from 'react';
|
||||||
import { MemoryRouter } from 'react-router';
|
import { MemoryRouter } from 'react-router';
|
||||||
import '../app/app.css';
|
import '../app/app.css';
|
||||||
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes';
|
import {
|
||||||
|
biergartenThemes,
|
||||||
|
defaultThemeName,
|
||||||
|
isBiergartenTheme,
|
||||||
|
} from '../app/features/theme/themes';
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
globalTypes: {
|
globalTypes: {
|
||||||
@@ -28,18 +32,23 @@ const preview: Preview = {
|
|||||||
? context.globals.theme
|
? context.globals.theme
|
||||||
: defaultThemeName;
|
: defaultThemeName;
|
||||||
|
|
||||||
return createElement(
|
const content = createElement(
|
||||||
MemoryRouter,
|
|
||||||
undefined,
|
|
||||||
createElement(
|
|
||||||
'div',
|
'div',
|
||||||
{
|
{
|
||||||
'data-theme': theme,
|
'data-theme': theme,
|
||||||
className: 'bg-base-200 p-6 text-base-content',
|
className: 'bg-base-200 p-6 text-base-content',
|
||||||
},
|
},
|
||||||
createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)),
|
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: {
|
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