Compare commits

...

10 Commits

Author SHA1 Message Date
1621313daa Refactor authentication and account management with modular UI components (#242) 2026-08-23 01:30:58 -04:00
Aaron Po
c514eb9814 Replace raw environment variable reads with IConfiguration in backend 2026-08-23 00:52:26 -04:00
Aaron Po
9e9fc98d33 Add self-service account management commands (delete, update username/email/password/profile) 2026-08-23 00:52:26 -04:00
Aaron Po
7a981bc331 Remove obsolete AuthRepository/IAuthRepository 2026-08-23 00:52:26 -04:00
Aaron Po
c055982066 Migrate Database.Seed to UserManager<ApplicationUser> 2026-08-23 00:52:26 -04:00
Aaron Po
6b9c14c14b Migrate TokenService.RefreshTokenAsync to UserManager
Last remaining IAuthRepository call site; RefreshTokenResult now
carries a plain (Guid, string) instead of UserAccount.
2026-08-23 00:52:26 -04:00
Aaron Po
2e5f02fb38 Migrate handlers to UserManager<> 2026-08-23 00:52:26 -04:00
Aaron Po
781d6bb2e6 Wire ASP.NET Core Identity DI alongside existing AuthRepository
Both stacks coexist until handlers are migrated one by one.
2026-08-23 00:52:26 -04:00
Aaron Po
885b2a9570 Decouple ITokenService from UserAccount entity
Token generation only ever needed the id/username, not the full UserAccount.
2026-08-23 00:52:26 -04:00
Aaron Po
7793129284 Add ApplicationUser, DapperUserStore, Argon2PasswordHasher scaffolding
Unwired Identity store/hasher adapters, ahead of wiring them into DI.
2026-08-23 00:52:26 -04:00
170 changed files with 16802 additions and 11479 deletions

10
.gitignore vendored
View File

@@ -16,11 +16,11 @@
/build
# project-specific build artifacts
/src/Website/build/
/src/Website/storybook-static/
/src/Website/.react-router/
/src/Website/playwright-report/
/src/Website/test-results/
/web/frontend/build/
/web/frontend/storybook-static/
/web/frontend/.react-router/
/web/frontend/playwright-report/
/web/frontend/test-results/
/test-results/
# misc

View File

@@ -1,3 +1,4 @@
# Testing
This document describes the testing strategy and how to run tests for The
@@ -125,6 +126,25 @@ npm run test:storybook:playwright
- The command will start or reuse the Storybook server defined in
`playwright.storybook.config.ts`
### Running Storybook tests with a visible browser
Both of the above run headless by default. To watch the browser while
debugging a failure:
```bash
# Playwright Storybook suite
npx playwright test -c playwright.storybook.config.ts --headed
# or the interactive step-through runner
npx playwright test -c playwright.storybook.config.ts --ui
# Vitest/story play-function suite
npx vitest run --project storybook --browser.headless=false
# or Vitest's own UI (drop `run` for watch mode)
npx vitest --project storybook --browser.headless=false --ui
```
The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
## Test coverage
### Current coverage

View File

@@ -32,7 +32,7 @@ public class JwtAuthenticationHandler(
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
// Use the same access-token secret source as TokenService to avoid mismatched validation.
string? secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
string? secret = configuration["ACCESS_TOKEN_SECRET"];
if (string.IsNullOrWhiteSpace(secret))
secret = configuration["Jwt:SecretKey"];

View File

@@ -1,7 +1,7 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shared.Contracts;
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
namespace API.Core.Controllers;
@@ -20,8 +20,8 @@ public class ProtectedController : ControllerBase
[HttpGet]
public ActionResult<ResponseBody<object>> Get()
{
string? userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
string? username = User.FindFirst(ClaimTypes.Name)?.Value;
string? userId = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
string? username = User.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
return Ok(
new ResponseBody<object>

View File

@@ -12,9 +12,9 @@ WORKDIR /src
# Copy the solution file and every project file first (preserving their real relative paths, since
# ProjectReference paths are resolved relative to the referencing .csproj's location), so the
# `dotnet restore` layer stays cached as long as no project file or package reference changes,
# independent of source-only edits. Restoring against Core.slnx avoids hand-maintaining a per-project
# independent of source-only edits. Restoring against Biergarten.API.slnx avoids hand-maintaining a per-project
# dependency list here that silently drifts out of sync as ProjectReferences change.
COPY ["Core.slnx", "./"]
COPY ["Biergarten.API.slnx", "./"]
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
@@ -38,7 +38,7 @@ COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHash
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Core.slnx"
RUN dotnet restore "Biergarten.API.slnx"
COPY . .
WORKDIR "/src/API/API.Core"
RUN dotnet build "./API.Core.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -12,6 +12,7 @@ using FluentValidation;
using FluentValidation.AspNetCore;
using Infrastructure.Jwt;
using Infrastructure.Sql.DependencyInjection;
using Microsoft.OpenApi.Models;
using Shared.Application.Behaviors;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
@@ -27,7 +28,37 @@ builder
.AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddSwaggerGen(options =>
{
options.AddSecurityDefinition(
"Bearer",
new OpenApiSecurityScheme
{
Name = "Authorization",
Type = SecuritySchemeType.Http,
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
Description = "Enter a JWT access token.",
}
);
options.AddSecurityRequirement(
new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer",
},
},
[]
},
}
);
});
builder.Services.AddOpenApi();
// Add FluentValidation

View File

@@ -3,8 +3,8 @@ ARG BUILD_CONFIGURATION=Release
WORKDIR /src
# See API.Core/Dockerfile for why this copies every project file (with real relative paths) and
# restores against Core.slnx rather than hand-listing API.Specs' transitive dependencies.
COPY ["Core.slnx", "./"]
# restores against Biergarten.API.slnx rather than hand-listing API.Specs' transitive dependencies.
COPY ["Biergarten.API.slnx", "./"]
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Database.Migrations/"]
@@ -28,7 +28,7 @@ COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHash
COPY ["Infrastructure/Infrastructure.Sql/Infrastructure.Sql.csproj", "Infrastructure/Infrastructure.Sql/"]
COPY ["Shared/Shared.Application/Shared.Application.csproj", "Shared/Shared.Application/"]
COPY ["Shared/Shared.Contracts/Shared.Contracts.csproj", "Shared/Shared.Contracts/"]
RUN dotnet restore "Core.slnx"
RUN dotnet restore "Biergarten.API.slnx"
COPY . .
WORKDIR "/src/API/API.Specs"
RUN dotnet build "./API.Specs.csproj" -c $BUILD_CONFIGURATION -o /app/build

View File

