mirror of
https://github.com/claunia/marechai.git
synced 2026-07-08 17:57:08 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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<global::Marechai.ApiClient.Models.MyInvitationCodeDto></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
|
||||
59
Marechai.ApiClient/Models/GrantInvitationCodesRequest.cs
Normal file
59
Marechai.ApiClient/Models/GrantInvitationCodesRequest.cs
Normal 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<string, Action<IParseNode>></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
|
||||
69
Marechai.ApiClient/Models/MyInvitationCodeDto.cs
Normal file
69
Marechai.ApiClient/Models/MyInvitationCodeDto.cs
Normal 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<string, Action<IParseNode>></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
|
||||
@@ -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<global::Marechai.ApiClient.Models.InvitationCodeDto></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
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"descriptionHash": "492DB5B6338223B3FFE30DFD6CF55F91D77B99A90DF599C0585C564C775E275C46131803B1E16D2CA08697F68E9D679370194DA08D605355F0B1D02CB46DA001",
|
||||
"descriptionHash": "A88EB48B54E94488C4D344ADBB16029566E71C3B603604B7A2F91B8705E8B1E5E8218E2011C3875C7649E176D644CF079C91CFDA713572F6C0B897891334950C",
|
||||
"descriptionLocation": "../../../../../tmp/marechai-openapi.json",
|
||||
"lockFileVersion": "1.0.0",
|
||||
"kiotaVersion": "1.31.1",
|
||||
|
||||
14
Marechai.Data/Models/GrantInvitationCodesRequest.cs
Normal file
14
Marechai.Data/Models/GrantInvitationCodesRequest.cs
Normal 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; }
|
||||
}
|
||||
17
Marechai.Data/Models/MyInvitationCodeDto.cs
Normal file
17
Marechai.Data/Models/MyInvitationCodeDto.cs
Normal 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; }
|
||||
}
|
||||
@@ -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 — 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);">
|
||||
|
||||
@@ -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,
|
||||
|
||||
53
Marechai/Pages/Admin/InvitationCodesCountDialog.razor
Normal file
53
Marechai/Pages/Admin/InvitationCodesCountDialog.razor
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user