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.");
+ }
+ }
}