@@ -2,6 +2,8 @@ using System.Text;
using System.Text.Json;
using FluentAssertions;
using Infrastructure.Jwt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll;
namespace API.Specs.Steps;
@@ -19,11 +21,8 @@ public class AuthSteps(ScenarioContext scenario)
private const string PreviousAccessTokenKey = "previousAccessToken";
private const string PreviousRefreshTokenKey = "previousRefreshToken";
private HttpClient GetClient()
private TestApiFactory GetFactory()
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
return client;
TestApiFactory? factory = scenario.TryGetValue<TestApiFactory>(
FactoryKey,
out TestApiFactory? f
@@ -31,16 +30,28 @@ public class AuthSteps(ScenarioContext scenario)
? f
: new TestApiFactory();
scenario[FactoryKey] = factory;
return factory;
}
client = factory.CreateClient();
private HttpClient GetClient()
{
if (scenario.TryGetValue<HttpClient>(ClientKey, out HttpClient? client))
return client;
client = GetFactory().CreateClient();
scenario[ClientKey] = client;
return client;
}
private static string GetRequiredEnvVar(string name)
/// <summary>
/// Reads a required configuration value from the running <see cref="TestApiFactory" />'s own
/// <see cref="IConfiguration" />, so forged tokens are always signed with the exact secret the
/// app under test resolved -- rather than assuming this process's environment variables match it.
/// </summary>
private string GetRequiredConfigValue(string key)
{
return Environment.GetEnvironmentVariable(name)
?? throw new InvalidOperationException($"{name} environment variable is not set");
return GetFactory().Services.GetRequiredService<IConfiguration>()[key]
?? throw new InvalidOperationException($"{key} configuration value is not set");
}
private static string GenerateJwtToken(
@@ -420,7 +431,7 @@ public class AuthSteps(ScenarioContext scenario)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
string secret = GetRequiredConfigValue("ACCESS_TOKEN_SECRET");
scenario["accessToken"] = GenerateJwtToken(
userId,
username,
@@ -439,7 +450,7 @@ public class AuthSteps(ScenarioContext scenario)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
string secret = GetRequiredConfigValue("CONFIRMATION_TOKEN_SECRET");
scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
@@ -458,7 +469,7 @@ public class AuthSteps(ScenarioContext scenario)
? user
: throw new InvalidOperationException("registered username not found in scenario");
string secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
string secret = GetRequiredConfigValue("CONFIRMATION_TOKEN_SECRET");
scenario["confirmationToken"] = GenerateJwtToken(
userId,
username,
@@ -602,12 +613,9 @@ public class AuthSteps(ScenarioContext scenario)
string? token = scenario.TryGetValue<string>("refreshToken", out string? t)
? t
: "valid-refresh-token";
string body = JsonSerializer.Serialize(new { refreshToken = token });
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
requestMessage.Headers.Add("X-Refresh-Token", token);
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
@@ -619,12 +627,9 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
{
HttpClient client = GetClient();
string body = JsonSerializer.Serialize(new { refreshToken = "invalid-refresh-token" });
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
requestMessage.Headers.Add("X-Refresh-Token", "invalid-refresh-token");
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
@@ -636,13 +641,9 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken()
{
HttpClient client = GetClient();
// Use an expired token
string body = JsonSerializer.Serialize(new { refreshToken = "expired-refresh-token" });
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
requestMessage.Headers.Add("X-Refresh-Token", "expired-refresh-token");
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
@@ -654,12 +655,8 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken()
{
HttpClient client = GetClient();
string body = JsonSerializer.Serialize(new { });
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
HttpRequestMessage requestMessage = new(HttpMethod.Post, "/api/auth/refresh");
HttpResponseMessage response = await client.SendAsync(requestMessage);
string responseBody = await response.Content.ReadAsStringAsync();

View File

@@ -11,6 +11,11 @@
<ItemGroup>
<PackageReference Include="dbup" Version="5.0.41" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.0" />
<PackageReference
Include="Microsoft.Extensions.Configuration.EnvironmentVariables"
Version="9.0.0"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Sql\Infrastructure.Sql.csproj" />

View File

@@ -4,13 +4,14 @@ using DbUp;
using DbUp.Engine;
using Infrastructure.Sql;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Database.Migrations;
/// <summary>
/// Entry point for the database migration runner. Reads connection details from environment
/// variables, optionally drops and recreates the target database, and applies all SQL
/// migration scripts embedded in this assembly using DbUp.
/// Entry point for the database migration runner. Reads connection details from configuration
/// (backed by environment variables), optionally drops and recreates the target database, and
/// applies all SQL migration scripts embedded in this assembly using DbUp.
/// </summary>
public static class Program
{
@@ -27,7 +28,10 @@ public static class Program
try
{
DatabaseSettings settings = DatabaseSettings.FromEnvironment();
IConfiguration configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
DatabaseSettings settings = DatabaseSettings.FromConfiguration(configuration);
if (settings.ClearDatabase)
{
@@ -159,28 +163,28 @@ public static class Program
/// <summary>
/// Builds the settings from the <c>DB_SERVER</c>, <c>DB_NAME</c>, <c>DB_USER</c>,
/// <c>DB_PASSWORD</c>, <c>DB_TRUST_SERVER_CERTIFICATE</c>, and <c>CLEAR_DATABASE</c>
/// environment variables.
/// configuration values.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when a required environment variable is not set.
/// Thrown when a required configuration value is not set.
/// </exception>
public static DatabaseSettings FromEnvironment()
public static DatabaseSettings FromConfiguration(IConfiguration configuration)
{
string databaseName =
Environment.GetEnvironmentVariable("DB_NAME")
configuration["DB_NAME"]
?? throw new InvalidOperationException(
"The DB_NAME environment variable is not set."
);
bool clearDatabase = string.Equals(
Environment.GetEnvironmentVariable("CLEAR_DATABASE"),
configuration["CLEAR_DATABASE"],
"true",
StringComparison.OrdinalIgnoreCase
);
return new DatabaseSettings(
SqlConnectionStringHelper.BuildConnectionString(databaseName),
SqlConnectionStringHelper.BuildMasterConnectionString(),
SqlConnectionStringHelper.BuildConnectionString(configuration, databaseName),
SqlConnectionStringHelper.BuildMasterConnectionString(configuration),
databaseName,
clearDatabase
);

View File

@@ -1,6 +1,7 @@
#region
using Infrastructure.Sql;
using Microsoft.Extensions.Configuration;
#endregion
@@ -8,7 +9,9 @@ namespace Database.Seed.DatabaseHelpers;
public class ConnectionStrings
{
public static string SqlServerConnectionString => SqlConnectionStringHelper.BuildConnectionString();
public static string GetSqlServerConnectionString(IConfiguration configuration) =>
SqlConnectionStringHelper.BuildConnectionString(configuration);
public static string SqliteConnectionString =>
"Data Source=SeedData/biergarten_seed_2026-07-20T05-46-02.993511Z.sqlite";
}

View File

@@ -3,15 +3,15 @@ using Database.Seed.PipelineData;
using Database.Seed.Sqlite;
using Domain.Entities;
using Features.Auth.DependencyInjection;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Breweries.DependencyInjection;
using Features.Breweries.Repository;
using Features.Locations.DependencyInjection;
using Features.Locations.Dtos;
using Features.Locations.Repository;
using idunno.Password;
using Infrastructure.PasswordHashing;
using Infrastructure.Sql.DependencyInjection;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Spectre.Console;
@@ -22,9 +22,12 @@ static async Task<int> RunAsync()
{
try
{
IConfiguration configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.Build();
ServiceCollection services = [];
services.AddSingleton<IConfiguration>(
new ConfigurationBuilder().Build());
services.AddSingleton(configuration);
services.AddInfrastructureSql();
services.AddFeaturesBreweries();
services.AddFeaturesLocations();
@@ -36,10 +39,8 @@ static async Task<int> RunAsync()
provider.GetRequiredService<IBreweryRepository>();
ILocationRepository locationRepository =
provider.GetRequiredService<ILocationRepository>();
IAuthRepository authRepository =
provider.GetRequiredService<IAuthRepository>();
IPasswordInfrastructure passwordInfrastructure =
provider.GetRequiredService<IPasswordInfrastructure>();
UserManager<ApplicationUser> userManager =
provider.GetRequiredService<UserManager<ApplicationUser>>();
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]")
.LeftJustified());
@@ -94,11 +95,7 @@ static async Task<int> RunAsync()
"Loading user data into target database...",
async ctx =>
{
postedByIds = await LoadUsersIntoDatabaseAsync(
authRepository,
passwordInfrastructure,
seedData.Users
);
postedByIds = await LoadUsersIntoDatabaseAsync(userManager, seedData.Users);
ctx.Status("User data loaded into target database.");
}
);
@@ -132,8 +129,7 @@ static async Task<int> RunAsync()
}
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
IAuthRepository authRepository,
IPasswordInfrastructure passwordInfrastructure,
UserManager<ApplicationUser> userManager,
IReadOnlyList<UserRecord> users
)
{
@@ -141,22 +137,29 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
foreach (UserRecord userRecord in users)
{
UserAccount userAccount = await authRepository.RegisterUserAsync(
new UserAccount()
ApplicationUser user = new()
{
FirstName = userRecord.User.FirstName,
LastName = userRecord.User.LastName,
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
Username = userRecord.User.Username,
UserName = userRecord.User.Username,
Email = userRecord.Email,
UserCredential = new UserCredential
{
Hash = passwordInfrastructure.Hash(
password: PasswordGenerator.Generate(64, 12, 12))
}
}
};
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
// digit characters (0-9), which idunno.Password rejects outright.
IdentityResult result = await userManager.CreateAsync(
user,
PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true)
);
userAccountIds.Add(userAccount.UserAccountId);
if (!result.Succeeded)
throw new InvalidOperationException(
$"Failed to seed user '{userRecord.User.Username}': "
+ string.Join("; ", result.Errors.Select(e => e.Description))
);
userAccountIds.Add(user.Id);
}
return userAccountIds;

View File

@@ -2,8 +2,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY . .
RUN dotnet restore "Core.slnx"
RUN dotnet build "Core.slnx" -c $BUILD_CONFIGURATION
RUN dotnet restore "Biergarten.API.slnx"
RUN dotnet build "Biergarten.API.slnx" -c $BUILD_CONFIGURATION
# Runs every Features.*.Tests project (unit + DbMocker-backed repository tests). API.Specs is
# excluded here since it's a separate BDD/integration suite, run by its own compose service

View File

@@ -1,27 +1,34 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Microsoft.AspNetCore.Identity;
using Moq;
namespace Features.Auth.Tests.Commands;
public class ConfirmUserHandlerTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<IUserEmailStore<ApplicationUser>> _emailStoreMock;
private readonly ConfirmUserHandler _handler;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public ConfirmUserHandlerTests()
{
_authRepositoryMock = new Mock<IAuthRepository>();
_userManagerMock = UserManagerMockFactory.Create();
_emailStoreMock = new Mock<IUserEmailStore<ApplicationUser>>();
_tokenServiceMock = new Mock<ITokenService>();
_handler = new ConfirmUserHandler(_authRepositoryMock.Object, _tokenServiceMock.Object);
_handler = new ConfirmUserHandler(
_userManagerMock.Object,
_emailStoreMock.Object,
_tokenServiceMock.Object
);
}
private static ValidatedToken MakeValidatedToken(Guid userId, string username)
@@ -44,12 +51,13 @@ public class ConfirmUserHandlerTests
const string confirmationToken = "valid-confirmation-token";
ValidatedToken validatedToken = MakeValidatedToken(userId, username);
UserAccount userAccount = new() { UserAccountId = userId, Username = username };
ApplicationUser user = new() { Id = userId, UserName = username };
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(validatedToken);
_authRepositoryMock.Setup(x => x.ConfirmUserAccountAsync(userId)).ReturnsAsync(userAccount);
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
ConfirmationPayload result = await _handler.Handle(
new ConfirmUserCommand(confirmationToken),
@@ -59,6 +67,12 @@ public class ConfirmUserHandlerTests
result.Should().NotBeNull();
result.UserAccountId.Should().Be(userId);
result.ConfirmedDate.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
_emailStoreMock.Verify(
x => x.SetEmailConfirmedAsync(user, true, It.IsAny<CancellationToken>()),
Times.Once
);
_userManagerMock.Verify(x => x.UpdateAsync(user), Times.Once);
}
[Fact]
@@ -85,9 +99,9 @@ public class ConfirmUserHandlerTests
_tokenServiceMock
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
.ReturnsAsync(MakeValidatedToken(userId, username));
_authRepositoryMock
.Setup(x => x.ConfirmUserAccountAsync(userId))
.ReturnsAsync((UserAccount?)null);
_userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
Func<Task<ConfirmationPayload>> act = async () =>
await _handler.Handle(

View File

@@ -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>();
}
}

View File

@@ -1,4 +1,3 @@
using Domain.Entities;
using Features.Auth.Commands.RefreshToken;
using Features.Auth.Dtos;
using Features.Auth.Services;
@@ -15,11 +14,12 @@ public class RefreshTokenHandlerTests
Mock<ITokenService> tokenServiceMock = new();
RefreshTokenHandler handler = new(tokenServiceMock.Object);
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId, Username = "testuser" };
tokenServiceMock
.Setup(x => x.RefreshTokenAsync("old-refresh-token"))
.ReturnsAsync(new RefreshTokenResult(user, "new-refresh-token", "new-access-token"));
.ReturnsAsync(
new RefreshTokenResult(userId, "testuser", "new-refresh-token", "new-access-token")
);
LoginPayload result = await handler.Handle(
new RefreshTokenCommand("old-refresh-token"),

View File

@@ -1,12 +1,12 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Commands.RegisterUser;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Moq;
using Shared.Application.Emails;
@@ -14,22 +14,19 @@ namespace Features.Auth.Tests.Commands;
public class RegisterUserHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly RegisterUserHandler _handler;
private readonly Mock<IMediator> _mediatorMock;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public RegisterUserHandlerTests()
{
_authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_userManagerMock = UserManagerMockFactory.Create();
_tokenServiceMock = new Mock<ITokenService>();
_mediatorMock = new Mock<IMediator>();
_handler = new RegisterUserHandler(
_authRepoMock.Object,
_passwordInfraMock.Object,
_userManagerMock.Object,
_tokenServiceMock.Object,
_mediatorMock.Object
);
@@ -54,51 +51,32 @@ public class RegisterUserHandlerTests
public async Task Handle_WithValidData_CreatesUserAndReturnsPayload()
{
RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_password_value";
Guid expectedUserId = Guid.NewGuid();
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
_userManagerMock
.Setup(x =>
x.RegisterUserAsync(
It.Is<UserAccount>(ua =>
ua.FirstName == command.FirstName
&& ua.LastName == command.LastName
&& ua.Username == command.Username
&& ua.Email == command.Email
&& ua.DateOfBirth == command.DateOfBirth
&& ua.UserCredential!.Hash == hashedPassword
x.CreateAsync(
It.Is<ApplicationUser>(u =>
u.FirstName == command.FirstName
&& u.LastName == command.LastName
&& u.UserName == command.Username
&& u.Email == command.Email
&& u.DateOfBirth == command.DateOfBirth
),
command.Password
)
)
)
.ReturnsAsync(
new UserAccount
{
UserAccountId = expectedUserId,
Username = command.Username,
FirstName = command.FirstName,
LastName = command.LastName,
Email = command.Email,
DateOfBirth = command.DateOfBirth,
CreatedAt = DateTime.UtcNow,
}
);
.Callback<ApplicationUser, string>((u, _) => u.Id = expectedUserId)
.ReturnsAsync(IdentityResult.Success);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("refresh-token");
_tokenServiceMock
.Setup(x => x.GenerateConfirmationToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateConfirmationToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("confirmation-token");
RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
@@ -120,18 +98,14 @@ public class RegisterUserHandlerTests
public async Task Handle_WithExistingUsername_ThrowsConflictException()
{
RegisterUserCommand command = ValidCommand("existinguser");
UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Username = "existinguser",
};
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync(existingUser);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(command.Email))
.ReturnsAsync((UserAccount?)null);
_userManagerMock
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.ReturnsAsync(
IdentityResult.Failed(
new IdentityError { Code = "DuplicateUserName", Description = "Username taken." }
)
);
Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None);
@@ -139,27 +113,20 @@ public class RegisterUserHandlerTests
await act.Should()
.ThrowAsync<ConflictException>()
.WithMessage("Username or email already exists");
_authRepoMock.Verify(
x => x.RegisterUserAsync(It.IsAny<UserAccount>()),
Times.Never
);
}
[Fact]
public async Task Handle_WithExistingEmail_ThrowsConflictException()
{
RegisterUserCommand command = ValidCommand(email: "existing@example.com");
UserAccount existingUser = new()
{
UserAccountId = Guid.NewGuid(),
Email = "existing@example.com",
};
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(command.Username))
.ReturnsAsync((UserAccount?)null);
_authRepoMock.Setup(x => x.GetUserByEmailAsync(command.Email)).ReturnsAsync(existingUser);
_userManagerMock
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.ReturnsAsync(
IdentityResult.Failed(
new IdentityError { Code = "DuplicateEmail", Description = "Email taken." }
)
);
Func<Task<RegistrationPayload>> act = async () =>
await _handler.Handle(command, CancellationToken.None);
@@ -173,45 +140,34 @@ public class RegisterUserHandlerTests
public async Task Handle_PasswordIsHashed_BeforeStoringInDatabase()
{
RegisterUserCommand command = ValidCommand();
const string hashedPassword = "hashed_secure_password";
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(command.Password)).Returns(hashedPassword);
_authRepoMock
.Setup(x =>
x.RegisterUserAsync(
It.Is<UserAccount>(ua => ua.UserCredential!.Hash == hashedPassword)
)
)
.ReturnsAsync(new UserAccount { UserAccountId = Guid.NewGuid() });
_userManagerMock
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
.ReturnsAsync(IdentityResult.Success);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("refresh-token");
await _handler.Handle(command, CancellationToken.None);
_passwordInfraMock.Verify(x => x.Hash(command.Password), Times.Once);
_authRepoMock.Verify(
// UserManager.CreateAsync receives the plaintext password and hashes it internally via
// IPasswordHasher<ApplicationUser> -- the handler never sees or stores a hash directly.
_userManagerMock.Verify(
x =>
x.RegisterUserAsync(
It.Is<UserAccount>(ua =>
ua.FirstName == command.FirstName
&& ua.LastName == command.LastName
&& ua.Username == command.Username
&& ua.DateOfBirth == command.DateOfBirth
&& ua.Email == command.Email
&& ua.UserCredential!.Hash == hashedPassword
)
x.CreateAsync(
It.Is<ApplicationUser>(u =>
u.FirstName == command.FirstName
&& u.LastName == command.LastName
&& u.UserName == command.Username
&& u.DateOfBirth == command.DateOfBirth
&& u.Email == command.Email
),
command.Password
),
Times.Once
);
@@ -222,29 +178,16 @@ public class RegisterUserHandlerTests
{
RegisterUserCommand command = ValidCommand();
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_authRepoMock
.Setup(x => x.GetUserByEmailAsync(It.IsAny<string>()))
.ReturnsAsync((UserAccount?)null);
_passwordInfraMock.Setup(x => x.Hash(It.IsAny<string>())).Returns("hashed");
_authRepoMock
.Setup(x => x.RegisterUserAsync(It.IsAny<UserAccount>()))
.ReturnsAsync(
new UserAccount
{
UserAccountId = Guid.NewGuid(),
Username = command.Username,
Email = command.Email,
}
);
_userManagerMock
.Setup(x => x.CreateAsync(It.IsAny<ApplicationUser>(), command.Password))
.Callback<ApplicationUser, string>((u, _) => u.Id = Guid.NewGuid())
.ReturnsAsync(IdentityResult.Success);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("refresh-token");
_mediatorMock
.Setup(x =>

View File

@@ -1,8 +1,9 @@
using Domain.Entities;
using Features.Auth.Commands.ResendConfirmationEmail;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Moq;
using Shared.Application.Emails;
@@ -10,15 +11,15 @@ namespace Features.Auth.Tests.Commands;
public class ResendConfirmationEmailHandlerTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock = new();
private readonly ResendConfirmationEmailHandler _handler;
private readonly Mock<IMediator> _mediatorMock = new();
private readonly Mock<ITokenService> _tokenServiceMock = new();
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock = UserManagerMockFactory.Create();
public ResendConfirmationEmailHandlerTests()
{
_handler = new ResendConfirmationEmailHandler(
_authRepositoryMock.Object,
_userManagerMock.Object,
_tokenServiceMock.Object,
_mediatorMock.Object
);
@@ -28,16 +29,18 @@ public class ResendConfirmationEmailHandlerTests
public async Task Handle_SendsFreshConfirmationEmail_WhenUserExistsAndUnverified()
{
Guid userId = Guid.NewGuid();
UserAccount user = new()
ApplicationUser user = new()
{
UserAccountId = userId,
Id = userId,
FirstName = "Aaron",
Email = "aaron@example.com",
};
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(false);
_tokenServiceMock.Setup(x => x.GenerateConfirmationToken(user)).Returns("fresh-token");
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(false);
_tokenServiceMock
.Setup(x => x.GenerateConfirmationToken(user.Id, user.UserName))
.Returns("fresh-token");
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
@@ -59,7 +62,9 @@ public class ResendConfirmationEmailHandlerTests
public async Task Handle_DoesNothing_WhenUserDoesNotExist()
{
Guid userId = Guid.NewGuid();
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
_userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);
@@ -77,10 +82,10 @@ public class ResendConfirmationEmailHandlerTests
public async Task Handle_DoesNothing_WhenUserAlreadyVerified()
{
Guid userId = Guid.NewGuid();
UserAccount user = new() { UserAccountId = userId };
ApplicationUser user = new() { Id = userId };
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(user);
_authRepositoryMock.Setup(x => x.IsUserVerifiedAsync(userId)).ReturnsAsync(true);
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
_userManagerMock.Setup(x => x.IsEmailConfirmedAsync(user)).ReturnsAsync(true);
await _handler.Handle(new ResendConfirmationEmailCommand(userId), CancellationToken.None);

View File

@@ -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>();
}
}

View File

@@ -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>();
}
}

View File

@@ -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>();
}
}

View File

@@ -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>();
}
}

View File

@@ -1,32 +1,26 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Commands.Login;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Infrastructure.PasswordHashing;
using Microsoft.AspNetCore.Identity;
using Moq;
namespace Features.Auth.Tests.Queries;
public class LoginHandlerTests
{
private readonly Mock<IAuthRepository> _authRepoMock;
private readonly LoginHandler _handler;
private readonly Mock<IPasswordInfrastructure> _passwordInfraMock;
private readonly Mock<ITokenService> _tokenServiceMock;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public LoginHandlerTests()
{
_authRepoMock = new Mock<IAuthRepository>();
_passwordInfraMock = new Mock<IPasswordInfrastructure>();
_userManagerMock = UserManagerMockFactory.Create();
_tokenServiceMock = new Mock<ITokenService>();
_handler = new LoginHandler(
_authRepoMock.Object,
_passwordInfraMock.Object,
_tokenServiceMock.Object
);
_handler = new LoginHandler(_userManagerMock.Object, _tokenServiceMock.Object);
}
[Fact]
@@ -35,36 +29,25 @@ public class LoginHandlerTests
const string username = "CogitoErgoSum";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new()
ApplicationUser user = new()
{
UserAccountId = userAccountId,
Username = username,
Id = userAccountId,
UserName = username,
FirstName = "René",
LastName = "Descartes",
Email = "r.descartes@example.com",
DateOfBirth = new DateTime(1596, 03, 31),
};
UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "some-hash",
Expiry = DateTime.MaxValue,
};
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(true);
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
_userManagerMock
.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>()))
.ReturnsAsync(true);
_tokenServiceMock
.Setup(x => x.GenerateAccessToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateAccessToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("access-token");
_tokenServiceMock
.Setup(x => x.GenerateRefreshToken(It.IsAny<UserAccount>()))
.Setup(x => x.GenerateRefreshToken(It.IsAny<Guid>(), It.IsAny<string>()))
.Returns("refresh-token");
LoginPayload result = await _handler.Handle(
@@ -83,16 +66,16 @@ public class LoginHandlerTests
public async Task Handle_WithUnregisteredUsername_ThrowsUnauthorizedException()
{
const string username = "de_beauvoir";
_authRepoMock
.Setup(x => x.GetUserByUsernameAsync(username))
.ReturnsAsync((UserAccount?)null);
_userManagerMock
.Setup(x => x.FindByNameAsync(username))
.ReturnsAsync((ApplicationUser?)null);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
await act.Should().ThrowAsync<UnauthorizedException>();
_authRepoMock.Verify(
x => x.GetActiveCredentialByUserAccountIdAsync(It.IsAny<Guid>()),
_userManagerMock.Verify(
x => x.CheckPasswordAsync(It.IsAny<ApplicationUser>(), It.IsAny<string>()),
Times.Never
);
}
@@ -101,13 +84,10 @@ public class LoginHandlerTests
public async Task Handle_WithNoActiveCredential_ThrowsUnauthorizedException()
{
const string username = "BRussell";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync((UserCredential?)null);
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
_userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(new LoginCommand(username, "password"), CancellationToken.None);
@@ -115,32 +95,16 @@ public class LoginHandlerTests
await act.Should()
.ThrowAsync<UnauthorizedException>()
.WithMessage("Invalid username or password.");
_passwordInfraMock.Verify(
x => x.Verify(It.IsAny<string>(), It.IsAny<string>()),
Times.Never
);
}
[Fact]
public async Task Handle_WithIncorrectPassword_ThrowsUnauthorizedException()
{
const string username = "RCarnap";
Guid userAccountId = Guid.NewGuid();
UserAccount userAccount = new() { UserAccountId = userAccountId, Username = username };
UserCredential userCredential = new()
{
UserCredentialId = Guid.NewGuid(),
UserAccountId = userAccountId,
Hash = "hashed-password",
};
ApplicationUser user = new() { Id = Guid.NewGuid(), UserName = username };
_authRepoMock.Setup(x => x.GetUserByUsernameAsync(username)).ReturnsAsync(userAccount);
_authRepoMock
.Setup(x => x.GetActiveCredentialByUserAccountIdAsync(userAccountId))
.ReturnsAsync(userCredential);
_passwordInfraMock
.Setup(x => x.Verify(It.IsAny<string>(), It.IsAny<string>()))
.Returns(false);
_userManagerMock.Setup(x => x.FindByNameAsync(username)).ReturnsAsync(user);
_userManagerMock.Setup(x => x.CheckPasswordAsync(user, It.IsAny<string>())).ReturnsAsync(false);
Func<Task<LoginPayload>> act = async () =>
await _handler.Handle(

View File

@@ -1,41 +1,45 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
using Moq;
namespace Features.Auth.Tests.Services;
public class TokenServiceRefreshTests
{
private readonly Mock<IAuthRepository> _authRepositoryMock;
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
private readonly TokenService _tokenService;
private readonly Mock<UserManager<ApplicationUser>> _userManagerMock;
public TokenServiceRefreshTests()
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
_authRepositoryMock = new Mock<IAuthRepository>();
_userManagerMock = UserManagerMockFactory.Create();
// Set environment variables for tokens
Environment.SetEnvironmentVariable(
"ACCESS_TOKEN_SECRET",
"test-access-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"REFRESH_TOKEN_SECRET",
"test-refresh-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890"
);
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(
new Dictionary<string, string?>
{
["ACCESS_TOKEN_SECRET"] = "test-access-secret-that-is-very-long-1234567890",
["REFRESH_TOKEN_SECRET"] = "test-refresh-secret-that-is-very-long-1234567890",
["CONFIRMATION_TOKEN_SECRET"] =
"test-confirmation-secret-that-is-very-long-1234567890",
}
)
.Build();
_tokenService = new TokenService(_tokenInfraMock.Object, _authRepositoryMock.Object);
_tokenService = new TokenService(
_tokenInfraMock.Object,
_userManagerMock.Object,
configuration
);
}
[Fact]
@@ -56,15 +60,7 @@ public class TokenServiceRefreshTests
ClaimsIdentity claimsIdentity = new(claims);
ClaimsPrincipal principal = new(claimsIdentity);
UserAccount userAccount = new()
{
UserAccountId = userId,
Username = username,
FirstName = "Test",
LastName = "User",
Email = "test@example.com",
DateOfBirth = new DateTime(1990, 1, 1),
};
ApplicationUser user = new() { Id = userId, UserName = username };
// Mock the validation of refresh token
_tokenInfraMock
@@ -78,19 +74,19 @@ public class TokenServiceRefreshTests
(Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}"
);
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync(userAccount);
_userManagerMock.Setup(x => x.FindByIdAsync(userId.ToString())).ReturnsAsync(user);
// Act
RefreshTokenResult result = await _tokenService.RefreshTokenAsync(refreshToken);
// Assert
result.Should().NotBeNull();
result.UserAccount.UserAccountId.Should().Be(userId);
result.UserAccount.Username.Should().Be(username);
result.UserId.Should().Be(userId);
result.Username.Should().Be(username);
result.AccessToken.Should().NotBeEmpty();
result.RefreshToken.Should().NotBeEmpty();
_authRepositoryMock.Verify(x => x.GetUserByIdAsync(userId), Times.Once);
_userManagerMock.Verify(x => x.FindByIdAsync(userId.ToString()), Times.Once);
// Verify tokens were generated (called twice - once for access, once for refresh)
_tokenInfraMock.Verify(
@@ -161,7 +157,9 @@ public class TokenServiceRefreshTests
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
_authRepositoryMock.Setup(x => x.GetUserByIdAsync(userId)).ReturnsAsync((UserAccount?)null);
_userManagerMock
.Setup(x => x.FindByIdAsync(userId.ToString()))
.ReturnsAsync((ApplicationUser?)null);
// Act & Assert
await FluentActions

View File

@@ -1,10 +1,11 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Exceptions;
using Features.Auth.Repository;
using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using Infrastructure.Jwt;
using Microsoft.Extensions.Configuration;
using Moq;
namespace Features.Auth.Tests.Services;
@@ -18,21 +19,23 @@ public class TokenServiceValidationTests
{
_tokenInfraMock = new Mock<ITokenInfrastructure>();
// Set environment variables for tokens
Environment.SetEnvironmentVariable(
"ACCESS_TOKEN_SECRET",
"test-access-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"REFRESH_TOKEN_SECRET",
"test-refresh-secret-that-is-very-long-1234567890"
);
Environment.SetEnvironmentVariable(
"CONFIRMATION_TOKEN_SECRET",
"test-confirmation-secret-that-is-very-long-1234567890"
);
IConfiguration configuration = new ConfigurationBuilder()
.AddInMemoryCollection(
new Dictionary<string, string?>
{
["ACCESS_TOKEN_SECRET"] = "test-access-secret-that-is-very-long-1234567890",
["REFRESH_TOKEN_SECRET"] = "test-refresh-secret-that-is-very-long-1234567890",
["CONFIRMATION_TOKEN_SECRET"] =
"test-confirmation-secret-that-is-very-long-1234567890",
}
)
.Build();
_tokenService = new TokenService(_tokenInfraMock.Object, new Mock<IAuthRepository>().Object);
_tokenService = new TokenService(
_tokenInfraMock.Object,
UserManagerMockFactory.Create().Object,
configuration
);
}
[Fact]

View File

@@ -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!
);
}
}

View File

@@ -1,9 +1,9 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using MediatR;
using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.ConfirmUser;
@@ -11,8 +11,11 @@ namespace Features.Auth.Commands.ConfirmUser;
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the
/// corresponding user account as confirmed.
/// </summary>
public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService tokenService)
: IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
public class ConfirmUserHandler(
UserManager<ApplicationUser> userManager,
IUserEmailStore<ApplicationUser> emailStore,
ITokenService tokenService
) : IRequestHandler<ConfirmUserCommand, ConfirmationPayload>
{
/// <exception cref="UnauthorizedException">
/// Thrown when the confirmation token is invalid or expired, or when the associated user account cannot be found.
@@ -26,11 +29,15 @@ public class ConfirmUserHandler(IAuthRepository authRepository, ITokenService to
request.Token
);
UserAccount? user = await authRepository.ConfirmUserAccountAsync(validatedToken.UserId);
ApplicationUser? user = await userManager.FindByIdAsync(
validatedToken.UserId.ToString()
);
if (user == null)
throw new UnauthorizedException("User account not found");
return new ConfirmationPayload(user.UserAccountId, DateTime.UtcNow);
await emailStore.SetEmailConfirmedAsync(user, true, cancellationToken);
await userManager.UpdateAsync(user);
return new ConfirmationPayload(user.Id, DateTime.UtcNow);
}
}

View File

@@ -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;

View File

@@ -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)));
}
}
}

