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.
This commit is contained in:
2026-06-19 22:28:54 +01:00
parent 6199e9f623
commit cb781ecd57
20 changed files with 699 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
// <auto-generated/>
#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
{
/// <summary>The mine property</summary>
public global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder Mine
{
get => new global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder(PathParameters, RequestAdapter);
}
/// <summary>Gets an item from the Marechai.ApiClient.invitationCodes.item collection</summary>
/// <param name="position">Unique identifier of the item</param>
/// <returns>A <see cref="global::Marechai.ApiClient.InvitationCodes.Item.WithCodeItemRequestBuilder"/></returns>

View File

@@ -0,0 +1,92 @@
// <auto-generated/>
#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
{
/// <summary>
/// Builds and executes requests for operations under \invitation-codes\mine
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class MineRequestBuilder : BaseRequestBuilder
{
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public MineRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/invitation-codes/mine", pathParameters)
{
}
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public MineRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/invitation-codes/mine", rawUrl)
{
}
/// <returns>A List&lt;global::Marechai.ApiClient.Models.MyInvitationCodeDto&gt;</returns>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<List<global::Marechai.ApiClient.Models.MyInvitationCodeDto>?> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<List<global::Marechai.ApiClient.Models.MyInvitationCodeDto>> GetAsync(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
var requestInfo = ToGetRequestInformation(requestConfiguration);
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "401", global::Marechai.ApiClient.Models.ProblemDetails.CreateFromDiscriminatorValue },
};
var collectionResult = await RequestAdapter.SendCollectionAsync<global::Marechai.ApiClient.Models.MyInvitationCodeDto>(requestInfo, global::Marechai.ApiClient.Models.MyInvitationCodeDto.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false);
return collectionResult?.AsList();
}
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToGetRequestInformation(Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default)
{
#endif
var requestInfo = new RequestInformation(Method.GET, UrlTemplate, PathParameters);
requestInfo.Configure(requestConfiguration);
requestInfo.Headers.TryAdd("Accept", "application/json");
return requestInfo;
}
/// <summary>
/// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder"/></returns>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
public global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder WithUrl(string rawUrl)
{
return new global::Marechai.ApiClient.InvitationCodes.Mine.MineRequestBuilder(rawUrl, RequestAdapter);
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[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<DefaultQueryParameters>
{
}
}
}
#pragma warning restore CS0618

View File

@@ -0,0 +1,59 @@
// <auto-generated/>
#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
{
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
public IDictionary<string, object> AdditionalData { get; set; }
/// <summary>The count property</summary>
public int? Count { get; set; }
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Models.GrantInvitationCodesRequest"/> and sets the default values.
/// </summary>
public GrantInvitationCodesRequest()
{
AdditionalData = new Dictionary<string, object>();
}
/// <summary>
/// Creates a new instance of the appropriate class based on discriminator value
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.Models.GrantInvitationCodesRequest"/></returns>
/// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
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();
}
/// <summary>
/// The deserialization information for the current model
/// </summary>
/// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
{
return new Dictionary<string, Action<IParseNode>>
{
{ "count", n => { Count = n.GetIntValue(); } },
};
}
/// <summary>
/// Serializes information the current object
/// </summary>
/// <param name="writer">Serialization writer to use to serialize this model</param>
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

View File

@@ -0,0 +1,69 @@
// <auto-generated/>
#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
{
/// <summary>Stores additional data not described in the OpenAPI description found when deserializing. Can be used for serialization as well.</summary>
public IDictionary<string, object> AdditionalData { get; set; }
/// <summary>The code property</summary>
#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
/// <summary>The isUsed property</summary>
public bool? IsUsed { get; set; }
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Models.MyInvitationCodeDto"/> and sets the default values.
/// </summary>
public MyInvitationCodeDto()
{
AdditionalData = new Dictionary<string, object>();
}
/// <summary>
/// Creates a new instance of the appropriate class based on discriminator value
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.Models.MyInvitationCodeDto"/></returns>
/// <param name="parseNode">The parse node to use to read the discriminator value and create the object</param>
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();
}
/// <summary>
/// The deserialization information for the current model
/// </summary>
/// <returns>A IDictionary&lt;string, Action&lt;IParseNode&gt;&gt;</returns>
public virtual IDictionary<string, Action<IParseNode>> GetFieldDeserializers()
{
return new Dictionary<string, Action<IParseNode>>
{
{ "code", n => { Code = n.GetStringValue(); } },
{ "isUsed", n => { IsUsed = n.GetBoolValue(); } },
};
}
/// <summary>
/// Serializes information the current object
/// </summary>
/// <param name="writer">Serialization writer to use to serialize this model</param>
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

View File

