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/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/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs index 58e3af537c..5d6ef1e9c4 100644 --- a/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs +++ b/application/AppGateway/Transformations/SharedAccessSignatureRequestTransform.cs @@ -3,14 +3,26 @@ 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 if (context.Path.StartsWithSegments("/logos")) + { + containerName = "logos"; + } + 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..624288e398 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}" }, @@ -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": { @@ -205,7 +217,7 @@ } } }, - "avatars-storage": { + "account-management-storage": { "Destinations": { "destination": { "Address": "http://127.0.0.1:10000/devstoreaccount1" 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/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 8ea37a4190..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; @@ -16,7 +17,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) @@ -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/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/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/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/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/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/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/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/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 && ( -