View File

@@ -1,43 +1,35 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using MediatR;
using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.Login;
/// <summary>
/// Handles <see cref="LoginCommand" /> by verifying credentials and issuing access/refresh tokens.
/// Handles <see cref="LoginCommand" /> by verifying credentials via
/// <see cref="UserManager{TUser}" /> and issuing access/refresh tokens.
/// </summary>
public class LoginHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
ITokenService tokenService
) : IRequestHandler<LoginCommand, LoginPayload>
public class LoginHandler(UserManager<ApplicationUser> userManager, ITokenService tokenService)
: IRequestHandler<LoginCommand, LoginPayload>
{
/// <exception cref="UnauthorizedException">
/// Thrown when the username does not match any account, the account has no active credential,
/// or the supplied password does not match the stored hash.
/// Thrown when the username does not match any account or the supplied password does not match
/// the stored hash.
/// </exception>
public async Task<LoginPayload> Handle(LoginCommand request, CancellationToken cancellationToken)
{
UserAccount user =
await authRepo.GetUserByUsernameAsync(request.Username)
ApplicationUser user =
await userManager.FindByNameAsync(request.Username)
?? throw new UnauthorizedException("Invalid username or password.");
// @todo handle expired passwords
UserCredential activeCred =
await authRepo.GetActiveCredentialByUserAccountIdAsync(user.UserAccountId)
?? throw new UnauthorizedException("Invalid username or password.");
if (!passwordInfrastructure.Verify(request.Password, activeCred.Hash))
if (!await userManager.CheckPasswordAsync(user, request.Password))
throw new UnauthorizedException("Invalid username or password.");
string accessToken = tokenService.GenerateAccessToken(user);
string refreshToken = tokenService.GenerateRefreshToken(user);
string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
return new LoginPayload(user.UserAccountId, user.Username, refreshToken, accessToken);
return new LoginPayload(user.Id, user.UserName, refreshToken, accessToken);
}
}