@@ -0,0 +1,103 @@
// <auto-generated/>
#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
{
/// <summary>
/// Builds and executes requests for operations under \users\{id}\invitation-codes
/// </summary>
[global::System.CodeDom.Compiler.GeneratedCode("Kiota", "1.0.0")]
public partial class InvitationCodesRequestBuilder : BaseRequestBuilder
{
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="pathParameters">Path parameters for the request</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public InvitationCodesRequestBuilder(Dictionary<string, object> pathParameters, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/users/{id}/invitation-codes", pathParameters)
{
}
/// <summary>
/// Instantiates a new <see cref="global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder"/> and sets the default values.
/// </summary>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
/// <param name="requestAdapter">The request adapter to use to execute the requests.</param>
public InvitationCodesRequestBuilder(string rawUrl, IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}/users/{id}/invitation-codes", rawUrl)
{
}
/// <returns>A List&lt;global::Marechai.ApiClient.Models.InvitationCodeDto&gt;</returns>
/// <param name="body">The request body</param>
/// <param name="cancellationToken">Cancellation token to use when cancelling requests</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 400 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 401 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 403 status code</exception>
/// <exception cref="global::Marechai.ApiClient.Models.ProblemDetails">When receiving a 404 status code</exception>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public async Task<List<global::Marechai.ApiClient.Models.InvitationCodeDto>?> PostAsync(global::Marechai.ApiClient.Models.GrantInvitationCodesRequest body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default, CancellationToken cancellationToken = default)
{
#nullable restore
#else
public async Task<List<global::Marechai.ApiClient.Models.InvitationCodeDto>> PostAsync(global::Marechai.ApiClient.Models.GrantInvitationCodesRequest body, Action<RequestConfiguration<DefaultQueryParameters>> requestConfiguration = default, CancellationToken cancellationToken = default)
{
#endif
if(ReferenceEquals(body, null)) throw new ArgumentNullException(nameof(body));
var requestInfo = ToPostRequestInformation(body, requestConfiguration);
var errorMapping = new Dictionary<string, ParsableFactory<IParsable>>
{
{ "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<global::Marechai.ApiClient.Models.InvitationCodeDto>(requestInfo, global::Marechai.ApiClient.Models.InvitationCodeDto.CreateFromDiscriminatorValue, errorMapping, cancellationToken).ConfigureAwait(false);
return collectionResult?.AsList();
}
/// <returns>A <see cref="RequestInformation"/></returns>
/// <param name="body">The request body</param>
/// <param name="requestConfiguration">Configuration for the request such as headers, query parameters, and middleware options.</param>
#if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_1_OR_GREATER
#nullable enable
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.GrantInvitationCodesRequest body, Action<RequestConfiguration<DefaultQueryParameters>>? requestConfiguration = default)
{
#nullable restore
#else
public RequestInformation ToPostRequestInformation(global::Marechai.ApiClient.Models.GrantInvitationCodesRequest body, Action<RequestConfiguration<DefaultQueryParameters>> 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;
}
/// <summary>
/// Returns a request builder with the provided arbitrary URL. Using this method means any other path or query parameters are ignored.
/// </summary>
/// <returns>A <see cref="global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder"/></returns>
/// <param name="rawUrl">The raw URL to use for the request builder.</param>
public global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder WithUrl(string rawUrl)
{
return new global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder(rawUrl, RequestAdapter);
}
/// <summary>
/// Configuration for the request such as headers, query parameters, and middleware options.
/// </summary>
[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<DefaultQueryParameters>
{
}
}
}
#pragma warning restore CS0618

View File

@@ -1,6 +1,7 @@
// <auto-generated/>
#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
{
/// <summary>The invitationCodes property</summary>
public global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder InvitationCodes
{
get => new global::Marechai.ApiClient.Users.Item.InvitationCodes.InvitationCodesRequestBuilder(PathParameters, RequestAdapter);
}
/// <summary>The password property</summary>
public global::Marechai.ApiClient.Users.Item.Password.PasswordRequestBuilder Password
{

View File

@@ -1,5 +1,5 @@
{
"descriptionHash": "492DB5B6338223B3FFE30DFD6CF55F91D77B99A90DF599C0585C564C775E275C46131803B1E16D2CA08697F68E9D679370194DA08D605355F0B1D02CB46DA001",
"descriptionHash": "A88EB48B54E94488C4D344ADBB16029566E71C3B603604B7A2F91B8705E8B1E5E8218E2011C3875C7649E176D644CF079C91CFDA713572F6C0B897891334950C",
"descriptionLocation": "../../../../../tmp/marechai-openapi.json",
"lockFileVersion": "1.0.0",
"kiotaVersion": "1.31.1",

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace Marechai.Data.Models;
/// <summary>
/// Request to grant N invitation codes to a user (admin only).
/// </summary>
public sealed record GrantInvitationCodesRequest
{
[JsonPropertyName("count")]
[Range(1, 50, ErrorMessage = "Count must be between 1 and 50.")]
public int Count { get; set; }
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json.Serialization;
namespace Marechai.Data.Models;
/// <summary>
/// Server-side projection of an <c>InvitationCode</c> row, returned by the self-service
/// <c>GET /invitation-codes/mine</c> endpoint. Deliberately omits <c>UsedByUserName</c> and
/// <c>UsedOn</c> so the owner does not learn who redeemed their code.
/// </summary>
public sealed record MyInvitationCodeDto
{
[JsonPropertyName("code")]
public string Code { get; set; } = null!;
[JsonPropertyName("isUsed")]
public bool IsUsed { get; set; }
}

View File

@@ -62,6 +62,7 @@ public class AuthController
AccountDeletionConfirmationEmailComposer accountDeletionConfirmationEmailComposer,
UserAccountDeletionService userAccountDeletionService,
AvatarFileCleaner avatarFileCleaner,
InvitationCodeGenerator invitationCodeGenerator,
ILogger<AuthController> logger) : ControllerBase
{
static readonly HashSet<string> _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 &mdash; 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 &mdash; the user is already legitimately confirmed and can log in.
try

View File

@@ -44,6 +44,32 @@ namespace Marechai.Server.Controllers;
public class InvitationCodesController(MarechaiContext context,
InvitationCodeGenerator generator) : ControllerBase
{
/// <summary>
/// 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.
/// </summary>
[HttpGet("mine")]
[Authorize]
[ProducesResponseType(typeof(List<MyInvitationCodeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[Produces("application/json")]
public Task<List<MyInvitationCodeDto>> GetMineAsync()
{
string userId = User.FindFirstValue(ClaimTypes.Sid);
if(string.IsNullOrEmpty(userId)) return Task.FromResult(new List<MyInvitationCodeDto>());
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();
}
/// <summary>
/// Lists invitation codes. Admins can filter to only-unused via <paramref name="unusedOnly" />=true.
/// </summary>

View File

@@ -45,6 +45,7 @@ namespace Marechai.Server.Controllers;
[Authorize(Roles = ApplicationRole.RoleUberAdmin)]
public class UsersController(UserManager<ApplicationUser> userManager,
UserAccountDeletionService userAccountDeletionService,
InvitationCodeGenerator invitationCodeGenerator,
MarechaiContext context) : ControllerBase
{
[HttpGet]
@@ -699,4 +700,39 @@ public class UsersController(UserManager<ApplicationUser> userManager,
return NoContent();
}
[HttpPost("{id}/invitation-codes")]
[Authorize(Roles = "Admin,UberAdmin")]
[ProducesResponseType(typeof(List<InvitationCodeDto>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Produces("application/json")]
[Consumes("application/json")]
public async Task<ActionResult<List<InvitationCodeDto>>> 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<InvitationCode> 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());
}
}

View File

@@ -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();
}
/// <summary>
/// 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.
/// </summary>
public async Task<List<InvitationCode>> GenerateForOwnerAsync(MarechaiContext context, string ownerId, int count)
{
var created = new List<InvitationCode>();
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;
}
}

View File

@@ -33,9 +33,11 @@
@inject NavigationManager Navigation
@inject CollectionService CollectionSvc
@inject SuggestionsService SuggestionsSvc
@inject InvitationCodesService InvitationCodesSvc
@inject ThemeStateService ThemeState
@inject IDialogService DialogService
@inject IStringLocalizer<ProfileService> L
@inject ISnackbar Snackbar
<EntityMetaTags Title="@L["Profile"]" Description="Your Marechai profile." CanonicalPath="/account/profile" NoIndex="true" />
@@ -910,6 +912,63 @@
<MudAlert Severity="Severity.Info">@L["You haven't submitted any suggestions yet."]</MudAlert>
}
</MudTabPanel>
@* ── My Invitation Codes Tab ── *@
<MudTabPanel Text="@L["My Invitation Codes"]" Icon="@Icons.Material.Filled.ConfirmationNumber">
<MudText Typo="Typo.h5" GutterBottom="true">@L["My Invitation Codes"]</MudText>
<MudText Typo="Typo.body2" Class="mb-4">@L["Codes you can share to invite others. Used codes are marked."]</MudText>
@if(_isLoadingInvitationCodes)
{
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Small" />
}
else if(_myInvitationCodes?.Count > 0)
{
<MudTable Items="_myInvitationCodes" Hover="true" Dense="true" Striped="true">
<HeaderContent>
<MudTh>@L["Code"]</MudTh>
<MudTh>@L["Status"]</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
@if(context.IsUsed == true)
{
<MudText Style="text-decoration: line-through; color: var(--mud-palette-text-secondary);">@context.Code</MudText>
}
else
{
<MudText Style="font-family: monospace; font-weight: 500;">@context.Code</MudText>
}
</MudTd>
<MudTd>
@if(context.IsUsed == true)
{
<MudChip T="string" Size="Size.Small" Color="Color.Secondary" Variant="Variant.Filled">@L["Used"]</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Success" Variant="Variant.Filled">@L["Available"]</MudChip>
}
</MudTd>
<MudTd>
@if(context.IsUsed != true)
{
<MudTooltip Text="@L["Copy to clipboard"]">
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
Size="Size.Small"
OnClick="@(() => CopyCodeToClipboard(context.Code))"/>
</MudTooltip>
}
</MudTd>
</RowTemplate>
</MudTable>
}
else
{
<MudAlert Severity="Severity.Info">@L["You have no invitation codes yet. They will appear here once you confirm your email."]</MudAlert>
}
</MudTabPanel>
</MudTabs>
<MudPaper Elevation="0" Class="pa-4 mt-6 rounded-lg" Style="border: 2px solid #e57373; background: rgba(229, 115, 115, 0.05);">

View File

@@ -81,6 +81,10 @@ public partial class Profile
// ── Suggestions state ──
List<SuggestionDto> _mySuggestions = new();
// ── Invitation Codes state ──
bool _isLoadingInvitationCodes;
List<MyInvitationCodeDto> _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,

View File

@@ -0,0 +1,53 @@
@using MudBlazor
@using Marechai.Services
@inject IStringLocalizer<UsersService> L
<MudDialog>
<DialogContent>
<MudText Typo="Typo.body2" Class="mb-4">@((MarkupString)string.Format(L["Grant invitation codes to {0}"], $"<strong>{UserEmail}</strong>"))</MudText>
<MudNumericField @bind-Value="_count"
Label="@L["Number of codes"]"
Variant="Variant.Outlined"
Min="1"
Max="50"
Required="true"
RequiredError="@L["Count is required."]"
HelperText="@L["Between 1 and 50"]"
Class="mb-4"/>
@if(!string.IsNullOrWhiteSpace(_validationError))
{
<MudAlert Severity="Severity.Error" Class="mt-2">@_validationError</MudAlert>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel">@L["Cancel"]</MudButton>
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="Submit">@L["Grant"]</MudButton>
</DialogActions>
</MudDialog>
@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));
}
}

View File

@@ -30,8 +30,10 @@
@using Marechai.ApiClient.Models
@using Marechai.Services
@inject UsersService UsersService
@inject InvitationCodesService InvitationCodesService
@inject IStringLocalizer<UsersService> L
@inject IDialogService DialogService
@inject ISnackbar Snackbar
<MudPaper Class="pa-6 mb-6" Elevation="0" Style="background: linear-gradient(135deg, #7e57c2 0%, #42a5f5 100%); border-radius: 16px;">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="3">
@@ -93,6 +95,8 @@
<MudTooltip Text="@L["Manage Roles"]"><MudIconButton Icon="@Icons.Material.Filled.Security" Size="Size.Small" OnClick="() => OpenManageRolesDialog(context.Item)"/></MudTooltip>
<MudTooltip Text="@L["Give Invitation Codes"]"><MudIconButton Icon="@Icons.Material.Filled.ConfirmationNumber" Size="Size.Small" OnClick="() => OpenGiveInvitationCodesDialog(context.Item)"/></MudTooltip>
@if(context.Item.TwoFactorEnabled == true)
{
<MudTooltip Text="@L["Disable 2FA"]"><MudIconButton Icon="@Icons.Material.Filled.LockOpen" Size="Size.Small" Color="Color.Warning" OnClick="() => ConfirmDisableTwoFactor(context.Item)"/></MudTooltip>

View File

@@ -259,6 +259,39 @@ public partial class Users
await _dataGrid.ReloadServerData();
}
async Task OpenGiveInvitationCodesDialog(UserDto user)
{
DialogParameters<InvitationCodesCountDialog> parameters = new()
{
{
x => x.UserEmail, user.Email
}
};
IDialogReference dialog = await DialogService.ShowAsync<InvitationCodesCountDialog>(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<DeleteConfirmDialog> parameters = new()

View File

@@ -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<List<MyInvitationCodeDto>> GetMyAsync()
{
try
{
List<MyInvitationCodeDto> codes = await client.InvitationCodes.Mine.GetAsync();
return codes ?? [];
}
catch(Exception ex)
{
logger.LogError(ex, "Error loading own invitation codes");
return [];
}
}
}

View File

@@ -422,4 +422,27 @@ public sealed class UsersService(Marechai.ApiClient.Client client, ILogger<Users
return (false, "An error occurred while disabling 2FA.");
}
}
public async Task<(bool Succeeded, string ErrorMessage)> 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.");
}
}
}