diff --git a/src/BuildingBlocks/Blazor.UI/Blazor.UI.csproj b/src/BuildingBlocks/Blazor.UI/Blazor.UI.csproj index cccdeac8f8..b642d5b445 100644 --- a/src/BuildingBlocks/Blazor.UI/Blazor.UI.csproj +++ b/src/BuildingBlocks/Blazor.UI/Blazor.UI.csproj @@ -5,7 +5,11 @@ FSH.Framework.Blazor.UI FullStackHero.Framework.Blazor.UI - $(NoWarn);MUD0002 + $(NoWarn);MUD0002;NU1507 + + + + false @@ -13,11 +17,15 @@ - - - - - - + + + + + + + + + + diff --git a/src/BuildingBlocks/Blazor.UI/Components/Base/FshComponentBase.cs b/src/BuildingBlocks/Blazor.UI/Components/Base/FshComponentBase.cs index 27538b09c2..5b29ed5904 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/Base/FshComponentBase.cs +++ b/src/BuildingBlocks/Blazor.UI/Components/Base/FshComponentBase.cs @@ -1,4 +1,6 @@ -using Microsoft.AspNetCore.Components; +using FSH.Framework.Shared.Localization; +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; namespace FSH.Framework.Blazor.UI.Components.Base; @@ -7,4 +9,5 @@ public abstract class FshComponentBase : ComponentBase [Inject] protected ISnackbar Snackbar { get; set; } = default!; [Inject] protected IDialogService DialogService { get; set; } = default!; [Inject] protected NavigationManager Navigation { get; set; } = default!; + [Inject] protected IStringLocalizer L { get; set; } = default!; } \ No newline at end of file diff --git a/src/BuildingBlocks/Blazor.UI/Components/Common/FshAccountMenu.razor b/src/BuildingBlocks/Blazor.UI/Components/Common/FshAccountMenu.razor new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshDialogService.cs b/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshDialogService.cs index 39afcbfd88..040677350b 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshDialogService.cs +++ b/src/BuildingBlocks/Blazor.UI/Components/Dialogs/FshDialogService.cs @@ -1,5 +1,6 @@ -using Microsoft.AspNetCore.Components; -using MudBlazor; +using FSH.Framework.Shared.Localization; +using Microsoft.Extensions.Localization; +using System.Globalization; namespace FSH.Framework.Blazor.UI.Components.Dialogs; @@ -45,25 +46,40 @@ public static async Task ShowConfirmAsync( public static Task ShowDeleteConfirmAsync( this IDialogService dialogService, - string itemName = "this item") + string itemName, + IStringLocalizer? localizer = null) { + string title = localizer?["DeleteConfirmation"] ?? "Delete Confirmation"; + string message = localizer != null + ? string.Format(CultureInfo.InvariantCulture, localizer["ConfirmDelete"], itemName) + : $"Are you sure you want to delete {itemName}? This action cannot be undone."; + string confirmText = localizer?["Delete"] ?? "Delete"; + string cancelText = localizer?["Cancel"] ?? "Cancel"; + return dialogService.ShowConfirmAsync( - title: "Delete Confirmation", - message: $"Are you sure you want to delete {itemName}? This action cannot be undone.", - confirmText: "Delete", - cancelText: "Cancel", + title: title, + message: message, + confirmText: confirmText, + cancelText: cancelText, confirmColor: Color.Error, icon: Icons.Material.Outlined.DeleteForever, iconColor: Color.Error); } - public static Task ShowSignOutConfirmAsync(this IDialogService dialogService) + public static Task ShowSignOutConfirmAsync( + this IDialogService dialogService, + IStringLocalizer? localizer = null) { + string title = localizer?["SignOut"] ?? "Sign Out"; + string message = localizer?["ConfirmSignOut"] ?? "Are you sure you want to sign out of your account?"; + string confirmText = localizer?["SignOut"] ?? "Sign Out"; + string cancelText = localizer?["Cancel"] ?? "Cancel"; + return dialogService.ShowConfirmAsync( - title: "Sign Out", - message: "Are you sure you want to sign out of your account?", - confirmText: "Sign Out", - cancelText: "Cancel", + title: title, + message: message, + confirmText: confirmText, + cancelText: cancelText, confirmColor: Color.Error, icon: Icons.Material.Outlined.Logout, iconColor: Color.Warning); diff --git a/src/BuildingBlocks/Blazor.UI/Components/Feedback/Snackbar/FshSnackbar.razor.cs b/src/BuildingBlocks/Blazor.UI/Components/Feedback/Snackbar/FshSnackbar.razor.cs index b6c2cf3833..3042d6089a 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/Feedback/Snackbar/FshSnackbar.razor.cs +++ b/src/BuildingBlocks/Blazor.UI/Components/Feedback/Snackbar/FshSnackbar.razor.cs @@ -5,15 +5,8 @@ namespace FSH.Framework.Blazor.UI.Components.Feedback.Snackbar; /// /// Convenience wrapper for snackbar calls with consistent styling. /// -public sealed class FshSnackbar +public sealed class FshSnackbar(ISnackbar snackbar) { - private readonly ISnackbar _snackbar; - - public FshSnackbar(ISnackbar snackbar) - { - _snackbar = snackbar; - } - public void Success(string message) => Add(message, Severity.Success); public void Info(string message) => Add(message, Severity.Info); public void Warning(string message) => Add(message, Severity.Warning); @@ -21,6 +14,6 @@ public FshSnackbar(ISnackbar snackbar) private void Add(string message, Severity severity) { - _snackbar.Add(message, severity); + snackbar.Add(message, severity); } } \ No newline at end of file diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshBrandAssetsPicker.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshBrandAssetsPicker.razor index 0dad490cf9..8a5647c649 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshBrandAssetsPicker.razor +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshBrandAssetsPicker.razor @@ -1,13 +1,16 @@ @using FSH.Framework.Blazor.UI.Theme +@using FSH.Framework.Shared.Localization +@using Microsoft.Extensions.Localization +@inject IStringLocalizer L - Brand Assets + @L["BrandAssets"] @* Light Mode Logo *@ - Logo (Light Mode) + @L["LogoLightMode"] @if (!string.IsNullOrEmpty(BrandAssets.LogoUrl)) {
@@ -21,7 +24,7 @@ Size="Size.Small" StartIcon="@Icons.Material.Filled.Delete" OnClick="@(() => ClearLogo())"> - Remove + @L["Remove"]
} @@ -31,15 +34,16 @@ Accept=".png,.jpg,.jpeg,.svg,.webp" FilesChanged="@OnLogoUpload" MaximumFileCount="1"> - + + Elevation="0" + @onclick="@(async (e) => await upload.OpenFilePickerAsync())"> - Click to upload logo + @L["ClickToUpload"] PNG, JPG, SVG, WebP - + }
@@ -48,7 +52,7 @@ @* Dark Mode Logo *@ - Logo (Dark Mode) + @L["LogoDarkMode"] @if (!string.IsNullOrEmpty(BrandAssets.LogoDarkUrl)) {
@@ -62,7 +66,7 @@ Size="Size.Small" StartIcon="@Icons.Material.Filled.Delete" OnClick="@(() => ClearLogoDark())"> - Remove + @L["Remove"]
} @@ -72,15 +76,16 @@ Accept=".png,.jpg,.jpeg,.svg,.webp" FilesChanged="@OnLogoDarkUpload" MaximumFileCount="1"> - + + Elevation="0" + @onclick="@(async (e) => await upload.OpenFilePickerAsync())"> - Click to upload logo + @L["ClickToUpload"] PNG, JPG, SVG, WebP - + }
@@ -89,7 +94,7 @@ @* Favicon *@ - Favicon + @L["Favicon"] @if (!string.IsNullOrEmpty(BrandAssets.FaviconUrl)) {
@@ -104,7 +109,7 @@ Size="Size.Small" StartIcon="@Icons.Material.Filled.Delete" OnClick="@(() => ClearFavicon())"> - Remove + @L["Remove"]
} @@ -114,15 +119,16 @@ Accept=".png,.ico,.svg" FilesChanged="@OnFaviconUpload" MaximumFileCount="1"> - + + Elevation="0" + @onclick="@(async (e) => await upload.OpenFilePickerAsync())"> - Click to upload favicon + @L["ClickToUpload"] 16x16 or 32x32 PNG, ICO - + }
diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshColorPalettePicker.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshColorPalettePicker.razor index 3fb5f71a19..746534bd65 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshColorPalettePicker.razor +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshColorPalettePicker.razor @@ -1,15 +1,18 @@ @using FSH.Framework.Blazor.UI.Theme +@using FSH.Framework.Shared.Localization +@using Microsoft.Extensions.Localization @using MudBlazor.Utilities +@inject IStringLocalizer L @Title - Primary + @L["Primary"] - Secondary + @L["Secondary"] - Tertiary + @L["Tertiary"] - Background + @L["Background"] - Surface + @L["Surface"] - Semantic Colors + @L["SemanticColors"] - Success + @L["Success"] - Info + @L["Info"] - Warning + @L["Warning"] - Error + @L["Error"] L - Layout Settings + @L["LayoutSettings"] - Default Elevation: @Layout.DefaultElevation + @L["DefaultElevation"]: @Layout.DefaultElevation - Preview + @L["Preview"]
- Card with elevation @Layout.DefaultElevation + @L["Preview"] @Layout.DefaultElevation - Button Preview + @L["Preview"]
diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemeCustomizer.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemeCustomizer.razor index 552efdfa82..7125140237 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemeCustomizer.razor +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemeCustomizer.razor @@ -13,16 +13,16 @@ @* Left Column - Settings *@ - +
- - + - - + @@ -30,7 +30,7 @@
- +
- +
- +
@@ -165,7 +165,7 @@ /// public async Task ResetToDefaultsAsync() { - var confirmed = await DialogService.ShowMessageBox( + var confirmed = await DialogService.ShowMessageBoxAsync( "Reset Theme", "Are you sure you want to reset all theme settings to defaults? This action cannot be undone.", yesText: "Reset", diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemePreview.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemePreview.razor index e05e942580..3f91769770 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemePreview.razor +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshThemePreview.razor @@ -1,16 +1,19 @@ @using FSH.Framework.Blazor.UI.Theme +@using FSH.Framework.Shared.Localization +@using Microsoft.Extensions.Localization +@inject IStringLocalizer L - Live Preview + @L["Preview"] - Light + @L["LightMode"] - Dark + @L["DarkMode"] @@ -26,7 +29,7 @@ { } - Brand Name + @L["Brand"] @@ -38,16 +41,16 @@ @* Cards *@ - Dashboard Card + @L["Dashboard"] - This is how your content cards will look with the selected theme settings. + @L["Preview"]
- Primary + @L["Primary"] - Outlined + @L["Preview"]
@@ -56,22 +59,22 @@ @* Alerts *@ - Alerts + @L["Alerts"] - Success message + @L["Success"] - Info message + @L["Info"] - Warning message + @L["Warning"] - Error message + @L["Error"] @@ -79,22 +82,22 @@ @* Form Elements *@ - Form Elements + @L["Form"] - - Option 1 Option 2 - - + + diff --git a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshTypographyPicker.razor b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshTypographyPicker.razor index 6be7bb25fd..0a14785303 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/Theme/FshTypographyPicker.razor +++ b/src/BuildingBlocks/Blazor.UI/Components/Theme/FshTypographyPicker.razor @@ -1,12 +1,15 @@ @using FSH.Framework.Blazor.UI.Theme +@using FSH.Framework.Shared.Localization +@using Microsoft.Extensions.Localization +@inject IStringLocalizer L - Typography + @L["Typography"] - Preview + @L["Preview"]
- Heading Text + @L["Preview"]
- This is body text. The quick brown fox jumps over the lazy dog. - Lorem ipsum dolor sit amet, consectetur adipiscing elit. + @L["Preview"] Caption text with secondary color diff --git a/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor b/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor index b917d083ca..7613b7a3fa 100644 --- a/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor +++ b/src/BuildingBlocks/Blazor.UI/Components/User/FshAccountMenu.razor @@ -6,8 +6,9 @@ TransformOrigin="Origin.TopRight" PopoverClass="fsh-account-popover" Dense="false"> - -
diff --git a/src/BuildingBlocks/Blazor.UI/Extensions/FshDialogService.cs b/src/BuildingBlocks/Blazor.UI/Extensions/FshDialogService.cs new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/BuildingBlocks/Caching/Caching.csproj b/src/BuildingBlocks/Caching/Caching.csproj index 3cc234917e..404954d8cc 100644 --- a/src/BuildingBlocks/Caching/Caching.csproj +++ b/src/BuildingBlocks/Caching/Caching.csproj @@ -4,6 +4,7 @@ FSH.Framework.Caching FSH.Framework.Caching FullStackHero.Framework.Caching + $(NoWarn);NU1507 diff --git a/src/BuildingBlocks/Caching/Extensions.cs b/src/BuildingBlocks/Caching/Extensions.cs index 1a18ff1d20..dc747018c6 100644 --- a/src/BuildingBlocks/Caching/Extensions.cs +++ b/src/BuildingBlocks/Caching/Extensions.cs @@ -46,7 +46,7 @@ public static IServiceCollection AddHeroCaching(this IServiceCollection services services.AddStackExchangeRedisCache(options => { var config = ConfigurationOptions.Parse(cacheOptions.Redis); - config.AbortOnConnectFail = true; + config.AbortOnConnectFail = false; // Only override SSL if explicitly configured if (cacheOptions.EnableSsl.HasValue) diff --git a/src/BuildingBlocks/Core/Core.csproj b/src/BuildingBlocks/Core/Core.csproj index 802f8e8684..16d440947c 100644 --- a/src/BuildingBlocks/Core/Core.csproj +++ b/src/BuildingBlocks/Core/Core.csproj @@ -4,6 +4,7 @@ FSH.Framework.Core FSH.Framework.Core FullStackHero.Framework.Core + $(NoWarn);NU1507 diff --git a/src/BuildingBlocks/Core/Domain/DomainEvent.cs b/src/BuildingBlocks/Core/Domain/DomainEvent.cs index f9332910c3..543570a6b5 100644 --- a/src/BuildingBlocks/Core/Domain/DomainEvent.cs +++ b/src/BuildingBlocks/Core/Domain/DomainEvent.cs @@ -24,6 +24,6 @@ public static T Create(Func factory) where T : DomainEvent { ArgumentNullException.ThrowIfNull(factory); - return factory(Guid.NewGuid(), DateTimeOffset.UtcNow); + return factory(Guid.CreateVersion7(), DateTimeOffset.UtcNow); } } \ No newline at end of file diff --git a/src/BuildingBlocks/Core/Domain/IDomainEvent.cs b/src/BuildingBlocks/Core/Domain/IDomainEvent.cs index c16a451fef..00744ad4ed 100644 --- a/src/BuildingBlocks/Core/Domain/IDomainEvent.cs +++ b/src/BuildingBlocks/Core/Domain/IDomainEvent.cs @@ -1,9 +1,12 @@ -namespace FSH.Framework.Core.Domain; +using Mediator; + +namespace FSH.Framework.Core.Domain; /// /// Represents a domain event with correlation and tenant context. +/// Implements INotification to enable publishing through Mediator. /// -public interface IDomainEvent +public interface IDomainEvent : INotification { /// /// Gets the unique event identifier. diff --git a/src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj b/src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj index 7611f8ac4d..961b30fdc2 100644 --- a/src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj +++ b/src/BuildingBlocks/Eventing.Abstractions/Eventing.Abstractions.csproj @@ -8,7 +8,7 @@ FSH.Framework.Eventing.Abstractions FullStackHero.Framework.Eventing.Abstractions Lightweight abstractions for FSH eventing - interfaces only, no implementation dependencies - $(NoWarn);CA1711;CA1716 + $(NoWarn);CA1711;CA1716;NU1507 diff --git a/src/BuildingBlocks/Eventing/Eventing.csproj b/src/BuildingBlocks/Eventing/Eventing.csproj index b1677f81cd..184052172e 100644 --- a/src/BuildingBlocks/Eventing/Eventing.csproj +++ b/src/BuildingBlocks/Eventing/Eventing.csproj @@ -7,7 +7,7 @@ FSH.Framework.Eventing FSH.Framework.Eventing FullStackHero.Framework.Eventing - $(NoWarn);CA1711;CA1716;CA1031;S2139;S1066 + $(NoWarn);CA1711;CA1716;CA1031;S2139;S1066;NU1507 diff --git a/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs b/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs index 1e15f4dda1..39b2eb843c 100644 --- a/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs +++ b/src/BuildingBlocks/Eventing/InMemory/InMemoryEventBus.cs @@ -10,17 +10,8 @@ namespace FSH.Framework.Eventing.InMemory; /// In-memory event bus implementation used for single-process deployments. /// It resolves handlers from DI and optionally uses an inbox store for idempotency. /// -public sealed partial class InMemoryEventBus : IEventBus +public sealed partial class InMemoryEventBus(IServiceProvider serviceProvider, ILogger logger) : IEventBus { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public InMemoryEventBus(IServiceProvider serviceProvider, ILogger logger) - { - _serviceProvider = serviceProvider; - _logger = logger; - } - public Task PublishAsync(IIntegrationEvent @event, CancellationToken ct = default) => PublishAsync(new[] { @event }, ct); @@ -39,7 +30,7 @@ private async Task PublishSingleAsync(IIntegrationEvent @event, CancellationToke var eventType = @event.GetType(); LogPublishingEvent(eventType.FullName, @event.Id); - using var scope = _serviceProvider.CreateScope(); + using var scope = serviceProvider.CreateScope(); var provider = scope.ServiceProvider; var handlers = ResolveHandlers(provider, eventType); @@ -80,10 +71,10 @@ private async Task InvokeHandlerAsync( return; } - var method = handlerInterfaceType.GetMethod(nameof(IIntegrationEventHandler.HandleAsync)); + var method = handlerInterfaceType.GetMethod(nameof(IIntegrationEventHandler<>.HandleAsync)); if (method == null) { - _logger.LogWarning("Handler {Handler} does not implement HandleAsync correctly for {EventType}", handlerName, eventType.FullName); + logger.LogWarning("Handler {Handler} does not implement HandleAsync correctly for {EventType}", handlerName, eventType.FullName); return; } @@ -119,7 +110,7 @@ await inbox.MarkProcessedAsync(@event.Id, handlerName, @event.TenantId, eventTyp // failures are captured regardless of exception type. catch (Exception ex) { - _logger.LogError(ex, "Error while handling integration event {EventId} with handler {Handler}", @event.Id, handlerName); + logger.LogError(ex, "Error while handling integration event {EventId} with handler {Handler}", @event.Id, handlerName); throw; } } diff --git a/src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs b/src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs index 58364a7abd..e7e02a54ef 100644 --- a/src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs +++ b/src/BuildingBlocks/Eventing/Inbox/EfCoreInboxStore.cs @@ -6,21 +6,12 @@ namespace FSH.Framework.Eventing.Inbox; /// EF Core-based inbox store for a specific DbContext. /// /// The DbContext that owns the InboxMessages set. -public sealed class EfCoreInboxStore : IInboxStore +public sealed class EfCoreInboxStore(TDbContext dbContext, TimeProvider timeProvider) : IInboxStore where TDbContext : DbContext { - private readonly TDbContext _dbContext; - private readonly TimeProvider _timeProvider; - - public EfCoreInboxStore(TDbContext dbContext, TimeProvider timeProvider) - { - _dbContext = dbContext; - _timeProvider = timeProvider; - } - public async Task HasProcessedAsync(Guid eventId, string handlerName, CancellationToken ct = default) { - return await _dbContext.Set() + return await dbContext.Set() .AnyAsync(i => i.Id == eventId && i.HandlerName == handlerName, ct) .ConfigureAwait(false); } @@ -33,10 +24,10 @@ public async Task MarkProcessedAsync(Guid eventId, string handlerName, string? t EventType = eventType, HandlerName = handlerName, TenantId = tenantId, - ProcessedOnUtc = _timeProvider.GetUtcNow().UtcDateTime + ProcessedOnUtc = timeProvider.GetUtcNow().UtcDateTime }; - await _dbContext.Set().AddAsync(message, ct).ConfigureAwait(false); - await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + await dbContext.Set().AddAsync(message, ct).ConfigureAwait(false); + await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/BuildingBlocks/Eventing/Inbox/InboxMessage.cs b/src/BuildingBlocks/Eventing/Inbox/InboxMessage.cs index 0b20ff5edc..0aaf5a98e1 100644 --- a/src/BuildingBlocks/Eventing/Inbox/InboxMessage.cs +++ b/src/BuildingBlocks/Eventing/Inbox/InboxMessage.cs @@ -19,20 +19,13 @@ public class InboxMessage public string? TenantId { get; set; } } -public class InboxMessageConfiguration : IEntityTypeConfiguration +public class InboxMessageConfiguration(string schema) : IEntityTypeConfiguration { - private readonly string _schema; - - public InboxMessageConfiguration(string schema) - { - _schema = schema; - } - public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - builder.ToTable("InboxMessages", _schema); + builder.ToTable("InboxMessages", schema); builder.HasKey(i => new { i.Id, i.HandlerName }); diff --git a/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs b/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs index eedfef4abd..59f8fbbd00 100644 --- a/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs +++ b/src/BuildingBlocks/Eventing/Outbox/EfCoreOutboxStore.cs @@ -8,31 +8,18 @@ namespace FSH.Framework.Eventing.Outbox; /// EF Core-based outbox store for a specific DbContext. /// /// The DbContext that owns the OutboxMessages set. -public sealed class EfCoreOutboxStore : IOutboxStore +public sealed class EfCoreOutboxStore( + TDbContext dbContext, + IEventSerializer serializer, + ILogger> logger, + TimeProvider timeProvider) : IOutboxStore where TDbContext : DbContext { - private readonly TDbContext _dbContext; - private readonly IEventSerializer _serializer; - private readonly ILogger> _logger; - private readonly TimeProvider _timeProvider; - - public EfCoreOutboxStore( - TDbContext dbContext, - IEventSerializer serializer, - ILogger> logger, - TimeProvider timeProvider) - { - _dbContext = dbContext; - _serializer = serializer; - _logger = logger; - _timeProvider = timeProvider; - } - public async Task AddAsync(IIntegrationEvent @event, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(@event); - var payload = _serializer.Serialize(@event); + var payload = serializer.Serialize(@event); var message = new OutboxMessage { Id = @event.Id, @@ -45,13 +32,13 @@ public async Task AddAsync(IIntegrationEvent @event, CancellationToken ct = defa IsDead = false }; - await _dbContext.Set().AddAsync(message, ct).ConfigureAwait(false); - await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + await dbContext.Set().AddAsync(message, ct).ConfigureAwait(false); + await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); } public async Task> GetPendingBatchAsync(int batchSize, CancellationToken ct = default) { - return await _dbContext.Set() + return await dbContext.Set() .Where(m => !m.IsDead && m.ProcessedOnUtc == null) .OrderBy(m => m.CreatedOnUtc) .Take(batchSize) @@ -63,9 +50,9 @@ public async Task MarkAsProcessedAsync(OutboxMessage message, CancellationToken { ArgumentNullException.ThrowIfNull(message); - message.ProcessedOnUtc = _timeProvider.GetUtcNow().UtcDateTime; - _dbContext.Set().Update(message); - await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + message.ProcessedOnUtc = timeProvider.GetUtcNow().UtcDateTime; + dbContext.Set().Update(message); + await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); } public async Task MarkAsFailedAsync(OutboxMessage message, string error, bool isDead, CancellationToken ct = default) @@ -75,11 +62,11 @@ public async Task MarkAsFailedAsync(OutboxMessage message, string error, bool is message.RetryCount++; message.LastError = error; message.IsDead = isDead; - _dbContext.Set().Update(message); + dbContext.Set().Update(message); - _logger.LogWarning("Outbox message {MessageId} failed. RetryCount={RetryCount}, IsDead={IsDead}, Error={Error}", + logger.LogWarning("Outbox message {MessageId} failed. RetryCount={RetryCount}, IsDead={IsDead}, Error={Error}", message.Id, message.RetryCount, message.IsDead, error); - await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/BuildingBlocks/Eventing/Outbox/OutboxMessage.cs b/src/BuildingBlocks/Eventing/Outbox/OutboxMessage.cs index 88d73163be..b8afc65a17 100644 --- a/src/BuildingBlocks/Eventing/Outbox/OutboxMessage.cs +++ b/src/BuildingBlocks/Eventing/Outbox/OutboxMessage.cs @@ -29,20 +29,13 @@ public class OutboxMessage public bool IsDead { get; set; } } -public class OutboxMessageConfiguration : IEntityTypeConfiguration +public class OutboxMessageConfiguration(string schema) : IEntityTypeConfiguration { - private readonly string _schema; - - public OutboxMessageConfiguration(string schema) - { - _schema = schema; - } - public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); - builder.ToTable("OutboxMessages", _schema); + builder.ToTable("OutboxMessages", schema); builder.HasKey(o => o.Id); diff --git a/src/BuildingBlocks/Jobs/Extensions.cs b/src/BuildingBlocks/Jobs/Extensions.cs index 1350a25141..ce8dacc7f1 100644 --- a/src/BuildingBlocks/Jobs/Extensions.cs +++ b/src/BuildingBlocks/Jobs/Extensions.cs @@ -100,15 +100,17 @@ public static IApplicationBuilder UseHeroJobDashboard(this IApplicationBuilder a ArgumentNullException.ThrowIfNull(config); var hangfireOptions = config.GetSection(nameof(HangfireOptions)).Get() ?? new HangfireOptions(); - var dashboardOptions = new DashboardOptions(); - dashboardOptions.AppPath = "/"; - dashboardOptions.Authorization = new[] + var dashboardOptions = new DashboardOptions { - new HangfireCustomBasicAuthenticationFilter - { - User = hangfireOptions.UserName!, - Pass = hangfireOptions.Password! - } + AppPath = "/", + Authorization = new[] + { + new HangfireCustomBasicAuthenticationFilter + { + User = hangfireOptions.UserName!, + Pass = hangfireOptions.Password! + } + } }; return app.UseHangfireDashboard(hangfireOptions.Route, dashboardOptions); diff --git a/src/BuildingBlocks/Jobs/FshJobActivator.cs b/src/BuildingBlocks/Jobs/FshJobActivator.cs index 042e0ea828..c1cf69d4d6 100644 --- a/src/BuildingBlocks/Jobs/FshJobActivator.cs +++ b/src/BuildingBlocks/Jobs/FshJobActivator.cs @@ -9,12 +9,9 @@ namespace FSH.Framework.Jobs; -public class FshJobActivator : JobActivator +public class FshJobActivator(IServiceScopeFactory scopeFactory) : JobActivator { - private readonly IServiceScopeFactory _scopeFactory; - - public FshJobActivator(IServiceScopeFactory scopeFactory) => - _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); + private readonly IServiceScopeFactory _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); public override JobActivatorScope BeginScope(PerformContext context) => new Scope(context, _scopeFactory.CreateScope()); diff --git a/src/BuildingBlocks/Jobs/FshJobFilter.cs b/src/BuildingBlocks/Jobs/FshJobFilter.cs index 8e91c3f9e1..a2363ce47c 100644 --- a/src/BuildingBlocks/Jobs/FshJobFilter.cs +++ b/src/BuildingBlocks/Jobs/FshJobFilter.cs @@ -9,14 +9,10 @@ namespace FSH.Framework.Jobs; -public class FshJobFilter : IClientFilter +public class FshJobFilter(IServiceProvider services) : IClientFilter { private static readonly ILog Logger = LogProvider.GetCurrentClassLogger(); - private readonly IServiceProvider _services; - - public FshJobFilter(IServiceProvider services) => _services = services; - public void OnCreating(CreatingContext context) { ArgumentNullException.ThrowIfNull(context); @@ -24,7 +20,7 @@ public void OnCreating(CreatingContext context) Logger.InfoFormat("Set TenantId and UserId parameters to job {0}.{1}...", context.Job.Method.ReflectedType?.FullName, context.Job.Method.Name); - using var scope = _services.CreateScope(); + using var scope = services.CreateScope(); var httpContextAccessor = scope.ServiceProvider.GetService(); var httpContext = httpContextAccessor?.HttpContext; diff --git a/src/BuildingBlocks/Jobs/HangfireCustomBasicAuthenticationFilter.cs b/src/BuildingBlocks/Jobs/HangfireCustomBasicAuthenticationFilter.cs index d03357c23d..4a3bb284da 100644 --- a/src/BuildingBlocks/Jobs/HangfireCustomBasicAuthenticationFilter.cs +++ b/src/BuildingBlocks/Jobs/HangfireCustomBasicAuthenticationFilter.cs @@ -7,10 +7,10 @@ namespace FSH.Framework.Jobs; -public sealed class HangfireCustomBasicAuthenticationFilter : IDashboardAuthorizationFilter +public sealed class HangfireCustomBasicAuthenticationFilter(ILogger logger) : IDashboardAuthorizationFilter { private const string AuthenticationScheme = "Basic"; - private readonly ILogger _logger; + public string User { get; set; } = default!; public string Pass { get; set; } = default!; @@ -19,8 +19,6 @@ public HangfireCustomBasicAuthenticationFilter() { } - public HangfireCustomBasicAuthenticationFilter(ILogger logger) => _logger = logger; - public bool Authorize(DashboardContext context) { var httpContext = context.GetHttpContext(); @@ -28,7 +26,7 @@ public bool Authorize(DashboardContext context) if (MissingAuthorizationHeader(header)) { - _logger.LogInformation("Request is missing Authorization Header"); + logger.LogInformation("Request is missing Authorization Header"); SetChallengeResponse(httpContext); return false; } @@ -37,7 +35,7 @@ public bool Authorize(DashboardContext context) if (NotBasicAuthentication(authValues)) { - _logger.LogInformation("Request is NOT BASIC authentication"); + logger.LogInformation("Request is NOT BASIC authentication"); SetChallengeResponse(httpContext); return false; } @@ -46,18 +44,18 @@ public bool Authorize(DashboardContext context) if (tokens.AreInvalid()) { - _logger.LogInformation("Authentication tokens are invalid (empty, null, whitespace)"); + logger.LogInformation("Authentication tokens are invalid (empty, null, whitespace)"); SetChallengeResponse(httpContext); return false; } if (tokens.CredentialsMatch(User, Pass)) { - _logger.LogInformation("Awesome, authentication tokens match configuration!"); + logger.LogInformation("Awesome, authentication tokens match configuration!"); return true; } - _logger.LogInformation("Hangfire dashboard authentication failed — credentials do not match configuration"); + logger.LogInformation("Hangfire dashboard authentication failed — credentials do not match configuration"); SetChallengeResponse(httpContext); return false; @@ -87,23 +85,16 @@ private static void SetChallengeResponse(HttpContext httpContext) } } -public sealed class BasicAuthenticationTokens +public sealed class BasicAuthenticationTokens(string[] tokens) { - private readonly string[] _tokens; - - public string? Username => _tokens.Length > 0 ? _tokens[0] : null; - public string? Password => _tokens.Length > 1 ? _tokens[1] : null; - - public BasicAuthenticationTokens(string[] tokens) - { - _tokens = tokens; - } + public string? Username => tokens.Length > 0 ? tokens[0] : null; + public string? Password => tokens.Length > 1 ? tokens[1] : null; public bool AreInvalid() { - return _tokens.Length != 2 - || string.IsNullOrWhiteSpace(_tokens[0]) - || string.IsNullOrWhiteSpace(_tokens[1]); + return tokens.Length != 2 + || string.IsNullOrWhiteSpace(tokens[0]) + || string.IsNullOrWhiteSpace(tokens[1]); } public bool CredentialsMatch(string user, string pass) diff --git a/src/BuildingBlocks/Jobs/Jobs.csproj b/src/BuildingBlocks/Jobs/Jobs.csproj index af6529f7ea..ceee0f8710 100644 --- a/src/BuildingBlocks/Jobs/Jobs.csproj +++ b/src/BuildingBlocks/Jobs/Jobs.csproj @@ -4,7 +4,7 @@ FSH.Framework.Jobs FSH.Framework.Jobs FullStackHero.Framework.Jobs - $(NoWarn);CA1031;S3376;S3993 + $(NoWarn);CA1031;S3376;S3993;NU1507 diff --git a/src/BuildingBlocks/Mailing/Mailing.csproj b/src/BuildingBlocks/Mailing/Mailing.csproj index c40d62ccc1..3bfe140c81 100644 --- a/src/BuildingBlocks/Mailing/Mailing.csproj +++ b/src/BuildingBlocks/Mailing/Mailing.csproj @@ -4,6 +4,7 @@ FSH.Framework.Mailing FSH.Framework.Mailing FullStackHero.Framework.Mailing + $(NoWarn);NU1507 diff --git a/src/BuildingBlocks/Persistence/DatabaseOptionsStartupLogger.cs b/src/BuildingBlocks/Persistence/DatabaseOptionsStartupLogger.cs index dca621492d..a6e1e8cc93 100644 --- a/src/BuildingBlocks/Persistence/DatabaseOptionsStartupLogger.cs +++ b/src/BuildingBlocks/Persistence/DatabaseOptionsStartupLogger.cs @@ -8,23 +8,16 @@ namespace FSH.Framework.Persistence; /// /// Hosted service that logs database configuration options during application startup. /// -public sealed class DatabaseOptionsStartupLogger : IHostedService +/// +/// Initializes a new instance of the class. +/// +/// Logger instance for writing startup information. +/// Database configuration options. +public sealed class DatabaseOptionsStartupLogger( + ILogger logger, + IOptions options) : IHostedService { - private readonly ILogger _logger; - private readonly IOptions _options; - - /// - /// Initializes a new instance of the class. - /// - /// Logger instance for writing startup information. - /// Database configuration options. - public DatabaseOptionsStartupLogger( - ILogger logger, - IOptions options) - { - _logger = logger; - _options = options; - } + private readonly IOptions _options = options; /// /// Logs database configuration information when the service starts. @@ -33,12 +26,12 @@ public DatabaseOptionsStartupLogger( /// A completed task. public Task StartAsync(CancellationToken cancellationToken) { - var options = _options.Value; - if (_logger.IsEnabled(LogLevel.Information)) + var opt = _options.Value; + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("current db provider: {Provider}", options.Provider); - _logger.LogInformation("for docs: https://www.fullstackhero.net"); - _logger.LogInformation("sponsor: https://opencollective.com/fullstackhero"); + logger.LogInformation("current db provider: {Provider}", opt.Provider); + logger.LogInformation("for docs: https://www.fullstackhero.net"); + logger.LogInformation("sponsor: https://opencollective.com/fullstackhero"); } return Task.CompletedTask; } diff --git a/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs b/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs index c9ff8cb23a..864fe70aff 100644 --- a/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs +++ b/src/BuildingBlocks/Persistence/Inteceptors/AuditableEntitySaveChangesInterceptor.cs @@ -11,19 +11,10 @@ namespace FSH.Framework.Persistence.Inteceptors; /// and handles soft delete for entities implementing . /// Uses an recursion guard to prevent StackOverflowException from nested SaveChanges calls. /// -public sealed class AuditableEntitySaveChangesInterceptor : SaveChangesInterceptor +public sealed class AuditableEntitySaveChangesInterceptor(ICurrentUser currentUser, TimeProvider timeProvider) : SaveChangesInterceptor { - private readonly ICurrentUser _currentUser; - private readonly TimeProvider _timeProvider; - private static readonly AsyncLocal _isSaving = new(); - public AuditableEntitySaveChangesInterceptor(ICurrentUser currentUser, TimeProvider timeProvider) - { - _currentUser = currentUser; - _timeProvider = timeProvider; - } - public override async ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, @@ -75,8 +66,8 @@ private void UpdateAuditEntities(DbContext? context) { if (context is null) return; - var userId = _currentUser.IsAuthenticated() ? _currentUser.GetUserId().ToString() : null; - var now = _timeProvider.GetUtcNow(); + var userId = currentUser.IsAuthenticated() ? currentUser.GetUserId().ToString() : null; + var now = timeProvider.GetUtcNow(); foreach (var entry in context.ChangeTracker.Entries()) { diff --git a/src/BuildingBlocks/Persistence/Inteceptors/DomainEventsInterceptor.cs b/src/BuildingBlocks/Persistence/Inteceptors/DomainEventsInterceptor.cs index 7bf883d0f0..aa52440183 100644 --- a/src/BuildingBlocks/Persistence/Inteceptors/DomainEventsInterceptor.cs +++ b/src/BuildingBlocks/Persistence/Inteceptors/DomainEventsInterceptor.cs @@ -8,21 +8,13 @@ namespace FSH.Framework.Persistence.Inteceptors; /// /// Entity Framework interceptor that automatically publishes domain events after saving changes. /// -public sealed class DomainEventsInterceptor : SaveChangesInterceptor +/// +/// Initializes a new instance of the class. +/// +/// The mediator publisher for publishing domain events. +/// Logger for tracking domain event publication. +public sealed class DomainEventsInterceptor(IPublisher publisher, ILogger logger) : SaveChangesInterceptor { - private readonly IPublisher _publisher; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// The mediator publisher for publishing domain events. - /// Logger for tracking domain event publication. - public DomainEventsInterceptor(IPublisher publisher, ILogger logger) - { - _publisher = publisher; - _logger = logger; - } /// /// Called before changes are saved to the database. @@ -70,13 +62,13 @@ public override async ValueTask SavedChangesAsync( if (domainEvents.Length == 0) return await base.SavedChangesAsync(eventData, result, cancellationToken); - if (_logger.IsEnabled(LogLevel.Debug)) + if (logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("Publishing {Count} domain events...", domainEvents.Length); + logger.LogDebug("Publishing {Count} domain events...", domainEvents.Length); } foreach (var domainEvent in domainEvents) - await _publisher.Publish(domainEvent, cancellationToken).ConfigureAwait(false); + await publisher.Publish(domainEvent, cancellationToken).ConfigureAwait(false); return await base.SavedChangesAsync(eventData, result, cancellationToken); } diff --git a/src/BuildingBlocks/Persistence/Pagination/PaginationExtensions.cs b/src/BuildingBlocks/Persistence/Pagination/PaginationExtensions.cs index dfcf47ca48..5b44830f3e 100644 --- a/src/BuildingBlocks/Persistence/Pagination/PaginationExtensions.cs +++ b/src/BuildingBlocks/Persistence/Pagination/PaginationExtensions.cs @@ -49,6 +49,38 @@ public static Task> ToPagedResponseAsync( return ToPagedResponseInternalAsync(source, pageNumber, pageSize, cancellationToken); } + /// + /// Counts on the raw query and projects only the paged subset to + /// , producing a simpler COUNT(*) and a narrower SELECT. + /// + public static Task> ToPagedResponseAsync( + this IQueryable source, + Expression> projection, + IPagedQuery pagination, + CancellationToken cancellationToken = default) + where TSource : class + where TResult : class + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(projection); + ArgumentNullException.ThrowIfNull(pagination); + + var pageNumber = pagination.PageNumber is null or <= 0 + ? 1 + : pagination.PageNumber.Value; + + var pageSize = pagination.PageSize is null or <= 0 + ? DefaultPageSize + : pagination.PageSize.Value; + + if (pageSize > MaxPageSize) + { + pageSize = MaxPageSize; + } + + return ToPagedResponseInternalAsync(source, projection, pageNumber, pageSize, cancellationToken); + } + private static async Task> ToPagedResponseInternalAsync( IQueryable source, int pageNumber, @@ -84,4 +116,45 @@ private static async Task> ToPagedResponseInternalAsync( TotalPages = totalPages }; } + + // COUNT on TSource (no projection columns in the count query), + // Skip/Take on TSource, then project — avoids SELECT COUNT(*) FROM (SELECT col1, col2...) subquery. + private static async Task> ToPagedResponseInternalAsync( + IQueryable source, + Expression> projection, + int pageNumber, + int pageSize, + CancellationToken cancellationToken) + where TSource : class + where TResult : class + { + var totalCount = await source.LongCountAsync(cancellationToken).ConfigureAwait(false); + + var totalPages = totalCount == 0 + ? 0 + : (int)Math.Ceiling(totalCount / (double)pageSize); + + if (pageNumber > totalPages && totalPages > 0) + { + pageNumber = totalPages; + } + + var skip = (pageNumber - 1) * pageSize; + + var items = await source + .Skip(skip) + .Take(pageSize) + .Select(projection) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + return new PagedResponse + { + Items = items, + PageNumber = pageNumber, + PageSize = pageSize, + TotalCount = totalCount, + TotalPages = totalPages + }; + } } \ No newline at end of file diff --git a/src/BuildingBlocks/Persistence/Persistence.csproj b/src/BuildingBlocks/Persistence/Persistence.csproj index c1bbe11b7c..c856380b3a 100644 --- a/src/BuildingBlocks/Persistence/Persistence.csproj +++ b/src/BuildingBlocks/Persistence/Persistence.csproj @@ -4,7 +4,7 @@ FSH.Framework.Persistence FSH.Framework.Persistence FullStackHero.Framework.Persistence - $(NoWarn);S4144;CS0618 + $(NoWarn);S4144;CS0618;NU1507 diff --git a/src/BuildingBlocks/Persistence/Specifications/Specification.cs b/src/BuildingBlocks/Persistence/Specifications/Specification.cs index e8af19ff1c..6acc505bab 100644 --- a/src/BuildingBlocks/Persistence/Specifications/Specification.cs +++ b/src/BuildingBlocks/Persistence/Specifications/Specification.cs @@ -223,20 +223,11 @@ private static Expression ReplaceParameter( ?? throw new InvalidOperationException("Failed to replace parameter in expression."); } - private sealed class ParameterReplaceVisitor : ExpressionVisitor + private sealed class ParameterReplaceVisitor(ParameterExpression source, ParameterExpression target) : ExpressionVisitor { - private readonly ParameterExpression _source; - private readonly ParameterExpression _target; - - public ParameterReplaceVisitor(ParameterExpression source, ParameterExpression target) - { - _source = source; - _target = target; - } - protected override Expression VisitParameter(ParameterExpression node) { - return node == _source ? _target : base.VisitParameter(node); + return node == source ? target : base.VisitParameter(node); } } } \ No newline at end of file diff --git a/src/BuildingBlocks/Shared/Localization/LocalizationConstants.cs b/src/BuildingBlocks/Shared/Localization/LocalizationConstants.cs new file mode 100644 index 0000000000..525ab935fe --- /dev/null +++ b/src/BuildingBlocks/Shared/Localization/LocalizationConstants.cs @@ -0,0 +1,16 @@ +namespace FSH.Framework.Shared.Localization; + +/// +/// Defines supported cultures and localization defaults for the application. +/// +public static class LocalizationConstants +{ + public const string DefaultCulture = "en-US"; + public const string CultureCookieName = ".FSH.Culture"; + + /// Cultures supported across all projects. + public static readonly string[] SupportedCultures = + [ + "en-US" + ]; +} diff --git a/src/BuildingBlocks/Shared/Localization/LocalizationExtensions.cs b/src/BuildingBlocks/Shared/Localization/LocalizationExtensions.cs new file mode 100644 index 0000000000..ed638c4479 --- /dev/null +++ b/src/BuildingBlocks/Shared/Localization/LocalizationExtensions.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Localization; +using Microsoft.Extensions.DependencyInjection; + +namespace FSH.Framework.Shared.Localization; + +/// +/// Extension methods to wire up localization in both the API and Blazor hosts. +/// Supports modular localization architecture: +/// - SharedResource: Common strings used across all modules (Email, Password, Required, etc.) +/// - Module-specific resources: Each module has its own resource file (e.g., IdentityResource, TenantResource) +/// +public static class LocalizationExtensions +{ + /// + /// Registers localization services for modular architecture. Call in IServiceCollection setup. + /// + /// + /// Resources are discovered based on namespace structure: + /// - FSH.Framework.Shared.Localization.SharedResource → Shared/Localization/SharedResource.resx + /// - FSH.Modules.Identity.Localization.IdentityResource → Identity/Localization/IdentityResource.resx + /// - FSH.Modules.Multitenancy.Localization.TenantResource → Multitenancy/Localization/TenantResource.resx + /// + /// Usage in components: + /// @inject IStringLocalizer<SharedResource> L // Common strings + /// @inject IStringLocalizer<IdentityResource> IL // Identity-specific strings + /// + public static IServiceCollection AddFshLocalization(this IServiceCollection services) + { + // Register localization without specifying ResourcesPath + // Resources are discovered based on the namespace structure + // This allows each module to have its own resource files + services.AddLocalization(); + return services; + } + + /// + /// Adds request localization middleware using . + /// Culture is resolved in order: cookie → Accept-Language header → default. + /// + public static IApplicationBuilder UseFshLocalization(this IApplicationBuilder app) + { + var options = new RequestLocalizationOptions() + .SetDefaultCulture(LocalizationConstants.DefaultCulture) + .AddSupportedCultures(LocalizationConstants.SupportedCultures) + .AddSupportedUICultures(LocalizationConstants.SupportedCultures); + + // Cookie provider allows per-user culture selection + options.RequestCultureProviders.Insert(0, new CookieRequestCultureProvider + { + CookieName = LocalizationConstants.CultureCookieName + }); + + app.UseRequestLocalization(options); + return app; + } +} diff --git a/src/BuildingBlocks/Shared/Localization/SharedResource.cs b/src/BuildingBlocks/Shared/Localization/SharedResource.cs new file mode 100644 index 0000000000..f84f238258 --- /dev/null +++ b/src/BuildingBlocks/Shared/Localization/SharedResource.cs @@ -0,0 +1,10 @@ +namespace FSH.Framework.Shared.Localization; + +/// +/// Marker class for shared localization resources. +/// Pair this with SharedResource.resx (and per-culture variants, e.g. SharedResource.af-ZA.resx). +/// Inject as IStringLocalizer<SharedResource> wherever shared strings are needed. +/// +#pragma warning disable S2094 // Intentionally empty marker class — pairs with SharedResource.resx +public sealed class SharedResource; +#pragma warning restore S2094 diff --git a/src/BuildingBlocks/Shared/Localization/SharedResource.resx b/src/BuildingBlocks/Shared/Localization/SharedResource.resx new file mode 100644 index 0000000000..d57f846928 --- /dev/null +++ b/src/BuildingBlocks/Shared/Localization/SharedResource.resx @@ -0,0 +1,477 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} is required. + + + {0} must not exceed {1} characters. + + + {0} must be at least {1} characters. + + + A valid email address is required. + + + Authentication failed. + + + The requested resource was not found. + + + You do not have permission to perform this action. + + + At least one user ID is required. + + + User IDs cannot be empty or whitespace. + + + Passwords do not match. + + + New password must be different from the current password. + + + This password has been used recently. Please choose a different password. + + + You cannot upload a new image and delete the current one simultaneously. + + + Create Account + + + Create an account + + + Enter your details to get started + + + Tenant + + + Enter tenant name + + + First Name + + + John + + + Last Name + + + Doe + + + Email + + + m@example.com + + + Username + + + johndoe + + + Phone Number + + + Optional + + + +1 (555) 000-0000 + + + Password + + + Create a strong password + + + Confirm Password + + + Confirm your password + + + Show password + + + Creating account... + + + Already have an account? + + + Sign in + + + By creating an account, you agree to our + + + and + + + Terms of Service + + + Privacy Policy + + + Account created successfully! Please check your email to confirm your account. + + An unexpected error occurred: {0} + + Login + + + Welcome Back + + + Login with your Apple or Google account + + + Login with Apple + + + Login with Google + + + Or continue with + + + Forgot your password? + + + Don't have an account? + + + Sign up + + + By clicking continue, you agree to our + + + + Save + Cancel + Delete + Edit + Create + Update + Close + Submit + Search + Filter + Export + Import + Refresh + Add + Remove + View + Back + Next + Previous + Confirm + Reset + Clear + Apply + + + Name + Description + Status + Active + Inactive + Enabled + Disabled + Actions + Details + Settings + Date + Time + Created At + Updated At + Created By + Updated By + + + Loading... + No data available + Saved successfully + Deleted successfully + Updated successfully + Created successfully + Are you sure you want to delete {0}? This action cannot be undone. + Operation cancelled + Processing request... + + + Dashboard + Users + Roles + Groups + Tenants + Audits + Profile + Security + Theme + Sessions + Permissions + Webhooks + + + ID + Image + Role + Members + Count + + + Assigned + Available + Available Roles + Categories + Changes + Will be added + Will be removed + Configured + Provisioning Status + Current Step + Started + Time Remaining + Expired + Expiring Soon + Dedicated + Shared + Danger Zone + View All + User + User ID + Admin + Verified + Unverified + days + Unlimited + Max Sessions per User + Session Idle Timeout + Session Absolute Timeout + Password History + Account Lockout + Two-Factor Authentication + Quotas + Max Users + Storage Limit + API Rate Limit + Email Notifications + System Alerts + Retry + Select All + Deselect All + Expand + Collapse + Pending + Upload + Email Verified + Not provided + Yes + No + Coming soon in a future update. + + + Administration + Communication + System + Chat + Notifications + Logs + Audit Logs + About + Database + Access Denied + Timestamp + Tracking + All + Total + Overview + Hide + Copy + Copied to clipboard + Rows per page: + Default + Filters + Protected + Expiring + Unknown + Phone + Type + Last Activity + Created + Other + View and edit your profile + View your recent activity + Sign out + Brand Assets + Logo (Light Mode) + Logo (Dark Mode) + Favicon + Click to upload + Primary + Secondary + Tertiary + Background + Surface + Semantic Colors + Success + Info + Warning + Error + Layout Settings + Border Radius + Default Elevation + Preview + Colors + Light Mode + Dark Mode + Brand + Typography + Layout + Alerts + Form + Body Font Family + Heading Font Family + Font Size + Line Height + Delete Confirmation + Are you sure you want to sign out of your account? + Signed in. + Signed out. + Your session has expired. Please sign in again. + Completed + Health + Home + None + diff --git a/src/BuildingBlocks/Shared/Localization/VERIFICATION.md b/src/BuildingBlocks/Shared/Localization/VERIFICATION.md new file mode 100644 index 0000000000..39e651de00 --- /dev/null +++ b/src/BuildingBlocks/Shared/Localization/VERIFICATION.md @@ -0,0 +1,178 @@ +# Blazor Localization Verification Guide + +## Current Setup Summary + +✅ **SharedResource.resx** configured in `BuildingBlocks\Shared\Localization\` +✅ **SharedResource.cs** marker class created +✅ **LocalizationExtensions** updated with `ResourcesPath = "Localization"` +✅ **Blazor Program.cs** already calls `AddFshLocalization()` and `UseFshLocalization()` +✅ **Blazor project** references `Shared` project + +## How to Verify Localization is Working + +### 1. Test with the Register Page + +The Register page (`/register`) now uses `IStringLocalizer` for validation messages. + +**Steps to test:** +1. Navigate to `/register` in the Blazor app +2. Fill in the registration form +3. Enter different passwords in "Password" and "Confirm Password" +4. Click "Create account" +5. You should see the localized error message: "Passwords do not match." + +This message comes from `Localizer["PasswordsMustMatch"]` which reads from `SharedResource.resx`. + +### 2. Test with Different Cultures + +Create a culture-specific resource file to test localization: + +1. Create `SharedResource.af-ZA.resx` in the same folder +2. Add the key `PasswordsMustMatch` with value `Wagwoorde stem nie ooreen nie.` +3. Set culture cookie in browser console: +```javascript +document.cookie = ".AspNetCore.Culture=c=af-ZA|uic=af-ZA; path=/"; +``` +4. Refresh and test the Register page again +5. You should see the Afrikaans error message + +### 3. Inject IStringLocalizer in Any Component + +```razor +@page "/your-page" +@inject IStringLocalizer Localizer + +@Localizer["Required", "Email"] +@Localizer["InvalidEmail"] +``` + +### 4. Use in Validators + +When creating validators, inject `IStringLocalizer`: + +```csharp +public class MyValidator : AbstractValidator +{ + public MyValidator(IStringLocalizer localizer) + { + RuleFor(x => x.Email) + .NotEmpty() + .WithMessage(IdentityValidationMessages.Required("Email", localizer)) + .EmailAddress() + .WithMessage(IdentityValidationMessages.InvalidEmail(localizer)); + } +} +``` + +## Testing Different Cultures + +### Option 1: Using Browser Developer Tools + +1. Open browser DevTools (F12) +2. Go to Console +3. Set a culture cookie: +```javascript +document.cookie = ".AspNetCore.Culture=c=af-ZA|uic=af-ZA; path=/"; +``` +4. Refresh the page + +### Option 2: Using Query String (if supported) + +Navigate to: `?culture=af-ZA&ui-culture=af-ZA` + +### Option 3: Create Culture-Specific Resource Files + +1. In Visual Studio, right-click `SharedResource.resx` +2. Select "Add" > "New Item" +3. Choose "Resources File" +4. Name it: `SharedResource.af-ZA.resx` (for Afrikaans) +5. Add the same keys with translated values + +Example for `SharedResource.af-ZA.resx`: +- `Required` → `{0} is vereis.` +- `InvalidEmail` → `'n Geldige e-posadres is vereis.` + +## Common Issues and Solutions + +### Issue: Localizer returns "[Required]" instead of the message + +**Cause**: The key doesn't exist in the .resx file or the namespace/path is incorrect. + +**Solution**: +1. Verify the key exists in `SharedResource.resx` +2. Check that `SharedResource.cs` namespace is `FSH.Framework.Shared.Localization` +3. Ensure the .resx file is set as `EmbeddedResource` (default in Visual Studio) + +### Issue: "Cannot inject IStringLocalizer" + +**Cause**: Localization services not registered. + +**Solution**: Verify `Program.cs` has: +```csharp +builder.Services.AddFshLocalization(); +``` + +### Issue: Culture not changing + +**Cause**: Request localization middleware not configured. + +**Solution**: Verify `Program.cs` has (after authentication): +```csharp +app.UseFshLocalization(); +``` + +## Architecture Overview + +``` +BuildingBlocks/Shared/Localization/ +├── SharedResource.resx ← Default (English) messages +├── SharedResource.af-ZA.resx ← Afrikaans translations (optional) +├── SharedResource.cs ← Marker class +├── LocalizationExtensions.cs ← Service registration +└── LocalizationConstants.cs ← Supported cultures + +Modules/Identity/Constants/ +└── IdentityValidationMessages.cs ← Helper methods that use SharedResource + +Playground/Playground.Blazor/ +├── Program.cs ← Calls AddFshLocalization() & UseFshLocalization() +└── Components/Pages/Authentication/ + └── Register.razor ← Real-world example using IStringLocalizer +``` + +## Next Steps + +1. **Test the Register page** at `/register` with mismatched passwords +2. **Verify the localized message** is displayed correctly +3. **Add culture-specific .resx files** for your target languages (e.g., `SharedResource.es.resx` for Spanish) +4. **Update validators** to inject `IStringLocalizer` for runtime localization +5. **Test with different cultures** using browser cookies + +## Real-World Example + +The **Register page** (`Playground\Playground.Blazor\Components\Pages\Authentication\Register.razor`) demonstrates: + +```csharp +@inject IStringLocalizer Localizer + +// In code: +if (_model.Password != _model.ConfirmPassword) +{ + _errorMessage = Localizer["PasswordsMustMatch"]; + return; +} +``` + +This shows how to use localized messages in real Blazor components for: +- ✅ Client-side validation +- ✅ Error messages +- ✅ User-facing text that needs translation + +## Important Notes + +- The `.resx` file must be in the `Localization` folder (matches `ResourcesPath`) +- The marker class namespace must match the folder structure: `FSH.Framework.Shared.Localization` +- For backward compatibility, `IdentityValidationMessages` methods have optional `localizer` parameter +- Without passing `localizer`, English defaults are used +- With `localizer`, runtime culture-specific messages are returned +- The Register page (`/register`) is a working example of localization in action diff --git a/src/BuildingBlocks/Shared/Shared.csproj b/src/BuildingBlocks/Shared/Shared.csproj index 12089a3246..b90f23bf4e 100644 --- a/src/BuildingBlocks/Shared/Shared.csproj +++ b/src/BuildingBlocks/Shared/Shared.csproj @@ -4,7 +4,7 @@ FSH.Framework.Shared FSH.Framework.Shared FullStackHero.Framework.Shared - $(NoWarn);CA1716;CA1711;CA1019;CA1305;CA1002;CA2227 + $(NoWarn);CA1716;CA1711;CA1019;CA1305;CA1002;CA2227;NU1507 @@ -15,7 +15,9 @@ - + + + diff --git a/src/BuildingBlocks/Storage/Local/LocalStorageService.cs b/src/BuildingBlocks/Storage/Local/LocalStorageService.cs index d27d9b40d3..68517de7bb 100644 --- a/src/BuildingBlocks/Storage/Local/LocalStorageService.cs +++ b/src/BuildingBlocks/Storage/Local/LocalStorageService.cs @@ -44,7 +44,7 @@ public async Task UploadAsync(FileUploadRequest request, FileType fil #pragma warning disable CA1308 // folder names are intentionally lower-case for URLs/paths var folder = Regex.Replace(typeof(T).Name.ToLowerInvariant(), @"[^a-z0-9]", "_"); #pragma warning restore CA1308 - var safeFileName = $"{Guid.NewGuid():N}_{SanitizeFileName(request.FileName)}"; + var safeFileName = $"{Guid.CreateVersion7():N}_{SanitizeFileName(request.FileName)}"; var relativePath = Path.Combine(UploadBasePath, folder, safeFileName); var fullPath = Path.Combine(_rootPath, relativePath); diff --git a/src/BuildingBlocks/Storage/S3/S3StorageService.cs b/src/BuildingBlocks/Storage/S3/S3StorageService.cs index 0fbe7b2dfa..1da56eccb8 100644 --- a/src/BuildingBlocks/Storage/S3/S3StorageService.cs +++ b/src/BuildingBlocks/Storage/S3/S3StorageService.cs @@ -187,7 +187,7 @@ public async Task ExistsAsync(string path, CancellationToken cancellationT private string BuildKey(string fileName) where T : class { var folder = Regex.Replace(typeof(T).Name.ToLowerInvariant(), @"[^a-z0-9]", "_"); - var relativePath = Path.Combine(UploadBasePath, folder, $"{Guid.NewGuid():N}_{fileName}").Replace("\\", "/", StringComparison.Ordinal); + var relativePath = Path.Combine(UploadBasePath, folder, $"{Guid.CreateVersion7():N}_{fileName}").Replace("\\", "/", StringComparison.Ordinal); if (!string.IsNullOrWhiteSpace(_options.Prefix)) { return $"{_options.Prefix.TrimEnd('/')}/{relativePath}"; diff --git a/src/BuildingBlocks/Storage/Storage.csproj b/src/BuildingBlocks/Storage/Storage.csproj index dd2ec36cee..280f9cb415 100644 --- a/src/BuildingBlocks/Storage/Storage.csproj +++ b/src/BuildingBlocks/Storage/Storage.csproj @@ -4,7 +4,7 @@ FSH.Framework.Storage FSH.Framework.Storage FullStackHero.Framework.Storage - $(NoWarn);CA1031;CA1056;CA1002;CA2227;CA1812;CA1308;CA1062 + $(NoWarn);CA1031;CA1056;CA1002;CA2227;CA1812;CA1308;CA1062;NU1507 diff --git a/src/BuildingBlocks/Web/Modules/FshModuleAttribute.cs b/src/BuildingBlocks/Web/Modules/FshModuleAttribute.cs index 4f125b42a0..d13919bd9e 100644 --- a/src/BuildingBlocks/Web/Modules/FshModuleAttribute.cs +++ b/src/BuildingBlocks/Web/Modules/FshModuleAttribute.cs @@ -3,19 +3,13 @@ namespace FSH.Framework.Web.Modules; [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] -public sealed class FshModuleAttribute : Attribute +public sealed class FshModuleAttribute(Type moduleType, int order = 0) : Attribute { - public Type ModuleType { get; } + public Type ModuleType { get; } = moduleType ?? throw new ArgumentNullException(nameof(moduleType)); /// /// Optional ordering hint that allows hosts to control module startup sequencing. /// Lower numbers execute first. /// - public int Order { get; } - - public FshModuleAttribute(Type moduleType, int order = 0) - { - ModuleType = moduleType ?? throw new ArgumentNullException(nameof(moduleType)); - Order = order; - } + public int Order { get; } = order; } \ No newline at end of file diff --git a/src/BuildingBlocks/Web/Modules/ModuleLoader.cs b/src/BuildingBlocks/Web/Modules/ModuleLoader.cs index faa33028e8..0f4a800d11 100644 --- a/src/BuildingBlocks/Web/Modules/ModuleLoader.cs +++ b/src/BuildingBlocks/Web/Modules/ModuleLoader.cs @@ -9,7 +9,7 @@ namespace FSH.Framework.Web.Modules; public static class ModuleLoader { private static readonly List _modules = new(); - private static readonly object _lock = new(); + private static readonly Lock _lock = new(); private static bool _modulesLoaded; public static IHostApplicationBuilder AddModules(this IHostApplicationBuilder builder, params Assembly[] assemblies) diff --git a/src/BuildingBlocks/Web/Observability/Logging/Serilog/HttpRequestContextEnricher.cs b/src/BuildingBlocks/Web/Observability/Logging/Serilog/HttpRequestContextEnricher.cs index ae6c3467f8..da77a20798 100644 --- a/src/BuildingBlocks/Web/Observability/Logging/Serilog/HttpRequestContextEnricher.cs +++ b/src/BuildingBlocks/Web/Observability/Logging/Serilog/HttpRequestContextEnricher.cs @@ -5,28 +5,22 @@ namespace FSH.Framework.Web.Observability.Logging.Serilog; -public class HttpRequestContextEnricher : ILogEventEnricher +public class HttpRequestContextEnricher(IHttpContextAccessor httpContextAccessor) : ILogEventEnricher { - private readonly IHttpContextAccessor _httpContextAccessor; - - public HttpRequestContextEnricher(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) { ArgumentNullException.ThrowIfNull(logEvent); ArgumentNullException.ThrowIfNull(propertyFactory); // Get HttpContext properties here - var httpContext = _httpContextAccessor.HttpContext; + var httpContext = httpContextAccessor.HttpContext; if (httpContext != null) { // Add properties to the log event based on HttpContext logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("RequestMethod", httpContext.Request.Method)); logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("RequestPath", httpContext.Request.Path)); - logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserAgent", httpContext.Request.Headers["User-Agent"])); + logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("UserAgent", httpContext.Request.Headers.UserAgent)); if (httpContext.User?.Identity?.IsAuthenticated == true) { diff --git a/src/BuildingBlocks/Web/Observability/OpenTelemetry/MediatorTracingBehavior.cs b/src/BuildingBlocks/Web/Observability/OpenTelemetry/MediatorTracingBehavior.cs index 4df47cd394..2d090775ef 100644 --- a/src/BuildingBlocks/Web/Observability/OpenTelemetry/MediatorTracingBehavior.cs +++ b/src/BuildingBlocks/Web/Observability/OpenTelemetry/MediatorTracingBehavior.cs @@ -6,29 +6,19 @@ namespace FSH.Framework.Web.Observability.OpenTelemetry; /// /// Emits spans around Mediator commands/queries to improve trace visibility. /// -public sealed class MediatorTracingBehavior : IPipelineBehavior +public sealed class MediatorTracingBehavior(ActivitySource activitySource) : IPipelineBehavior where TMessage : IMessage { - private readonly ActivitySource _activitySource; - - public MediatorTracingBehavior(ActivitySource activitySource) - { - _activitySource = activitySource; - } - public async ValueTask Handle(TMessage message, MessageHandlerDelegate next, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(message); ArgumentNullException.ThrowIfNull(next); - using var activity = _activitySource.StartActivity( + using var activity = activitySource.StartActivity( $"Mediator {typeof(TMessage).Name}", ActivityKind.Internal); - if (activity is not null) - { - activity.SetTag("mediator.request_type", typeof(TMessage).FullName); - } + activity?.SetTag("mediator.request_type", typeof(TMessage).FullName); try { diff --git a/src/BuildingBlocks/Web/Security/SecurityHeadersMiddleware.cs b/src/BuildingBlocks/Web/Security/SecurityHeadersMiddleware.cs index 7b54aadf1c..adb90c9dd5 100644 --- a/src/BuildingBlocks/Web/Security/SecurityHeadersMiddleware.cs +++ b/src/BuildingBlocks/Web/Security/SecurityHeadersMiddleware.cs @@ -26,14 +26,14 @@ public Task InvokeAsync(HttpContext context) var headers = context.Response.Headers; - headers["X-Content-Type-Options"] = "nosniff"; - headers["X-Frame-Options"] = "DENY"; + headers.XContentTypeOptions = "nosniff"; + headers.XFrameOptions = "DENY"; headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; - headers["X-XSS-Protection"] = "0"; + headers.XXSSProtection = "0"; if (context.Request.IsHttps) { - headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"; + headers.StrictTransportSecurity = "max-age=31536000; includeSubDomains"; } if (!headers.ContainsKey("Content-Security-Policy")) @@ -50,7 +50,7 @@ public Task InvokeAsync(HttpContext context) "frame-ancestors 'none'; " + "base-uri 'self';"; - headers["Content-Security-Policy"] = csp; + headers.ContentSecurityPolicy = csp; } return next(context); diff --git a/src/BuildingBlocks/Web/Sse/SseConnectionManager.cs b/src/BuildingBlocks/Web/Sse/SseConnectionManager.cs index 298a12c1e1..a0d09d5eb1 100644 --- a/src/BuildingBlocks/Web/Sse/SseConnectionManager.cs +++ b/src/BuildingBlocks/Web/Sse/SseConnectionManager.cs @@ -8,16 +8,10 @@ namespace FSH.Framework.Web.Sse; /// Manages active SSE connections per user. Supports targeted sends (by userId) /// and tenant-wide broadcasts. Thread-safe via ConcurrentDictionary. /// -public sealed class SseConnectionManager +public sealed class SseConnectionManager(ILogger logger) { private readonly ConcurrentDictionary> _connections = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _userTenantMap = new(StringComparer.Ordinal); - private readonly ILogger _logger; - - public SseConnectionManager(ILogger logger) - { - _logger = logger; - } /// /// Creates a channel for the user and returns a reader to consume events. @@ -37,9 +31,9 @@ public ChannelReader Connect(string userId, string? tenantId = null) _userTenantMap[userId] = tenantId; } - if (_logger.IsEnabled(LogLevel.Debug)) + if (logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("SSE client connected: {UserId} (tenant: {TenantId})", userId, tenantId ?? "none"); + logger.LogDebug("SSE client connected: {UserId} (tenant: {TenantId})", userId, tenantId ?? "none"); } return channel.Reader; @@ -55,9 +49,9 @@ public void Disconnect(string userId) channel.Writer.TryComplete(); _userTenantMap.TryRemove(userId, out _); - if (_logger.IsEnabled(LogLevel.Debug)) + if (logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug("SSE client disconnected: {UserId}", userId); + logger.LogDebug("SSE client disconnected: {UserId}", userId); } } } diff --git a/src/BuildingBlocks/Web/Web.csproj b/src/BuildingBlocks/Web/Web.csproj index 2787be8487..6ffed302b2 100644 --- a/src/BuildingBlocks/Web/Web.csproj +++ b/src/BuildingBlocks/Web/Web.csproj @@ -4,7 +4,7 @@ FSH.Framework.Web FSH.Framework.Web FullStackHero.Framework.Web - $(NoWarn);CA1805;CA1307;CA1308;S1854;CA1812;CA1305;CA2000 + $(NoWarn);CA1805;CA1307;CA1308;S1854;CA1812;CA1305;CA2000;NU1507 diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index b2c2e34f38..f8ef5c70c2 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -9,22 +9,22 @@ true - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - + + - - + + - - + + @@ -53,7 +53,7 @@ - + @@ -84,16 +84,16 @@ - + - + - + @@ -106,7 +106,7 @@ - + @@ -116,4 +116,4 @@ - + \ No newline at end of file diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnvelope.cs b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnvelope.cs index 0aaab8945c..390527a4dd 100644 --- a/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnvelope.cs +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/AuditEnvelope.cs @@ -6,60 +6,41 @@ namespace FSH.Modules.Auditing.Contracts; /// Concrete event instance ready to be published/persisted. /// Carries normalized metadata + strongly-typed Payload. /// -public sealed class AuditEnvelope : IAuditEvent +public sealed class AuditEnvelope( + Guid id, + DateTime occurredAtUtc, + DateTime receivedAtUtc, + AuditEventType eventType, + AuditSeverity severity, + string? tenantId, + string? userId, + string? userName, + string? traceId, + string? spanId, + string? correlationId, + string? requestId, + string? source, + AuditTag tags, + object payload) : IAuditEvent { - public Guid Id { get; } - public DateTime OccurredAtUtc { get; } - public DateTime ReceivedAtUtc { get; } + public Guid Id { get; } = id; + public DateTime OccurredAtUtc { get; } = occurredAtUtc.Kind == DateTimeKind.Utc ? occurredAtUtc : occurredAtUtc.ToUniversalTime(); + public DateTime ReceivedAtUtc { get; } = receivedAtUtc.Kind == DateTimeKind.Utc ? receivedAtUtc : receivedAtUtc.ToUniversalTime(); - public AuditEventType EventType { get; } - public AuditSeverity Severity { get; } + public AuditEventType EventType { get; } = eventType; + public AuditSeverity Severity { get; } = severity; - public string? TenantId { get; } - public string? UserId { get; } - public string? UserName { get; } + public string? TenantId { get; } = tenantId; + public string? UserId { get; } = userId; + public string? UserName { get; } = userName; - public string? TraceId { get; } - public string? SpanId { get; } - public string? CorrelationId { get; } - public string? RequestId { get; } - public string? Source { get; } + public string? TraceId { get; } = traceId ?? Activity.Current?.TraceId.ToString(); + public string? SpanId { get; } = spanId ?? Activity.Current?.SpanId.ToString(); + public string? CorrelationId { get; } = correlationId; + public string? RequestId { get; } = requestId; + public string? Source { get; } = source; - public AuditTag Tags { get; } + public AuditTag Tags { get; } = tags; - public object Payload { get; } - - public AuditEnvelope( - Guid id, - DateTime occurredAtUtc, - DateTime receivedAtUtc, - AuditEventType eventType, - AuditSeverity severity, - string? tenantId, - string? userId, - string? userName, - string? traceId, - string? spanId, - string? correlationId, - string? requestId, - string? source, - AuditTag tags, - object payload) - { - Id = id; - OccurredAtUtc = occurredAtUtc.Kind == DateTimeKind.Utc ? occurredAtUtc : occurredAtUtc.ToUniversalTime(); - ReceivedAtUtc = receivedAtUtc.Kind == DateTimeKind.Utc ? receivedAtUtc : receivedAtUtc.ToUniversalTime(); - EventType = eventType; - Severity = severity; - TenantId = tenantId; - UserId = userId; - UserName = userName; - TraceId = traceId ?? Activity.Current?.TraceId.ToString(); - SpanId = spanId ?? Activity.Current?.SpanId.ToString(); - CorrelationId = correlationId; - RequestId = requestId; - Source = source; - Tags = tags; - Payload = payload ?? throw new ArgumentNullException(nameof(payload)); - } + public object Payload { get; } = payload ?? throw new ArgumentNullException(nameof(payload)); } \ No newline at end of file diff --git a/src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj b/src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj index 1a67480ec3..f59ceaf64f 100644 --- a/src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj +++ b/src/Modules/Auditing/Modules.Auditing.Contracts/Modules.Auditing.Contracts.csproj @@ -4,7 +4,7 @@ FSH.Modules.Auditing.Contracts FSH.Modules.Auditing.Contracts FullStackHero.Modules.Auditing.Contracts - $(NoWarn);S2094 + $(NoWarn);S2094;NU1507 diff --git a/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs b/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs index 3931c69c66..a347816b93 100644 --- a/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs +++ b/src/Modules/Auditing/Modules.Auditing/Core/AuditBackgroundWorker.cs @@ -8,32 +8,19 @@ namespace FSH.Modules.Auditing; /// /// Drains the channel and writes to the configured sink in batches. /// -public sealed class AuditBackgroundWorker : BackgroundService +public sealed class AuditBackgroundWorker( + ChannelAuditPublisher publisher, + IAuditSink sink, + ILogger logger, + int batchSize = 200, + int flushIntervalMs = 1000) : BackgroundService { - private readonly ChannelAuditPublisher _publisher; - private readonly IAuditSink _sink; - private readonly ILogger _logger; - - private readonly int _batchSize; - private readonly TimeSpan _flushInterval; - - public AuditBackgroundWorker( - ChannelAuditPublisher publisher, - IAuditSink sink, - ILogger logger, - int batchSize = 200, - int flushIntervalMs = 1000) - { - _publisher = publisher; - _sink = sink; - _logger = logger; - _batchSize = Math.Max(1, batchSize); - _flushInterval = TimeSpan.FromMilliseconds(Math.Max(50, flushIntervalMs)); - } + private readonly int _batchSize = Math.Max(1, batchSize); + private readonly TimeSpan _flushInterval = TimeSpan.FromMilliseconds(Math.Max(50, flushIntervalMs)); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var reader = _publisher.Reader; + var reader = publisher.Reader; var batch = new List(_batchSize); var delayTask = Task.Delay(_flushInterval, stoppingToken); @@ -56,7 +43,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) { - _logger.LogError(ex, "Audit background worker crashed."); + logger.LogError(ex, "Audit background worker crashed."); } await FinalFlushAsync(batch, stoppingToken); @@ -107,11 +94,11 @@ private async Task FinalFlushAsync(List batch, CancellationToken { try { - await _sink.WriteAsync(batch, stoppingToken); + await sink.WriteAsync(batch, stoppingToken); } catch (Exception ex) { - _logger.LogError(ex, "Final audit flush failed."); + logger.LogError(ex, "Final audit flush failed."); } } } @@ -120,11 +107,11 @@ private async Task FlushAsync(List batch, CancellationToken ct) { try { - await _sink.WriteAsync(batch, ct); + await sink.WriteAsync(batch, ct); } catch (Exception ex) { - _logger.LogError(ex, "Audit background flush failed."); + logger.LogError(ex, "Audit background flush failed."); await Task.Delay(250, ct); } finally diff --git a/src/Modules/Auditing/Modules.Auditing/Core/AuditingConfigurator.cs b/src/Modules/Auditing/Modules.Auditing/Core/AuditingConfigurator.cs index 7927d77876..859906d05d 100644 --- a/src/Modules/Auditing/Modules.Auditing/Core/AuditingConfigurator.cs +++ b/src/Modules/Auditing/Modules.Auditing/Core/AuditingConfigurator.cs @@ -4,25 +4,14 @@ namespace FSH.Modules.Auditing; -public sealed class AuditingConfigurator : IHostedService +public sealed class AuditingConfigurator( + IAuditPublisher publisher, + IAuditSerializer serializer, + IEnumerable enrichers) : IHostedService { - private readonly IAuditPublisher _publisher; - private readonly IAuditSerializer _serializer; - private readonly IEnumerable _enrichers; - - public AuditingConfigurator( - IAuditPublisher publisher, - IAuditSerializer serializer, - IEnumerable enrichers) - { - _publisher = publisher; - _serializer = serializer; - _enrichers = enrichers; - } - public Task StartAsync(CancellationToken cancellationToken) { - Audit.Configure(_publisher, _serializer, _enrichers); + Audit.Configure(publisher, serializer, enrichers); return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; diff --git a/src/Modules/Auditing/Modules.Auditing/Core/SecurityAudit.cs b/src/Modules/Auditing/Modules.Auditing/Core/SecurityAudit.cs index 1afc3f5a93..df5b9da340 100644 --- a/src/Modules/Auditing/Modules.Auditing/Core/SecurityAudit.cs +++ b/src/Modules/Auditing/Modules.Auditing/Core/SecurityAudit.cs @@ -2,31 +2,28 @@ namespace FSH.Modules.Auditing; -public sealed class SecurityAudit : ISecurityAudit +public sealed class SecurityAudit(IAuditClient audit) : ISecurityAudit { - private readonly IAuditClient _audit; - public SecurityAudit(IAuditClient audit) => _audit = audit; - public ValueTask LoginSucceededAsync(string userId, string userName, string clientId, string ip, string userAgent, CancellationToken ct = default) - => _audit.WriteSecurityAsync(SecurityAction.LoginSucceeded, + => audit.WriteSecurityAsync(SecurityAction.LoginSucceeded, subjectId: userId, clientId: clientId, authMethod: "Password", reasonCode: "", claims: new Dictionary { ["ip"] = ip, ["userAgent"] = userAgent }, severity: AuditSeverity.Information, source: "Identity", ct); public ValueTask LoginFailedAsync(string subjectIdOrName, string clientId, string reason, string ip, CancellationToken ct = default) - => _audit.WriteSecurityAsync(SecurityAction.LoginFailed, + => audit.WriteSecurityAsync(SecurityAction.LoginFailed, subjectId: subjectIdOrName, clientId: clientId, authMethod: "Password", reasonCode: reason, claims: new Dictionary { ["ip"] = ip }, severity: AuditSeverity.Warning, source: "Identity", ct); public ValueTask TokenIssuedAsync(string userId, string userName, string clientId, string tokenFingerprint, DateTime expiresUtc, CancellationToken ct = default) - => _audit.WriteSecurityAsync(SecurityAction.TokenIssued, + => audit.WriteSecurityAsync(SecurityAction.TokenIssued, subjectId: userId, clientId: clientId, authMethod: "Password", reasonCode: "", claims: new Dictionary { ["fingerprint"] = tokenFingerprint, ["expiresAt"] = expiresUtc }, severity: AuditSeverity.Information, source: "Identity", ct); public ValueTask TokenRevokedAsync(string userId, string clientId, string reason, CancellationToken ct = default) - => _audit.WriteSecurityAsync(SecurityAction.TokenRevoked, + => audit.WriteSecurityAsync(SecurityAction.TokenRevoked, subjectId: userId, clientId: clientId, authMethod: "", reasonCode: reason, claims: null, severity: AuditSeverity.Information, source: "Identity", ct); } \ No newline at end of file diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs index c7bb4232d1..167d421d8f 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditById/GetAuditByIdQueryHandler.cs @@ -9,31 +9,16 @@ namespace FSH.Modules.Auditing.Features.v1.GetAuditById; -public sealed class GetAuditByIdQueryHandler : IQueryHandler +public sealed class GetAuditByIdQueryHandler(AuditDbContext dbContext, ILogger logger) : IQueryHandler { - private readonly AuditDbContext _dbContext; - private readonly ILogger _logger; - - public GetAuditByIdQueryHandler(AuditDbContext dbContext, ILogger logger) - { - _dbContext = dbContext; - _logger = logger; - } - public async ValueTask Handle(GetAuditByIdQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - var record = await _dbContext.AuditRecords + var record = await dbContext.AuditRecords .AsNoTracking() .FirstOrDefaultAsync(a => a.Id == query.Id, cancellationToken) - .ConfigureAwait(false); - - if (record is null) - { - throw new KeyNotFoundException($"Audit record {query.Id} not found."); - } - + .ConfigureAwait(false) ?? throw new KeyNotFoundException($"Audit record {query.Id} not found."); JsonElement payload; try { @@ -42,7 +27,7 @@ public async ValueTask Handle(GetAuditByIdQuery query, Cancellat } catch (JsonException ex) { - _logger.LogWarning(ex, "Failed to parse audit payload JSON for record {AuditId}.", query.Id); + logger.LogWarning(ex, "Failed to parse audit payload JSON for record {AuditId}.", query.Id); payload = JsonDocument.Parse("{}").RootElement.Clone(); } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs index a8d0445cb3..29ab5fa96d 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditSummary/GetAuditSummaryQueryHandler.cs @@ -7,23 +7,50 @@ namespace FSH.Modules.Auditing.Features.v1.GetAuditSummary; -public sealed class GetAuditSummaryQueryHandler : IQueryHandler +public sealed class GetAuditSummaryQueryHandler(AuditDbContext dbContext) : IQueryHandler { - private readonly AuditDbContext _dbContext; - - public GetAuditSummaryQueryHandler(AuditDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask Handle(GetAuditSummaryQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - var audits = ApplyFilters(_dbContext.AuditRecords.AsNoTracking(), query); - var list = await audits.ToListAsync(cancellationToken).ConfigureAwait(false); - - return AggregateRecords(list); + var audits = ApplyFilters(dbContext.AuditRecords.AsNoTracking(), query); + + // Push all aggregation to the database via GROUP BY — avoids loading any rows into memory. + // EF Core DbContext is not thread-safe, so queries are run sequentially. + + var byType = await audits + .GroupBy(a => a.EventType) + .Select(g => new { EventType = g.Key, Count = g.LongCount() }) + .ToDictionaryAsync(g => (AuditEventType)g.EventType, g => g.Count, cancellationToken) + .ConfigureAwait(false); + + var bySeverity = await audits + .GroupBy(a => a.Severity) + .Select(g => new { Severity = g.Key, Count = g.LongCount() }) + .ToDictionaryAsync(g => (AuditSeverity)g.Severity, g => g.Count, cancellationToken) + .ConfigureAwait(false); + + var bySource = await audits + .Where(a => a.Source != null) + .GroupBy(a => a.Source!) + .Select(g => new { Source = g.Key, Count = g.LongCount() }) + .ToDictionaryAsync(g => g.Source, g => g.Count, StringComparer.OrdinalIgnoreCase, cancellationToken) + .ConfigureAwait(false); + + var byTenant = await audits + .Where(a => a.TenantId != null) + .GroupBy(a => a.TenantId!) + .Select(g => new { TenantId = g.Key, Count = g.LongCount() }) + .ToDictionaryAsync(g => g.TenantId, g => g.Count, StringComparer.OrdinalIgnoreCase, cancellationToken) + .ConfigureAwait(false); + + return new AuditSummaryAggregateDto + { + EventsByType = byType, + EventsBySeverity = bySeverity, + EventsBySource = bySource, + EventsByTenant = byTenant + }; } private static IQueryable ApplyFilters(IQueryable audits, GetAuditSummaryQuery query) @@ -45,47 +72,4 @@ private static IQueryable ApplyFilters(IQueryable audi return audits; } - - private static AuditSummaryAggregateDto AggregateRecords(List records) - { - var aggregate = new AuditSummaryAggregateDto(); - - foreach (var record in records) - { - AggregateByType(aggregate, record); - AggregrateBySeverity(aggregate, record); - AggregateBySource(aggregate, record); - AggregateByTenant(aggregate, record); - } - - return aggregate; - } - - private static void AggregateByType(AuditSummaryAggregateDto aggregate, AuditRecord record) - { - var type = (AuditEventType)record.EventType; - aggregate.EventsByType[type] = aggregate.EventsByType.TryGetValue(type, out var c) ? c + 1 : 1; - } - - private static void AggregrateBySeverity(AuditSummaryAggregateDto aggregate, AuditRecord record) - { - var severity = (AuditSeverity)record.Severity; - aggregate.EventsBySeverity[severity] = aggregate.EventsBySeverity.TryGetValue(severity, out var s) ? s + 1 : 1; - } - - private static void AggregateBySource(AuditSummaryAggregateDto aggregate, AuditRecord record) - { - if (!string.IsNullOrWhiteSpace(record.Source)) - { - aggregate.EventsBySource[record.Source] = aggregate.EventsBySource.TryGetValue(record.Source, out var cs) ? cs + 1 : 1; - } - } - - private static void AggregateByTenant(AuditSummaryAggregateDto aggregate, AuditRecord record) - { - if (!string.IsNullOrWhiteSpace(record.TenantId)) - { - aggregate.EventsByTenant[record.TenantId] = aggregate.EventsByTenant.TryGetValue(record.TenantId, out var ct) ? ct + 1 : 1; - } - } -} \ No newline at end of file +} diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs index 1ab9acef76..749ee6f7b9 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAudits/GetAuditsQueryHandler.cs @@ -9,21 +9,21 @@ namespace FSH.Modules.Auditing.Features.v1.GetAudits; -public sealed class GetAuditsQueryHandler : IQueryHandler> +public sealed class GetAuditsQueryHandler(AuditDbContext dbContext) : IQueryHandler> { - private readonly AuditDbContext _dbContext; - - public GetAuditsQueryHandler(AuditDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask> Handle(GetAuditsQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - IQueryable audits = _dbContext.AuditRecords.AsNoTracking(); + IQueryable audits = dbContext.AuditRecords.AsNoTracking(); + + // Apply tenant filter first (indexed) + if (!string.IsNullOrWhiteSpace(query.TenantId)) + { + audits = audits.Where(a => a.TenantId == query.TenantId); + } + // Apply time range filters (indexed with composite index) if (query.FromUtc.HasValue) { audits = audits.Where(a => a.OccurredAtUtc >= query.FromUtc.Value); @@ -34,11 +34,7 @@ public async ValueTask> Handle(GetAuditsQuery que audits = audits.Where(a => a.OccurredAtUtc <= query.ToUtc.Value); } - if (!string.IsNullOrWhiteSpace(query.TenantId)) - { - audits = audits.Where(a => a.TenantId == query.TenantId); - } - + // Apply indexed filters if (!string.IsNullOrWhiteSpace(query.UserId)) { audits = audits.Where(a => a.UserId == query.UserId); @@ -54,12 +50,6 @@ public async ValueTask> Handle(GetAuditsQuery que audits = audits.Where(a => a.Severity == (byte)query.Severity.Value); } - if (query.Tags.HasValue && query.Tags.Value != AuditTag.None) - { - long tagMask = (long)query.Tags.Value; - audits = audits.Where(a => (a.Tags & tagMask) != 0); - } - if (!string.IsNullOrWhiteSpace(query.Source)) { audits = audits.Where(a => a.Source == query.Source); @@ -75,6 +65,13 @@ public async ValueTask> Handle(GetAuditsQuery que audits = audits.Where(a => a.TraceId == query.TraceId); } + if (query.Tags.HasValue && query.Tags.Value != AuditTag.None) + { + long tagMask = (long)query.Tags.Value; + audits = audits.Where(a => (a.Tags & tagMask) != 0); + } + + // Apply search last (most expensive operation) if (!string.IsNullOrWhiteSpace(query.Search)) { string term = query.Search; @@ -86,22 +83,23 @@ public async ValueTask> Handle(GetAuditsQuery que audits = audits.OrderByDescending(a => a.OccurredAtUtc); - IQueryable projected = audits.Select(a => new AuditSummaryDto - { - Id = a.Id, - OccurredAtUtc = a.OccurredAtUtc, - EventType = (AuditEventType)a.EventType, - Severity = (AuditSeverity)a.Severity, - TenantId = a.TenantId, - UserId = a.UserId, - UserName = a.UserName, - TraceId = a.TraceId, - CorrelationId = a.CorrelationId, - RequestId = a.RequestId, - Source = a.Source, - Tags = (AuditTag)a.Tags - }); - - return await projected.ToPagedResponseAsync(query, cancellationToken).ConfigureAwait(false); + return await audits.ToPagedResponseAsync( + a => new AuditSummaryDto + { + Id = a.Id, + OccurredAtUtc = a.OccurredAtUtc, + EventType = (AuditEventType)a.EventType, + Severity = (AuditSeverity)a.Severity, + TenantId = a.TenantId, + UserId = a.UserId, + UserName = a.UserName, + TraceId = a.TraceId, + CorrelationId = a.CorrelationId, + RequestId = a.RequestId, + Source = a.Source, + Tags = (AuditTag)a.Tags + }, + query, + cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryHandler.cs index d409097292..0ac9e0455a 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByCorrelation/GetAuditsByCorrelationQueryHandler.cs @@ -7,20 +7,13 @@ namespace FSH.Modules.Auditing.Features.v1.GetAuditsByCorrelation; -public sealed class GetAuditsByCorrelationQueryHandler : IQueryHandler> +public sealed class GetAuditsByCorrelationQueryHandler(AuditDbContext dbContext) : IQueryHandler> { - private readonly AuditDbContext _dbContext; - - public GetAuditsByCorrelationQueryHandler(AuditDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask> Handle(GetAuditsByCorrelationQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - IQueryable audits = _dbContext.AuditRecords + IQueryable audits = dbContext.AuditRecords .AsNoTracking() .Where(a => a.CorrelationId == query.CorrelationId); diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryHandler.cs index 49a877a845..3aa5dfba06 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetAuditsByTrace/GetAuditsByTraceQueryHandler.cs @@ -7,20 +7,13 @@ namespace FSH.Modules.Auditing.Features.v1.GetAuditsByTrace; -public sealed class GetAuditsByTraceQueryHandler : IQueryHandler> +public sealed class GetAuditsByTraceQueryHandler(AuditDbContext dbContext) : IQueryHandler> { - private readonly AuditDbContext _dbContext; - - public GetAuditsByTraceQueryHandler(AuditDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask> Handle(GetAuditsByTraceQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - IQueryable audits = _dbContext.AuditRecords + IQueryable audits = dbContext.AuditRecords .AsNoTracking() .Where(a => a.TraceId == query.TraceId); diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs index 413f210107..c4af113102 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetExceptionAudits/GetExceptionAuditsQueryHandler.cs @@ -7,15 +7,8 @@ namespace FSH.Modules.Auditing.Features.v1.GetExceptionAudits; -public sealed class GetExceptionAuditsQueryHandler : IQueryHandler> +public sealed class GetExceptionAuditsQueryHandler(AuditDbContext dbContext) : IQueryHandler> { - private readonly AuditDbContext _dbContext; - - public GetExceptionAuditsQueryHandler(AuditDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask> Handle(GetExceptionAuditsQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); @@ -30,7 +23,7 @@ public async ValueTask> Handle(GetExceptionAudits private IQueryable GetBaseQuery() { - return _dbContext.AuditRecords + return dbContext.AuditRecords .AsNoTracking() .Where(a => a.EventType == (int)AuditEventType.Exception); } diff --git a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs index 4b8b9fbdf7..a14983278b 100644 --- a/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs +++ b/src/Modules/Auditing/Modules.Auditing/Features/v1/GetSecurityAudits/GetSecurityAuditsQueryHandler.cs @@ -7,20 +7,13 @@ namespace FSH.Modules.Auditing.Features.v1.GetSecurityAudits; -public sealed class GetSecurityAuditsQueryHandler : IQueryHandler> +public sealed class GetSecurityAuditsQueryHandler(AuditDbContext dbContext) : IQueryHandler> { - private readonly AuditDbContext _dbContext; - - public GetSecurityAuditsQueryHandler(AuditDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask> Handle(GetSecurityAuditsQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - IQueryable audits = _dbContext.AuditRecords + IQueryable audits = dbContext.AuditRecords .AsNoTracking() .Where(a => a.EventType == (int)AuditEventType.Security); diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditResource.cs b/src/Modules/Auditing/Modules.Auditing/Localization/AuditResource.cs new file mode 100644 index 0000000000..2ce42a0cc4 --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Modules.Auditing.Localization; + +#pragma warning disable S2094 +public sealed class AuditResource; +#pragma warning restore S2094 diff --git a/src/Modules/Auditing/Modules.Auditing/Localization/AuditResource.resx b/src/Modules/Auditing/Modules.Auditing/Localization/AuditResource.resx new file mode 100644 index 0000000000..a796cc9a9c --- /dev/null +++ b/src/Modules/Auditing/Modules.Auditing/Localization/AuditResource.resx @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Audit trail created + Audit trail retrieved successfully + Audit trail exported successfully + Audit trail cleared + Entity created + Entity updated + Entity deleted + No audit trails found + Auditing is currently disabled + Auditing is enabled + Retention policy applied + Old audit trails deleted + Compliance report generated + Audit trail archived + Invalid date range specified + diff --git a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj index 00a1e8ffb3..dc75befdf3 100644 --- a/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj +++ b/src/Modules/Auditing/Modules.Auditing/Modules.Auditing.csproj @@ -4,7 +4,7 @@ FSH.Modules.Auditing FSH.Modules.Auditing FullStackHero.Modules.Auditing - $(NoWarn);CA1031;CA1812;CA1859;S3267 + $(NoWarn);CA1031;CA1812;CA1859;S3267;NU1507 diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs index 4893f4b25d..f490b1e624 100644 --- a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditDbContext.cs @@ -8,14 +8,12 @@ namespace FSH.Modules.Auditing.Persistence; -public sealed class AuditDbContext : BaseDbContext +public sealed class AuditDbContext( +IMultiTenantContextAccessor multiTenantContextAccessor, +DbContextOptions options, +IOptions settings, +IHostEnvironment environment) : BaseDbContext(multiTenantContextAccessor, options, settings, environment) { - public AuditDbContext( - IMultiTenantContextAccessor multiTenantContextAccessor, - DbContextOptions options, - IOptions settings, - IHostEnvironment environment) : base(multiTenantContextAccessor, options, settings, environment) { } - public DbSet AuditRecords => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs index eb61189b0d..fd8e12214b 100644 --- a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditRecordConfiguration.cs @@ -6,6 +6,8 @@ namespace FSH.Modules.Auditing.Persistence; public class AuditRecordConfiguration : IEntityTypeConfiguration { + private static readonly string[] JsonbPathOpsOperator = ["jsonb_path_ops"]; + public void Configure(EntityTypeBuilder builder) { ArgumentNullException.ThrowIfNull(builder); @@ -16,8 +18,28 @@ public void Configure(EntityTypeBuilder builder) builder.Property(x => x.Severity).HasConversion(); builder.Property(x => x.Tags).HasConversion(); builder.Property(x => x.PayloadJson).HasColumnType("jsonb"); + + // Individual indexes for common filter columns builder.HasIndex(x => x.TenantId); + builder.HasIndex(x => x.UserId); builder.HasIndex(x => x.EventType); - builder.HasIndex(x => x.OccurredAtUtc); + builder.HasIndex(x => x.Severity); + builder.HasIndex(x => x.Source); + builder.HasIndex(x => x.CorrelationId); + builder.HasIndex(x => x.TraceId); + builder.HasIndex(x => x.Tags); + + // Composite index for time-based queries (most common pattern) + builder.HasIndex(x => new { x.TenantId, x.OccurredAtUtc }) + .IsDescending(false, true); + + // Composite index for user audit tracking + builder.HasIndex(x => new { x.UserId, x.OccurredAtUtc }) + .IsDescending(false, true); + + // GIN index for JSONB full-text search (PostgreSQL specific) + builder.HasIndex(x => x.PayloadJson) + .HasMethod("gin") + .HasAnnotation("Npgsql:IndexOperators", JsonbPathOpsOperator); } } \ No newline at end of file diff --git a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditingSaveChangesInterceptor.cs b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditingSaveChangesInterceptor.cs index 698e85c54c..6a5dd83826 100644 --- a/src/Modules/Auditing/Modules.Auditing/Persistence/AuditingSaveChangesInterceptor.cs +++ b/src/Modules/Auditing/Modules.Auditing/Persistence/AuditingSaveChangesInterceptor.cs @@ -7,17 +7,8 @@ namespace FSH.Modules.Auditing.Persistence; /// /// Captures EF Core entity changes at SaveChanges to produce an EntityChange event. /// -public sealed class AuditingSaveChangesInterceptor : SaveChangesInterceptor +public sealed class AuditingSaveChangesInterceptor(IAuditPublisher publisher, TimeProvider timeProvider) : SaveChangesInterceptor { - private readonly IAuditPublisher _publisher; - private readonly TimeProvider _timeProvider; - - public AuditingSaveChangesInterceptor(IAuditPublisher publisher, TimeProvider timeProvider) - { - _publisher = publisher; - _timeProvider = timeProvider; - } - public override async ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, @@ -49,7 +40,7 @@ public override async ValueTask> SavingChangesAsync( Changes: group.SelectMany(g => g.Changes).ToList(), TransactionId: ctx.Database.CurrentTransaction?.TransactionId.ToString()); - var now = _timeProvider.GetUtcNow().UtcDateTime; + var now = timeProvider.GetUtcNow().UtcDateTime; var env = new AuditEnvelope( id: Guid.CreateVersion7(), occurredAtUtc: now, @@ -62,7 +53,7 @@ public override async ValueTask> SavingChangesAsync( tags: AuditTag.None, payload: payload); - await _publisher.PublishAsync(env, cancellationToken); + await publisher.PublishAsync(env, cancellationToken); } } diff --git a/src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj b/src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj index 7f68fd84db..995f457e10 100644 --- a/src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj +++ b/src/Modules/Identity/Modules.Identity.Contracts/Modules.Identity.Contracts.csproj @@ -4,7 +4,7 @@ FSH.Modules.Identity.Contracts FSH.Modules.Identity.Contracts FullStackHero.Modules.Identity.Contracts - $(NoWarn);CA1002;CA1056;CS1572;S2094 + $(NoWarn);CA1002;CA1056;CS1572;S2094;NU1507 diff --git a/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs index 614a5d079e..52637cf205 100644 --- a/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs +++ b/src/Modules/Identity/Modules.Identity/Authorization/Jwt/ConfigureJwtBearerOptions.cs @@ -41,6 +41,8 @@ public void Configure(string? name, JwtBearerOptions options) options.RequireHttpsMetadata = true; options.SaveToken = false; + options.MapInboundClaims = true; + options.UseSecurityTokenValidators = true; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, diff --git a/src/Modules/Identity/Modules.Identity/Constants/IdentityValidationMessages.cs b/src/Modules/Identity/Modules.Identity/Constants/IdentityValidationMessages.cs new file mode 100644 index 0000000000..f4789bd39b --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Constants/IdentityValidationMessages.cs @@ -0,0 +1,57 @@ +namespace FSH.Modules.Identity.Constants; + +using FSH.Framework.Shared.Localization; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.DependencyInjection; +using System.Globalization; + +/// +/// Validation message helper for the Identity module. +/// Uses SharedResource.resx for localized validation messages. +/// Methods accept an optional IStringLocalizer for runtime localization, falling back to English defaults. +/// +internal static class IdentityValidationMessages +{ + // ── Required — method uses localized format or falls back to default ───────── + public static string Required(string fieldName, IStringLocalizer? localizer = null) + { + var format = localizer?[nameof(Required)] ?? "{0} is required."; + return string.Format(CultureInfo.InvariantCulture, format, fieldName); + } + + // ── Required — non-standard messages that don't fit the field-name pattern ─ + public static string UserIdsRequired(IStringLocalizer? localizer = null) => + localizer?[nameof(UserIdsRequired)] ?? "At least one user ID is required."; + + public static string UserIdsInvalid(IStringLocalizer? localizer = null) => + localizer?[nameof(UserIdsInvalid)] ?? "User IDs cannot be empty or whitespace."; + + // ── Length — methods use localized format '{0} must not exceed {1} characters.' when localized ── + public static string MaxLength(string fieldName, int max, IStringLocalizer? localizer = null) + { + var format = localizer?[nameof(MaxLength)] ?? "{0} must not exceed {1} characters."; + return string.Format(CultureInfo.InvariantCulture, format, fieldName, max); + } + + public static string MinLength(string fieldName, int min, IStringLocalizer? localizer = null) + { + var format = localizer?[nameof(MinLength)] ?? "{0} must be at least {1} characters."; + return string.Format(CultureInfo.InvariantCulture, format, fieldName, min); + } + + // ── Format & rules ──────────────────────────────────────────────────────── + public static string InvalidEmail(IStringLocalizer? localizer = null) => + localizer?[nameof(InvalidEmail)] ?? "A valid email address is required."; + + public static string PasswordsMustMatch(IStringLocalizer? localizer = null) => + localizer?[nameof(PasswordsMustMatch)] ?? "Passwords do not match."; + + public static string NewPasswordMustDiffer(IStringLocalizer? localizer = null) => + localizer?[nameof(NewPasswordMustDiffer)] ?? "New password must be different from the current password."; + + public static string PasswordInHistory(IStringLocalizer? localizer = null) => + localizer?[nameof(PasswordInHistory)] ?? "This password has been used recently. Please choose a different password."; + + public static string ImageConflict(IStringLocalizer? localizer = null) => + localizer?[nameof(ImageConflict)] ?? "You cannot upload a new image and delete the current one simultaneously."; +} \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Domain/Events/PasswordChangedEvent.cs b/src/Modules/Identity/Modules.Identity/Domain/Events/PasswordChangedEvent.cs index c10accce17..d559edf777 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/Events/PasswordChangedEvent.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/Events/PasswordChangedEvent.cs @@ -13,5 +13,5 @@ public sealed record PasswordChangedEvent( ) : DomainEvent(EventId, OccurredOnUtc, CorrelationId, TenantId) { public static PasswordChangedEvent Create(string userId, bool wasReset = false, string? correlationId = null, string? tenantId = null) - => new(Guid.NewGuid(), DateTimeOffset.UtcNow, userId, wasReset, correlationId, tenantId); + => new(Guid.CreateVersion7(), DateTimeOffset.UtcNow, userId, wasReset, correlationId, tenantId); } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Domain/Events/SessionRevokedEvent.cs b/src/Modules/Identity/Modules.Identity/Domain/Events/SessionRevokedEvent.cs index 2cb778c566..b33c45e173 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/Events/SessionRevokedEvent.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/Events/SessionRevokedEvent.cs @@ -15,5 +15,5 @@ public sealed record SessionRevokedEvent( ) : DomainEvent(EventId, OccurredOnUtc, CorrelationId, TenantId) { public static SessionRevokedEvent Create(string userId, Guid sessionId, string? revokedBy = null, string? reason = null, string? correlationId = null, string? tenantId = null) - => new(Guid.NewGuid(), DateTimeOffset.UtcNow, userId, sessionId, revokedBy, reason, correlationId, tenantId); + => new(Guid.CreateVersion7(), DateTimeOffset.UtcNow, userId, sessionId, revokedBy, reason, correlationId, tenantId); } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Domain/Events/UserActivatedEvent.cs b/src/Modules/Identity/Modules.Identity/Domain/Events/UserActivatedEvent.cs index 70093d7b7a..7dc5bdd9ac 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/Events/UserActivatedEvent.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/Events/UserActivatedEvent.cs @@ -13,5 +13,5 @@ public sealed record UserActivatedEvent( ) : DomainEvent(EventId, OccurredOnUtc, CorrelationId, TenantId) { public static UserActivatedEvent Create(string userId, string? activatedBy = null, string? correlationId = null, string? tenantId = null) - => new(Guid.NewGuid(), DateTimeOffset.UtcNow, userId, activatedBy, correlationId, tenantId); + => new(Guid.CreateVersion7(), DateTimeOffset.UtcNow, userId, activatedBy, correlationId, tenantId); } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Domain/Events/UserDeactivatedEvent.cs b/src/Modules/Identity/Modules.Identity/Domain/Events/UserDeactivatedEvent.cs index 82d061db15..63300ba7a0 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/Events/UserDeactivatedEvent.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/Events/UserDeactivatedEvent.cs @@ -14,5 +14,5 @@ public sealed record UserDeactivatedEvent( ) : DomainEvent(EventId, OccurredOnUtc, CorrelationId, TenantId) { public static UserDeactivatedEvent Create(string userId, string? deactivatedBy = null, string? reason = null, string? correlationId = null, string? tenantId = null) - => new(Guid.NewGuid(), DateTimeOffset.UtcNow, userId, deactivatedBy, reason, correlationId, tenantId); + => new(Guid.CreateVersion7(), DateTimeOffset.UtcNow, userId, deactivatedBy, reason, correlationId, tenantId); } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Domain/Events/UserRegisteredEvent.cs b/src/Modules/Identity/Modules.Identity/Domain/Events/UserRegisteredEvent.cs index 85f36b3bef..f7ccf2675e 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/Events/UserRegisteredEvent.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/Events/UserRegisteredEvent.cs @@ -15,5 +15,5 @@ public sealed record UserRegisteredEvent( ) : DomainEvent(EventId, OccurredOnUtc, CorrelationId, TenantId) { public static UserRegisteredEvent Create(string userId, string email, string? firstName = null, string? lastName = null, string? correlationId = null, string? tenantId = null) - => new(Guid.NewGuid(), DateTimeOffset.UtcNow, userId, email, firstName, lastName, correlationId, tenantId); + => new(Guid.CreateVersion7(), DateTimeOffset.UtcNow, userId, email, firstName, lastName, correlationId, tenantId); } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Domain/Events/UserRoleAssignedEvent.cs b/src/Modules/Identity/Modules.Identity/Domain/Events/UserRoleAssignedEvent.cs index f4f2af0a2d..e031257fc5 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/Events/UserRoleAssignedEvent.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/Events/UserRoleAssignedEvent.cs @@ -13,5 +13,5 @@ public sealed record UserRoleAssignedEvent( ) : DomainEvent(EventId, OccurredOnUtc, CorrelationId, TenantId) { public static UserRoleAssignedEvent Create(string userId, IEnumerable assignedRoles, string? correlationId = null, string? tenantId = null) - => new(Guid.NewGuid(), DateTimeOffset.UtcNow, userId, assignedRoles.ToList().AsReadOnly(), correlationId, tenantId); + => new(Guid.CreateVersion7(), DateTimeOffset.UtcNow, userId, assignedRoles.ToList().AsReadOnly(), correlationId, tenantId); } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Domain/Group.cs b/src/Modules/Identity/Modules.Identity/Domain/Group.cs index 67901e2ac4..4fb55f5cae 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/Group.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/Group.cs @@ -31,7 +31,7 @@ public static Group Create(string name, string? description = null, bool isDefau { return new Group { - Id = Guid.NewGuid(), + Id = Guid.CreateVersion7(), Name = name, Description = description, IsDefault = isDefault, diff --git a/src/Modules/Identity/Modules.Identity/Domain/UserSession.cs b/src/Modules/Identity/Modules.Identity/Domain/UserSession.cs index eee4f6983d..5a7f38ac94 100644 --- a/src/Modules/Identity/Modules.Identity/Domain/UserSession.cs +++ b/src/Modules/Identity/Modules.Identity/Domain/UserSession.cs @@ -49,7 +49,7 @@ public static UserSession Create( { return new UserSession { - Id = Guid.NewGuid(), + Id = Guid.CreateVersion7(), UserId = userId, RefreshTokenHash = refreshTokenHash, IpAddress = ipAddress, diff --git a/src/Modules/Identity/Modules.Identity/Events/PasswordChangedLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/PasswordChangedLogHandler.cs new file mode 100644 index 0000000000..aefb9833dd --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Events/PasswordChangedLogHandler.cs @@ -0,0 +1,27 @@ +using FSH.Modules.Identity.Domain.Events; +using Mediator; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Identity.Events; + +/// +/// Logs password change events for security auditing purposes. +/// +public sealed class PasswordChangedLogHandler(ILogger logger) : INotificationHandler +{ + public ValueTask Handle(PasswordChangedEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Password {Action} for user {UserId} in tenant {TenantId}", + notification.WasReset ? "reset" : "changed", + notification.UserId, + notification.TenantId ?? "root"); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Events/SessionRevokedLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/SessionRevokedLogHandler.cs new file mode 100644 index 0000000000..10f3dd3e07 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Events/SessionRevokedLogHandler.cs @@ -0,0 +1,28 @@ +using FSH.Modules.Identity.Domain.Events; +using Mediator; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Identity.Events; + +/// +/// Logs session revocation events for security auditing purposes. +/// +public sealed class SessionRevokedLogHandler(ILogger logger) : INotificationHandler +{ + public ValueTask Handle(SessionRevokedEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Session {SessionId} revoked for user {UserId} in tenant {TenantId}. Reason: {Reason}", + notification.SessionId, + notification.UserId, + notification.TenantId ?? "root", + notification.RevokedBy); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs index 4a18288ee4..28ae3434ef 100644 --- a/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Events/TokenGeneratedLogHandler.cs @@ -8,23 +8,16 @@ namespace FSH.Modules.Identity.Events; /// Example handler that logs when a token is generated. /// This is primarily intended to make it easier to test the integration event pipeline. /// -public sealed class TokenGeneratedLogHandler - : IIntegrationEventHandler +public sealed class TokenGeneratedLogHandler(ILogger logger) + : IIntegrationEventHandler { - private readonly ILogger _logger; - - public TokenGeneratedLogHandler(ILogger logger) - { - _logger = logger; - } - public Task HandleAsync(TokenGeneratedIntegrationEvent @event, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(@event); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation( + logger.LogInformation( "Token generated for user {UserId} ({Email}) with client {ClientId}, IP {IpAddress}, UserAgent {UserAgent}, expires at {ExpiresAtUtc} (fingerprint: {Fingerprint})", @event.UserId, @event.Email, diff --git a/src/Modules/Identity/Modules.Identity/Events/UserActivatedLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserActivatedLogHandler.cs new file mode 100644 index 0000000000..58a0db2c35 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Events/UserActivatedLogHandler.cs @@ -0,0 +1,26 @@ +using FSH.Modules.Identity.Domain.Events; +using Mediator; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Identity.Events; + +/// +/// Logs user activation events for auditing purposes. +/// +public sealed class UserActivatedLogHandler(ILogger logger) : INotificationHandler +{ + public ValueTask Handle(UserActivatedEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "User {UserId} activated in tenant {TenantId}", + notification.UserId, + notification.TenantId ?? "root"); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Events/UserDeactivatedLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserDeactivatedLogHandler.cs new file mode 100644 index 0000000000..bfeba1e6e4 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Events/UserDeactivatedLogHandler.cs @@ -0,0 +1,26 @@ +using FSH.Modules.Identity.Domain.Events; +using Mediator; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Identity.Events; + +/// +/// Logs user deactivation events for auditing purposes. +/// +public sealed class UserDeactivatedLogHandler(ILogger logger) : INotificationHandler +{ + public ValueTask Handle(UserDeactivatedEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "User {UserId} deactivated in tenant {TenantId}", + notification.UserId, + notification.TenantId ?? "root"); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs index 4b40673004..115a2af013 100644 --- a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredEmailHandler.cs @@ -8,16 +8,9 @@ namespace FSH.Modules.Identity.Events; /// /// Sends a welcome email when a new user registers. /// -public sealed class UserRegisteredEmailHandler - : IIntegrationEventHandler +public sealed class UserRegisteredEmailHandler(IMailService mailService) + : IIntegrationEventHandler { - private readonly IMailService _mailService; - - public UserRegisteredEmailHandler(IMailService mailService) - { - _mailService = mailService; - } - public async Task HandleAsync(UserRegisteredIntegrationEvent @event, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(@event); @@ -32,6 +25,6 @@ public async Task HandleAsync(UserRegisteredIntegrationEvent @event, Cancellatio subject: "Welcome!", body: $"Hi {@event.FirstName}, thanks for registering."); - await _mailService.SendAsync(mail, ct).ConfigureAwait(false); + await mailService.SendAsync(mail, ct).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Events/UserRegisteredLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredLogHandler.cs new file mode 100644 index 0000000000..c608f6e1b5 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Events/UserRegisteredLogHandler.cs @@ -0,0 +1,29 @@ +using FSH.Modules.Identity.Domain.Events; +using Mediator; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Identity.Events; + +/// +/// Logs user registration events for auditing purposes. +/// Note: This is a domain event handler. There's also UserRegisteredEmailHandler +/// which handles the integration event for cross-module communication. +/// +public sealed class UserRegisteredLogHandler(ILogger logger) : INotificationHandler +{ + public ValueTask Handle(UserRegisteredEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "User {UserId} ({Email}) registered in tenant {TenantId}", + notification.UserId, + notification.Email, + notification.TenantId ?? "root"); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Events/UserRoleAssignedLogHandler.cs b/src/Modules/Identity/Modules.Identity/Events/UserRoleAssignedLogHandler.cs new file mode 100644 index 0000000000..bd4e66e93e --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Events/UserRoleAssignedLogHandler.cs @@ -0,0 +1,28 @@ +using FSH.Modules.Identity.Domain.Events; +using Mediator; +using Microsoft.Extensions.Logging; + +namespace FSH.Modules.Identity.Events; + +/// +/// Logs user role assignment events for auditing purposes. +/// +public sealed class UserRoleAssignedLogHandler(ILogger logger) : INotificationHandler +{ + public ValueTask Handle(UserRoleAssignedEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + + if (logger.IsEnabled(LogLevel.Information)) + { + var roles = string.Join(", ", notification.AssignedRoles); + logger.LogInformation( + "Roles assigned to user {UserId} in tenant {TenantId}: {Roles}", + notification.UserId, + notification.TenantId ?? "root", + roles); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs index fe501d50f2..62c675c1fe 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandHandler.cs @@ -8,23 +8,14 @@ namespace FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; -public sealed class AddUsersToGroupCommandHandler : ICommandHandler +public sealed class AddUsersToGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) : ICommandHandler { - private readonly IdentityDbContext _dbContext; - private readonly ICurrentUser _currentUser; - - public AddUsersToGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) - { - _dbContext = dbContext; - _currentUser = currentUser; - } - public async ValueTask Handle(AddUsersToGroupCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); // Validate group exists - var groupExists = await _dbContext.Groups + var groupExists = await dbContext.Groups .AnyAsync(g => g.Id == command.GroupId, cancellationToken); if (!groupExists) @@ -33,7 +24,7 @@ public async ValueTask Handle(AddUsersToGroupCommand co } // Validate user IDs exist - var existingUserIds = await _dbContext.Users + var existingUserIds = await dbContext.Users .Where(u => command.UserIds.Contains(u.Id)) .Select(u => u.Id) .ToListAsync(cancellationToken); @@ -45,7 +36,7 @@ public async ValueTask Handle(AddUsersToGroupCommand co } // Get existing memberships - var existingMemberships = await _dbContext.UserGroups + var existingMemberships = await dbContext.UserGroups .Where(ug => ug.GroupId == command.GroupId && command.UserIds.Contains(ug.UserId)) .Select(ug => ug.UserId) .ToListAsync(cancellationToken); @@ -54,13 +45,13 @@ public async ValueTask Handle(AddUsersToGroupCommand co var usersToAdd = command.UserIds.Except(existingMemberships).ToList(); // Add new memberships - var currentUserId = _currentUser.GetUserId().ToString(); + var currentUserId = currentUser.GetUserId().ToString(); foreach (var userId in usersToAdd) { - _dbContext.UserGroups.Add(UserGroup.Create(userId, command.GroupId, currentUserId)); + dbContext.UserGroups.Add(UserGroup.Create(userId, command.GroupId, currentUserId)); } - await _dbContext.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); return new AddUsersToGroupResponse(usersToAdd.Count, alreadyMemberUserIds); } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs index e90a9a564d..6e5c008f3f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/AddUsersToGroup/AddUsersToGroupCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Groups.AddUsersToGroup; namespace FSH.Modules.Identity.Features.v1.Groups.AddUsersToGroup; @@ -8,11 +9,13 @@ public sealed class AddUsersToGroupCommandValidator : AbstractValidator x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty() + .WithMessage(IdentityValidationMessages.Required("Group ID")); RuleFor(x => x.UserIds) - .NotEmpty().WithMessage("At least one user ID is required.") + .NotEmpty() + .WithMessage(IdentityValidationMessages.UserIdsRequired()) .Must(ids => ids.All(id => !string.IsNullOrWhiteSpace(id))) - .WithMessage("User IDs cannot be empty or whitespace."); + .WithMessage(IdentityValidationMessages.UserIdsInvalid()); } -} \ No newline at end of file +} diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs index d0e1493737..7b5d6ee7e8 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandHandler.cs @@ -9,23 +9,14 @@ namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; -public sealed class CreateGroupCommandHandler : ICommandHandler +public sealed class CreateGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) : ICommandHandler { - private readonly IdentityDbContext _dbContext; - private readonly ICurrentUser _currentUser; - - public CreateGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) - { - _dbContext = dbContext; - _currentUser = currentUser; - } - public async ValueTask Handle(CreateGroupCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); // Validate name is unique within tenant - var nameExists = await _dbContext.Groups + var nameExists = await dbContext.Groups .AnyAsync(g => g.Name == command.Name, cancellationToken); if (nameExists) @@ -37,7 +28,7 @@ public async ValueTask Handle(CreateGroupCommand command, Cancellation List<(string Id, string Name)> resolvedRoles = []; if (command.RoleIds is { Count: > 0 }) { - var rawRoles = await _dbContext.Roles + var rawRoles = await dbContext.Roles .Where(r => command.RoleIds.Contains(r.Id)) .Select(r => new { r.Id, r.Name }) .ToListAsync(cancellationToken); @@ -55,16 +46,16 @@ public async ValueTask Handle(CreateGroupCommand command, Cancellation description: command.Description, isDefault: command.IsDefault, isSystemGroup: false, - createdBy: _currentUser.GetUserId().ToString()); + createdBy: currentUser.GetUserId().ToString()); // Add role assignments foreach (var role in resolvedRoles) { - _dbContext.GroupRoles.Add(GroupRole.Create(group.Id, role.Item1)); + dbContext.GroupRoles.Add(GroupRole.Create(group.Id, role.Item1)); } - _dbContext.Groups.Add(group); - await _dbContext.SaveChangesAsync(cancellationToken); + dbContext.Groups.Add(group); + await dbContext.SaveChangesAsync(cancellationToken); return new GroupDto { diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs index 42a8dd668e..e791c9ecfb 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/CreateGroup/CreateGroupCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Groups.CreateGroup; namespace FSH.Modules.Identity.Features.v1.Groups.CreateGroup; @@ -8,10 +9,10 @@ public sealed class CreateGroupCommandValidator : AbstractValidator x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Group name")) + .MaximumLength(256).WithMessage(IdentityValidationMessages.MaxLength("Group name", 256)); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(IdentityValidationMessages.MaxLength("Description", 1024)); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs index 544d3fbbac..9de4912ea9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandHandler.cs @@ -7,22 +7,13 @@ namespace FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; -public sealed class DeleteGroupCommandHandler : ICommandHandler +public sealed class DeleteGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) : ICommandHandler { - private readonly IdentityDbContext _dbContext; - private readonly ICurrentUser _currentUser; - - public DeleteGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) - { - _dbContext = dbContext; - _currentUser = currentUser; - } - public async ValueTask Handle(DeleteGroupCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - var group = await _dbContext.Groups + var group = await dbContext.Groups .FirstOrDefaultAsync(g => g.Id == command.Id, cancellationToken) ?? throw new NotFoundException($"Group with ID '{command.Id}' not found."); @@ -32,9 +23,9 @@ public async ValueTask Handle(DeleteGroupCommand command, CancellationToke } // Soft delete via domain method - group.Delete(_currentUser.GetUserId().ToString()); + group.Delete(currentUser.GetUserId().ToString()); - await _dbContext.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); return Unit.Value; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs index 4805b8769a..c3bba462e3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/DeleteGroup/DeleteGroupCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Groups.DeleteGroup; namespace FSH.Modules.Identity.Features.v1.Groups.DeleteGroup; @@ -8,6 +9,6 @@ public sealed class DeleteGroupCommandValidator : AbstractValidator x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Group ID")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs index df604aeac9..99ac87f6aa 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupById/GetGroupByIdQueryHandler.cs @@ -7,29 +7,22 @@ namespace FSH.Modules.Identity.Features.v1.Groups.GetGroupById; -public sealed class GetGroupByIdQueryHandler : IQueryHandler +public sealed class GetGroupByIdQueryHandler(IdentityDbContext dbContext) : IQueryHandler { - private readonly IdentityDbContext _dbContext; - - public GetGroupByIdQueryHandler(IdentityDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask Handle(GetGroupByIdQuery query, CancellationToken cancellationToken) { - var group = await _dbContext.Groups + var group = await dbContext.Groups .AsNoTracking() .Include(g => g.GroupRoles) .FirstOrDefaultAsync(g => g.Id == query.Id, cancellationToken) ?? throw new NotFoundException($"Group with ID '{query.Id}' not found."); - var memberCount = await _dbContext.UserGroups + var memberCount = await dbContext.UserGroups .CountAsync(ug => ug.GroupId == group.Id, cancellationToken); var roleIds = group.GroupRoles.Select(gr => gr.RoleId).ToList(); var roleNames = roleIds.Count > 0 - ? await _dbContext.Roles + ? await dbContext.Roles .AsNoTracking() .Where(r => roleIds.Contains(r.Id)) .Select(r => r.Name!) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs index 022f203cc8..d3e1a094e4 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroupMembers/GetGroupMembersQueryHandler.cs @@ -7,19 +7,12 @@ namespace FSH.Modules.Identity.Features.v1.Groups.GetGroupMembers; -public sealed class GetGroupMembersQueryHandler : IQueryHandler> +public sealed class GetGroupMembersQueryHandler(IdentityDbContext dbContext) : IQueryHandler> { - private readonly IdentityDbContext _dbContext; - - public GetGroupMembersQueryHandler(IdentityDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask> Handle(GetGroupMembersQuery query, CancellationToken cancellationToken) { // Validate group exists - var groupExists = await _dbContext.Groups + var groupExists = await dbContext.Groups .AnyAsync(g => g.Id == query.GroupId, cancellationToken); if (!groupExists) @@ -28,11 +21,11 @@ public async ValueTask> Handle(GetGroupMembersQuery } // Get memberships with user info - var memberships = await _dbContext.UserGroups + var memberships = await dbContext.UserGroups .AsNoTracking() .Where(ug => ug.GroupId == query.GroupId) .Join( - _dbContext.Users, + dbContext.Users, ug => ug.UserId, u => u.Id, (ug, u) => new GroupMemberDto diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsQueryHandler.cs index a134ce3b23..614738aeff 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/GetGroups/GetGroupsQueryHandler.cs @@ -6,18 +6,11 @@ namespace FSH.Modules.Identity.Features.v1.Groups.GetGroups; -public sealed class GetGroupsQueryHandler : IQueryHandler> +public sealed class GetGroupsQueryHandler(IdentityDbContext dbContext) : IQueryHandler> { - private readonly IdentityDbContext _dbContext; - - public GetGroupsQueryHandler(IdentityDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask> Handle(GetGroupsQuery query, CancellationToken cancellationToken) { - var groupsQuery = _dbContext.Groups + var groupsQuery = dbContext.Groups .AsNoTracking() .Include(g => g.GroupRoles) .AsQueryable(); @@ -37,7 +30,7 @@ public async ValueTask> Handle(GetGroupsQuery query, Cance // Get member counts in one query var groupIds = groups.Select(g => g.Id).ToList(); - var memberCounts = await _dbContext.UserGroups + var memberCounts = await dbContext.UserGroups .Where(ug => groupIds.Contains(ug.GroupId)) .GroupBy(ug => ug.GroupId) .Select(g => new { GroupId = g.Key, Count = g.Count() }) @@ -49,7 +42,7 @@ public async ValueTask> Handle(GetGroupsQuery query, Cance .Distinct() .ToList(); - var roleNames = await _dbContext.Roles + var roleNames = await dbContext.Roles .AsNoTracking() .Where(r => allRoleIds.Contains(r.Id)) .ToDictionaryAsync(r => r.Id, r => r.Name!, cancellationToken); diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs index 505bd4639b..1512c49788 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandHandler.cs @@ -6,29 +6,16 @@ namespace FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; -public sealed class RemoveUserFromGroupCommandHandler : ICommandHandler +public sealed class RemoveUserFromGroupCommandHandler(IdentityDbContext dbContext) : ICommandHandler { - private readonly IdentityDbContext _dbContext; - - public RemoveUserFromGroupCommandHandler(IdentityDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask Handle(RemoveUserFromGroupCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - var membership = await _dbContext.UserGroups - .FirstOrDefaultAsync(ug => ug.GroupId == command.GroupId && ug.UserId == command.UserId, cancellationToken); - - if (membership is null) - { - throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'."); - } - - _dbContext.UserGroups.Remove(membership); - await _dbContext.SaveChangesAsync(cancellationToken); + var membership = await dbContext.UserGroups + .FirstOrDefaultAsync(ug => ug.GroupId == command.GroupId && ug.UserId == command.UserId, cancellationToken) ?? throw new NotFoundException($"User '{command.UserId}' is not a member of group '{command.GroupId}'."); + dbContext.UserGroups.Remove(membership); + await dbContext.SaveChangesAsync(cancellationToken); return Unit.Value; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs index da5ce2bd3d..049ae7694c 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/RemoveUserFromGroup/RemoveUserFromGroupCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Groups.RemoveUserFromGroup; namespace FSH.Modules.Identity.Features.v1.Groups.RemoveUserFromGroup; @@ -8,9 +9,9 @@ public sealed class RemoveUserFromGroupCommandValidator : AbstractValidator x.GroupId) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Group ID")); RuleFor(x => x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("User ID")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs index 483a7763d8..c239933110 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandHandler.cs @@ -9,17 +9,8 @@ namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; -public sealed class UpdateGroupCommandHandler : ICommandHandler +public sealed class UpdateGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) : ICommandHandler { - private readonly IdentityDbContext _dbContext; - private readonly ICurrentUser _currentUser; - - public UpdateGroupCommandHandler(IdentityDbContext dbContext, ICurrentUser currentUser) - { - _dbContext = dbContext; - _currentUser = currentUser; - } - public async ValueTask Handle(UpdateGroupCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); @@ -28,19 +19,19 @@ public async ValueTask Handle(UpdateGroupCommand command, Cancellation await ValidateUniqueNameAsync(command.Id, command.Name, cancellationToken); await ValidateRoleIdsAsync(command.RoleIds, cancellationToken); - var userId = _currentUser.GetUserId().ToString(); + var userId = currentUser.GetUserId().ToString(); group.Update(command.Name, command.Description, userId); group.SetAsDefault(command.IsDefault, userId); var newRoleIds = UpdateRoleAssignments(group, command.RoleIds); - await _dbContext.SaveChangesAsync(cancellationToken); + await dbContext.SaveChangesAsync(cancellationToken); return await BuildResponseAsync(group, newRoleIds, cancellationToken); } private async Task GetGroupAsync(Guid id, CancellationToken cancellationToken) { - return await _dbContext.Groups + return await dbContext.Groups .Include(g => g.GroupRoles) .FirstOrDefaultAsync(g => g.Id == id, cancellationToken) ?? throw new NotFoundException($"Group with ID '{id}' not found."); @@ -48,7 +39,7 @@ private async Task GetGroupAsync(Guid id, CancellationToken cancellationT private async Task ValidateUniqueNameAsync(Guid excludeId, string name, CancellationToken cancellationToken) { - var nameExists = await _dbContext.Groups + var nameExists = await dbContext.Groups .AnyAsync(g => g.Name == name && g.Id != excludeId, cancellationToken); if (nameExists) @@ -64,7 +55,7 @@ private async Task ValidateRoleIdsAsync(IReadOnlyList? roleIds, Cancella return; } - var existingRoleIds = await _dbContext.Roles + var existingRoleIds = await dbContext.Roles .Where(r => roleIds.Contains(r.Id)) .Select(r => r.Id) .ToListAsync(cancellationToken); @@ -97,11 +88,11 @@ private static HashSet UpdateRoleAssignments(Group group, IReadOnlyList< private async Task BuildResponseAsync(Group group, HashSet roleIds, CancellationToken cancellationToken) { - var memberCount = await _dbContext.UserGroups + var memberCount = await dbContext.UserGroups .CountAsync(ug => ug.GroupId == group.Id, cancellationToken); var roleNames = roleIds.Count > 0 - ? await _dbContext.Roles + ? await dbContext.Roles .Where(r => roleIds.Contains(r.Id)) .Select(r => r.Name!) .ToListAsync(cancellationToken) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs index 4c111e0c05..f414182484 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Groups/UpdateGroup/UpdateGroupCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Groups.UpdateGroup; namespace FSH.Modules.Identity.Features.v1.Groups.UpdateGroup; @@ -8,13 +9,13 @@ public sealed class UpdateGroupCommandValidator : AbstractValidator x.Id) - .NotEmpty().WithMessage("Group ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Group ID")); RuleFor(x => x.Name) - .NotEmpty().WithMessage("Group name is required.") - .MaximumLength(256).WithMessage("Group name must not exceed 256 characters."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Group name")) + .MaximumLength(256).WithMessage(IdentityValidationMessages.MaxLength("Group name", 256)); RuleFor(x => x.Description) - .MaximumLength(1024).WithMessage("Description must not exceed 1024 characters."); + .MaximumLength(1024).WithMessage(IdentityValidationMessages.MaxLength("Description", 1024)); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandHandler.cs index 834980eb3a..856cfd4bee 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandHandler.cs @@ -4,20 +4,13 @@ namespace FSH.Modules.Identity.Features.v1.Roles.DeleteRole; -public sealed class DeleteRoleCommandHandler : ICommandHandler +public sealed class DeleteRoleCommandHandler(IRoleService roleService) : ICommandHandler { - private readonly IRoleService _roleService; - - public DeleteRoleCommandHandler(IRoleService roleService) - { - _roleService = roleService; - } - public async ValueTask Handle(DeleteRoleCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - await _roleService.DeleteRoleAsync(command.Id, cancellationToken).ConfigureAwait(false); + await roleService.DeleteRoleAsync(command.Id, cancellationToken).ConfigureAwait(false); return Unit.Value; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs index bf213c86a5..e4b97867f3 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/DeleteRole/DeleteRoleCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Roles.DeleteRole; namespace FSH.Modules.Identity.Features.v1.Roles.DeleteRole; @@ -8,6 +9,6 @@ public sealed class DeleteRoleCommandValidator : AbstractValidator x.Id) - .NotEmpty().WithMessage("Role ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Role ID")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdQueryHandler.cs index 264fe83053..f89b05fdfa 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleById/GetRoleByIdQueryHandler.cs @@ -5,18 +5,11 @@ namespace FSH.Modules.Identity.Features.v1.Roles.GetRoleById; -public sealed class GetRoleByIdQueryHandler : IQueryHandler +public sealed class GetRoleByIdQueryHandler(IRoleService roleService) : IQueryHandler { - private readonly IRoleService _roleService; - - public GetRoleByIdQueryHandler(IRoleService roleService) - { - _roleService = roleService; - } - public async ValueTask Handle(GetRoleQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - return await _roleService.GetRoleAsync(query.Id, cancellationToken).ConfigureAwait(false); + return await roleService.GetRoleAsync(query.Id, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQueryHandler.cs index bac6da5976..1a386ae799 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoleWithPermissions/GetRoleWithPermissionsQueryHandler.cs @@ -5,18 +5,11 @@ namespace FSH.Modules.Identity.Features.v1.Roles.GetRoleWithPermissions; -public sealed class GetRoleWithPermissionsQueryHandler : IQueryHandler +public sealed class GetRoleWithPermissionsQueryHandler(IRoleService roleService) : IQueryHandler { - private readonly IRoleService _roleService; - - public GetRoleWithPermissionsQueryHandler(IRoleService roleService) - { - _roleService = roleService; - } - public async ValueTask Handle(GetRoleWithPermissionsQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - return await _roleService.GetWithPermissionsAsync(query.Id, cancellationToken).ConfigureAwait(false); + return await roleService.GetWithPermissionsAsync(query.Id, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryHandler.cs index 797f10113d..5f07b58a39 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/GetRoles/GetRolesQueryHandler.cs @@ -5,17 +5,10 @@ namespace FSH.Modules.Identity.Features.v1.Roles.GetRoles; -public sealed class GetRolesQueryHandler : IQueryHandler> +public sealed class GetRolesQueryHandler(IRoleService roleService) : IQueryHandler> { - private readonly IRoleService _roleService; - - public GetRolesQueryHandler(IRoleService roleService) - { - _roleService = roleService; - } - public async ValueTask> Handle(GetRolesQuery query, CancellationToken cancellationToken) { - return await _roleService.GetRolesAsync(cancellationToken).ConfigureAwait(false); + return await roleService.GetRolesAsync(cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandHandler.cs index f75e45bbc9..168871a98c 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpdateRolePermissions/UpdatePermissionsCommandHandler.cs @@ -4,18 +4,11 @@ namespace FSH.Modules.Identity.Features.v1.Roles.UpdateRolePermissions; -public sealed class UpdatePermissionsCommandHandler : ICommandHandler +public sealed class UpdatePermissionsCommandHandler(IRoleService roleService) : ICommandHandler { - private readonly IRoleService _roleService; - - public UpdatePermissionsCommandHandler(IRoleService roleService) - { - _roleService = roleService; - } - public async ValueTask Handle(UpdatePermissionsCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - return await _roleService.UpdatePermissionsAsync(command.RoleId, command.Permissions, cancellationToken).ConfigureAwait(false); + return await roleService.UpdatePermissionsAsync(command.RoleId, command.Permissions, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandHandler.cs index 0875bd817c..398f8c022e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandHandler.cs @@ -5,20 +5,13 @@ namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; -public sealed class UpsertRoleCommandHandler : ICommandHandler +public sealed class UpsertRoleCommandHandler(IRoleService roleService) : ICommandHandler { - private readonly IRoleService _roleService; - - public UpsertRoleCommandHandler(IRoleService roleService) - { - _roleService = roleService; - } - public async ValueTask Handle(UpsertRoleCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - return await _roleService.CreateOrUpdateRoleAsync(command.Id, command.Name, command.Description ?? string.Empty, cancellationToken) + return await roleService.CreateOrUpdateRoleAsync(command.Id, command.Name, command.Description ?? string.Empty, cancellationToken) .ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs index e206420a88..9cdb0684b9 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Roles/UpsertRole/UpsertRoleCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Roles.UpsertRole; namespace FSH.Modules.Identity.Features.v1.Roles.UpsertRole; @@ -7,6 +8,6 @@ public sealed class UpsertRoleCommandValidator : AbstractValidator x.Name).NotEmpty().WithMessage("Role name is required."); + RuleFor(x => x.Name).NotEmpty().WithMessage(IdentityValidationMessages.Required("Role name")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandHandler.cs index 9d452fa2f7..5151966c5c 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandHandler.cs @@ -5,21 +5,12 @@ namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; -public sealed class AdminRevokeAllSessionsCommandHandler : ICommandHandler +public sealed class AdminRevokeAllSessionsCommandHandler(ISessionService sessionService, ICurrentUser currentUser) : ICommandHandler { - private readonly ISessionService _sessionService; - private readonly ICurrentUser _currentUser; - - public AdminRevokeAllSessionsCommandHandler(ISessionService sessionService, ICurrentUser currentUser) - { - _sessionService = sessionService; - _currentUser = currentUser; - } - public async ValueTask Handle(AdminRevokeAllSessionsCommand command, CancellationToken cancellationToken) { - var adminId = _currentUser.GetUserId().ToString(); - return await _sessionService.RevokeAllSessionsForAdminAsync( + var adminId = currentUser.GetUserId().ToString(); + return await sessionService.RevokeAllSessionsForAdminAsync( command.UserId.ToString(), adminId, command.Reason ?? "Revoked by administrator", diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs index f9b8449cb6..9bdb44156e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeAllSessions/AdminRevokeAllSessionsCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeAllSessions; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeAllSessions; @@ -8,10 +9,10 @@ public sealed class AdminRevokeAllSessionsCommandValidator : AbstractValidator x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("User ID")); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(IdentityValidationMessages.MaxLength("Reason", 500)) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandHandler.cs index d3a6d3fa60..f8e324a93b 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandHandler.cs @@ -5,30 +5,21 @@ namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; -public sealed class AdminRevokeSessionCommandHandler : ICommandHandler +public sealed class AdminRevokeSessionCommandHandler(ISessionService sessionService, ICurrentUser currentUser) : ICommandHandler { - private readonly ISessionService _sessionService; - private readonly ICurrentUser _currentUser; - - public AdminRevokeSessionCommandHandler(ISessionService sessionService, ICurrentUser currentUser) - { - _sessionService = sessionService; - _currentUser = currentUser; - } - public async ValueTask Handle(AdminRevokeSessionCommand command, CancellationToken cancellationToken) { - var adminId = _currentUser.GetUserId().ToString(); + var adminId = currentUser.GetUserId().ToString(); // Get the session to verify it belongs to the specified user - var session = await _sessionService.GetSessionAsync(command.SessionId, cancellationToken); + var session = await sessionService.GetSessionAsync(command.SessionId, cancellationToken); if (session is null || session.UserId != command.UserId.ToString()) { return false; } // Use the admin revocation method (doesn't check ownership) - return await _sessionService.RevokeSessionForAdminAsync( + return await sessionService.RevokeSessionForAdminAsync( command.SessionId, adminId, command.Reason ?? "Revoked by administrator", diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs index 8e59cb4ec9..752661fe71 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/AdminRevokeSession/AdminRevokeSessionCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Sessions.AdminRevokeSession; namespace FSH.Modules.Identity.Features.v1.Sessions.AdminRevokeSession; @@ -8,13 +9,13 @@ public sealed class AdminRevokeSessionCommandValidator : AbstractValidator x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("User ID")); RuleFor(x => x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Session ID")); RuleFor(x => x.Reason) - .MaximumLength(500).WithMessage("Reason must not exceed 500 characters.") + .MaximumLength(500).WithMessage(IdentityValidationMessages.MaxLength("Reason", 500)) .When(x => x.Reason is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsQueryHandler.cs index 5f831ee59c..3555805ad2 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetMySessions/GetMySessionsQueryHandler.cs @@ -6,20 +6,11 @@ namespace FSH.Modules.Identity.Features.v1.Sessions.GetMySessions; -public sealed class GetMySessionsQueryHandler : IQueryHandler> +public sealed class GetMySessionsQueryHandler(ISessionService sessionService, ICurrentUser currentUser) : IQueryHandler> { - private readonly ISessionService _sessionService; - private readonly ICurrentUser _currentUser; - - public GetMySessionsQueryHandler(ISessionService sessionService, ICurrentUser currentUser) - { - _sessionService = sessionService; - _currentUser = currentUser; - } - public async ValueTask> Handle(GetMySessionsQuery query, CancellationToken cancellationToken) { - var userId = _currentUser.GetUserId().ToString(); - return await _sessionService.GetUserSessionsAsync(userId, cancellationToken); + var userId = currentUser.GetUserId().ToString(); + return await sessionService.GetUserSessionsAsync(userId, cancellationToken); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsQueryHandler.cs index 1c4380a66f..e7b66a4faf 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/GetUserSessions/GetUserSessionsQueryHandler.cs @@ -5,17 +5,10 @@ namespace FSH.Modules.Identity.Features.v1.Sessions.GetUserSessions; -public sealed class GetUserSessionsQueryHandler : IQueryHandler> +public sealed class GetUserSessionsQueryHandler(ISessionService sessionService) : IQueryHandler> { - private readonly ISessionService _sessionService; - - public GetUserSessionsQueryHandler(ISessionService sessionService) - { - _sessionService = sessionService; - } - public async ValueTask> Handle(GetUserSessionsQuery query, CancellationToken cancellationToken) { - return await _sessionService.GetUserSessionsForAdminAsync(query.UserId.ToString(), cancellationToken); + return await sessionService.GetUserSessionsForAdminAsync(query.UserId.ToString(), cancellationToken); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommandHandler.cs index 635914aaeb..8a99768be0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeAllSessions/RevokeAllSessionsCommandHandler.cs @@ -5,21 +5,12 @@ namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeAllSessions; -public sealed class RevokeAllSessionsCommandHandler : ICommandHandler +public sealed class RevokeAllSessionsCommandHandler(ISessionService sessionService, ICurrentUser currentUser) : ICommandHandler { - private readonly ISessionService _sessionService; - private readonly ICurrentUser _currentUser; - - public RevokeAllSessionsCommandHandler(ISessionService sessionService, ICurrentUser currentUser) - { - _sessionService = sessionService; - _currentUser = currentUser; - } - public async ValueTask Handle(RevokeAllSessionsCommand command, CancellationToken cancellationToken) { - var userId = _currentUser.GetUserId().ToString(); - return await _sessionService.RevokeAllSessionsAsync( + var userId = currentUser.GetUserId().ToString(); + return await sessionService.RevokeAllSessionsAsync( userId, userId, command.ExceptSessionId, diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandHandler.cs index 91e580ea92..d55b47b825 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandHandler.cs @@ -5,21 +5,12 @@ namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; -public sealed class RevokeSessionCommandHandler : ICommandHandler +public sealed class RevokeSessionCommandHandler(ISessionService sessionService, ICurrentUser currentUser) : ICommandHandler { - private readonly ISessionService _sessionService; - private readonly ICurrentUser _currentUser; - - public RevokeSessionCommandHandler(ISessionService sessionService, ICurrentUser currentUser) - { - _sessionService = sessionService; - _currentUser = currentUser; - } - public async ValueTask Handle(RevokeSessionCommand command, CancellationToken cancellationToken) { - var userId = _currentUser.GetUserId().ToString(); - return await _sessionService.RevokeSessionAsync( + var userId = currentUser.GetUserId().ToString(); + return await sessionService.RevokeSessionAsync( command.SessionId, userId, "User requested", diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs index c0ceb12b33..1ca0d0d965 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Sessions/RevokeSession/RevokeSessionCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Sessions.RevokeSession; namespace FSH.Modules.Identity.Features.v1.Sessions.RevokeSession; @@ -8,6 +9,6 @@ public sealed class RevokeSessionCommandValidator : AbstractValidator x.SessionId) - .NotEmpty().WithMessage("Session ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Session ID")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs index 2e6ff8d4c0..918bbbf325 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/RefreshToken/RefreshTokenCommandHandler.cs @@ -9,47 +9,30 @@ namespace FSH.Modules.Identity.Features.v1.Tokens.RefreshToken; -public sealed class RefreshTokenCommandHandler - : ICommandHandler +public sealed class RefreshTokenCommandHandler( + IIdentityService identityService, + ITokenService tokenService, + ISecurityAudit securityAudit, + IRequestContext requestContext, + ISessionService sessionService, + ILogger logger) + : ICommandHandler { - private readonly IIdentityService _identityService; - private readonly ITokenService _tokenService; - private readonly ISecurityAudit _securityAudit; - private readonly IRequestContext _requestContext; - private readonly ISessionService _sessionService; - private readonly ILogger _logger; - - public RefreshTokenCommandHandler( - IIdentityService identityService, - ITokenService tokenService, - ISecurityAudit securityAudit, - IRequestContext requestContext, - ISessionService sessionService, - ILogger logger) - { - _identityService = identityService; - _tokenService = tokenService; - _securityAudit = securityAudit; - _requestContext = requestContext; - _sessionService = sessionService; - _logger = logger; - } - public async ValueTask Handle( RefreshTokenCommand request, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(request); - var clientId = _requestContext.ClientId; + var clientId = requestContext.ClientId; // Validate refresh token and rebuild subject + claims - var validated = await _identityService + var validated = await identityService .ValidateRefreshTokenAsync(request.RefreshToken, cancellationToken); if (validated is null) { - await _securityAudit.TokenRevokedAsync("unknown", clientId!, "InvalidRefreshToken", cancellationToken); + await securityAudit.TokenRevokedAsync("unknown", clientId!, "InvalidRefreshToken", cancellationToken); throw new UnauthorizedAccessException("Invalid refresh token."); } @@ -57,10 +40,10 @@ public async ValueTask Handle( // Check if the session associated with this refresh token is still valid var refreshTokenHash = Sha256Short(request.RefreshToken); - var isSessionValid = await _sessionService.ValidateSessionAsync(refreshTokenHash, cancellationToken); + var isSessionValid = await sessionService.ValidateSessionAsync(refreshTokenHash, cancellationToken); if (!isSessionValid) { - await _securityAudit.TokenRevokedAsync(subject, clientId!, "SessionRevoked", cancellationToken); + await securityAudit.TokenRevokedAsync(subject, clientId!, "SessionRevoked", cancellationToken); throw new UnauthorizedAccessException("Session has been revoked."); } @@ -73,7 +56,7 @@ public async ValueTask Handle( } catch (Exception ex) { - _logger.LogDebug(ex, "Failed to parse access token during refresh; relying on refresh-token validation only"); + logger.LogDebug(ex, "Failed to parse access token during refresh; relying on refresh-token validation only"); } if (parsedAccessToken is not null) @@ -85,23 +68,23 @@ public async ValueTask Handle( if (!string.IsNullOrEmpty(accessTokenSubject) && !string.Equals(accessTokenSubject, subject, StringComparison.Ordinal)) { - await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenSubjectMismatch", cancellationToken); + await securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenSubjectMismatch", cancellationToken); throw new UnauthorizedAccessException("Access token subject mismatch."); } } // Audit previous token revocation by rotation (no raw tokens) - await _securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenRotated", cancellationToken); + await securityAudit.TokenRevokedAsync(subject, clientId!, "RefreshTokenRotated", cancellationToken); // Issue new tokens - var newToken = await _tokenService.IssueAsync(subject, claims, null, cancellationToken); + var newToken = await tokenService.IssueAsync(subject, claims, null, cancellationToken); // Persist rotated refresh token for this user - await _identityService.StoreRefreshTokenAsync(subject, newToken.RefreshToken, newToken.RefreshTokenExpiresAt, cancellationToken); + await identityService.StoreRefreshTokenAsync(subject, newToken.RefreshToken, newToken.RefreshTokenExpiresAt, cancellationToken); // Update the session with the new refresh token hash var newRefreshTokenHash = Sha256Short(newToken.RefreshToken); - await _sessionService.UpdateSessionRefreshTokenAsync( + await sessionService.UpdateSessionRefreshTokenAsync( refreshTokenHash, newRefreshTokenHash, newToken.RefreshTokenExpiresAt, @@ -109,7 +92,7 @@ await _sessionService.UpdateSessionRefreshTokenAsync( // Audit the newly issued token with a fingerprint var fingerprint = Sha256Short(newToken.AccessToken); - await _securityAudit.TokenIssuedAsync( + await securityAudit.TokenIssuedAsync( userId: subject, userName: claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? string.Empty, clientId: clientId!, diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs index da0c5795b9..22a8cdcc5e 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Tokens/TokenGeneration/GenerateTokenCommandHandler.cs @@ -13,38 +13,17 @@ namespace FSH.Modules.Identity.Features.v1.Tokens.TokenGeneration; -public sealed class GenerateTokenCommandHandler - : ICommandHandler +public sealed class GenerateTokenCommandHandler( + IIdentityService identityService, + ITokenService tokenService, + ISecurityAudit securityAudit, + IRequestContext requestContext, + IOutboxStore outboxStore, + IMultiTenantContextAccessor multiTenantContextAccessor, + ISessionService sessionService, + ILogger logger) + : ICommandHandler { - private readonly IIdentityService _identityService; - private readonly ITokenService _tokenService; - private readonly ISecurityAudit _securityAudit; - private readonly IRequestContext _requestContext; - private readonly IOutboxStore _outboxStore; - private readonly IMultiTenantContextAccessor _multiTenantContextAccessor; - private readonly ISessionService _sessionService; - private readonly ILogger _logger; - - public GenerateTokenCommandHandler( - IIdentityService identityService, - ITokenService tokenService, - ISecurityAudit securityAudit, - IRequestContext requestContext, - IOutboxStore outboxStore, - IMultiTenantContextAccessor multiTenantContextAccessor, - ISessionService sessionService, - ILogger logger) - { - _identityService = identityService; - _tokenService = tokenService; - _securityAudit = securityAudit; - _requestContext = requestContext; - _outboxStore = outboxStore; - _multiTenantContextAccessor = multiTenantContextAccessor; - _sessionService = sessionService; - _logger = logger; - } - public async ValueTask Handle( GenerateTokenCommand request, CancellationToken cancellationToken) @@ -52,18 +31,18 @@ public async ValueTask Handle( ArgumentNullException.ThrowIfNull(request); // Gather context for auditing - var ip = _requestContext.IpAddress ?? "unknown"; - var ua = _requestContext.UserAgent ?? "unknown"; - var clientId = _requestContext.ClientId; + var ip = requestContext.IpAddress ?? "unknown"; + var ua = requestContext.UserAgent ?? "unknown"; + var clientId = requestContext.ClientId; // Validate credentials - var identityResult = await _identityService + var identityResult = await identityService .ValidateCredentialsAsync(request.Email, request.Password, cancellationToken); if (identityResult is null) { // 1) Audit failed login BEFORE throwing - await _securityAudit.LoginFailedAsync( + await securityAudit.LoginFailedAsync( subjectIdOrName: request.Email, clientId: clientId!, reason: "InvalidCredentials", @@ -77,7 +56,7 @@ await _securityAudit.LoginFailedAsync( var (subject, claims) = identityResult.Value; // 2) Audit successful login - await _securityAudit.LoginSucceededAsync( + await securityAudit.LoginSucceededAsync( userId: subject, userName: claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? request.Email, clientId: clientId!, @@ -86,16 +65,16 @@ await _securityAudit.LoginSucceededAsync( ct: cancellationToken); // Issue token - var token = await _tokenService.IssueAsync(subject, claims, /*extra*/ null, cancellationToken); + var token = await tokenService.IssueAsync(subject, claims, /*extra*/ null, cancellationToken); // Persist refresh token (hashed) for this user - await _identityService.StoreRefreshTokenAsync(subject, token.RefreshToken, token.RefreshTokenExpiresAt, cancellationToken); + await identityService.StoreRefreshTokenAsync(subject, token.RefreshToken, token.RefreshTokenExpiresAt, cancellationToken); // Create user session for session management (non-blocking, fail gracefully) try { var refreshTokenHash = Sha256Short(token.RefreshToken); - await _sessionService.CreateSessionAsync( + await sessionService.CreateSessionAsync( subject, refreshTokenHash, ip, @@ -107,12 +86,12 @@ await _sessionService.CreateSessionAsync( { // Session creation is non-critical - don't fail the login // This can happen if migrations haven't been applied yet - _logger.LogWarning(ex, "Failed to create user session for user {UserId}. Login will continue without session tracking.", subject); + logger.LogWarning(ex, "Failed to create user session for user {UserId}. Login will continue without session tracking.", subject); } // 3) Audit token issuance with a fingerprint (never raw token) var fingerprint = Sha256Short(token.AccessToken); - await _securityAudit.TokenIssuedAsync( + await securityAudit.TokenIssuedAsync( userId: subject, userName: claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value ?? request.Email, clientId: clientId!, @@ -121,11 +100,11 @@ await _securityAudit.TokenIssuedAsync( ct: cancellationToken); // 4) Enqueue integration event for token generation (sample event for testing eventing) - var tenantId = _multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id; - var correlationId = Guid.NewGuid().ToString(); + var tenantId = multiTenantContextAccessor.MultiTenantContext?.TenantInfo?.Id; + var correlationId = Guid.CreateVersion7().ToString(); var integrationEvent = new TokenGeneratedIntegrationEvent( - Id: Guid.NewGuid(), + Id: Guid.CreateVersion7(), OccurredOnUtc: TimeProvider.System.GetUtcNow().UtcDateTime, TenantId: tenantId, CorrelationId: correlationId, @@ -138,7 +117,7 @@ await _securityAudit.TokenIssuedAsync( TokenFingerprint: fingerprint, AccessTokenExpiresAtUtc: token.AccessTokenExpiresAt); - await _outboxStore.AddAsync(integrationEvent, cancellationToken).ConfigureAwait(false); + await outboxStore.AddAsync(integrationEvent, cancellationToken).ConfigureAwait(false); return token; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs index 703d43d398..98c44ec88f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/AssignUserRoles/AssignUserRolesCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Users.AssignUserRoles; namespace FSH.Modules.Identity.Features.v1.Users.AssignUserRoles; @@ -8,9 +9,9 @@ public sealed class AssignUserRolesCommandValidator : AbstractValidator x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("User ID")); RuleFor(x => x.UserRoles) - .NotNull().WithMessage("User roles list is required."); + .NotNull().WithMessage(IdentityValidationMessages.Required("User roles list")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordCommandHandler.cs index 10f05c3507..07c484621a 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordCommandHandler.cs @@ -5,29 +5,20 @@ namespace FSH.Modules.Identity.Features.v1.Users.ChangePassword; -public sealed class ChangePasswordCommandHandler : ICommandHandler +public sealed class ChangePasswordCommandHandler(IUserService userService, ICurrentUser currentUser) : ICommandHandler { - private readonly IUserService _userService; - private readonly ICurrentUser _currentUser; - - public ChangePasswordCommandHandler(IUserService userService, ICurrentUser currentUser) - { - _userService = userService; - _currentUser = currentUser; - } - public async ValueTask Handle(ChangePasswordCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - if (!_currentUser.IsAuthenticated()) + if (!currentUser.IsAuthenticated()) { throw new InvalidOperationException("User is not authenticated."); } - var userId = _currentUser.GetUserId().ToString(); + var userId = currentUser.GetUserId().ToString(); - await _userService.ChangePasswordAsync(command.Password, command.NewPassword, command.ConfirmNewPassword, userId).ConfigureAwait(false); + await userService.ChangePasswordAsync(command.Password, command.NewPassword, command.ConfirmNewPassword, userId).ConfigureAwait(false); return "password reset email sent"; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs index 0581ae1878..fdee05e47d 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ChangePassword/ChangePasswordValidator.cs @@ -1,5 +1,6 @@ using FluentValidation; using FSH.Framework.Core.Context; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.Services; using FSH.Modules.Identity.Contracts.v1.Users.ChangePassword; @@ -19,19 +20,19 @@ public ChangePasswordValidator( RuleFor(p => p.Password) .NotEmpty() - .WithMessage("Current password is required."); + .WithMessage(IdentityValidationMessages.Required("Current password")); RuleFor(p => p.NewPassword) .NotEmpty() - .WithMessage("New password is required.") + .WithMessage(IdentityValidationMessages.Required("New password")) .NotEqual(p => p.Password) - .WithMessage("New password must be different from the current password.") + .WithMessage(IdentityValidationMessages.NewPasswordMustDiffer()) .MustAsync(NotBeInPasswordHistoryAsync) - .WithMessage("This password has been used recently. Please choose a different password."); + .WithMessage(IdentityValidationMessages.PasswordInHistory()); RuleFor(p => p.ConfirmNewPassword) .Equal(p => p.NewPassword) - .WithMessage("Passwords do not match."); + .WithMessage(IdentityValidationMessages.PasswordsMustMatch()); } private async Task NotBeInPasswordHistoryAsync(string newPassword, CancellationToken cancellationToken) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandHandler.cs index 18d09d03ca..ab452529c0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandHandler.cs @@ -4,20 +4,13 @@ namespace FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; -public sealed class ConfirmEmailCommandHandler : ICommandHandler +public sealed class ConfirmEmailCommandHandler(IUserService userService) : ICommandHandler { - private readonly IUserService _userService; - - public ConfirmEmailCommandHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask Handle(ConfirmEmailCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - return await _userService.ConfirmEmailAsync(command.UserId, command.Code, command.Tenant, cancellationToken) + return await userService.ConfirmEmailAsync(command.UserId, command.Code, command.Tenant, cancellationToken) .ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs index 54805a491b..de626b75da 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ConfirmEmail/ConfirmEmailCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Users.ConfirmEmail; namespace FSH.Modules.Identity.Features.v1.Users.ConfirmEmail; @@ -8,12 +9,12 @@ public sealed class ConfirmEmailCommandValidator : AbstractValidator x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("User ID")); RuleFor(x => x.Code) - .NotEmpty().WithMessage("Confirmation code is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Confirmation code")); RuleFor(x => x.Tenant) - .NotEmpty().WithMessage("Tenant is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Tenant")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandHandler.cs index e2d7f90d77..02ca30c3a0 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandHandler.cs @@ -4,20 +4,13 @@ namespace FSH.Modules.Identity.Features.v1.Users.DeleteUser; -public sealed class DeleteUserCommandHandler : ICommandHandler +public sealed class DeleteUserCommandHandler(IUserService userService) : ICommandHandler { - private readonly IUserService _userService; - - public DeleteUserCommandHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask Handle(DeleteUserCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - await _userService.DeleteAsync(command.Id).ConfigureAwait(false); + await userService.DeleteAsync(command.Id).ConfigureAwait(false); return Unit.Value; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs index f5410d84ac..892e8fd71c 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/DeleteUser/DeleteUserCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Users.DeleteUser; namespace FSH.Modules.Identity.Features.v1.Users.DeleteUser; @@ -8,6 +9,6 @@ public sealed class DeleteUserCommandValidator : AbstractValidator x.Id) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("User ID")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs index 267f49887b..ef22705497 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ForgotPassword/ForgotPasswordCommandHandler.cs @@ -6,28 +6,19 @@ namespace FSH.Modules.Identity.Features.v1.Users.ForgotPassword; -public sealed class ForgotPasswordCommandHandler : ICommandHandler +public sealed class ForgotPasswordCommandHandler(IUserService userService, IOptions originOptions) : ICommandHandler { - private readonly IUserService _userService; - private readonly IOptions _originOptions; - - public ForgotPasswordCommandHandler(IUserService userService, IOptions originOptions) - { - _userService = userService; - _originOptions = originOptions; - } - public async ValueTask Handle(ForgotPasswordCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - var origin = _originOptions.Value?.OriginUrl?.ToString(); + var origin = originOptions.Value?.OriginUrl?.ToString(); if (string.IsNullOrWhiteSpace(origin)) { throw new InvalidOperationException("Origin URL is not configured."); } - await _userService.ForgotPasswordAsync(command.Email, origin, cancellationToken).ConfigureAwait(false); + await userService.ForgotPasswordAsync(command.Email, origin, cancellationToken).ConfigureAwait(false); return "Password reset email sent."; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdQueryHandler.cs index 38c72611a0..2a0e98bec6 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserById/GetUserByIdQueryHandler.cs @@ -5,18 +5,11 @@ namespace FSH.Modules.Identity.Features.v1.Users.GetUserById; -public sealed class GetUserByIdQueryHandler : IQueryHandler +public sealed class GetUserByIdQueryHandler(IUserService userService) : IQueryHandler { - private readonly IUserService _userService; - - public GetUserByIdQueryHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask Handle(GetUserQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - return await _userService.GetAsync(query.Id, cancellationToken).ConfigureAwait(false); + return await userService.GetAsync(query.Id, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs index a75f4a1bc8..6f39dbb1af 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserGroups/GetUserGroupsQueryHandler.cs @@ -7,19 +7,12 @@ namespace FSH.Modules.Identity.Features.v1.Users.GetUserGroups; -public sealed class GetUserGroupsQueryHandler : IQueryHandler> +public sealed class GetUserGroupsQueryHandler(IdentityDbContext dbContext) : IQueryHandler> { - private readonly IdentityDbContext _dbContext; - - public GetUserGroupsQueryHandler(IdentityDbContext dbContext) - { - _dbContext = dbContext; - } - public async ValueTask> Handle(GetUserGroupsQuery query, CancellationToken cancellationToken) { // Validate user exists - var userExists = await _dbContext.Users + var userExists = await dbContext.Users .AnyAsync(u => u.Id == query.UserId, cancellationToken); if (!userExists) @@ -28,7 +21,7 @@ public async ValueTask> Handle(GetUserGroupsQuery query, C } // Get user's groups - var groupIds = await _dbContext.UserGroups + var groupIds = await dbContext.UserGroups .AsNoTracking() .Where(ug => ug.UserId == query.UserId) .Select(ug => ug.GroupId) @@ -39,14 +32,14 @@ public async ValueTask> Handle(GetUserGroupsQuery query, C return []; } - var groups = await _dbContext.Groups + var groups = await dbContext.Groups .AsNoTracking() .Include(g => g.GroupRoles) .Where(g => groupIds.Contains(g.Id)) .ToListAsync(cancellationToken); // Get member counts - var memberCounts = await _dbContext.UserGroups + var memberCounts = await dbContext.UserGroups .Where(ug => groupIds.Contains(ug.GroupId)) .GroupBy(ug => ug.GroupId) .Select(g => new { GroupId = g.Key, Count = g.Count() }) @@ -59,7 +52,7 @@ public async ValueTask> Handle(GetUserGroupsQuery query, C .ToList(); var roleNames = allRoleIds.Count > 0 - ? await _dbContext.Roles + ? await dbContext.Roles .AsNoTracking() .Where(r => allRoleIds.Contains(r.Id)) .ToDictionaryAsync(r => r.Id, r => r.Name!, cancellationToken) diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQueryHandler.cs index d9764cc822..dd0e1d263b 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserPermissions/GetCurrentUserPermissionsQueryHandler.cs @@ -4,18 +4,11 @@ namespace FSH.Modules.Identity.Features.v1.Users.GetUserPermissions; -public sealed class GetCurrentUserPermissionsQueryHandler : IQueryHandler?> +public sealed class GetCurrentUserPermissionsQueryHandler(IUserService userService) : IQueryHandler?> { - private readonly IUserService _userService; - - public GetCurrentUserPermissionsQueryHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask?> Handle(GetCurrentUserPermissionsQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - return await _userService.GetPermissionsAsync(query.UserId, cancellationToken).ConfigureAwait(false); + return await userService.GetPermissionsAsync(query.UserId, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetCurrentUserProfileQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetCurrentUserProfileQueryHandler.cs index 81b159673f..9ee02c4cbc 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetCurrentUserProfileQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserProfile/GetCurrentUserProfileQueryHandler.cs @@ -5,18 +5,11 @@ namespace FSH.Modules.Identity.Features.v1.Users.GetUserProfile; -public sealed class GetCurrentUserProfileQueryHandler : IQueryHandler +public sealed class GetCurrentUserProfileQueryHandler(IUserService userService) : IQueryHandler { - private readonly IUserService _userService; - - public GetCurrentUserProfileQueryHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask Handle(GetCurrentUserProfileQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - return await _userService.GetAsync(query.UserId, cancellationToken).ConfigureAwait(false); + return await userService.GetAsync(query.UserId, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesQueryHandler.cs index 18563b39e0..3f6befcbb6 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUserRoles/GetUserRolesQueryHandler.cs @@ -5,18 +5,11 @@ namespace FSH.Modules.Identity.Features.v1.Users.GetUserRoles; -public sealed class GetUserRolesQueryHandler : IQueryHandler> +public sealed class GetUserRolesQueryHandler(IUserService userService) : IQueryHandler> { - private readonly IUserService _userService; - - public GetUserRolesQueryHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask> Handle(GetUserRolesQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - return await _userService.GetUserRolesAsync(query.UserId, cancellationToken).ConfigureAwait(false); + return await userService.GetUserRolesAsync(query.UserId, cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersQueryHandler.cs index 9b568e3469..ddf83bd391 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/GetUsers/GetUsersQueryHandler.cs @@ -5,17 +5,10 @@ namespace FSH.Modules.Identity.Features.v1.Users.GetUsers; -public sealed class GetUsersQueryHandler : IQueryHandler> +public sealed class GetUsersQueryHandler(IUserService userService) : IQueryHandler> { - private readonly IUserService _userService; - - public GetUsersQueryHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask> Handle(GetUsersQuery query, CancellationToken cancellationToken) { - return await _userService.GetListAsync(cancellationToken).ConfigureAwait(false); + return await userService.GetListAsync(cancellationToken).ConfigureAwait(false); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs index 25e0742505..cce8e78eae 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandHandler.cs @@ -4,20 +4,13 @@ namespace FSH.Modules.Identity.Features.v1.Users.RegisterUser; -public sealed class RegisterUserCommandHandler : ICommandHandler +public sealed class RegisterUserCommandHandler(IUserService userService) : ICommandHandler { - private readonly IUserService _userService; - - public RegisterUserCommandHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask Handle(RegisterUserCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - string userId = await _userService.RegisterAsync( + string userId = await userService.RegisterAsync( command.FirstName, command.LastName, command.Email, diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs index 54d774b2da..3892f8ab29 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/RegisterUser/RegisterUserCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Users.RegisterUser; namespace FSH.Modules.Identity.Features.v1.Users.RegisterUser; @@ -8,32 +9,32 @@ public sealed class RegisterUserCommandValidator : AbstractValidator x.FirstName) - .NotEmpty().WithMessage("First name is required.") - .MaximumLength(100).WithMessage("First name must not exceed 100 characters."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("First name")) + .MaximumLength(100).WithMessage(IdentityValidationMessages.MaxLength("First name", 100)); RuleFor(x => x.LastName) - .NotEmpty().WithMessage("Last name is required.") - .MaximumLength(100).WithMessage("Last name must not exceed 100 characters."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Last name")) + .MaximumLength(100).WithMessage(IdentityValidationMessages.MaxLength("Last name", 100)); RuleFor(x => x.Email) - .NotEmpty().WithMessage("Email is required.") - .EmailAddress().WithMessage("A valid email address is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Email")) + .EmailAddress().WithMessage(IdentityValidationMessages.InvalidEmail()); RuleFor(x => x.UserName) - .NotEmpty().WithMessage("Username is required.") - .MinimumLength(3).WithMessage("Username must be at least 3 characters.") - .MaximumLength(50).WithMessage("Username must not exceed 50 characters."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Username")) + .MinimumLength(3).WithMessage(IdentityValidationMessages.MinLength("Username", 3)) + .MaximumLength(50).WithMessage(IdentityValidationMessages.MaxLength("Username", 50)); RuleFor(x => x.Password) - .NotEmpty().WithMessage("Password is required.") - .MinimumLength(6).WithMessage("Password must be at least 6 characters."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Password")) + .MinimumLength(6).WithMessage(IdentityValidationMessages.MinLength("Password", 6)); RuleFor(x => x.ConfirmPassword) - .NotEmpty().WithMessage("Password confirmation is required.") - .Equal(x => x.Password).WithMessage("Passwords do not match."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("Password confirmation")) + .Equal(x => x.Password).WithMessage(IdentityValidationMessages.PasswordsMustMatch()); RuleFor(x => x.PhoneNumber) - .MaximumLength(20).WithMessage("Phone number must not exceed 20 characters.") + .MaximumLength(20).WithMessage(IdentityValidationMessages.MaxLength("Phone number", 20)) .When(x => x.PhoneNumber is not null); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandHandler.cs index b8017d1713..26ee9e0300 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ResetPassword/ResetPasswordCommandHandler.cs @@ -4,20 +4,13 @@ namespace FSH.Modules.Identity.Features.v1.Users.ResetPassword; -public sealed class ResetPasswordCommandHandler : ICommandHandler +public sealed class ResetPasswordCommandHandler(IUserService userService) : ICommandHandler { - private readonly IUserService _userService; - - public ResetPasswordCommandHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask Handle(ResetPasswordCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - await _userService.ResetPasswordAsync(command.Email, command.Password, command.Token, cancellationToken).ConfigureAwait(false); + await userService.ResetPasswordAsync(command.Email, command.Password, command.Token, cancellationToken).ConfigureAwait(false); return "Password has been reset."; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs index 76b319cf54..dad23c9d9f 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/SearchUsers/SearchUsersQueryHandler.cs @@ -12,27 +12,16 @@ namespace FSH.Modules.Identity.Features.v1.Users.SearchUsers; -public sealed class SearchUsersQueryHandler : IQueryHandler> +public sealed class SearchUsersQueryHandler( + UserManager userManager, + IdentityDbContext dbContext, + IRequestContext requestContext) : IQueryHandler> { - private readonly UserManager _userManager; - private readonly IdentityDbContext _dbContext; - private readonly IRequestContext _requestContext; - - public SearchUsersQueryHandler( - UserManager userManager, - IdentityDbContext dbContext, - IRequestContext requestContext) - { - _userManager = userManager; - _dbContext = dbContext; - _requestContext = requestContext; - } - public async ValueTask> Handle(SearchUsersQuery query, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(query); - IQueryable users = _userManager.Users.AsNoTracking(); + IQueryable users = userManager.Users.AsNoTracking(); // Apply filters if (!string.IsNullOrWhiteSpace(query.Search)) @@ -57,7 +46,7 @@ public async ValueTask> Handle(SearchUsersQuery query, Ca if (!string.IsNullOrWhiteSpace(query.RoleId)) { - var userIdsInRole = await _dbContext.UserRoles + var userIdsInRole = await dbContext.UserRoles .Where(ur => ur.RoleId == query.RoleId) .Select(ur => ur.UserId) .ToListAsync(cancellationToken); @@ -179,7 +168,7 @@ private static IOrderedQueryable ApplySortExpression( return imageUrl; } - var origin = _requestContext.Origin; + var origin = requestContext.Origin; if (string.IsNullOrEmpty(origin)) { return imageUrl; diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandHandler.cs index 9300187dd9..5578aa4962 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandHandler.cs @@ -4,15 +4,8 @@ namespace FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; -public sealed class ToggleUserStatusCommandHandler : ICommandHandler +public sealed class ToggleUserStatusCommandHandler(IUserService userService) : ICommandHandler { - private readonly IUserService _userService; - - public ToggleUserStatusCommandHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask Handle(ToggleUserStatusCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); @@ -22,7 +15,7 @@ public async ValueTask Handle(ToggleUserStatusCommand command, Cancellatio throw new ArgumentException("UserId must be provided.", nameof(command.UserId)); } - await _userService.ToggleStatusAsync(command.ActivateUser, command.UserId, cancellationToken).ConfigureAwait(false); + await userService.ToggleStatusAsync(command.ActivateUser, command.UserId, cancellationToken).ConfigureAwait(false); return Unit.Value; } diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs index 4eece88de2..13efce963b 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/ToggleUserStatus/ToggleUserStatusCommandValidator.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Users.ToggleUserStatus; namespace FSH.Modules.Identity.Features.v1.Users.ToggleUserStatus; @@ -8,6 +9,6 @@ public sealed class ToggleUserStatusCommandValidator : AbstractValidator x.UserId) - .NotEmpty().WithMessage("User ID is required."); + .NotEmpty().WithMessage(IdentityValidationMessages.Required("User ID")); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs index 494eac53b3..8f38ec66bf 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandHandler.cs @@ -4,20 +4,13 @@ namespace FSH.Modules.Identity.Features.v1.Users.UpdateUser; -public sealed class UpdateUserCommandHandler : ICommandHandler +public sealed class UpdateUserCommandHandler(IUserService userService) : ICommandHandler { - private readonly IUserService _userService; - - public UpdateUserCommandHandler(IUserService userService) - { - _userService = userService; - } - public async ValueTask Handle(UpdateUserCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); - await _userService.UpdateAsync( + await userService.UpdateAsync( command.Id, command.FirstName ?? string.Empty, command.LastName ?? string.Empty, diff --git a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs index 6fc722d9d9..78c472b494 100644 --- a/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs +++ b/src/Modules/Identity/Modules.Identity/Features/v1/Users/UpdateUser/UpdateUserCommandValidator.cs @@ -1,5 +1,6 @@ using FluentValidation; using FSH.Framework.Storage; +using FSH.Modules.Identity.Constants; using FSH.Modules.Identity.Contracts.v1.Users.UpdateUser; namespace FSH.Modules.Identity.Features.v1.Users.UpdateUser; @@ -10,7 +11,7 @@ public UpdateUserCommandValidator() { RuleFor(x => x.Id) .NotEmpty() - .WithMessage("User ID is required."); + .WithMessage(IdentityValidationMessages.Required("User ID")); RuleFor(x => x.FirstName) .MaximumLength(50) @@ -34,9 +35,8 @@ public UpdateUserCommandValidator() .SetValidator(new UserImageValidator(FileType.Image)); }); - // Prevent deleting and uploading image at the same time RuleFor(x => x) .Must(x => !(x.DeleteCurrentImage && x.Image is not null)) - .WithMessage("You cannot upload a new image and delete the current one simultaneously."); + .WithMessage(IdentityValidationMessages.ImageConflict()); } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/IdentityModule.cs b/src/Modules/Identity/Modules.Identity/IdentityModule.cs index 876a9a8465..50b1558a63 100644 --- a/src/Modules/Identity/Modules.Identity/IdentityModule.cs +++ b/src/Modules/Identity/Modules.Identity/IdentityModule.cs @@ -159,14 +159,11 @@ public void MapEndpoints(IEndpointRouteBuilder endpoints) // example Hangfire setup for Identity outbox dispatcher var jobManager = endpoints.ServiceProvider.GetService(); - if (jobManager is not null) - { - jobManager.AddOrUpdate( + jobManager?.AddOrUpdate( "identity-outbox-dispatcher", Job.FromExpression(d => d.DispatchAsync(CancellationToken.None)), Cron.Minutely(), new RecurringJobOptions()); - } // roles group.MapGetRolesEndpoint(); diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResource.cs b/src/Modules/Identity/Modules.Identity/Localization/IdentityResource.cs new file mode 100644 index 0000000000..bc4fab179d --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResource.cs @@ -0,0 +1,14 @@ +namespace FSH.Modules.Identity.Localization; + +/// +/// Marker class for Identity module localization resources. +/// Pair this with IdentityResource.resx (and per-culture variants, e.g. IdentityResource.af-ZA.resx). +/// Inject as IStringLocalizer<IdentityResource> in Identity module components/services. +/// +/// +/// Use this for Identity-specific strings like "UserCreated", "LoginSuccessful", etc. +/// For common strings (Required, Email, Password), use IStringLocalizer<SharedResource>. +/// +#pragma warning disable S2094 // Intentionally empty marker class — pairs with IdentityResource.resx +public sealed class IdentityResource; +#pragma warning restore S2094 diff --git a/src/Modules/Identity/Modules.Identity/Localization/IdentityResource.resx b/src/Modules/Identity/Modules.Identity/Localization/IdentityResource.resx new file mode 100644 index 0000000000..aaa4e892f5 --- /dev/null +++ b/src/Modules/Identity/Modules.Identity/Localization/IdentityResource.resx @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + + + + User created successfully + + + User updated successfully + + + User deleted successfully + + + User deactivated + + + User activated + + + + + Login successful + + + Logout successful + + + Invalid email or password + + + Your account has been locked. Please contact support. + + + + + Password changed successfully + + + Password reset email has been sent + + + Password has been reset successfully + + + + + Email confirmed successfully + + + Confirmation email has been sent + + + + + Role assigned successfully + + + Role removed successfully + + + Permission granted + + + Permission revoked + + + + + Group created successfully + + + Group updated successfully + + + Group deleted successfully + + + User added to group + + + User removed from group + + + + + Session revoked successfully + + + All sessions revoked successfully + + diff --git a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj index c5e1889cdd..7d293505a0 100644 --- a/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj +++ b/src/Modules/Identity/Modules.Identity/Modules.Identity.csproj @@ -4,7 +4,7 @@ FSH.Modules.Identity FSH.Modules.Identity FullStackHero.Modules.Identity - $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862;CA2227 + $(NoWarn);CA1031;CA1812;CA2208;S3267;S3928;CA1062;CA1304;CA1308;CA1311;CA1862;CA2227;NU1507 diff --git a/src/Modules/Identity/Modules.Identity/Services/GroupRoleService.cs b/src/Modules/Identity/Modules.Identity/Services/GroupRoleService.cs index b04e62784a..5ce957276e 100644 --- a/src/Modules/Identity/Modules.Identity/Services/GroupRoleService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/GroupRoleService.cs @@ -4,21 +4,14 @@ namespace FSH.Modules.Identity.Services; -public sealed class GroupRoleService : IGroupRoleService +public sealed class GroupRoleService(IdentityDbContext dbContext) : IGroupRoleService { - private readonly IdentityDbContext _dbContext; - - public GroupRoleService(IdentityDbContext dbContext) - { - _dbContext = dbContext; - } - public async Task> GetUserGroupRolesAsync(string userId, CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(userId); // Get all group IDs the user belongs to - var userGroupIds = await _dbContext.UserGroups + var userGroupIds = await dbContext.UserGroups .Where(ug => ug.UserId == userId) .Select(ug => ug.GroupId) .ToListAsync(ct); @@ -29,7 +22,7 @@ public async Task> GetUserGroupRolesAsync(string userId, C } // Get all distinct role names from those groups - var groupRoles = await _dbContext.GroupRoles + var groupRoles = await dbContext.GroupRoles .Where(gr => userGroupIds.Contains(gr.GroupId)) .Select(gr => gr.Role!.Name!) .Distinct() diff --git a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs index 77c62b4e1c..4d77055cc3 100644 --- a/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/IdentityService.cs @@ -12,28 +12,13 @@ namespace FSH.Modules.Identity.Services; -public sealed class IdentityService : IIdentityService +public sealed class IdentityService( + UserManager userManager, + IMultiTenantContextAccessor? multiTenantContextAccessor, + ILogger logger, + IGroupRoleService groupRoleService, + TimeProvider timeProvider) : IIdentityService { - private readonly UserManager _userManager; - private readonly ILogger _logger; - private readonly IMultiTenantContextAccessor? _multiTenantContextAccessor; - private readonly IGroupRoleService _groupRoleService; - private readonly TimeProvider _timeProvider; - - public IdentityService( - UserManager userManager, - IMultiTenantContextAccessor? multiTenantContextAccessor, - ILogger logger, - IGroupRoleService groupRoleService, - TimeProvider timeProvider) - { - _userManager = userManager; - _multiTenantContextAccessor = multiTenantContextAccessor; - _logger = logger; - _groupRoleService = groupRoleService; - _timeProvider = timeProvider; - } - public async Task<(string Subject, IEnumerable Claims)?> ValidateCredentialsAsync(string email, string password, CancellationToken ct = default) { @@ -67,24 +52,24 @@ public IdentityService( public async Task StoreRefreshTokenAsync(string subject, string refreshToken, DateTime expiresAtUtc, CancellationToken ct = default) { var tenant = GetValidatedTenant(); - var user = await _userManager.FindByIdAsync(subject) + var user = await userManager.FindByIdAsync(subject) ?? throw new UnauthorizedException("user not found"); var hashedToken = HashToken(refreshToken); user.RefreshToken = hashedToken; user.RefreshTokenExpiryTime = expiresAtUtc; - if (_logger.IsEnabled(LogLevel.Debug)) + if (logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug( + logger.LogDebug( "Storing refresh token for user {UserId} in tenant {TenantId}. Token hash: {TokenHash}, Expires: {ExpiresAt}", subject, tenant.Id, hashedToken[..Math.Min(8, hashedToken.Length)] + "...", expiresAtUtc); } - var result = await _userManager.UpdateAsync(user); + var result = await userManager.UpdateAsync(user); if (!result.Succeeded) { - _logger.LogError("Failed to persist refresh token for user {UserId}: {Errors}", + logger.LogError("Failed to persist refresh token for user {UserId}: {Errors}", subject, string.Join(", ", result.Errors.Select(e => e.Description))); throw new UnauthorizedException("could not persist refresh token"); } @@ -92,7 +77,7 @@ public async Task StoreRefreshTokenAsync(string subject, string refreshToken, Da private AppTenantInfo GetValidatedTenant() { - var tenant = _multiTenantContextAccessor!.MultiTenantContext.TenantInfo + var tenant = multiTenantContextAccessor!.MultiTenantContext.TenantInfo ?? throw new UnauthorizedException(); if (string.IsNullOrWhiteSpace(tenant.Id)) @@ -105,8 +90,8 @@ private AppTenantInfo GetValidatedTenant() private async Task FindAndValidateUserByCredentialsAsync(string email, string password) { - var user = await _userManager.FindByEmailAsync(email.Trim().Normalize()); - if (user is null || !await _userManager.CheckPasswordAsync(user, password)) + var user = await userManager.FindByEmailAsync(email.Trim().Normalize()); + if (user is null || !await userManager.CheckPasswordAsync(user, password)) { throw new UnauthorizedException(); } @@ -118,19 +103,19 @@ private async Task FindUserByRefreshTokenAsync(string refreshToken, str { var hashedToken = HashToken(refreshToken); - if (_logger.IsEnabled(LogLevel.Debug)) + if (logger.IsEnabled(LogLevel.Debug)) { - _logger.LogDebug( + logger.LogDebug( "Validating refresh token for tenant {TenantId}. Token hash: {TokenHash}", tenantId, hashedToken[..Math.Min(8, hashedToken.Length)] + "..."); } - var user = await _userManager.Users + var user = await userManager.Users .FirstOrDefaultAsync(u => u.RefreshToken == hashedToken, ct); if (user is null) { - _logger.LogWarning("No user found with matching refresh token hash for tenant {TenantId}", tenantId); + logger.LogWarning("No user found with matching refresh token hash for tenant {TenantId}", tenantId); throw new UnauthorizedException("refresh token is invalid or expired"); } @@ -139,10 +124,10 @@ private async Task FindUserByRefreshTokenAsync(string refreshToken, str private void ValidateRefreshTokenExpiry(FshUser user) { - var now = _timeProvider.GetUtcNow().UtcDateTime; + var now = timeProvider.GetUtcNow().UtcDateTime; if (user.RefreshTokenExpiryTime <= now) { - _logger.LogWarning( + logger.LogWarning( "Refresh token expired for user {UserId}. Expired at: {ExpiryTime}, Current time: {CurrentTime}", user.Id, user.RefreshTokenExpiryTime, now); throw new UnauthorizedException("refresh token is invalid or expired"); @@ -174,7 +159,7 @@ private void ValidateTenantStatus(AppTenantInfo tenant) throw new UnauthorizedException($"tenant {tenant.Id} is deactivated"); } - if (_timeProvider.GetUtcNow().UtcDateTime > tenant.ValidUpto) + if (timeProvider.GetUtcNow().UtcDateTime > tenant.ValidUpto) { throw new UnauthorizedException($"tenant {tenant.Id} validity has expired"); } @@ -189,7 +174,7 @@ private async Task> BuildUserClaimsAsync(FshUser user, string tenant private static List CreateBasicClaims(FshUser user, string tenantId) => [ - new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), + new(JwtRegisteredClaimNames.Jti, Guid.CreateVersion7().ToString()), new(ClaimTypes.NameIdentifier, user.Id), new(ClaimTypes.Email, user.Email!), new(ClaimTypes.Name, user.FirstName ?? string.Empty), @@ -202,8 +187,8 @@ private static List CreateBasicClaims(FshUser user, string tenantId) => private async Task AddRoleClaimsAsync(List claims, FshUser user, CancellationToken ct) { - var directRoles = await _userManager.GetRolesAsync(user); - var groupRoles = await _groupRoleService.GetUserGroupRolesAsync(user.Id, ct); + var directRoles = await userManager.GetRolesAsync(user); + var groupRoles = await groupRoleService.GetUserGroupRolesAsync(user.Id, ct); var allRoles = directRoles.Union(groupRoles).Distinct(); claims.AddRange(allRoles.Select(r => new Claim(ClaimTypes.Role, r))); diff --git a/src/Modules/Identity/Modules.Identity/Services/PasswordExpiryService.cs b/src/Modules/Identity/Modules.Identity/Services/PasswordExpiryService.cs index b75da9bc46..90bcbaf946 100644 --- a/src/Modules/Identity/Modules.Identity/Services/PasswordExpiryService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/PasswordExpiryService.cs @@ -7,25 +7,16 @@ namespace FSH.Modules.Identity.Services; -internal sealed class PasswordExpiryService : IPasswordExpiryService +internal sealed class PasswordExpiryService( + UserManager userManager, + IOptions passwordPolicyOptions, + TimeProvider timeProvider) : IPasswordExpiryService { - private readonly UserManager _userManager; - private readonly PasswordPolicyOptions _passwordPolicyOptions; - private readonly TimeProvider _timeProvider; - - public PasswordExpiryService( - UserManager userManager, - IOptions passwordPolicyOptions, - TimeProvider timeProvider) - { - _userManager = userManager; - _passwordPolicyOptions = passwordPolicyOptions.Value; - _timeProvider = timeProvider; - } + private readonly PasswordPolicyOptions _passwordPolicyOptions = passwordPolicyOptions.Value; public async Task IsPasswordExpiredAsync(string userId, CancellationToken cancellationToken = default) { - var user = await _userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(userId); if (user is null) { return false; @@ -36,7 +27,7 @@ public async Task IsPasswordExpiredAsync(string userId, CancellationToken public async Task GetDaysUntilExpiryAsync(string userId, CancellationToken cancellationToken = default) { - var user = await _userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(userId); if (user is null) { return int.MaxValue; @@ -47,7 +38,7 @@ public async Task GetDaysUntilExpiryAsync(string userId, CancellationToken public async Task IsPasswordExpiringWithinWarningPeriodAsync(string userId, CancellationToken cancellationToken = default) { - var user = await _userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(userId); if (user is null) { return false; @@ -58,7 +49,7 @@ public async Task IsPasswordExpiringWithinWarningPeriodAsync(string userId public async Task GetPasswordExpiryStatusAsync(string userId, CancellationToken cancellationToken = default) { - var user = await _userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(userId); if (user is null) { return new PasswordExpiryStatusDto @@ -75,11 +66,11 @@ public async Task GetPasswordExpiryStatusAsync(string u public async Task UpdateLastPasswordChangeDateAsync(string userId, CancellationToken cancellationToken = default) { - var user = await _userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(userId); if (user is not null) { - user.LastPasswordChangeDate = _timeProvider.GetUtcNow().UtcDateTime; - await _userManager.UpdateAsync(user); + user.LastPasswordChangeDate = timeProvider.GetUtcNow().UtcDateTime; + await userManager.UpdateAsync(user); } } @@ -92,7 +83,7 @@ private bool IsPasswordExpired(FshUser user) } var expiryDate = user.LastPasswordChangeDate.AddDays(_passwordPolicyOptions.PasswordExpiryDays); - return _timeProvider.GetUtcNow().UtcDateTime > expiryDate; + return timeProvider.GetUtcNow().UtcDateTime > expiryDate; } private int GetDaysUntilExpiry(FshUser user) @@ -103,7 +94,7 @@ private int GetDaysUntilExpiry(FshUser user) } var expiryDate = user.LastPasswordChangeDate.AddDays(_passwordPolicyOptions.PasswordExpiryDays); - var daysUntilExpiry = (int)(expiryDate - _timeProvider.GetUtcNow().UtcDateTime).TotalDays; + var daysUntilExpiry = (int)(expiryDate - timeProvider.GetUtcNow().UtcDateTime).TotalDays; return daysUntilExpiry; } diff --git a/src/Modules/Identity/Modules.Identity/Services/PasswordHistoryService.cs b/src/Modules/Identity/Modules.Identity/Services/PasswordHistoryService.cs index 15f4e2c296..35c6099f25 100644 --- a/src/Modules/Identity/Modules.Identity/Services/PasswordHistoryService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/PasswordHistoryService.cs @@ -7,28 +7,19 @@ namespace FSH.Modules.Identity.Services; -internal sealed class PasswordHistoryService : IPasswordHistoryService +internal sealed class PasswordHistoryService( + IdentityDbContext db, + UserManager userManager, + IOptions passwordPolicyOptions) : IPasswordHistoryService { - private readonly IdentityDbContext _db; - private readonly UserManager _userManager; - private readonly PasswordPolicyOptions _passwordPolicyOptions; - - public PasswordHistoryService( - IdentityDbContext db, - UserManager userManager, - IOptions passwordPolicyOptions) - { - _db = db; - _userManager = userManager; - _passwordPolicyOptions = passwordPolicyOptions.Value; - } + private readonly PasswordPolicyOptions _passwordPolicyOptions = passwordPolicyOptions.Value; public async Task IsPasswordInHistoryAsync(string userId, string newPassword, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(userId); ArgumentNullException.ThrowIfNull(newPassword); - var user = await _userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(userId); if (user is null) { return false; @@ -41,7 +32,7 @@ public async Task IsPasswordInHistoryAsync(string userId, string newPasswo return false; // Password history check disabled } - var recentPasswordHashes = await _db.Set() + var recentPasswordHashes = await db.Set() .Where(ph => ph.UserId == userId) .OrderByDescending(ph => ph.CreatedAt) .Take(passwordHistoryCount) @@ -51,7 +42,7 @@ public async Task IsPasswordInHistoryAsync(string userId, string newPasswo // Check if the new password matches any recent password foreach (var passwordHash in recentPasswordHashes) { - var passwordHasher = _userManager.PasswordHasher; + var passwordHasher = userManager.PasswordHasher; var result = passwordHasher.VerifyHashedPassword(user, passwordHash, newPassword); if (result == PasswordVerificationResult.Success || result == PasswordVerificationResult.SuccessRehashNeeded) @@ -67,7 +58,7 @@ public async Task SavePasswordHistoryAsync(string userId, CancellationToken canc { ArgumentNullException.ThrowIfNull(userId); - var user = await _userManager.FindByIdAsync(userId); + var user = await userManager.FindByIdAsync(userId); if (user is null || string.IsNullOrEmpty(user.PasswordHash)) { return; @@ -75,8 +66,8 @@ public async Task SavePasswordHistoryAsync(string userId, CancellationToken canc var passwordHistoryEntry = PasswordHistory.Create(userId, user.PasswordHash); - _db.Set().Add(passwordHistoryEntry); - await _db.SaveChangesAsync(cancellationToken); + db.Set().Add(passwordHistoryEntry); + await db.SaveChangesAsync(cancellationToken); // Clean up old password history entries await CleanupOldPasswordHistoryAsync(userId, cancellationToken); @@ -93,7 +84,7 @@ public async Task CleanupOldPasswordHistoryAsync(string userId, CancellationToke } // Get all password history entries for the user, ordered by most recent - var allPasswordHistories = await _db.Set() + var allPasswordHistories = await db.Set() .Where(ph => ph.UserId == userId) .OrderByDescending(ph => ph.CreatedAt) .ToListAsync(cancellationToken); @@ -105,8 +96,8 @@ public async Task CleanupOldPasswordHistoryAsync(string userId, CancellationToke .Skip(passwordHistoryCount) .ToList(); - _db.Set().RemoveRange(oldPasswordHistories); - await _db.SaveChangesAsync(cancellationToken); + db.Set().RemoveRange(oldPasswordHistories); + await db.SaveChangesAsync(cancellationToken); } } } \ No newline at end of file diff --git a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs index 691e3e44d6..f3500721da 100644 --- a/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/RequestContextService.cs @@ -10,30 +10,23 @@ namespace FSH.Modules.Identity.Services; /// Provides HTTP request context information through an abstraction. /// This allows handlers to access request metadata without direct ASP.NET Core dependencies. /// -internal sealed class RequestContextService : IRequestContextService +internal sealed class RequestContextService( + IHttpContextAccessor httpContextAccessor, + IOptions originOptions) : IRequestContextService { - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly Uri? _originUrl; - - public RequestContextService( - IHttpContextAccessor httpContextAccessor, - IOptions originOptions) - { - _httpContextAccessor = httpContextAccessor; - _originUrl = originOptions.Value.OriginUrl; - } + private readonly Uri? _originUrl = originOptions.Value.OriginUrl; public string? IpAddress => - _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); + httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); public string? UserAgent => - _httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString(); + httpContextAccessor.HttpContext?.Request.Headers.UserAgent.ToString(); public string ClientId { get { - var clientId = _httpContextAccessor.HttpContext?.Request.Headers["X-Client-Id"].ToString(); + var clientId = httpContextAccessor.HttpContext?.Request.Headers["X-Client-Id"].ToString(); return string.IsNullOrWhiteSpace(clientId) ? "web" : clientId; } } @@ -47,7 +40,7 @@ public string? Origin return _originUrl.AbsoluteUri.TrimEnd('/'); } - var request = _httpContextAccessor.HttpContext?.Request; + var request = httpContextAccessor.HttpContext?.Request; if (request is not null && !string.IsNullOrWhiteSpace(request.Scheme) && request.Host.HasValue) { return $"{request.Scheme}://{request.Host.Value}{request.PathBase}".TrimEnd('/'); diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs index ec4f6dab02..5aec485480 100644 --- a/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/SessionCleanupHostedService.cs @@ -10,27 +10,17 @@ namespace FSH.Modules.Identity.Services; /// Background service that periodically cleans up expired sessions. /// Runs every hour and removes sessions that have been expired for more than 30 days. /// -public sealed class SessionCleanupHostedService : BackgroundService +public sealed class SessionCleanupHostedService( + IServiceScopeFactory scopeFactory, + ILogger logger, + TimeProvider timeProvider) : BackgroundService { - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - private readonly TimeProvider _timeProvider; private readonly TimeSpan _cleanupInterval = TimeSpan.FromHours(1); private readonly int _retentionDays = 30; - public SessionCleanupHostedService( - IServiceScopeFactory scopeFactory, - ILogger logger, - TimeProvider timeProvider) - { - _scopeFactory = scopeFactory; - _logger = logger; - _timeProvider = timeProvider; - } - protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("Session cleanup service started"); + logger.LogInformation("Session cleanup service started"); while (!stoppingToken.IsCancellationRequested) { @@ -46,19 +36,19 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } catch (Exception ex) { - _logger.LogError(ex, "Error during session cleanup"); + logger.LogError(ex, "Error during session cleanup"); } } - _logger.LogInformation("Session cleanup service stopped"); + logger.LogInformation("Session cleanup service stopped"); } private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationToken) { - using var scope = _scopeFactory.CreateScope(); + using var scope = scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - var now = _timeProvider.GetUtcNow().UtcDateTime; + var now = timeProvider.GetUtcNow().UtcDateTime; var cutoffDate = now.AddDays(-_retentionDays); var expiredSessions = await db.UserSessions .Where(s => s.ExpiresAt < now && s.ExpiresAt < cutoffDate) @@ -68,9 +58,9 @@ private async Task CleanupExpiredSessionsAsync(CancellationToken cancellationTok { db.UserSessions.RemoveRange(expiredSessions); await db.SaveChangesAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count); + logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count); } } } diff --git a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs index 24cad698c5..1a96f1198e 100644 --- a/src/Modules/Identity/Modules.Identity/Services/SessionService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/SessionService.cs @@ -11,33 +11,18 @@ namespace FSH.Modules.Identity.Services; -public sealed class SessionService : ISessionService +public sealed class SessionService( + IdentityDbContext db, + ICurrentUser currentUser, + IMultiTenantContextAccessor multiTenantContextAccessor, + ILogger logger, + TimeProvider timeProvider) : ISessionService { - private readonly IdentityDbContext _db; - private readonly ICurrentUser _currentUser; - private readonly IMultiTenantContextAccessor _multiTenantContextAccessor; - private readonly ILogger _logger; - private readonly TimeProvider _timeProvider; - private readonly Parser _uaParser; - - public SessionService( - IdentityDbContext db, - ICurrentUser currentUser, - IMultiTenantContextAccessor multiTenantContextAccessor, - ILogger logger, - TimeProvider timeProvider) - { - _db = db; - _currentUser = currentUser; - _multiTenantContextAccessor = multiTenantContextAccessor; - _logger = logger; - _timeProvider = timeProvider; - _uaParser = Parser.GetDefault(); - } + private readonly Parser _uaParser = Parser.GetDefault(); private void EnsureValidTenant() { - if (string.IsNullOrWhiteSpace(_multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) + if (string.IsNullOrWhiteSpace(multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id)) { throw new UnauthorizedAccessException("Invalid tenant"); } @@ -67,12 +52,12 @@ public async Task CreateSessionAsync( operatingSystem: clientInfo.OS.Family, osVersion: clientInfo.OS.Major); - _db.UserSessions.Add(session); - await _db.SaveChangesAsync(cancellationToken); + db.UserSessions.Add(session); + await db.SaveChangesAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Created session {SessionId} for user {UserId}", session.Id, userId); + logger.LogInformation("Created session {SessionId} for user {UserId}", session.Id, userId); } return MapToDto(session, isCurrentSession: true); @@ -84,14 +69,14 @@ public async Task> GetUserSessionsAsync( { EnsureValidTenant(); - var currentUserId = _currentUser.GetUserId().ToString(); + var currentUserId = currentUser.GetUserId().ToString(); if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException("Cannot view sessions for another user"); } - var now = _timeProvider.GetUtcNow().UtcDateTime; - var sessions = await _db.UserSessions + var now = timeProvider.GetUtcNow().UtcDateTime; + var sessions = await db.UserSessions .AsNoTracking() .Where(s => s.UserId == userId && !s.IsRevoked && s.ExpiresAt > now) .OrderByDescending(s => s.LastActivityAt) @@ -106,8 +91,8 @@ public async Task> GetUserSessionsForAdminAsync( { EnsureValidTenant(); - var now = _timeProvider.GetUtcNow().UtcDateTime; - var sessions = await _db.UserSessions + var now = timeProvider.GetUtcNow().UtcDateTime; + var sessions = await db.UserSessions .AsNoTracking() .Include(s => s.User) .Where(s => s.UserId == userId && !s.IsRevoked && s.ExpiresAt > now) @@ -123,7 +108,7 @@ public async Task> GetUserSessionsForAdminAsync( { EnsureValidTenant(); - var session = await _db.UserSessions + var session = await db.UserSessions .AsNoTracking() .Include(s => s.User) .FirstOrDefaultAsync(s => s.Id == sessionId, cancellationToken); @@ -139,7 +124,7 @@ public async Task RevokeSessionAsync( { EnsureValidTenant(); - var session = await _db.UserSessions + var session = await db.UserSessions .FirstOrDefaultAsync(s => s.Id == sessionId && !s.IsRevoked, cancellationToken); if (session is null) @@ -147,20 +132,20 @@ public async Task RevokeSessionAsync( return false; } - var currentUserId = _currentUser.GetUserId().ToString(); + var currentUserId = currentUser.GetUserId().ToString(); if (!string.Equals(session.UserId, currentUserId, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException("Cannot revoke session for another user"); } - var tenantId = _multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; + var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; session.Revoke(revokedBy, reason ?? "User requested", tenantId); - await _db.SaveChangesAsync(cancellationToken); + await db.SaveChangesAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Session {SessionId} revoked by {RevokedBy}", sessionId, revokedBy); + logger.LogInformation("Session {SessionId} revoked by {RevokedBy}", sessionId, revokedBy); } return true; @@ -175,13 +160,13 @@ public async Task RevokeAllSessionsAsync( { EnsureValidTenant(); - var currentUserId = _currentUser.GetUserId().ToString(); + var currentUserId = currentUser.GetUserId().ToString(); if (!string.Equals(userId, currentUserId, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException("Cannot revoke sessions for another user"); } - var query = _db.UserSessions + var query = db.UserSessions .Where(s => s.UserId == userId && !s.IsRevoked); if (exceptSessionId.HasValue) @@ -191,17 +176,17 @@ public async Task RevokeAllSessionsAsync( var sessions = await query.ToListAsync(cancellationToken); - var tenantId = _multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; + var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; foreach (var session in sessions) { session.Revoke(revokedBy, reason ?? "User requested logout from all devices", tenantId); } - await _db.SaveChangesAsync(cancellationToken); + await db.SaveChangesAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Revoked {Count} sessions for user {UserId}", sessions.Count, userId); + logger.LogInformation("Revoked {Count} sessions for user {UserId}", sessions.Count, userId); } return sessions.Count; @@ -215,21 +200,21 @@ public async Task RevokeAllSessionsForAdminAsync( { EnsureValidTenant(); - var sessions = await _db.UserSessions + var sessions = await db.UserSessions .Where(s => s.UserId == userId && !s.IsRevoked) .ToListAsync(cancellationToken); - var tenantId = _multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; + var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; foreach (var session in sessions) { session.Revoke(revokedBy, reason ?? "Admin requested", tenantId); } - await _db.SaveChangesAsync(cancellationToken); + await db.SaveChangesAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Admin {AdminId} revoked {Count} sessions for user {UserId}", + logger.LogInformation("Admin {AdminId} revoked {Count} sessions for user {UserId}", revokedBy, sessions.Count, userId); } @@ -244,7 +229,7 @@ public async Task RevokeSessionForAdminAsync( { EnsureValidTenant(); - var session = await _db.UserSessions + var session = await db.UserSessions .FirstOrDefaultAsync(s => s.Id == sessionId && !s.IsRevoked, cancellationToken); if (session is null) @@ -252,14 +237,14 @@ public async Task RevokeSessionForAdminAsync( return false; } - var tenantId = _multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; + var tenantId = multiTenantContextAccessor?.MultiTenantContext?.TenantInfo?.Id; session.Revoke(revokedBy, reason ?? "Admin requested", tenantId); - await _db.SaveChangesAsync(cancellationToken); + await db.SaveChangesAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Admin {AdminId} revoked session {SessionId}", revokedBy, sessionId); + logger.LogInformation("Admin {AdminId} revoked session {SessionId}", revokedBy, sessionId); } return true; @@ -271,13 +256,13 @@ public async Task UpdateSessionActivityAsync( { EnsureValidTenant(); - var session = await _db.UserSessions + var session = await db.UserSessions .FirstOrDefaultAsync(s => s.RefreshTokenHash == refreshTokenHash && !s.IsRevoked, cancellationToken); if (session is not null) { session.UpdateActivity(); - await _db.SaveChangesAsync(cancellationToken); + await db.SaveChangesAsync(cancellationToken); } } @@ -289,17 +274,17 @@ public async Task UpdateSessionRefreshTokenAsync( { EnsureValidTenant(); - var session = await _db.UserSessions + var session = await db.UserSessions .FirstOrDefaultAsync(s => s.RefreshTokenHash == oldRefreshTokenHash && !s.IsRevoked, cancellationToken); if (session is not null) { session.UpdateRefreshToken(newRefreshTokenHash, newExpiresAt); - await _db.SaveChangesAsync(cancellationToken); + await db.SaveChangesAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Updated session {SessionId} with new refresh token", session.Id); + logger.LogInformation("Updated session {SessionId} with new refresh token", session.Id); } } } @@ -310,7 +295,7 @@ public async Task ValidateSessionAsync( { EnsureValidTenant(); - var session = await _db.UserSessions + var session = await db.UserSessions .AsNoTracking() .FirstOrDefaultAsync(s => s.RefreshTokenHash == refreshTokenHash, cancellationToken); @@ -319,7 +304,7 @@ public async Task ValidateSessionAsync( return true; // No session tracking for this token (backwards compatibility) } - return !session.IsRevoked && session.ExpiresAt > _timeProvider.GetUtcNow().UtcDateTime; + return !session.IsRevoked && session.ExpiresAt > timeProvider.GetUtcNow().UtcDateTime; } public async Task GetSessionIdByRefreshTokenAsync( @@ -328,7 +313,7 @@ public async Task ValidateSessionAsync( { EnsureValidTenant(); - var session = await _db.UserSessions + var session = await db.UserSessions .AsNoTracking() .FirstOrDefaultAsync(s => s.RefreshTokenHash == refreshTokenHash && !s.IsRevoked, cancellationToken); @@ -338,19 +323,19 @@ public async Task ValidateSessionAsync( public async Task CleanupExpiredSessionsAsync( CancellationToken cancellationToken = default) { - var now = _timeProvider.GetUtcNow().UtcDateTime; + var now = timeProvider.GetUtcNow().UtcDateTime; var cutoffDate = now.AddDays(-30); // Keep revoked sessions for 30 days for audit - var expiredSessions = await _db.UserSessions + var expiredSessions = await db.UserSessions .Where(s => s.ExpiresAt < now && s.ExpiresAt < cutoffDate) .ToListAsync(cancellationToken); if (expiredSessions.Count > 0) { - _db.UserSessions.RemoveRange(expiredSessions); - await _db.SaveChangesAsync(cancellationToken); - if (_logger.IsEnabled(LogLevel.Information)) + db.UserSessions.RemoveRange(expiredSessions); + await db.SaveChangesAsync(cancellationToken); + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count); + logger.LogInformation("Cleaned up {Count} expired sessions", expiredSessions.Count); } } } @@ -372,7 +357,7 @@ private UserSessionDto MapToDto(UserSession session, bool isCurrentSession) CreatedAt = session.CreatedAt, LastActivityAt = session.LastActivityAt, ExpiresAt = session.ExpiresAt, - IsActive = !session.IsRevoked && session.ExpiresAt > _timeProvider.GetUtcNow().UtcDateTime, + IsActive = !session.IsRevoked && session.ExpiresAt > timeProvider.GetUtcNow().UtcDateTime, IsCurrentSession = isCurrentSession }; } diff --git a/src/Modules/Identity/Modules.Identity/Services/TokenService.cs b/src/Modules/Identity/Modules.Identity/Services/TokenService.cs index 661928badf..466821da86 100644 --- a/src/Modules/Identity/Modules.Identity/Services/TokenService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/TokenService.cs @@ -48,7 +48,7 @@ public Task IssueAsync( var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtToken); // Refresh token - var refreshToken = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); + var refreshToken = Convert.ToBase64String(Guid.CreateVersion7().ToByteArray()); var refreshTokenExpiry = now.AddDays(_options.RefreshTokenDays); if (_logger.IsEnabled(LogLevel.Information)) diff --git a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs index 6b43132782..2e2eb2aae4 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserPasswordService.cs @@ -27,12 +27,7 @@ public async Task ForgotPasswordAsync(string email, string origin, CancellationT { EnsureValidTenant(); - var user = await userManager.FindByEmailAsync(email); - if (user == null) - { - throw new NotFoundException("user not found"); - } - + var user = await userManager.FindByEmailAsync(email) ?? throw new NotFoundException("user not found"); if (string.IsNullOrWhiteSpace(user.Email)) { throw new InvalidOperationException("user email cannot be null or empty"); @@ -54,12 +49,7 @@ public async Task ResetPasswordAsync(string email, string password, string token { EnsureValidTenant(); - var user = await userManager.FindByEmailAsync(email); - if (user == null) - { - throw new NotFoundException("user not found"); - } - + var user = await userManager.FindByEmailAsync(email) ?? throw new NotFoundException("user not found"); token = Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(token)); var result = await userManager.ResetPasswordAsync(user, token, password); diff --git a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs index 30b6fd7f43..bff6c80a2a 100644 --- a/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs +++ b/src/Modules/Identity/Modules.Identity/Services/UserRegistrationService.cs @@ -170,7 +170,7 @@ private async Task EnsureUniqueUserNameAsync(string userName) { if (await userManager.FindByNameAsync(userName) is not null) { - return $"{userName}_{Guid.NewGuid():N}"[..20]; + return $"{userName}_{Guid.CreateVersion7():N}"[..20]; } return userName; } @@ -265,10 +265,10 @@ private async Task PublishUserRegisteredAsync( await db.SaveChangesAsync(cancellationToken); var integrationEvent = new UserRegisteredIntegrationEvent( - Id: Guid.NewGuid(), + Id: Guid.CreateVersion7(), OccurredOnUtc: TimeProvider.System.GetUtcNow().UtcDateTime, TenantId: tenantId, - CorrelationId: Guid.NewGuid().ToString(), + CorrelationId: Guid.CreateVersion7().ToString(), Source: source, UserId: user.Id, Email: user.Email ?? string.Empty, diff --git a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj index 0ff7845ef8..375e1fa73a 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj +++ b/src/Modules/Multitenancy/Modules.Multitenancy.Contracts/Modules.Multitenancy.Contracts.csproj @@ -3,7 +3,7 @@ FSH.Modules.Multitenancy.Contracts FSH.Modules.Multitenancy.Contracts FullStackHero.Modules.Multitenancy.Contracts - $(NoWarn);CA1056;S2094 + $(NoWarn);CA1056;S2094;NU1507 diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs index 6caa4c5b1a..075ccfc1a9 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Data/TenantDbContext.cs @@ -6,15 +6,10 @@ namespace FSH.Modules.Multitenancy.Data; -public class TenantDbContext : EFCoreStoreDbContext +public class TenantDbContext(DbContextOptions options) : EFCoreStoreDbContext(options) { public const string Schema = "tenant"; - public TenantDbContext(DbContextOptions options) - : base(options) - { - } - public DbSet TenantProvisionings => Set(); public DbSet TenantProvisioningSteps => Set(); diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs index f263d9c63e..2434b78b60 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Domain/TenantTheme.cs @@ -58,7 +58,7 @@ public static TenantTheme Create(string tenantId, string? createdBy = null) { return new TenantTheme { - Id = Guid.NewGuid(), + Id = Guid.CreateVersion7(), TenantId = tenantId, CreatedBy = createdBy, CreatedOnUtc = DateTimeOffset.UtcNow diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandHandler.cs index 5d8867bc4d..2ae909a896 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandHandler.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/ChangeTenantActivation/ChangeTenantActivationCommandHandler.cs @@ -5,15 +5,8 @@ namespace FSH.Modules.Multitenancy.Features.v1.ChangeTenantActivation; -public sealed class ChangeTenantActivationCommandHandler : ICommandHandler +public sealed class ChangeTenantActivationCommandHandler(ITenantService tenantService) : ICommandHandler { - private readonly ITenantService _tenantService; - - public ChangeTenantActivationCommandHandler(ITenantService tenantService) - { - _tenantService = tenantService; - } - public async ValueTask Handle(ChangeTenantActivationCommand command, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(command); @@ -22,14 +15,14 @@ public async ValueTask Handle(ChangeTenantActivationCo if (command.IsActive) { - message = await _tenantService.ActivateAsync(command.TenantId, cancellationToken).ConfigureAwait(false); + message = await tenantService.ActivateAsync(command.TenantId, cancellationToken).ConfigureAwait(false); } else { - message = await _tenantService.DeactivateAsync(command.TenantId, cancellationToken).ConfigureAwait(false); + message = await tenantService.DeactivateAsync(command.TenantId, cancellationToken).ConfigureAwait(false); } - var status = await _tenantService.GetStatusAsync(command.TenantId, cancellationToken).ConfigureAwait(false); + var status = await tenantService.GetStatusAsync(command.TenantId, cancellationToken).ConfigureAwait(false); return new TenantLifecycleResultDto { diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/GetTenantMigrationsQueryHandler.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/GetTenantMigrationsQueryHandler.cs index 4b9f19aaa7..1ac142c4f4 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/GetTenantMigrationsQueryHandler.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Features/v1/GetTenantMigrations/GetTenantMigrationsQueryHandler.cs @@ -9,25 +9,16 @@ namespace FSH.Modules.Multitenancy.Features.v1.GetTenantMigrations; -public sealed class GetTenantMigrationsQueryHandler - : IQueryHandler> +public sealed class GetTenantMigrationsQueryHandler( + IMultiTenantStore tenantStore, + IServiceScopeFactory scopeFactory) + : IQueryHandler> { - private readonly IMultiTenantStore _tenantStore; - private readonly IServiceScopeFactory _scopeFactory; - - public GetTenantMigrationsQueryHandler( - IMultiTenantStore tenantStore, - IServiceScopeFactory scopeFactory) - { - _tenantStore = tenantStore; - _scopeFactory = scopeFactory; - } - public async ValueTask> Handle( GetTenantMigrationsQuery query, CancellationToken cancellationToken) { - var tenants = await _tenantStore.GetAllAsync().ConfigureAwait(false); + var tenants = await tenantStore.GetAllAsync().ConfigureAwait(false); var tenantMigrationStatuses = new List(); @@ -43,7 +34,7 @@ public async ValueTask> Handle( try { - using IServiceScope tenantScope = _scopeFactory.CreateScope(); + using IServiceScope tenantScope = scopeFactory.CreateScope(); tenantScope.ServiceProvider.GetRequiredService() .MultiTenantContext = new MultiTenantContext(tenant); diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/TenantResource.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/TenantResource.cs new file mode 100644 index 0000000000..1cf92c916c --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/TenantResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Modules.Multitenancy.Localization; + +#pragma warning disable S2094 +public sealed class TenantResource; +#pragma warning restore S2094 diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Localization/TenantResource.resx b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/TenantResource.resx new file mode 100644 index 0000000000..e4480ca523 --- /dev/null +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Localization/TenantResource.resx @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Tenant created successfully + Tenant updated successfully + Tenant deleted successfully + Tenant activated + Tenant deactivated + Tenant upgraded successfully + Tenant downgraded successfully + Subscription renewed + Subscription cancelled + Subscription has expired + Subscription expiring soon + Tenant limit has been reached + Invalid tenant + Tenant not found + Tenant already exists + Connection string updated + Database created successfully + Database deleted successfully + Migration completed successfully + Theme updated successfully + diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj index 7e0df4724c..4fd592f8c6 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Modules.Multitenancy.csproj @@ -3,7 +3,7 @@ FSH.Modules.Multitenancy FSH.Modules.Multitenancy FullStackHero.Modules.Multitenancy - $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172 + $(NoWarn);CA1031;CA1056;CA1008;CA1716;CA1812;S1135;S2139;S6667;S3267;S1172;NU1507 diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs index 06f5aabc02..5325e20f0d 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioning.cs @@ -2,7 +2,7 @@ namespace FSH.Modules.Multitenancy.Provisioning; public sealed class TenantProvisioning { - public Guid Id { get; private set; } = Guid.NewGuid(); + public Guid Id { get; private set; } = Guid.CreateVersion7(); public string TenantId { get; private set; } = default!; diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs index f06e2f0798..e4719c4a38 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningJob.cs @@ -9,79 +9,64 @@ namespace FSH.Modules.Multitenancy.Provisioning; -public sealed class TenantProvisioningJob +public sealed class TenantProvisioningJob( + ITenantProvisioningService provisioningService, + IMultiTenantStore tenantStore, + IMultiTenantContextSetter tenantContextSetter, + ITenantService tenantService, + ILogger logger) { - private readonly ITenantProvisioningService _provisioningService; - private readonly IMultiTenantStore _tenantStore; - private readonly IMultiTenantContextSetter _tenantContextSetter; - private readonly ITenantService _tenantService; - private readonly ILogger _logger; - - public TenantProvisioningJob( - ITenantProvisioningService provisioningService, - IMultiTenantStore tenantStore, - IMultiTenantContextSetter tenantContextSetter, - ITenantService tenantService, - ILogger logger) - { - _provisioningService = provisioningService; - _tenantStore = tenantStore; - _tenantContextSetter = tenantContextSetter; - _tenantService = tenantService; - _logger = logger; - } - public async Task RunAsync(string tenantId, string correlationId, CancellationToken cancellationToken = default) { - var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) + var tenant = await tenantStore.GetAsync(tenantId).ConfigureAwait(false) ?? throw new NotFoundException($"Tenant {tenantId} not found during provisioning."); var currentStep = TenantProvisioningStepName.Database; try { - var runDatabase = await _provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); + var runDatabase = await provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); - _tenantContextSetter.MultiTenantContext = new MultiTenantContext(tenant); + tenantContextSetter.MultiTenantContext = new MultiTenantContext(tenant); if (runDatabase) { - await _provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); + await provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); } currentStep = TenantProvisioningStepName.Migrations; - var runMigrations = await _provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); + var runMigrations = await provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); if (runMigrations) { - await _tenantService.MigrateTenantAsync(tenant, cancellationToken).ConfigureAwait(false); - await _provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); + await tenantService.MigrateTenantAsync(tenant, cancellationToken).ConfigureAwait(false); + await provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); } currentStep = TenantProvisioningStepName.Seeding; - var runSeeding = await _provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); + var runSeeding = await provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); if (runSeeding) { - await _tenantService.SeedTenantAsync(tenant, cancellationToken).ConfigureAwait(false); - await _provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); + await tenantService.SeedTenantAsync(tenant, cancellationToken).ConfigureAwait(false); + await provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); } currentStep = TenantProvisioningStepName.CacheWarm; - var runCacheWarm = await _provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); + var runCacheWarm = await provisioningService.MarkRunningAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); if (runCacheWarm) { - await _provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); + await provisioningService.MarkStepCompletedAsync(tenantId, correlationId, currentStep, cancellationToken).ConfigureAwait(false); } - await _provisioningService.MarkCompletedAsync(tenantId, correlationId, cancellationToken).ConfigureAwait(false); + await provisioningService.MarkCompletedAsync(tenantId, correlationId, cancellationToken).ConfigureAwait(false); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Provisioned tenant {TenantId} correlation {CorrelationId}", tenantId, correlationId); + logger.LogInformation("Provisioned tenant {TenantId} correlation {CorrelationId}", tenantId, correlationId); } } catch (Exception ex) { - _logger.LogError(ex, "Provisioning failed for tenant {TenantId}", tenantId); - await _provisioningService.MarkFailedAsync(tenantId, correlationId, currentStep, ex.Message, cancellationToken).ConfigureAwait(false); + logger.LogError(ex, "Provisioning failed for tenant {TenantId}", tenantId); + await provisioningService.MarkFailedAsync(tenantId, correlationId, currentStep, ex.Message, cancellationToken).ConfigureAwait(false); throw; } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs index c7a9ec719a..9bfb3a1c9e 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningService.cs @@ -11,31 +11,16 @@ namespace FSH.Modules.Multitenancy.Provisioning; -public sealed class TenantProvisioningService : ITenantProvisioningService +public sealed class TenantProvisioningService( + TenantDbContext dbContext, + IMultiTenantStore tenantStore, + IJobService jobService, + IServiceScopeFactory scopeFactory, + ILogger logger) : ITenantProvisioningService { - private readonly TenantDbContext _dbContext; - private readonly IMultiTenantStore _tenantStore; - private readonly IJobService _jobService; - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - public TenantProvisioningService( - TenantDbContext dbContext, - IMultiTenantStore tenantStore, - IJobService jobService, - IServiceScopeFactory scopeFactory, - ILogger logger) - { - _dbContext = dbContext; - _tenantStore = tenantStore; - _jobService = jobService; - _scopeFactory = scopeFactory; - _logger = logger; - } - public async Task StartAsync(string tenantId, CancellationToken cancellationToken) { - var tenant = await _tenantStore.GetAsync(tenantId).ConfigureAwait(false) + var tenant = await tenantStore.GetAsync(tenantId).ConfigureAwait(false) ?? throw new NotFoundException($"Tenant {tenantId} not found for provisioning."); var existing = await GetLatestAsync(tenantId, cancellationToken).ConfigureAwait(false); @@ -44,7 +29,7 @@ public async Task StartAsync(string tenantId, CancellationTo throw new CustomException($"Provisioning already running for tenant {tenantId}."); } - var correlationId = Guid.NewGuid().ToString(); + var correlationId = Guid.CreateVersion7().ToString(); var provisioning = new TenantProvisioning(tenant.Id, correlationId); provisioning.Steps.Add(new TenantProvisioningStep(provisioning.Id, TenantProvisioningStepName.Database)); @@ -52,29 +37,29 @@ public async Task StartAsync(string tenantId, CancellationTo provisioning.Steps.Add(new TenantProvisioningStep(provisioning.Id, TenantProvisioningStepName.Seeding)); provisioning.Steps.Add(new TenantProvisioningStep(provisioning.Id, TenantProvisioningStepName.CacheWarm)); - _dbContext.Add(provisioning); - await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + dbContext.Add(provisioning); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); if (!TryEnsureJobStorage()) { - _logger.LogWarning("Background job storage not available; running provisioning inline for tenant {TenantId}.", tenantId); + logger.LogWarning("Background job storage not available; running provisioning inline for tenant {TenantId}.", tenantId); provisioning.SetJobId("inline"); - await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await RunInlineProvisioningAsync(tenant.Id, correlationId, cancellationToken).ConfigureAwait(false); return provisioning; } - var jobId = _jobService.Enqueue(job => job.RunAsync(tenant.Id, correlationId)); + var jobId = jobService.Enqueue(job => job.RunAsync(tenant.Id, correlationId)); provisioning.SetJobId(jobId); - await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return provisioning; } public async Task GetLatestAsync(string tenantId, CancellationToken cancellationToken) { - return await _dbContext.Set() + return await dbContext.Set() .Include(p => p.Steps) .Where(p => p.TenantId == tenantId) .OrderByDescending(p => p.CreatedUtc) @@ -123,7 +108,7 @@ public async Task MarkRunningAsync(string tenantId, string correlationId, provisioning.MarkRunning(step.ToString()); stepEntity.MarkRunning(); - await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return true; } @@ -138,7 +123,7 @@ public async Task MarkStepCompletedAsync(string tenantId, string correlationId, } stepEntity.MarkCompleted(); - await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task MarkFailedAsync(string tenantId, string correlationId, TenantProvisioningStepName step, string error, CancellationToken cancellationToken) @@ -149,7 +134,7 @@ public async Task MarkFailedAsync(string tenantId, string correlationId, TenantP var stepEntity = provisioning.Steps.First(s => s.Step == step); stepEntity.MarkFailed(error); - await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } public async Task MarkCompletedAsync(string tenantId, string correlationId, CancellationToken cancellationToken) @@ -162,12 +147,12 @@ public async Task MarkCompletedAsync(string tenantId, string correlationId, Canc } provisioning.MarkCompleted(); - await _dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } private async Task RequireAsync(string tenantId, string correlationId, CancellationToken cancellationToken) { - return await _dbContext.Set() + return await dbContext.Set() .Include(p => p.Steps) .FirstOrDefaultAsync(p => p.TenantId == tenantId && p.CorrelationId == correlationId, cancellationToken) .ConfigureAwait(false) @@ -189,7 +174,7 @@ private static bool TryEnsureJobStorage() private async Task RunInlineProvisioningAsync(string tenantId, string correlationId, CancellationToken cancellationToken) { - using var scope = _scopeFactory.CreateScope(); + using var scope = scopeFactory.CreateScope(); var job = scope.ServiceProvider.GetRequiredService(); await job.RunAsync(tenantId, correlationId, cancellationToken).ConfigureAwait(false); } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStep.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStep.cs index 1a69a69a2f..39d529a800 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStep.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantProvisioningStep.cs @@ -4,7 +4,7 @@ namespace FSH.Modules.Multitenancy.Provisioning; public sealed class TenantProvisioningStep { - public Guid Id { get; private set; } = Guid.NewGuid(); + public Guid Id { get; private set; } = Guid.CreateVersion7(); public Guid ProvisioningId { get; private set; } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantStoreInitializerHostedService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantStoreInitializerHostedService.cs index ce6576fd15..f06e0e61ce 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantStoreInitializerHostedService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Provisioning/TenantStoreInitializerHostedService.cs @@ -10,26 +10,17 @@ namespace FSH.Modules.Multitenancy.Provisioning; /// /// Initializes the tenant catalog database and seeds the root tenant on startup. /// -public sealed class TenantStoreInitializerHostedService : IHostedService +public sealed class TenantStoreInitializerHostedService( + IServiceProvider serviceProvider, + ILogger logger) : IHostedService { - private readonly IServiceProvider _serviceProvider; - private readonly ILogger _logger; - - public TenantStoreInitializerHostedService( - IServiceProvider serviceProvider, - ILogger logger) - { - _serviceProvider = serviceProvider; - _logger = logger; - } - public async Task StartAsync(CancellationToken cancellationToken) { - using var scope = _serviceProvider.CreateScope(); + using var scope = serviceProvider.CreateScope(); var tenantDbContext = scope.ServiceProvider.GetRequiredService(); await tenantDbContext.Database.MigrateAsync(cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Applied tenant catalog migrations."); + logger.LogInformation("Applied tenant catalog migrations."); if (await tenantDbContext.TenantInfo.FindAsync([MultitenancyConstants.Root.Id], cancellationToken).ConfigureAwait(false) is null) { @@ -45,7 +36,7 @@ public async Task StartAsync(CancellationToken cancellationToken) await tenantDbContext.TenantInfo.AddAsync(rootTenant, cancellationToken).ConfigureAwait(false); await tenantDbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); - _logger.LogInformation("Seeded root tenant."); + logger.LogInformation("Seeded root tenant."); } } diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs index abe1b56c7f..79ef489830 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/Services/TenantThemeService.cs @@ -15,38 +15,21 @@ namespace FSH.Modules.Multitenancy.Services; -public sealed class TenantThemeService : ITenantThemeService +public sealed class TenantThemeService( + ICacheService cache, + TenantDbContext dbContext, + IMultiTenantContextAccessor tenantAccessor, + IStorageService storageService, + ILogger logger, + ICurrentUser currentUser) : ITenantThemeService { private const string CacheKeyPrefix = "theme:"; private const string DefaultThemeCacheKey = "theme:default"; private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1); - private readonly ICacheService _cache; - private readonly TenantDbContext _dbContext; - private readonly IMultiTenantContextAccessor _tenantAccessor; - private readonly IStorageService _storageService; - private readonly ILogger _logger; - private readonly ICurrentUser _currentUser; - - public TenantThemeService( - ICacheService cache, - TenantDbContext dbContext, - IMultiTenantContextAccessor tenantAccessor, - IStorageService storageService, - ILogger logger, - ICurrentUser currentUser) - { - _cache = cache; - _dbContext = dbContext; - _tenantAccessor = tenantAccessor; - _storageService = storageService; - _logger = logger; - _currentUser = currentUser; - } - public async Task GetCurrentTenantThemeAsync(CancellationToken ct = default) { - var tenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id + var tenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id ?? throw new InvalidOperationException("No tenant context available"); return await GetThemeAsync(tenantId, ct).ConfigureAwait(false); } @@ -57,7 +40,7 @@ public async Task GetThemeAsync(string tenantId, CancellationTok var cacheKey = $"{CacheKeyPrefix}{tenantId}"; - var theme = await _cache.GetOrSetAsync( + var theme = await cache.GetOrSetAsync( cacheKey, async () => await LoadThemeFromDbAsync(tenantId, ct).ConfigureAwait(false), CacheDuration, @@ -68,7 +51,7 @@ public async Task GetThemeAsync(string tenantId, CancellationTok public async Task GetDefaultThemeAsync(CancellationToken ct = default) { - var theme = await _cache.GetOrSetAsync( + var theme = await cache.GetOrSetAsync( DefaultThemeCacheKey, async () => await LoadDefaultThemeFromDbAsync(ct).ConfigureAwait(false), CacheDuration, @@ -82,14 +65,14 @@ public async Task UpdateThemeAsync(string tenantId, TenantThemeDto theme, Cancel ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentNullException.ThrowIfNull(theme); - var entity = await _dbContext.TenantThemes + var entity = await dbContext.TenantThemes .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct) .ConfigureAwait(false); if (entity is null) { entity = TenantTheme.Create(tenantId); - _dbContext.TenantThemes.Add(entity); + dbContext.TenantThemes.Add(entity); } // Handle brand asset uploads @@ -98,12 +81,12 @@ public async Task UpdateThemeAsync(string tenantId, TenantThemeDto theme, Cancel MapDtoToEntity(theme, entity); entity.Update(GetCurrentUserId()); - await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); await InvalidateCacheAsync(tenantId, ct).ConfigureAwait(false); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Updated theme for tenant {TenantId}", tenantId); + logger.LogInformation("Updated theme for tenant {TenantId}", tenantId); } } @@ -113,15 +96,15 @@ private async Task HandleBrandAssetUploadsAsync(BrandAssetsDto assets, TenantThe if (assets.Logo?.Data is { Count: > 0 }) { var oldLogoUrl = entity.LogoUrl; - entity.LogoUrl = await _storageService.UploadAsync(assets.Logo, FileType.Image, ct).ConfigureAwait(false); + entity.LogoUrl = await storageService.UploadAsync(assets.Logo, FileType.Image, ct).ConfigureAwait(false); if (!string.IsNullOrEmpty(oldLogoUrl)) { - await _storageService.RemoveAsync(oldLogoUrl, ct).ConfigureAwait(false); + await storageService.RemoveAsync(oldLogoUrl, ct).ConfigureAwait(false); } } else if (assets.DeleteLogo && !string.IsNullOrEmpty(entity.LogoUrl)) { - await _storageService.RemoveAsync(entity.LogoUrl, ct).ConfigureAwait(false); + await storageService.RemoveAsync(entity.LogoUrl, ct).ConfigureAwait(false); entity.LogoUrl = null; } @@ -129,15 +112,15 @@ private async Task HandleBrandAssetUploadsAsync(BrandAssetsDto assets, TenantThe if (assets.LogoDark?.Data is { Count: > 0 }) { var oldLogoUrl = entity.LogoDarkUrl; - entity.LogoDarkUrl = await _storageService.UploadAsync(assets.LogoDark, FileType.Image, ct).ConfigureAwait(false); + entity.LogoDarkUrl = await storageService.UploadAsync(assets.LogoDark, FileType.Image, ct).ConfigureAwait(false); if (!string.IsNullOrEmpty(oldLogoUrl)) { - await _storageService.RemoveAsync(oldLogoUrl, ct).ConfigureAwait(false); + await storageService.RemoveAsync(oldLogoUrl, ct).ConfigureAwait(false); } } else if (assets.DeleteLogoDark && !string.IsNullOrEmpty(entity.LogoDarkUrl)) { - await _storageService.RemoveAsync(entity.LogoDarkUrl, ct).ConfigureAwait(false); + await storageService.RemoveAsync(entity.LogoDarkUrl, ct).ConfigureAwait(false); entity.LogoDarkUrl = null; } @@ -145,15 +128,15 @@ private async Task HandleBrandAssetUploadsAsync(BrandAssetsDto assets, TenantThe if (assets.Favicon?.Data is { Count: > 0 }) { var oldFaviconUrl = entity.FaviconUrl; - entity.FaviconUrl = await _storageService.UploadAsync(assets.Favicon, FileType.Image, ct).ConfigureAwait(false); + entity.FaviconUrl = await storageService.UploadAsync(assets.Favicon, FileType.Image, ct).ConfigureAwait(false); if (!string.IsNullOrEmpty(oldFaviconUrl)) { - await _storageService.RemoveAsync(oldFaviconUrl, ct).ConfigureAwait(false); + await storageService.RemoveAsync(oldFaviconUrl, ct).ConfigureAwait(false); } } else if (assets.DeleteFavicon && !string.IsNullOrEmpty(entity.FaviconUrl)) { - await _storageService.RemoveAsync(entity.FaviconUrl, ct).ConfigureAwait(false); + await storageService.RemoveAsync(entity.FaviconUrl, ct).ConfigureAwait(false); entity.FaviconUrl = null; } } @@ -162,7 +145,7 @@ public async Task ResetThemeAsync(string tenantId, CancellationToken ct = defaul { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); - var entity = await _dbContext.TenantThemes + var entity = await dbContext.TenantThemes .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct) .ConfigureAwait(false); @@ -170,14 +153,14 @@ public async Task ResetThemeAsync(string tenantId, CancellationToken ct = defaul { entity.ResetToDefaults(); entity.Update(GetCurrentUserId()); - await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); } await InvalidateCacheAsync(tenantId, ct).ConfigureAwait(false); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Reset theme to defaults for tenant {TenantId}", tenantId); + logger.LogInformation("Reset theme to defaults for tenant {TenantId}", tenantId); } } @@ -186,53 +169,44 @@ public async Task SetAsDefaultThemeAsync(string tenantId, CancellationToken ct = ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); // Ensure only root tenant can set default theme - var currentTenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id; + var currentTenantId = tenantAccessor.MultiTenantContext?.TenantInfo?.Id; if (currentTenantId != MultitenancyConstants.Root.Id) { throw new ForbiddenException("Only the root tenant can set the default theme"); } // Clear existing default - var existingDefault = await _dbContext.TenantThemes + var existingDefault = await dbContext.TenantThemes .FirstOrDefaultAsync(t => t.IsDefault, ct) .ConfigureAwait(false); - if (existingDefault is not null) - { - existingDefault.IsDefault = false; - } + existingDefault?.IsDefault = false; // Set new default - var entity = await _dbContext.TenantThemes + var entity = await dbContext.TenantThemes .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct) - .ConfigureAwait(false); - - if (entity is null) - { - throw new NotFoundException($"Theme for tenant {tenantId} not found"); - } - + .ConfigureAwait(false) ?? throw new NotFoundException($"Theme for tenant {tenantId} not found"); entity.IsDefault = true; - await _dbContext.SaveChangesAsync(ct).ConfigureAwait(false); + await dbContext.SaveChangesAsync(ct).ConfigureAwait(false); // Invalidate default theme cache - await _cache.RemoveItemAsync(DefaultThemeCacheKey, ct).ConfigureAwait(false); + await cache.RemoveItemAsync(DefaultThemeCacheKey, ct).ConfigureAwait(false); - if (_logger.IsEnabled(LogLevel.Information)) + if (logger.IsEnabled(LogLevel.Information)) { - _logger.LogInformation("Set theme for tenant {TenantId} as default", tenantId); + logger.LogInformation("Set theme for tenant {TenantId} as default", tenantId); } } public async Task InvalidateCacheAsync(string tenantId, CancellationToken ct = default) { var cacheKey = $"{CacheKeyPrefix}{tenantId}"; - await _cache.RemoveItemAsync(cacheKey, ct).ConfigureAwait(false); + await cache.RemoveItemAsync(cacheKey, ct).ConfigureAwait(false); } private async Task LoadThemeFromDbAsync(string tenantId, CancellationToken ct) { - var entity = await _dbContext.TenantThemes + var entity = await dbContext.TenantThemes .AsNoTracking() .FirstOrDefaultAsync(t => t.TenantId == tenantId, ct) .ConfigureAwait(false); @@ -242,7 +216,7 @@ public async Task InvalidateCacheAsync(string tenantId, CancellationToken ct = d private async Task LoadDefaultThemeFromDbAsync(CancellationToken ct) { - var entity = await _dbContext.TenantThemes + var entity = await dbContext.TenantThemes .AsNoTracking() .FirstOrDefaultAsync(t => t.IsDefault, ct) .ConfigureAwait(false); @@ -352,7 +326,7 @@ private static void MapDtoToEntity(TenantThemeDto dto, TenantTheme entity) private string? GetCurrentUserId() { - var userId = _currentUser.GetUserId(); + var userId = currentUser.GetUserId(); return userId == Guid.Empty ? null : userId.ToString(); } } \ No newline at end of file diff --git a/src/Modules/Multitenancy/Modules.Multitenancy/TenantMigrationsHealthCheck.cs b/src/Modules/Multitenancy/Modules.Multitenancy/TenantMigrationsHealthCheck.cs index b00b035074..0924fcfe7e 100644 --- a/src/Modules/Multitenancy/Modules.Multitenancy/TenantMigrationsHealthCheck.cs +++ b/src/Modules/Multitenancy/Modules.Multitenancy/TenantMigrationsHealthCheck.cs @@ -8,18 +8,11 @@ namespace FSH.Modules.Multitenancy; -public sealed class TenantMigrationsHealthCheck : IHealthCheck +public sealed class TenantMigrationsHealthCheck(IServiceScopeFactory scopeFactory) : IHealthCheck { - private readonly IServiceScopeFactory _scopeFactory; - - public TenantMigrationsHealthCheck(IServiceScopeFactory scopeFactory) - { - _scopeFactory = scopeFactory; - } - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { - using IServiceScope scope = _scopeFactory.CreateScope(); + using IServiceScope scope = scopeFactory.CreateScope(); var tenantStore = scope.ServiceProvider.GetRequiredService>(); var tenants = await tenantStore.GetAllAsync().ConfigureAwait(false); diff --git a/src/Modules/Webhooks/Modules.Webhooks.Contracts/Modules.Webhooks.Contracts.csproj b/src/Modules/Webhooks/Modules.Webhooks.Contracts/Modules.Webhooks.Contracts.csproj index bc502a7857..1e90ecdd04 100644 --- a/src/Modules/Webhooks/Modules.Webhooks.Contracts/Modules.Webhooks.Contracts.csproj +++ b/src/Modules/Webhooks/Modules.Webhooks.Contracts/Modules.Webhooks.Contracts.csproj @@ -3,7 +3,7 @@ FSH.Modules.Webhooks.Contracts FSH.Modules.Webhooks.Contracts FullStackHero.Modules.Webhooks.Contracts - $(NoWarn);CA1054;CA1056;S2094 + $(NoWarn);CA1054;CA1056;S2094;NU1507 diff --git a/src/Modules/Webhooks/Modules.Webhooks/Data/WebhookDbContext.cs b/src/Modules/Webhooks/Modules.Webhooks/Data/WebhookDbContext.cs index d0132ed982..23e35b66cb 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Data/WebhookDbContext.cs +++ b/src/Modules/Webhooks/Modules.Webhooks/Data/WebhookDbContext.cs @@ -9,14 +9,12 @@ namespace FSH.Modules.Webhooks.Data; -public sealed class WebhookDbContext : BaseDbContext +public sealed class WebhookDbContext( + IMultiTenantContextAccessor multiTenantContextAccessor, + DbContextOptions options, + IOptions settings, + IHostEnvironment environment) : BaseDbContext(multiTenantContextAccessor, options, settings, environment) { - public WebhookDbContext( - IMultiTenantContextAccessor multiTenantContextAccessor, - DbContextOptions options, - IOptions settings, - IHostEnvironment environment) : base(multiTenantContextAccessor, options, settings, environment) { } - public DbSet Subscriptions => Set(); public DbSet Deliveries => Set(); diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhookResource.cs b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhookResource.cs new file mode 100644 index 0000000000..325a1d2ed7 --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhookResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Modules.Webhooks.Localization; + +#pragma warning disable S2094 +public sealed class WebhookResource; +#pragma warning restore S2094 diff --git a/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhookResource.resx b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhookResource.resx new file mode 100644 index 0000000000..0c784bf9e6 --- /dev/null +++ b/src/Modules/Webhooks/Modules.Webhooks/Localization/WebhookResource.resx @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Webhook created successfully + Webhook updated successfully + Webhook deleted successfully + Webhook activated + Webhook deactivated + Webhook triggered + Webhook delivered successfully + Webhook delivery failed + Retrying webhook delivery + Webhook retry limit reached + Invalid webhook URL + Webhook not found + Webhook already exists for this event + Webhook secret updated + Subscribed to event successfully + Unsubscribed from event + Delivery history cleared + Webhook test completed + diff --git a/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj b/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj index 90c1c1da40..723e8fb8d4 100644 --- a/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj +++ b/src/Modules/Webhooks/Modules.Webhooks/Modules.Webhooks.csproj @@ -3,7 +3,7 @@ FSH.Modules.Webhooks FSH.Modules.Webhooks FullStackHero.Modules.Webhooks - $(NoWarn);CA1031;CA1054;CA1056;CA1308;CA1812;CA1859;S3267 + $(NoWarn);CA1031;CA1054;CA1056;CA1308;CA1812;CA1859;S3267;NU1507 diff --git a/src/Playground/FSH.Api/FSH.Api.csproj b/src/Playground/FSH.Api/FSH.Api.csproj index 7ab5bc03d3..2a29ab3782 100644 --- a/src/Playground/FSH.Api/FSH.Api.csproj +++ b/src/Playground/FSH.Api/FSH.Api.csproj @@ -4,7 +4,7 @@ FSH.Api FSH.Api false - + $(NoWarn);NU1507 8080 root diff --git a/src/Playground/FSH.Api/Program.cs b/src/Playground/FSH.Api/Program.cs index e3af236928..17958e4751 100644 --- a/src/Playground/FSH.Api/Program.cs +++ b/src/Playground/FSH.Api/Program.cs @@ -1,4 +1,5 @@ -using FSH.Framework.Web; +using FSH.Framework.Shared.Localization; +using FSH.Framework.Web; using FSH.Framework.Web.Modules; using FSH.Modules.Auditing; using FSH.Modules.Identity; @@ -50,6 +51,8 @@ static void Require(IConfiguration config, string key) typeof(WebhooksModule).Assembly }; +builder.Services.AddFshLocalization(); + builder.AddHeroPlatform(o => { o.EnableCaching = true; @@ -61,6 +64,7 @@ static void Require(IConfiguration config, string key) var app = builder.Build(); app.UseHeroMultiTenantDatabases(); +app.UseFshLocalization(); app.UseHeroPlatform(p => { p.MapModules = true; diff --git a/src/Playground/FSH.Playground.AppHost/AppHost.cs b/src/Playground/FSH.Playground.AppHost/AppHost.cs index 2d4d30cc4f..2555ac3e82 100644 --- a/src/Playground/FSH.Playground.AppHost/AppHost.cs +++ b/src/Playground/FSH.Playground.AppHost/AppHost.cs @@ -20,8 +20,7 @@ .WithEnvironment("DatabaseOptions__Provider", "POSTGRESQL") .WithEnvironment("DatabaseOptions__ConnectionString", postgres.Resource.ConnectionStringExpression) .WithEnvironment("DatabaseOptions__MigrationsAssembly", "FSH.Migrations.PostgreSQL") - .WithEnvironment("CachingOptions__Redis", redis.Resource.ConnectionStringExpression) - .WithEnvironment("CachingOptions__EnableSsl", "false"); + .WithEnvironment("CachingOptions__Redis", redis.Resource.ConnectionStringExpression); // Blazor UI builder.AddProject("playground-blazor") diff --git a/src/Playground/FSH.Playground.AppHost/FSH.Playground.AppHost.csproj b/src/Playground/FSH.Playground.AppHost/FSH.Playground.AppHost.csproj index d38a31a086..8ebe307ba5 100644 --- a/src/Playground/FSH.Playground.AppHost/FSH.Playground.AppHost.csproj +++ b/src/Playground/FSH.Playground.AppHost/FSH.Playground.AppHost.csproj @@ -1,4 +1,4 @@ - + Exe @@ -6,6 +6,7 @@ enable 9fe5df9a-b9b2-4202-bdb4-d30b01b71d1a false + $(NoWarn);NU1507 diff --git a/src/Playground/Migrations.PostgreSQL/Audit/20260401091314_AddAuditIndexesForPerformance.Designer.cs b/src/Playground/Migrations.PostgreSQL/Audit/20260401091314_AddAuditIndexesForPerformance.Designer.cs new file mode 100644 index 0000000000..266d88745d --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Audit/20260401091314_AddAuditIndexesForPerformance.Designer.cs @@ -0,0 +1,114 @@ +// +using System; +using FSH.Modules.Auditing.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FSH.Migrations.PostgreSQL.Audit +{ + [DbContext(typeof(AuditDbContext))] + [Migration("20260401091314_AddAuditIndexesForPerformance")] + partial class AddAuditIndexesForPerformance + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FSH.Modules.Auditing.AuditRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("text"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("OccurredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PayloadJson") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestId") + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("smallint"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("SpanId") + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("bigint"); + + b.Property("TenantId") + .IsRequired() + .HasColumnType("text"); + + b.Property("TraceId") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("UserName") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("CorrelationId"); + + b.HasIndex("EventType"); + + b.HasIndex("PayloadJson"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("PayloadJson"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("PayloadJson"), new[] { "jsonb_path_ops" }); + + b.HasIndex("Severity"); + + b.HasIndex("Source"); + + b.HasIndex("Tags"); + + b.HasIndex("TenantId"); + + b.HasIndex("TraceId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "OccurredAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "OccurredAtUtc") + .IsDescending(false, true); + + b.ToTable("AuditRecords", "audit"); + + b.HasAnnotation("Finbuckle:MultiTenant", true); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Audit/20260401091314_AddAuditIndexesForPerformance.cs b/src/Playground/Migrations.PostgreSQL/Audit/20260401091314_AddAuditIndexesForPerformance.cs new file mode 100644 index 0000000000..10b33bbf2a --- /dev/null +++ b/src/Playground/Migrations.PostgreSQL/Audit/20260401091314_AddAuditIndexesForPerformance.cs @@ -0,0 +1,152 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FSH.Migrations.PostgreSQL.Audit +{ + /// + public partial class AddAuditIndexesForPerformance : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AuditRecords_OccurredAtUtc", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.AlterColumn( + name: "TenantId", + schema: "audit", + table: "AuditRecords", + type: "text", + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(64)", + oldMaxLength: 64); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_CorrelationId", + schema: "audit", + table: "AuditRecords", + column: "CorrelationId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_PayloadJson", + schema: "audit", + table: "AuditRecords", + column: "PayloadJson") + .Annotation("Npgsql:IndexMethod", "gin") + .Annotation("Npgsql:IndexOperators", new[] { "jsonb_path_ops" }); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_Severity", + schema: "audit", + table: "AuditRecords", + column: "Severity"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_Source", + schema: "audit", + table: "AuditRecords", + column: "Source"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_Tags", + schema: "audit", + table: "AuditRecords", + column: "Tags"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_TenantId_OccurredAtUtc", + schema: "audit", + table: "AuditRecords", + columns: new[] { "TenantId", "OccurredAtUtc" }, + descending: new[] { false, true }); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_TraceId", + schema: "audit", + table: "AuditRecords", + column: "TraceId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_UserId", + schema: "audit", + table: "AuditRecords", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_UserId_OccurredAtUtc", + schema: "audit", + table: "AuditRecords", + columns: new[] { "UserId", "OccurredAtUtc" }, + descending: new[] { false, true }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_AuditRecords_CorrelationId", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.DropIndex( + name: "IX_AuditRecords_PayloadJson", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.DropIndex( + name: "IX_AuditRecords_Severity", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.DropIndex( + name: "IX_AuditRecords_Source", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.DropIndex( + name: "IX_AuditRecords_Tags", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.DropIndex( + name: "IX_AuditRecords_TenantId_OccurredAtUtc", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.DropIndex( + name: "IX_AuditRecords_TraceId", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.DropIndex( + name: "IX_AuditRecords_UserId", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.DropIndex( + name: "IX_AuditRecords_UserId_OccurredAtUtc", + schema: "audit", + table: "AuditRecords"); + + migrationBuilder.AlterColumn( + name: "TenantId", + schema: "audit", + table: "AuditRecords", + type: "character varying(64)", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "text"); + + migrationBuilder.CreateIndex( + name: "IX_AuditRecords_OccurredAtUtc", + schema: "audit", + table: "AuditRecords", + column: "OccurredAtUtc"); + } + } +} diff --git a/src/Playground/Migrations.PostgreSQL/Audit/AuditDbContextModelSnapshot.cs b/src/Playground/Migrations.PostgreSQL/Audit/AuditDbContextModelSnapshot.cs index b60eb1e413..1384be83aa 100644 --- a/src/Playground/Migrations.PostgreSQL/Audit/AuditDbContextModelSnapshot.cs +++ b/src/Playground/Migrations.PostgreSQL/Audit/AuditDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("ProductVersion", "10.0.5") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -61,8 +61,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("TenantId") .IsRequired() - .HasMaxLength(64) - .HasColumnType("character varying(64)"); + .HasColumnType("text"); b.Property("TraceId") .HasColumnType("text"); @@ -75,12 +74,33 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); + b.HasIndex("CorrelationId"); + b.HasIndex("EventType"); - b.HasIndex("OccurredAtUtc"); + b.HasIndex("PayloadJson"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("PayloadJson"), "gin"); + NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("PayloadJson"), new[] { "jsonb_path_ops" }); + + b.HasIndex("Severity"); + + b.HasIndex("Source"); + + b.HasIndex("Tags"); b.HasIndex("TenantId"); + b.HasIndex("TraceId"); + + b.HasIndex("UserId"); + + b.HasIndex("TenantId", "OccurredAtUtc") + .IsDescending(false, true); + + b.HasIndex("UserId", "OccurredAtUtc") + .IsDescending(false, true); + b.ToTable("AuditRecords", "audit"); b.HasAnnotation("Finbuckle:MultiTenant", true); diff --git a/src/Playground/Migrations.PostgreSQL/Migrations.PostgreSQL.csproj b/src/Playground/Migrations.PostgreSQL/Migrations.PostgreSQL.csproj index aa4c5448aa..19019ab82e 100644 --- a/src/Playground/Migrations.PostgreSQL/Migrations.PostgreSQL.csproj +++ b/src/Playground/Migrations.PostgreSQL/Migrations.PostgreSQL.csproj @@ -4,6 +4,7 @@ FSH.Migrations.PostgreSQL FSH.Migrations.PostgreSQL false + $(NoWarn);NU1507 diff --git a/src/Playground/Playground.Blazor/Components/App.razor b/src/Playground/Playground.Blazor/Components/App.razor index df345779aa..bf0dabf713 100644 --- a/src/Playground/Playground.Blazor/Components/App.razor +++ b/src/Playground/Playground.Blazor/Components/App.razor @@ -7,8 +7,9 @@ - - + + @* CSS Isolation bundle - commenting out as it's causing 404 errors. Scoped CSS is still applied through the component bundle. *@ + @* *@ diff --git a/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor b/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor index 30d0fbcd21..02318acad4 100644 --- a/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor +++ b/src/Playground/Playground.Blazor/Components/Layout/NavMenu.razor @@ -1,83 +1,86 @@ @using FSH.Framework.Shared.Constants @using FSH.Framework.Shared.Multitenancy +@using FSH.Framework.Shared.Localization +@using Microsoft.Extensions.Localization @using Microsoft.AspNetCore.Components.Authorization @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthenticationStateProvider +@inject IStringLocalizer L diff --git a/src/Playground/Playground.Blazor/Components/Layout/PlaygroundLayout.razor b/src/Playground/Playground.Blazor/Components/Layout/PlaygroundLayout.razor index 7c00cfca92..7943454d22 100644 --- a/src/Playground/Playground.Blazor/Components/Layout/PlaygroundLayout.razor +++ b/src/Playground/Playground.Blazor/Components/Layout/PlaygroundLayout.razor @@ -8,7 +8,9 @@ @using FSH.Playground.Blazor.Components.Pages @using Microsoft.AspNetCore.WebUtilities @using FSH.Playground.Blazor.Services +@using FSH.Framework.Shared.Localization @using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.Extensions.Localization @using System.Security.Claims @inject IHttpContextAccessor HttpContextAccessor @inject AuthenticationStateProvider AuthenticationStateProvider @@ -23,6 +25,7 @@ @inject IDialogService DialogService @inject IAuthStateNotifier AuthStateNotifier @inject IJSRuntime JS +@inject IStringLocalizer L @@ -226,15 +229,15 @@ else var toast = toastValues.ToString(); if (toast == "login_success") { - Snackbar.Add("Signed in.", Severity.Success); + Snackbar.Add(L["SignedIn"], Severity.Success); } else if (toast == "logout_success") { - Snackbar.Add("Signed out.", Severity.Success); + Snackbar.Add(L["SignedOut"], Severity.Success); } else if (toast == "session_expired") { - Snackbar.Add("Your session has expired. Please sign in again.", Severity.Warning); + Snackbar.Add(L["SessionExpired"], Severity.Warning); } var cleanUri = currentUri.GetLeftPart(UriPartial.Path); @@ -248,7 +251,7 @@ else private async Task ConfirmAndLogoutAsync() { - var confirmed = await DialogService.ShowSignOutConfirmAsync(); + var confirmed = await DialogService.ShowSignOutConfirmAsync(L); if (confirmed) { await LogoutAsync(); diff --git a/src/Playground/Playground.Blazor/Components/Pages/Audits.razor b/src/Playground/Playground.Blazor/Components/Pages/Audits.razor index b2e41dfef7..4dc2a2b139 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Audits.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Audits.razor @@ -4,21 +4,26 @@ @using System.Text.Json @using MudBlazor @using FSH.Framework.Blazor.UI.Components.Cards +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Audits +@using Microsoft.Extensions.Localization +@inject IStringLocalizer L +@inject IStringLocalizer AL - + - + - Export as CSV + @AL["ExportAudits"] - Export as JSON + @AL["ExportAsJson"] @@ -26,7 +31,7 @@ Color="Color.Primary" Variant="Variant.Filled" OnClick="ReloadTable" - Title="Refresh Data" /> + Title="@L["Refresh"]" /> @@ -38,29 +43,29 @@ + Label="@AL["TotalEvents"]" + Badge="@L["Total"]" /> + Label="@AL["ErrorEvents"]" + Badge="@AL["Errors"]" /> + Label="@AL["SecurityEvents"]" + Badge="@L["Security"]" /> + Label="@AL["UniqueSources"]" + Badge="@AL["Sources"]" /> } @@ -68,44 +73,44 @@ - Quick Filters: + @AL["QuickFilters"]: Last Hour + OnClick="@(() => ApplyQuickRange(TimeSpan.FromHours(1), "1h"))">@AL["LastHour"] Last 24 Hours + OnClick="@(() => ApplyQuickRange(TimeSpan.FromHours(24), "24h"))">@AL["Last24Hours"] Last 7 Days + OnClick="@(() => ApplyQuickRange(TimeSpan.FromDays(7), "7d"))">@AL["Last7Days"] Errors Only + OnClick="ApplyErrorsFilter">@AL["ErrorsOnly"] Security Events + OnClick="ApplySecurityFilter">@AL["SecurityEvents"] Exceptions + OnClick="ApplyExceptionsFilter">@AL["Exceptions"] Clear All + OnClick="ResetFilters">@L["Clear"] @@ -115,10 +120,10 @@ - Advanced Filters + @AL["AdvancedFilters"] @if (HasActiveFilters()) { - @GetActiveFiltersCount() Active + @GetActiveFiltersCount() @L["Active"] } @@ -133,7 +138,7 @@ - All Types + @L["All"] @foreach (var value in _eventTypes) { @@ -177,12 +182,12 @@ - All Severities + @L["All"] @foreach (var value in _severities) { @@ -193,8 +198,8 @@ - Apply Filters + @L["Apply"] - Reset All + @L["Reset"] @@ -263,17 +268,17 @@ Breakpoint="Breakpoint.Sm" Elevation="0"> - Timestamp - Event Type - Severity - User - Tenant - Source - Tracking - Actions + @L["Timestamp"] + @AL["EventType"] + @AL["Severity"] + @L["User"] + @L["Tenant"] + @AL["Sources"] + @L["Tracking"] + @L["Actions"] - + @context.OccurredAtUtc.ToLocalTime().ToString("MMM dd, yyyy") @@ -283,7 +288,7 @@ - + - + - + - @(string.IsNullOrEmpty(context.UserName) ? "System" : context.UserName) + @(string.IsNullOrEmpty(context.UserName) ? L["System"] : context.UserName) @if (!string.IsNullOrEmpty(context.UserId) && context.UserId != context.UserName) { @context.UserId } - + @(string.IsNullOrEmpty(context.TenantId) ? "-" : context.TenantId) - + @context.Source - + @if (!string.IsNullOrEmpty(context.CorrelationId)) { - + + - + - @(IsExpanded(context.Id) ? "Hide" : "Details") + @(IsExpanded(context.Id) ? L["Hide"] : L["Details"]) @@ -360,40 +365,40 @@ { - + - Event Information + @AL["EventType"] - Event Type: + @AL["EventType"]: @FormatEventType(detail.EventType) - Severity: + @AL["Severity"]: @FormatSeverity(detail.Severity) - Occurred At: + @L["Timestamp"]: @detail.OccurredAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") - Received At: + @AL["ReceivedAt"]: @detail.ReceivedAtUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss.fff") - Latency: + @AL["Latency"]: @((detail.ReceivedAtUtc - detail.OccurredAtUtc).TotalMilliseconds.ToString("F2")) ms @@ -404,25 +409,25 @@ - Context & Identity + @AL["ContextIdentity"] - User: - @(string.IsNullOrEmpty(detail.UserName) ? "System" : detail.UserName) + @L["User"]: + @(string.IsNullOrEmpty(detail.UserName) ? L["System"] : detail.UserName) - User ID: + @L["UserId"]: @(string.IsNullOrEmpty(detail.UserId) ? "-" : detail.UserId) - Tenant: + @L["Tenant"]: @(string.IsNullOrEmpty(detail.TenantId) ? "-" : detail.TenantId) - Source: + @AL["Sources"]: @detail.Source @@ -432,13 +437,13 @@ - + @@ -479,14 +484,14 @@ StartIcon="@Icons.Material.Filled.Link" OnClick="@(() => ViewByCorrelation(detail.CorrelationId))" Disabled="@string.IsNullOrEmpty(detail.CorrelationId)"> - View Correlated Events + @AL["ViewCorrelatedEvents"] - View Trace Timeline + @AL["ViewTraceTimeline"] @@ -495,7 +500,7 @@ - + @@ -507,7 +512,7 @@ Color="Color.Primary" StartIcon="@Icons.Material.Filled.ContentCopy" OnClick="@(() => CopyToClipboard(FormatPayload(detail.Payload)))"> - Copy Payload + @L["Copy"] @@ -519,7 +524,7 @@ { - Loading details... + @L["Loading"] } @@ -528,22 +533,22 @@ - No audit events found + @L["NoDataAvailable"] - Try adjusting your filters or date range to see more results + @AL["TryAdjustingFilters"] - Clear All Filters + @L["Clear"] + RowsPerPageString="@L["RowsPerPage"]" /> @@ -552,8 +557,8 @@ - @(_relatedType == "correlation" ? "Correlated Events" : "Trace Timeline") - @_relatedEvents.Count events + @(_relatedType == "correlation" ? AL["ViewCorrelatedEvents"] : AL["ViewTraceTimeline"]) + @_relatedEvents.Count @@ -561,7 +566,7 @@ { - Loading related events... + @L["Loading"] } else if (_relatedEvents.Any()) @@ -587,7 +592,7 @@ @evt.UserName - Source: @evt.Source + @AL["Sources"]: @evt.Source @@ -599,12 +604,12 @@ { - No related events found + @L["NoDataAvailable"] } - Close + @L["Close"] @@ -685,7 +690,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load summary: {ex.Message}", Severity.Warning); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Warning); _showSummary = false; } } @@ -736,7 +741,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load audits: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); return new TableData { Items = Array.Empty(), @@ -821,7 +826,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load audit detail: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); _expanded.Remove(audit.Id); } } @@ -847,7 +852,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load correlated events: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -877,7 +882,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load trace events: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -893,11 +898,11 @@ try { NavigationManager.NavigateTo($"javascript:navigator.clipboard.writeText('{text}')"); - Snackbar.Add("Copied to clipboard", Severity.Success); + Snackbar.Add(L["CopiedToClipboard"], Severity.Success); } catch { - Snackbar.Add("Failed to copy to clipboard", Severity.Warning); + Snackbar.Add(L["UnexpectedErrorOccurred"], Severity.Warning); } return Task.CompletedTask; @@ -1007,7 +1012,7 @@ if (items.Count == 0) { - Snackbar.Add("No audits to export for the current filters.", Severity.Info); + Snackbar.Add(L["NoDataAvailable"], Severity.Info); return; } @@ -1018,12 +1023,12 @@ var contentType = format.Equals("json", StringComparison.OrdinalIgnoreCase) ? "application/json" : "text/csv"; var dataUrl = $"data:{contentType};base64,{Convert.ToBase64String(bytes)}"; - Snackbar.Add($"Exported {items.Count} audit events", Severity.Success); + Snackbar.Add(AL["ExportAudits"], Severity.Success); NavigationManager.NavigateTo(dataUrl, true); } catch (Exception ex) { - Snackbar.Add($"Export failed: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } } diff --git a/src/Playground/Playground.Blazor/Components/Pages/Authentication/ForgotPassword.razor b/src/Playground/Playground.Blazor/Components/Pages/Authentication/ForgotPassword.razor index 2d474e61fe..58a0647179 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Authentication/ForgotPassword.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Authentication/ForgotPassword.razor @@ -2,13 +2,18 @@ @layout EmptyLayout @attribute [AllowAnonymous] @using FSH.Framework.Shared.Multitenancy +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Authentication @using Microsoft.AspNetCore.Authorization +@using Microsoft.Extensions.Localization @using System.Net.Http.Json @inject NavigationManager Navigation @inject IHttpClientFactory HttpClientFactory @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer AL -Forgot Password +@AL["ForgotPassword"] @@ -111,7 +116,7 @@ @* Footer *@
@@ -138,7 +143,7 @@ await SendResetRequestAsync(); if (string.IsNullOrEmpty(_errorMessage)) { - Snackbar.Add("Password reset email sent again.", Severity.Success); + Snackbar.Add(AL["ResetLinkSent"], Severity.Success); } } @@ -148,7 +153,7 @@ if (string.IsNullOrWhiteSpace(_model.Email)) { - _errorMessage = "Please enter your email address."; + _errorMessage = L["Required"]; return; } @@ -170,12 +175,12 @@ } else { - _errorMessage = "Failed to send reset email. Please check your email address and try again."; + _errorMessage = L["UnexpectedErrorOccurred"]; } } catch (Exception ex) { - _errorMessage = "An unexpected error occurred. Please try again."; + _errorMessage = L["UnexpectedErrorOccurred"]; Console.WriteLine(ex); } finally diff --git a/src/Playground/Playground.Blazor/Components/Pages/Authentication/Register.razor b/src/Playground/Playground.Blazor/Components/Pages/Authentication/Register.razor index 5d23531f15..f0d5a15efa 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Authentication/Register.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Authentication/Register.razor @@ -2,13 +2,18 @@ @layout EmptyLayout @attribute [AllowAnonymous] @using FSH.Framework.Shared.Multitenancy +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Components.Validation @using Microsoft.AspNetCore.Authorization +@using Microsoft.Extensions.Localization +@using System.ComponentModel.DataAnnotations @using FSH.Playground.Blazor.ApiClient @inject NavigationManager Navigation @inject IIdentityClient IdentityClient @inject ISnackbar Snackbar +@inject IStringLocalizer L -Create Account +@L["CreateAccount"]
@* Logo/Brand *@ @@ -23,20 +28,21 @@
@* Header *@
-

Create an account

-

Enter your details to get started

+

@L["CreateAnAccount"]

+

@L["EnterYourDetailsToGetStarted"]

@* Registration Form *@ + @* Tenant Field *@
- +
@@ -44,77 +50,84 @@ @* Name Fields *@
- + +
- + +
@* Email Field *@
- + +
@* Username Field *@
- + +
@* Phone Field (Optional) *@
- + +
@* Password Field *@
- + +
@* Confirm Password Field *@
- + +
@* Show Password Toggle *@
@@ -132,24 +145,24 @@ @if (_isLoading) { - Creating account... + @L["CreatingAccount"] } else { - Create account + @L["CreateAccount"] } @* Login Link *@
@* Footer *@
@@ -160,38 +173,54 @@ private bool _isLoading = false; private string? _errorMessage; - private class RegisterModel + private class RegisterModel : IValidatableObject { public string Tenant { get; set; } = "root"; + + [Required] + [MaxLength(100)] public string FirstName { get; set; } = string.Empty; + + [Required] + [MaxLength(100)] public string LastName { get; set; } = string.Empty; + + [Required] + [EmailAddress] public string Email { get; set; } = string.Empty; + + [Required] + [MinLength(3)] + [MaxLength(50)] public string UserName { get; set; } = string.Empty; + + [MaxLength(20)] public string? PhoneNumber { get; set; } + + [Required] + [MinLength(6)] public string Password { get; set; } = string.Empty; - public string ConfirmPassword { get; set; } = string.Empty; - } - private async Task HandleSubmitAsync() - { - _errorMessage = null; + [Required] + public string ConfirmPassword { get; set; } = string.Empty; - if (_model.Password != _model.ConfirmPassword) + public IEnumerable Validate(ValidationContext validationContext) { - _errorMessage = "Passwords do not match."; - return; - } + // Get the localizer from the service provider + var localizer = validationContext.GetService(typeof(IStringLocalizer)) as IStringLocalizer; - if (string.IsNullOrWhiteSpace(_model.FirstName) || - string.IsNullOrWhiteSpace(_model.LastName) || - string.IsNullOrWhiteSpace(_model.Email) || - string.IsNullOrWhiteSpace(_model.UserName) || - string.IsNullOrWhiteSpace(_model.Password)) - { - _errorMessage = "Please fill in all required fields."; - return; + if (Password != ConfirmPassword) + { + yield return new ValidationResult( + localizer?["PasswordsMustMatch"] ?? "Passwords do not match.", + new[] { nameof(ConfirmPassword) }); + } } + } + private async Task HandleSubmitAsync() + { + _errorMessage = null; _isLoading = true; try @@ -209,7 +238,7 @@ await IdentityClient.SelfRegisterAsync(_model.Tenant, command); - Snackbar.Add("Account created successfully! Please check your email to confirm your account.", Severity.Success); + Snackbar.Add(L["AccountCreatedSuccessfully"], Severity.Success); Navigation.NavigateTo("/login"); } catch (ApiException ex) @@ -218,7 +247,7 @@ } catch (Exception ex) { - _errorMessage = "An unexpected error occurred. Please try again."; + _errorMessage = L["UnexpectedErrorOccurred"]; Console.WriteLine(ex); } finally diff --git a/src/Playground/Playground.Blazor/Components/Pages/Authentication/Register.razor.css b/src/Playground/Playground.Blazor/Components/Pages/Authentication/Register.razor.css index 1b240f2c0f..d2c7a0aea5 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Authentication/Register.razor.css +++ b/src/Playground/Playground.Blazor/Components/Pages/Authentication/Register.razor.css @@ -171,6 +171,23 @@ font-size: 18px; } +/* ===== Validation Messages ===== */ +::deep .validation-message { + font-size: 0.75rem; + color: #dc2626; + margin-top: 0.25rem; + display: block; +} + +::deep .form-input.invalid { + border-color: #dc2626; +} + +::deep .form-input.invalid:focus { + border-color: #dc2626; + box-shadow: 0 0 0 1px #dc2626; +} + /* ===== Auth Button ===== */ .auth-button { display: flex; diff --git a/src/Playground/Playground.Blazor/Components/Pages/Authentication/ResetPassword.razor b/src/Playground/Playground.Blazor/Components/Pages/Authentication/ResetPassword.razor index 052069214c..a487a72b61 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Authentication/ResetPassword.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Authentication/ResetPassword.razor @@ -2,13 +2,18 @@ @layout EmptyLayout @attribute [AllowAnonymous] @using FSH.Framework.Shared.Multitenancy +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Authentication @using Microsoft.AspNetCore.Authorization +@using Microsoft.Extensions.Localization @using FSH.Playground.Blazor.ApiClient @inject NavigationManager Navigation @inject IIdentityClient IdentityClient @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer AL -Reset Password +@AL["ResetPassword"]
@* Logo/Brand *@ @@ -30,11 +35,11 @@
-

Invalid reset link

-

This password reset link is invalid or has expired. Please request a new one.

+

@AL["InvalidOrExpiredToken"]

+

@AL["InvalidOrExpiredToken"]

- Request new link + @AL["SendResetLink"]
} @@ -45,8 +50,8 @@
-

Set new password

-

Your new password must be different from previously used passwords.

+

@AL["ResetPasswordTitle"]

+

@AL["EnterNewPassword"]

@* Form *@ @@ -55,30 +60,30 @@ @* Tenant Field *@
- +
@* Password Field *@
- +
@* Confirm Password Field *@
- +
@@ -87,7 +92,7 @@
@@ -105,18 +110,18 @@ @if (_isLoading) { - Resetting password... + @L["ProcessingRequest"] } else { - Reset password + @AL["ResetPasswordButton"] } @* Back to Login *@ - Back to login + @AL["BackToLogin"] } @@ -128,11 +133,11 @@
-

Password reset successful

-

Your password has been successfully reset. You can now sign in with your new password.

+

@AL["PasswordResetSuccessful"]

+

@AL["PasswordResetSuccessful"]

- Sign in + @L["Login"] } @@ -172,19 +177,19 @@ if (_model.Password != _model.ConfirmPassword) { - _errorMessage = "Passwords do not match."; + _errorMessage = L["PasswordsMustMatch"]; return; } if (string.IsNullOrWhiteSpace(_model.Password)) { - _errorMessage = "Please enter a new password."; + _errorMessage = L["Required"]; return; } if (string.IsNullOrWhiteSpace(_token) || string.IsNullOrWhiteSpace(Email)) { - _errorMessage = "Invalid reset link. Please request a new password reset."; + _errorMessage = AL["InvalidOrExpiredToken"]; return; } @@ -202,7 +207,7 @@ await IdentityClient.ResetPasswordAsync(_model.Tenant, command); _passwordReset = true; - Snackbar.Add("Password reset successfully!", Severity.Success); + Snackbar.Add(AL["PasswordResetSuccessful"], Severity.Success); } catch (ApiException ex) { @@ -210,7 +215,7 @@ } catch (Exception ex) { - _errorMessage = "An unexpected error occurred. Please try again."; + _errorMessage = L["UnexpectedErrorOccurred"]; Console.WriteLine(ex); } finally diff --git a/src/Playground/Playground.Blazor/Components/Pages/Counter.razor b/src/Playground/Playground.Blazor/Components/Pages/Counter.razor index db95bf3872..ecffa030dc 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Counter.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Counter.razor @@ -1,12 +1,15 @@ @page "/counter" +@using FSH.Playground.Blazor.Localization +@using Microsoft.Extensions.Localization +@inject IStringLocalizer PL -Counter +@PL["Counter"] -Counter +@PL["Counter"] -Current count: @currentCount +@PL["CurrentCount"]: @currentCount -Click me +@PL["ClickMe"] @code { private int currentCount = 0; diff --git a/src/Playground/Playground.Blazor/Components/Pages/Dashboard/DashboardPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Dashboard/DashboardPage.razor index f550ab4218..3292a837ec 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Dashboard/DashboardPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Dashboard/DashboardPage.razor @@ -3,13 +3,18 @@ @attribute [StreamRendering(true)] @using System.Linq @using FSH.Framework.Blazor.UI.Components.Cards +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Dashboard +@using Microsoft.Extensions.Localization @inherits ComponentBase @inject FSH.Playground.Blazor.ApiClient.IV1Client V1Client @inject FSH.Playground.Blazor.ApiClient.IIdentityClient IdentityClient @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer DL - +
@@ -17,42 +22,42 @@ + Label="@DL["TotalUsers"]" /> + Label="@L["Roles"]" /> + Label="@DL["TotalTenants"]" /> + Label="@DL["RecentActivity"]" /> - Recent Audits + @DL["RecentActivity"] - Timestamp - Event Type - User - Correlation + @L["Date"] + @L["Status"] + @L["Users"] + @L["Id"] - @context.OccurredAtUtc.ToLocalTime() - @context.EventType - @context.UserName - @context.CorrelationId + @context.OccurredAtUtc.ToLocalTime() + @context.EventType + @context.UserName + @context.CorrelationId @@ -85,7 +90,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load audits: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } } @@ -104,7 +109,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load summary: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } } diff --git a/src/Playground/Playground.Blazor/Components/Pages/Error.razor b/src/Playground/Playground.Blazor/Components/Pages/Error.razor index 576cc2d2f4..b6c0b3abf8 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Error.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Error.razor @@ -1,36 +1,104 @@ @page "/Error" +@page "/not-found" +@layout PlaygroundLayout @using System.Diagnostics +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization +@using Microsoft.Extensions.Localization +@inject NavigationManager Navigation +@inject IStringLocalizer L +@inject IStringLocalizer PL -Error +@ErrorTitle -

Error.

-

An error occurred while processing your request.

+ + +
+ -@if (ShowRequestId) -{ -

- Request ID: @RequestId -

-} + @ErrorTitle + @ErrorMessage + + @if (ShowRequestId) + { + + Request ID: @RequestId + + } -

Development Mode

-

- Swapping to Development environment will display more detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

+ @if (StatusCode == 404) + { + + The page you are looking for might have been removed, had its name changed, or is temporarily unavailable. + + + @PL["ReturnHome"] + + } + else + { + Development Mode + + Swapping to Development environment will display more detailed information about the error that occurred. + + + The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. + + } +
+
+
@code{ [CascadingParameter] private HttpContext? HttpContext { get; set; } + [Parameter] + public int? StatusCodeParameter { get; set; } + private string? RequestId { get; set; } private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + private int? StatusCode { get; set; } - protected override void OnInitialized() => + private string ErrorTitle => StatusCode switch + { + 404 => "404 - Page Not Found", + _ => "Error" + }; + + private string ErrorMessage => StatusCode switch + { + 404 => "The page you requested could not be found.", + _ => "An error occurred while processing your request." + }; + + protected override void OnInitialized() + { RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; + + // First check if status code was passed as a parameter + if (StatusCodeParameter.HasValue) + { + StatusCode = StatusCodeParameter; + } + else + { + // Check for status code in query parameters + var uri = new Uri(Navigation.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var statusParam = query["status"]; + if (!string.IsNullOrEmpty(statusParam) && int.TryParse(statusParam, out var status)) + { + StatusCode = status; + } + else + { + // Default to 404 if no status code is provided (e.g., when used as NotFoundPage) + StatusCode = 404; + } + } + } } diff --git a/src/Playground/Playground.Blazor/Components/Pages/Groups/AddMembersDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Groups/AddMembersDialog.razor index 4a839497f0..45f9a1ee68 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Groups/AddMembersDialog.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Groups/AddMembersDialog.razor @@ -1,14 +1,19 @@ @using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Groups +@using Microsoft.Extensions.Localization @inject IIdentityClient IdentityClient @inject IGroupsClient GroupsClient @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer GL - Add Members to Group + @GL["AddMembers"] - Select users to add to this group + @GL["SelectUsers"] @@ -16,8 +21,8 @@ @* Search *@ - @_selectedUserIds.Count user(s) selected + @string.Format(L["Count"], _selectedUserIds.Count) } } else { - No users found matching your search. + @L["NoDataAvailable"] } @@ -90,7 +95,7 @@ Color="Color.Default" OnClick="Cancel" Disabled="_busy"> - Cancel + @L["Cancel"] - Adding... + @L["ProcessingRequest"] } else { - Add @_selectedUserIds.Count Member(s) + @GL["AddMembers"] (@_selectedUserIds.Count) } @@ -154,7 +159,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load users: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -212,7 +217,7 @@ { if (!_selectedUserIds.Any()) { - Snackbar.Add("Please select at least one user.", Severity.Warning); + Snackbar.Add(GL["SelectUsers"], Severity.Warning); return; } @@ -228,19 +233,19 @@ if (response.AddedCount > 0) { - Snackbar.Add($"Added {response.AddedCount} member(s) to the group.", Severity.Success); + Snackbar.Add(GL["MembersAddedSuccessfully"], Severity.Success); } if (response.AlreadyMemberUserIds?.Any() == true) { - Snackbar.Add($"{response.AlreadyMemberUserIds.Count} user(s) were already members.", Severity.Info); + Snackbar.Add(GL["AlreadyMembers"], Severity.Info); } MudDialog.Close(DialogResult.Ok(true)); } catch (Exception ex) { - Snackbar.Add($"Failed to add members: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Groups/CreateGroupDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Groups/CreateGroupDialog.razor index deb389c646..d6eeb4ba4a 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Groups/CreateGroupDialog.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Groups/CreateGroupDialog.razor @@ -1,13 +1,18 @@ @using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Groups +@using Microsoft.Extensions.Localization @inject IIdentityClient IdentityClient @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer GL - @(IsEditMode ? "Edit Group" : "Create New Group") + @(IsEditMode ? GL["EditGroup"] : GL["CreateGroup"]) - @(IsEditMode ? "Modify group details and role assignments" : "Define a new group for organizing users") + @GL["GroupManagement"] @@ -16,39 +21,37 @@ @* Group Name *@ + Immediate="true" /> @* Group Description *@ + Lines="3" /> @* Is Default Group *@ - New users will be automatically added to default groups + @GL["DefaultGroupTooltip"] @* Role Assignments *@ - Role Assignments + @L["Roles"] - Members of this group will inherit these roles + @GL["GroupManagement"] @if (_loadingRoles) @@ -83,7 +86,7 @@ else { - No roles available for assignment. + @GL["NoRolesAssigned"] } @@ -92,14 +95,7 @@ - @if (IsEditMode) - { - After saving, you can manage group members from the group details page. - } - else - { - After creating the group, you can add members from the group details page. - } + @GL["GroupDetails"] @@ -111,7 +107,7 @@ Color="Color.Default" OnClick="Cancel" Disabled="_busy"> - Cancel + @L["Cancel"] - @(IsEditMode ? "Saving..." : "Creating...") + @L["ProcessingRequest"] } else { - @(IsEditMode ? "Save Changes" : "Create Group") + @(IsEditMode ? L["Save"] : GL["CreateGroup"]) } @@ -185,7 +181,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load roles: {ex.Message}", Severity.Warning); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Warning); } finally { @@ -228,7 +224,7 @@ { if (_form is not null) { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) { return; @@ -237,7 +233,7 @@ if (string.IsNullOrWhiteSpace(_name)) { - Snackbar.Add("Please enter a group name.", Severity.Warning); + Snackbar.Add(L["Required"], Severity.Warning); return; } @@ -267,12 +263,12 @@ await IdentityClient.GroupsPostAsync(command); } - Snackbar.Add($"Group '{_name}' {(IsEditMode ? "updated" : "created")} successfully!", Severity.Success); + Snackbar.Add(IsEditMode ? GL["GroupUpdatedSuccessfully"] : GL["GroupCreatedSuccessfully"], Severity.Success); MudDialog.Close(DialogResult.Ok(true)); } catch (Exception ex) { - Snackbar.Add($"Failed to {(IsEditMode ? "update" : "create")} group: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupMembersPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupMembersPage.razor index a11cab99f4..762549b64b 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupMembersPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupMembersPage.razor @@ -1,27 +1,32 @@ @page "/groups/{GroupId:guid}/members" @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Dialogs +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Groups +@using Microsoft.Extensions.Localization @inherits ComponentBase @inject IIdentityClient IdentityClient @inject IGroupsClient GroupsClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject IStringLocalizer L +@inject IStringLocalizer GL - + - Back to Groups + @L["Back"] - Add Members + @GL["AddMembers"] @@ -39,13 +44,13 @@ @if (_group.IsSystemGroup) { - System + @GL["System"] } @if (_group.IsDefault) { - Default + @GL["DefaultGroups"] } @@ -85,7 +90,7 @@ Class="members-datagrid"> - - + + @context.Item.AddedAt.LocalDateTime.ToString("MMM d, yyyy") @@ -124,9 +129,9 @@ - + - + - No members in this group - Add members to get started. + @L["NoDataAvailable"] + @GL["AddMembers"] - Add Members + @GL["AddMembers"] @@ -204,7 +209,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load group: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } } @@ -219,7 +224,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load members: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -277,7 +282,7 @@ CloseButton = true }; - var dialog = await DialogService.ShowAsync("Add Members", parameters, options); + var dialog = await DialogService.ShowAsync(GL["AddMembers"], parameters, options); var result = await dialog.Result; if (result is not null && !result.Canceled) @@ -291,10 +296,10 @@ if (string.IsNullOrWhiteSpace(member.UserId)) return; var confirmed = await DialogService.ShowConfirmAsync( - "Remove Member", - $"Remove {member.FirstName} {member.LastName} from this group?", - "Remove", - "Cancel", + GL["RemoveMember"], + string.Format(GL["ConfirmDeleteGroup"], $"{member.FirstName} {member.LastName}"), + L["Delete"], + L["Cancel"], Color.Error); if (!confirmed) return; @@ -303,12 +308,12 @@ try { await GroupsClient.MembersDeleteAsync(GroupId, member.UserId); - Snackbar.Add($"{member.FirstName} {member.LastName} removed from group.", Severity.Success); + Snackbar.Add(GL["MemberRemovedSuccessfully"], Severity.Success); await LoadMembers(); } catch (Exception ex) { - Snackbar.Add($"Failed to remove member: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupsPage.razor index a4bca77132..84d55c9680 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupsPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Groups/GroupsPage.razor @@ -2,14 +2,19 @@ @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Dialogs @using FSH.Framework.Blazor.UI.Components.Cards +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Groups +@using Microsoft.Extensions.Localization @inherits ComponentBase @inject IIdentityClient IdentityClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject IStringLocalizer L +@inject IStringLocalizer GL - + @if (_selectedGroups.Any()) { @@ -18,11 +23,11 @@ Color="Color.Error" OnClick="BulkDelete" Disabled="_bulkBusy"> - Delete (@_selectedGroups.Count) + @L["Delete"] (@_selectedGroups.Count) - Clear + @L["Clear"] } @@ -30,7 +35,7 @@ Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="ShowCreate"> - New Group + @GL["NewGroup"] @@ -41,40 +46,40 @@ + Label="@GL["TotalGroups"]" + Badge="@L["Total"]" /> + Label="@GL["SystemGroups"]" + Badge="@GL["System"]" /> + Label="@GL["DefaultGroups"]" + Badge="@L["Default"]" /> + Label="@GL["CustomGroups"]" + Badge="@GL["Custom"]" /> @* Filter Panel *@ - + - Clear Filters + @L["Clear"] - Refresh + @L["Refresh"] @@ -118,7 +123,7 @@ Class="groups-datagrid"> - + @@ -127,36 +132,36 @@ @if (context.Item.IsDefault) { - + } - @(string.IsNullOrEmpty(context.Item.Description) ? "No description" : context.Item.Description) + @(string.IsNullOrEmpty(context.Item.Description) ? GL["NoDescription"] : context.Item.Description) - + @if (context.Item.IsSystemGroup) { - System + @GL["System"] } else { - Custom + @GL["Custom"] } - + @if (context.Item.RoleNames?.Any() == true) { @@ -177,11 +182,11 @@ } else { - No roles assigned + @GL["NoRolesAssigned"] } - + @@ -189,23 +194,23 @@ - + - + - + - + - No groups found - Try adjusting your filters or create a new group. + @GL["NoGroupsFound"] + @GL["TryAdjustingFilters"] @@ -285,7 +290,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load groups: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -365,7 +370,7 @@ CloseButton = true }; - var dialog = await DialogService.ShowAsync("Create Group", options); + var dialog = await DialogService.ShowAsync(GL["CreateGroup"], options); var result = await dialog.Result; if (result is not null && !result.Canceled) @@ -378,7 +383,7 @@ { if (group.IsSystemGroup) { - Snackbar.Add("System groups cannot be edited.", Severity.Warning); + Snackbar.Add(GL["SystemGroupsCannotBeEdited"], Severity.Warning); return; } @@ -395,7 +400,7 @@ CloseButton = true }; - var dialog = await DialogService.ShowAsync("Edit Group", parameters, options); + var dialog = await DialogService.ShowAsync(GL["EditGroup"], parameters, options); var result = await dialog.Result; if (result is not null && !result.Canceled) @@ -408,23 +413,23 @@ { if (group.IsSystemGroup) { - Snackbar.Add("System groups cannot be deleted.", Severity.Warning); + Snackbar.Add(GL["SystemGroupsCannotBeDeleted"], Severity.Warning); return; } - var confirmed = await DialogService.ShowDeleteConfirmAsync(group.Name ?? "this group"); + var confirmed = await DialogService.ShowDeleteConfirmAsync(group.Name ?? L["Unknown"], L); if (!confirmed) return; _busyGroupId = group.Id; try { await IdentityClient.GroupsDeleteAsync(group.Id); - Snackbar.Add("Group deleted successfully.", Severity.Success); + Snackbar.Add(GL["GroupDeletedSuccessfully"], Severity.Success); await LoadData(); } catch (Exception ex) { - Snackbar.Add($"Failed to delete group: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -437,15 +442,15 @@ var groups = _selectedGroups.Where(g => !g.IsSystemGroup).ToList(); if (!groups.Any()) { - Snackbar.Add("No deletable groups selected. System groups cannot be deleted.", Severity.Info); + Snackbar.Add(GL["NoDeletableGroupsSelected"], Severity.Info); return; } var confirmed = await DialogService.ShowConfirmAsync( - "Bulk Delete", - $"Delete {groups.Count} group(s)? This action cannot be undone.", - "Delete All", - "Cancel", + GL["BulkDelete"], + string.Format(GL["DeleteGroupsConfirm"], groups.Count), + L["Delete"], + L["Cancel"], Color.Error, Icons.Material.Outlined.DeleteForever, Color.Error); @@ -467,7 +472,7 @@ } } _bulkBusy = false; - Snackbar.Add($"Deleted {success} of {groups.Count} groups.", Severity.Success); + Snackbar.Add(string.Format(GL["DeletedGroupsCount"], success, groups.Count), Severity.Success); ClearSelection(); await LoadData(); } diff --git a/src/Playground/Playground.Blazor/Components/Pages/Health/HealthPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Health/HealthPage.razor index c71c3a0895..ec8de534b1 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Health/HealthPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Health/HealthPage.razor @@ -1,28 +1,33 @@ @page "/health" @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Cards +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Health +@using Microsoft.Extensions.Localization @using System.Timers @implements IDisposable @inject IHealthClient HealthClient @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer HL - + - Last updated: @_lastUpdated.ToString("HH:mm:ss") + @_lastUpdated.ToString("HH:mm:ss") + Title="@(_autoRefresh ? HL["AutoRefreshOn"] : HL["AutoRefreshOff"])" /> + Title="@L["Refresh"]" /> @@ -32,34 +37,34 @@ + Label="@HL["HealthyServices"]" + Badge="@HL["Healthy"]" /> + Label="@HL["DegradedServices"]" + Badge="@HL["Degraded"]" /> + Label="@HL["UnhealthyServices"]" + Badge="@HL["Unhealthy"]" /> + Label="@HL["AvgResponseTime"]" + Badge="@HL["Response"]" /> @* Services Grid *@ -Service Status +@HL["ServiceStatus"] @if (_readyResult?.Results != null) { @@ -95,14 +100,14 @@ - Liveness Probe + @HL["LivenessProbe"] - @(_liveResult?.Status ?? "Unknown") + @(_liveResult?.Status ?? L["Unknown"]) - Basic process check - confirms the application is running. + @HL["LivenessProbeDescription"] @@ -118,14 +123,14 @@ - Readiness Probe + @HL["ReadinessProbe"] - @(_readyResult?.Status ?? "Unknown") + @(_readyResult?.Status ?? L["Unknown"]) - Full dependency check - validates all services and databases. + @HL["ReadinessProbeDescription"] @@ -145,8 +150,8 @@ - Recent Checks - Last @_healthHistory.Count checks + @HL["RecentChecks"] + @string.Format(HL["LastNChecks"], _healthHistory.Count) @foreach (var record in _healthHistory.TakeLast(20)) @@ -229,7 +234,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to fetch health status: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); _healthHistory.Add(new HealthRecord { Time = DateTime.Now, @@ -272,19 +277,20 @@ _ => Icons.Material.Outlined.Dns }; - private static string FormatServiceName(string? name) + private string FormatServiceName(string? name) { - if (string.IsNullOrEmpty(name)) return "Unknown"; + if (string.IsNullOrEmpty(name)) return L["Unknown"]; return name switch { - "self" => "Application Core", - "db:identity" => "Identity Database", - "db:auditing" => "Audit Database", - "db:multitenancy" => "Multitenancy Database", - "db:tenants-migrations" => "Tenant Migrations", + "self" => HL["ApplicationCore"], + "db:identity" => HL["IdentityDatabase"], + "db:auditing" => HL["AuditDatabase"], + "db:multitenancy" => HL["MultitenancyDatabase"], + "db:tenants-migrations" => HL["TenantMigrations"], _ => name.Replace(":", " - ").Replace("-", " ") .Split(' ') + .Where(w => w.Length > 0) .Select(w => char.ToUpper(w[0]) + w[1..].ToLower()) .Aggregate((a, b) => $"{a} {b}") }; diff --git a/src/Playground/Playground.Blazor/Components/Pages/HealthPage.razor b/src/Playground/Playground.Blazor/Components/Pages/HealthPage.razor new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/Playground/Playground.Blazor/Components/Pages/Home.razor b/src/Playground/Playground.Blazor/Components/Pages/Home.razor index 313b80233d..0d2e72fbcb 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Home.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Home.razor @@ -1,5 +1,8 @@ @page "/welcome" +@using FSH.Playground.Blazor.Localization +@using Microsoft.Extensions.Localization +@inject IStringLocalizer PL @inherits ComponentBase - + diff --git a/src/Playground/Playground.Blazor/Components/Pages/ProfileSettings.razor b/src/Playground/Playground.Blazor/Components/Pages/ProfileSettings.razor index c4bf753c63..f5fcbee146 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/ProfileSettings.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/ProfileSettings.razor @@ -1,21 +1,26 @@ @page "/settings/profile" @using System.Security.Claims @using Microsoft.AspNetCore.Components.Authorization +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Profile +@using Microsoft.Extensions.Localization @inject FSH.Playground.Blazor.ApiClient.IIdentityClient IdentityClient @inject ISnackbar Snackbar @inject NavigationManager Navigation @inject AuthenticationStateProvider AuthenticationStateProvider @inject FSH.Playground.Blazor.Services.IUserProfileState UserProfileState +@inject IStringLocalizer L +@inject IStringLocalizer PRL - + @if (_loading) { - Loading profile... + @L["Loading"] } else @@ -28,7 +33,7 @@ else - Profile Photo + @PRL["ProfilePhoto"] @@ -50,25 +55,26 @@ else } @if (_hasImageChanges) { - Pending + @L["Pending"] }
- Upload a profile photo. Max 2MB, images only. + @PRL["ProfilePhotoDescription"] - + - Upload Photo + Size="Size.Small" + OnClick="@(async (e) => await upload.OpenFilePickerAsync())"> + @L["Upload"] - + @if (!string.IsNullOrEmpty(_avatarPreviewUrl)) @@ -78,7 +84,7 @@ else StartIcon="@Icons.Material.Filled.Delete" Size="Size.Small" OnClick="DeleteAvatar"> - Remove + @L["Delete"] } @@ -92,28 +98,28 @@ else - Account Status + @L["Status"] - Account Status + @L["Status"] - @(_profile?.IsActive == true ? "Active" : "Inactive") + @(_profile?.IsActive == true ? L["Active"] : L["Inactive"]) - Email Verified + @L["EmailVerified"] - @(_profile?.EmailConfirmed == true ? "Verified" : "Pending") + @(_profile?.EmailConfirmed == true ? L["Verified"] : L["Pending"]) - Username + @L["Username"] @(_profile?.UserName ?? "-") @@ -125,46 +131,46 @@ else - + + RequiredError="@L["Required"]" /> + RequiredError="@L["Required"]" /> + RequiredError="@L["Required"]" /> @@ -179,7 +185,7 @@ else StartIcon="@Icons.Material.Filled.Refresh" OnClick="ResetProfile" Disabled="_saving"> - Reset + @L["Reset"] - Saving... + @L["ProcessingRequest"] } else { - Save Changes + @L["Save"] } @@ -201,14 +207,14 @@ else - + - Change Password + @PRL["ChangePassword"] - Update your password to keep your account secure + @PRL["PasswordDescription"] @@ -218,40 +224,40 @@ else + RequiredError="@L["Required"]" /> + RequiredError="@L["Required"]" + HelperText="@PRL["PasswordRequirements"]" /> @@ -264,7 +270,7 @@ else StartIcon="@Icons.Material.Filled.Clear" OnClick="ResetPasswordForm" Disabled="_changingPassword"> - Clear + @L["Clear"] - Updating... + @L["ProcessingRequest"] } else { - Update Password + @PRL["ChangePassword"] } @@ -385,7 +391,7 @@ else } catch (Exception ex) { - Snackbar.Add($"Failed to load profile: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -410,14 +416,14 @@ else // Validate file size (2MB max) if (file.Size > 2 * 1024 * 1024) { - Snackbar.Add("File too large. Maximum 2MB allowed.", Severity.Error); + Snackbar.Add(PRL["FileTooLarge"], Severity.Error); return; } // Validate file type if (!file.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) { - Snackbar.Add("Only image files are allowed.", Severity.Error); + Snackbar.Add(PRL["OnlyImagesAllowed"], Severity.Error); return; } @@ -437,12 +443,12 @@ else // Set preview URL _avatarPreviewUrl = $"data:{file.ContentType};base64,{Convert.ToBase64String(bytes)}"; - Snackbar.Add("Image ready. Click Save Changes to upload.", Severity.Info); + Snackbar.Add(PRL["ImageReady"], Severity.Info); StateHasChanged(); } catch (Exception ex) { - Snackbar.Add($"Failed to process image: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } } @@ -454,7 +460,7 @@ else _pendingImageContentType = null; _deleteCurrentImage = true; _hasImageChanges = true; - Snackbar.Add("Photo will be removed when you save changes.", Severity.Info); + Snackbar.Add(PRL["PhotoWillBeRemoved"], Severity.Info); StateHasChanged(); } @@ -483,10 +489,10 @@ else { if (_profileForm is not null) { - await _profileForm.Validate(); + await _profileForm.ValidateAsync(); if (!_profileForm.IsValid) { - Snackbar.Add("Please fix the validation errors.", Severity.Warning); + Snackbar.Add(L["Required"], Severity.Warning); return; } } @@ -517,7 +523,7 @@ else await IdentityClient.ProfilePutAsync(command); - Snackbar.Add("Profile updated successfully!", Severity.Success); + Snackbar.Add(PRL["ProfileUpdatedSuccessfully"], Severity.Success); // Reload profile to get updated data await LoadProfileAsync(); @@ -528,11 +534,11 @@ else } catch (FSH.Playground.Blazor.ApiClient.ApiException ex) { - Snackbar.Add($"Failed to update profile: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } catch (Exception ex) { - Snackbar.Add($"An error occurred: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -555,7 +561,7 @@ else return null; // Required validation handles empty if (confirmPassword != _passwordModel.NewPassword) - return "Passwords do not match"; + return L["PasswordsMustMatch"]; return null; } @@ -564,17 +570,17 @@ else { if (_passwordForm is not null) { - await _passwordForm.Validate(); + await _passwordForm.ValidateAsync(); if (!_passwordForm.IsValid) { - Snackbar.Add("Please fix the validation errors.", Severity.Warning); + Snackbar.Add(L["Required"], Severity.Warning); return; } } if (_passwordModel.NewPassword != _passwordModel.ConfirmPassword) { - Snackbar.Add("Passwords do not match.", Severity.Warning); + Snackbar.Add(L["PasswordsMustMatch"], Severity.Warning); return; } @@ -590,16 +596,16 @@ else await IdentityClient.ChangePasswordAsync(command); - Snackbar.Add("Password changed successfully!", Severity.Success); + Snackbar.Add(PRL["PasswordChangedSuccessfully"], Severity.Success); ResetPasswordForm(); } catch (FSH.Playground.Blazor.ApiClient.ApiException ex) { - Snackbar.Add($"Failed to change password: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } catch (Exception ex) { - Snackbar.Add($"An error occurred: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Roles/CreateRoleDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Roles/CreateRoleDialog.razor index 44c456552f..c885144873 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Roles/CreateRoleDialog.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Roles/CreateRoleDialog.razor @@ -1,13 +1,18 @@ @using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Roles +@using Microsoft.Extensions.Localization @inject IIdentityClient IdentityClient @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer RL - @(IsEditMode ? "Edit Role" : "Create New Role") + @(IsEditMode ? RL["EditRole"] : RL["CreateRole"]) - @(IsEditMode ? "Modify role details" : "Define a new role for your organization") + @RL["RoleManagement"] @@ -16,40 +21,31 @@ @* Role Name *@ + Disabled="@(IsEditMode && IsSystemRole(_model.Name))" /> @* Role Description *@ + Lines="3" /> @* Info Alert *@ - @if (IsEditMode) - { - After saving, you can manage permissions for this role from the Roles page. - } - else - { - After creating the role, you can assign permissions to define what users with this role can do. - } + @RL["ManagePermissions"] @@ -61,7 +57,7 @@ Color="Color.Default" OnClick="Cancel" Disabled="_busy"> - Cancel + @L["Cancel"] - @(IsEditMode ? "Saving..." : "Creating...") + @L["ProcessingRequest"] } else { - @(IsEditMode ? "Save Changes" : "Create Role") + @(IsEditMode ? L["Save"] : RL["CreateRole"]) } @@ -130,7 +126,7 @@ { if (_form is not null) { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) { return; @@ -139,7 +135,7 @@ if (string.IsNullOrWhiteSpace(_model.Name)) { - Snackbar.Add("Please enter a role name.", Severity.Warning); + Snackbar.Add(L["Required"], Severity.Warning); return; } @@ -147,12 +143,12 @@ try { await IdentityClient.RolesPostAsync(_model); - Snackbar.Add($"Role '{_model.Name}' {(IsEditMode ? "updated" : "created")} successfully!", Severity.Success); + Snackbar.Add(IsEditMode ? RL["RoleUpdatedSuccessfully"] : RL["RoleCreatedSuccessfully"], Severity.Success); MudDialog.Close(DialogResult.Ok(true)); } catch (Exception ex) { - Snackbar.Add($"Failed to {(IsEditMode ? "update" : "create")} role: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Roles/RolePermissionsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Roles/RolePermissionsPage.razor index 8b65223dea..42095e4ad6 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Roles/RolePermissionsPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Roles/RolePermissionsPage.razor @@ -1,20 +1,25 @@ @page "/roles/{Id:guid}/permissions" @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Dialogs +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Roles +@using Microsoft.Extensions.Localization @inherits ComponentBase @inject IIdentityClient IdentityClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject IStringLocalizer L +@inject IStringLocalizer RL - + - Back to Roles + @L["Back"] @@ -24,7 +29,7 @@ - Loading role information... + @L["Loading"] } @@ -33,10 +38,10 @@ else if (_role is null) - Role Not Found - The requested role could not be found. + @L["NotFound"] + @L["NotFound"] - Back to Roles + @L["Back"] @@ -49,28 +54,28 @@ else @_role.Name - @(string.IsNullOrEmpty(_role.Description) ? "No description provided" : _role.Description) + @(string.IsNullOrEmpty(_role.Description) ? RL["NoDescription"] : _role.Description) @if (IsAdminRole(_role.Name)) { - Admin (Full Access) + Admin } else if (IsSystemRole(_role.Name)) { - System Role + @RL["System"] } else { - Custom Role + @RL["Custom"] } @@ -87,7 +92,7 @@ else Color="Color.Default" OnClick="ResetChanges" Disabled="_saving"> - Reset + @L["Reset"] } } - Save Changes + @L["Save"] } @@ -110,7 +115,7 @@ else { - Admin role has full access to all permissions. These permissions cannot be modified. + @RL["ManagePermissions"] } @@ -124,7 +129,7 @@ else @_currentPermissions.Count - Assigned + @L["Assigned"] @@ -137,7 +142,7 @@ else @(_allPermissions.Count - _currentPermissions.Count) - Available + @L["Available"] @@ -150,7 +155,7 @@ else @_groupedPermissions.Count - Categories + @L["Categories"] @@ -164,7 +169,7 @@ else Color="@(HasChanges ? Color.Warning : Color.Success)" /> @GetChangedCount() - Changes + @L["Changes"] @@ -176,7 +181,7 @@ else - Deselect All + @L["DeselectAll"] } - Expand + @L["Expand"] - Collapse + @L["Collapse"] @@ -275,7 +280,7 @@ else @if (HasPermissionChanged(perm)) { - @(IsPermissionAssigned(perm) ? "Will be added" : "Will be removed") + @(IsPermissionAssigned(perm) ? L["WillBeAdded"] : L["WillBeRemoved"]) } @@ -300,15 +305,15 @@ else - No permissions found + @L["NoDataAvailable"] @if (!string.IsNullOrEmpty(_searchTerm)) { - No permissions match your search. Try a different term. + @L["NoDataAvailable"] } else { - No permissions available in the system. + @L["NoDataAvailable"] } @@ -474,7 +479,7 @@ else } catch (Exception ex) { - Snackbar.Add($"Failed to load data: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -616,11 +621,11 @@ else _originalPermissions = new HashSet(_currentPermissions); - Snackbar.Add("Permissions updated successfully.", Severity.Success); + Snackbar.Add(RL["PermissionsUpdatedSuccessfully"], Severity.Success); } catch (Exception ex) { - Snackbar.Add($"Failed to save permissions: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -633,9 +638,9 @@ else Navigation.NavigateTo("/roles"); } - private static string FormatCategoryName(string category) + private string FormatCategoryName(string category) { - if (string.IsNullOrEmpty(category)) return "Other"; + if (string.IsNullOrEmpty(category)) return L["Other"]; return char.ToUpper(category[0]) + category.Substring(1); } diff --git a/src/Playground/Playground.Blazor/Components/Pages/Roles/RolesPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Roles/RolesPage.razor index 29a5b4d6f0..9bb83af7af 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Roles/RolesPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Roles/RolesPage.razor @@ -2,14 +2,19 @@ @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Dialogs @using FSH.Framework.Blazor.UI.Components.Cards +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Roles +@using Microsoft.Extensions.Localization @inherits ComponentBase @inject IIdentityClient IdentityClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject IStringLocalizer L +@inject IStringLocalizer RL - + @if (_selectedRoles.Any()) { @@ -18,11 +23,11 @@ Color="Color.Error" OnClick="BulkDelete" Disabled="_bulkBusy"> - Delete (@_selectedRoles.Count) + @L["Delete"] (@_selectedRoles.Count) - Clear + @L["Clear"] } @@ -41,40 +46,40 @@ + Label="@RL["TotalRoles"]" + Badge="@L["Total"]" />
+ Label="@RL["SystemRoles"]" + Badge="@L["System"]" /> + Label="@RL["CustomRoles"]" + Badge="@RL["Custom"]" /> + Label="@L["Protected"]" + Badge="@L["Protected"]" />
@* Filter Panel *@ - + - Clear Filters + @L["Clear"] - @(string.IsNullOrEmpty(context.Item.Description) ? "No description" : context.Item.Description) + @(string.IsNullOrEmpty(context.Item.Description) ? RL["NoDescription"] : context.Item.Description) - + @if (IsSystemRole(context.Item.Name)) { - System + @L["System"] } else { - Custom + @RL["Custom"] } - + - + - + - No roles found - Try adjusting your filters or create a new role. + @RL["NoRolesFound"] + @RL["TryAdjustingFilters"] @@ -256,7 +261,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load roles: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -335,7 +340,7 @@ CloseButton = true }; - var dialog = await DialogService.ShowAsync("Create Role", options); + var dialog = await DialogService.ShowAsync(RL["CreateRole"], options); var result = await dialog.Result; if (result is not null && !result.Canceled) @@ -348,7 +353,7 @@ { if (IsProtectedRole(role.Name)) { - Snackbar.Add("Protected roles cannot be edited.", Severity.Warning); + Snackbar.Add(RL["SystemRolesCannotBeEdited"], Severity.Warning); return; } @@ -365,7 +370,7 @@ CloseButton = true }; - var dialog = await DialogService.ShowAsync("Edit Role", parameters, options); + var dialog = await DialogService.ShowAsync(RL["EditRole"], parameters, options); var result = await dialog.Result; if (result is not null && !result.Canceled) @@ -380,23 +385,23 @@ if (IsProtectedRole(role.Name)) { - Snackbar.Add("Protected roles cannot be deleted.", Severity.Warning); + Snackbar.Add(RL["SystemRolesCannotBeDeleted"], Severity.Warning); return; } - var confirmed = await DialogService.ShowDeleteConfirmAsync(role.Name ?? "this role"); + var confirmed = await DialogService.ShowDeleteConfirmAsync(role.Name ?? L["Unknown"], L); if (!confirmed) return; _busyRoleId = role.Id; try { await IdentityClient.RolesDeleteAsync(Guid.Parse(role.Id)); - Snackbar.Add("Role deleted successfully.", Severity.Success); + Snackbar.Add(RL["RoleDeletedSuccessfully"], Severity.Success); await LoadData(); } catch (Exception ex) { - Snackbar.Add($"Failed to delete role: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -409,15 +414,15 @@ var roles = _selectedRoles.Where(r => !IsProtectedRole(r.Name)).ToList(); if (!roles.Any()) { - Snackbar.Add("No deletable roles selected. Protected roles cannot be deleted.", Severity.Info); + Snackbar.Add(RL["NoDeletableRolesSelected"], Severity.Info); return; } var confirmed = await DialogService.ShowConfirmAsync( - "Bulk Delete", - $"Delete {roles.Count} role(s)? This action cannot be undone.", - "Delete All", - "Cancel", + RL["BulkDelete"], + string.Format(RL["DeleteRolesConfirm"], roles.Count), + L["Delete"], + L["Cancel"], Color.Error, Icons.Material.Outlined.DeleteForever, Color.Error); @@ -439,7 +444,7 @@ } } _bulkBusy = false; - Snackbar.Add($"Deleted {success} of {roles.Count} roles.", Severity.Success); + Snackbar.Add(string.Format(RL["DeletedRolesCount"], success, roles.Count), Severity.Success); ClearSelection(); await LoadData(); } diff --git a/src/Playground/Playground.Blazor/Components/Pages/SecuritySettings.razor b/src/Playground/Playground.Blazor/Components/Pages/SecuritySettings.razor new file mode 100644 index 0000000000..f6a2d0c8b6 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Pages/SecuritySettings.razor @@ -0,0 +1,126 @@ +@page "/settings/security" +@attribute [Microsoft.AspNetCore.Authorization.Authorize] +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Profile +@using Microsoft.Extensions.Localization +@inject FSH.Playground.Blazor.ApiClient.IIdentityClient IdentityClient +@inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer PRL + +@PRL["SecuritySettings"] + + + + + + + + + + + @PRL["ChangePassword"] + + + + + + + + + + + + + + + @if (_saving) + { + + @L["ProcessingRequest"] + } + else + { + @PRL["ChangePassword"] + } + + + + + + +@code { + private string _currentPassword = string.Empty; + private string _newPassword = string.Empty; + private string _confirmPassword = string.Empty; + private bool _showCurrent; + private bool _showNew; + private bool _showConfirm; + private bool _saving; + + private bool CanSubmit => + !string.IsNullOrWhiteSpace(_currentPassword) && + !string.IsNullOrWhiteSpace(_newPassword) && + _newPassword == _confirmPassword; + + private async Task ChangePasswordAsync() + { + if (!CanSubmit) return; + + _saving = true; + try + { + await IdentityClient.ChangePasswordAsync(new FSH.Playground.Blazor.ApiClient.ChangePasswordCommand + { + Password = _currentPassword, + NewPassword = _newPassword, + ConfirmNewPassword = _confirmPassword + }); + + Snackbar.Add(PRL["PasswordChangedSuccessfully"], Severity.Success); + _currentPassword = string.Empty; + _newPassword = string.Empty; + _confirmPassword = string.Empty; + } + catch (FSH.Playground.Blazor.ApiClient.ApiException ex) when (ex.StatusCode == 400) + { + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); + } + finally + { + _saving = false; + } + } +} diff --git a/src/Playground/Playground.Blazor/Components/Pages/Sessions/SessionsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Sessions/SessionsPage.razor index e3e9033aee..02bc1b8143 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Sessions/SessionsPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Sessions/SessionsPage.razor @@ -2,6 +2,9 @@ @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Dialogs @using FSH.Framework.Blazor.UI.Components.Cards +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Sessions +@using Microsoft.Extensions.Localization @inherits ComponentBase @using System.Security.Claims @using Microsoft.AspNetCore.Components.Authorization @@ -11,16 +14,18 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @inject AuthenticationStateProvider AuthenticationStateProvider +@inject IStringLocalizer L +@inject IStringLocalizer SL - + - Logout All Other Devices + @SL["RevokeAllSessions"] @@ -31,29 +36,29 @@ + Label="@SL["ActiveSessions"]" + Badge="@L["Total"]" /> + Label="@SL["DesktopSessions"]" + Badge="@SL["Desktop"]" /> + Label="@SL["MobileSessions"]" + Badge="@SL["Mobile"]" /> + Label="@SL["TabletSessions"]" + Badge="@SL["Tablet"]" /> @@ -108,12 +113,12 @@ - + @context.Item.IpAddress - + @FormatRelativeTime(context.Item.LastActivityAt) @@ -121,7 +126,7 @@ - + @FormatRelativeTime(context.Item.CreatedAt) @@ -129,23 +134,23 @@ - + @if (context.Item.IsActive) { - Active + @L["Active"] } else { - Expired + @L["Expired"] } - + @if (!context.Item.IsCurrentSession) { - + + - No active sessions - You don't have any active sessions at the moment. + @L["NoDataAvailable"] + @SL["NoOtherSessions"] @@ -228,7 +233,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load sessions: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -250,10 +255,10 @@ private async Task RevokeSession(UserSessionDto session) { var confirmed = await DialogService.ShowConfirmAsync( - "Revoke Session", - $"Are you sure you want to log out from this device?\n\n{session.Browser} on {session.OperatingSystem}", - "Revoke", - "Cancel", + SL["RevokeSession"], + string.Format(SL["ConfirmRevokeSession"], session.Browser, session.OperatingSystem), + SL["RevokeSession"], + L["Cancel"], Color.Error); if (!confirmed) return; @@ -262,12 +267,12 @@ try { await IdentityClient.SessionsAsync(session.Id); - Snackbar.Add("Session revoked successfully.", Severity.Success); + Snackbar.Add(SL["SessionRevokedSuccessfully"], Severity.Success); await LoadSessions(); } catch (Exception ex) { - Snackbar.Add($"Failed to revoke session: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -280,15 +285,15 @@ var otherSessions = _sessions.Count(s => !s.IsCurrentSession); if (otherSessions == 0) { - Snackbar.Add("No other sessions to revoke.", Severity.Info); + Snackbar.Add(SL["NoOtherSessions"], Severity.Info); return; } var confirmed = await DialogService.ShowConfirmAsync( - "Logout All Other Devices", - $"This will log you out from {otherSessions} other device(s). Your current session will remain active.", - "Logout All", - "Cancel", + SL["LogoutAllDevices"], + string.Format(SL["LogoutAllConfirm"], otherSessions), + SL["LogoutAll"], + L["Cancel"], Color.Warning, Icons.Material.Outlined.Warning, Color.Warning); @@ -299,12 +304,12 @@ try { await SessionsClient.RevokeAllPostAsync(null); - Snackbar.Add($"Successfully logged out from {otherSessions} device(s).", Severity.Success); + Snackbar.Add(SL["AllSessionsRevoked"], Severity.Success); await LoadSessions(); } catch (Exception ex) { - Snackbar.Add($"Failed to revoke sessions: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -326,21 +331,21 @@ _ => Color.Info }; - private static string FormatRelativeTime(DateTimeOffset dateTime) + private string FormatRelativeTime(DateTimeOffset dateTime) { var timeSpan = DateTimeOffset.UtcNow - dateTime; if (timeSpan.TotalMinutes < 1) - return "Just now"; + return SL["JustNow"]; if (timeSpan.TotalMinutes < 60) - return $"{(int)timeSpan.TotalMinutes} min ago"; + return string.Format(SL["MinutesAgo"], (int)timeSpan.TotalMinutes); if (timeSpan.TotalHours < 24) - return $"{(int)timeSpan.TotalHours} hour(s) ago"; + return string.Format(SL["HoursAgo"], (int)timeSpan.TotalHours); if (timeSpan.TotalDays < 7) - return $"{(int)timeSpan.TotalDays} day(s) ago"; + return string.Format(SL["DaysAgo"], (int)timeSpan.TotalDays); if (timeSpan.TotalDays < 30) - return $"{(int)(timeSpan.TotalDays / 7)} week(s) ago"; + return string.Format(SL["WeeksAgo"], (int)(timeSpan.TotalDays / 7)); - return $"{(int)(timeSpan.TotalDays / 30)} month(s) ago"; + return string.Format(SL["MonthsAgo"], (int)(timeSpan.TotalDays / 30)); } } diff --git a/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor b/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor index cc5248d8eb..76f668d842 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/SimpleLogin.razor @@ -2,15 +2,19 @@ @layout EmptyLayout @attribute [AllowAnonymous] @using FSH.Framework.Shared.Multitenancy +@using FSH.Framework.Shared.Localization @using FSH.Playground.Blazor.Services @using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Components.Authorization +@using Microsoft.Extensions.Localization @inject NavigationManager Navigation @inject IHttpClientFactory HttpClientFactory @inject AuthenticationStateProvider AuthenticationStateProvider @inject IWebHostEnvironment Environment +@inject ISnackbar Snackbar +@inject IStringLocalizer L -Login +@L["Login"] @@ -115,6 +119,7 @@ private string _email = string.Empty; private string _password = string.Empty; private bool _showPassword = false; + private bool _isAuthenticated = false; protected override async Task OnInitializedAsync() { @@ -128,9 +133,41 @@ } var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); - if (authState.User.Identity?.IsAuthenticated == true) + _isAuthenticated = authState.User.Identity?.IsAuthenticated == true; + + // Check for error message in query parameters + var uri = new Uri(Navigation.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var errorMessage = query["error"]; + if (!string.IsNullOrEmpty(errorMessage)) + { + Snackbar.Add(errorMessage, Severity.Error); + // Remove the error parameter from URL to avoid showing it again on refresh + try + { + Navigation.NavigateTo("/login", replace: true); + } + catch (NavigationException) + { + // NavigationException is expected when NavigateTo interrupts the render cycle + // The navigation will still succeed + } + } + } + + protected override void OnAfterRender(bool firstRender) + { + if (firstRender && _isAuthenticated) { - Navigation.NavigateTo("/", replace: true); + try + { + Navigation.NavigateTo("/", replace: true); + } + catch (NavigationException) + { + // NavigationException is expected when NavigateTo interrupts the render cycle + // The navigation will still succeed + } } } diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/CreateTenantDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/CreateTenantDialog.razor index 527c725504..5983fedd37 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Tenants/CreateTenantDialog.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/CreateTenantDialog.razor @@ -1,6 +1,11 @@ @using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Tenants +@using Microsoft.Extensions.Localization @inject IV1Client V1Client @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer TL @@ -9,8 +14,8 @@ - Create New Tenant - Add a new organization to the platform + @TL["CreateTenant"] + @TL["TenantManagement"] @@ -21,32 +26,30 @@ - Basic Information + @L["Details"] @@ -54,45 +57,45 @@ - Administrator Account + @L["Admin"] @* Advanced Settings Section *@ - + + HelperText="@TL["Issuer"]" /> @@ -104,7 +107,7 @@ - A new tenant will be provisioned with default settings. The admin will receive credentials to access the tenant. + @TL["TenantDetails"] @@ -117,7 +120,7 @@ Color="Color.Default" OnClick="Cancel" Disabled="_busy"> - Cancel + @L["Cancel"] - Creating... + @L["ProcessingRequest"] } else { - Create Tenant + @TL["CreateTenant"] } @@ -160,7 +163,7 @@ { if (_form is not null) { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) { return; @@ -171,7 +174,7 @@ string.IsNullOrWhiteSpace(_model.Name) || string.IsNullOrWhiteSpace(_model.AdminEmail)) { - Snackbar.Add("Please fill in all required fields.", Severity.Warning); + Snackbar.Add(L["Required"], Severity.Warning); return; } @@ -179,7 +182,7 @@ var tenantId = _model.Id.Trim().ToLowerInvariant(); if (tenantId.Contains(' ') || tenantId.Contains('\t')) { - Snackbar.Add("Tenant ID cannot contain spaces.", Severity.Error); + Snackbar.Add(L["Required"], Severity.Error); return; } @@ -189,12 +192,12 @@ try { await V1Client.TenantsPostAsync(_model); - Snackbar.Add($"Tenant '{_model.Name}' created successfully!", Severity.Success); + Snackbar.Add(TL["TenantCreatedSuccessfully"], Severity.Success); MudDialog.Close(DialogResult.Ok(true)); } catch (Exception ex) { - Snackbar.Add($"Failed to create tenant: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantDetailPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantDetailPage.razor index e9780d88a9..12155e712f 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantDetailPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantDetailPage.razor @@ -3,6 +3,9 @@ @using FSH.Framework.Blazor.UI.Components.Dialogs @using FSH.Framework.Shared.Constants @using FSH.Framework.Shared.Multitenancy +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Tenants +@using Microsoft.Extensions.Localization @using Microsoft.AspNetCore.Components.Authorization @inherits ComponentBase @inject ITenantsClient TenantsClient @@ -11,46 +14,48 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @inject AuthenticationStateProvider AuthenticationStateProvider +@inject IStringLocalizer L +@inject IStringLocalizer TL @if (!_isRootTenantAdmin) { - You do not have permission to access this page. Only root tenant administrators can manage tenants. + @L["AccessDenied"] } else if (_loading) { - + - Loading tenant details... + @L["Loading"] } else if (_tenant is null) { - + - Tenant not found. + @L["NotFound"] } else { - + - Back to Tenants + @L["Back"] - Upgrade + @TL["UpgradeTenant"] @if (!IsRootTenant) { @@ -59,7 +64,7 @@ else StartIcon="@(_tenant.IsActive ? Icons.Material.Filled.Block : Icons.Material.Filled.CheckCircle)" OnClick="ToggleStatus" Disabled="_busy"> - @(_tenant.IsActive ? "Deactivate" : "Activate") + @(_tenant.IsActive ? TL["Deactivate"] : TL["Activate"]) } @@ -76,11 +81,11 @@ else @if (_tenant.IsActive) { - Active + @L["Active"] } else { - Inactive + @L["Inactive"] }
@@ -91,25 +96,25 @@ else - Tenant Information + @L["Details"] - Tenant ID + @TL["TenantIdentifier"] @_tenant.Id - Name + @L["Name"] @_tenant.Name - Admin Email + @TL["AdminEmail"] @_tenant.AdminEmail - Issuer - @(string.IsNullOrEmpty(_tenant.Issuer) ? "Default" : _tenant.Issuer) + @TL["Issuer"] + @(string.IsNullOrEmpty(_tenant.Issuer) ? L["Default"] : _tenant.Issuer) @@ -120,27 +125,27 @@ else - Subscription Details + @TL["Subscription"] - Status + @L["Status"] @if (_tenant.IsActive) { - Active + @L["Active"] } else { - Inactive + @L["Inactive"] } - Valid Until + @TL["ValidUpto"] @_tenant.ValidUpto.ToString("MMMM dd, yyyy") - Time Remaining + @L["TimeRemaining"] @GetExpiryText(_tenant.ValidUpto) @@ -150,13 +155,13 @@ else @if (IsExpiringSoon(_tenant.ValidUpto)) { - This tenant's subscription is expiring soon. Consider upgrading. + @L["ExpiringSoon"] } else if (IsExpired(_tenant.ValidUpto)) { - This tenant's subscription has expired. + @L["Expired"] } @@ -167,32 +172,32 @@ else - Database Configuration + @L["Database"] - Database Type + @L["Type"] @if (_tenant.HasConnectionString) { - Dedicated + @L["Dedicated"] } else { - Shared + @L["Shared"] } @if (_tenant.HasConnectionString) { - Connection String - + @TL["ConnectionString"] + @@ -207,7 +212,7 @@ else - Provisioning Status + @L["ProvisioningStatus"] - No provisioning information available. + @L["NoDataAvailable"] } else { - Status + @L["Status"] @_provisioningStatus.Status @@ -240,21 +245,21 @@ else @if (!string.IsNullOrEmpty(_provisioningStatus.CurrentStep)) { - Current Step + @L["CurrentStep"] @_provisioningStatus.CurrentStep } @if (_provisioningStatus.StartedUtc.HasValue) { - Started + @L["Started"] @_provisioningStatus.StartedUtc.Value.LocalDateTime.ToString("g") } @if (_provisioningStatus.CompletedUtc.HasValue) { - Completed + @L["Completed"] @_provisioningStatus.CompletedUtc.Value.LocalDateTime.ToString("g") } @@ -276,11 +281,11 @@ else @if (_retrying) { - Retrying... + @L["Loading"] } else { - Retry Provisioning + @TL["RetryProvisioning"] } } @@ -290,7 +295,7 @@ else @if (_provisioningStatus.Steps?.Any() == true) { - Provisioning Steps + @L["ProvisioningStatus"] @foreach (var step in _provisioningStatus.Steps) { @@ -377,7 +382,7 @@ else } catch (Exception ex) { - Snackbar.Add($"Failed to load tenant: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -422,21 +427,21 @@ else return validUpto > now && validUpto <= now.AddDays(30); } - private static string GetExpiryText(DateTimeOffset validUpto) + private string GetExpiryText(DateTimeOffset validUpto) { var now = DateTimeOffset.UtcNow; if (validUpto <= now) - return "Expired"; + return L["Expired"]; var diff = validUpto - now; if (diff.TotalDays < 1) - return "Expires today"; + return L["ExpiringSoon"]; if (diff.TotalDays < 2) - return "Expires tomorrow"; + return L["ExpiringSoon"]; if (diff.TotalDays <= 30) - return $"Expires in {(int)diff.TotalDays} days"; + return $"{(int)diff.TotalDays} {L["Days"]}"; - return $"{(int)diff.TotalDays} days remaining"; + return $"{(int)diff.TotalDays} {L["Days"]}"; } private static Color GetExpiryColor(DateTimeOffset validUpto) @@ -472,10 +477,10 @@ else if (_tenant is null) return; var confirmed = await DialogService.ShowConfirmAsync( - "Retry Provisioning", - $"Are you sure you want to retry provisioning for '{_tenant.Name}'? This will attempt to complete any failed provisioning steps.", - "Retry", - "Cancel", + TL["RetryProvisioning"], + string.Format(TL["ConfirmRetryProvisioning"], _tenant.Name), + L["Retry"], + L["Cancel"], Color.Primary); if (!confirmed) return; @@ -484,11 +489,11 @@ else try { _provisioningStatus = await ProvisioningClient.RetryAsync(_tenant.Id); - Snackbar.Add("Provisioning retry initiated successfully.", Severity.Success); + Snackbar.Add(TL["ProvisioningRetrySuccess"], Severity.Success); } catch (Exception ex) { - Snackbar.Add($"Failed to retry provisioning: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -504,16 +509,15 @@ else if (IsRootTenant) { - Snackbar.Add("Root tenant cannot be deactivated.", Severity.Warning); + Snackbar.Add(TL["RootCannotBeDeactivated"], Severity.Warning); return; } - var action = _tenant.IsActive ? "deactivate" : "activate"; var confirmed = await DialogService.ShowConfirmAsync( - $"{(_tenant.IsActive ? "Deactivate" : "Activate")} Tenant", - $"Are you sure you want to {action} '{_tenant.Name}'?", - _tenant.IsActive ? "Deactivate" : "Activate", - "Cancel", + _tenant.IsActive ? TL["Deactivate"] : TL["Activate"], + string.Format(TL["ConfirmToggleStatus"], _tenant.Name), + _tenant.IsActive ? TL["Deactivate"] : TL["Activate"], + L["Cancel"], _tenant.IsActive ? Color.Warning : Color.Success); if (!confirmed) return; @@ -527,12 +531,12 @@ else IsActive = !_tenant.IsActive }; await TenantsClient.ActivationAsync(_tenant.Id, command); - Snackbar.Add($"Tenant {(_tenant.IsActive ? "deactivated" : "activated")} successfully.", Severity.Success); + Snackbar.Add(TL["TenantUpdatedSuccessfully"], Severity.Success); await LoadTenant(); } catch (Exception ex) { - Snackbar.Add($"Failed to update tenant: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -567,7 +571,7 @@ else CloseButton = true }; - var dialog = await DialogService.ShowAsync("Upgrade Subscription", parameters, options); + var dialog = await DialogService.ShowAsync(TL["UpgradeTenant"], parameters, options); var result = await dialog.Result; if (result is not null && !result.Canceled) diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantSettingsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantSettingsPage.razor index 62789fa24b..8a7287f749 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantSettingsPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantSettingsPage.razor @@ -3,22 +3,27 @@ @using FSH.Framework.Blazor.UI.Components.Page @using FSH.Framework.Shared.Constants @using FSH.Framework.Shared.Multitenancy +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Tenants +@using Microsoft.Extensions.Localization @using Microsoft.AspNetCore.Components.Authorization @inherits ComponentBase @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject AuthenticationStateProvider AuthenticationStateProvider +@inject IStringLocalizer L +@inject IStringLocalizer TL @if (!_isRootTenantAdmin) { - You do not have permission to access this page. Only root tenant administrators can manage tenant settings. + @L["AccessDenied"] } else { - + @* Session Management Settings *@ @@ -26,28 +31,28 @@ else - Session Management + @L["Sessions"] - Session management settings will be available in a future update. + @L["ComingSoon"] - Configure session limits, idle timeouts, and concurrent session policies for all tenants. + @TL["TenantManagement"] - Max Sessions per User - Unlimited + @L["MaxSessions"] + @L["Unlimited"] - Session Idle Timeout - None + @L["SessionIdleTimeout"] + @L["None"] - Session Absolute Timeout - 7 days + @L["SessionAbsoluteTimeout"] + 7 @L["Days"] @@ -59,28 +64,28 @@ else - Security Policies + @L["Security"] - Security policy settings will be available in a future update. + @L["ComingSoon"] - Configure password policies, lockout settings, and authentication requirements. + @TL["TenantManagement"] - Password History - Enabled (5) + @L["PasswordHistory"] + @L["Enabled"] (5) - Account Lockout - Default + @L["AccountLockout"] + @L["Default"] - Two-Factor Authentication - Optional + @L["TwoFactorAuthentication"] + @L["Optional"] @@ -92,28 +97,28 @@ else - Usage Quotas + @L["Quotas"] - Quota settings will be available in a future update. + @L["ComingSoon"] - Configure storage limits, API rate limits, and resource quotas per tenant. + @TL["TenantManagement"] - Max Users - Unlimited + @L["MaxUsers"] + @L["Unlimited"] - Storage Limit - Unlimited + @L["StorageLimit"] + @L["Unlimited"] - API Rate Limit - None + @L["APIRateLimit"] + @L["None"] @@ -125,28 +130,28 @@ else - Notifications + @L["Notifications"] - Notification settings will be available in a future update. + @L["ComingSoon"] - Configure email notifications, alerts, and system messages for tenants. + @TL["TenantManagement"] - Email Notifications - Enabled + @L["EmailNotifications"] + @L["Enabled"] - System Alerts - Enabled + @L["SystemAlerts"] + @L["Enabled"] - Subscription Reminders - Enabled + @TL["SubscriptionReminders"] + @L["Enabled"] diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantsPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantsPage.razor index 40761eb3b2..fdc09c2048 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantsPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/TenantsPage.razor @@ -4,6 +4,10 @@ @using FSH.Framework.Blazor.UI.Components.Cards @using FSH.Framework.Shared.Constants @using FSH.Framework.Shared.Multitenancy +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Tenants +@using Microsoft.Extensions.Localization +@using Microsoft.AspNetCore.Components @using Microsoft.AspNetCore.Components.Authorization @inherits ComponentBase @inject IV1Client V1Client @@ -12,23 +16,25 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @inject AuthenticationStateProvider AuthenticationStateProvider +@inject IStringLocalizer L +@inject IStringLocalizer TL @if (!_isRootTenantAdmin) { - You do not have permission to access this page. Only root tenant administrators can manage tenants. + @TL["NoPermission"] } else { - + - New Tenant + @TL["CreateTenant"] @@ -39,40 +45,40 @@ else + Label="@TL["TotalTenants"]" + Badge="@L["Total"]" />
+ Label="@TL["ActiveTenants"]" + Badge="@L["Active"]" /> + Label="@TL["InactiveTenants"]" + Badge="@L["Inactive"]" /> + Label="@L["ExpiringSoon"]" + Badge="@L["Expiring"]" />
@* Filter Panel *@ - + - - All - Active - Inactive + + @L["All"] + @L["Active"] + @L["Inactive"] @@ -91,7 +97,7 @@ else Color="Color.Default" Variant="Variant.Text" OnClick="ClearFilters"> - Clear Filters + @L["Clear"] - - + + @context.Item.ValidUpto.ToString("MMM dd, yyyy") @@ -147,7 +153,7 @@ else } else if (IsExpired(context.Item.ValidUpto)) { - Expired + @L["Expired"] } else { @@ -158,46 +164,46 @@ else - + @if (!string.IsNullOrWhiteSpace(context.Item.ConnectionString)) { - Dedicated + @L["Dedicated"] } else { - Shared + @L["Shared"] } - + @if (context.Item.IsActive) { - Active + @L["Active"] } else { - Inactive + @L["Inactive"] } - + - + - + - No tenants found - Try adjusting your filters or create a new tenant. + @TL["NoTenantsFound"] + @TL["TryAdjustingFilters"] @@ -318,7 +324,7 @@ else } catch (Exception ex) { - Snackbar.Add($"Failed to load tenants: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -403,21 +409,21 @@ else return validUpto > now && validUpto <= now.AddDays(30); } - private static string GetExpiryText(DateTimeOffset validUpto) + private string GetExpiryText(DateTimeOffset validUpto) { var now = DateTimeOffset.UtcNow; if (validUpto <= now) - return "Expired"; + return L["Expired"]; var diff = validUpto - now; if (diff.TotalDays < 1) - return "Expires today"; + return L["ExpiringSoon"]; if (diff.TotalDays < 2) - return "Expires tomorrow"; + return L["ExpiringSoon"]; if (diff.TotalDays <= 30) - return $"Expires in {(int)diff.TotalDays} days"; + return $"{(int)diff.TotalDays} {L["Days"]}"; - return $"{(int)diff.TotalDays} days remaining"; + return $"{(int)diff.TotalDays} {L["Days"]}"; } private bool IsToggleDisabled(TenantDto tenant) => @@ -425,17 +431,26 @@ else _busyTenantId == tenant.Id || IsRootTenant(tenant); - private static string GetToggleTooltip(TenantDto tenant) + private string GetToggleTooltip(TenantDto tenant) { if (IsRootTenant(tenant)) - return "Root tenant cannot be deactivated"; - return tenant.IsActive ? "Deactivate tenant" : "Activate tenant"; + return TL["RootTenantCannotBeDeactivated"]; + return tenant.IsActive ? TL["Deactivate"] : TL["Activate"]; } private void GoToDetail(string? id) { if (!string.IsNullOrWhiteSpace(id)) - Navigation.NavigateTo($"/tenants/{id}"); + { + try + { + Navigation.NavigateTo($"/tenants/{id}"); + } + catch (NavigationException) + { + // NavigationException is expected in Blazor Server - navigation still succeeds + } + } } private async Task ToggleStatus(TenantDto tenant) @@ -444,16 +459,15 @@ else if (IsRootTenant(tenant)) { - Snackbar.Add("Root tenant cannot be deactivated.", Severity.Warning); + Snackbar.Add(TL["RootTenantCannotBeDeactivated"], Severity.Warning); return; } - var action = tenant.IsActive ? "deactivate" : "activate"; var confirmed = await DialogService.ShowConfirmAsync( - $"{(tenant.IsActive ? "Deactivate" : "Activate")} Tenant", - $"Are you sure you want to {action} '{tenant.Name}'?", - tenant.IsActive ? "Deactivate" : "Activate", - "Cancel", + tenant.IsActive ? TL["Deactivate"] : TL["Activate"], + string.Format(TL["ConfirmToggleStatus"], tenant.Name), + tenant.IsActive ? TL["Deactivate"] : TL["Activate"], + L["Cancel"], tenant.IsActive ? Color.Warning : Color.Success); if (!confirmed) return; @@ -467,12 +481,12 @@ else IsActive = !tenant.IsActive }; await TenantsClient.ActivationAsync(tenant.Id, command); - Snackbar.Add($"Tenant {(tenant.IsActive ? "deactivated" : "activated")} successfully.", Severity.Success); + Snackbar.Add(TL["TenantUpdatedSuccessfully"], Severity.Success); await LoadData(); } catch (Exception ex) { - Snackbar.Add($"Failed to update tenant: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -490,7 +504,7 @@ else CloseButton = true }; - var dialog = await DialogService.ShowAsync("Create Tenant", options); + var dialog = await DialogService.ShowAsync(TL["CreateTenant"], options); var result = await dialog.Result; if (result is not null && !result.Canceled) @@ -514,7 +528,7 @@ else CloseButton = true }; - var dialog = await DialogService.ShowAsync("Upgrade Subscription", parameters, options); + var dialog = await DialogService.ShowAsync(TL["UpgradeTenant"], parameters, options); var result = await dialog.Result; if (result is not null && !result.Canceled) diff --git a/src/Playground/Playground.Blazor/Components/Pages/Tenants/UpgradeTenantDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Tenants/UpgradeTenantDialog.razor index e09ca42093..5100f97449 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Tenants/UpgradeTenantDialog.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Tenants/UpgradeTenantDialog.razor @@ -1,6 +1,11 @@ @using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Tenants +@using Microsoft.Extensions.Localization @inject ITenantsClient TenantsClient @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer TL @@ -9,8 +14,8 @@ - Upgrade Subscription - Extend the subscription for @Tenant?.Name + @TL["UpgradeTenant"] + @TL["ExtendValidUpto"] - @Tenant?.Name @@ -21,30 +26,30 @@ @* Current Subscription Info *@ - Current Subscription + @L["Details"] - Tenant + @L["Tenant"] @Tenant.Name - Valid Until + @TL["ValidUpto"] @Tenant.ValidUpto.ToString("MMMM dd, yyyy") - Status + @L["Status"] @if (IsExpired(Tenant.ValidUpto)) { - Expired + @L["Expired"] } else if (IsExpiringSoon(Tenant.ValidUpto)) { - Expiring Soon + @L["ExpiringSoon"] } else { - Active + @L["Active"] } @@ -52,13 +57,13 @@ @* New Expiry Date *@ - New Expiry Date + @TL["ExtendValidUpto"] - Quick Extend + @TL["ExtendValidUpto"] - +30 Days + +30 @L["Days"] - +90 Days + +90 @L["Days"] - +6 Months + +6 @TL["Months"] - +1 Year + +1 @TL["Year"] @@ -124,7 +129,7 @@ Color="Color.Default" OnClick="Cancel" Disabled="_busy"> - Cancel + @L["Cancel"] - Upgrading... + @L["ProcessingRequest"] } else { - Upgrade Subscription + @TL["UpgradeTenant"] } @@ -207,13 +212,13 @@ { if (Tenant is null || !_newExpiryDate.HasValue) { - Snackbar.Add("Please select a new expiry date.", Severity.Warning); + Snackbar.Add(L["Required"], Severity.Warning); return; } if (_newExpiryDate.Value <= DateTime.Today) { - Snackbar.Add("New expiry date must be in the future.", Severity.Error); + Snackbar.Add(L["Required"], Severity.Error); return; } @@ -230,12 +235,12 @@ }; await TenantsClient.UpgradeAsync(Tenant.Id, command); - Snackbar.Add($"Subscription for '{Tenant.Name}' upgraded successfully!", Severity.Success); + Snackbar.Add(TL["TenantUpgradedSuccessfully"], Severity.Success); MudDialog.Close(DialogResult.Ok(true)); } catch (Exception ex) { - Snackbar.Add($"Failed to upgrade subscription: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/ThemeSettings.razor b/src/Playground/Playground.Blazor/Components/Pages/ThemeSettings.razor index cc0e37f4dd..a518b7014b 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/ThemeSettings.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/ThemeSettings.razor @@ -2,21 +2,26 @@ @using FSH.Framework.Blazor.UI.Theme @using FSH.Framework.Blazor.UI.Components.Theme @using FSH.Framework.Blazor.UI.Components.Page +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Profile @using FSH.Playground.Blazor.Services +@using Microsoft.Extensions.Localization @inject ITenantThemeState ThemeState @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer PRL -Theme Settings +@PRL["ThemeSettings"] - + - Reset to Defaults + @L["Reset"] - Saving... + @L["ProcessingRequest"] } else { - Save Changes + @L["Save"] } @@ -64,7 +69,7 @@ { // Reload theme after save to get the actual uploaded URLs await ThemeState.LoadThemeAsync(); - Snackbar.Add("Theme saved successfully.", Severity.Success); + Snackbar.Add(PRL["ThemeSavedSuccessfully"], Severity.Success); } private async Task HandleAssetUpload((IBrowserFile File, string AssetType, Action SetAsset) args) @@ -74,13 +79,13 @@ // Validate file if (args.File.Size > 2 * 1024 * 1024) // 2MB max { - Snackbar.Add("File too large. Maximum 2MB allowed.", Severity.Error); + Snackbar.Add(PRL["FileTooLarge"], Severity.Error); return; } if (!args.File.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) { - Snackbar.Add("Only image files are allowed.", Severity.Error); + Snackbar.Add(PRL["OnlyImagesAllowed"], Severity.Error); return; } @@ -103,11 +108,11 @@ // Set both preview URL and file upload data via callback args.SetAsset(previewUrl, fileUpload); - Snackbar.Add("Image ready. Click Save to upload.", Severity.Info); + Snackbar.Add(PRL["ImageReady"], Severity.Info); } catch (Exception ex) { - Snackbar.Add($"Failed to process file: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } } } diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/CreateUserDialog.razor b/src/Playground/Playground.Blazor/Components/Pages/Users/CreateUserDialog.razor index a79ea7799e..5b07411dd0 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Users/CreateUserDialog.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/CreateUserDialog.razor @@ -1,6 +1,11 @@ @using FSH.Playground.Blazor.ApiClient +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Users +@using Microsoft.Extensions.Localization @inject IIdentityClient IdentityClient @inject ISnackbar Snackbar +@inject IStringLocalizer L +@inject IStringLocalizer UL @@ -9,8 +14,8 @@ - Create New User - Add a new team member to the system + @UL["CreateUser"] + @UL["UserManagement"] @@ -22,21 +27,21 @@ - Personal + @L["Profile"] - Account + @L["Details"] - Security + @L["Security"] @@ -45,16 +50,16 @@ - Personal Information + @L["Profile"] - Account Details + @L["Details"] + AdornmentIcon="@Icons.Material.Filled.Phone" /> @* Security Section *@ - Security + @L["Security"] @@ -156,7 +156,7 @@ - The user will receive an email to verify their account. You can manage their roles after creation. + @UL["UserDetails"] @@ -169,7 +169,7 @@ Color="Color.Default" OnClick="Cancel" Disabled="_busy"> - Cancel + @L["Cancel"] - Creating... + @L["ProcessingRequest"] } else { - Create User + @UL["CreateUser"] } @@ -229,7 +229,7 @@ { if (_form is not null) { - await _form.Validate(); + await _form.ValidateAsync(); if (!_form.IsValid) { return; @@ -242,13 +242,13 @@ string.IsNullOrWhiteSpace(_model.UserName) || string.IsNullOrWhiteSpace(_model.Password)) { - Snackbar.Add("Please fill in all required fields.", Severity.Warning); + Snackbar.Add(L["Required"], Severity.Warning); return; } if (_model.Password != _model.ConfirmPassword) { - Snackbar.Add("Passwords do not match.", Severity.Error); + Snackbar.Add(L["PasswordsMustMatch"], Severity.Error); return; } @@ -256,12 +256,12 @@ try { await IdentityClient.RegisterAsync(_model); - Snackbar.Add($"User '{_model.FirstName} {_model.LastName}' created successfully!", Severity.Success); + Snackbar.Add(UL["UserCreatedSuccessfully"], Severity.Success); MudDialog.Close(DialogResult.Ok(true)); } catch (Exception ex) { - Snackbar.Add($"Failed to create user: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor index 11d3bf5061..053ad72e43 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/UserDetailPage.razor @@ -1,6 +1,9 @@ @page "/users/{Id:guid}" @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Dialogs +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Users +@using Microsoft.Extensions.Localization @inherits ComponentBase @using System.Security.Claims @using FSH.Framework.Shared.Constants @@ -11,15 +14,17 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @inject AuthenticationStateProvider AuthenticationStateProvider +@inject IStringLocalizer L +@inject IStringLocalizer UL - + - Back to Users + @L["Back"] @if (_user != null) { @@ -27,7 +32,7 @@ Color="Color.Primary" StartIcon="@Icons.Material.Outlined.Security" OnClick="GoToRoles"> - Manage Roles + @UL["ManageRoles"] } @@ -38,7 +43,7 @@ - Loading user... + @L["Loading"] } @@ -47,10 +52,10 @@ else if (_user is null) - User not found - The user you're looking for doesn't exist. + @L["NotFound"] + @L["NotFound"] - Back to Users + @L["Back"] @@ -88,16 +93,16 @@ else - @(_user.IsActive ? "Active" : "Inactive") + @(_user.IsActive ? L["Active"] : L["Inactive"]) - @(_user.EmailConfirmed ? "Verified" : "Unverified") + @(_user.EmailConfirmed ? L["Verified"] : L["Unverified"]) @if (_targetIsAdmin) { - Admin + @L["Admin"] } @@ -107,19 +112,19 @@ else - Email + @L["Email"] @_user.Email - Phone - @(string.IsNullOrEmpty(_user.PhoneNumber) ? "Not provided" : _user.PhoneNumber) + @L["PhoneNumber"] + @(string.IsNullOrEmpty(_user.PhoneNumber) ? L["NotProvided"] : _user.PhoneNumber) - User ID + @L["UserId"] @_user.Id @@ -138,7 +143,7 @@ else @_roles.Count(r => r.Enabled) - Roles + @L["Roles"] @@ -149,8 +154,8 @@ else - @(_user.EmailConfirmed ? "Yes" : "No") - Verified + @(_user.EmailConfirmed ? L["Yes"] : L["No"]) + @L["Verified"] @@ -161,8 +166,8 @@ else - @(_user.IsActive ? "Active" : "Inactive") - Status + @(_user.IsActive ? L["Active"] : L["Inactive"]) + @L["Status"] @@ -173,8 +178,8 @@ else - @(_targetIsAdmin ? "Admin" : "User") - Type + @(_targetIsAdmin ? L["Admin"] : L["User"]) + @L["Type"] @@ -186,18 +191,18 @@ else - Assigned Roles + @L["Roles"] - Manage + @UL["ManageRoles"] @if (!_roles.Any(r => r.Enabled)) { - No roles assigned. Click "Manage" to assign roles. + @L["NoDataAvailable"] } else @@ -213,11 +218,11 @@ else - Group Memberships + @L["Groups"] - View All Groups + @L["ViewAll"] @@ -228,7 +233,7 @@ else else if (!_groups.Any()) { - Not a member of any groups. + @L["NoDataAvailable"] } else @@ -258,7 +263,7 @@ else - Account Actions + @L["Actions"] @@ -268,20 +273,20 @@ else - Confirm Email + @UL["ConfirmEmail"] - Enter the confirmation code sent to the user's email. + @UL["ConfirmEmailDescription"] - Confirm Email + @UL["ConfirmEmail"] @@ -291,26 +296,26 @@ else - Reset Password + @UL["ResetPassword"] - Requires a valid reset token from email flow. + @UL["ResetPasswordDescription"] - Reset Password + @UL["ResetPassword"] @@ -319,15 +324,15 @@ else - Send Password Reset + @UL["SendResetEmail"] - Send a password reset email to the user. + @UL["SendResetEmailDescription"] - Send Reset Email + @UL["SendResetEmail"] @@ -341,17 +346,17 @@ else - Danger Zone + @L["DangerZone"] - @(_user.IsActive ? "Deactivate Account" : "Activate Account") + @(_user.IsActive ? UL["DeactivateAccount"] : UL["ActivateAccount"]) - @(_user.IsActive ? "User will lose access to the system" : "Restore user access") + @(_user.IsActive ? UL["DeactivateDescription"] : UL["ActivateDescription"]) @@ -360,19 +365,19 @@ else Size="Size.Small" OnClick="ToggleStatus" Disabled="@IsToggleDisabled"> - @(_user.IsActive ? "Deactivate" : "Activate") + @(_user.IsActive ? UL["Deactivate"] : UL["Activate"]) - Delete Account - Permanently remove this user + @UL["DeleteAccount"] + @UL["DeleteAccountDescription"] - Delete + @L["Delete"] @@ -418,7 +423,7 @@ else } catch (Exception ex) { - Snackbar.Add($"Failed to load user: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -450,10 +455,10 @@ else { get { - if (_user is null) return "User not loaded"; - if (IsCurrentUser) return "Cannot modify your own account"; - if (_targetIsAdmin) return "Cannot deactivate administrators"; - return _user.IsActive ? "Deactivate user" : "Activate user"; + if (_user is null) return L["Loading"]; + if (IsCurrentUser) return UL["CannotModifyOwnAccount"]; + if (_targetIsAdmin) return UL["CannotDeactivateAdmin"]; + return _user.IsActive ? UL["Deactivate"] : UL["Activate"]; } } @@ -461,12 +466,11 @@ else { if (_user is null || string.IsNullOrWhiteSpace(_user.Id) || IsToggleDisabled) return; - var action = _user.IsActive ? "deactivate" : "activate"; var confirmed = await DialogService.ShowConfirmAsync( - $"{(_user.IsActive ? "Deactivate" : "Activate")} User", - $"Are you sure you want to {action} {_user.FirstName} {_user.LastName}?", - _user.IsActive ? "Deactivate" : "Activate", - "Cancel", + _user.IsActive ? UL["DeactivateAccount"] : UL["ActivateAccount"], + string.Format(UL["ConfirmToggleStatus"], _user.FirstName, _user.LastName), + _user.IsActive ? UL["Deactivate"] : UL["Activate"], + L["Cancel"], _user.IsActive ? Color.Warning : Color.Success); if (!confirmed) return; @@ -479,12 +483,12 @@ else ActivateUser = !_user.IsActive, UserId = _user.Id }); - Snackbar.Add($"User {(_user.IsActive ? "deactivated" : "activated")} successfully", Severity.Success); + Snackbar.Add(UL["UserUpdatedSuccessfully"], Severity.Success); await LoadUser(); } catch (Exception ex) { - Snackbar.Add($"Failed: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -496,19 +500,19 @@ else { if (_user is null || string.IsNullOrWhiteSpace(_user.Id)) return; - var confirmed = await DialogService.ShowDeleteConfirmAsync($"{_user.FirstName} {_user.LastName}"); + var confirmed = await DialogService.ShowDeleteConfirmAsync($"{_user.FirstName} {_user.LastName}", L); if (!confirmed) return; _busy = true; try { await IdentityClient.UsersDeleteAsync(Id); - Snackbar.Add("User deleted", Severity.Success); + Snackbar.Add(UL["UserDeletedSuccessfully"], Severity.Success); Navigation.NavigateTo("/users"); } catch (Exception ex) { - Snackbar.Add($"Failed: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -523,12 +527,12 @@ else try { await IdentityClient.ResetPasswordAsync(TenantContext, _resetModel); - Snackbar.Add("Password reset successfully", Severity.Success); + Snackbar.Add(UL["PasswordResetSuccessfully"], Severity.Success); _resetModel = new ResetPasswordCommand { Email = _user.Email ?? string.Empty }; } catch (Exception ex) { - Snackbar.Add($"Failed: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -543,12 +547,12 @@ else try { await IdentityClient.ConfirmEmailAsync(_confirmModel.UserId, _confirmModel.Code ?? string.Empty, TenantContext); - Snackbar.Add("Email confirmed successfully", Severity.Success); + Snackbar.Add(UL["EmailConfirmedSuccessfully"], Severity.Success); await LoadUser(); } catch (Exception ex) { - Snackbar.Add($"Failed: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/UserRolesPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Users/UserRolesPage.razor index 7bfa316653..dea9e0b592 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Users/UserRolesPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/UserRolesPage.razor @@ -1,21 +1,26 @@ @page "/users/{Id:guid}/roles" @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Dialogs +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Users +@using Microsoft.Extensions.Localization @inherits ComponentBase @inject IIdentityClient IdentityClient @inject IUsersClient UsersClient @inject NavigationManager Navigation @inject ISnackbar Snackbar @inject IDialogService DialogService +@inject IStringLocalizer L +@inject IStringLocalizer UL - + - Back to Users + @L["Back"] @@ -25,7 +30,7 @@ - Loading user information... + @L["Loading"] } @@ -34,10 +39,10 @@ else if (_user is null) - User Not Found - The requested user could not be found. + @L["NotFound"] + @L["NotFound"] - Back to Users + @L["Back"] @@ -63,16 +68,16 @@ else @if (_user.IsActive) { - Active + @L["Active"] } else { - Inactive + @L["Inactive"] } @if (_user.EmailConfirmed) { - Verified + @L["Verified"] } @@ -84,9 +89,9 @@ else - Current Roles + @L["Roles"] - @_currentRoleIds.Count assigned + @_currentRoleIds.Count @L["Assigned"] @@ -111,7 +116,7 @@ else else { - This user has no roles assigned. Assign roles below to grant permissions. + @L["NoDataAvailable"] } @@ -121,7 +126,7 @@ else - Available Roles + @L["AvailableRoles"] @if (HasChanges) { @@ -129,7 +134,7 @@ else Color="Color.Default" OnClick="ResetChanges" Disabled="_saving"> - Reset + @L["Reset"] } } - Save Changes + @L["Save"] @@ -176,7 +181,7 @@ else - @(isAssigned ? "Will be added" : "Will be removed") + @(isAssigned ? L["WillBeAdded"] : L["WillBeRemoved"]) } @if (isAssigned) @@ -193,7 +198,7 @@ else else { - No roles available in the system. Contact an administrator. + @L["NoDataAvailable"] } @@ -266,7 +271,7 @@ else } catch (Exception ex) { - Snackbar.Add($"Failed to load data: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -300,10 +305,10 @@ else if (string.IsNullOrEmpty(roleId)) return; var confirmed = await DialogService.ShowConfirmAsync( - "Remove Role", - $"Remove the '{roleName}' role from this user?", - "Remove", - "Cancel", + UL["RemoveRole"], + string.Format(UL["ConfirmRemoveRole"], roleName), + L["Delete"], + L["Cancel"], Color.Warning); if (!confirmed) return; @@ -343,11 +348,11 @@ else // Update original state to match current _originalRoleIds = new HashSet(_currentRoleIds); - Snackbar.Add("Roles updated successfully.", Severity.Success); + Snackbar.Add(UL["RolesUpdatedSuccessfully"], Severity.Success); } catch (Exception ex) { - Snackbar.Add($"Failed to save roles: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { diff --git a/src/Playground/Playground.Blazor/Components/Pages/Users/UsersPage.razor b/src/Playground/Playground.Blazor/Components/Pages/Users/UsersPage.razor index 548467c81d..54310308d4 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Users/UsersPage.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Users/UsersPage.razor @@ -2,6 +2,9 @@ @using FSH.Playground.Blazor.ApiClient @using FSH.Framework.Blazor.UI.Components.Dialogs @using FSH.Framework.Blazor.UI.Components.Cards +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization.Users +@using Microsoft.Extensions.Localization @inherits ComponentBase @using System.Security.Claims @using FSH.Framework.Shared.Constants @@ -12,9 +15,11 @@ @inject ISnackbar Snackbar @inject IDialogService DialogService @inject AuthenticationStateProvider AuthenticationStateProvider +@inject IStringLocalizer L +@inject IStringLocalizer UL - + @if (_selectedUsers.Any()) { @@ -23,23 +28,23 @@ Color="Color.Success" OnClick="BulkActivate" Disabled="_bulkBusy"> - Activate (@_selectedUsers.Count) + @L["Active"] (@_selectedUsers.Count) - Deactivate + @L["Inactive"] - Delete + @L["Delete"] - Clear + @L["Clear"] } @@ -47,7 +52,7 @@ Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="ShowCreate"> - New User + @UL["NewUser"] @@ -58,40 +63,40 @@ + Label="@UL["TotalUsers"]" + Badge="@L["Total"]" /> + Label="@UL["ActiveUsers"]" + Badge="@L["Active"]" /> + Label="@UL["InactiveUsers"]" + Badge="@L["Inactive"]" /> + Label="@L["Pending"]" + Badge="@L["Unverified"]" /> @* Filter Panel *@ - + - - All - Active - Inactive + + @L["All"] + @L["Active"] + @L["Inactive"] - - All - Confirmed - Unconfirmed + + @L["All"] + @L["Verified"] + @L["Unverified"] @@ -117,7 +122,7 @@ Color="Color.Default" Variant="Variant.Text" OnClick="ClearFilters"> - Clear Filters + @L["Clear"] - + @context.Item.Email @if (context.Item.EmailConfirmed) { - + } else { - + } - - + + @if (_userRolesCache.TryGetValue(context.Item.Id ?? "", out var roles) && roles.Any()) { @@ -208,33 +213,33 @@ else { - No roles + @L["NoDataAvailable"] } - + @if (context.Item.IsActive) { - Active + @L["Active"] } else { - Inactive + @L["Inactive"] } - + - + - + - + - No users found - Try adjusting your filters or create a new user. + @UL["NoUsersFound"] + @UL["TryAdjustingFilters"] @@ -343,7 +348,7 @@ } catch (Exception ex) { - Snackbar.Add($"Failed to load users: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -489,8 +494,8 @@ private string GetToggleTooltip(UserDto user) { - if (IsCurrentUser(user)) return "Cannot modify your own account"; - return user.IsActive ? "Deactivate user" : "Activate user"; + if (IsCurrentUser(user)) return UL["CannotDeleteSelf"]; + return user.IsActive ? UL["Deactivate"] : UL["Activate"]; } private async Task IsUserAdminAsync(string userId) @@ -539,23 +544,22 @@ if (IsCurrentUser(user)) { - Snackbar.Add("You cannot modify your own account.", Severity.Warning); + Snackbar.Add(UL["CannotDeleteSelf"], Severity.Warning); return; } var isAdmin = await IsUserAdminAsync(user.Id); if (isAdmin && user.IsActive) { - Snackbar.Add("Administrators cannot be deactivated.", Severity.Warning); + Snackbar.Add(UL["CannotDeactivateAdmin"], Severity.Warning); return; } - var action = user.IsActive ? "deactivate" : "activate"; var confirmed = await DialogService.ShowConfirmAsync( - $"{(user.IsActive ? "Deactivate" : "Activate")} User", - $"Are you sure you want to {action} {user.FirstName} {user.LastName}?", - user.IsActive ? "Deactivate" : "Activate", - "Cancel", + user.IsActive ? UL["Deactivate"] : UL["Activate"], + string.Format(UL["ConfirmToggleStatus"], user.FirstName, user.LastName), + user.IsActive ? UL["Deactivate"] : UL["Activate"], + L["Cancel"], user.IsActive ? Color.Warning : Color.Success); if (!confirmed) return; @@ -565,12 +569,12 @@ { var command = new ToggleUserStatusCommand { ActivateUser = !user.IsActive, UserId = user.Id }; await IdentityClient.UsersPatchAsync(Guid.Parse(user.Id), command); - Snackbar.Add($"User {(user.IsActive ? "deactivated" : "activated")} successfully.", Severity.Success); + Snackbar.Add(UL["UserUpdatedSuccessfully"], Severity.Success); await LoadData(); } catch (Exception ex) { - Snackbar.Add($"Failed to update user: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -582,19 +586,19 @@ { if (string.IsNullOrWhiteSpace(user.Id)) return; - var confirmed = await DialogService.ShowDeleteConfirmAsync($"{user.FirstName} {user.LastName}"); + var confirmed = await DialogService.ShowDeleteConfirmAsync($"{user.FirstName} {user.LastName}", L); if (!confirmed) return; _busyUserId = user.Id; try { await IdentityClient.UsersDeleteAsync(Guid.Parse(user.Id)); - Snackbar.Add("User deleted successfully.", Severity.Success); + Snackbar.Add(UL["UserDeletedSuccessfully"], Severity.Success); await LoadData(); } catch (Exception ex) { - Snackbar.Add($"Failed to delete user: {ex.Message}", Severity.Error); + Snackbar.Add(string.Format(L["UnexpectedErrorOccurred"], ex.Message), Severity.Error); } finally { @@ -607,15 +611,15 @@ var users = _selectedUsers.Where(u => !u.IsActive && !IsCurrentUser(u)).ToList(); if (!users.Any()) { - Snackbar.Add("No inactive users selected.", Severity.Info); + Snackbar.Add(L["NoDataAvailable"], Severity.Info); return; } var confirmed = await DialogService.ShowConfirmAsync( - "Bulk Activate", - $"Activate {users.Count} user(s)?", - "Activate All", - "Cancel", + UL["Activate"], + string.Format(UL["UsersActivated"], users.Count), + UL["Activate"], + L["Cancel"], Color.Success); if (!confirmed) return; @@ -637,7 +641,7 @@ })); var success = results.Count(r => r); _bulkBusy = false; - Snackbar.Add($"Activated {success} of {users.Count} users.", Severity.Success); + Snackbar.Add(string.Format(UL["UsersActivated"], success), Severity.Success); ClearSelection(); await LoadData(); } @@ -647,15 +651,15 @@ var users = _selectedUsers.Where(u => u.IsActive && !IsCurrentUser(u)).ToList(); if (!users.Any()) { - Snackbar.Add("No active users selected (excluding yourself).", Severity.Info); + Snackbar.Add(L["NoDataAvailable"], Severity.Info); return; } var confirmed = await DialogService.ShowConfirmAsync( - "Bulk Deactivate", - $"Deactivate {users.Count} user(s)?", - "Deactivate All", - "Cancel", + UL["Deactivate"], + string.Format(UL["UsersDeactivated"], users.Count), + UL["Deactivate"], + L["Cancel"], Color.Warning); if (!confirmed) return; @@ -678,7 +682,7 @@ })); var success = results.Count(r => r); _bulkBusy = false; - Snackbar.Add($"Deactivated {success} of {users.Count} users.", Severity.Success); + Snackbar.Add(string.Format(UL["UsersDeactivated"], success), Severity.Success); ClearSelection(); await LoadData(); } @@ -688,15 +692,15 @@ var users = _selectedUsers.Where(u => !IsCurrentUser(u)).ToList(); if (!users.Any()) { - Snackbar.Add("No users selected (excluding yourself).", Severity.Info); + Snackbar.Add(L["NoDataAvailable"], Severity.Info); return; } var confirmed = await DialogService.ShowConfirmAsync( - "Bulk Delete", - $"Delete {users.Count} user(s)? This action cannot be undone.", - "Delete All", - "Cancel", + UL["BulkDelete"], + string.Format(UL["DeleteUsersConfirm"], users.Count), + L["Delete"], + L["Cancel"], Color.Error, Icons.Material.Outlined.DeleteForever, Color.Error); @@ -718,7 +722,7 @@ } } _bulkBusy = false; - Snackbar.Add($"Deleted {success} of {users.Count} users.", Severity.Success); + Snackbar.Add(string.Format(UL["UsersDeleted"], success, users.Count), Severity.Success); ClearSelection(); await LoadData(); } @@ -733,7 +737,7 @@ CloseButton = true }; - var dialog = await DialogService.ShowAsync("Create User", options); + var dialog = await DialogService.ShowAsync(UL["CreateUser"], options); var result = await dialog.Result; if (result is not null && !result.Canceled) diff --git a/src/Playground/Playground.Blazor/Components/Pages/Weather.razor b/src/Playground/Playground.Blazor/Components/Pages/Weather.razor index 22ebe557a2..9597578dd9 100644 --- a/src/Playground/Playground.Blazor/Components/Pages/Weather.razor +++ b/src/Playground/Playground.Blazor/Components/Pages/Weather.razor @@ -1,9 +1,14 @@ @page "/weather" +@using FSH.Framework.Shared.Localization +@using FSH.Playground.Blazor.Localization +@using Microsoft.Extensions.Localization +@inject IStringLocalizer L +@inject IStringLocalizer PL -Weather +@PL["Weather"] - @if (forecasts == null) @@ -14,16 +19,16 @@ else { - Date - Temp. (C) - Temp. (F) - Summary + @L["Date"] + @PL["TemperatureC"] + @PL["TemperatureF"] + @PL["Summary"] - @context.Date - @context.TemperatureC - @context.TemperatureF - @context.Summary + @context.Date + @context.TemperatureC + @context.TemperatureF + @context.Summary diff --git a/src/Playground/Playground.Blazor/Components/Routes.razor b/src/Playground/Playground.Blazor/Components/Routes.razor index 6a464950cd..8a68244e6e 100644 --- a/src/Playground/Playground.Blazor/Components/Routes.razor +++ b/src/Playground/Playground.Blazor/Components/Routes.razor @@ -1,7 +1,8 @@ @using FSH.Playground.Blazor.Components.Layout +@using FSH.Playground.Blazor.Components.Pages @using Microsoft.AspNetCore.Components.Authorization - + @@ -13,9 +14,4 @@ - - -

Sorry, there's nothing at this address.

-
-
diff --git a/src/Playground/Playground.Blazor/Components/Validation/LocalizedDataAnnotationsValidator.razor b/src/Playground/Playground.Blazor/Components/Validation/LocalizedDataAnnotationsValidator.razor new file mode 100644 index 0000000000..841a1ea291 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Validation/LocalizedDataAnnotationsValidator.razor @@ -0,0 +1,83 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.Extensions.Localization +@using Microsoft.Extensions.DependencyInjection +@using FSH.Framework.Shared.Localization +@using System.ComponentModel.DataAnnotations +@inherits ComponentBase +@implements IDisposable +@inject IServiceProvider ServiceProvider +@inject IStringLocalizer Localizer + +@code { + [CascadingParameter] + private EditContext? EditContext { get; set; } + + private ValidationMessageStore? _validationMessageStore; + + protected override void OnInitialized() + { + if (EditContext == null) + { + throw new InvalidOperationException($"{nameof(LocalizedDataAnnotationsValidator)} requires a cascading parameter of type {nameof(EditContext)}."); + } + + _validationMessageStore = new ValidationMessageStore(EditContext); + EditContext.OnValidationRequested += HandleValidationRequested; + EditContext.OnFieldChanged += HandleFieldChanged; + } + + private void HandleValidationRequested(object? sender, ValidationRequestedEventArgs args) + { + _validationMessageStore?.Clear(); + + if (EditContext?.Model is IValidatableObject validatableModel) + { + var validationContext = new ValidationContext(EditContext.Model, ServiceProvider, null); + var validationResults = validatableModel.Validate(validationContext); + + foreach (var validationResult in validationResults.Where(vr => vr.MemberNames.Any())) + { + foreach (var memberName in validationResult.MemberNames) + { + var fieldIdentifier = new FieldIdentifier(EditContext.Model, memberName); + _validationMessageStore?.Add(fieldIdentifier, validationResult.ErrorMessage ?? string.Empty); + } + } + } + + EditContext?.NotifyValidationStateChanged(); + } + + private void HandleFieldChanged(object? sender, FieldChangedEventArgs args) + { + _validationMessageStore?.Clear(args.FieldIdentifier); + + if (EditContext?.Model is IValidatableObject validatableModel) + { + var validationContext = new ValidationContext(EditContext.Model, ServiceProvider, null) + { + MemberName = args.FieldIdentifier.FieldName + }; + + var validationResults = validatableModel.Validate(validationContext) + .Where(vr => vr.MemberNames.Contains(args.FieldIdentifier.FieldName)); + + foreach (var validationResult in validationResults) + { + _validationMessageStore?.Add(args.FieldIdentifier, validationResult.ErrorMessage ?? string.Empty); + } + } + + EditContext?.NotifyValidationStateChanged(); + } + + public void Dispose() + { + if (EditContext != null) + { + EditContext.OnValidationRequested -= HandleValidationRequested; + EditContext.OnFieldChanged -= HandleFieldChanged; + } + } +} + diff --git a/src/Playground/Playground.Blazor/Components/Validation/LocalizedValidationMessages.cs b/src/Playground/Playground.Blazor/Components/Validation/LocalizedValidationMessages.cs new file mode 100644 index 0000000000..30ffeaf391 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Validation/LocalizedValidationMessages.cs @@ -0,0 +1,53 @@ +using System.ComponentModel.DataAnnotations; +using Microsoft.Extensions.Localization; +using FSH.Framework.Shared.Localization; +using System.Globalization; + +namespace FSH.Playground.Blazor.Components.Validation; + +/// +/// Provides localized validation error messages for DataAnnotations. +/// This class formats validation messages using IStringLocalizer with SharedResource. +/// +internal static class LocalizedValidationMessages +{ + /// + /// Gets a localized Required field error message. + /// + public static string GetRequiredMessage(IStringLocalizer L, string fieldName) + { + return string.Format(CultureInfo.InvariantCulture, L["Required"], fieldName); + } + + /// + /// Gets a localized MaxLength error message. + /// + public static string GetMaxLengthMessage(IStringLocalizer L, string fieldName, int maxLength) + { + return string.Format(CultureInfo.InvariantCulture, L["MaxLength"], fieldName, maxLength); + } + + /// + /// Gets a localized MinLength error message. + /// + public static string GetMinLengthMessage(IStringLocalizer L, string fieldName, int minLength) + { + return string.Format(CultureInfo.InvariantCulture, L["MinLength"], fieldName, minLength); + } + + /// + /// Gets a localized Invalid Email error message. + /// + public static string GetInvalidEmailMessage(IStringLocalizer L) + { + return L["InvalidEmail"]; + } + + /// + /// Gets a localized Passwords Must Match error message. + /// + public static string GetPasswordsMustMatchMessage(IStringLocalizer L) + { + return L["PasswordsMustMatch"]; + } +} diff --git a/src/Playground/Playground.Blazor/Components/Validation/README.md b/src/Playground/Playground.Blazor/Components/Validation/README.md new file mode 100644 index 0000000000..4daa085725 --- /dev/null +++ b/src/Playground/Playground.Blazor/Components/Validation/README.md @@ -0,0 +1,157 @@ +# Localized DataAnnotations Validation in Blazor + +## Overview + +The Register page now implements **localized validation** using: +1. **DataAnnotations** attributes on the model +2. **IValidatableObject** for custom cross-field validation +3. **IStringLocalizer** for localized error messages +4. **Custom LocalizedDataAnnotationsValidator** component + +## How It Works + +### 1. Model with DataAnnotations + +```csharp +private class RegisterModel : IValidatableObject +{ + [Required] + [MaxLength(100)] + public string FirstName { get; set; } = string.Empty; + + [Required] + [EmailAddress] + public string Email { get; set; } = string.Empty; + + [Required] + [MinLength(6)] + public string Password { get; set; } = string.Empty; + + public IEnumerable Validate(ValidationContext validationContext) + { + // Access IStringLocalizer from the service provider + var localizer = validationContext.GetService(typeof(IStringLocalizer)) + as IStringLocalizer; + + // Custom validation with localized messages + if (Password != ConfirmPassword) + { + yield return new ValidationResult( + localizer?["PasswordsMustMatch"] ?? "Passwords do not match.", + new[] { nameof(ConfirmPassword) }); + } + } +} +``` + +###2. Blazor Form + +```razor + + + + +
+ + + +
+ + +
+``` + +### 3. Custom LocalizedDataAnnotationsValidator + +This component handles validation from `IValidatableObject.Validate()`: + +```csharp +@inject IServiceProvider ServiceProvider + +protected override void OnInitialized() +{ + EditContext.OnValidationRequested += HandleValidationRequested; +} + +private void HandleValidationRequested(object? sender, ValidationRequestedEventArgs args) +{ + if (EditContext?.Model is IValidatableObject validatableModel) + { + var validationContext = new ValidationContext(EditContext.Model, ServiceProvider, null); + var validationResults = validatableModel.Validate(validationContext); + + // Add validation messages to the store + foreach (var result in validationResults) + { + // ... add to ValidationMessageStore + } + } +} +``` + +## Benefits + +✅ **Localized error messages** - Uses SharedResource.resx +✅ **Standard DataAnnotations** - Required, EmailAddress, MinLength, MaxLength +✅ **Custom validation** - Cross-field validation via IValidatableObject +✅ **Real-time feedback** - Validates on field change and form submit +✅ **Clean separation** - Validation logic in the model + +## Default DataAnnotations Messages + +By default, DataAnnotations uses English error messages. Our custom `IValidatableObject.Validate()` method provides localized messages for custom validation. + +For built-in attributes (Required, EmailAddress, etc.), the messages are still in English unless you: +1. Use custom attributes with `ErrorMessage` parameter +2. Create custom validation attributes that use IStringLocalizer +3. Override the default messages globally + +## Example: Custom Localized Required Attribute + +```csharp +public class LocalizedRequiredAttribute : RequiredAttribute +{ + protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) + { + var result = base.IsValid(value, validationContext); + + if (result != ValidationResult.Success) + { + var localizer = validationContext.GetService(typeof(IStringLocalizer)) + as IStringLocalizer; + + return new ValidationResult( + string.Format(localizer?["Required"] ?? "{0} is required.", validationContext.DisplayName)); + } + + return result; + } +} +``` + +## Current Implementation + +The Register page uses: +- **Standard attributes** for basic validation (Required, EmailAddress, MinLength, MaxLength) +- **IValidatableObject** for the "passwords must match" validation with **localized message** +- **ValidationMessage** components to display errors next to each field + +## Testing + +1. Navigate to `/register` +2. Leave fields empty and click "Create account" → See "The X field is required" (English, from DataAnnotations) +3. Enter different passwords → See "Passwords do not match." (Localized from SharedResource.resx!) +4. Add culture-specific .resx file to test translations + +## Future Enhancements + +To fully localize all validation messages: +1. Create custom attributes: `LocalizedRequiredAttribute`, `LocalizedEmailAddressAttribute`, etc. +2. Or use FluentValidation.Blazor for full localization support +3. Or override DataAnnotations default messages globally + +## Current Status + +✅ **Custom validation localized** (e.g., PasswordsMustMatch) +⚠️ **Built-in attributes not localized** (e.g., Required, EmailAddress) - use English defaults +✅ **Infrastructure ready** for full localization via custom attributes or FluentValidation diff --git a/src/Playground/Playground.Blazor/Localization/Audits/AuditResource.cs b/src/Playground/Playground.Blazor/Localization/Audits/AuditResource.cs new file mode 100644 index 0000000000..b64d42d99f --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Audits/AuditResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Audits; + +#pragma warning disable S2094 +internal sealed class AuditResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Audits/AuditResource.resx b/src/Playground/Playground.Blazor/Localization/Audits/AuditResource.resx new file mode 100644 index 0000000000..c5a4f14dfd --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Audits/AuditResource.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Audit Trails + Audit Details + Entity Type + Entity ID + Operation + Old Values + New Values + Primary Key + Affected Columns + Date Range + Start Date + End Date + Export Audits + Total Events + Error Events + Errors + Security Events + Unique Sources + Sources + Quick Filters + Last Hour + Last 24 Hours + Last 7 Days + Errors Only + Exceptions + Advanced Filters + Severity + Event Type + Received At + Latency + Context & Identity + Correlation ID + Trace ID + Request ID + Tags + View Correlated Events + View Trace Timeline + Payload + Try adjusting your filters or date range to see more results + Span ID + Export as JSON + diff --git a/src/Playground/Playground.Blazor/Localization/Authentication/AuthResource.cs b/src/Playground/Playground.Blazor/Localization/Authentication/AuthResource.cs new file mode 100644 index 0000000000..38f1ce98cf --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Authentication/AuthResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Authentication; + +#pragma warning disable S2094 +internal sealed class AuthResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Authentication/AuthResource.resx b/src/Playground/Playground.Blazor/Localization/Authentication/AuthResource.resx new file mode 100644 index 0000000000..0a402b3c3c --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Authentication/AuthResource.resx @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Forgot Password + Forgot your password? + Enter your email address and we'll send you a link to reset your password + Send Reset Link + Back to Login + Password reset link has been sent to your email + + Reset Password + Reset your password + Enter your new password + Reset Password + Password has been reset successfully + Invalid or expired reset token + Check your email + Didn't receive the email? + Click to resend + Need help? + Contact support + diff --git a/src/Playground/Playground.Blazor/Localization/Dashboard/DashboardResource.cs b/src/Playground/Playground.Blazor/Localization/Dashboard/DashboardResource.cs new file mode 100644 index 0000000000..726e444f1c --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Dashboard/DashboardResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Dashboard; + +#pragma warning disable S2094 +internal sealed class DashboardResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Dashboard/DashboardResource.resx b/src/Playground/Playground.Blazor/Localization/Dashboard/DashboardResource.resx new file mode 100644 index 0000000000..1c82e8edee --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Dashboard/DashboardResource.resx @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Dashboard + Welcome to Dashboard + Overview + Statistics + Total Users + Active Users + Total Tenants + Recent Activity + Quick Actions + diff --git a/src/Playground/Playground.Blazor/Localization/Groups/GroupResource.cs b/src/Playground/Playground.Blazor/Localization/Groups/GroupResource.cs new file mode 100644 index 0000000000..c2ef771243 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Groups/GroupResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Groups; + +#pragma warning disable S2094 +internal sealed class GroupResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Groups/GroupResource.resx b/src/Playground/Playground.Blazor/Localization/Groups/GroupResource.resx new file mode 100644 index 0000000000..67a4a2270a --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Groups/GroupResource.resx @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Group Management + Create Group + Edit Group + Delete Group + Group Details + Group Members + Add Members + Remove Member + Group Name + Group Description + Group created successfully + Group updated successfully + Group deleted successfully + Members added successfully + Member removed successfully + Are you sure you want to delete this group? + Select Users + Member Count + New Group + Total Groups + System Groups + Default Groups + Custom Groups + System + Custom + No description + No roles assigned + Manage Members + System groups cannot be edited + System groups cannot be deleted + No groups found + Try adjusting your filters or create a new group. + New users are automatically added to this group + Bulk Delete + Delete {0} group(s)? This action cannot be undone. + Deleted {0} of {1} groups. + No deletable groups selected. System groups cannot be deleted. + Some users were already members. + diff --git a/src/Playground/Playground.Blazor/Localization/Health/HealthResource.cs b/src/Playground/Playground.Blazor/Localization/Health/HealthResource.cs new file mode 100644 index 0000000000..c590dd54b1 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Health/HealthResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Health; + +#pragma warning disable S2094 +internal sealed class HealthResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Health/HealthResource.resx b/src/Playground/Playground.Blazor/Localization/Health/HealthResource.resx new file mode 100644 index 0000000000..4e08c64f33 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Health/HealthResource.resx @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Health Check + System Health + Check System Health + Health Status + Healthy + Unhealthy + Degraded + Duration + Component + Tags + Healthy Services + Degraded Services + Unhealthy Services + Avg Response Time + Response + Service Status + Liveness Probe + Basic process check - confirms the application is running. + Readiness Probe + Full dependency check - validates all services and databases. + Recent Checks + Auto-refresh ON + Auto-refresh OFF + Application Core + Identity Database + Audit Database + Multitenancy Database + Tenant Migrations + Last {0} checks + diff --git a/src/Playground/Playground.Blazor/Localization/PLAYGROUND_LOCALIZATION_COMPLETE.md b/src/Playground/Playground.Blazor/Localization/PLAYGROUND_LOCALIZATION_COMPLETE.md new file mode 100644 index 0000000000..32febe40dd --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/PLAYGROUND_LOCALIZATION_COMPLETE.md @@ -0,0 +1,114 @@ +# Playground.Blazor Localization - Complete + +## Resource Files Created (By Namespace) + +### Authentication +- `Localization/Authentication/AuthResource` (12 keys) + - ForgotPassword, ResetPassword pages + +### Users +- `Localization/Users/UserResource` (15 keys) + - UsersPage, UserDetailPage, UserRolesPage, CreateUserDialog + +### Roles +- `Localization/Roles/RoleResource` (13 keys) + - RolesPage, RolePermissionsPage, CreateRoleDialog + +### Groups +- `Localization/Groups/GroupResource` (18 keys) + - GroupsPage, GroupMembersPage, CreateGroupDialog, AddMembersDialog + +### Tenants +- `Localization/Tenants/TenantResource` (19 keys) + - TenantsPage, TenantDetailPage, TenantSettingsPage, CreateTenantDialog, UpgradeTenantDialog + +### Sessions +- `Localization/Sessions/SessionResource` (14 keys) + - SessionsPage + +### Audits +- `Localization/Audits/AuditResource` (13 keys) + - Audits page + +### Dashboard +- `Localization/Dashboard/DashboardResource` (9 keys) + - DashboardPage + +### Profile +- `Localization/Profile/ProfileResource` (20 keys) + - ProfileSettings, SecuritySettings, ThemeSettings + +### Health +- `Localization/Health/HealthResource` (10 keys) + - HealthPage + +### General +- `Localization/PlaygroundResource` (33 keys) + - Counter, Weather, Home, Error pages + +## Localized Pages + +### Core Pages +- ✅ Counter.razor +- ✅ Weather.razor +- ✅ Home.razor +- ✅ Error.razor + +### Authentication +- ✅ Register.razor +- ✅ SimpleLogin.razor +- ForgotPassword.razor (resource ready) +- ResetPassword.razor (resource ready) + +### Management Pages (Resources Ready) +- Users pages (7 files) +- Roles pages (3 files) +- Groups pages (4 files) +- Tenants pages (5 files) +- Sessions page +- Audits page +- Dashboard page +- Profile/Settings pages (3 files) +- Health page + +## SharedResource Additions + +Added 70+ common keys: +- Actions (Save, Cancel, Delete, Edit, Create, Update, etc.) +- Labels (Name, Description, Status, Active, Inactive, etc.) +- Messages (Loading, NoDataAvailable, SavedSuccessfully, etc.) +- Navigation (Dashboard, Users, Roles, Groups, Tenants, etc.) +- Table headers (Id, Image, Role, Members, Count, etc.) + +## Pattern + +Each namespace has: +``` +Playground.Blazor/ + Localization/ + [Area]/ + [Area]Resource.resx ← Strings + [Area]Resource.cs ← Marker class +``` + +Usage: +```razor +@using FSH.Playground.Blazor.Localization.[Area] +@inject IStringLocalizer<[Area]Resource> [Prefix]L + +
@[Prefix]L["KeyName"]
+``` + +## Build Status +✅ All resources compile successfully +✅ No errors +✅ Ready for page updates + +## Total Resources +- Module resources: 78 keys (Identity) +- Module resources: 20 keys (Tenant) +- Module resources: 15 keys (Audit) +- Module resources: 18 keys (Webhook) +- Playground resources: 143+ keys +- Shared resources: 120+ keys +- **Total: 394+ localization keys** diff --git a/src/Playground/Playground.Blazor/Localization/PlaygroundResource.cs b/src/Playground/Playground.Blazor/Localization/PlaygroundResource.cs new file mode 100644 index 0000000000..acd731eb99 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/PlaygroundResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization; + +#pragma warning disable S2094 +internal sealed class PlaygroundResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/PlaygroundResource.resx b/src/Playground/Playground.Blazor/Localization/PlaygroundResource.resx new file mode 100644 index 0000000000..dabf0e242d --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/PlaygroundResource.resx @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Counter + Current count + Click me + + Weather + Weather Forecast + This component demonstrates showing data. + Temp. (C) + Temp. (F) + Summary + + Home + Hello, world! + Welcome to your new app. + + Profile Settings + Update Profile + Profile updated successfully + + Security Settings + Change Password + Current Password + New Password + Password changed successfully + + Theme Settings + Customize your theme + Primary Color + Secondary Color + Dark Mode + Theme updated successfully + + Session Management + Active Sessions + Device + Location + Last Active + Revoke Session + Revoke All Sessions + Session revoked successfully + + Health Check + System Health + Healthy + Unhealthy + Degraded + + An error occurred + Error Details + Return Home + diff --git a/src/Playground/Playground.Blazor/Localization/Profile/ProfileResource.cs b/src/Playground/Playground.Blazor/Localization/Profile/ProfileResource.cs new file mode 100644 index 0000000000..03cca14f83 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Profile/ProfileResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Profile; + +#pragma warning disable S2094 +internal sealed class ProfileResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Profile/ProfileResource.resx b/src/Playground/Playground.Blazor/Localization/Profile/ProfileResource.resx new file mode 100644 index 0000000000..cb82aa946e --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Profile/ProfileResource.resx @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Profile Settings + Update Profile + Profile Information + Upload Photo + Remove Photo + Profile updated successfully + + Security Settings + Change Password + Two-Factor Authentication + Enable Two-Factor Authentication + Disable Two-Factor Authentication + Security settings updated successfully + + Theme Settings + Customize Appearance + Color Scheme + Light + Dark + System + Profile Photo + Upload a profile photo. Max 2MB, images only. + Current Password + New Password + Update your password to keep your account secure + Use at least 8 characters with a mix of letters, numbers and symbols + Password changed successfully + File too large. Maximum 2MB allowed. + Only image files are allowed. + Image ready. Click Save to upload. + Photo will be removed when you save changes. + Theme saved successfully + diff --git a/src/Playground/Playground.Blazor/Localization/Roles/RoleResource.cs b/src/Playground/Playground.Blazor/Localization/Roles/RoleResource.cs new file mode 100644 index 0000000000..6681d01392 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Roles/RoleResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Roles; + +#pragma warning disable S2094 +internal sealed class RoleResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Roles/RoleResource.resx b/src/Playground/Playground.Blazor/Localization/Roles/RoleResource.resx new file mode 100644 index 0000000000..b566d27e94 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Roles/RoleResource.resx @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Role Management + Create Role + Edit Role + Delete Role + Role Permissions + Manage Permissions + Role Name + Role Description + Role created successfully + Role updated successfully + Role deleted successfully + Are you sure you want to delete this role? + Permissions updated successfully + New Role + Total Roles + System Roles + Custom Roles + System + Custom + No roles found + Try adjusting your filters or create a new role. + System roles cannot be edited + System roles cannot be deleted + Bulk Delete + Delete {0} role(s)? This action cannot be undone. + Deleted {0} of {1} roles. + No deletable roles selected. System roles cannot be deleted. + No description + User Count + diff --git a/src/Playground/Playground.Blazor/Localization/Sessions/SessionResource.cs b/src/Playground/Playground.Blazor/Localization/Sessions/SessionResource.cs new file mode 100644 index 0000000000..08ce9c4e2b --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Sessions/SessionResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Sessions; + +#pragma warning disable S2094 +internal sealed class SessionResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Sessions/SessionResource.resx b/src/Playground/Playground.Blazor/Localization/Sessions/SessionResource.resx new file mode 100644 index 0000000000..f79180ff8a --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Sessions/SessionResource.resx @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Session Management + Active Sessions + Session Details + Revoke Session + Revoke All Sessions + Refresh Token + Expires At + Session revoked successfully + All sessions revoked successfully + Are you sure you want to log out from this device? {0} on {1} + Are you sure you want to revoke all sessions? + Current Session + IP Address + User Agent + No other sessions to revoke. + Logout All Other Devices + This will log you out from {0} other device(s). Your current session will remain active. + Logout All + All other sessions have been revoked. + Desktop Sessions + Mobile Sessions + Tablet Sessions + Desktop + Mobile + Tablet + Just now + {0} min ago + {0} hour(s) ago + {0} day(s) ago + {0} week(s) ago + {0} month(s) ago + diff --git a/src/Playground/Playground.Blazor/Localization/Tenants/TenantResource.cs b/src/Playground/Playground.Blazor/Localization/Tenants/TenantResource.cs new file mode 100644 index 0000000000..e2652644fc --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Tenants/TenantResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Tenants; + +#pragma warning disable S2094 +internal sealed class TenantResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Tenants/TenantResource.resx b/src/Playground/Playground.Blazor/Localization/Tenants/TenantResource.resx new file mode 100644 index 0000000000..6e7395ed85 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Tenants/TenantResource.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + Tenant Management + Create Tenant + Edit Tenant + Delete Tenant + Tenant Details + Tenant Settings + Upgrade Tenant + Tenant Identifier + Admin Email + Connection String + Valid From + Valid Upto + Issuer + Tenant created successfully + Tenant updated successfully + Tenant deleted successfully + Tenant upgraded successfully + Are you sure you want to delete this tenant? + Select Subscription + Extend Valid Upto + Total Tenants + Active Tenants + Inactive Tenants + Expired Tenants + No tenants found + Try adjusting your filters or create a new tenant. + View Details + Activate + Deactivate + Root tenant cannot be deleted + Root tenant cannot be deactivated + Subscription + Retry Provisioning + Are you sure you want to retry provisioning for '{0}'? + Provisioning retry initiated successfully + Root tenant cannot be deactivated + Are you sure you want to change the status of '{0}'? + Subscription Reminders + Months + Year + You do not have permission to access this page. Only root tenant administrators can manage tenants. + diff --git a/src/Playground/Playground.Blazor/Localization/Users/UserResource.cs b/src/Playground/Playground.Blazor/Localization/Users/UserResource.cs new file mode 100644 index 0000000000..2c7f6ac34b --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Users/UserResource.cs @@ -0,0 +1,5 @@ +namespace FSH.Playground.Blazor.Localization.Users; + +#pragma warning disable S2094 +internal sealed class UserResource; +#pragma warning restore S2094 diff --git a/src/Playground/Playground.Blazor/Localization/Users/UserResource.resx b/src/Playground/Playground.Blazor/Localization/Users/UserResource.resx new file mode 100644 index 0000000000..e70c4ac8e7 --- /dev/null +++ b/src/Playground/Playground.Blazor/Localization/Users/UserResource.resx @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + text/microsoft-resx + 2.0 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + User Management + Create User + Edit User + Delete User + User Details + User Roles + Assign Role + Remove Role + User created successfully + User updated successfully + User deleted successfully + Are you sure you want to delete this user? + Is Active + Email Confirmed + Phone Confirmed + New User + Total Users + Active Users + Inactive Users + Confirmed Users + No users found + Try adjusting your filters or create a new user. + Manage Roles + View Details + Activate + Deactivate + {0} user(s) activated + {0} user(s) deactivated + Deleted {0} of {1} users + Bulk Delete + Delete {0} user(s)? This action cannot be undone. + Cannot delete your own account + Remove the '{0}' role from this user? + Roles updated successfully + Are you sure you want to change the status of {0} {1}? + Deactivate Account + Activate Account + User will lose access to the system + Restore user access + Delete Account + Permanently remove this user + Cannot modify your own account + Cannot deactivate administrators + Confirm Email + Enter the confirmation code sent to the user's email. + Confirmation Code + Email confirmed successfully + Reset Password + Requires a valid reset token from email flow. + Reset Token + Password reset successfully + Send Password Reset Email + Send a password reset email to the user. + diff --git a/src/Playground/Playground.Blazor/Playground.Blazor.csproj b/src/Playground/Playground.Blazor/Playground.Blazor.csproj index 4070df65f2..b4544e83d1 100644 --- a/src/Playground/Playground.Blazor/Playground.Blazor.csproj +++ b/src/Playground/Playground.Blazor/Playground.Blazor.csproj @@ -18,8 +18,8 @@ DefaultContainer - - + + @@ -35,6 +35,6 @@ - $(NoWarn);MUD0002;S3260;S2933;S3459;S3923;S108;S1144;S4487;CA1031;CA5394;CA1812 + $(NoWarn);MUD0002;S3260;S2933;S3459;S3923;S108;S1144;S4487;CA1031;CA5394;CA1812;NU1507 diff --git a/src/Playground/Playground.Blazor/Program.cs b/src/Playground/Playground.Blazor/Program.cs index 83318f9c30..e44722d5a0 100644 --- a/src/Playground/Playground.Blazor/Program.cs +++ b/src/Playground/Playground.Blazor/Program.cs @@ -1,3 +1,4 @@ +using FSH.Framework.Shared.Localization; using FSH.Framework.Blazor.UI; using FSH.Framework.Blazor.UI.Theme; using FSH.Playground.Blazor; @@ -118,6 +119,8 @@ .Expire(TimeSpan.FromSeconds(10))); }); +builder.Services.AddFshLocalization(); + builder.Services.AddRazorComponents() .AddInteractiveServerComponents(); @@ -129,6 +132,10 @@ app.UseHsts(); } +// Status Code Pages Middleware - handles 404s by re-executing the request pipeline +// This prevents the browser from showing its default 404 page +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); + // Simple health endpoints for ALB/ECS app.MapGet("/health/ready", () => Results.Ok(new { status = "Healthy" })) .AllowAnonymous(); @@ -136,6 +143,7 @@ app.MapGet("/health/live", () => Results.Ok(new { status = "Alive" })) .AllowAnonymous(); +app.UseFshLocalization(); app.UseResponseCompression(); // Must come before UseStaticFiles app.UseOutputCache(); app.UseHttpsRedirection(); diff --git a/src/Playground/Playground.Blazor/Services/Api/ApiClientRegistration.cs b/src/Playground/Playground.Blazor/Services/Api/ApiClientRegistration.cs index e3bd00da7d..8240e803f5 100644 --- a/src/Playground/Playground.Blazor/Services/Api/ApiClientRegistration.cs +++ b/src/Playground/Playground.Blazor/Services/Api/ApiClientRegistration.cs @@ -53,6 +53,9 @@ static HttpClient ResolveClient(IServiceProvider sp) => services.AddTransient(sp => new HealthClient(ResolveClient(sp))); + services.AddTransient(sp => + new ProvisioningClient(ResolveClient(sp))); + return services; } } \ No newline at end of file diff --git a/src/Playground/Playground.Blazor/Services/Api/AuthorizationHeaderHandler.cs b/src/Playground/Playground.Blazor/Services/Api/AuthorizationHeaderHandler.cs index 66c8fe4999..e91f417ec7 100644 --- a/src/Playground/Playground.Blazor/Services/Api/AuthorizationHeaderHandler.cs +++ b/src/Playground/Playground.Blazor/Services/Api/AuthorizationHeaderHandler.cs @@ -9,12 +9,12 @@ namespace FSH.Playground.Blazor.Services.Api; /// by attempting to refresh the access token. If refresh fails, signs out the user and /// notifies Blazor components via IAuthStateNotifier. /// -internal sealed class AuthorizationHeaderHandler : DelegatingHandler +internal sealed class AuthorizationHeaderHandler( + IHttpContextAccessor httpContextAccessor, + IServiceProvider serviceProvider, + ICircuitTokenCache circuitTokenCache, + ILogger logger) : DelegatingHandler { - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IServiceProvider _serviceProvider; - private readonly ICircuitTokenCache _circuitTokenCache; - private readonly ILogger _logger; /// /// Track if sign-out has already been initiated to prevent multiple sign-out attempts. @@ -22,18 +22,6 @@ internal sealed class AuthorizationHeaderHandler : DelegatingHandler /// private bool _signOutInitiated; - public AuthorizationHeaderHandler( - IHttpContextAccessor httpContextAccessor, - IServiceProvider serviceProvider, - ICircuitTokenCache circuitTokenCache, - ILogger logger) - { - _httpContextAccessor = httpContextAccessor; - _serviceProvider = serviceProvider; - _circuitTokenCache = circuitTokenCache; - _logger = logger; - } - protected override async Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) @@ -61,17 +49,17 @@ protected override async Task SendAsync( if (string.IsNullOrEmpty(accessToken)) { - _logger.LogDebug("Received 401 but no access token available - cannot refresh"); + logger.LogDebug("Received 401 but no access token available - cannot refresh"); return response; } - _logger.LogInformation("Received 401, attempting token refresh"); + logger.LogInformation("Received 401, attempting token refresh"); var newAccessToken = await TryRefreshTokenAsync(cancellationToken); if (!string.IsNullOrEmpty(newAccessToken)) { - _logger.LogInformation("Token refresh successful, retrying request"); + logger.LogInformation("Token refresh successful, retrying request"); // Clone the request with new token using var retryRequest = await CloneHttpRequestMessageAsync(request); @@ -85,7 +73,7 @@ protected override async Task SendAsync( } else { - _logger.LogWarning("Token refresh failed, signing out user"); + logger.LogWarning("Token refresh failed, signing out user"); // Mark sign-out as initiated to prevent multiple sign-out attempts _signOutInitiated = true; @@ -102,7 +90,7 @@ private async Task SignOutUserAsync() { try { - var httpContext = _httpContextAccessor.HttpContext; + var httpContext = httpContextAccessor.HttpContext; if (httpContext is not null) { // Try to sign out via cookies, but this may fail in Blazor Server's @@ -112,34 +100,34 @@ private async Task SignOutUserAsync() if (!httpContext.Response.HasStarted) { await httpContext.SignOutAsync("Cookies"); - _logger.LogInformation("User signed out due to expired refresh token"); + logger.LogInformation("User signed out due to expired refresh token"); } else { - _logger.LogDebug("Response already started, skipping cookie sign-out"); + logger.LogDebug("Response already started, skipping cookie sign-out"); } } catch (InvalidOperationException ex) { // Expected in Blazor Server SignalR context - headers are read-only - _logger.LogDebug(ex, "Could not sign out via cookies (response started), using navigation redirect"); + logger.LogDebug(ex, "Could not sign out via cookies (response started), using navigation redirect"); } // Notify Blazor components that session has expired // This will trigger navigation to login page with forceLoad:true, // which will create a new HTTP request where cookies can be cleared - var authStateNotifier = _serviceProvider.GetService(); + var authStateNotifier = serviceProvider.GetService(); authStateNotifier?.NotifySessionExpired(); } } catch (Microsoft.AspNetCore.Components.NavigationException ex) { // Expected - NavigateTo with forceLoad throws this to interrupt execution - _logger.LogDebug(ex, "Navigation to login triggered (NavigationException is expected)"); + logger.LogDebug(ex, "Navigation to login triggered (NavigationException is expected)"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to handle session expiration"); + logger.LogError(ex, "Failed to handle session expiration"); } } @@ -150,13 +138,13 @@ private async Task SignOutUserAsync() // First, check circuit-scoped cache for refreshed tokens // This is critical because httpContext.User claims are cached per circuit // and don't update even after SignInAsync - if (!string.IsNullOrEmpty(_circuitTokenCache.AccessToken)) + if (!string.IsNullOrEmpty(circuitTokenCache.AccessToken)) { - return Task.FromResult(_circuitTokenCache.AccessToken); + return Task.FromResult(circuitTokenCache.AccessToken); } // Fall back to claims (initial token from cookie) - var httpContext = _httpContextAccessor.HttpContext; + var httpContext = httpContextAccessor.HttpContext; var user = httpContext?.User; if (user?.Identity?.IsAuthenticated == true) @@ -166,7 +154,7 @@ private async Task SignOutUserAsync() } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get access token"); + logger.LogWarning(ex, "Failed to get access token"); } return Task.FromResult(null); @@ -178,10 +166,10 @@ private async Task SignOutUserAsync() { // Resolve the token refresh service from the service provider // We use IServiceProvider to avoid circular dependency issues - var tokenRefreshService = _serviceProvider.GetService(); + var tokenRefreshService = serviceProvider.GetService(); if (tokenRefreshService is null) { - _logger.LogWarning("TokenRefreshService is not registered"); + logger.LogWarning("TokenRefreshService is not registered"); return null; } @@ -189,7 +177,7 @@ private async Task SignOutUserAsync() } catch (Exception ex) { - _logger.LogError(ex, "Error during token refresh"); + logger.LogError(ex, "Error during token refresh"); return null; } } diff --git a/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs b/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs index 0375d2d3bd..004834dbd0 100644 --- a/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs +++ b/src/Playground/Playground.Blazor/Services/Api/TokenRefreshService.cs @@ -13,13 +13,12 @@ internal interface ITokenRefreshService Task TryRefreshTokenAsync(CancellationToken cancellationToken = default); } -internal sealed class TokenRefreshService : ITokenRefreshService, IDisposable +internal sealed class TokenRefreshService( + IHttpContextAccessor httpContextAccessor, + ITokenClient tokenClient, + ICircuitTokenCache circuitTokenCache, + ILogger logger) : ITokenRefreshService, IDisposable { - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly ITokenClient _tokenClient; - private readonly ICircuitTokenCache _circuitTokenCache; - private readonly ILogger _logger; - private static readonly SemaphoreSlim RefreshLock = new(1, 1); private static readonly TimeSpan RefreshCacheDuration = TimeSpan.FromSeconds(30); private static readonly TimeSpan FailedTokenCacheDuration = TimeSpan.FromMinutes(5); @@ -30,37 +29,25 @@ internal sealed class TokenRefreshService : ITokenRefreshService, IDisposable private static string? _failedRefreshToken; private static DateTime _failedRefreshTime = DateTime.MinValue; - public TokenRefreshService( - IHttpContextAccessor httpContextAccessor, - ITokenClient tokenClient, - ICircuitTokenCache circuitTokenCache, - ILogger logger) - { - _httpContextAccessor = httpContextAccessor; - _tokenClient = tokenClient; - _circuitTokenCache = circuitTokenCache; - _logger = logger; - } - public async Task TryRefreshTokenAsync(CancellationToken cancellationToken = default) { - var httpContext = _httpContextAccessor.HttpContext; + var httpContext = httpContextAccessor.HttpContext; if (httpContext is null) { - _logger.LogDebug("HttpContext is not available for token refresh"); + logger.LogDebug("HttpContext is not available for token refresh"); return null; } var currentRefreshToken = GetCurrentRefreshToken(httpContext); if (string.IsNullOrEmpty(currentRefreshToken)) { - _logger.LogDebug("No refresh token available"); + logger.LogDebug("No refresh token available"); return null; } if (IsTokenRecentlyFailed(currentRefreshToken)) { - _logger.LogDebug("Skipping refresh - token already failed recently"); + logger.LogDebug("Skipping refresh - token already failed recently"); return null; } @@ -75,7 +62,7 @@ public TokenRefreshService( private string? GetCurrentRefreshToken(HttpContext httpContext) { - var circuitRefreshToken = _circuitTokenCache.RefreshToken; + var circuitRefreshToken = circuitTokenCache.RefreshToken; var claimsRefreshToken = httpContext.User?.FindFirst("refresh_token")?.Value; return !string.IsNullOrEmpty(circuitRefreshToken) ? circuitRefreshToken : claimsRefreshToken; @@ -103,7 +90,7 @@ private static bool IsTokenRecentlyFailed(string refreshToken) => { if (!await RefreshLock.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken)) { - _logger.LogWarning("Token refresh lock acquisition timed out"); + logger.LogWarning("Token refresh lock acquisition timed out"); return null; } @@ -150,10 +137,10 @@ private static bool IsTokenRecentlyFailed(string refreshToken) => } var newClaims = BuildNewClaims(user, refreshResponse); - UpdateCaches(refreshResponse, currentRefreshToken); + UpdateCaches(refreshResponse); await TryUpdateCookieAsync(httpContext, newClaims); - _logger.LogInformation("Access token refreshed successfully"); + logger.LogInformation("Access token refreshed successfully"); return refreshResponse.Token; } catch (ApiException ex) when (ex.StatusCode == 401) @@ -163,19 +150,19 @@ private static bool IsTokenRecentlyFailed(string refreshToken) => } catch (Exception ex) { - _logger.LogError(ex, "Failed to refresh access token"); + logger.LogError(ex, "Failed to refresh access token"); return null; } } private (string AccessToken, string RefreshToken, string Tenant)? GetCurrentTokens(ClaimsPrincipal user) { - var currentAccessToken = !string.IsNullOrEmpty(_circuitTokenCache.AccessToken) - ? _circuitTokenCache.AccessToken + var currentAccessToken = !string.IsNullOrEmpty(circuitTokenCache.AccessToken) + ? circuitTokenCache.AccessToken : user.FindFirst("access_token")?.Value; - var refreshToken = !string.IsNullOrEmpty(_circuitTokenCache.RefreshToken) - ? _circuitTokenCache.RefreshToken + var refreshToken = !string.IsNullOrEmpty(circuitTokenCache.RefreshToken) + ? circuitTokenCache.RefreshToken : user.FindFirst("refresh_token")?.Value; var tenant = user.FindFirst("tenant")?.Value ?? "root"; @@ -192,7 +179,7 @@ private static bool IsTokenRecentlyFailed(string refreshToken) => (string AccessToken, string RefreshToken, string Tenant) tokens, CancellationToken cancellationToken) { - var refreshResponse = await _tokenClient.RefreshAsync( + var refreshResponse = await tokenClient.RefreshAsync( tokens.Tenant, new RefreshTokenCommand { @@ -203,7 +190,7 @@ private static bool IsTokenRecentlyFailed(string refreshToken) => if (refreshResponse is null || string.IsNullOrEmpty(refreshResponse.Token)) { - _logger.LogWarning("Token refresh returned empty response"); + logger.LogWarning("Token refresh returned empty response"); return null; } @@ -218,7 +205,7 @@ private static List BuildNewClaims(ClaimsPrincipal user, RefreshTokenComm var newClaims = new List { - new(ClaimTypes.NameIdentifier, jwtToken.Subject ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? Guid.NewGuid().ToString()), + new(ClaimTypes.NameIdentifier, jwtToken.Subject ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? Guid.CreateVersion7().ToString()), new(ClaimTypes.Email, user.FindFirst(ClaimTypes.Email)?.Value ?? string.Empty), new("access_token", response.Token), new("refresh_token", response.RefreshToken), @@ -246,13 +233,13 @@ private static void AddRoleClaims(List claims, JwtSecurityToken jwtToken) claims.AddRange(roleClaims.Select(r => new Claim(ClaimTypes.Role, r.Value))); } - private void UpdateCaches(RefreshTokenCommandResponse response, string oldRefreshToken) + private void UpdateCaches(RefreshTokenCommandResponse response) { - _circuitTokenCache.UpdateTokens(response.Token, response.RefreshToken); + circuitTokenCache.UpdateTokens(response.Token, response.RefreshToken); #pragma warning disable S2696 _lastRefreshedToken = response.Token; - _cachedForRefreshToken = oldRefreshToken; + _cachedForRefreshToken = response.RefreshToken; _lastRefreshTime = DateTime.UtcNow; #pragma warning restore S2696 } @@ -278,7 +265,7 @@ private static async Task TryUpdateCookieAsync(HttpContext httpContext, List { - new(ClaimTypes.NameIdentifier, jwtToken.Subject ?? Guid.NewGuid().ToString()), + new(ClaimTypes.NameIdentifier, jwtToken.Subject ?? Guid.CreateVersion7().ToString()), new(ClaimTypes.Email, email), new("access_token", token.AccessToken), // Store JWT for API calls new("refresh_token", token.RefreshToken), // Store refresh token for token renewal @@ -89,7 +91,29 @@ public static void MapSimpleBffAuthEndpoints(this WebApplication app) } catch (ApiException ex) when (ex.StatusCode == 401) { - return Results.Unauthorized(); + // Extract error message from ProblemDetails response + string errorMessage = "Invalid credentials"; + if (!string.IsNullOrEmpty(ex.Response)) + { + try + { + var problemDetails = System.Text.Json.JsonSerializer.Deserialize(ex.Response); + if (problemDetails.TryGetProperty("detail", out var detail)) + { + errorMessage = detail.GetString() ?? errorMessage; + } + } + catch (Exception parseEx) + { + // If parsing fails, use default message + logger.LogDebug(parseEx, "Failed to parse error response, using default message"); + } + } + + logger.LogWarning(ex, "Login failed: {ErrorMessage}", errorMessage); + + // Redirect to login page with error message as query parameter + return Results.Redirect($"/login?error={Uri.EscapeDataString(errorMessage)}"); } catch (Exception ex) { @@ -115,5 +139,23 @@ public static void MapSimpleBffAuthEndpoints(this WebApplication app) return Results.Redirect("/login?toast=logout_success"); }) .AllowAnonymous(); + + // Culture set endpoint - persists the chosen culture in a cookie and redirects back + app.MapGet("/Culture/Set", (HttpContext httpContext, string culture, string? redirectUri) => + { + if (!LocalizationConstants.SupportedCultures.Contains(culture)) + { + return Results.BadRequest($"Culture '{culture}' is not supported."); + } + + httpContext.Response.Cookies.Append( + LocalizationConstants.CultureCookieName, + CookieRequestCultureProvider.MakeCookieValue(new RequestCulture(culture)), + new CookieOptions { MaxAge = TimeSpan.FromDays(365) }); + + var destination = string.IsNullOrEmpty(redirectUri) ? "/" : redirectUri; + return Results.Redirect(destination); + }) + .AllowAnonymous(); } } \ No newline at end of file diff --git a/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs b/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs index 2423869c71..eefaedede4 100644 --- a/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs +++ b/src/Playground/Playground.Blazor/Services/ThemeStateFactory.cs @@ -15,26 +15,15 @@ internal interface IThemeStateFactory /// /// Redis-cached implementation of theme state factory. /// -internal sealed class CachedThemeStateFactory : IThemeStateFactory +internal sealed class CachedThemeStateFactory( + IDistributedCache cache, + HttpClient httpClient, + ILogger logger) : IThemeStateFactory { private static readonly Uri ThemeEndpoint = new("/api/v1/tenants/theme", UriKind.Relative); private static readonly TenantThemeSettings DefaultSettings = TenantThemeSettings.Default; - - private readonly IDistributedCache _cache; - private readonly HttpClient _httpClient; - private readonly ILogger _logger; private readonly TimeSpan _cacheExpiry = TimeSpan.FromMinutes(15); - public CachedThemeStateFactory( - IDistributedCache cache, - HttpClient httpClient, - ILogger logger) - { - _cache = cache; - _httpClient = httpClient; - _logger = logger; - } - public async Task GetThemeAsync(string tenantId, CancellationToken cancellationToken = default) { var cacheKey = $"theme:{tenantId}"; @@ -55,7 +44,7 @@ public async Task GetThemeAsync(string tenantId, Cancellati { try { - var json = await _cache.GetStringAsync(cacheKey, cancellationToken); + var json = await cache.GetStringAsync(cacheKey, cancellationToken); if (json is null) { return null; @@ -65,7 +54,7 @@ public async Task GetThemeAsync(string tenantId, Cancellati } catch (Exception ex) { - _logger.LogWarning(ex, "Cache unavailable, fetching theme directly for tenant {TenantId}", SanitizeLogValue(tenantId)); + logger.LogWarning(ex, "Cache unavailable, fetching theme directly for tenant {TenantId}", SanitizeLogValue(tenantId)); return null; } } @@ -78,7 +67,7 @@ public async Task GetThemeAsync(string tenantId, Cancellati } catch (JsonException ex) { - _logger.LogWarning(ex, "Failed to deserialize cached theme for tenant {TenantId}", SanitizeLogValue(tenantId)); + logger.LogWarning(ex, "Failed to deserialize cached theme for tenant {TenantId}", SanitizeLogValue(tenantId)); return null; } } @@ -99,7 +88,7 @@ private async Task FetchAndCacheThemeAsync( } catch (Exception ex) { - _logger.LogError(ex, "Error loading tenant theme for {TenantId}", SanitizeLogValue(tenantId)); + logger.LogError(ex, "Error loading tenant theme for {TenantId}", SanitizeLogValue(tenantId)); } return DefaultSettings; @@ -107,11 +96,11 @@ private async Task FetchAndCacheThemeAsync( private async Task FetchThemeFromApiAsync(CancellationToken cancellationToken) { - var response = await _httpClient.GetAsync(ThemeEndpoint, cancellationToken); + var response = await httpClient.GetAsync(ThemeEndpoint, cancellationToken); if (!response.IsSuccessStatusCode) { - _logger.LogWarning("Failed to load tenant theme from API: {StatusCode}", response.StatusCode); + logger.LogWarning("Failed to load tenant theme from API: {StatusCode}", response.StatusCode); return null; } @@ -131,11 +120,11 @@ private async Task TryCacheThemeAsync( { AbsoluteExpirationRelativeToNow = _cacheExpiry }; - await _cache.SetStringAsync(cacheKey, serialized, options, cancellationToken); + await cache.SetStringAsync(cacheKey, serialized, options, cancellationToken); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to cache theme, continuing without cache"); + logger.LogWarning(ex, "Failed to cache theme, continuing without cache"); } } diff --git a/src/Playground/Playground.Blazor/appsettings.Development.json b/src/Playground/Playground.Blazor/appsettings.Development.json index 0c208ae918..c49f0e64ab 100644 --- a/src/Playground/Playground.Blazor/appsettings.Development.json +++ b/src/Playground/Playground.Blazor/appsettings.Development.json @@ -1,8 +1,12 @@ { + "DetailedErrors": true, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "CircuitOptions": { + "DetailedErrors": true } } diff --git a/src/Tests/Architecture.Tests/Architecture.Tests.csproj b/src/Tests/Architecture.Tests/Architecture.Tests.csproj index 980a21ec80..3cf1a2fadc 100644 --- a/src/Tests/Architecture.Tests/Architecture.Tests.csproj +++ b/src/Tests/Architecture.Tests/Architecture.Tests.csproj @@ -5,7 +5,7 @@ false enable enable - $(NoWarn);CA1515;CA1861;CA1707;CA1307 + $(NoWarn);CA1515;CA1861;CA1707;CA1307;NU1507 diff --git a/src/Tests/Auditing.Tests/Auditing.Tests.csproj b/src/Tests/Auditing.Tests/Auditing.Tests.csproj index 4bbcc25c4f..89a34662fc 100644 --- a/src/Tests/Auditing.Tests/Auditing.Tests.csproj +++ b/src/Tests/Auditing.Tests/Auditing.Tests.csproj @@ -5,7 +5,7 @@ false enable enable - $(NoWarn);CA1515;CA1861;CA1707 + $(NoWarn);CA1515;CA1861;CA1707;NU1507 diff --git a/src/Tests/Auditing.Tests/Contracts/AuditEnvelopeTests.cs b/src/Tests/Auditing.Tests/Contracts/AuditEnvelopeTests.cs index 1f23cabc2f..44d6e006b5 100644 --- a/src/Tests/Auditing.Tests/Contracts/AuditEnvelopeTests.cs +++ b/src/Tests/Auditing.Tests/Contracts/AuditEnvelopeTests.cs @@ -7,7 +7,7 @@ namespace Auditing.Tests.Contracts; /// public sealed class AuditEnvelopeTests { - private static readonly Guid TestId = Guid.NewGuid(); + private static readonly Guid TestId = Guid.CreateVersion7(); private static readonly DateTime TestOccurredAt = new(2024, 1, 15, 12, 0, 0, DateTimeKind.Utc); private static readonly DateTime TestReceivedAt = new(2024, 1, 15, 12, 0, 1, DateTimeKind.Utc); @@ -185,7 +185,7 @@ public void Constructor_Should_AcceptAllEventTypes() { // Act var envelope = new AuditEnvelope( - Guid.NewGuid(), + Guid.CreateVersion7(), TestOccurredAt, TestReceivedAt, eventType, @@ -209,7 +209,7 @@ public void Constructor_Should_AcceptAllSeverityLevels() { // Act var envelope = new AuditEnvelope( - Guid.NewGuid(), + Guid.CreateVersion7(), TestOccurredAt, TestReceivedAt, AuditEventType.Activity, diff --git a/src/Tests/Generic.Tests/Generic.Tests.csproj b/src/Tests/Generic.Tests/Generic.Tests.csproj index 6b28c1e96a..689d5e9e1e 100644 --- a/src/Tests/Generic.Tests/Generic.Tests.csproj +++ b/src/Tests/Generic.Tests/Generic.Tests.csproj @@ -5,7 +5,7 @@ false enable enable - $(NoWarn);CA1515;CA1861;CA1707 + $(NoWarn);CA1515;CA1861;CA1707;NU1507 diff --git a/src/Tests/Identity.Tests/Identity.Tests.csproj b/src/Tests/Identity.Tests/Identity.Tests.csproj index 6b9d2742c9..1a8d1ffeda 100644 --- a/src/Tests/Identity.Tests/Identity.Tests.csproj +++ b/src/Tests/Identity.Tests/Identity.Tests.csproj @@ -5,7 +5,7 @@ false enable enable - $(NoWarn);CA1515;CA1861;CA1707 + $(NoWarn);CA1515;CA1861;CA1707;NU1507 diff --git a/src/Tests/Identity.Tests/Services/CurrentUserServiceTests.cs b/src/Tests/Identity.Tests/Services/CurrentUserServiceTests.cs index 9af8b3a9e9..9d150617f5 100644 --- a/src/Tests/Identity.Tests/Services/CurrentUserServiceTests.cs +++ b/src/Tests/Identity.Tests/Services/CurrentUserServiceTests.cs @@ -50,7 +50,7 @@ public void GetUserId_Should_ReturnGuid_When_Authenticated() { // Arrange var service = new CurrentUserService(); - var userId = Guid.NewGuid(); + var userId = Guid.CreateVersion7(); var principal = CreateAuthenticatedPrincipal(userId.ToString()); service.SetCurrentUser(principal); @@ -66,7 +66,7 @@ public void GetUserId_Should_ReturnStoredId_When_NotAuthenticated() { // Arrange var service = new CurrentUserService(); - var userId = Guid.NewGuid(); + var userId = Guid.CreateVersion7(); service.SetCurrentUserId(userId.ToString()); // Act @@ -99,7 +99,7 @@ public void GetUserEmail_Should_ReturnEmail_When_Authenticated() // Arrange var service = new CurrentUserService(); var principal = CreateAuthenticatedPrincipal( - Guid.NewGuid().ToString(), + Guid.CreateVersion7().ToString(), email: "test@example.com"); service.SetCurrentUser(principal); @@ -134,7 +134,7 @@ public void IsAuthenticated_Should_ReturnTrue_When_UserAuthenticated() { // Arrange var service = new CurrentUserService(); - var principal = CreateAuthenticatedPrincipal(Guid.NewGuid().ToString()); + var principal = CreateAuthenticatedPrincipal(Guid.CreateVersion7().ToString()); service.SetCurrentUser(principal); // Act @@ -182,7 +182,7 @@ public void IsInRole_Should_ReturnTrue_When_UserHasRole() // Arrange var service = new CurrentUserService(); var principal = CreateAuthenticatedPrincipal( - Guid.NewGuid().ToString(), + Guid.CreateVersion7().ToString(), null, null, null, "Admin", "User"); service.SetCurrentUser(principal); @@ -200,7 +200,7 @@ public void IsInRole_Should_ReturnFalse_When_UserLacksRole() // Arrange var service = new CurrentUserService(); var principal = CreateAuthenticatedPrincipal( - Guid.NewGuid().ToString(), + Guid.CreateVersion7().ToString(), null, null, null, "User"); service.SetCurrentUser(principal); @@ -235,7 +235,7 @@ public void GetUserClaims_Should_ReturnAllClaims_When_Authenticated() // Arrange var service = new CurrentUserService(); var principal = CreateAuthenticatedPrincipal( - Guid.NewGuid().ToString(), + Guid.CreateVersion7().ToString(), email: "test@example.com", name: "Test User"); service.SetCurrentUser(principal); @@ -272,7 +272,7 @@ public void GetTenant_Should_ReturnTenant_When_Authenticated() // Arrange var service = new CurrentUserService(); var principal = CreateAuthenticatedPrincipal( - Guid.NewGuid().ToString(), + Guid.CreateVersion7().ToString(), tenant: "tenant-1"); service.SetCurrentUser(principal); @@ -307,7 +307,7 @@ public void SetCurrentUser_Should_Throw_When_CalledTwice() { // Arrange var service = new CurrentUserService(); - var principal = CreateAuthenticatedPrincipal(Guid.NewGuid().ToString()); + var principal = CreateAuthenticatedPrincipal(Guid.CreateVersion7().ToString()); service.SetCurrentUser(principal); // Act & Assert @@ -320,7 +320,7 @@ public void SetCurrentUser_Should_StorePrincipal() { // Arrange var service = new CurrentUserService(); - var principal = CreateAuthenticatedPrincipal(Guid.NewGuid().ToString()); + var principal = CreateAuthenticatedPrincipal(Guid.CreateVersion7().ToString()); // Act service.SetCurrentUser(principal); @@ -338,7 +338,7 @@ public void SetCurrentUserId_Should_Throw_When_CalledTwice() { // Arrange var service = new CurrentUserService(); - var userId = Guid.NewGuid().ToString(); + var userId = Guid.CreateVersion7().ToString(); service.SetCurrentUserId(userId); // Act & Assert @@ -362,7 +362,7 @@ public void SetCurrentUserId_Should_ParseAndStoreGuid() { // Arrange var service = new CurrentUserService(); - var userId = Guid.NewGuid(); + var userId = Guid.CreateVersion7(); // Act service.SetCurrentUserId(userId.ToString()); @@ -382,7 +382,7 @@ public void Name_Should_ReturnIdentityName_When_Set() var service = new CurrentUserService(); var claims = new List { - new(ClaimTypes.NameIdentifier, Guid.NewGuid().ToString()), + new(ClaimTypes.NameIdentifier, Guid.CreateVersion7().ToString()), new(ClaimTypes.Name, "John Doe") }; var identity = new ClaimsIdentity(claims, "TestAuthType", ClaimTypes.Name, ClaimTypes.Role); diff --git a/src/Tests/Identity.Tests/Services/PasswordExpiryServiceTests.cs b/src/Tests/Identity.Tests/Services/PasswordExpiryServiceTests.cs index aa7ea71f4d..4074deb1a4 100644 --- a/src/Tests/Identity.Tests/Services/PasswordExpiryServiceTests.cs +++ b/src/Tests/Identity.Tests/Services/PasswordExpiryServiceTests.cs @@ -31,7 +31,7 @@ private static FshUser CreateUser(DateTime lastPasswordChangeDate) { return new FshUser { - Id = Guid.NewGuid().ToString(), + Id = Guid.CreateVersion7().ToString(), Email = "test@example.com", UserName = "testuser", LastPasswordChangeDate = lastPasswordChangeDate diff --git a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs index c0c1cdb2b9..c1d48f24a0 100644 --- a/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs +++ b/src/Tests/Identity.Tests/Validators/UpdateGroupCommandValidatorTests.cs @@ -16,7 +16,7 @@ public sealed class UpdateGroupCommandValidatorTests public void Id_Should_Pass_When_Valid() { // Arrange - var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Developers", null, false, null); // Act var result = _sut.Validate(command); @@ -62,7 +62,7 @@ public void Id_Should_Have_CorrectErrorMessage() public void Name_Should_Pass_When_Valid() { // Arrange - var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Developers", null, false, null); // Act var result = _sut.Validate(command); @@ -78,7 +78,7 @@ public void Name_Should_Pass_When_Valid() public void Name_Should_Fail_When_Empty(string? name) { // Arrange - var command = new UpdateGroupCommand(Guid.NewGuid(), name!, null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), name!, null, false, null); // Act var result = _sut.Validate(command); @@ -93,7 +93,7 @@ public void Name_Should_Fail_When_ExceedsMaxLength() { // Arrange var longName = new string('a', 257); - var command = new UpdateGroupCommand(Guid.NewGuid(), longName, null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), longName, null, false, null); // Act var result = _sut.Validate(command); @@ -108,7 +108,7 @@ public void Name_Should_Pass_When_ExactlyMaxLength() { // Arrange var maxLengthName = new string('a', 256); - var command = new UpdateGroupCommand(Guid.NewGuid(), maxLengthName, null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), maxLengthName, null, false, null); // Act var result = _sut.Validate(command); @@ -121,7 +121,7 @@ public void Name_Should_Pass_When_ExactlyMaxLength() public void Name_Should_Have_CorrectErrorMessage_When_Empty() { // Arrange - var command = new UpdateGroupCommand(Guid.NewGuid(), "", null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "", null, false, null); // Act var result = _sut.Validate(command); @@ -137,7 +137,7 @@ public void Name_Should_Have_CorrectErrorMessage_When_TooLong() { // Arrange var longName = new string('a', 257); - var command = new UpdateGroupCommand(Guid.NewGuid(), longName, null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), longName, null, false, null); // Act var result = _sut.Validate(command); @@ -156,7 +156,7 @@ public void Name_Should_Have_CorrectErrorMessage_When_TooLong() public void Description_Should_Pass_When_Null() { // Arrange - var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Developers", null, false, null); // Act var result = _sut.Validate(command); @@ -169,7 +169,7 @@ public void Description_Should_Pass_When_Null() public void Description_Should_Pass_When_Empty() { // Arrange - var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", "", false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Developers", "", false, null); // Act var result = _sut.Validate(command); @@ -182,7 +182,7 @@ public void Description_Should_Pass_When_Empty() public void Description_Should_Pass_When_Valid() { // Arrange - var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", "Software development team", false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Developers", "Software development team", false, null); // Act var result = _sut.Validate(command); @@ -196,7 +196,7 @@ public void Description_Should_Fail_When_ExceedsMaxLength() { // Arrange var longDescription = new string('a', 1025); - var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", longDescription, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Developers", longDescription, false, null); // Act var result = _sut.Validate(command); @@ -211,7 +211,7 @@ public void Description_Should_Pass_When_ExactlyMaxLength() { // Arrange var maxLengthDescription = new string('a', 1024); - var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", maxLengthDescription, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Developers", maxLengthDescription, false, null); // Act var result = _sut.Validate(command); @@ -225,7 +225,7 @@ public void Description_Should_Have_CorrectErrorMessage_When_TooLong() { // Arrange var longDescription = new string('a', 1025); - var command = new UpdateGroupCommand(Guid.NewGuid(), "Developers", longDescription, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Developers", longDescription, false, null); // Act var result = _sut.Validate(command); @@ -245,7 +245,7 @@ public void Validate_Should_Pass_When_AllFieldsValid() { // Arrange var command = new UpdateGroupCommand( - Guid.NewGuid(), + Guid.CreateVersion7(), "Engineering Team", "All software engineers", true, @@ -263,7 +263,7 @@ public void Validate_Should_Pass_When_AllFieldsValid() public void Validate_Should_Pass_When_OptionalFieldsAreNull() { // Arrange - var command = new UpdateGroupCommand(Guid.NewGuid(), "Basic Group", null, false, null); + var command = new UpdateGroupCommand(Guid.CreateVersion7(), "Basic Group", null, false, null); // Act var result = _sut.Validate(command); diff --git a/src/Tests/Multitenancy.Tests/Multitenancy.Tests.csproj b/src/Tests/Multitenancy.Tests/Multitenancy.Tests.csproj index 69d3751735..79d4554bda 100644 --- a/src/Tests/Multitenancy.Tests/Multitenancy.Tests.csproj +++ b/src/Tests/Multitenancy.Tests/Multitenancy.Tests.csproj @@ -5,7 +5,7 @@ false enable enable - $(NoWarn);S125;S3261;CA1707 + $(NoWarn);S125;S3261;CA1707;NU1507 diff --git a/src/Tests/Multitenancy.Tests/Provisioning/TenantProvisioningStepTests.cs b/src/Tests/Multitenancy.Tests/Provisioning/TenantProvisioningStepTests.cs index 4e81ec1c41..0cc4727eee 100644 --- a/src/Tests/Multitenancy.Tests/Provisioning/TenantProvisioningStepTests.cs +++ b/src/Tests/Multitenancy.Tests/Provisioning/TenantProvisioningStepTests.cs @@ -13,7 +13,7 @@ public sealed class TenantProvisioningStepTests public void Constructor_Should_SetProvisioningId() { // Arrange - var provisioningId = Guid.NewGuid(); + var provisioningId = Guid.CreateVersion7(); // Act var step = new TenantProvisioningStep(provisioningId, TenantProvisioningStepName.Database); @@ -26,7 +26,7 @@ public void Constructor_Should_SetProvisioningId() public void Constructor_Should_SetStep() { // Arrange - var provisioningId = Guid.NewGuid(); + var provisioningId = Guid.CreateVersion7(); // Act var step = new TenantProvisioningStep(provisioningId, TenantProvisioningStepName.Migrations); @@ -39,7 +39,7 @@ public void Constructor_Should_SetStep() public void Constructor_Should_SetStatusToPending() { // Act - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); // Assert step.Status.ShouldBe(TenantProvisioningStatus.Pending); @@ -49,7 +49,7 @@ public void Constructor_Should_SetStatusToPending() public void Constructor_Should_GenerateNewId() { // Act - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); // Assert step.Id.ShouldNotBe(Guid.Empty); @@ -59,7 +59,7 @@ public void Constructor_Should_GenerateNewId() public void Constructor_Should_InitializeNullFields() { // Act - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); // Assert step.Error.ShouldBeNull(); @@ -75,7 +75,7 @@ public void Constructor_Should_InitializeNullFields() public void Constructor_Should_AcceptAllStepNames(TenantProvisioningStepName stepName) { // Act - var step = new TenantProvisioningStep(Guid.NewGuid(), stepName); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), stepName); // Assert step.Step.ShouldBe(stepName); @@ -89,7 +89,7 @@ public void Constructor_Should_AcceptAllStepNames(TenantProvisioningStepName ste public void MarkRunning_Should_SetStatusToRunning() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); // Act step.MarkRunning(); @@ -102,7 +102,7 @@ public void MarkRunning_Should_SetStatusToRunning() public void MarkRunning_Should_SetStartedUtc_OnFirstCall() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); var before = DateTime.UtcNow; // Act @@ -119,7 +119,7 @@ public void MarkRunning_Should_SetStartedUtc_OnFirstCall() public void MarkRunning_Should_NotOverwriteStartedUtc_OnSubsequentCalls() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); step.MarkRunning(); var firstStartedUtc = step.StartedUtc; @@ -138,7 +138,7 @@ public void MarkRunning_Should_NotOverwriteStartedUtc_OnSubsequentCalls() public void MarkCompleted_Should_SetStatusToCompleted() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); step.MarkRunning(); // Act @@ -152,7 +152,7 @@ public void MarkCompleted_Should_SetStatusToCompleted() public void MarkCompleted_Should_SetCompletedUtc() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); var before = DateTime.UtcNow; // Act @@ -173,7 +173,7 @@ public void MarkCompleted_Should_SetCompletedUtc() public void MarkFailed_Should_SetStatusToFailed() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); // Act step.MarkFailed("Connection failed"); @@ -186,7 +186,7 @@ public void MarkFailed_Should_SetStatusToFailed() public void MarkFailed_Should_SetError() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); var error = "Database connection timeout"; // Act @@ -200,7 +200,7 @@ public void MarkFailed_Should_SetError() public void MarkFailed_Should_SetCompletedUtc() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Database); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Database); var before = DateTime.UtcNow; // Act @@ -221,7 +221,7 @@ public void MarkFailed_Should_SetCompletedUtc() public void Step_Should_SupportSuccessfulLifecycle() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Migrations); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Migrations); step.Status.ShouldBe(TenantProvisioningStatus.Pending); // Act - Running @@ -239,7 +239,7 @@ public void Step_Should_SupportSuccessfulLifecycle() public void Step_Should_SupportFailureLifecycle() { // Arrange - var step = new TenantProvisioningStep(Guid.NewGuid(), TenantProvisioningStepName.Seeding); + var step = new TenantProvisioningStep(Guid.CreateVersion7(), TenantProvisioningStepName.Seeding); // Act - Running step.MarkRunning(); diff --git a/src/Tests/Multitenancy.Tests/Provisioning/TenantProvisioningTests.cs b/src/Tests/Multitenancy.Tests/Provisioning/TenantProvisioningTests.cs index b54ac71008..30a184ee18 100644 --- a/src/Tests/Multitenancy.Tests/Provisioning/TenantProvisioningTests.cs +++ b/src/Tests/Multitenancy.Tests/Provisioning/TenantProvisioningTests.cs @@ -14,7 +14,7 @@ public void Constructor_Should_SetTenantId() { // Arrange var tenantId = "tenant-1"; - var correlationId = Guid.NewGuid().ToString(); + var correlationId = Guid.CreateVersion7().ToString(); // Act var provisioning = new TenantProvisioning(tenantId, correlationId); @@ -28,7 +28,7 @@ public void Constructor_Should_SetCorrelationId() { // Arrange var tenantId = "tenant-1"; - var correlationId = Guid.NewGuid().ToString(); + var correlationId = Guid.CreateVersion7().ToString(); // Act var provisioning = new TenantProvisioning(tenantId, correlationId); @@ -41,7 +41,7 @@ public void Constructor_Should_SetCorrelationId() public void Constructor_Should_SetStatusToPending() { // Act - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); // Assert provisioning.Status.ShouldBe(TenantProvisioningStatus.Pending); @@ -54,7 +54,7 @@ public void Constructor_Should_SetCreatedUtc() var before = DateTime.UtcNow; // Act - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); var after = DateTime.UtcNow; // Assert @@ -66,7 +66,7 @@ public void Constructor_Should_SetCreatedUtc() public void Constructor_Should_GenerateNewId() { // Act - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); // Assert provisioning.Id.ShouldNotBe(Guid.Empty); @@ -76,7 +76,7 @@ public void Constructor_Should_GenerateNewId() public void Constructor_Should_InitializeNullFields() { // Act - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); // Assert provisioning.CurrentStep.ShouldBeNull(); @@ -90,7 +90,7 @@ public void Constructor_Should_InitializeNullFields() public void Constructor_Should_InitializeEmptySteps() { // Act - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); // Assert provisioning.Steps.ShouldNotBeNull(); @@ -105,7 +105,7 @@ public void Constructor_Should_InitializeEmptySteps() public void SetJobId_Should_SetJobId() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); var jobId = "job-12345"; // Act @@ -119,7 +119,7 @@ public void SetJobId_Should_SetJobId() public void SetJobId_Should_AllowOverwriting() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); provisioning.SetJobId("job-1"); // Act @@ -137,7 +137,7 @@ public void SetJobId_Should_AllowOverwriting() public void MarkRunning_Should_SetStatusToRunning() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); // Act provisioning.MarkRunning("Migration"); @@ -150,7 +150,7 @@ public void MarkRunning_Should_SetStatusToRunning() public void MarkRunning_Should_SetCurrentStep() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); // Act provisioning.MarkRunning("Migration"); @@ -163,7 +163,7 @@ public void MarkRunning_Should_SetCurrentStep() public void MarkRunning_Should_SetStartedUtc_OnFirstCall() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); var before = DateTime.UtcNow; // Act @@ -180,7 +180,7 @@ public void MarkRunning_Should_SetStartedUtc_OnFirstCall() public void MarkRunning_Should_NotOverwriteStartedUtc_OnSubsequentCalls() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); provisioning.MarkRunning("Migration"); var firstStartedUtc = provisioning.StartedUtc; @@ -200,7 +200,7 @@ public void MarkRunning_Should_NotOverwriteStartedUtc_OnSubsequentCalls() public void MarkCompleted_Should_SetStatusToCompleted() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); provisioning.MarkRunning("Migration"); // Act @@ -214,7 +214,7 @@ public void MarkCompleted_Should_SetStatusToCompleted() public void MarkCompleted_Should_SetCompletedUtc() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); var before = DateTime.UtcNow; // Act @@ -231,7 +231,7 @@ public void MarkCompleted_Should_SetCompletedUtc() public void MarkCompleted_Should_ClearCurrentStep() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); provisioning.MarkRunning("Migration"); // Act @@ -245,7 +245,7 @@ public void MarkCompleted_Should_ClearCurrentStep() public void MarkCompleted_Should_ClearError() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); provisioning.MarkFailed("Migration", "Some error"); // Act @@ -263,7 +263,7 @@ public void MarkCompleted_Should_ClearError() public void MarkFailed_Should_SetStatusToFailed() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); // Act provisioning.MarkFailed("Migration", "Database connection failed"); @@ -276,7 +276,7 @@ public void MarkFailed_Should_SetStatusToFailed() public void MarkFailed_Should_SetCurrentStep() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); // Act provisioning.MarkFailed("Migration", "Database connection failed"); @@ -289,7 +289,7 @@ public void MarkFailed_Should_SetCurrentStep() public void MarkFailed_Should_SetError() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); var error = "Database connection failed"; // Act @@ -303,7 +303,7 @@ public void MarkFailed_Should_SetError() public void MarkFailed_Should_SetCompletedUtc() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); var before = DateTime.UtcNow; // Act @@ -324,7 +324,7 @@ public void MarkFailed_Should_SetCompletedUtc() public void Provisioning_Should_SupportFullLifecycle() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); provisioning.Status.ShouldBe(TenantProvisioningStatus.Pending); // Act & Assert - Running @@ -345,7 +345,7 @@ public void Provisioning_Should_SupportFullLifecycle() public void Provisioning_Should_SupportFailureFromRunning() { // Arrange - var provisioning = new TenantProvisioning("tenant-1", Guid.NewGuid().ToString()); + var provisioning = new TenantProvisioning("tenant-1", Guid.CreateVersion7().ToString()); provisioning.MarkRunning("Migration"); // Act diff --git a/src/Tools/CLI/FSH.CLI.csproj b/src/Tools/CLI/FSH.CLI.csproj index c71ffd620b..61845e62be 100644 --- a/src/Tools/CLI/FSH.CLI.csproj +++ b/src/Tools/CLI/FSH.CLI.csproj @@ -14,7 +14,7 @@ - $(NoWarn);CA1812;CA1031;CA1303;CA1308;CA2234;CA1852;CA1859;CA1826;CA1869;CA1307;CA1822;S1118;S4487;S1066;S3267;S1172;S2325;S125 + $(NoWarn);CA1812;CA1031;CA1303;CA1308;CA2234;CA1852;CA1859;CA1826;CA1869;CA1307;CA1822;S1118;S4487;S1066;S3267;S1172;S2325;S125;NU1507 true diff --git a/src/Tools/CLI/Scaffolding/TemplateRenderer.cs b/src/Tools/CLI/Scaffolding/TemplateRenderer.cs index 3410f2b22a..2c4a27bec5 100644 --- a/src/Tools/CLI/Scaffolding/TemplateRenderer.cs +++ b/src/Tools/CLI/Scaffolding/TemplateRenderer.cs @@ -7,18 +7,11 @@ namespace FSH.CLI.Scaffolding; /// Renders templates with variable substitution /// [SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "Lowercase is required for Docker, Terraform, and GitHub Actions naming conventions")] -internal sealed class TemplateRenderer : ITemplateRenderer +internal sealed class TemplateRenderer(ITemplateLoader templateLoader, ITemplateParser templateParser /*, ITemplateCache templateCache*/) : ITemplateRenderer { - private readonly ITemplateLoader _templateLoader; - private readonly ITemplateParser _templateParser; - private readonly ITemplateCache _templateCache; - - public TemplateRenderer(ITemplateLoader templateLoader, ITemplateParser templateParser, ITemplateCache templateCache) - { - _templateLoader = templateLoader ?? throw new ArgumentNullException(nameof(templateLoader)); - _templateParser = templateParser ?? throw new ArgumentNullException(nameof(templateParser)); - _templateCache = templateCache ?? throw new ArgumentNullException(nameof(templateCache)); - } + private readonly ITemplateLoader _templateLoader = templateLoader ?? throw new ArgumentNullException(nameof(templateLoader)); + private readonly ITemplateParser _templateParser = templateParser ?? throw new ArgumentNullException(nameof(templateParser)); + // private readonly ITemplateCache _templateCache = templateCache ?? throw new ArgumentNullException(nameof(templateCache)); #region Solution and Project Templates diff --git a/src/Tools/CLI/Scaffolding/TemplateValidator.cs b/src/Tools/CLI/Scaffolding/TemplateValidator.cs index b4914d780a..38ef6e5578 100644 --- a/src/Tools/CLI/Scaffolding/TemplateValidator.cs +++ b/src/Tools/CLI/Scaffolding/TemplateValidator.cs @@ -5,16 +5,10 @@ namespace FSH.CLI.Scaffolding; /// /// Validates template structure and project configuration /// -internal sealed class TemplateValidator : ITemplateValidator +internal sealed class TemplateValidator(ITemplateLoader templateLoader, ITemplateParser templateParser) : ITemplateValidator { - private readonly ITemplateLoader _templateLoader; - private readonly ITemplateParser _templateParser; - - public TemplateValidator(ITemplateLoader templateLoader, ITemplateParser templateParser) - { - _templateLoader = templateLoader ?? throw new ArgumentNullException(nameof(templateLoader)); - _templateParser = templateParser ?? throw new ArgumentNullException(nameof(templateParser)); - } + private readonly ITemplateLoader _templateLoader = templateLoader ?? throw new ArgumentNullException(nameof(templateLoader)); + private readonly ITemplateParser _templateParser = templateParser ?? throw new ArgumentNullException(nameof(templateParser)); public ValidationResult ValidateProjectOptions(ProjectOptions options) {