From 6063b776077db855d733dd32dfc2d1fc55f41eb7 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 10 May 2026 20:44:20 +0200 Subject: [PATCH] feat(users): add self-service account unlock via email When users get locked out from failed sign-ins, they can now request an unlock link by email instead of waiting or contacting an admin. - Add SendUnlockEmailEndpoint (POST with rate limiting, no email enumeration leak) - Add UnlockAccountEndpoint (token verification, security stamp rotation) - Add IAccountUnlockEmailSender with console and production implementations - Add UserSelfUnlockedEvent for audit trail - Update Lockout page with link to unlock flow - Add integration tests for happy path, unknown email, invalid token Closes #181 --- .../src/SimpleModule.Email/EmailModule.cs | 1 + .../IdentityAccountUnlockEmailSender.cs | 22 +++ .../Events/UserSelfUnlockedEvent.cs | 5 + .../IAccountUnlockEmailSender.cs | 6 + .../UsersConstants.cs | 3 + .../Pages/Account/Lockout.tsx | 14 +- .../Pages/Account/SendUnlockEmail.tsx | 55 ++++++ .../Account/SendUnlockEmailConfirmation.tsx | 21 +++ .../SendUnlockEmailConfirmationEndpoint.cs | 18 ++ .../Pages/Account/SendUnlockEmailEndpoint.cs | 60 +++++++ .../Pages/Account/UnlockAccount.tsx | 33 ++++ .../Pages/Account/UnlockAccountEndpoint.cs | 89 ++++++++++ .../src/SimpleModule.Users/Pages/index.ts | 4 + .../ConsoleAccountUnlockEmailSender.cs | 18 ++ .../src/SimpleModule.Users/UsersModule.cs | 1 + .../Integration/AccountUnlockEndpointTests.cs | 165 ++++++++++++++++++ packages/SimpleModule.Client/src/routes.ts | 16 +- 17 files changed, 522 insertions(+), 9 deletions(-) create mode 100644 modules/Email/src/SimpleModule.Email/Services/IdentityAccountUnlockEmailSender.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/Events/UserSelfUnlockedEvent.cs create mode 100644 modules/Users/src/SimpleModule.Users.Contracts/IAccountUnlockEmailSender.cs create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmail.tsx create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailConfirmation.tsx create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailConfirmationEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/UnlockAccount.tsx create mode 100644 modules/Users/src/SimpleModule.Users/Pages/Account/UnlockAccountEndpoint.cs create mode 100644 modules/Users/src/SimpleModule.Users/Services/ConsoleAccountUnlockEmailSender.cs create mode 100644 modules/Users/tests/SimpleModule.Users.Tests/Integration/AccountUnlockEndpointTests.cs diff --git a/modules/Email/src/SimpleModule.Email/EmailModule.cs b/modules/Email/src/SimpleModule.Email/EmailModule.cs index 5a1ec487..184a73a3 100644 --- a/modules/Email/src/SimpleModule.Email/EmailModule.cs +++ b/modules/Email/src/SimpleModule.Email/EmailModule.cs @@ -64,6 +64,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config // Replace Identity's ConsoleEmailSender with a real one services.AddScoped, IdentityEmailSender>(); + services.AddScoped(); services.AddModuleJob(); services.AddModuleJob(); diff --git a/modules/Email/src/SimpleModule.Email/Services/IdentityAccountUnlockEmailSender.cs b/modules/Email/src/SimpleModule.Email/Services/IdentityAccountUnlockEmailSender.cs new file mode 100644 index 00000000..370d2d58 --- /dev/null +++ b/modules/Email/src/SimpleModule.Email/Services/IdentityAccountUnlockEmailSender.cs @@ -0,0 +1,22 @@ +using SimpleModule.Email.Contracts; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Email.Services; + +public class IdentityAccountUnlockEmailSender(IEmailContracts emailContracts) + : IAccountUnlockEmailSender +{ + public async Task SendUnlockLinkAsync(string email, string unlockLink) + { + await emailContracts.SendEmailAsync( + new SendEmailRequest + { + To = email, + Subject = "Unlock your account", + Body = + $"""Your account has been locked due to multiple failed sign-in attempts. Click here to unlock your account.""", + IsHtml = true, + } + ); + } +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSelfUnlockedEvent.cs b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSelfUnlockedEvent.cs new file mode 100644 index 00000000..22a1f6a7 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/Events/UserSelfUnlockedEvent.cs @@ -0,0 +1,5 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Users.Contracts.Events; + +public sealed record UserSelfUnlockedEvent(UserId UserId, string Email) : IEvent; diff --git a/modules/Users/src/SimpleModule.Users.Contracts/IAccountUnlockEmailSender.cs b/modules/Users/src/SimpleModule.Users.Contracts/IAccountUnlockEmailSender.cs new file mode 100644 index 00000000..fea37537 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users.Contracts/IAccountUnlockEmailSender.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.Users.Contracts; + +public interface IAccountUnlockEmailSender +{ + Task SendUnlockLinkAsync(string email, string unlockLink); +} diff --git a/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs b/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs index 32d40cef..2b55b754 100644 --- a/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs +++ b/modules/Users/src/SimpleModule.Users.Contracts/UsersConstants.cs @@ -32,6 +32,9 @@ public static class Routes public const string LoginWith2fa = "/LoginWith2fa"; public const string LoginWithRecoveryCode = "/LoginWithRecoveryCode"; public const string Lockout = "/Lockout"; + public const string SendUnlockEmail = "/SendUnlockEmail"; + public const string SendUnlockEmailConfirmation = "/SendUnlockEmailConfirmation"; + public const string UnlockAccount = "/UnlockAccount"; public const string AccessDenied = "/AccessDenied"; public const string Error = "/Error"; public const string RegisterConfirmation = "/RegisterConfirmation"; diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/Lockout.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/Lockout.tsx index e70cb681..66904fda 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/Account/Lockout.tsx +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/Lockout.tsx @@ -1,3 +1,4 @@ +import { Link } from '@inertiajs/react'; import { Card, CardContent, Container } from '@simplemodule/ui'; export default function Lockout() { @@ -8,9 +9,20 @@ export default function Lockout() {