View File

@@ -4,7 +4,7 @@ using MediatR;
namespace Features.Auth.Commands.RefreshToken;
/// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair. Bound directly from the
/// request body of <c>POST /api/auth/refresh</c>.
/// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the
/// <c>X-Refresh-Token</c> header of <c>POST /api/auth/refresh</c>.
/// </summary>
public record RefreshTokenCommand(string RefreshToken) : IRequest<LoginPayload>;

View File

@@ -22,8 +22,8 @@ public class RefreshTokenHandler(ITokenService tokenService)
{
RefreshTokenResult result = await tokenService.RefreshTokenAsync(request.RefreshToken);
return new LoginPayload(
result.UserAccount.UserAccountId,
result.UserAccount.Username,
result.UserId,
result.Username,
result.RefreshToken,
result.AccessToken
);

View File

@@ -1,75 +1,63 @@
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Dtos;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Shared.Application.Emails;
namespace Features.Auth.Commands.RegisterUser;
/// <summary>
/// Handles <see cref="RegisterUserCommand" />: validates uniqueness, hashes the password, persists the
/// account, issues access/refresh/confirmation tokens, and attempts to send the registration
/// confirmation email via Features.Emails.
/// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" />
/// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and
/// attempts to send the registration confirmation email via Features.Emails.
/// </summary>
public class RegisterUserHandler(
IAuthRepository authRepo,
IPasswordInfrastructure passwordInfrastructure,
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IMediator mediator
) : IRequestHandler<RegisterUserCommand, RegistrationPayload>
{
/// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address.
/// Thrown when an existing account already has the same username or email address, or when user
/// creation otherwise fails.
/// </exception>
public async Task<RegistrationPayload> Handle(
RegisterUserCommand request,
CancellationToken cancellationToken
)
{
await ValidateUserDoesNotExist(request.Username, request.Email);
string hashed = passwordInfrastructure.Hash(request.Password);
UserAccount createdUser = await authRepo.RegisterUserAsync(
new UserAccount
ApplicationUser user = new UserAccount
{
Username = request.Username,
Email = request.Email,
FirstName = request.FirstName,
LastName = request.LastName,
DateOfBirth = request.DateOfBirth,
Email = request.Email,
UserCredential = new UserCredential
{
Hash = hashed,
}
}
);
}.ToApplicationUser();
string accessToken = tokenService.GenerateAccessToken(createdUser);
string refreshToken = tokenService.GenerateRefreshToken(createdUser);
string confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
IdentityResult result = await userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
if (result.Errors.Any(e => e.Code is "DuplicateUserName" or "DuplicateEmail"))
throw new ConflictException("Username or email already exists");
throw new ConflictException(string.Join("; ", result.Errors.Select(e => e.Description)));
}
string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
if (string.IsNullOrEmpty(accessToken) || string.IsNullOrEmpty(refreshToken))
return new RegistrationPayload(
createdUser.UserAccountId,
createdUser.Username,
string.Empty,
string.Empty,
false
);
return new RegistrationPayload(user.Id, user.UserName, string.Empty, string.Empty, false);
bool emailSent = false;
try
{
await mediator.Send(
new SendRegistrationEmailCommand(
createdUser.FirstName,
createdUser.Email,
confirmationToken
),
new SendRegistrationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken
);
emailSent = true;
@@ -81,24 +69,6 @@ public class RegisterUserHandler(
// ignored
}
return new RegistrationPayload(
createdUser.UserAccountId,
createdUser.Username,
refreshToken,
accessToken,
emailSent
);
}
/// <exception cref="ConflictException">
/// Thrown when an existing account already has the same username or email address.
/// </exception>
private async Task ValidateUserDoesNotExist(string username, string email)
{
UserAccount? existingUsername = await authRepo.GetUserByUsernameAsync(username);
UserAccount? existingEmail = await authRepo.GetUserByEmailAsync(email);
if (existingUsername != null || existingEmail != null)
throw new ConflictException("Username or email already exists");
return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent);
}
}

