diff --git a/Directory.Packages.props b/Directory.Packages.props index aff6ed2..39c7d4a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,10 @@ + + + + diff --git a/src/AndreGoepel.Marten.Identity.Blazor/Email/UserInvitationMailer.cs b/src/AndreGoepel.Marten.Identity.Blazor/Email/UserInvitationMailer.cs index 0949fe6..c168d48 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/Email/UserInvitationMailer.cs +++ b/src/AndreGoepel.Marten.Identity.Blazor/Email/UserInvitationMailer.cs @@ -1,4 +1,5 @@ using System.Text; +using AndreGoepel.Core; using AndreGoepel.Marten.Identity.Users; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Identity; @@ -29,10 +30,10 @@ public async Task InviteAsync( ) { var result = await invitations.InviteAsync(email, roles, cancellationToken); - if (!result.Succeeded) - return result.Result; + if (!result.IsSuccess) + return Failed(result.Error); - await SendAsync(result, email, cancellationToken); + await SendAsync(result.Value!, email, cancellationToken); return IdentityResult.Success; } @@ -42,31 +43,42 @@ public async Task ResendAsync( ) { var result = await invitations.ResendAsync(user, cancellationToken); - if (!result.Succeeded) - return result.Result; + if (!result.IsSuccess) + return Failed(result.Error); - await SendAsync(result, user.Email!, cancellationToken); + await SendAsync(result.Value!, user.Email!, cancellationToken); return IdentityResult.Success; } + /// + /// Adapts a failure back into the + /// this class's own callers (the admin dialog and Users page) expect. A generic error + /// code is used because neither caller branches on it today, only on the joined + /// description text. + /// + private static IdentityResult Failed(string description) => + IdentityResult.Failed( + new IdentityError { Code = "InvitationFailed", Description = description } + ); + private async Task SendAsync( - UserInvitationResult result, + UserInvitationDetails details, string email, CancellationToken cancellationToken ) { // userId identifies the account; the token rides in the query Base64Url-encoded so // it survives the URL intact. - var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(result.Token!)); + var encodedCode = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(details.Token)); var link = navigation.GetUriWithQueryParameters( navigation.ToAbsoluteUri("Account/AcceptInvitation").AbsoluteUri, - new Dictionary { ["userId"] = result.User!.Id, ["code"] = encodedCode } + new Dictionary { ["userId"] = details.User.Id, ["code"] = encodedCode } ); // Pass the raw URL. HTML-encoding is the sender's job, done only if it embeds the // link in HTML — encoding here turns the query separator into "&", which breaks // the link for any sender that emits plain text (e.g. a dev logger writing it to a // console, where "&" is copied verbatim into the browser and splits the query). - await emailSender.SendInvitationLinkAsync(result.User, email, link, cancellationToken); + await emailSender.SendInvitationLinkAsync(details.User, email, link, cancellationToken); } } diff --git a/src/AndreGoepel.Marten.Identity.Blazor/packages.lock.json b/src/AndreGoepel.Marten.Identity.Blazor/packages.lock.json index 71e4576..45c10a7 100644 --- a/src/AndreGoepel.Marten.Identity.Blazor/packages.lock.json +++ b/src/AndreGoepel.Marten.Identity.Blazor/packages.lock.json @@ -184,6 +184,7 @@ "andregoepel.marten.identity": { "type": "Project", "dependencies": { + "AndreGoepel.Core": "[1.0.1, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", "AndreGoepel.Marten.Identity.Abstractions": "[1.8.0, )", "Marten": "[9.19.0, )", @@ -194,6 +195,12 @@ "andregoepel.marten.identity.abstractions": { "type": "Project" }, + "AndreGoepel.Core": { + "type": "CentralTransitive", + "requested": "[1.0.1, )", + "resolved": "1.0.1", + "contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA==" + }, "Marten.AspNetCore": { "type": "CentralTransitive", "requested": "[9.19.0, )", diff --git a/src/AndreGoepel.Marten.Identity/AndreGoepel.Marten.Identity.csproj b/src/AndreGoepel.Marten.Identity/AndreGoepel.Marten.Identity.csproj index 6e655ec..2f37eb7 100644 --- a/src/AndreGoepel.Marten.Identity/AndreGoepel.Marten.Identity.csproj +++ b/src/AndreGoepel.Marten.Identity/AndreGoepel.Marten.Identity.csproj @@ -27,6 +27,7 @@ + diff --git a/src/AndreGoepel.Marten.Identity/Users/UserInvitationService.cs b/src/AndreGoepel.Marten.Identity/Users/UserInvitationService.cs index e2aa8bc..6b796ed 100644 --- a/src/AndreGoepel.Marten.Identity/Users/UserInvitationService.cs +++ b/src/AndreGoepel.Marten.Identity/Users/UserInvitationService.cs @@ -1,4 +1,5 @@ -using AndreGoepel.Marten.Identity.Services; +using AndreGoepel.Core; +using AndreGoepel.Marten.Identity.Services; using Microsoft.AspNetCore.Identity; namespace AndreGoepel.Marten.Identity.Users; @@ -27,38 +28,23 @@ public sealed class UserInvitationService( IIdentityAuthorizer authorizer ) { - private static IdentityResult NotAuthorized() => - IdentityResult.Failed( - new IdentityError - { - Code = IdentityErrorCodes.NotAuthorized, - Description = "Inviting a user requires administrator authority.", - } - ); - - private static IdentityResult Failure(string code, string description) => - IdentityResult.Failed(new IdentityError { Code = code, Description = description }); + private const string NotAuthorizedMessage = "Inviting a user requires administrator authority."; /// /// Creates a passwordless, unconfirmed account for , assigns /// , and returns the invitation token to embed in the link. /// - public async Task InviteAsync( + public async Task> InviteAsync( string email, IEnumerable? roles = null, CancellationToken cancellationToken = default ) { if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) - return UserInvitationResult.Failed(NotAuthorized()); + return Result.Fail(NotAuthorizedMessage); if (await userManager.FindByEmailAsync(email) is not null) - return UserInvitationResult.Failed( - Failure( - IdentityErrorCodes.DuplicateEmail, - $"An account already exists for {email}." - ) - ); + return Result.Fail($"An account already exists for {email}."); // No password argument: the account is created with a null password hash, so it // cannot be signed in to until the invitee redeems the link and sets one. Leaving @@ -69,43 +55,40 @@ public async Task InviteAsync( var createResult = await userManager.CreateAsync(user); if (!createResult.Succeeded) - return UserInvitationResult.Failed(createResult); + return Result.Fail(Describe(createResult)); var roleList = roles?.ToArray() ?? []; foreach (var role in roleList) { var roleResult = await userStore.AddToRoleAsync(user, role, cancellationToken); if (!roleResult.Succeeded) - return UserInvitationResult.Failed(roleResult); + return Result.Fail(Describe(roleResult)); } - return UserInvitationResult.Success(user, await GenerateTokenAsync(user)); + return Result.Ok(new UserInvitationDetails(user, await GenerateTokenAsync(user))); } /// /// Issues a fresh invitation token for an account that was invited but has not been /// claimed yet, for when the first email is lost or expires. /// - public async Task ResendAsync( + public async Task> ResendAsync( User user, CancellationToken cancellationToken = default ) { if (!await authorizer.IsCurrentUserAdministratorAsync(cancellationToken)) - return UserInvitationResult.Failed(NotAuthorized()); + return Result.Fail(NotAuthorizedMessage); // Refuse to re-issue against a claimed account. Without this, "resend invitation" // would amount to a password reset for an active colleague that skips their // mailbox check: it would mint a link setting their password on demand. if (!IsPending(user)) - return UserInvitationResult.Failed( - Failure( - IdentityErrorCodes.InvitationAlreadyAccepted, - "This account has already been set up; send a password reset instead." - ) + return Result.Fail( + "This account has already been set up; send a password reset instead." ); - return UserInvitationResult.Success(user, await GenerateTokenAsync(user)); + return Result.Ok(new UserInvitationDetails(user, await GenerateTokenAsync(user))); } /// @@ -121,29 +104,11 @@ private Task GenerateTokenAsync(User user) => UserInvitationTokenProvider.ProviderName, UserInvitationTokenProvider.Purpose ); -} - -/// Outcome of an invitation attempt: the created user and its token on success. -public sealed record UserInvitationResult -{ - private UserInvitationResult() { } - public bool Succeeded { get; private init; } - public IdentityResult Result { get; private init; } = IdentityResult.Success; - public User? User { get; private init; } - public string? Token { get; private init; } - - /// The failure descriptions, joined for display. - public string ErrorMessage => string.Join(", ", Result.Errors.Select(e => e.Description)); - - internal static UserInvitationResult Success(User user, string token) => - new() - { - Succeeded = true, - User = user, - Token = token, - }; - - internal static UserInvitationResult Failed(IdentityResult result) => - new() { Succeeded = false, Result = result }; + /// Joins an 's failure descriptions into one message. + private static string Describe(IdentityResult result) => + string.Join(", ", result.Errors.Select(e => e.Description)); } + +/// The created user and its invitation token, on a successful invite or resend. +public sealed record UserInvitationDetails(User User, string Token); diff --git a/src/AndreGoepel.Marten.Identity/packages.lock.json b/src/AndreGoepel.Marten.Identity/packages.lock.json index 1b53e6e..100279e 100644 --- a/src/AndreGoepel.Marten.Identity/packages.lock.json +++ b/src/AndreGoepel.Marten.Identity/packages.lock.json @@ -2,6 +2,12 @@ "version": 2, "dependencies": { "net10.0": { + "AndreGoepel.Core": { + "type": "Direct", + "requested": "[1.0.1, )", + "resolved": "1.0.1", + "contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA==" + }, "AndreGoepel.Marten.Configuration": { "type": "Direct", "requested": "[1.0.1, )", diff --git a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/packages.lock.json b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/packages.lock.json index 8a567cb..251bddb 100644 --- a/tests/AndreGoepel.Marten.Identity.Blazor.Tests/packages.lock.json +++ b/tests/AndreGoepel.Marten.Identity.Blazor.Tests/packages.lock.json @@ -370,6 +370,7 @@ "andregoepel.marten.identity": { "type": "Project", "dependencies": { + "AndreGoepel.Core": "[1.0.1, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", "AndreGoepel.Marten.Identity.Abstractions": "[1.8.0, )", "Marten": "[9.19.0, )", @@ -390,6 +391,12 @@ "Radzen.Blazor": "[11.1.7, )" } }, + "AndreGoepel.Core": { + "type": "CentralTransitive", + "requested": "[1.0.1, )", + "resolved": "1.0.1", + "contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA==" + }, "AndreGoepel.Design.Blazor": { "type": "CentralTransitive", "requested": "[1.4.2, )", diff --git a/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserInvitationServiceTests.cs b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserInvitationServiceTests.cs index 8ee90af..0a46168 100644 --- a/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserInvitationServiceTests.cs +++ b/tests/AndreGoepel.Marten.Identity.IntegrationTests/Users/UserInvitationServiceTests.cs @@ -46,8 +46,8 @@ public async Task InviteAsync_AnonymousActor_ReturnsNotAuthorized() var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct); // Assert - Assert.False(result.Succeeded); - Assert.Contains(result.Result.Errors, e => e.Code == "NotAuthorized"); + Assert.False(result.IsSuccess); + Assert.Contains("administrator authority", result.Error); } [Fact] @@ -60,8 +60,8 @@ public async Task InviteAsync_NonAdminActor_ReturnsNotAuthorized() var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct); // Assert - Assert.False(result.Succeeded); - Assert.Contains(result.Result.Errors, e => e.Code == "NotAuthorized"); + Assert.False(result.IsSuccess); + Assert.Contains("administrator authority", result.Error); Assert.Null(await FindAsync("new@example.com")); } @@ -76,8 +76,8 @@ public async Task InviteAsync_AdminActor_CreatesPasswordlessUnconfirmedUserWithT var result = await invitations.InviteAsync("new@example.com", cancellationToken: Ct); // Assert - Assert.True(result.Succeeded); - Assert.False(string.IsNullOrEmpty(result.Token)); + Assert.True(result.IsSuccess); + Assert.False(string.IsNullOrEmpty(result.Value!.Token)); var created = await FindAsync("new@example.com"); Assert.NotNull(created); @@ -102,7 +102,7 @@ public async Task InviteAsync_WithRoles_AssignsThem() ); // Assert - Assert.True(result.Succeeded); + Assert.True(result.IsSuccess); var created = await users.FindByEmailAsync("new@example.com"); Assert.Contains("Member", await users.GetRolesAsync(created!)); } @@ -119,8 +119,8 @@ public async Task InviteAsync_DuplicateEmail_Fails() var again = await invitations.InviteAsync("dupe@example.com", cancellationToken: Ct); // Assert - Assert.False(again.Succeeded); - Assert.Contains(again.Result.Errors, e => e.Code == "DuplicateEmail"); + Assert.False(again.IsSuccess); + Assert.Contains("dupe@example.com", again.Error); } [Fact] @@ -136,8 +136,8 @@ public async Task ResendAsync_PendingInvitation_IssuesFreshToken() var resend = await invitations.ResendAsync(user!, Ct); // Assert - Assert.True(resend.Succeeded); - Assert.False(string.IsNullOrEmpty(resend.Token)); + Assert.True(resend.IsSuccess); + Assert.False(string.IsNullOrEmpty(resend.Value!.Token)); } [Fact] @@ -158,8 +158,8 @@ public async Task ResendAsync_AlreadyAcceptedAccount_IsRefused() var resend = await invitations.ResendAsync(claimed!, Ct); // Assert - Assert.False(resend.Succeeded); - Assert.Contains(resend.Result.Errors, e => e.Code == "InvitationAlreadyAccepted"); + Assert.False(resend.IsSuccess); + Assert.Contains("already been set up", resend.Error); } #region Harness diff --git a/tests/AndreGoepel.Marten.Identity.IntegrationTests/packages.lock.json b/tests/AndreGoepel.Marten.Identity.IntegrationTests/packages.lock.json index 6090b2b..a58f01a 100644 --- a/tests/AndreGoepel.Marten.Identity.IntegrationTests/packages.lock.json +++ b/tests/AndreGoepel.Marten.Identity.IntegrationTests/packages.lock.json @@ -417,6 +417,7 @@ "andregoepel.marten.identity": { "type": "Project", "dependencies": { + "AndreGoepel.Core": "[1.0.1, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", "AndreGoepel.Marten.Identity.Abstractions": "[1.8.0, )", "Marten": "[9.19.0, )", @@ -437,6 +438,12 @@ "Radzen.Blazor": "[11.1.7, )" } }, + "AndreGoepel.Core": { + "type": "CentralTransitive", + "requested": "[1.0.1, )", + "resolved": "1.0.1", + "contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA==" + }, "AndreGoepel.Design.Blazor": { "type": "CentralTransitive", "requested": "[1.4.2, )", diff --git a/tests/AndreGoepel.Marten.Identity.Tests/packages.lock.json b/tests/AndreGoepel.Marten.Identity.Tests/packages.lock.json index 0a3ba31..15127bb 100644 --- a/tests/AndreGoepel.Marten.Identity.Tests/packages.lock.json +++ b/tests/AndreGoepel.Marten.Identity.Tests/packages.lock.json @@ -631,6 +631,7 @@ "andregoepel.marten.identity": { "type": "Project", "dependencies": { + "AndreGoepel.Core": "[1.0.1, )", "AndreGoepel.Marten.Configuration": "[1.0.1, )", "AndreGoepel.Marten.Identity.Abstractions": "[1.8.0, )", "Marten": "[9.19.0, )", @@ -641,6 +642,12 @@ "andregoepel.marten.identity.abstractions": { "type": "Project" }, + "AndreGoepel.Core": { + "type": "CentralTransitive", + "requested": "[1.0.1, )", + "resolved": "1.0.1", + "contentHash": "jA27iMianyxUdrCtz5b6KxZOYxXlhZqkxDVKD6sed1sDCHF1upfV28CnczJ6kYPcrqJNj9+cDaxMWPlNvDkjcA==" + }, "AndreGoepel.Marten.Configuration": { "type": "CentralTransitive", "requested": "[1.0.1, )",