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, )",