View File

@@ -1,7 +1,7 @@
using Domain.Entities;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Shared.Application.Emails;
namespace Features.Auth.Commands.ResendConfirmationEmail;
@@ -15,7 +15,7 @@ namespace Features.Auth.Commands.ResendConfirmationEmail;
/// or if the user's account is already verified.
/// </remarks>
public class ResendConfirmationEmailHandler(
IAuthRepository authRepository,
UserManager<ApplicationUser> userManager,
ITokenService tokenService,
IMediator mediator
) : IRequestHandler<ResendConfirmationEmailCommand>
@@ -25,14 +25,14 @@ public class ResendConfirmationEmailHandler(
CancellationToken cancellationToken
)
{
UserAccount? user = await authRepository.GetUserByIdAsync(request.UserId);
ApplicationUser? user = await userManager.FindByIdAsync(request.UserId.ToString());
if (user == null)
return; // Silent return to prevent user enumeration
if (await authRepository.IsUserVerifiedAsync(request.UserId))
if (await userManager.IsEmailConfirmedAsync(user))
return; // Already confirmed, no-op
string confirmationToken = tokenService.GenerateConfirmationToken(user);
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
await mediator.Send(
new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
cancellationToken

View File

@@ -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>;

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>;

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>;

View File

@@ -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);
}
}

View File

@@ -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");
}
}

View File

@@ -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>;

View File

@@ -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);
}
}

View File

@@ -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"
);
}
}

View File

