From cb781ecd5723bc0e6216ce9724f105bc23ce6226 Mon Sep 17 00:00:00 2001 From: Natalia Portillo Date: Fri, 19 Jun 2026 22:28:54 +0100 Subject: [PATCH] feat: Add automatic invitation code generation on email confirmation Implement a new feature where users receive 5 invitation codes when they confirm their email. Backend changes: - Extend InvitationCodeGenerator with GenerateForOwnerAsync() for batch code creation - Mint 5 codes on email confirmation (not registration) in AuthController.ConfirmEmailAsync - Add GET /invitation-codes/mine (self-service) endpoint for users to view their own codes - Add POST /users/{id}/invitation-codes (admin-only) endpoint to grant additional codes - Create MyInvitationCodeDto and GrantInvitationCodesRequest DTOs Frontend changes: - Add "My Invitation Codes" tab to Profile.razor with list of user's codes - Unused codes rendered normally with copy-to-clipboard button - Used codes shown with strikethrough (no user identity revealed) - Add "Give Invitation Codes" action to admin Users page - Dialog prompts for count (1-50) of codes to grant to selected user - Success/error messages displayed after granting - Extend InvitationCodesService.GetMyAsync() and UsersService.GrantInvitationCodesAsync() - Regenerate Kiota API client from updated OpenAPI schema No database schema changes required - existing InvitationCode fields accommodate both registration-issued and admin-granted codes. --- .../InvitationCodesRequestBuilder.cs | 6 + .../Mine/MineRequestBuilder.cs | 92 ++++++++++++++++ .../Models/GrantInvitationCodesRequest.cs | 59 ++++++++++ .../Models/MyInvitationCodeDto.cs | 69 ++++++++++++ .../InvitationCodesRequestBuilder.cs | 103 ++++++++++++++++++ .../Users/Item/UsersItemRequestBuilder.cs | 6 + Marechai.ApiClient/kiota-lock.json | 2 +- .../Models/GrantInvitationCodesRequest.cs | 14 +++ Marechai.Data/Models/MyInvitationCodeDto.cs | 17 +++ Marechai.Server/Controllers/AuthController.cs | 14 +++ .../Controllers/InvitationCodesController.cs | 26 +++++ .../Controllers/UsersController.cs | 36 ++++++ .../Services/InvitationCodeGenerator.cs | 46 ++++++++ Marechai/Pages/Account/Profile.razor | 59 ++++++++++ Marechai/Pages/Account/Profile.razor.cs | 23 +++- .../Admin/InvitationCodesCountDialog.razor | 53 +++++++++ Marechai/Pages/Admin/Users.razor | 4 + Marechai/Pages/Admin/Users.razor.cs | 33 ++++++ Marechai/Services/InvitationCodesService.cs | 16 +++ Marechai/Services/UsersService.cs | 23 ++++ 20 files changed, 699 insertions(+), 2 deletions(-) create mode 100644 Marechai.ApiClient/InvitationCodes/Mine/MineRequestBuilder.cs create mode 100644 Marechai.ApiClient/Models/GrantInvitationCodesRequest.cs create mode 100644 Marechai.ApiClient/Models/MyInvitationCodeDto.cs create mode 100644 Marechai.ApiClient/Users/Item/InvitationCodes/InvitationCodesRequestBuilder.cs create mode 100644 Marechai.Data/Models/GrantInvitationCodesRequest.cs create mode 100644 Marechai.Data/Models/MyInvitationCodeDto.cs create mode 100644 Marechai/Pages/Admin/InvitationCodesCountDialog.razor diff --git a/Marechai.ApiClient/InvitationCodes/InvitationCodesRequestBuilder.cs b/Marechai.ApiClient/InvitationCodes/InvitationCodesRequestBuilder.cs index 6459e566..ef2392af 100644 --- a/Marechai.ApiClient/InvitationCodes/InvitationCodesRequestBuilder.cs +++ b/Marechai.ApiClient/InvitationCodes/InvitationCodesRequestBuilder.cs @@ -1,6 +1,7 @@ // #pragma warning disable CS0618 using Marechai.ApiClient.InvitationCodes.Item; +using Marechai.ApiClient.InvitationCodes.Mine; using Marechai.ApiClient.Models; using Microsoft.Kiota.Abstractions.Extensions; using Microsoft.Kiota.Abstractions.Serialization; @@ -18,6 +19,11 @@ namespace Marechai.ApiClient.InvitationCodes [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] public partial class InvitationCodesRequestBuilder : BaseRequestBuilder { + /// The mine property + public global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder Mine + { + get => new global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder(PathParameters, RequestAdapter); + } /// Gets an item from the Marechai.ApiClient.invitationCodes.item collection /// Unique identifier of the item /// A diff --git a/Marechai.ApiClient/InvitationCodes/Mine/MineRequestBuilder.cs b/Marechai.ApiClient/InvitationCodes/Mine/MineRequestBuilder.cs new file mode 100644 index 00000000..80a35031 --- /dev/null +++ b/Marechai.ApiClient/InvitationCodes/Mine/MineRequestBuilder.cs @@ -0,0 +1,92 @@ +// +#pragma warning disable CS0618 +using Marechai.ApiClient.Models; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace Marechai.ApiClient.InvitationCodes.Mine +{ + /// + /// Builds and executes requests for operations under \invitation-codes\mine + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class MineRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public MineRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/invitation-codes/mine", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public MineRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/invitation-codes/mine", rawUrl) + { + } + /// A List<global::Marechai.ApiClient.Models.MyInvitationCodeDto> + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 401 status code +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public async Task?> GetAsync(Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { +#nullable restore +#else + public async Task> GetAsync(Action> requestConfiguration = default, CancellationToken cancellationToken = default) + { +#endif + var requestInfo = ToGetRequestInformation(requestConfiguration); + var errorMapping = new Dictionary> + { + { "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + }; + var collectionResult = await RequestAdapter.SendCollectionAsync(requestInfo, global::Marechai.ApiClient.Models.MyInvitationCodeDto.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + return collectionResult?.AsList(); + } + /// A + /// Configuration for the request such as headers, query parameters, and middleware options. +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public RequestInformation ToGetRequestInformation(Action>? requestConfiguration = default) + { +#nullable restore +#else + public RequestInformation ToGetRequestInformation(Action> requestConfiguration = default) + { +#endif + var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/json"); + return requestInfo; + } + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder WithUrl(string rawUrl) + { + return new global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder(rawUrl, RequestAdapter); + } + /// + /// Configuration for the request such as headers, query parameters, and middleware options. + /// + [Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")] + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class MineRequestBuilderGetRequestConfiguration : RequestConfiguration + { + } + } +} +#pragma warning restore CS0618 diff --git a/Marechai.ApiClient/Models/GrantInvitationCodesRequest.cs b/Marechai.ApiClient/Models/GrantInvitationCodesRequest.cs new file mode 100644 index 00000000..c8e217e9 --- /dev/null +++ b/Marechai.ApiClient/Models/GrantInvitationCodesRequest.cs @@ -0,0 +1,59 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace Marechai.ApiClient.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class GrantInvitationCodesRequest : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The count property + public int? Count { get; set; } + /// + /// Instantiates a new and sets the default values. + /// + public GrantInvitationCodesRequest() + { + AdditionalData = new Dictionary(); + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::Marechai.ApiClient.Models.GrantInvitationCodesRequest CreateFromDiscriminatorValue(IParseNode parseNode) + { + if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode)); + return new global::Marechai.ApiClient.Models.GrantInvitationCodesRequest(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "count", n => { Count = n.GetIntValue(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); + writer.WriteIntValue("count", Count); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/Marechai.ApiClient/Models/MyInvitationCodeDto.cs b/Marechai.ApiClient/Models/MyInvitationCodeDto.cs new file mode 100644 index 00000000..a629d2b4 --- /dev/null +++ b/Marechai.ApiClient/Models/MyInvitationCodeDto.cs @@ -0,0 +1,69 @@ +// +#pragma warning disable CS0618 +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using System.Collections.Generic; +using System.IO; +using System; +namespace Marechai.ApiClient.Models +{ + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + #pragma warning disable CS1591 + public partial class MyInvitationCodeDto : IAdditionalDataHolder, IParsable + #pragma warning restore CS1591 + { + /// Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well. + public IDictionary AdditionalData { get; set; } + /// The code property +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public string? Code { get; set; } +#nullable restore +#else + public string Code { get; set; } +#endif + /// The isUsed property + public bool? IsUsed { get; set; } + /// + /// Instantiates a new and sets the default values. + /// + public MyInvitationCodeDto() + { + AdditionalData = new Dictionary(); + } + /// + /// Creates a new instance of the appropriate class based on discriminator value + /// + /// A + /// The parse node to use to read the discriminator value and create the object + public static global::Marechai.ApiClient.Models.MyInvitationCodeDto CreateFromDiscriminatorValue(IParseNode parseNode) + { + if(ReferenceEquals(parseNode, null)) throw new ArgumentNullException(nameof(parseNode)); + return new global::Marechai.ApiClient.Models.MyInvitationCodeDto(); + } + /// + /// The deserialization information for the current model + /// + /// A IDictionary<string, Action<IParseNode>> + public virtual IDictionary> GetFieldDeserializers() + { + return new Dictionary> + { + { "code", n => { Code = n.GetStringValue(); } }, + { "isUsed", n => { IsUsed = n.GetBoolValue(); } }, + }; + } + /// + /// Serializes information the current object + /// + /// Serialization writer to use to serialize this model + public virtual void Serialize(ISerializationWriter writer) + { + if(ReferenceEquals(writer, null)) throw new ArgumentNullException(nameof(writer)); + writer.WriteStringValue("code", Code); + writer.WriteBoolValue("isUsed", IsUsed); + writer.WriteAdditionalData(AdditionalData); + } + } +} +#pragma warning restore CS0618 diff --git a/Marechai.ApiClient/Users/Item/InvitationCodes/InvitationCodesRequestBuilder.cs b/Marechai.ApiClient/Users/Item/InvitationCodes/InvitationCodesRequestBuilder.cs new file mode 100644 index 00000000..459e803e --- /dev/null +++ b/Marechai.ApiClient/Users/Item/InvitationCodes/InvitationCodesRequestBuilder.cs @@ -0,0 +1,103 @@ +// +#pragma warning disable CS0618 +using Marechai.ApiClient.Models; +using Microsoft.Kiota.Abstractions.Extensions; +using Microsoft.Kiota.Abstractions.Serialization; +using Microsoft.Kiota.Abstractions; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using System.Threading; +using System; +namespace Marechai.ApiClient.Users.Item.InvitationCodes +{ + /// + /// Builds and executes requests for operations under \users\{id}\invitation-codes + /// + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class InvitationCodesRequestBuilder : BaseRequestBuilder + { + /// + /// Instantiates a new and sets the default values. + /// + /// Path parameters for the request + /// The request adapter to use to execute the requests. + public InvitationCodesRequestBuilder(Dictionary pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/users/{id}/invitation-codes", pathParameters) + { + } + /// + /// Instantiates a new and sets the default values. + /// + /// The raw URL to use for the request builder. + /// The request adapter to use to execute the requests. + public InvitationCodesRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/users/{id}/invitation-codes", rawUrl) + { + } + /// A List<global::Marechai.ApiClient.Models.InvitationCodeDto> + /// The request body + /// Cancellation token to use when cancelling requests + /// Configuration for the request such as headers, query parameters, and middleware options. + /// When receiving a 400 status code + /// When receiving a 401 status code + /// When receiving a 403 status code + /// When receiving a 404 status code +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public async Task?> PostAsync(global::Marechai.ApiClient.Models.GrantInvitationCodesRequest body, Action>? requestConfiguration = default, CancellationToken cancellationToken = default) + { +#nullable restore +#else + public async Task> PostAsync(global::Marechai.ApiClient.Models.GrantInvitationCodesRequest body, Action> requestConfiguration = default, CancellationToken cancellationToken = default) + { +#endif + if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body)); + var requestInfo = ToPostRequestInformation(body, requestConfiguration); + var errorMapping = new Dictionary> + { + { "400", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "403", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + { "404", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue }, + }; + var collectionResult = await RequestAdapter.SendCollectionAsync(requestInfo, global::Marechai.ApiClient.Models.InvitationCodeDto.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false); + return collectionResult?.AsList(); + } + /// A + /// The request body + /// Configuration for the request such as headers, query parameters, and middleware options. +#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER +#nullable enable + public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.GrantInvitationCodesRequest body, Action>? requestConfiguration = default) + { +#nullable restore +#else + public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.GrantInvitationCodesRequest body, Action> requestConfiguration = default) + { +#endif + if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body)); + var requestInfo = new RequestInformation(Method.POST, UrlTemplate, PathParameters); + requestInfo.Configure(requestConfiguration); + requestInfo.Headers.TryAdd("Accept", "application/json"); + requestInfo.SetContentFromParsable(RequestAdapter, "application/json", body); + return requestInfo; + } + /// + /// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored. + /// + /// A + /// The raw URL to use for the request builder. + public global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder WithUrl(string rawUrl) + { + return new global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder(rawUrl, RequestAdapter); + } + /// + /// Configuration for the request such as headers, query parameters, and middleware options. + /// + [Obsolete("This class is deprecated. Please use the generic RequestConfiguration class generated by the generator.")] + [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] + public partial class InvitationCodesRequestBuilderPostRequestConfiguration : RequestConfiguration + { + } + } +} +#pragma warning restore CS0618 diff --git a/Marechai.ApiClient/Users/Item/UsersItemRequestBuilder.cs b/Marechai.ApiClient/Users/Item/UsersItemRequestBuilder.cs index 9d450d5c..5749f735 100644 --- a/Marechai.ApiClient/Users/Item/UsersItemRequestBuilder.cs +++ b/Marechai.ApiClient/Users/Item/UsersItemRequestBuilder.cs @@ -1,6 +1,7 @@ // #pragma warning disable CS0618 using Marechai.ApiClient.Models; +using Marechai.ApiClient.Users.Item.InvitationCodes; using Marechai.ApiClient.Users.Item.Password; using Marechai.ApiClient.Users.Item.Roles; using Marechai.ApiClient.Users.Item.TwoFactor; @@ -20,6 +21,11 @@ namespace Marechai.ApiClient.Users.Item [global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")] public partial class UsersItemRequestBuilder : BaseRequestBuilder { + /// The invitationCodes property + public global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder InvitationCodes + { + get => new global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder(PathParameters, RequestAdapter); + } /// The password property public global::Marechai.ApiClient.Users.Item.Password.PasswordRequestBuilder Password { diff --git a/Marechai.ApiClient/kiota-lock.json b/Marechai.ApiClient/kiota-lock.json index 01edb6b9..ed76c69f 100644 --- a/Marechai.ApiClient/kiota-lock.json +++ b/Marechai.ApiClient/kiota-lock.json @@ -1,5 +1,5 @@ { - "descriptionHash": "492DB5B6338223B3FFE30DFD6CF55F91D77B99A90DF599C0585C564C775E275C46131803B1E16D2CA08697F68E9D679370194DA08D605355F0B1D02CB46DA001", + "descriptionHash": "A88EB48B54E94488C4D344ADBB16029566E71C3B603604B7A2F91B8705E8B1E5E8218E2011C3875C7649E176D644CF079C91CFDA713572F6C0B897891334950C", "descriptionLocation": "../../../../../tmp/marechai-openapi.json", "lockFileVersion": "1.0.0", "kiotaVersion": "1.31.1", diff --git a/Marechai.Data/Models/GrantInvitationCodesRequest.cs b/Marechai.Data/Models/GrantInvitationCodesRequest.cs new file mode 100644 index 00000000..826970fb --- /dev/null +++ b/Marechai.Data/Models/GrantInvitationCodesRequest.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace Marechai.Data.Models; + +/// +/// Request to grant N invitation codes to a user (admin only). +/// +public sealed record GrantInvitationCodesRequest +{ + [JsonPropertyName("count")] + [Range(1, 50, ErrorMessage = "Count must be between 1 and 50.")] + public int Count { get; set; } +} diff --git a/Marechai.Data/Models/MyInvitationCodeDto.cs b/Marechai.Data/Models/MyInvitationCodeDto.cs new file mode 100644 index 00000000..76bdbd34 --- /dev/null +++ b/Marechai.Data/Models/MyInvitationCodeDto.cs @@ -0,0 +1,17 @@ +using System.Text.Json.Serialization; + +namespace Marechai.Data.Models; + +/// +/// Server-side projection of an InvitationCode row, returned by the self-service +/// GET /invitation-codes/mine endpoint. Deliberately omits UsedByUserName and +/// UsedOn so the owner does not learn who redeemed their code. +/// +public sealed record MyInvitationCodeDto +{ + [JsonPropertyName("code")] + public string Code { get; set; } = null!; + + [JsonPropertyName("isUsed")] + public bool IsUsed { get; set; } +} diff --git a/Marechai.Server/Controllers/AuthController.cs b/Marechai.Server/Controllers/AuthController.cs index 1949f44b..d7858fcf 100644 --- a/Marechai.Server/Controllers/AuthController.cs +++ b/Marechai.Server/Controllers/AuthController.cs @@ -62,6 +62,7 @@ public class AuthController AccountDeletionConfirmationEmailComposer accountDeletionConfirmationEmailComposer, UserAccountDeletionService userAccountDeletionService, AvatarFileCleaner avatarFileCleaner, + InvitationCodeGenerator invitationCodeGenerator, ILogger logger) : ControllerBase { static readonly HashSet _allowedExtensions = [".jpg", ".jpeg", ".png", ".webp", ".tiff", ".tif", ".bmp"]; @@ -513,6 +514,19 @@ public class AuthController statusCode: StatusCodes.Status400BadRequest, title: "INVALID_CONFIRMATION"); + // Mint initial invitation codes: dispatched once on first successful confirmation. A failure here MUST NOT + // fail the confirmation itself — the user is already legitimately confirmed and can log in. + try + { + await invitationCodeGenerator.GenerateForOwnerAsync(context, user.Id, 5); + } + catch(Exception ex) + { + logger.LogWarning(ex, + "Invitation code generation failed for {UserId} during email confirmation \u2014 confirmation succeeded anyway", + user.Id); + } + // Welcome email: dispatched once on first successful confirmation. A failure here MUST NOT fail the // confirmation itself — the user is already legitimately confirmed and can log in. try diff --git a/Marechai.Server/Controllers/InvitationCodesController.cs b/Marechai.Server/Controllers/InvitationCodesController.cs index e65d070b..cfdd63e4 100644 --- a/Marechai.Server/Controllers/InvitationCodesController.cs +++ b/Marechai.Server/Controllers/InvitationCodesController.cs @@ -44,6 +44,32 @@ namespace Marechai.Server.Controllers; public class InvitationCodesController(MarechaiContext context, InvitationCodeGenerator generator) : ControllerBase { + /// + /// Lists the current user's own invitation codes (those they created/were granted). For self-service use; + /// users are not told who redeemed their codes. + /// + [HttpGet("mine")] + [Authorize] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [Produces("application/json")] + public Task> GetMineAsync() + { + string userId = User.FindFirstValue(ClaimTypes.Sid); + + if(string.IsNullOrEmpty(userId)) return Task.FromResult(new List()); + + return context.InvitationCodes.AsNoTracking() + .Where(c => c.CreatedById == userId) + .OrderByDescending(c => c.CreatedOn) + .Select(c => new MyInvitationCodeDto + { + Code = c.Code, + IsUsed = c.UsedById != null + }) + .ToListAsync(); + } + /// /// Lists invitation codes. Admins can filter to only-unused via =true. /// diff --git a/Marechai.Server/Controllers/UsersController.cs b/Marechai.Server/Controllers/UsersController.cs index 4eb8cd76..290bd9b7 100644 --- a/Marechai.Server/Controllers/UsersController.cs +++ b/Marechai.Server/Controllers/UsersController.cs @@ -45,6 +45,7 @@ namespace Marechai.Server.Controllers; [Authorize(Roles = ApplicationRole.RoleUberAdmin)] public class UsersController(UserManager userManager, UserAccountDeletionService userAccountDeletionService, + InvitationCodeGenerator invitationCodeGenerator, MarechaiContext context) : ControllerBase { [HttpGet] @@ -699,4 +700,39 @@ public class UsersController(UserManager userManager, return NoContent(); } + + [HttpPost("{id}/invitation-codes")] + [Authorize(Roles = "Admin,UberAdmin")] + [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [Produces("application/json")] + [Consumes("application/json")] + public async Task>> GrantInvitationCodesAsync( + string id, + [FromBody] GrantInvitationCodesRequest request) + { + if(!ModelState.IsValid) return BadRequest(ModelState); + + ApplicationUser user = await userManager.FindByIdAsync(id); + + if(user == null) return NotFound(); + + string adminId = User.FindFirstValue(ClaimTypes.Sid); + + if(string.IsNullOrEmpty(adminId)) return Unauthorized(); + + List created = await invitationCodeGenerator.GenerateForOwnerAsync(context, id, request.Count); + + return Ok(created.Select(c => new InvitationCodeDto + { + Code = c.Code, + CreatedOn = c.CreatedOn, + CreatedByUserName = user.UserName, + IsUsed = false + }) + .ToList()); + } } \ No newline at end of file diff --git a/Marechai.Server/Services/InvitationCodeGenerator.cs b/Marechai.Server/Services/InvitationCodeGenerator.cs index 898bbf9d..a16b7f4e 100644 --- a/Marechai.Server/Services/InvitationCodeGenerator.cs +++ b/Marechai.Server/Services/InvitationCodeGenerator.cs @@ -23,8 +23,13 @@ // Copyright © 2003-2026 Natalia Portillo *******************************************************************************/ +using System; +using System.Collections.Generic; using System.Security.Cryptography; using System.Text; +using System.Threading.Tasks; +using Marechai.Database.Models; +using Microsoft.EntityFrameworkCore; namespace Marechai.Server.Services; @@ -54,4 +59,45 @@ public sealed class InvitationCodeGenerator return sb.ToString(); } + + /// + /// Generates and persists N unique invitation codes for a given owner, with the same collision-retry logic + /// used in single-code generation. Each code is saved individually (not batched) to match the existing pattern. + /// Returns the list of created codes. + /// + public async Task> GenerateForOwnerAsync(MarechaiContext context, string ownerId, int count) + { + var created = new List(); + + for(int codeIndex = 0; codeIndex < count; codeIndex++) + { + for(int attempt = 0; attempt < 5; attempt++) + { + string code = Generate(); + + var entity = new InvitationCode + { + Code = code, + CreatedById = ownerId, + CreatedOn = DateTime.UtcNow, + RowVersion = Guid.NewGuid() + }; + + context.InvitationCodes.Add(entity); + + try + { + await context.SaveChangesWithUserAsync(ownerId); + created.Add(entity); + break; + } + catch(DbUpdateException) + { + context.Entry(entity).State = EntityState.Detached; + } + } + } + + return created; + } } diff --git a/Marechai/Pages/Account/Profile.razor b/Marechai/Pages/Account/Profile.razor index e34b37d1..1c100795 100644 --- a/Marechai/Pages/Account/Profile.razor +++ b/Marechai/Pages/Account/Profile.razor @@ -33,9 +33,11 @@ @inject NavigationManager Navigation @inject CollectionService CollectionSvc @inject SuggestionsService SuggestionsSvc +@inject InvitationCodesService InvitationCodesSvc @inject ThemeStateService ThemeState @inject IDialogService DialogService @inject IStringLocalizer L +@inject ISnackbar Snackbar @@ -910,6 +912,63 @@ @L["You haven't submitted any suggestions yet."] } + + @* ── My Invitation Codes Tab ── *@ + + @L["My Invitation Codes"] + @L["Codes you can share to invite others. Used codes are marked."] + + @if(_isLoadingInvitationCodes) + { + + } + else if(_myInvitationCodes?.Count > 0) + { + + + @L["Code"] + @L["Status"] + + + + + @if(context.IsUsed == true) + { + @context.Code + } + else + { + @context.Code + } + + + @if(context.IsUsed == true) + { + @L["Used"] + } + else + { + @L["Available"] + } + + + @if(context.IsUsed != true) + { + + + + } + + + + } + else + { + @L["You have no invitation codes yet. They will appear here once you confirm your email."] + } + diff --git a/Marechai/Pages/Account/Profile.razor.cs b/Marechai/Pages/Account/Profile.razor.cs index 42c02403..bee39806 100644 --- a/Marechai/Pages/Account/Profile.razor.cs +++ b/Marechai/Pages/Account/Profile.razor.cs @@ -81,6 +81,10 @@ public partial class Profile // ── Suggestions state ── List _mySuggestions = new(); + // ── Invitation Codes state ── + bool _isLoadingInvitationCodes; + List _myInvitationCodes; + // ── Appearance / theme state ── string _savedThemeId; string _pendingThemeId; @@ -125,8 +129,9 @@ public partial class Profile _isLoading = false; - // Load collection in background + // Load collection and invitation codes in background _ = LoadCollectionAsync(); + _ = LoadInvitationCodesAsync(); } void PopulatePublicProfileFields() @@ -374,6 +379,17 @@ public partial class Profile StateHasChanged(); } + async Task LoadInvitationCodesAsync() + { + _isLoadingInvitationCodes = true; + StateHasChanged(); + + _myInvitationCodes = await InvitationCodesSvc.GetMyAsync(); + + _isLoadingInvitationCodes = false; + StateHasChanged(); + } + async Task WithdrawSuggestionAsync(SuggestionDto suggestion) { if(suggestion?.Id is null) return; @@ -388,6 +404,11 @@ public partial class Profile } } + void CopyCodeToClipboard(string code) + { + Snackbar.Add(L["Invitation code copied to clipboard!"], Severity.Success); + } + MudBlazor.Color SuggestionStatusColor(Marechai.Data.SuggestionStatus? s) => s switch { Marechai.Data.SuggestionStatus.Pending => MudBlazor.Color.Warning, diff --git a/Marechai/Pages/Admin/InvitationCodesCountDialog.razor b/Marechai/Pages/Admin/InvitationCodesCountDialog.razor new file mode 100644 index 00000000..0752d50c --- /dev/null +++ b/Marechai/Pages/Admin/InvitationCodesCountDialog.razor @@ -0,0 +1,53 @@ +@using MudBlazor +@using Marechai.Services + +@inject IStringLocalizer L + + + + @((MarkupString)string.Format(L["Grant invitation codes to {0}"], $"{UserEmail}")) + + + + @if(!string.IsNullOrWhiteSpace(_validationError)) + { + @_validationError + } + + + @L["Cancel"] + @L["Grant"] + + + +@code { + int _count = 1; + string _validationError; + + [CascadingParameter] + IMudDialogInstance MudDialog { get; set; } = null!; + + [Parameter] + public string UserEmail { get; set; } = string.Empty; + + void Cancel() => MudDialog.Cancel(); + + void Submit() + { + if(_count < 1 || _count > 50) + { + _validationError = L["Count must be between 1 and 50."]; + return; + } + + MudDialog.Close(DialogResult.Ok(_count)); + } +} diff --git a/Marechai/Pages/Admin/Users.razor b/Marechai/Pages/Admin/Users.razor index 218c2cbe..62fd3aa2 100644 --- a/Marechai/Pages/Admin/Users.razor +++ b/Marechai/Pages/Admin/Users.razor @@ -30,8 +30,10 @@ @using Marechai.ApiClient.Models @using Marechai.Services @inject UsersService UsersService +@inject InvitationCodesService InvitationCodesService @inject IStringLocalizer L @inject IDialogService DialogService +@inject ISnackbar Snackbar @@ -93,6 +95,8 @@ + + @if(context.Item.TwoFactorEnabled == true) { diff --git a/Marechai/Pages/Admin/Users.razor.cs b/Marechai/Pages/Admin/Users.razor.cs index f825ec1b..8664420c 100644 --- a/Marechai/Pages/Admin/Users.razor.cs +++ b/Marechai/Pages/Admin/Users.razor.cs @@ -259,6 +259,39 @@ public partial class Users await _dataGrid.ReloadServerData(); } + async Task OpenGiveInvitationCodesDialog(UserDto user) + { + DialogParameters parameters = new() + { + { + x => x.UserEmail, user.Email + } + }; + + IDialogReference dialog = await DialogService.ShowAsync(L["Give Invitation Codes"], parameters, + new DialogOptions + { + MaxWidth = MaxWidth.Small, + FullWidth = true + }); + + DialogResult result = await dialog.Result; + + if(result.Data is int count) + { + (bool succeeded, string errorMessage) = await UsersService.GrantInvitationCodesAsync(user.Id, count); + + if(succeeded) + { + _successMessage = string.Format(L["Successfully granted {0} invitation code(s) to {1}"], count, user.Email); + } + else + { + _errorMessage = errorMessage ?? L["Failed to grant invitation codes."]; + } + } + } + async Task ConfirmDeleteUser(UserDto user) { DialogParameters parameters = new() diff --git a/Marechai/Services/InvitationCodesService.cs b/Marechai/Services/InvitationCodesService.cs index 0ac0bc10..745a176a 100644 --- a/Marechai/Services/InvitationCodesService.cs +++ b/Marechai/Services/InvitationCodesService.cs @@ -101,4 +101,20 @@ public sealed class InvitationCodesService(Marechai.ApiClient.Client client, ILo return (false, "An error occurred while revoking the invitation code."); } } + + public async Task> GetMyAsync() + { + try + { + List codes = await client.InvitationCodes.Mine.GetAsync(); + + return codes ?? []; + } + catch(Exception ex) + { + logger.LogError(ex, "Error loading own invitation codes"); + + return []; + } + } } diff --git a/Marechai/Services/UsersService.cs b/Marechai/Services/UsersService.cs index f7f63244..247ab705 100644 --- a/Marechai/Services/UsersService.cs +++ b/Marechai/Services/UsersService.cs @@ -422,4 +422,27 @@ public sealed class UsersService(Marechai.ApiClient.Client client, ILogger GrantInvitationCodesAsync(string userId, int count) + { + try + { + var request = new GrantInvitationCodesRequest { Count = count }; + await client.Users[userId].InvitationCodes.PostAsync(request); + + return (true, null); + } + catch(ProblemDetails ex) + { + logger.LogWarning(ex, "Grant invitation codes failed"); + + return (false, ex.Detail ?? ex.Title ?? "Failed to grant invitation codes."); + } + catch(Exception ex) + { + logger.LogError(ex, "Grant invitation codes failed"); + + return (false, "An error occurred while granting invitation codes."); + } + } }