Locked out

-

+

This account has been locked out, please try again later.

+
+

+ You can also{' '} + + receive an unlock link by email + + . +

diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmail.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmail.tsx new file mode 100644 index 00000000..e0265b2d --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmail.tsx @@ -0,0 +1,55 @@ +import { router } from '@inertiajs/react'; +import { + Button, + Card, + CardContent, + Container, + Field, + FieldGroup, + Input, + Label, +} from '@simplemodule/ui'; + +export default function SendUnlockEmail() { + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + router.post('/Identity/Account/SendUnlockEmail', formData); + } + + return ( + +
+
+ + +

Unlock your account

+

+ Enter your email to receive an unlock link. +

+
+
+ + + + + + + +
+
+
+
+
+
+ ); +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailConfirmation.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailConfirmation.tsx new file mode 100644 index 00000000..36ff3246 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailConfirmation.tsx @@ -0,0 +1,21 @@ +import { Card, CardContent, Container } from '@simplemodule/ui'; + +export default function SendUnlockEmailConfirmation() { + return ( + +
+
+ + +

Check your email

+

+ If an account with that email exists and is currently locked, we've sent an unlock + link. Please check your email. +

+
+
+
+
+
+ ); +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailConfirmationEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailConfirmationEndpoint.cs new file mode 100644 index 00000000..84567fe0 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailConfirmationEndpoint.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Pages.Account; + +public class SendUnlockEmailConfirmationEndpoint : IViewEndpoint +{ + public const string Route = UsersConstants.Routes.SendUnlockEmailConfirmation; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet(Route, () => Inertia.Render("Users/Account/SendUnlockEmailConfirmation")) + .AllowAnonymous(); + } +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailEndpoint.cs new file mode 100644 index 00000000..13547a36 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/SendUnlockEmailEndpoint.cs @@ -0,0 +1,60 @@ +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.WebUtilities; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Core.RateLimiting; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Pages.Account; + +public class SendUnlockEmailEndpoint : IViewEndpoint +{ + public const string Route = UsersConstants.Routes.SendUnlockEmail; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet(Route, () => Inertia.Render("Users/Account/SendUnlockEmail")).AllowAnonymous(); + + app.MapPost( + Route, + async ( + [FromForm] string email, + UserManager userManager, + IAccountUnlockEmailSender unlockEmailSender, + HttpContext context + ) => + { + var user = await userManager.FindByEmailAsync(email); + if (user is not null && await userManager.IsLockedOutAsync(user)) + { + var code = await userManager.GenerateUserTokenAsync( + user, + TokenOptions.DefaultProvider, + "AccountUnlock" + ); + code = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var request = context.Request; + var baseUrl = $"{request.Scheme}://{request.Host}"; + var callbackUrl = + $"{baseUrl}{UsersConstants.ViewPrefix}{UsersConstants.Routes.UnlockAccount}?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(code)}"; + + await unlockEmailSender.SendUnlockLinkAsync(email, callbackUrl); + } + + // Always redirect to confirmation — don't reveal whether the email exists or is locked + return TypedResults.Redirect( + $"{UsersConstants.ViewPrefix}{UsersConstants.Routes.SendUnlockEmailConfirmation}" + ); + } + ) + .AllowAnonymous() + .DisableAntiforgery() + .RateLimit("auth-strict"); + } +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/UnlockAccount.tsx b/modules/Users/src/SimpleModule.Users/Pages/Account/UnlockAccount.tsx new file mode 100644 index 00000000..3cc7cad0 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/UnlockAccount.tsx @@ -0,0 +1,33 @@ +import { Link } from '@inertiajs/react'; +import { Button, Card, CardContent, Container } from '@simplemodule/ui'; + +interface Props { + success: boolean; + message: string; +} + +export default function UnlockAccount({ success, message }: Props) { + return ( + +
+
+ + +

+ {success ? 'Account unlocked' : 'Unlock failed'} +

+

{message}

+ {success && ( + + + + )} +
+
+
+
+
+ ); +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/Account/UnlockAccountEndpoint.cs b/modules/Users/src/SimpleModule.Users/Pages/Account/UnlockAccountEndpoint.cs new file mode 100644 index 00000000..51852d91 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Pages/Account/UnlockAccountEndpoint.cs @@ -0,0 +1,89 @@ +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.WebUtilities; +using SimpleModule.Core; +using SimpleModule.Core.Inertia; +using SimpleModule.Users.Contracts; +using SimpleModule.Users.Contracts.Events; +using Wolverine; + +namespace SimpleModule.Users.Pages.Account; + +public class UnlockAccountEndpoint : IViewEndpoint +{ + public const string Route = UsersConstants.Routes.UnlockAccount; + + public void Map(IEndpointRouteBuilder app) + { + app.MapGet( + Route, + async Task ( + [FromQuery] string? userId, + [FromQuery] string? code, + UserManager userManager, + IMessageBus bus + ) => + { + if (userId is null || code is null) + { + return TypedResults.Redirect("/"); + } + + var user = await userManager.FindByIdAsync(userId); + if (user is null) + { + return Inertia.Render( + "Users/Account/UnlockAccount", + new { success = false, message = "Unable to unlock account." } + ); + } + + var decodedCode = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(code)); + var isValid = await userManager.VerifyUserTokenAsync( + user, + TokenOptions.DefaultProvider, + "AccountUnlock", + decodedCode + ); + + if (!isValid) + { + return Inertia.Render( + "Users/Account/UnlockAccount", + new + { + success = false, + message = "Invalid or expired unlock link.", + } + ); + } + + user.LockoutEnd = null; + user.AccessFailedCount = 0; + await userManager.UpdateAsync(user); + await userManager.UpdateSecurityStampAsync(user); + + await bus.PublishAsync( + new UserSelfUnlockedEvent( + UserId.From(user.Id), + user.Email ?? string.Empty + ) + ); + + return Inertia.Render( + "Users/Account/UnlockAccount", + new + { + success = true, + message = "Your account has been unlocked successfully.", + } + ); + } + ) + .AllowAnonymous(); + } +} diff --git a/modules/Users/src/SimpleModule.Users/Pages/index.ts b/modules/Users/src/SimpleModule.Users/Pages/index.ts index 9f7f02bf..8b78a199 100644 --- a/modules/Users/src/SimpleModule.Users/Pages/index.ts +++ b/modules/Users/src/SimpleModule.Users/Pages/index.ts @@ -14,6 +14,10 @@ export const pages: Record = { 'Users/Account/LoginWith2fa': () => import('./Account/LoginWith2fa'), 'Users/Account/LoginWithRecoveryCode': () => import('./Account/LoginWithRecoveryCode'), 'Users/Account/Lockout': () => import('./Account/Lockout'), + 'Users/Account/SendUnlockEmail': () => import('./Account/SendUnlockEmail'), + 'Users/Account/SendUnlockEmailConfirmation': () => + import('./Account/SendUnlockEmailConfirmation'), + 'Users/Account/UnlockAccount': () => import('./Account/UnlockAccount'), 'Users/Account/AccessDenied': () => import('./Account/AccessDenied'), 'Users/Account/Error': () => import('./Account/Error'), 'Users/Account/ExternalLogin': () => import('./Account/ExternalLogin'), diff --git a/modules/Users/src/SimpleModule.Users/Services/ConsoleAccountUnlockEmailSender.cs b/modules/Users/src/SimpleModule.Users/Services/ConsoleAccountUnlockEmailSender.cs new file mode 100644 index 00000000..4a477d42 --- /dev/null +++ b/modules/Users/src/SimpleModule.Users/Services/ConsoleAccountUnlockEmailSender.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Logging; +using SimpleModule.Users.Contracts; + +namespace SimpleModule.Users.Services; + +public partial class ConsoleAccountUnlockEmailSender( + ILogger logger +) : IAccountUnlockEmailSender +{ + public Task SendUnlockLinkAsync(string email, string unlockLink) + { + LogUnlockLink(logger, email, unlockLink); + return Task.CompletedTask; + } + + [LoggerMessage(Level = LogLevel.Information, Message = "Account unlock for {Email}: {Link}")] + private static partial void LogUnlockLink(ILogger logger, string email, string link); +} diff --git a/modules/Users/src/SimpleModule.Users/UsersModule.cs b/modules/Users/src/SimpleModule.Users/UsersModule.cs index 670aec99..d4399c79 100644 --- a/modules/Users/src/SimpleModule.Users/UsersModule.cs +++ b/modules/Users/src/SimpleModule.Users/UsersModule.cs @@ -80,6 +80,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config services.AddHostedService(); services.AddSingleton, ConsoleEmailSender>(); + services.AddSingleton(); } public void ConfigurePermissions(PermissionRegistryBuilder builder) diff --git a/modules/Users/tests/SimpleModule.Users.Tests/Integration/AccountUnlockEndpointTests.cs b/modules/Users/tests/SimpleModule.Users.Tests/Integration/AccountUnlockEndpointTests.cs new file mode 100644 index 00000000..915f5028 --- /dev/null +++ b/modules/Users/tests/SimpleModule.Users.Tests/Integration/AccountUnlockEndpointTests.cs @@ -0,0 +1,165 @@ +using System.Net; +using System.Text; +using FluentAssertions; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Tests.Shared.Fixtures; +using SimpleModule.Users.Contracts; + +namespace Users.Tests.Integration; + +[Collection(TestCollections.Integration)] +public class AccountUnlockEndpointTests +{ + private readonly SimpleModuleWebApplicationFactory _factory; + private readonly HttpClient _client; + + public AccountUnlockEndpointTests(SimpleModuleWebApplicationFactory factory) + { + _factory = factory; + _client = factory.CreateClient( + new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false, + } + ); + } + + [Fact] + public async Task SendUnlockEmail_UnknownEmail_RedirectsToConfirmation() + { + using var content = new FormUrlEncodedContent( + [new KeyValuePair("email", "nonexistent@example.com")] + ); + + var response = await _client.PostAsync( + "/Identity/Account/SendUnlockEmail", + content + ); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.OriginalString.Should() + .Contain("/Identity/Account/SendUnlockEmailConfirmation"); + } + + [Fact] + public async Task SendUnlockEmail_LockedUser_RedirectsToConfirmation() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var user = new ApplicationUser + { + UserName = "locked-unlock-test@example.com", + Email = "locked-unlock-test@example.com", + EmailConfirmed = true, + DisplayName = "Locked User", + }; + await userManager.CreateAsync(user, "TestPass1234!"); + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync( + user, + DateTimeOffset.UtcNow.AddHours(1) + ); + + using var content = new FormUrlEncodedContent( + [new KeyValuePair("email", user.Email)] + ); + + var response = await _client.PostAsync( + "/Identity/Account/SendUnlockEmail", + content + ); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.OriginalString.Should() + .Contain("/Identity/Account/SendUnlockEmailConfirmation"); + } + + [Fact] + public async Task UnlockAccount_MissingParams_RedirectsToHome() + { + var response = await _client.GetAsync("/Identity/Account/UnlockAccount"); + + response.StatusCode.Should().Be(HttpStatusCode.Redirect); + response.Headers.Location!.OriginalString.Should().Be("/"); + } + + [Fact] + public async Task UnlockAccount_InvalidUserId_ReturnsPage() + { + var response = await _client.GetAsync( + "/Identity/Account/UnlockAccount?userId=nonexistent&code=bogus" + ); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UnlockAccount_InvalidToken_ReturnsPage() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var user = new ApplicationUser + { + UserName = "invalid-token-test@example.com", + Email = "invalid-token-test@example.com", + EmailConfirmed = true, + DisplayName = "Token Test User", + }; + await userManager.CreateAsync(user, "TestPass1234!"); + + var tamperedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes("tampered-token")); + var response = await _client.GetAsync( + $"/Identity/Account/UnlockAccount?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(tamperedCode)}" + ); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task UnlockAccount_ValidToken_UnlocksUser() + { + await using var scope = _factory.Services.CreateAsyncScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + var user = new ApplicationUser + { + UserName = "valid-unlock-test@example.com", + Email = "valid-unlock-test@example.com", + EmailConfirmed = true, + DisplayName = "Valid Unlock User", + }; + await userManager.CreateAsync(user, "TestPass1234!"); + await userManager.SetLockoutEnabledAsync(user, true); + await userManager.SetLockoutEndDateAsync( + user, + DateTimeOffset.UtcNow.AddHours(1) + ); + + // Generate a valid unlock token + var code = await userManager.GenerateUserTokenAsync( + user, + TokenOptions.DefaultProvider, + "AccountUnlock" + ); + var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(code)); + + var response = await _client.GetAsync( + $"/Identity/Account/UnlockAccount?userId={Uri.EscapeDataString(user.Id)}&code={Uri.EscapeDataString(encodedCode)}" + ); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + + // Verify the user is actually unlocked + await using var verifyScope = _factory.Services.CreateAsyncScope(); + var verifyManager = + verifyScope.ServiceProvider.GetRequiredService>(); + var updatedUser = await verifyManager.FindByIdAsync(user.Id); + updatedUser.Should().NotBeNull(); + (await verifyManager.IsLockedOutAsync(updatedUser!)).Should().BeFalse(); + updatedUser!.AccessFailedCount.Should().Be(0); + } +} diff --git a/packages/SimpleModule.Client/src/routes.ts b/packages/SimpleModule.Client/src/routes.ts index 0707b4da..aa8d8bad 100644 --- a/packages/SimpleModule.Client/src/routes.ts +++ b/packages/SimpleModule.Client/src/routes.ts @@ -88,19 +88,16 @@ export const routes = { }, tenants: { api: { - deleteTenantFeature: (id: string | number, flagName: string | number) => - `/api/tenants/${id}/features/${flagName}`, + deleteTenantFeature: (id: string | number, flagName: string | number) => `/api/tenants/${id}/features/${flagName}`, getTenantFeatures: (id: string | number) => `/api/tenants/${id}/features`, - setTenantFeature: (id: string | number, flagName: string | number) => - `/api/tenants/${id}/features/${flagName}`, + setTenantFeature: (id: string | number, flagName: string | number) => `/api/tenants/${id}/features/${flagName}`, addHost: (id: string | number) => `/api/tenants/${id}/hosts`, changeStatus: (id: string | number) => `/api/tenants/${id}/status`, create: () => '/api/tenants' as const, delete: (id: string | number) => `/api/tenants/${id}`, getAll: () => '/api/tenants' as const, getById: (id: string | number) => `/api/tenants/${id}`, - removeHost: (id: string | number, hostId: string | number) => - `/api/tenants/${id}/hosts/${hostId}`, + removeHost: (id: string | number, hostId: string | number) => `/api/tenants/${id}/hosts/${hostId}`, update: (id: string | number) => `/api/tenants/${id}`, }, views: { @@ -157,7 +154,10 @@ export const routes = { resetAuthenticator: () => '/Identity/Account/Manage/ResetAuthenticator' as const, resetPasswordConfirmation: () => '/Identity/Account/ResetPasswordConfirmation' as const, resetPassword: () => '/Identity/Account/ResetPassword' as const, + sendUnlockEmailConfirmation: () => '/Identity/Account/SendUnlockEmailConfirmation' as const, + sendUnlockEmail: () => '/Identity/Account/SendUnlockEmail' as const, twoFactorAuthentication: () => '/Identity/Account/Manage/TwoFactorAuthentication' as const, + unlockAccount: () => '/Identity/Account/UnlockAccount' as const, changePassword: () => '/Identity/Account/Manage/ChangePassword' as const, deletePersonalData: () => '/Identity/Account/Manage/DeletePersonalData' as const, email: () => '/Identity/Account/Manage/Email' as const, @@ -205,8 +205,7 @@ export const routes = { admin: { api: { adminRoles: () => '/admin/roles' as const, - adminSessions: (id: string | number, tokenId: string | number) => - `/admin/users/${id}/sessions/${tokenId}`, + adminSessions: (id: string | number, tokenId: string | number) => `/admin/users/${id}/sessions/${tokenId}`, adminUsers: () => '/admin/users' as const, }, views: { @@ -220,3 +219,4 @@ export const routes = { }, }, } as const; +