@@ -1,8 +1,15 @@
using System.IdentityModel.Tokens.Jwt;
using Domain.Exceptions;
using Features.Auth.Commands.ConfirmUser;
using Features.Auth.Commands.DeleteAccount;
using Features.Auth.Commands.Login;
using Features.Auth.Commands.RefreshToken;
using Features.Auth.Commands.RegisterUser;
using Features.Auth.Commands.ResendConfirmationEmail;
using Features.Auth.Commands.UpdateEmail;
using Features.Auth.Commands.UpdatePassword;
using Features.Auth.Commands.UpdateProfile;
using Features.Auth.Commands.UpdateUsername;
using Features.Auth.Dtos;
using MediatR;
using Microsoft.AspNetCore.Authorization;
@@ -12,7 +19,8 @@ using Shared.Contracts;
namespace Features.Auth.Controllers;
/// <summary>
/// Handles user authentication concerns: registration, login, email confirmation, and token refresh.
/// Handles user authentication concerns: registration, login, email confirmation, token refresh, and
/// self-service account management.
/// </summary>
/// <remarks>
/// The controller is decorated with <c>[Authorize(AuthenticationSchemes = "JWT")]</c> by default, but most
@@ -23,6 +31,18 @@ namespace Features.Auth.Controllers;
[Authorize(AuthenticationSchemes = "JWT")]
public class AuthController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Extracts the authenticated caller's user ID from the validated access token's <c>sub</c> claim.
/// </summary>
/// <exception cref="UnauthorizedException">Thrown when the claim is missing or malformed.</exception>
private Guid GetAuthenticatedUserId()
{
string? userIdClaim = User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
if (!Guid.TryParse(userIdClaim, out Guid userId))
throw new UnauthorizedException("Access token is missing a valid user ID claim");
return userId;
}
/// <summary>Registers a new user account.</summary>
/// <remarks>
/// Anonymous access. Returns 201 Created with the new account's ID, username, and issued tokens.
@@ -98,16 +118,19 @@ public class AuthController(IMediator mediator) : ControllerBase
/// <summary>Exchanges a valid refresh token for a new access/refresh token pair.</summary>
/// <remarks>
/// Anonymous access. Returns 200 OK with the new tokens, or 401 Unauthorized if the refresh token is
/// invalid or expired.
/// Anonymous access. The refresh token is supplied via the <c>X-Refresh-Token</c> header, not the
/// request body. Returns 200 OK with the new tokens, 400 Bad Request if the header is missing, or
/// 401 Unauthorized if the refresh token is invalid or expired.
/// </remarks>
[AllowAnonymous]
[HttpPost("refresh")]
public async Task<ActionResult<ResponseBody<LoginPayload>>> Refresh(
[FromBody] RefreshTokenCommand command
[FromHeader(Name = "X-Refresh-Token")] string? refreshToken
)
{
LoginPayload payload = await mediator.Send(command);
LoginPayload payload = await mediator.Send(
new RefreshTokenCommand(refreshToken ?? string.Empty)
);
return Ok(
new ResponseBody<LoginPayload>
{
@@ -116,4 +139,107 @@ public class AuthController(IMediator mediator) : ControllerBase
}
);
}
/// <summary>Changes the authenticated user's username.</summary>
/// <remarks>Returns 200 OK with the updated username, or 409 Conflict if it is already taken.</remarks>
[HttpPatch("username")]
public async Task<ActionResult<ResponseBody<UpdateUsernamePayload>>> UpdateUsername(
[FromBody] UpdateUsernameRequest request
)
{
UpdateUsernamePayload payload = await mediator.Send(
new UpdateUsernameCommand(GetAuthenticatedUserId(), request.NewUsername)
);
return Ok(
new ResponseBody<UpdateUsernamePayload>
{
Message = "Username updated successfully.",
Payload = payload,
}
);
}
/// <summary>Changes the authenticated user's email address.</summary>
/// <remarks>
/// Returns 200 OK with the updated (now unconfirmed) email address, or 409 Conflict if it is
/// already in use. The caller should use <c>POST /api/auth/confirm/resend</c> to re-confirm.
/// </remarks>
[HttpPatch("email")]
public async Task<ActionResult<ResponseBody<UpdateEmailPayload>>> UpdateEmail(
[FromBody] UpdateEmailRequest request
)
{
UpdateEmailPayload payload = await mediator.Send(
new UpdateEmailCommand(GetAuthenticatedUserId(), request.NewEmail)
);
return Ok(
new ResponseBody<UpdateEmailPayload>
{
Message = "Email updated successfully.",
Payload = payload,
}
);
}
/// <summary>Changes the authenticated user's password.</summary>
/// <remarks>
/// Returns 200 OK on success, or 401 Unauthorized if <c>CurrentPassword</c> does not match the
/// account's current password.
/// </remarks>
[HttpPatch("password")]
public async Task<ActionResult<ResponseBody<UpdatePasswordPayload>>> UpdatePassword(
[FromBody] UpdatePasswordRequest request
)
{
UpdatePasswordPayload payload = await mediator.Send(
new UpdatePasswordCommand(
GetAuthenticatedUserId(),
request.CurrentPassword,
request.NewPassword
)
);
return Ok(
new ResponseBody<UpdatePasswordPayload>
{
Message = "Password updated successfully.",
Payload = payload,
}
);
}
/// <summary>Updates the authenticated user's profile fields (first name, last name, date of birth).</summary>
/// <remarks>Returns 200 OK with the updated profile.</remarks>
[HttpPatch("profile")]
public async Task<ActionResult<ResponseBody<UpdateProfilePayload>>> UpdateProfile(
[FromBody] UpdateProfileRequest request
)
{
UpdateProfilePayload payload = await mediator.Send(
new UpdateProfileCommand(
GetAuthenticatedUserId(),
request.FirstName,
request.LastName,
request.DateOfBirth
)
);
return Ok(
new ResponseBody<UpdateProfilePayload>
{
Message = "Profile updated successfully.",
Payload = payload,
}
);
}
/// <summary>Permanently deletes the authenticated user's account.</summary>
/// <remarks>
/// Returns 200 OK on success, or 409 Conflict if the account still has associated posts, comments,
/// photos, or follows.
/// </remarks>
[HttpDelete("account")]
public async Task<ActionResult<ResponseBody>> DeleteAccount()
{
await mediator.Send(new DeleteAccountCommand(GetAuthenticatedUserId()));
return Ok(new ResponseBody { Message = "Account deleted successfully." });
}
}

View File

@@ -1,6 +1,7 @@
using Features.Auth.Repository;
using Features.Auth.Identity;
using Features.Auth.Services;
using Infrastructure.PasswordHashing;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.DependencyInjection;
namespace Features.Auth.DependencyInjection;
@@ -9,14 +10,44 @@ namespace Features.Auth.DependencyInjection;
public static class FeaturesAuthServiceCollectionExtensions
{
/// <summary>
/// Adds <see cref="IAuthRepository" />, <see cref="ITokenService" />, and the Argon2 password hashing
/// infrastructure as scoped services.
/// Adds <see cref="ITokenService" /> and ASP.NET Core Identity (<see cref="UserManager{TUser}" />
/// backed by <see cref="DapperUserStore" /> and <see cref="Argon2PasswordHasher" />) as scoped services.
/// </summary>
public static IServiceCollection AddFeaturesAuth(this IServiceCollection services)
{
services.AddScoped<IAuthRepository, AuthRepository>();
services.AddScoped<ITokenService, TokenService>();
services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
// AddDefaultTokenProviders() is deliberately not called: it registers
// DataProtectorTokenProvider<TUser>, which UserManager's constructor resolves eagerly and which
// requires IDataProtectionProvider -- not registered in the plain-console-app DI containers this
// solution builds (e.g. Database.Seed). Identity's own token providers are unused here anyway;
// account confirmation keeps using its own JWT-based tokens (see ITokenService).
services.AddIdentityCore<ApplicationUser>(options =>
{
options.User.RequireUniqueEmail = true;
// Password/username complexity is already enforced by RegisterUserValidator
// (FluentValidation); disable Identity's own checks to avoid duplicated/drifting rules.
options.Password.RequireDigit = false;
options.Password.RequireLowercase = false;
options.Password.RequireUppercase = false;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequiredLength = 1;
});
services.AddScoped<DapperUserStore>();
services.AddScoped<IUserStore<ApplicationUser>>(sp =>
sp.GetRequiredService<DapperUserStore>()
);
services.AddScoped<IUserEmailStore<ApplicationUser>>(sp =>
sp.GetRequiredService<DapperUserStore>()
);
services.AddScoped<IUserPasswordStore<ApplicationUser>>(sp =>
sp.GetRequiredService<DapperUserStore>()
);
services.AddScoped<IPasswordHasher<ApplicationUser>, Argon2PasswordHasher>();
return services;
}
}

View File

@@ -19,3 +19,48 @@ public record RegistrationPayload(
/// <summary>Payload returned to the client after a user account's email has been confirmed.</summary>
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
/// <summary>Request body for changing the authenticated user's username.</summary>
/// <param name="NewUsername">
/// The desired username; must be 3-64 characters and contain only letters, numbers, dots,
/// underscores, and hyphens.
/// </param>
public record UpdateUsernameRequest(string NewUsername);
/// <summary>Payload returned to the client after a username change.</summary>
public record UpdateUsernamePayload(Guid UserAccountId, string Username);
/// <summary>Request body for changing the authenticated user's email address.</summary>
/// <param name="NewEmail">The new email address; up to 128 characters and must be a valid email format.</param>
public record UpdateEmailRequest(string NewEmail);
/// <summary>
/// Payload returned to the client after an email change. The new address is unconfirmed until the
/// user completes the confirmation flow again.
/// </summary>
public record UpdateEmailPayload(Guid UserAccountId, string Email, bool EmailConfirmed);
/// <summary>Request body for updating the authenticated user's profile fields.</summary>
/// <param name="FirstName">The user's first name; up to 128 characters.</param>
/// <param name="LastName">The user's last name; up to 128 characters.</param>
/// <param name="DateOfBirth">The user's date of birth; the user must be at least 19 years old.</param>
public record UpdateProfileRequest(string FirstName, string LastName, DateTime DateOfBirth);
/// <summary>Payload returned to the client after a profile update.</summary>
public record UpdateProfilePayload(
Guid UserAccountId,
string FirstName,
string LastName,
DateTime DateOfBirth
);
/// <summary>Request body for changing the authenticated user's password.</summary>
/// <param name="CurrentPassword">The user's current plaintext password, verified before the change is applied.</param>
/// <param name="NewPassword">
/// The desired new plaintext password; must be at least 8 characters and contain an uppercase letter, a
/// lowercase letter, a number, and a special character.
/// </param>
public record UpdatePasswordRequest(string CurrentPassword, string NewPassword);
/// <summary>Payload returned to the client after a password change.</summary>
public record UpdatePasswordPayload(Guid UserAccountId, DateTime ChangedAt);

View File

@@ -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&lt;TKey&gt;</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; }
}

