From 4cb228948326342bdd09edc2e9438c5a200b40f2 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 4 Aug 2025 10:06:37 +0200 Subject: [PATCH 1/6] Rename avatars-storage to account-management blob storage name in Bicep and AppGateway for unified storage connection per system --- .../Filters/ClusterDestinationConfigFilter.cs | 2 +- application/AppGateway/Program.cs | 2 +- .../SharedAccessSignatureRequestTransform.cs | 14 +++++++++++--- application/AppGateway/appsettings.json | 4 ++-- .../account-management/Core/Configuration.cs | 2 +- .../Core/Features/Users/Shared/AvatarUpdater.cs | 2 +- cloud-infrastructure/cluster/main-cluster.bicep | 2 +- 7 files changed, 18 insertions(+), 10 deletions(-) diff --git a/application/AppGateway/Filters/ClusterDestinationConfigFilter.cs b/application/AppGateway/Filters/ClusterDestinationConfigFilter.cs index 33fc1389de..6559368519 100644 --- a/application/AppGateway/Filters/ClusterDestinationConfigFilter.cs +++ b/application/AppGateway/Filters/ClusterDestinationConfigFilter.cs @@ -10,7 +10,7 @@ public ValueTask ConfigureClusterAsync(ClusterConfig cluster, Can { "account-management-api" => ReplaceDestinationAddress(cluster, "ACCOUNT_MANAGEMENT_API_URL"), "account-management-static" => ReplaceDestinationAddress(cluster, "ACCOUNT_MANAGEMENT_API_URL"), - "avatars-storage" => ReplaceDestinationAddress(cluster, "AVATARS_STORAGE_URL"), + "account-management-storage" => ReplaceDestinationAddress(cluster, "ACCOUNT_MANAGEMENT_STORAGE_URL"), "back-office-api" => ReplaceDestinationAddress(cluster, "BACK_OFFICE_API_URL"), "back-office-static" => ReplaceDestinationAddress(cluster, "BACK_OFFICE_API_URL"), _ => throw new InvalidOperationException($"Unknown Cluster ID {cluster.ClusterId}.") diff --git a/application/AppGateway/Program.cs b/application/AppGateway/Program.cs index fa7fa18d4c..20f396bca1 100644 --- a/application/AppGateway/Program.cs +++ b/application/AppGateway/Program.cs @@ -38,7 +38,7 @@ ); } -builder.AddNamedBlobStorages(("avatars-storage", "AVATARS_STORAGE_URL")); +builder.AddNamedBlobStorages(("account-management-storage", "ACCOUNT_MANAGEMENT_STORAGE_URL")); builder.WebHost.UseKestrel(option => option.AddServerHeader = false); diff --git a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs index 58e3af537c..c5fcc44277 100644 --- a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs +++ b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs @@ -3,14 +3,22 @@ namespace PlatformPlatform.AppGateway.Transformations; -public class SharedAccessSignatureRequestTransform([FromKeyedServices("avatars-storage")] BlobStorageClient blobStorageClient) +public class SharedAccessSignatureRequestTransform([FromKeyedServices("account-management-storage")] BlobStorageClient accountManagementBlobStorageClient) : RequestTransform { public override ValueTask ApplyAsync(RequestTransformContext context) { - if (!context.Path.StartsWithSegments("/avatars")) return ValueTask.CompletedTask; + string containerName; + if (context.Path.StartsWithSegments("/avatars")) + { + containerName = "avatars"; + } + else + { + return ValueTask.CompletedTask; + } - var sharedAccessSignature = blobStorageClient.GetSharedAccessSignature("avatars", TimeSpan.FromMinutes(10)); + var sharedAccessSignature = accountManagementBlobStorageClient.GetSharedAccessSignature(containerName, TimeSpan.FromMinutes(10)); context.HttpContext.Request.QueryString = new QueryString(sharedAccessSignature); return ValueTask.CompletedTask; diff --git a/application/AppGateway/appsettings.json b/application/AppGateway/appsettings.json index bc3966aca8..b49e85f954 100644 --- a/application/AppGateway/appsettings.json +++ b/application/AppGateway/appsettings.json @@ -68,7 +68,7 @@ } }, "avatars": { - "ClusterId": "avatars-storage", + "ClusterId": "account-management-storage", "Match": { "Path": "/avatars/{**catch-all}" }, @@ -205,7 +205,7 @@ } } }, - "avatars-storage": { + "account-management-storage": { "Destinations": { "destination": { "Address": "http://127.0.0.1:10000/devstoreaccount1" diff --git a/application/account-management/Core/Configuration.cs b/application/account-management/Core/Configuration.cs index 8ea37a4190..e68611df0b 100644 --- a/application/account-management/Core/Configuration.cs +++ b/application/account-management/Core/Configuration.cs @@ -16,7 +16,7 @@ public static IHostApplicationBuilder AddAccountManagementInfrastructure(this IH // Infrastructure is configured separately from other Infrastructure services to allow mocking in tests return builder .AddSharedInfrastructure("account-management-database") - .AddNamedBlobStorages(("avatars-storage", "BLOB_STORAGE_URL")); + .AddNamedBlobStorages(("account-management-storage", "BLOB_STORAGE_URL")); } public static IServiceCollection AddAccountManagementServices(this IServiceCollection services) diff --git a/application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs b/application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs index 96eba3f464..7466971c03 100644 --- a/application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs +++ b/application/account-management/Core/Features/Users/Shared/AvatarUpdater.cs @@ -5,7 +5,7 @@ namespace PlatformPlatform.AccountManagement.Features.Users.Shared; -public sealed class AvatarUpdater(IUserRepository userRepository, [FromKeyedServices("avatars-storage")] BlobStorageClient blobStorageClient) +public sealed class AvatarUpdater(IUserRepository userRepository, [FromKeyedServices("account-management-storage")] BlobStorageClient blobStorageClient) { private const string ContainerName = "avatars"; diff --git a/cloud-infrastructure/cluster/main-cluster.bicep b/cloud-infrastructure/cluster/main-cluster.bicep index 0202d89c92..2e31d189b8 100644 --- a/cloud-infrastructure/cluster/main-cluster.bicep +++ b/cloud-infrastructure/cluster/main-cluster.bicep @@ -444,7 +444,7 @@ module appGateway '../modules/container-app.bicep' = { value: 'https://${keyVault.outputs.name}${az.environment().suffixes.keyvaultDns}' } { - name: 'AVATARS_STORAGE_URL' + name: 'ACCOUNT_MANAGEMENT_STORAGE_URL' value: 'https://${accountManagementStorageAccountName}.blob.${az.environment().suffixes.storage}' } { From 0188a2ec8efe8395f8365697ecdbbc35af9ead8a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 4 Aug 2025 10:08:28 +0200 Subject: [PATCH 2/6] Create /logos endpoint to fetch tenant logs from blob storage using shared access signatures --- .../SharedAccessSignatureRequestTransform.cs | 4 ++++ application/AppGateway/appsettings.json | 12 ++++++++++++ application/AppHost/Program.cs | 1 + cloud-infrastructure/cluster/main-cluster.bicep | 4 ++++ 4 files changed, 21 insertions(+) diff --git a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs index c5fcc44277..5d6ef1e9c4 100644 --- a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs +++ b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs @@ -13,6 +13,10 @@ public override ValueTask ApplyAsync(RequestTransformContext context) { containerName = "avatars"; } + else if (context.Path.StartsWithSegments("/logos")) + { + containerName = "logos"; + } else { return ValueTask.CompletedTask; diff --git a/application/AppGateway/appsettings.json b/application/AppGateway/appsettings.json index b49e85f954..624288e398 100644 --- a/application/AppGateway/appsettings.json +++ b/application/AppGateway/appsettings.json @@ -79,6 +79,18 @@ } ] }, + "logos": { + "ClusterId": "account-management-storage", + "Match": { + "Path": "/logos/{**catch-all}" + }, + "Transforms": [ + { + "ResponseHeader": "Cache-Control", + "Set": "public, max-age=2592000, immutable" + } + ] + }, "account-management-api": { "ClusterId": "account-management-api", "Match": { diff --git a/application/AppHost/Program.cs b/application/AppHost/Program.cs index fc57b4f931..0891757c94 100644 --- a/application/AppHost/Program.cs +++ b/application/AppHost/Program.cs @@ -45,6 +45,7 @@ .WithUrlForEndpoint("http", u => u.DisplayText = "Read mail here"); CreateBlobContainer("avatars"); +CreateBlobContainer("logos"); var frontendBuild = builder .AddNpmApp("frontend-build", "../") diff --git a/cloud-infrastructure/cluster/main-cluster.bicep b/cloud-infrastructure/cluster/main-cluster.bicep index 2e31d189b8..f71cd01c56 100644 --- a/cloud-infrastructure/cluster/main-cluster.bicep +++ b/cloud-infrastructure/cluster/main-cluster.bicep @@ -175,6 +175,10 @@ module accountManagementStorageAccount '../modules/storage-account.bicep' = { name: 'avatars' publicAccess: 'None' } + { + name: 'logos' + publicAccess: 'None' + } ] } dependsOn: [accountManagementIdentity] From 98ef478475ec3b6df58d6c5da9e2128831d7e94e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 4 Aug 2025 10:27:56 +0200 Subject: [PATCH 3/6] Create backend for uploading logos and configure AppGateway to proxy public links to blob storage --- .../ManagedIdentityTransform.cs | 8 +- .../Api/Endpoints/TenantEndpoints.cs | 8 ++ .../account-management/Core/Configuration.cs | 3 + .../20250804001944_AddTenantLogo.cs | 19 ++++ .../Core/Features/TelemetryEvents.cs | 6 ++ .../Tenants/Commands/RemoveTenantLogo.cs | 36 ++++++++ .../Tenants/Commands/UpdateTenantLogo.cs | 90 +++++++++++++++++++ .../Core/Features/Tenants/Domain/Tenant.cs | 15 ++++ .../Tenants/Domain/TenantConfiguration.cs | 2 + .../Tenants/Queries/GetCurrentTenant.cs | 9 +- .../Features/Tenants/TenantMapsterConfig.cs | 15 ++++ .../Features/Users/Shared/UserInfoFactory.cs | 1 + .../Tests/Tenants/GetCurrentTenantTests.cs | 5 +- .../Tests/Tenants/RemoveTenantLogoTests.cs | 37 ++++++++ .../Tests/Tenants/UpdateTenantLogoTests.cs | 35 ++++++++ .../shared/lib/api/AccountManagement.Api.json | 60 +++++++++++++ .../TokenGeneration/AccessTokenGenerator.cs | 1 + .../SharedKernel/Authentication/UserInfo.cs | 3 + 18 files changed, 348 insertions(+), 5 deletions(-) create mode 100644 application/account-management/Core/Database/Migrations/20250804001944_AddTenantLogo.cs create mode 100644 application/account-management/Core/Features/Tenants/Commands/RemoveTenantLogo.cs create mode 100644 application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs create mode 100644 application/account-management/Core/Features/Tenants/TenantMapsterConfig.cs create mode 100644 application/account-management/Tests/Tenants/RemoveTenantLogoTests.cs create mode 100644 application/account-management/Tests/Tenants/UpdateTenantLogoTests.cs diff --git a/application/AppGateway/Transformations/ManagedIdentityTransform.cs b/application/AppGateway/Transformations/ManagedIdentityTransform.cs index a1e646837f..3ea7352a7e 100644 --- a/application/AppGateway/Transformations/ManagedIdentityTransform.cs +++ b/application/AppGateway/Transformations/ManagedIdentityTransform.cs @@ -8,7 +8,8 @@ public class ManagedIdentityTransform(TokenCredential credential) { protected override string? GetValue(RequestTransformContext context) { - if (!context.HttpContext.Request.Path.StartsWithSegments("/avatars", StringComparison.OrdinalIgnoreCase)) + if (!context.HttpContext.Request.Path.StartsWithSegments("/avatars", StringComparison.OrdinalIgnoreCase) && + !context.HttpContext.Request.Path.StartsWithSegments("/logos", StringComparison.OrdinalIgnoreCase)) { return null; } @@ -23,6 +24,9 @@ public class ApiVersionHeaderTransform() : RequestHeaderTransform("x-ms-version" { protected override string? GetValue(RequestTransformContext context) { - return !context.HttpContext.Request.Path.StartsWithSegments("/avatars") ? null : "2023-11-03"; + return !context.HttpContext.Request.Path.StartsWithSegments("/avatars") && + !context.HttpContext.Request.Path.StartsWithSegments("/logos") + ? null + : "2023-11-03"; } } diff --git a/application/account-management/Api/Endpoints/TenantEndpoints.cs b/application/account-management/Api/Endpoints/TenantEndpoints.cs index a678c87a6c..09c796ddac 100644 --- a/application/account-management/Api/Endpoints/TenantEndpoints.cs +++ b/application/account-management/Api/Endpoints/TenantEndpoints.cs @@ -22,6 +22,14 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => (await mediator.Send(command)).AddRefreshAuthenticationTokens() ); + group.MapPost("/current/update-logo", async Task (IFormFile file, IMediator mediator) + => await mediator.Send(new UpdateTenantLogoCommand(file.OpenReadStream(), file.ContentType)) + ).DisableAntiforgery(); + + group.MapDelete("/current/remove-logo", async Task (IMediator mediator) + => await mediator.Send(new RemoveTenantLogoCommand()) + ); + routes.MapDelete("/internal-api/account-management/tenants/{id}", async Task (TenantId id, IMediator mediator) => await mediator.Send(new DeleteTenantCommand(id)) ); diff --git a/application/account-management/Core/Configuration.cs b/application/account-management/Core/Configuration.cs index e68611df0b..c3a5146e6a 100644 --- a/application/account-management/Core/Configuration.cs +++ b/application/account-management/Core/Configuration.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.Tenants; using PlatformPlatform.AccountManagement.Features.Users.Shared; using PlatformPlatform.AccountManagement.Integrations.Gravatar; using PlatformPlatform.SharedKernel.Configuration; @@ -28,6 +29,8 @@ public static IServiceCollection AddAccountManagementServices(this IServiceColle } ); + TenantMapsterConfig.Configure(); + return services .AddSharedServices(Assembly) .AddScoped() diff --git a/application/account-management/Core/Database/Migrations/20250804001944_AddTenantLogo.cs b/application/account-management/Core/Database/Migrations/20250804001944_AddTenantLogo.cs new file mode 100644 index 0000000000..348cd26c27 --- /dev/null +++ b/application/account-management/Core/Database/Migrations/20250804001944_AddTenantLogo.cs @@ -0,0 +1,19 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace PlatformPlatform.AccountManagement.Database.Migrations; + +[DbContext(typeof(AccountManagementDbContext))] +[Migration("20250804001944_AddTenantLogo")] +public sealed class AddTenantLogo : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Logo", + table: "Tenants", + type: "varchar(150)", + nullable: false, + defaultValue: "{}"); + } +} \ No newline at end of file diff --git a/application/account-management/Core/Features/TelemetryEvents.cs b/application/account-management/Core/Features/TelemetryEvents.cs index eb6efe9e1f..d255597053 100644 --- a/application/account-management/Core/Features/TelemetryEvents.cs +++ b/application/account-management/Core/Features/TelemetryEvents.cs @@ -51,6 +51,12 @@ public sealed class TenantCreated(TenantId tenantId, TenantState state) public sealed class TenantDeleted(TenantId tenantId, TenantState tenantState, int usersDeleted) : TelemetryEvent(("tenant_id", tenantId), ("tenant_state", tenantState), ("users_deleted", usersDeleted)); +public sealed class TenantLogoRemoved + : TelemetryEvent; + +public sealed class TenantLogoUpdated(string contentType, long size) + : TelemetryEvent(("content_type", contentType), ("size", size)); + public sealed class TenantUpdated : TelemetryEvent; diff --git a/application/account-management/Core/Features/Tenants/Commands/RemoveTenantLogo.cs b/application/account-management/Core/Features/Tenants/Commands/RemoveTenantLogo.cs new file mode 100644 index 0000000000..8bec7b0cd4 --- /dev/null +++ b/application/account-management/Core/Features/Tenants/Commands/RemoveTenantLogo.cs @@ -0,0 +1,36 @@ +using JetBrains.Annotations; +using PlatformPlatform.AccountManagement.Features.Tenants.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.Tenants.Commands; + +[PublicAPI] +public sealed record RemoveTenantLogoCommand : ICommand, IRequest; + +public sealed class RemoveTenantLogoHandler( + ITenantRepository tenantRepository, + IExecutionContext executionContext, + ITelemetryEventsCollector events +) + : IRequestHandler +{ + public async Task Handle(RemoveTenantLogoCommand command, CancellationToken cancellationToken) + { + if (executionContext.UserInfo.Role != UserRole.Owner.ToString()) + { + return Result.Forbidden("Only owners are allowed to remove tenant logo."); + } + + var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken); + + tenant.RemoveLogo(); + tenantRepository.Update(tenant); + + events.CollectEvent(new TenantLogoRemoved()); + + return Result.Success(); + } +} diff --git a/application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs b/application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs new file mode 100644 index 0000000000..b037b779fc --- /dev/null +++ b/application/account-management/Core/Features/Tenants/Commands/UpdateTenantLogo.cs @@ -0,0 +1,90 @@ +using System.Security.Cryptography; +using FluentValidation; +using JetBrains.Annotations; +using Microsoft.Extensions.DependencyInjection; +using PlatformPlatform.AccountManagement.Features.Tenants.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.ExecutionContext; +using PlatformPlatform.SharedKernel.Integrations.BlobStorage; +using PlatformPlatform.SharedKernel.Telemetry; + +namespace PlatformPlatform.AccountManagement.Features.Tenants.Commands; + +[PublicAPI] +public sealed record UpdateTenantLogoCommand(Stream FileStream, string ContentType) : ICommand, IRequest; + +public sealed class UpdateTenantLogoValidator : AbstractValidator +{ + public UpdateTenantLogoValidator() + { + RuleFor(x => x.ContentType) + .Must(x => x is "image/jpeg" or "image/png" or "image/gif" or "image/webp" or "image/svg+xml") + .WithMessage(_ => "Image must be of type JPEG, PNG, GIF, WebP, or SVG."); + + RuleFor(x => x.FileStream.Length) + .LessThanOrEqualTo(2 * 1024 * 1024) + .WithMessage(_ => "Image must be smaller than 2 MB"); + } +} + +public sealed class UpdateTenantLogoHandler( + ITenantRepository tenantRepository, + IExecutionContext executionContext, + [FromKeyedServices("account-management-storage")] + BlobStorageClient blobStorageClient, + ITelemetryEventsCollector events +) + : IRequestHandler +{ + private const string ContainerName = "logos"; + + public async Task Handle(UpdateTenantLogoCommand command, CancellationToken cancellationToken) + { + if (executionContext.UserInfo.Role != UserRole.Owner.ToString()) + { + return Result.Forbidden("Only owners are allowed to update tenant logo."); + } + + var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken); + + var fileHash = await GetFileHash(command.FileStream, cancellationToken); + var fileExtension = GetFileExtension(command.ContentType); + var blobName = $"{tenant.Id}/logo/{fileHash}.{fileExtension}"; + var logoUrl = $"/{ContainerName}/{blobName}"; + + if (tenant.Logo.Url != logoUrl) + { + await blobStorageClient.UploadAsync(ContainerName, blobName, command.ContentType, command.FileStream, cancellationToken); + + tenant.UpdateLogo(logoUrl); + tenantRepository.Update(tenant); + + events.CollectEvent(new TenantLogoUpdated(command.ContentType, command.FileStream.Length)); + } + + return Result.Success(); + } + + private static async Task GetFileHash(Stream fileStream, CancellationToken cancellationToken) + { + using var sha1 = SHA1.Create(); + var hashBytes = await sha1.ComputeHashAsync(fileStream, cancellationToken); + fileStream.Position = 0; + // This just needs to be unique for one tenant, who likely will ever only have one logo, so 16 chars should be enough + return BitConverter.ToString(hashBytes).Replace("-", "")[..16].ToUpper(); + } + + private static string GetFileExtension(string contentType) + { + return contentType switch + { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + "image/svg+xml" => "svg", + _ => throw new InvalidOperationException($"Unsupported content type: {contentType}") + }; + } +} diff --git a/application/account-management/Core/Features/Tenants/Domain/Tenant.cs b/application/account-management/Core/Features/Tenants/Domain/Tenant.cs index 8deaa346d9..27f26453b1 100644 --- a/application/account-management/Core/Features/Tenants/Domain/Tenant.cs +++ b/application/account-management/Core/Features/Tenants/Domain/Tenant.cs @@ -7,12 +7,15 @@ public sealed class Tenant : AggregateRoot private Tenant() : base(TenantId.NewId()) { State = TenantState.Trial; + Logo = new Logo(); } public string Name { get; private set; } = string.Empty; public TenantState State { get; private set; } + public Logo Logo { get; private set; } + public static Tenant Create(string email) { var tenant = new Tenant(); @@ -24,4 +27,16 @@ public void Update(string tenantName) { Name = tenantName; } + + public void UpdateLogo(string logoUrl) + { + Logo = new Logo(logoUrl, Logo.Version + 1); + } + + public void RemoveLogo() + { + Logo = new Logo(Version: Logo.Version); + } } + +public sealed record Logo(string? Url = null, int Version = 0); diff --git a/application/account-management/Core/Features/Tenants/Domain/TenantConfiguration.cs b/application/account-management/Core/Features/Tenants/Domain/TenantConfiguration.cs index 882ef9d5ea..69f65722d2 100644 --- a/application/account-management/Core/Features/Tenants/Domain/TenantConfiguration.cs +++ b/application/account-management/Core/Features/Tenants/Domain/TenantConfiguration.cs @@ -10,5 +10,7 @@ public sealed class TenantConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.MapStronglyTypedLongId(t => t.Id); + + builder.OwnsOne(t => t.Logo, b => b.ToJson()); } } diff --git a/application/account-management/Core/Features/Tenants/Queries/GetCurrentTenant.cs b/application/account-management/Core/Features/Tenants/Queries/GetCurrentTenant.cs index 40ccd6ffd9..9af2ac4aab 100644 --- a/application/account-management/Core/Features/Tenants/Queries/GetCurrentTenant.cs +++ b/application/account-management/Core/Features/Tenants/Queries/GetCurrentTenant.cs @@ -10,7 +10,14 @@ namespace PlatformPlatform.AccountManagement.Features.Tenants.Queries; public sealed record GetCurrentTenantQuery : IRequest>; [PublicAPI] -public sealed record TenantResponse(TenantId Id, DateTimeOffset CreatedAt, DateTimeOffset? ModifiedAt, string Name, TenantState State); +public sealed record TenantResponse( + TenantId Id, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + string Name, + TenantState State, + string? LogoUrl +); public sealed class GetTenantHandler(ITenantRepository tenantRepository) : IRequestHandler> diff --git a/application/account-management/Core/Features/Tenants/TenantMapsterConfig.cs b/application/account-management/Core/Features/Tenants/TenantMapsterConfig.cs new file mode 100644 index 0000000000..55e0e47bc1 --- /dev/null +++ b/application/account-management/Core/Features/Tenants/TenantMapsterConfig.cs @@ -0,0 +1,15 @@ +using Mapster; +using PlatformPlatform.AccountManagement.Features.Tenants.Domain; +using PlatformPlatform.AccountManagement.Features.Tenants.Queries; + +namespace PlatformPlatform.AccountManagement.Features.Tenants; + +public static class TenantMapsterConfig +{ + public static void Configure() + { + TypeAdapterConfig + .NewConfig() + .Map(dest => dest.LogoUrl, src => src.Logo.Url); + } +} diff --git a/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs index 1945a393e2..538c1bff8e 100644 --- a/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs @@ -32,6 +32,7 @@ public async Task CreateUserInfoAsync(User user, CancellationToken can Title = user.Title, AvatarUrl = user.Avatar.Url, TenantName = tenant?.Name, + TenantLogoUrl = tenant?.Logo.Url, Locale = user.Locale, IsInternalUser = user.IsInternalUser }; diff --git a/application/account-management/Tests/Tenants/GetCurrentTenantTests.cs b/application/account-management/Tests/Tenants/GetCurrentTenantTests.cs index dce6dff0e5..5254348d4c 100644 --- a/application/account-management/Tests/Tenants/GetCurrentTenantTests.cs +++ b/application/account-management/Tests/Tenants/GetCurrentTenantTests.cs @@ -26,9 +26,10 @@ public async Task GetCurrentTenant_WhenTenantExists_ShouldReturnTenantWithValidC 'createdAt': {'type': 'string', 'format': 'date-time'}, 'modifiedAt': {'type': ['null', 'string'], 'format': 'date-time'}, 'name': {'type': 'string', 'minLength': 0, 'maxLength': 30}, - 'state': {'type': 'string', 'minLength': 1, 'maxLength':20} + 'state': {'type': 'string', 'minLength': 1, 'maxLength':20}, + 'logoUrl': {'type': ['null', 'string']} }, - 'required': ['id', 'createdAt', 'modifiedAt', 'name', 'state'], + 'required': ['id', 'createdAt', 'modifiedAt', 'name', 'state', 'logoUrl'], 'additionalProperties': false } """ diff --git a/application/account-management/Tests/Tenants/RemoveTenantLogoTests.cs b/application/account-management/Tests/Tenants/RemoveTenantLogoTests.cs new file mode 100644 index 0000000000..f10f6997c0 --- /dev/null +++ b/application/account-management/Tests/Tenants/RemoveTenantLogoTests.cs @@ -0,0 +1,37 @@ +using System.Net; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features; +using PlatformPlatform.SharedKernel.Tests; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Tenants; + +public sealed class RemoveTenantLogoTests : EndpointBaseTest +{ + [Fact] + public async Task RemoveTenantLogo_WhenOwnerUser_ShouldSucceed() + { + // Arrange + TelemetryEventsCollectorSpy.Reset(); + + // Act + var response = await AuthenticatedOwnerHttpClient.DeleteAsync("/api/account-management/tenants/current/remove-logo"); + + // Assert + response.ShouldHaveEmptyHeaderAndLocationOnSuccess(); + + TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1); + TelemetryEventsCollectorSpy.CollectedEvents[0].Should().BeOfType(); + } + + [Fact] + public async Task RemoveTenantLogo_WhenMemberUser_ShouldReturnForbidden() + { + // Act + var response = await AuthenticatedMemberHttpClient.DeleteAsync("/api/account-management/tenants/current/remove-logo"); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, "Only owners are allowed to remove tenant logo."); + } +} diff --git a/application/account-management/Tests/Tenants/UpdateTenantLogoTests.cs b/application/account-management/Tests/Tenants/UpdateTenantLogoTests.cs new file mode 100644 index 0000000000..86a1c27e4f --- /dev/null +++ b/application/account-management/Tests/Tenants/UpdateTenantLogoTests.cs @@ -0,0 +1,35 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.SharedKernel.Tests; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Tenants; + +public sealed class UpdateTenantLogoTests : EndpointBaseTest +{ + public UpdateTenantLogoTests() + { + // Set up blob storage URL for tests - tests won't actually upload files + Environment.SetEnvironmentVariable("BLOB_STORAGE_URL", "https://test.blob.core.windows.net"); + } + + [Fact] + public async Task UpdateTenantLogo_WhenMemberUser_ShouldReturnForbidden() + { + // Arrange + var svgContent = ""; + var fileContent = new ByteArrayContent(Encoding.UTF8.GetBytes(svgContent)); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/svg+xml"); + + var formData = new MultipartFormDataContent(); + formData.Add(fileContent, "file", "logo.svg"); + + // Act + var response = await AuthenticatedMemberHttpClient.PostAsync("/api/account-management/tenants/current/update-logo", formData); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, "Only owners are allowed to update tenant logo."); + } +} diff --git a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index c55e345606..a693680dba 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -369,6 +369,62 @@ } } }, + "/api/account-management/tenants/current/update-logo": { + "post": { + "tags": [ + "Tenants" + ], + "operationId": "PostApiAccountManagementTenantsCurrentUpdateLogo", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary", + "nullable": true + } + } + } + } + } + }, + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, + "/api/account-management/tenants/current/remove-logo": { + "delete": { + "tags": [ + "Tenants" + ], + "operationId": "DeleteApiAccountManagementTenantsCurrentRemoveLogo", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + } + } + } + }, "/api/account-management/users": { "get": { "tags": [ @@ -1081,6 +1137,10 @@ }, "state": { "$ref": "#/components/schemas/TenantState" + }, + "logoUrl": { + "type": "string", + "nullable": true } } }, diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs index cb75a76be7..8ec2131380 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs @@ -23,6 +23,7 @@ public string Generate(UserInfo userInfo) new Claim(ClaimTypes.Role, userInfo.Role!), new Claim("tenant_id", userInfo.TenantId!.ToString()), new Claim("tenant_name", userInfo.TenantName ?? string.Empty), + new Claim("tenant_logo_url", userInfo.TenantLogoUrl ?? string.Empty), new Claim("title", userInfo.Title ?? string.Empty), new Claim("avatar_url", userInfo.AvatarUrl ?? string.Empty), new Claim("locale", userInfo.Locale!) diff --git a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs index 44f017dc06..8be2352e2f 100644 --- a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs +++ b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs @@ -45,6 +45,8 @@ public class UserInfo public string? TenantName { get; init; } + public string? TenantLogoUrl { get; init; } + public bool IsInternalUser { get; init; } public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale) @@ -74,6 +76,7 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale) Title = user.FindFirstValue("title"), AvatarUrl = user.FindFirstValue("avatar_url"), TenantName = user.FindFirstValue("tenant_name"), + TenantLogoUrl = user.FindFirstValue("tenant_logo_url"), Locale = GetValidLocale(user.FindFirstValue("locale")), IsInternalUser = IsInternalUserEmail(email) }; From b146e86c1f95e08326a7e163e2fed0637bcc482a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 4 Aug 2025 10:29:38 +0200 Subject: [PATCH 4/6] Add frontend for uploading and removing tenant logo --- .../common/UserProfileModal.tsx | 8 +- .../sideMenu/FederatedSideMenu.tsx | 1 + .../WebApp/routes/admin/account/index.tsx | 279 ++++++++++++++++-- .../shared/translations/locale/da-DK.po | 24 +- .../shared/translations/locale/en-US.po | 24 +- .../shared/translations/locale/nl-NL.po | 24 +- .../WebApp/tests/e2e/signup-flows.spec.ts | 2 +- .../shared-webapp/build/environment.d.ts | 4 + .../infrastructure/http/queryClient.ts | 4 +- .../shared-webapp/ui/components/SideMenu.tsx | 28 +- .../ui/components/TenantLogo.tsx | 95 ++++++ .../shared-webapp/ui/types/FileUpload.ts | 9 + 12 files changed, 458 insertions(+), 44 deletions(-) create mode 100644 application/shared-webapp/ui/components/TenantLogo.tsx create mode 100644 application/shared-webapp/ui/types/FileUpload.ts diff --git a/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx index 2e132c6af5..76319b9b20 100644 --- a/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx +++ b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx @@ -11,6 +11,7 @@ import { Modal } from "@repo/ui/components/Modal"; import { TextField } from "@repo/ui/components/TextField"; import { toastQueue } from "@repo/ui/components/Toast"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; +import type { FileUploadMutation } from "@repo/ui/types/FileUpload"; import { useMutation } from "@tanstack/react-query"; import { CameraIcon, MailIcon, Trash2Icon, XIcon } from "lucide-react"; import { useCallback, useContext, useEffect, useRef, useState } from "react"; @@ -66,17 +67,20 @@ export default function UserProfileModal({ isOpen, onOpenChange }: Readonly({ mutationFn: async (data) => { + // Handle avatar changes first if (selectedAvatarFile) { const formData = new FormData(); formData.append("file", selectedAvatarFile); - // biome-ignore lint/suspicious/noExplicitAny: The client does not support typed file uploads, see https://github.com/openapi-ts/openapi-typescript/issues/1214 - await updateAvatarMutation.mutateAsync({ body: formData as any }); + await (updateAvatarMutation as unknown as FileUploadMutation).mutateAsync({ body: formData }); } else if (removeAvatarFlag) { await removeAvatarMutation.mutateAsync({}); setRemoveAvatarFlag(false); } + // Update user profile data await updateCurrentUserMutation.mutateAsync(data); + + // Refetch to get the updated user data including new avatar URL const { data: updatedUser } = await refetch(); if (updatedUser) { updateUserInfo(updatedUser); diff --git a/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx b/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx index 199ec0a8bd..8ab1474d7f 100644 --- a/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx +++ b/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx @@ -22,6 +22,7 @@ export default function FederatedSideMenu({ currentSystem }: Readonly setIsProfileModalOpen(true)} />} tenantName={userInfo?.tenantName} + tenantLogoUrl={userInfo?.tenantLogoUrl ?? undefined} > diff --git a/application/account-management/WebApp/routes/admin/account/index.tsx b/application/account-management/WebApp/routes/admin/account/index.tsx index 344bf4ff64..57a81f37e6 100644 --- a/application/account-management/WebApp/routes/admin/account/index.tsx +++ b/application/account-management/WebApp/routes/admin/account/index.tsx @@ -1,6 +1,5 @@ import FederatedSideMenu from "@/federated-modules/sideMenu/FederatedSideMenu"; import { TopMenu } from "@/shared/components/topMenu"; -import logoWrap from "@/shared/images/logo-wrap.svg"; import { UserRole, api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; @@ -8,24 +7,258 @@ import { AppLayout } from "@repo/ui/components/AppLayout"; import { Breadcrumb } from "@repo/ui/components/Breadcrumbs"; import { Button } from "@repo/ui/components/Button"; import { Form } from "@repo/ui/components/Form"; +import { Menu, MenuItem, MenuSeparator, MenuTrigger } from "@repo/ui/components/Menu"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; import { TextField } from "@repo/ui/components/TextField"; import { toastQueue } from "@repo/ui/components/Toast"; import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; +import type { FileUploadMutation } from "@repo/ui/types/FileUpload"; +import { useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; -import { Separator } from "react-aria-components"; +import { CameraIcon, Trash2, Trash2Icon } from "lucide-react"; +import type React from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { FileTrigger, Label, Separator } from "react-aria-components"; import DeleteAccountConfirmation from "./-components/DeleteAccountConfirmation"; export const Route = createFileRoute("/admin/account/")({ component: AccountSettings }); +const MAX_FILE_SIZE = 1024 * 1024; // 1MB in bytes +const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"]; // Align with backend + +// Helper function for file validation +function validateLogoFile(file: File): boolean { + if (!ALLOWED_FILE_TYPES.includes(file.type)) { + alert(t`Please select a JPEG, PNG, GIF, WebP, or SVG image.`); + return false; + } + + if (file.size > MAX_FILE_SIZE) { + alert(t`Image must be smaller than 1 MB.`); + return false; + } + + return true; +} + +// Custom hook for managing logo state +function useLogoManagement( + updateTenantLogoMutation: FileUploadMutation, + removeTenantLogoMutation: { mutateAsync: (params: Record) => Promise }, + refetchTenant: () => void, + queryClient: ReturnType, + logoFileInputRef: React.RefObject +) { + const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); + const [logoMenuOpen, setLogoMenuOpen] = useState(false); + const [shouldClearInput, setShouldClearInput] = useState(false); + + // Handle clearing the input when removal is successful + useEffect(() => { + if (shouldClearInput && logoFileInputRef.current) { + logoFileInputRef.current.value = ""; + setShouldClearInput(false); + } + }, [shouldClearInput, logoFileInputRef]); + + const handleLogoUpload = useCallback( + async (files: FileList | null) => { + const file = files?.[0]; + if (!file || !validateLogoFile(file)) { + return; + } + + // Create preview + const objectUrl = URL.createObjectURL(file); + setLogoPreviewUrl(objectUrl); + + // Upload immediately + const formData = new FormData(); + formData.append("file", file); + await updateTenantLogoMutation.mutateAsync({ body: formData }); + + // Clean up preview after successful upload + URL.revokeObjectURL(objectUrl); + setLogoPreviewUrl(null); + + // Invalidate all queries to refresh UI + await queryClient.invalidateQueries(); + refetchTenant(); + + toastQueue.add({ + title: t`Success`, + description: t`Logo uploaded successfully`, + variant: "success" + }); + }, + [updateTenantLogoMutation, refetchTenant, queryClient] + ); + + const handleLogoRemoval = useCallback(async () => { + await removeTenantLogoMutation.mutateAsync({}); + + // Invalidate all queries to refresh UI + await queryClient.invalidateQueries(); + refetchTenant(); + + // Trigger input clearing via state + setShouldClearInput(true); + + toastQueue.add({ + title: t`Success`, + description: t`Logo removed successfully`, + variant: "success" + }); + }, [removeTenantLogoMutation, refetchTenant, queryClient]); + + const cleanupLogoPreview = useCallback(() => { + if (logoPreviewUrl) { + URL.revokeObjectURL(logoPreviewUrl); + setLogoPreviewUrl(null); + } + }, [logoPreviewUrl]); + + return { + logoPreviewUrl, + logoMenuOpen, + setLogoMenuOpen, + handleLogoUpload, + handleLogoRemoval, + cleanupLogoPreview + }; +} + +// Logo management component +function LogoSection({ + tenant, + logoPreviewUrl, + logoMenuOpen, + setLogoMenuOpen, + handleLogoUpload, + handleLogoRemoval, + logoFileInputRef, + isOwner +}: Readonly<{ + tenant: { logoUrl?: string | null; name?: string } | null | undefined; + logoPreviewUrl: string | null; + logoMenuOpen: boolean; + setLogoMenuOpen: (open: boolean) => void; + handleLogoUpload: (files: FileList | null) => void; + handleLogoRemoval: () => void; + logoFileInputRef: React.RefObject; + isOwner: boolean; +}>) { + return ( + <> + { + setLogoMenuOpen(false); + handleLogoUpload(files); + }} + acceptedFileTypes={ALLOWED_FILE_TYPES} + /> + + + + + + + { + logoFileInputRef.current?.click(); + }} + > + + Upload logo + + {(tenant?.logoUrl || logoPreviewUrl) && ( + <> + + { + setLogoMenuOpen(false); + handleLogoRemoval(); + }} + > + + + Remove logo + + + + )} + + + + ); +} + +// Danger zone component +function DangerZone({ setIsDeleteModalOpen }: { setIsDeleteModalOpen: (open: boolean) => void }) { + return ( +
+

+ Danger zone +

+ +
+

+ Delete your account and all data. This action is irreversible—proceed with caution. +

+ + +
+
+ ); +} + export function AccountSettings() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const { data: tenant, isLoading: tenantLoading } = api.useQuery("get", "/api/account-management/tenants/current"); + const logoFileInputRef = useRef(null); + const queryClient = useQueryClient(); + + const { + data: tenant, + isLoading: tenantLoading, + refetch: refetchTenant + } = api.useQuery("get", "/api/account-management/tenants/current"); const { data: currentUser, isLoading: userLoading } = api.useQuery("get", "/api/account-management/users/me"); const updateCurrentTenantMutation = api.useMutation("put", "/api/account-management/tenants/current"); + const updateTenantLogoMutation = api.useMutation("post", "/api/account-management/tenants/current/update-logo"); + const removeTenantLogoMutation = api.useMutation("delete", "/api/account-management/tenants/current/remove-logo"); + + const { logoPreviewUrl, logoMenuOpen, setLogoMenuOpen, handleLogoUpload, handleLogoRemoval } = useLogoManagement( + updateTenantLogoMutation as unknown as FileUploadMutation, + removeTenantLogoMutation, + refetchTenant, + queryClient, + logoFileInputRef + ); const isOwner = currentUser?.role === UserRole.Owner; @@ -33,11 +266,12 @@ export function AccountSettings() { if (updateCurrentTenantMutation.isSuccess) { toastQueue.add({ title: t`Success`, - description: t`Account updated successfully`, + description: t`Account name updated successfully`, variant: "success" }); + refetchTenant(); } - }, [updateCurrentTenantMutation.isSuccess]); + }, [updateCurrentTenantMutation.isSuccess, refetchTenant]); if (tenantLoading || userLoading) { return null; @@ -72,9 +306,17 @@ export function AccountSettings() { - Logo + - {t`Logo`} - {isOwner && ( -
-

- Danger zone -

- -
-

- Delete your account and all data. This action is irreversible—proceed with caution. -

- - -
-
- )} + {isOwner && } diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 7ec1b733a9..db2f35dbca 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -29,12 +29,12 @@ msgstr "Kontoinformation" msgid "Account name" msgstr "Kontonavn" +msgid "Account name updated successfully" +msgstr "Kontonavn opdateret succesfuldt" + msgid "Account settings" msgstr "Kontoindstillinger" -msgid "Account updated successfully" -msgstr "Konto opdateret succesfuldt" - msgid "Active" msgstr "Aktiv" @@ -97,6 +97,9 @@ msgstr "Annuller" msgid "Change language" msgstr "Skift sprog" +msgid "Change logo" +msgstr "Skift logo" + msgid "Change profile picture" msgstr "Skift profilbillede" @@ -255,6 +258,12 @@ msgstr "Login-bekræftelseskode" msgid "Logo" msgstr "Logo" +msgid "Logo removed successfully" +msgstr "Logo fjernet succesfuldt" + +msgid "Logo uploaded successfully" +msgstr "Logo uploadet succesfuldt" + msgid "Manage your account here." msgstr "Administrer din konto her." @@ -312,6 +321,9 @@ msgstr "Tjek din e-mail for en bekræftelseskode sendt til <0>{email}" msgid "Please select a JPEG, PNG, GIF, or WebP image." msgstr "Vælg et JPEG-, PNG-, GIF- eller WebP-billede." +msgid "Please select a JPEG, PNG, GIF, WebP, or SVG image." +msgstr "Vælg et JPEG-, PNG-, GIF-, WebP- eller SVG-billede." + msgid "Powered by" msgstr "Powered by" @@ -333,6 +345,9 @@ msgstr "Profil opdateret succesfuldt" msgid "Region" msgstr "Region" +msgid "Remove logo" +msgstr "Fjern logo" + msgid "Remove profile picture" msgstr "Fjern profilbillede" @@ -412,6 +427,9 @@ msgstr "Prøv igen" msgid "Update your profile picture and personal details here." msgstr "Opdater dit profilbillede og personlige oplysninger her." +msgid "Upload logo" +msgstr "Upload logo" + msgid "Upload profile picture" msgstr "Upload profilbillede" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 0310e62d20..1760b79b91 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -29,12 +29,12 @@ msgstr "Account information" msgid "Account name" msgstr "Account name" +msgid "Account name updated successfully" +msgstr "Account name updated successfully" + msgid "Account settings" msgstr "Account settings" -msgid "Account updated successfully" -msgstr "Account updated successfully" - msgid "Active" msgstr "Active" @@ -97,6 +97,9 @@ msgstr "Cancel" msgid "Change language" msgstr "Change language" +msgid "Change logo" +msgstr "Change logo" + msgid "Change profile picture" msgstr "Change profile picture" @@ -255,6 +258,12 @@ msgstr "Login verification code" msgid "Logo" msgstr "Logo" +msgid "Logo removed successfully" +msgstr "Logo removed successfully" + +msgid "Logo uploaded successfully" +msgstr "Logo uploaded successfully" + msgid "Manage your account here." msgstr "Manage your account here." @@ -312,6 +321,9 @@ msgstr "Please check your email for a verification code sent to <0>{email}" msgid "Please select a JPEG, PNG, GIF, or WebP image." msgstr "Please select a JPEG, PNG, GIF, or WebP image." +msgid "Please select a JPEG, PNG, GIF, WebP, or SVG image." +msgstr "Please select a JPEG, PNG, GIF, WebP, or SVG image." + msgid "Powered by" msgstr "Powered by" @@ -333,6 +345,9 @@ msgstr "Profile updated successfully" msgid "Region" msgstr "Region" +msgid "Remove logo" +msgstr "Remove logo" + msgid "Remove profile picture" msgstr "Remove profile picture" @@ -412,6 +427,9 @@ msgstr "Try again" msgid "Update your profile picture and personal details here." msgstr "Update your profile picture and personal details here." +msgid "Upload logo" +msgstr "Upload logo" + msgid "Upload profile picture" msgstr "Upload profile picture" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 3888ed7e1f..57e757b166 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -29,12 +29,12 @@ msgstr "Accountinformatie" msgid "Account name" msgstr "Accountnaam" +msgid "Account name updated successfully" +msgstr "Accountnaam succesvol bijgewerkt" + msgid "Account settings" msgstr "Accountinstellingen" -msgid "Account updated successfully" -msgstr "Account succesvol bijgewerkt" - msgid "Active" msgstr "Actief" @@ -97,6 +97,9 @@ msgstr "Annuleren" msgid "Change language" msgstr "Taal wijzigen" +msgid "Change logo" +msgstr "Logo wijzigen" + msgid "Change profile picture" msgstr "Profielfoto wijzigen" @@ -255,6 +258,12 @@ msgstr "Verificatiecode voor inloggen" msgid "Logo" msgstr "Logo" +msgid "Logo removed successfully" +msgstr "Logo succesvol verwijderd" + +msgid "Logo uploaded successfully" +msgstr "Logo succesvol geüpload" + msgid "Manage your account here." msgstr "Beheer je account hier." @@ -312,6 +321,9 @@ msgstr "Controleer je e-mail voor een verificatiecode verzonden naar <0>{email}< msgid "Please select a JPEG, PNG, GIF, or WebP image." msgstr "Selecteer een JPEG-, PNG-, GIF- of WebP-afbeelding." +msgid "Please select a JPEG, PNG, GIF, WebP, or SVG image." +msgstr "Selecteer een JPEG-, PNG-, GIF-, WebP- of SVG-afbeelding." + msgid "Powered by" msgstr "Powered by" @@ -333,6 +345,9 @@ msgstr "Profiel succesvol bijgewerkt" msgid "Region" msgstr "Regio" +msgid "Remove logo" +msgstr "Logo verwijderen" + msgid "Remove profile picture" msgstr "Profielfoto verwijderen" @@ -412,6 +427,9 @@ msgstr "Probeer opnieuw" msgid "Update your profile picture and personal details here." msgstr "Werk hier je profielfoto en persoonlijke gegevens bij." +msgid "Upload logo" +msgstr "Logo uploaden" + msgid "Upload profile picture" msgstr "Profielfoto uploaden" diff --git a/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts index a8cc11a365..da90b3687a 100644 --- a/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts +++ b/application/account-management/WebApp/tests/e2e/signup-flows.spec.ts @@ -176,7 +176,7 @@ test.describe("@smoke", () => { await page.getByRole("button", { name: "Save changes" }).focus(); await page.getByRole("button", { name: "Save changes" }).click(); - await expectToastMessage(testContext, 200, "Account updated successfully"); + await expectToastMessage(testContext, 200, "Account name updated successfully"); })(); await step("Update user profile title & verify successful profile update")(async () => { diff --git a/application/shared-webapp/build/environment.d.ts b/application/shared-webapp/build/environment.d.ts index 8bd9d5ce57..2a5064fcb8 100644 --- a/application/shared-webapp/build/environment.d.ts +++ b/application/shared-webapp/build/environment.d.ts @@ -84,6 +84,10 @@ export declare global { * Tenant name **/ tenantName?: string; + /** + * Tenant logo URL + **/ + tenantLogoUrl?: string | null; /** * Is internal user (has access to BackOffice) **/ diff --git a/application/shared-webapp/infrastructure/http/queryClient.ts b/application/shared-webapp/infrastructure/http/queryClient.ts index 34ad644087..ee80969553 100644 --- a/application/shared-webapp/infrastructure/http/queryClient.ts +++ b/application/shared-webapp/infrastructure/http/queryClient.ts @@ -70,7 +70,9 @@ export const queryClient = new QueryClient({ onError: (error: unknown) => { // Validation errors in mutations should be handled by UI const httpError = error as HttpError; - if (httpError.kind === "validation") { + + // Only skip global error handling for validation errors that have kind: "validation" + if (httpError?.kind === "validation") { return; } diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 96310434f2..33d3a53d2d 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -10,6 +10,7 @@ import logoMarkUrl from "../images/logo-mark.svg"; import { MEDIA_QUERIES, SIDE_MENU_DEFAULT_WIDTH, SIDE_MENU_MAX_WIDTH, SIDE_MENU_MIN_WIDTH } from "../utils/responsive"; import { Button } from "./Button"; import { Link } from "./Link"; +import { TenantLogo } from "./TenantLogo"; import { Tooltip, TooltipTrigger } from "./Tooltip"; import { focusRing } from "./focusRing"; @@ -455,6 +456,7 @@ type SideMenuProps = { mobileMenuAriaLabel: string; topMenuContent?: React.ReactNode; tenantName?: string; + tenantLogoUrl?: string; }; // Helper function to get initial menu width from localStorage @@ -756,7 +758,11 @@ const OverlayBackdrop = ({ closeOverlay }: { closeOverlay: () => void }) => ( ); // Logo and tenant name component -const LogoSection = ({ actualIsCollapsed, tenantName }: { actualIsCollapsed: boolean; tenantName?: string }) => ( +const LogoSection = ({ + actualIsCollapsed, + tenantName, + tenantLogoUrl +}: { actualIsCollapsed: boolean; tenantName?: string; tenantLogoUrl?: string }) => (
- Logo + {tenantLogoUrl ? ( + + ) : ( + Logo + )} {!actualIsCollapsed && ( void; tenantName?: string; + tenantLogoUrl?: string; toggleButtonRef: React.RefObject; toggleMenu: () => void; hasDraggedRef: React.RefObject; @@ -916,7 +934,7 @@ const MenuNav = ({ {/* Fixed header section with logo */}
- + {/* Toggle button centered on divider, at intersection with topbar border */}
) { const { className, forceCollapsed, overlayMode, isHidden } = useResponsiveMenu(); const sideMenuRef = useRef(null); @@ -1127,6 +1146,7 @@ export function SideMenu({ shouldShowResizeHandle={shouldShowResizeHandle} handleResizeStart={handleResizeStart} tenantName={tenantName} + tenantLogoUrl={tenantLogoUrl} toggleButtonRef={toggleButtonRef} toggleMenu={toggleMenu} hasDraggedRef={hasDraggedRef} diff --git a/application/shared-webapp/ui/components/TenantLogo.tsx b/application/shared-webapp/ui/components/TenantLogo.tsx new file mode 100644 index 0000000000..b74a8d02ee --- /dev/null +++ b/application/shared-webapp/ui/components/TenantLogo.tsx @@ -0,0 +1,95 @@ +import { ImageIcon } from "lucide-react"; +import { type HTMLAttributes, useCallback, useRef, useState } from "react"; +import { tv } from "tailwind-variants"; +import logoMarkUrl from "../images/logo-mark.svg"; + +export type TenantLogoProps = { + /** + * The URL of the logo to display. + */ + logoUrl?: string | null; + /** + * The tenant name to use for initials. + */ + tenantName: string; + /** + * The size of the logo. + */ + size?: "xs" | "lg"; + /** + * Whether the logo should be round or rounded. + */ + isRound?: boolean; + /** + * Additional class names to apply to the logo. + */ + className?: string; +} & HTMLAttributes; + +const backgroundStyles = tv({ + base: "relative inline-flex shrink-0 items-center justify-center overflow-hidden font-semibold uppercase", + variants: { + isRound: { + true: "rounded-full", + false: "rounded-md" + }, + size: { + xs: "h-8 w-8 text-xs", + lg: "h-16 w-16 text-lg" + } + }, + defaultVariants: { + isRound: false, + size: "xs" + } +}); + +export function TenantLogo({ logoUrl, tenantName, size, isRound, className, ...props }: TenantLogoProps) { + const imgRef = useRef(null); + const [imageFailed, setImageFailed] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); + + const handleError = useCallback(() => setImageFailed(true), []); + const handleLoad = useCallback(() => setImageLoaded(true), []); + + const initial = tenantName?.charAt(0).toUpperCase() || ""; + const hasName = tenantName && tenantName.trim().length > 0; + + // Different defaults based on size + // xs (sidebar) - use PlatformPlatform logo mark + // lg (account settings) - use placeholder icon + const renderFallback = () => { + if (hasName) { + return initial; + } + + if (size === "lg") { + // Account settings - show placeholder icon + return ; + } + // Sidebar - show PlatformPlatform logo mark + return PlatformPlatform; + }; + + return ( +
+ {logoUrl && !imageFailed ? ( + <> + Tenant logo + {!imageLoaded && renderFallback()} + + ) : ( + renderFallback() + )} +
+ ); +} diff --git a/application/shared-webapp/ui/types/FileUpload.ts b/application/shared-webapp/ui/types/FileUpload.ts new file mode 100644 index 0000000000..f8bfb13293 --- /dev/null +++ b/application/shared-webapp/ui/types/FileUpload.ts @@ -0,0 +1,9 @@ +/** + * Type-safe wrapper for file uploads to handle the disconnect between + * OpenAPI generated types (which show string) and runtime requirements (which need FormData) + * + * @see https://github.com/openapi-ts/openapi-typescript/issues/1214 + */ +export type FileUploadMutation = { + mutateAsync: (params: { body: FormData }) => Promise; +}; From 66c16d088f5ba5580827b19dce6ce6d49a4d9855 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 6 Aug 2025 23:51:53 +0200 Subject: [PATCH 5/6] Add logic to prevent inviting users if account lacks a name --- .../Features/Users/Commands/InviteUser.cs | 10 +++- .../Authentication/CompleteLoginTests.cs | 5 ++ .../Tests/Users/InviteUserTests.cs | 36 +++++++++++-- .../-components/TenantNameRequiredDialog.tsx | 53 +++++++++++++++++++ .../admin/users/-components/UserToolbar.tsx | 15 +++++- .../shared/translations/locale/da-DK.po | 12 +++++ .../shared/translations/locale/en-US.po | 12 +++++ .../shared/translations/locale/nl-NL.po | 12 +++++ 8 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 application/account-management/WebApp/routes/admin/users/-components/TenantNameRequiredDialog.tsx diff --git a/application/account-management/Core/Features/Users/Commands/InviteUser.cs b/application/account-management/Core/Features/Users/Commands/InviteUser.cs index 5584ae5ccf..67e5c6044a 100644 --- a/application/account-management/Core/Features/Users/Commands/InviteUser.cs +++ b/application/account-management/Core/Features/Users/Commands/InviteUser.cs @@ -1,5 +1,6 @@ using FluentValidation; using JetBrains.Annotations; +using PlatformPlatform.AccountManagement.Features.Tenants.Domain; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.ExecutionContext; @@ -26,6 +27,7 @@ public InviteUserValidator() public sealed class InviteUserHandler( IUserRepository userRepository, + ITenantRepository tenantRepository, IEmailClient emailClient, IExecutionContext executionContext, IMediator mediator, @@ -39,6 +41,12 @@ public async Task Handle(InviteUserCommand command, CancellationToken ca return Result.Forbidden("Only owners are allowed to invite other users."); } + var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(tenant.Name)) + { + return Result.BadRequest("Account name must be set before inviting users."); + } + if (!await userRepository.IsEmailFreeAsync(command.Email, cancellationToken)) { return Result.BadRequest($"The user with '{command.Email}' already exists."); @@ -53,7 +61,7 @@ public async Task Handle(InviteUserCommand command, CancellationToken ca var loginPath = $"{Environment.GetEnvironmentVariable(SinglePageAppConfiguration.PublicUrlKey)}/login"; var inviter = $"{executionContext.UserInfo.FirstName} {executionContext.UserInfo.LastName}".Trim(); inviter = inviter.Length > 0 ? inviter : executionContext.UserInfo.Email; - await emailClient.SendAsync(command.Email.ToLower(), $"You have been invited to join {executionContext.TenantId} on PlatformPlatform", + await emailClient.SendAsync(command.Email.ToLower(), $"You have been invited to join {tenant.Name} on PlatformPlatform", $"""

{inviter} invited you to join PlatformPlatform. diff --git a/application/account-management/Tests/Authentication/CompleteLoginTests.cs b/application/account-management/Tests/Authentication/CompleteLoginTests.cs index c57ae8270d..5ebeec852e 100644 --- a/application/account-management/Tests/Authentication/CompleteLoginTests.cs +++ b/application/account-management/Tests/Authentication/CompleteLoginTests.cs @@ -192,6 +192,11 @@ public async Task CompleteLogin_WhenLoginExpired_ShouldReturnBadRequest() public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcceptedEvent() { // Arrange + // Set tenant name first (required for inviting users) + Connection.Update("Tenants", "Id", DatabaseSeeder.Tenant1.Id.ToString(), + [("Name", "Test Company")] + ); + var email = Faker.Internet.Email(); var inviteUserCommand = new InviteUserCommand(email); await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/users/invite", inviteUserCommand); diff --git a/application/account-management/Tests/Users/InviteUserTests.cs b/application/account-management/Tests/Users/InviteUserTests.cs index 728dbc5d7c..8dca4e7b59 100644 --- a/application/account-management/Tests/Users/InviteUserTests.cs +++ b/application/account-management/Tests/Users/InviteUserTests.cs @@ -14,10 +14,31 @@ namespace PlatformPlatform.AccountManagement.Tests.Users; public sealed class InviteUserTests : EndpointBaseTest { [Fact] - public async Task InviteUser_WhenValid_ShouldCreateUserWithEmailConfirmedFalse() + public async Task InviteUser_WhenTenantNameNotSet_ShouldReturnBadRequest() { // Arrange - var tenantId = DatabaseSeeder.Tenant1.Id.ToString(); + var email = Faker.Internet.Email(); + var command = new InviteUserCommand(email); + + // Act + var response = await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/users/invite", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, "Account name must be set before inviting users."); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } + + [Fact] + public async Task InviteUser_WhenTenantHasName_ShouldCreateUserAndUseTenantNameInEmail() + { + // Arrange + var tenantName = "Test Company"; + // Update tenant name using SqliteConnectionExtensions + Connection.Update("Tenants", "Id", DatabaseSeeder.Tenant1.Id.ToString(), + [("Name", tenantName)] + ); + var email = Faker.Internet.Email(); var command = new InviteUserCommand(email); @@ -26,9 +47,11 @@ public async Task InviteUser_WhenValid_ShouldCreateUserWithEmailConfirmedFalse() // Assert await response.ShouldBeSuccessfulPostRequest(hasLocation: false); + + // Verify user was created Connection.ExecuteScalar( "SELECT COUNT(*) FROM Users WHERE TenantId = @tenantId AND Email = @email AND EmailConfirmed = 0", - new { tenantId, email = email.ToLower() } + new { tenantId = DatabaseSeeder.Tenant1.Id.ToString(), email = email.ToLower() } ).Should().Be(1); TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(2); @@ -38,7 +61,7 @@ public async Task InviteUser_WhenValid_ShouldCreateUserWithEmailConfirmedFalse() await EmailClient.Received(1).SendAsync( email.ToLower(), - $"You have been invited to join {tenantId} on PlatformPlatform", + $"You have been invited to join {tenantName} on PlatformPlatform", Arg.Is(s => s.Contains("To gain access")), Arg.Any() ); @@ -68,6 +91,11 @@ public async Task InviteUser_WhenInvalidEmail_ShouldReturnBadRequest() public async Task InviteUser_WhenUserExists_ShouldReturnBadRequest() { // Arrange + // Set tenant name first (required for inviting users) + Connection.Update("Tenants", "Id", DatabaseSeeder.Tenant1.Id.ToString(), + [("Name", "Test Company")] + ); + var existingUserEmail = DatabaseSeeder.Tenant1Owner.Email; var command = new InviteUserCommand(existingUserEmail); diff --git a/application/account-management/WebApp/routes/admin/users/-components/TenantNameRequiredDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/TenantNameRequiredDialog.tsx new file mode 100644 index 0000000000..13e11c6213 --- /dev/null +++ b/application/account-management/WebApp/routes/admin/users/-components/TenantNameRequiredDialog.tsx @@ -0,0 +1,53 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@repo/ui/components/Button"; +import { Dialog } from "@repo/ui/components/Dialog"; +import { DialogContent, DialogFooter, DialogHeader } from "@repo/ui/components/DialogFooter"; +import { Heading } from "@repo/ui/components/Heading"; +import { Modal } from "@repo/ui/components/Modal"; +import { Link } from "@tanstack/react-router"; +import { AlertCircleIcon, XIcon } from "lucide-react"; + +interface TenantNameRequiredDialogProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} + +export function TenantNameRequiredDialog({ isOpen, onOpenChange }: Readonly) { + return ( + + + {({ close }) => ( + <> + + + + Add your account name + + + + +
+ +

+ Your team needs to know who's inviting them. Add an account name to get started. +

+
+
+ + + + + + + + + )} +
+
+ ); +} diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx index 14d92ee09d..2430c12f7b 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -8,6 +8,7 @@ import { PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { DeleteUserDialog } from "./DeleteUserDialog"; import InviteUserDialog from "./InviteUserDialog"; +import { TenantNameRequiredDialog } from "./TenantNameRequiredDialog"; import { UserQuerying } from "./UserQuerying"; type UserDetails = components["schemas"]["UserDetails"]; @@ -19,14 +20,17 @@ interface UserToolbarProps { export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly) { const { data: currentUser } = api.useQuery("get", "/api/account-management/users/me"); + const { data: tenant } = api.useQuery("get", "/api/account-management/tenants/current"); const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [showTenantNameRequiredDialog, setShowTenantNameRequiredDialog] = useState(false); const [_isFilterBarExpanded, setIsFilterBarExpanded] = useState(false); const [_hasActiveFilters, setHasActiveFilters] = useState(false); const [shouldUseCompactButtons, setShouldUseCompactButtons] = useState(false); const isOwner = currentUser?.role === UserRole.Owner; const hasSelectedSelf = selectedUsers.some((user) => user.id === currentUser?.id); + const hasTenantName = tenant?.name && tenant.name.trim() !== ""; const handleFilterStateChange = (isExpanded: boolean, hasFilters: boolean, useCompact: boolean) => { setIsFilterBarExpanded(isExpanded); @@ -34,13 +38,21 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly { + if (!hasTenantName) { + setShowTenantNameRequiredDialog(true); + return; + } + setIsInviteModalOpen(true); + }; + return (
onSelectedUsersChange([])} />
{selectedUsers.length < 2 && isOwner && ( -