View File

@@ -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;
}

View 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;
}

View File

@@ -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,
};
}

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -1,5 +1,4 @@
using System.Security.Claims;
using Domain.Entities;
namespace Features.Auth.Services;
@@ -22,7 +21,7 @@ public enum TokenType
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
/// <summary>Represents the result of refreshing a user's session.</summary>
public record RefreshTokenResult(UserAccount UserAccount, string RefreshToken, string AccessToken);
public record RefreshTokenResult(Guid UserId, string Username, string RefreshToken, string AccessToken);
/// <summary>
/// Defines the expiration windows, in hours, for each type of token issued by <see cref="ITokenService" />.
@@ -48,19 +47,19 @@ public interface ITokenService
/// Generates a signed access token for the given user, expiring after
/// <see cref="TokenServiceExpirationHours.AccessTokenHours" /> hours.
/// </summary>
string GenerateAccessToken(UserAccount user);
string GenerateAccessToken(Guid userId, string username);
/// <summary>
/// Generates a signed refresh token for the given user, expiring after
/// <see cref="TokenServiceExpirationHours.RefreshTokenHours" /> hours.
/// </summary>
string GenerateRefreshToken(UserAccount user);
string GenerateRefreshToken(Guid userId, string username);
/// <summary>
/// Generates a signed confirmation token for the given user, expiring after
/// <see cref="TokenServiceExpirationHours.ConfirmationTokenHours" /> hours.
/// </summary>
string GenerateConfirmationToken(UserAccount user);
string GenerateConfirmationToken(Guid userId, string username);
/// <summary>Validates an access token's signature and expiration and extracts the caller's identity.</summary>

View File

@@ -1,88 +1,83 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Domain.Entities;
using Domain.Exceptions;
using Features.Auth.Repository;
using Features.Auth.Identity;
using Infrastructure.Jwt;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Configuration;
namespace Features.Auth.Services;
/// <summary>
/// Default implementation of <see cref="ITokenService" /> that generates and validates JWTs
/// for access, refresh, and confirmation flows using secrets read from environment variables.
/// for access, refresh, and confirmation flows using secrets read from configuration.
/// </summary>
public class TokenService : ITokenService
{
private readonly string _accessTokenSecret;
private readonly IAuthRepository _authRepository;
private readonly string _confirmationTokenSecret;
private readonly string _refreshTokenSecret;
private readonly ITokenInfrastructure _tokenInfrastructure;
private readonly UserManager<ApplicationUser> _userManager;
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
/// <exception cref="InvalidOperationException">
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
/// <c>CONFIRMATION_TOKEN_SECRET</c> environment variables are not set.
/// <c>CONFIRMATION_TOKEN_SECRET</c> configuration values are not set.
/// </exception>
public TokenService(ITokenInfrastructure tokenInfrastructure, IAuthRepository authRepository)
public TokenService(
ITokenInfrastructure tokenInfrastructure,
UserManager<ApplicationUser> userManager,
IConfiguration configuration
)
{
_tokenInfrastructure = tokenInfrastructure;
_authRepository = authRepository;
_userManager = userManager;
_accessTokenSecret =
Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
configuration["ACCESS_TOKEN_SECRET"]
?? throw new InvalidOperationException(
"ACCESS_TOKEN_SECRET environment variable is not set"
);
_refreshTokenSecret =
Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
configuration["REFRESH_TOKEN_SECRET"]
?? throw new InvalidOperationException(
"REFRESH_TOKEN_SECRET environment variable is not set"
);
_confirmationTokenSecret =
Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
configuration["CONFIRMATION_TOKEN_SECRET"]
?? throw new InvalidOperationException(
"CONFIRMATION_TOKEN_SECRET environment variable is not set"
);
}
/// <inheritdoc />
public string GenerateAccessToken(UserAccount user)
public string GenerateAccessToken(Guid userId, string username)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_accessTokenSecret
);
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _accessTokenSecret);
}
/// <inheritdoc />
public string GenerateRefreshToken(UserAccount user)
public string GenerateRefreshToken(Guid userId, string username)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(
TokenServiceExpirationHours.RefreshTokenHours
);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
expiresAt,
_refreshTokenSecret
);
return _tokenInfrastructure.GenerateJwt(userId, username, expiresAt, _refreshTokenSecret);
}
/// <inheritdoc />
public string GenerateConfirmationToken(UserAccount user)
public string GenerateConfirmationToken(Guid userId, string username)
{
DateTime expiresAt = DateTime.UtcNow.AddHours(
TokenServiceExpirationHours.ConfirmationTokenHours
);
return _tokenInfrastructure.GenerateJwt(
user.UserAccountId,
user.Username,
userId,
username,
expiresAt,
_confirmationTokenSecret
);
@@ -110,14 +105,14 @@ public class TokenService : ITokenService
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
{
ValidatedToken validated = await ValidateRefreshTokenAsync(refreshTokenString);
UserAccount? user = await _authRepository.GetUserByIdAsync(validated.UserId);
ApplicationUser? user = await _userManager.FindByIdAsync(validated.UserId.ToString());
if (user == null)
throw new UnauthorizedException("User account not found");
string newAccess = GenerateAccessToken(user);
string newRefresh = GenerateRefreshToken(user);
string newAccess = GenerateAccessToken(user.Id, user.UserName);
string newRefresh = GenerateRefreshToken(user.Id, user.UserName);
return new RefreshTokenResult(user, newRefresh, newAccess);
return new RefreshTokenResult(user.Id, user.UserName, newRefresh, newAccess);
}
/// <summary>

View File

@@ -6,6 +6,13 @@
<RootNamespace>Features.Emails</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />

View File

@@ -1,5 +1,6 @@
using Infrastructure.Email;
using Infrastructure.Email.Templates.Rendering;
using Microsoft.Extensions.Configuration;
namespace Features.Emails.Services;
@@ -8,18 +9,19 @@ namespace Features.Emails.Services;
/// </summary>
public class EmailDispatcher(
IEmailProvider emailProvider,
IEmailTemplateProvider emailTemplateProvider
IEmailTemplateProvider emailTemplateProvider,
IConfiguration configuration
) : IEmailDispatcher
{
/// <summary>
/// The base URL of the website, used to build confirmation links. Read from the
/// <c>WEBSITE_BASE_URL</c> environment variable.
/// <c>WEBSITE_BASE_URL</c> configuration value.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown at type initialization time when the <c>WEBSITE_BASE_URL</c> environment variable is not set.
/// Thrown when the <c>WEBSITE_BASE_URL</c> configuration value is not set.
/// </exception>
private static readonly string WebsiteBaseUrl =
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
private string WebsiteBaseUrl =>
configuration["WEBSITE_BASE_URL"]
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
/// <inheritdoc />

View File

@@ -8,5 +8,9 @@
<ItemGroup>
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference
Include="Microsoft.Extensions.Configuration.Abstractions"
Version="10.0.1"
/>
</ItemGroup>
</Project>

View File

@@ -1,12 +1,13 @@
using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Configuration;
using MimeKit;
namespace Infrastructure.Email;
/// <summary>
/// SMTP email service implementation using MailKit.
/// Configured via environment variables.
/// Configured via application configuration (backed by environment variables).
/// </summary>
public class SmtpEmailProvider : IEmailProvider
{
@@ -20,35 +21,35 @@ public class SmtpEmailProvider : IEmailProvider
/// <summary>
/// Initializes a new instance of the <see cref="SmtpEmailProvider" /> class, reading SMTP configuration
/// from environment variables (<c>SMTP_HOST</c>, <c>SMTP_PORT</c>, <c>SMTP_USERNAME</c>,
/// <c>SMTP_PASSWORD</c>, <c>SMTP_USE_SSL</c>, <c>SMTP_FROM_EMAIL</c>, <c>SMTP_FROM_NAME</c>).
/// (<c>SMTP_HOST</c>, <c>SMTP_PORT</c>, <c>SMTP_USERNAME</c>, <c>SMTP_PASSWORD</c>, <c>SMTP_USE_SSL</c>,
/// <c>SMTP_FROM_EMAIL</c>, <c>SMTP_FROM_NAME</c>) from <paramref name="configuration" />.
/// </summary>
/// <exception cref="InvalidOperationException">
/// Thrown when <c>SMTP_HOST</c> or <c>SMTP_FROM_EMAIL</c> is not set, or when <c>SMTP_PORT</c> is not a valid integer.
/// </exception>
public SmtpEmailProvider()
public SmtpEmailProvider(IConfiguration configuration)
{
_host =
Environment.GetEnvironmentVariable("SMTP_HOST")
configuration["SMTP_HOST"]
?? throw new InvalidOperationException("SMTP_HOST environment variable is not set");
string portString = Environment.GetEnvironmentVariable("SMTP_PORT") ?? "587";
string portString = configuration["SMTP_PORT"] ?? "587";
if (!int.TryParse(portString, out _port))
throw new InvalidOperationException($"SMTP_PORT '{portString}' is not a valid integer");
_username = Environment.GetEnvironmentVariable("SMTP_USERNAME");
_password = Environment.GetEnvironmentVariable("SMTP_PASSWORD");
_username = configuration["SMTP_USERNAME"];
_password = configuration["SMTP_PASSWORD"];
string useSslString = Environment.GetEnvironmentVariable("SMTP_USE_SSL") ?? "true";
string useSslString = configuration["SMTP_USE_SSL"] ?? "true";
_useSsl = bool.Parse(useSslString);
_fromEmail =
Environment.GetEnvironmentVariable("SMTP_FROM_EMAIL")
configuration["SMTP_FROM_EMAIL"]
?? throw new InvalidOperationException(
"SMTP_FROM_EMAIL environment variable is not set"
);
_fromName = Environment.GetEnvironmentVariable("SMTP_FROM_NAME") ?? "The Biergarten";
_fromName = configuration["SMTP_FROM_NAME"] ?? "The Biergarten";
}
/// <inheritdoc/>

View File

@@ -20,21 +20,21 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlCon
/// <summary>
/// Resolves the SQL Server connection string, preferring (in order): the <c>DB_CONNECTION_STRING</c>
/// environment variable, a connection string built from individual <c>DB_*</c> environment variables,
/// configuration value, a connection string built from individual <c>DB_*</c> configuration values,
/// and finally the <c>"Default"</c> connection string from configuration.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
string? fullConnectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING");
string? fullConnectionString = configuration["DB_CONNECTION_STRING"];
if (!string.IsNullOrEmpty(fullConnectionString))
return fullConnectionString;
// Try to build from individual environment variables (preferred method for Docker)
// Try to build from individual configuration values (preferred method for Docker)
try
{
return SqlConnectionStringHelper.BuildConnectionString();
return SqlConnectionStringHelper.BuildConnectionString(configuration);
}
catch (InvalidOperationException)
{

View File

@@ -1,23 +1,29 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
namespace Infrastructure.Sql;
/// <summary>
/// Helper for building SQL Server connection strings from environment variables.
/// Helper for building SQL Server connection strings from configuration
/// (backed by the <c>DB_*</c> environment variables).
/// </summary>
public static class SqlConnectionStringHelper
{
/// <summary>
/// Builds a SQL Server connection string from environment variables.
/// Builds a SQL Server connection string from configuration.
/// </summary>
/// <remarks>Throws <see cref="InvalidOperationException" /> if DB_SERVER, DB_NAME, DB_USER, or DB_PASSWORD is unset.</remarks>
/// <param name="databaseName">If null, falls back to the DB_NAME environment variable.</param>
public static string BuildConnectionString(string? databaseName = null)
/// <param name="configuration">The configuration to read the <c>DB_*</c> values from.</param>
/// <param name="databaseName">If null, falls back to the DB_NAME configuration value.</param>
public static string BuildConnectionString(
IConfiguration configuration,
string? databaseName = null
)
{
string server = Required("DB_SERVER");
string dbName = databaseName ?? Required("DB_NAME");
string user = Required("DB_USER");
string password = Required("DB_PASSWORD");
string server = Required(configuration, "DB_SERVER");
string dbName = databaseName ?? Required(configuration, "DB_NAME");
string user = Required(configuration, "DB_USER");
string password = Required(configuration, "DB_PASSWORD");
SqlConnectionStringBuilder builder = new()
{
@@ -25,7 +31,7 @@ public static class SqlConnectionStringHelper
InitialCatalog = dbName,
UserID = user,
Password = password,
TrustServerCertificate = ResolveTrustServerCertificate(),
TrustServerCertificate = ResolveTrustServerCertificate(configuration),
Encrypt = true,
};
@@ -33,29 +39,27 @@ public static class SqlConnectionStringHelper
}
/// <summary>
/// Builds a connection string to the master database using environment variables.
/// Builds a connection string to the master database using configuration.
/// </summary>
public static string BuildMasterConnectionString()
public static string BuildMasterConnectionString(IConfiguration configuration)
{
return BuildConnectionString("master");
return BuildConnectionString(configuration, "master");
}
/// <summary>
/// Resolves the <c>DB_TRUST_SERVER_CERTIFICATE</c> environment variable, defaulting to
/// Resolves the <c>DB_TRUST_SERVER_CERTIFICATE</c> configuration value, defaulting to
/// <c>true</c> when it is unset or not a valid boolean.
/// </summary>
private static bool ResolveTrustServerCertificate()
private static bool ResolveTrustServerCertificate(IConfiguration configuration)
{
return !bool.TryParse(
Environment.GetEnvironmentVariable("DB_TRUST_SERVER_CERTIFICATE"),
configuration["DB_TRUST_SERVER_CERTIFICATE"],
out bool trustServerCertificate
)
|| trustServerCertificate;
}
private static string Required(string variableName) =>
Environment.GetEnvironmentVariable(variableName)
?? throw new InvalidOperationException(
$"The {variableName} environment variable is not set."
);
private static string Required(IConfiguration configuration, string key) =>
configuration[key]
?? throw new InvalidOperationException($"The {key} environment variable is not set.");
}

3
web/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.features-gen/
.react-router

View File

@@ -4,7 +4,7 @@
"singleQuote": true,
"trailingComma": "all",
"semi": true,
"tabWidth": 3,
"tabWidth": 4,
"useTabs": false,
"arrowParens": "always",
"endOfLine": "lf"

View File

@@ -1,9 +0,0 @@
// Generated by React Router
import "react-router";
declare module "react-router" {
interface Future {
v8_middleware: false
}
}

View File

@@ -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");
};

View File

@@ -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"];
}

View File

@@ -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"];
}

View File

@@ -2,10 +2,10 @@
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../confirm.js")
type Module = typeof import("../account.js")
type Info = GetInfo<{
file: "routes/confirm.tsx",
file: "routes/account.tsx",
module: Module
}>
@@ -13,11 +13,11 @@ type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/confirm";
module: typeof import("../confirm.js");
id: "routes/account";
module: typeof import("../account.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
export namespace Route {
// links
@@ -54,9 +54,18 @@ export namespace Route {
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// ServerHydrateFallback
export type ServerHydrateFallbackProps = Annotations["ServerHydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ServerComponent
export type ServerComponentProps = Annotations["ServerComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
// ServerErrorBoundary
export type ServerErrorBoundaryProps = Annotations["ServerErrorBoundaryProps"];
}

View File

@@ -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"];
}

View File

@@ -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"];
}

View File

@@ -2,10 +2,10 @@
import type { GetInfo, GetAnnotations } from "react-router/internal";
type Module = typeof import("../beers.js")
type Module = typeof import("../brewery-detail.js")
type Info = GetInfo<{
file: "routes/beers.tsx",
file: "routes/brewery-detail.tsx",
module: Module
}>
@@ -13,11 +13,11 @@ type Matches = [{
id: "root";
module: typeof import("../../root.js");
}, {
id: "routes/beers";
module: typeof import("../beers.js");
id: "routes/brewery-detail";
module: typeof import("../brewery-detail.js");
}];
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }, false>;
type Annotations = GetAnnotations<Info & { module: Module, matches: Matches }>;
export namespace Route {
// links
@@ -54,9 +54,18 @@ export namespace Route {
// HydrateFallback
export type HydrateFallbackProps = Annotations["HydrateFallbackProps"];
// ServerHydrateFallback
export type ServerHydrateFallbackProps = Annotations["ServerHydrateFallbackProps"];
// Component
export type ComponentProps = Annotations["ComponentProps"];
// ServerComponent
export type ServerComponentProps = Annotations["ServerComponentProps"];
// ErrorBoundary
export type ErrorBoundaryProps = Annotations["ErrorBoundaryProps"];
// ServerErrorBoundary
export type ServerErrorBoundaryProps = Annotations["ServerErrorBoundaryProps"];
}

View File

@@ -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"];
}

View File

@@ -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"];
}

View File

@@ -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"];
}

View File

@@ -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"];
}

View File

@@ -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"];
}

View File

@@ -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"];
}

View File

@@ -5,6 +5,11 @@ const config: StorybookConfig = {
'../stories/Configure.mdx',
'../stories/SubmitButton.stories.tsx',
'../stories/FormField.stories.tsx',
'../stories/SectionCard.stories.tsx',
'../stories/UsernameSection.stories.tsx',
'../stories/PasswordSection.stories.tsx',
'../stories/LoginForm.stories.tsx',
'../stories/RegisterForm.stories.tsx',
'../stories/Navbar.stories.tsx',
'../stories/Toast.stories.tsx',
'../stories/Themes.stories.tsx',

View File

@@ -2,7 +2,11 @@ import type { Preview } from '@storybook/react-vite';
import { createElement } from 'react';
import { MemoryRouter } from 'react-router';
import '../app/app.css';
import { biergartenThemes, defaultThemeName, isBiergartenTheme } from '../app/lib/themes';
import {
biergartenThemes,
defaultThemeName,
isBiergartenTheme,
} from '../app/features/theme/themes';
const preview: Preview = {
globalTypes: {
@@ -28,18 +32,23 @@ const preview: Preview = {
? context.globals.theme
: defaultThemeName;
return createElement(
MemoryRouter,
undefined,
createElement(
const content = createElement(
'div',
{
'data-theme': theme,
className: 'bg-base-200 p-6 text-base-content',
},
createElement('div', { className: 'mx-auto max-w-7xl' }, createElement(Story)),
),
);
// Stories driving `useSubmit`/`useNavigation` bring their own data
// router (createMemoryRouter + RouterProvider), which can't nest
// inside another <Router>.
if (context.parameters.usesDataRouter) {
return content;
}
return createElement(MemoryRouter, undefined, content);
},
],
parameters: {

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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)',
},
},
}}
/>
);
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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 &amp; 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 &amp; 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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