diff --git a/application/account-management/Api/Endpoints/TenantEndpoints.cs b/application/account-management/Api/Endpoints/TenantEndpoints.cs index 9fc220fa4..a678c87a6 100644 --- a/application/account-management/Api/Endpoints/TenantEndpoints.cs +++ b/application/account-management/Api/Endpoints/TenantEndpoints.cs @@ -19,7 +19,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes) ).Produces(); group.MapPut("/current", async Task (UpdateCurrentTenantCommand command, IMediator mediator) - => await mediator.Send(command) + => (await mediator.Send(command)).AddRefreshAuthenticationTokens() ); routes.MapDelete("/internal-api/account-management/tenants/{id}", async Task (TenantId id, IMediator mediator) diff --git a/application/account-management/Api/Endpoints/UserEndpoints.cs b/application/account-management/Api/Endpoints/UserEndpoints.cs index 1dd1c1d8b..4634741a3 100644 --- a/application/account-management/Api/Endpoints/UserEndpoints.cs +++ b/application/account-management/Api/Endpoints/UserEndpoints.cs @@ -18,6 +18,10 @@ public void MapEndpoints(IEndpointRouteBuilder routes) => await mediator.Send(query) ).Produces(); + group.MapGet("/{id}", async Task> (UserId id, IMediator mediator) + => await mediator.Send(new GetUserByIdQuery(id)) + ).Produces(); + group.MapGet("/summary", async Task> (IMediator mediator) => await mediator.Send(new GetUserSummaryQuery()) ).Produces(); diff --git a/application/account-management/Core/Configuration.cs b/application/account-management/Core/Configuration.cs index 4dfd2b52a..8ea37a419 100644 --- a/application/account-management/Core/Configuration.cs +++ b/application/account-management/Core/Configuration.cs @@ -30,6 +30,7 @@ public static IServiceCollection AddAccountManagementServices(this IServiceColle return services .AddSharedServices(Assembly) - .AddScoped(); + .AddScoped() + .AddScoped(); } } diff --git a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs index 4a338d30d..aa69c28a9 100644 --- a/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs +++ b/application/account-management/Core/Features/Authentication/Commands/CompleteLogin.cs @@ -1,11 +1,9 @@ using JetBrains.Annotations; -using Mapster; using PlatformPlatform.AccountManagement.Features.Authentication.Domain; using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; using PlatformPlatform.AccountManagement.Features.Users.Domain; using PlatformPlatform.AccountManagement.Features.Users.Shared; using PlatformPlatform.AccountManagement.Integrations.Gravatar; -using PlatformPlatform.SharedKernel.Authentication; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.Telemetry; @@ -22,6 +20,7 @@ public sealed record CompleteLoginCommand(string OneTimePassword) : ICommand, IR public sealed class CompleteLoginHandler( IUserRepository userRepository, ILoginRepository loginRepository, + UserInfoFactory userInfoFactory, AuthenticationTokenService authenticationTokenService, IMediator mediator, AvatarUpdater avatarUpdater, @@ -75,7 +74,8 @@ public async Task Handle(CompleteLoginCommand command, CancellationToken login.MarkAsCompleted(); loginRepository.Update(login); - authenticationTokenService.CreateAndSetAuthenticationTokens(user.Adapt()); + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken); + authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo); events.CollectEvent(new LoginCompleted(user.Id, completeEmailConfirmationResult.Value!.ConfirmationTimeInSeconds)); diff --git a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs index 3b21c8742..5cfe2757c 100644 --- a/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs +++ b/application/account-management/Core/Features/Authentication/Commands/RefreshAuthenticationTokens.cs @@ -1,10 +1,9 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using JetBrains.Annotations; -using Mapster; using Microsoft.AspNetCore.Http; using PlatformPlatform.AccountManagement.Features.Users.Domain; -using PlatformPlatform.SharedKernel.Authentication; +using PlatformPlatform.AccountManagement.Features.Users.Shared; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.Domain; @@ -17,6 +16,7 @@ public sealed record RefreshAuthenticationTokensCommand : ICommand, IRequest Handle(RefreshAuthenticationTokensCommand command, Can // TODO: Check if the refreshTokenId exists in the database and if the jwtId and refreshTokenVersion are valid - authenticationTokenService.RefreshAuthenticationTokens(user.Adapt(), refreshTokenId, refreshTokenVersion, refreshTokenExpires); + var userInfo = await userInfoFactory.CreateUserInfoAsync(user, cancellationToken); + authenticationTokenService.RefreshAuthenticationTokens(userInfo, refreshTokenId, refreshTokenVersion, refreshTokenExpires); events.CollectEvent(new AuthenticationTokensRefreshed()); return Result.Success(); diff --git a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs b/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs index 697039e56..293544752 100644 --- a/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs +++ b/application/account-management/Core/Features/Signups/Commands/CompleteSignup.cs @@ -1,10 +1,9 @@ using JetBrains.Annotations; -using Mapster; using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Commands; using PlatformPlatform.AccountManagement.Features.EmailConfirmations.Domain; using PlatformPlatform.AccountManagement.Features.Tenants.Commands; using PlatformPlatform.AccountManagement.Features.Users.Domain; -using PlatformPlatform.SharedKernel.Authentication; +using PlatformPlatform.AccountManagement.Features.Users.Shared; using PlatformPlatform.SharedKernel.Authentication.TokenGeneration; using PlatformPlatform.SharedKernel.Cqrs; using PlatformPlatform.SharedKernel.Telemetry; @@ -20,6 +19,7 @@ public sealed record CompleteSignupCommand(string OneTimePassword, string Prefer public sealed class CompleteSignupHandler( IUserRepository userRepository, + UserInfoFactory userInfoFactory, AuthenticationTokenService authenticationTokenService, IMediator mediator, ITelemetryEventsCollector events @@ -42,7 +42,8 @@ public async Task Handle(CompleteSignupCommand command, CancellationToke if (!createTenantResult.IsSuccess) return Result.From(createTenantResult); var user = await userRepository.GetByIdAsync(createTenantResult.Value!.UserId, cancellationToken); - authenticationTokenService.CreateAndSetAuthenticationTokens(user!.Adapt()); + var userInfo = await userInfoFactory.CreateUserInfoAsync(user!, cancellationToken); + authenticationTokenService.CreateAndSetAuthenticationTokens(userInfo); events.CollectEvent( new SignupCompleted(createTenantResult.Value.TenantId, completeEmailConfirmationResult.Value!.ConfirmationTimeInSeconds) diff --git a/application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs b/application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs index 487ec7127..0e23a76d3 100644 --- a/application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs +++ b/application/account-management/Core/Features/Tenants/Commands/UpdateCurrentTenant.cs @@ -1,7 +1,9 @@ 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; using PlatformPlatform.SharedKernel.Telemetry; namespace PlatformPlatform.AccountManagement.Features.Tenants.Commands; @@ -20,11 +22,19 @@ public UpdateCurrentTenantValidator() } } -public sealed class UpdateTenantHandler(ITenantRepository tenantRepository, ITelemetryEventsCollector events) - : IRequestHandler +public sealed class UpdateTenantHandler( + ITenantRepository tenantRepository, + IExecutionContext executionContext, + ITelemetryEventsCollector events +) : IRequestHandler { public async Task Handle(UpdateCurrentTenantCommand command, CancellationToken cancellationToken) { + if (executionContext.UserInfo.Role != UserRole.Owner.ToString()) + { + return Result.Forbidden("Only owners are allowed to update tenant information."); + } + var tenant = await tenantRepository.GetCurrentTenantAsync(cancellationToken); tenant.Update(command.Name); diff --git a/application/account-management/Core/Features/Users/Domain/UserRepository.cs b/application/account-management/Core/Features/Users/Domain/UserRepository.cs index bd9dcf842..561d1a096 100644 --- a/application/account-management/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account-management/Core/Features/Users/Domain/UserRepository.cs @@ -158,8 +158,16 @@ CancellationToken cancellationToken ? users.OrderBy(u => u.ModifiedAt) : users.OrderByDescending(u => u.ModifiedAt), SortableUserProperties.Name => sortOrder == SortOrder.Ascending - ? users.OrderBy(u => u.FirstName).ThenBy(u => u.LastName) - : users.OrderByDescending(u => u.FirstName).ThenByDescending(u => u.LastName), + ? users.OrderBy(u => u.FirstName == null ? 1 : 0) + .ThenBy(u => u.FirstName) + .ThenBy(u => u.LastName == null ? 1 : 0) + .ThenBy(u => u.LastName) + .ThenBy(u => u.Email) + : users.OrderBy(u => u.FirstName == null ? 0 : 1) + .ThenByDescending(u => u.FirstName) + .ThenBy(u => u.LastName == null ? 0 : 1) + .ThenByDescending(u => u.LastName) + .ThenBy(u => u.Email), SortableUserProperties.Email => sortOrder == SortOrder.Ascending ? users.OrderBy(u => u.Email) : users.OrderByDescending(u => u.Email), @@ -167,6 +175,11 @@ CancellationToken cancellationToken ? users.OrderBy(u => u.Role) : users.OrderByDescending(u => u.Role), _ => users + .OrderBy(u => u.FirstName == null ? 1 : 0) + .ThenBy(u => u.FirstName) + .ThenBy(u => u.LastName == null ? 1 : 0) + .ThenBy(u => u.LastName) + .ThenBy(u => u.Email) }; pageSize ??= 50; diff --git a/application/account-management/Core/Features/Users/Queries/GetUserById.cs b/application/account-management/Core/Features/Users/Queries/GetUserById.cs new file mode 100644 index 000000000..caf448d21 --- /dev/null +++ b/application/account-management/Core/Features/Users/Queries/GetUserById.cs @@ -0,0 +1,26 @@ +using JetBrains.Annotations; +using Mapster; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Cqrs; +using PlatformPlatform.SharedKernel.Domain; + +namespace PlatformPlatform.AccountManagement.Features.Users.Queries; + +[PublicAPI] +public sealed record GetUserByIdQuery(UserId Id) : IRequest>; + +public sealed class GetUserByIdHandler(IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetUserByIdQuery query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdAsync(query.Id, cancellationToken); + + if (user is null) + { + return Result.NotFound($"User with ID '{query.Id}' not found."); + } + + return user.Adapt(); + } +} diff --git a/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs new file mode 100644 index 000000000..46a78941f --- /dev/null +++ b/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs @@ -0,0 +1,38 @@ +using PlatformPlatform.AccountManagement.Features.Tenants.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Authentication; + +namespace PlatformPlatform.AccountManagement.Features.Users.Shared; + +/// +/// Factory for creating UserInfo instances with tenant information. +/// Centralizes the logic for creating UserInfo to follow SRP and avoid duplication. +/// +public sealed class UserInfoFactory(ITenantRepository tenantRepository) +{ + /// + /// Creates a UserInfo instance from a User entity, including tenant name. + /// + /// The user entity + /// Cancellation token + /// UserInfo with all required properties including tenant name + public async Task CreateUserInfoAsync(User user, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdAsync(user.TenantId, cancellationToken); + + return new UserInfo + { + IsAuthenticated = true, + Id = user.Id, + TenantId = user.TenantId, + Role = user.Role.ToString(), + Email = user.Email, + FirstName = user.FirstName, + LastName = user.LastName, + Title = user.Title, + AvatarUrl = user.Avatar.Url, + TenantName = tenant?.Name, + Locale = user.Locale + }; + } +} diff --git a/application/account-management/Tests/Tenants/UpdateCurrentTenantTests.cs b/application/account-management/Tests/Tenants/UpdateCurrentTenantTests.cs index a34bc4681..7b9c4fd1e 100644 --- a/application/account-management/Tests/Tenants/UpdateCurrentTenantTests.cs +++ b/application/account-management/Tests/Tenants/UpdateCurrentTenantTests.cs @@ -47,4 +47,19 @@ public async Task UpdateCurrentTenant_WhenInvalid_ShouldReturnBadRequest() TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); } + + [Fact] + public async Task UpdateCurrentTenant_WhenNonOwner_ShouldReturnForbidden() + { + // Arrange + var command = new UpdateCurrentTenantCommand { Name = Faker.TenantName() }; + + // Act + var response = await AuthenticatedMemberHttpClient.PutAsJsonAsync("/api/account-management/tenants/current", command); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.Forbidden, "Only owners are allowed to update tenant information."); + + TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse(); + } } diff --git a/application/account-management/Tests/Users/GetUserByIdTests.cs b/application/account-management/Tests/Users/GetUserByIdTests.cs new file mode 100644 index 000000000..a94150c45 --- /dev/null +++ b/application/account-management/Tests/Users/GetUserByIdTests.cs @@ -0,0 +1,85 @@ +using System.Net; +using System.Text.Json; +using FluentAssertions; +using PlatformPlatform.AccountManagement.Database; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.AccountManagement.Features.Users.Queries; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Tests; +using PlatformPlatform.SharedKernel.Tests.Persistence; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Users; + +public sealed class GetUserByIdTests : EndpointBaseTest +{ + private readonly UserId _userId = UserId.NewId(); + + public GetUserByIdTests() + { + Connection.Insert("Users", [ + ("TenantId", DatabaseSeeder.Tenant1.Id.ToString()), + ("Id", _userId.ToString()), + ("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)), + ("ModifiedAt", null), + ("Email", Faker.Internet.Email()), + ("FirstName", Faker.Name.FirstName()), + ("LastName", Faker.Name.LastName()), + ("Title", Faker.Name.JobTitle()), + ("Role", UserRole.Member.ToString()), + ("EmailConfirmed", true), + ("Avatar", JsonSerializer.Serialize(new Avatar())), + ("Locale", "en-US") + ] + ); + } + + [Fact] + public async Task GetUserById_WhenUserExists_ShouldReturnUserDetails() + { + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/api/account-management/users/{_userId}"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var userDetails = await response.DeserializeResponse(); + userDetails.Should().NotBeNull(); + userDetails.Id.Should().Be(_userId); + } + + [Fact] + public async Task GetUserById_WhenUserDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var nonExistentUserId = UserId.NewId(); + + // Act + var response = await AuthenticatedOwnerHttpClient.GetAsync($"/api/account-management/users/{nonExistentUserId}"); + + // Assert + await response.ShouldHaveErrorStatusCode(HttpStatusCode.NotFound, $"User with ID '{nonExistentUserId}' not found."); + } + + [Fact] + public async Task GetUserById_WhenMemberTriesToAccessOtherUser_ShouldSucceed() + { + // Act + var response = await AuthenticatedMemberHttpClient.GetAsync($"/api/account-management/users/{_userId}"); + + // Assert + response.ShouldBeSuccessfulGetRequest(); + var userDetails = await response.DeserializeResponse(); + userDetails.Should().NotBeNull(); + userDetails.Id.Should().Be(_userId); + } + + [Fact] + public async Task GetUserById_WhenNotAuthenticated_ShouldReturnUnauthorized() + { + // Act + var response = await AnonymousHttpClient.GetAsync($"/api/account-management/users/{_userId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/application/account-management/WebApp/routes/admin/account/index.tsx b/application/account-management/WebApp/routes/admin/account/index.tsx index cafbdff78..23aa21891 100644 --- a/application/account-management/WebApp/routes/admin/account/index.tsx +++ b/application/account-management/WebApp/routes/admin/account/index.tsx @@ -1,7 +1,7 @@ import { SharedSideMenu } from "@/shared/components/SharedSideMenu"; import { TopMenu } from "@/shared/components/topMenu"; import logoWrap from "@/shared/images/logo-wrap.svg"; -import { api } from "@/shared/lib/api/client"; +import { UserRole, api } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; @@ -23,9 +23,12 @@ export const Route = createFileRoute("/admin/account/")({ export function AccountSettings() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const { data: tenant, isLoading } = api.useQuery("get", "/api/account-management/tenants/current"); + const { data: tenant, isLoading: tenantLoading } = 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 isOwner = currentUser?.role === UserRole.Owner; + useEffect(() => { if (updateCurrentTenantMutation.isSuccess) { toastQueue.add({ @@ -36,7 +39,7 @@ export function AccountSettings() { } }, [updateCurrentTenantMutation.isSuccess]); - if (isLoading) { + if (tenantLoading || userLoading) { return null; } @@ -64,8 +67,8 @@ export function AccountSettings() {

@@ -83,30 +86,36 @@ export function AccountSettings() { name="name" defaultValue={tenant?.name ?? ""} isDisabled={updateCurrentTenantMutation.isPending} + isReadOnly={!isOwner} label={t`Account name`} + description={!isOwner ? t`Only account owners can modify the account name` : undefined} validationBehavior="aria" /> - + {isOwner && ( + + )}
-
-

- Danger zone -

- -
-

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

+ {isOwner && ( +
+

+ Danger zone +

+ +
+

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

- + +
-
+ )} diff --git a/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx index 3c15d022b..fbf60fc6f 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/ChangeUserRoleDialog.tsx @@ -3,10 +3,11 @@ import { getUserRoleLabel } from "@/shared/lib/api/userRole"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AlertDialog } from "@repo/ui/components/AlertDialog"; +import { Button } from "@repo/ui/components/Button"; import { Modal } from "@repo/ui/components/Modal"; import { Select, SelectItem } from "@repo/ui/components/Select"; import { toastQueue } from "@repo/ui/components/Toast"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; type UserDetails = components["schemas"]["UserDetails"]; @@ -17,29 +18,38 @@ interface ChangeUserRoleDialogProps { } export function ChangeUserRoleDialog({ user, isOpen, onOpenChange }: Readonly) { + const [selectedRole, setSelectedRole] = useState(null); const changeUserRoleMutation = api.useMutation("put", "/api/account-management/users/{id}/change-user-role"); - const handleUserRoleChange = useCallback( - async (newUserRole: UserRole) => { - if (!user) { - return null; - } + const handleConfirm = useCallback(async () => { + if (!user || !selectedRole) { + return; + } - changeUserRoleMutation - .mutateAsync({ params: { path: { id: user.id } }, body: { userRole: newUserRole } }) - .then(() => { - const userDisplayName = `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email; - toastQueue.add({ - title: t`Success`, - description: t`User role updated successfully for ${userDisplayName}`, - variant: "success" - }); + try { + await changeUserRoleMutation.mutateAsync({ + params: { path: { id: user.id } }, + body: { userRole: selectedRole } + }); - onOpenChange(false); - }); - }, - [user, changeUserRoleMutation, onOpenChange] - ); + const userDisplayName = `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email; + toastQueue.add({ + title: t`Success`, + description: t`User role updated successfully for ${userDisplayName}`, + variant: "success" + }); + + onOpenChange(false); + setSelectedRole(null); + } catch (_error) { + // Error is handled by the mutation + } + }, [user, selectedRole, changeUserRoleMutation, onOpenChange]); + + const handleCancel = useCallback(() => { + onOpenChange(false); + setSelectedRole(null); + }, [onOpenChange]); return ( @@ -55,8 +65,8 @@ export function ChangeUserRoleDialog({ user, isOpen, onOpenChange }: Readonly handleUserRoleChange(key as UserRole)} + selectedKey={selectedRole || user?.role} + onSelectionChange={(key) => setSelectedRole(key as UserRole)} className="flex w-full flex-col" > {Object.values(UserRole).map((userRole) => ( @@ -65,6 +75,15 @@ export function ChangeUserRoleDialog({ user, isOpen, onOpenChange }: Readonly ))} + +
+ + +
diff --git a/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx index fe1089e12..65674d353 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx @@ -39,7 +39,7 @@ export default function InviteUserDialog({ isOpen, onOpenChange }: ReadonlyInvite user

- An invitation email will be sent to the user with a link to log in. + An email with login instructions will be sent to the user.

void; + onDeleteUser: (user: UserDetails) => void; + isUserInCurrentView?: boolean; + isDataNewer?: boolean; + isLoading?: boolean; +} + +function UserProfileContent({ + user, + canModifyUser, + onChangeRole +}: Readonly<{ + user: UserDetails; + canModifyUser: boolean; + onChangeRole: () => void; +}>) { + return ( + <> + {/* User Avatar and Basic Info */} +
+ + + {user.firstName} {user.lastName} + + {user.title && {user.title}} +
+ + {/* Contact Information */} +
+
+
+ + Email + +
+ {user.email} + {user.emailConfirmed ? ( + + Verified + + ) : ( + + Pending + + )} +
+
+
+
+ + + + {/* Role Information */} +
+ + Role + + {canModifyUser ? ( + + ) : ( + + {getUserRoleLabel(user.role)} + + )} +
+ + + + {/* Account Details */} +
+
+
+ + Created + + {formatDate(user.createdAt, true)} +
+
+ + Modified + + {formatDate(user.modifiedAt, true)} +
+
+
+ + ); +} + +function useSidePaneAccessibility( + isOpen: boolean, + onClose: () => void, + sidePaneRef: React.RefObject, + closeButtonRef: React.RefObject +) { + useEffect(() => { + const isMobileScreen = !window.matchMedia(MEDIA_QUERIES.sm).matches; + if (isOpen && closeButtonRef.current && isMobileScreen) { + closeButtonRef.current.focus(); + } + }, [isOpen, closeButtonRef]); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && isOpen) { + event.preventDefault(); + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleKeyDown); + } + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onClose]); + + useEffect(() => { + const isMobileScreen = !window.matchMedia(MEDIA_QUERIES.sm).matches; + if (!isOpen || !sidePaneRef.current || !isMobileScreen) { + return; + } + + const focusableElements = sidePaneRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + const firstElement = focusableElements[0] as HTMLElement; + const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; + + const handleTabKey = (event: KeyboardEvent) => { + if (event.key !== "Tab") { + return; + } + + const isShiftTab = event.shiftKey; + const activeElement = document.activeElement; + + if (isShiftTab && activeElement === firstElement) { + event.preventDefault(); + lastElement.focus(); + } else if (!isShiftTab && activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener("keydown", handleTabKey); + return () => document.removeEventListener("keydown", handleTabKey); + }, [isOpen, sidePaneRef]); +} + +export function UserProfileSidePane({ + user, + isOpen, + onClose, + onDeleteUser, + isUserInCurrentView = true, + isDataNewer = false, + isLoading = false +}: Readonly) { + const userInfo = useUserInfo(); + const sidePaneRef = useRef(null); + const closeButtonRef = useRef(null); + const [isChangeRoleDialogOpen, setIsChangeRoleDialogOpen] = useState(false); + + useSidePaneAccessibility(isOpen, onClose, sidePaneRef, closeButtonRef); + + if (!isOpen) { + return null; + } + + const isCurrentUser = user?.id === userInfo?.id; + const canModifyUser = userInfo?.role === "Owner" && !isCurrentUser; + + return ( + <> + {/* Side pane */} + + + {/* Change User Role Dialog */} + {user && ( + + )} + + ); +} diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx index 76afea85c..e648f8afc 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -11,10 +11,11 @@ import { Heading } from "@repo/ui/components/Heading"; import { Modal } from "@repo/ui/components/Modal"; import { SearchField } from "@repo/ui/components/SearchField"; import { Select, SelectItem } from "@repo/ui/components/Select"; -import { MEDIA_QUERIES } from "@repo/ui/utils/responsive"; +import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { useSideMenuLayout } from "@repo/ui/hooks/useSideMenuLayout"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { FilterIcon, FilterXIcon, XIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { ListFilter, ListFilterPlus, XIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; // SearchParams interface defines the structure of URL query parameters interface SearchParams { @@ -28,20 +29,33 @@ interface SearchParams { pageOffset: number | undefined; } +interface UserQueryingProps { + onFilterStateChange?: ( + isFilterBarExpanded: boolean, + hasActiveFilters: boolean, + shouldUseCompactButtons: boolean + ) => void; + onFiltersUpdated?: () => void; +} + /** * UserQuerying component handles the user list filtering. * Uses URL parameters as the single source of truth for all filters. * The only local state is for the search input, which is debounced * to prevent too many URL updates while typing. */ -export function UserQuerying() { +export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQueryingProps = {}) { const navigate = useNavigate(); const searchParams = (useLocation().search as SearchParams) ?? {}; + const { isOverlayOpen, isMobileMenuOpen } = useSideMenuLayout(); + const containerRef = useRef(null); const [search, setSearch] = useState(searchParams.search); const [showAllFilters, setShowAllFilters] = useState( Boolean(searchParams.userRole ?? searchParams.userStatus ?? searchParams.startDate ?? searchParams.endDate) ); + const [searchTimeoutId, setSearchTimeoutId] = useState(null); const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false); + const [, forceUpdate] = useState({}); // Convert URL date strings to DateRange if they exist const dateRange = @@ -54,27 +68,40 @@ export function UserQuerying() { // Updates URL parameters while preserving existing ones const updateFilter = useCallback( - (params: Partial) => { + (params: Partial, isSearchUpdate = false) => { navigate({ to: "/admin/users", search: (prev) => ({ ...prev, ...params, - pageOffset: prev.pageOffset === 0 ? undefined : prev.pageOffset + pageOffset: undefined, + userId: undefined }) }); + // Only call onFiltersUpdated for actual filter changes, not search updates + if (!isSearchUpdate) { + onFiltersUpdated?.(); + } }, - [navigate] + [navigate, onFiltersUpdated] ); // Debounce search updates to avoid too many URL changes while typing useEffect(() => { - const timeoutId = setTimeout(() => { - updateFilter({ search: (search as string) || undefined }); - }, 500); + // Only update if search value actually changed from URL params + if (search !== searchParams.search) { + const timeoutId = setTimeout(() => { + updateFilter({ search: (search as string) || undefined }, true); + setSearchTimeoutId(null); + }, 500); + setSearchTimeoutId(timeoutId); - return () => clearTimeout(timeoutId); - }, [search, updateFilter]); + return () => { + clearTimeout(timeoutId); + setSearchTimeoutId(null); + }; + } + }, [search, searchParams.search, updateFilter]); // Count active filters for badge const getActiveFilterCount = () => { @@ -93,25 +120,152 @@ export function UserQuerying() { const activeFilterCount = getActiveFilterCount(); - // Handle screen size changes to show/hide filters appropriately + // Detect if side pane is open by checking DOM + const [isSidePaneOpen, setIsSidePaneOpen] = useState(false); + useEffect(() => { - const handleResize = () => { - const isLargeScreen = window.matchMedia(MEDIA_QUERIES.lg).matches; - if (isLargeScreen && activeFilterCount > 0 && !showAllFilters) { - // On large screens, show inline filters if there are active filters + const checkSidePaneState = () => { + const sidePane = document.querySelector('[class*="fixed"][class*="inset-0"][class*="z-[60]"]'); + const isOpen = !!sidePane; + if (isOpen !== isSidePaneOpen) { + setIsSidePaneOpen(isOpen); + } + }; + + // Check immediately + checkSidePaneState(); + + // Use MutationObserver to detect when side pane is added/removed + const observer = new MutationObserver(checkSidePaneState); + observer.observe(document.body, { childList: true, subtree: true }); + + return () => observer.disconnect(); + }, [isSidePaneOpen]); + + // Handle screen size and container space changes to show/hide filters appropriately + useEffect(() => { + let debounceTimeout: NodeJS.Timeout | null = null; + let lastStateChange = 0; + + const shouldSkipSpaceCheck = (now: number) => { + return now - lastStateChange < 200; + }; + + const shouldHideFiltersForOverlays = () => { + return isOverlayOpen || isMobileMenuOpen; + }; + + const getToolbarContainer = () => { + if (!containerRef.current) { + return null; + } + return containerRef.current.closest(".flex.items-center.justify-between") as HTMLElement; + }; + + const calculateAvailableSpace = (toolbarContainer: HTMLElement) => { + const toolbarWidth = toolbarContainer.offsetWidth; + const searchField = containerRef.current?.querySelector('input[type="text"]') as HTMLElement; + const filterButton = containerRef.current?.querySelector('[data-testid="filter-button"]') as HTMLElement; + + const searchWidth = searchField?.offsetWidth || 300; + const filterButtonWidth = filterButton?.offsetWidth || 50; + const rightSideWidth = 130; + const gaps = 16; + + const usedSpace = searchWidth + filterButtonWidth + rightSideWidth + gaps; + return toolbarWidth - usedSpace; + }; + + const updateFiltersVisibility = (hasSpace: boolean, now: number) => { + if (hasSpace && activeFilterCount > 0 && !showAllFilters) { + lastStateChange = now; setShowAllFilters(true); - } else if (!isLargeScreen && showAllFilters) { - // On small/medium screens, hide inline filters + } else if (!hasSpace && showAllFilters) { + lastStateChange = now; setShowAllFilters(false); } }; - // Check on mount - handleResize(); + const checkFilterSpace = () => { + const now = Date.now(); + + if (shouldSkipSpaceCheck(now)) { + return; + } + + if (shouldHideFiltersForOverlays()) { + if (showAllFilters) { + lastStateChange = now; + setShowAllFilters(false); + } + return; + } + + const toolbarContainer = getToolbarContainer(); + if (!toolbarContainer) { + return; + } + + const availableSpace = calculateAvailableSpace(toolbarContainer); + const minimumFilterSpace = 300; + const hasSpaceForInlineFilters = availableSpace >= minimumFilterSpace; + + updateFiltersVisibility(hasSpaceForInlineFilters, now); + }; + + const debouncedCheckFilterSpace = () => { + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + debounceTimeout = setTimeout(checkFilterSpace, 100); + }; + + // Run check immediately + checkFilterSpace(); + + // Also listen for resize events to handle browser-specific timing issues + const handleResize = () => { + debouncedCheckFilterSpace(); + }; + + // Listen for side menu events that affect layout + const handleSideMenuToggle = () => { + debouncedCheckFilterSpace(); + }; + + const handleSideMenuResize = () => { + debouncedCheckFilterSpace(); + }; + + // Force a recheck after mount to ensure correct initial state across browsers + const timeoutId = setTimeout(() => { + forceUpdate({}); + checkFilterSpace(); + }, 100); window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, [activeFilterCount, showAllFilters]); + window.addEventListener("side-menu-toggle", handleSideMenuToggle); + window.addEventListener("side-menu-resize", handleSideMenuResize); + + return () => { + window.removeEventListener("resize", handleResize); + window.removeEventListener("side-menu-toggle", handleSideMenuToggle); + window.removeEventListener("side-menu-resize", handleSideMenuResize); + clearTimeout(timeoutId); + if (debounceTimeout) { + clearTimeout(debounceTimeout); + } + }; + }, [activeFilterCount, showAllFilters, isMobileMenuOpen, isOverlayOpen]); + + // Notify parent component when filter state changes + useEffect(() => { + // On 2XL+ screens, keep full buttons even with filters + const is2XlScreen = window.matchMedia("(min-width: 1536px)").matches; + const shouldUseCompactButtons = !is2XlScreen && (showAllFilters || activeFilterCount > 0); + + onFilterStateChange?.(showAllFilters, activeFilterCount > 0, shouldUseCompactButtons); + }, [showAllFilters, activeFilterCount, onFilterStateChange]); const clearAllFilters = () => { updateFilter({ userRole: undefined, userStatus: undefined, startDate: undefined, endDate: undefined }); @@ -120,8 +274,22 @@ export function UserQuerying() { }; return ( -
- +
+ { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + updateFilter({ search: (search as string) || undefined }, true); + }} + label={t`Search`} + autoFocus={true} + className="min-w-32" + /> {showAllFilters && ( <> @@ -176,38 +344,80 @@ export function UserQuerying() { )} {/* Filter button with responsive behavior */} - + + + {showAllFilters ? Clear filters : Show filters} + {/* Filter dialog for small/medium screens */} diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx index fe0e05b7b..a96185d20 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -9,24 +9,34 @@ import { Button } from "@repo/ui/components/Button"; import { Menu, MenuItem, MenuSeparator } from "@repo/ui/components/Menu"; import { Pagination } from "@repo/ui/components/Pagination"; import { Cell, Column, Row, Table, TableHeader } from "@repo/ui/components/Table"; +import { Text } from "@repo/ui/components/Text"; import { formatDate } from "@repo/utils/date/formatDate"; import { getInitials } from "@repo/utils/string/getInitials"; import { useNavigate, useSearch } from "@tanstack/react-router"; -import { EllipsisVerticalIcon, PencilIcon, Trash2Icon, UserIcon } from "lucide-react"; +import { EllipsisVerticalIcon, SettingsIcon, Trash2Icon, UserIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import type { Selection, SortDescriptor } from "react-aria-components"; import { MenuTrigger, TableBody } from "react-aria-components"; -import { ChangeUserRoleDialog } from "./ChangeUserRoleDialog"; -import { DeleteUserDialog } from "./DeleteUserDialog"; type UserDetails = components["schemas"]["UserDetails"]; interface UserTableProps { selectedUsers: UserDetails[]; onSelectedUsersChange: (users: UserDetails[]) => void; + onViewProfile: (user: UserDetails | null) => void; + onDeleteUser: (user: UserDetails) => void; + onChangeRole: (user: UserDetails) => void; + onUsersLoaded?: (users: UserDetails[]) => void; } -export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly) { +export function UserTable({ + selectedUsers, + onSelectedUsersChange, + onViewProfile, + onDeleteUser, + onChangeRole, + onUsersLoaded +}: Readonly) { const navigate = useNavigate(); const { search, userRole, userStatus, startDate, endDate, orderBy, sortOrder, pageOffset } = useSearch({ strict: false @@ -53,9 +63,6 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly(null); - const [userToChangeRole, setUserToChangeRole] = useState(null); - const handlePageChange = useCallback( (page: number) => { navigate({ @@ -85,21 +92,39 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly { onSelectedUsersChange([]); - }, [onSelectedUsersChange]); + }, [onSelectedUsersChange, pageOffset]); + + useEffect(() => { + if (users?.users) { + onUsersLoaded?.(users.users); + } + }, [users?.users, onUsersLoaded]); const handleSelectionChange = useCallback( (keys: Selection) => { if (keys === "all") { onSelectedUsersChange(users?.users ?? []); + // Close profile when selecting all users + onViewProfile(null); } else { const selectedKeys = typeof keys === "string" ? new Set([keys]) : keys; const selectedUsersList = users?.users.filter((user) => selectedKeys.has(user.id)) ?? []; onSelectedUsersChange(selectedUsersList); + + // Handle profile viewing based on selection + if (selectedUsersList.length === 1) { + // Single user selected - show profile + onViewProfile(selectedUsersList[0]); + } else { + // Multiple users selected or no users selected - close profile + onViewProfile(null); + } } }, - [users?.users, onSelectedUsersChange] + [users?.users, onSelectedUsersChange, onViewProfile] ); if (isLoading) { @@ -109,141 +134,134 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly - !isOpen && setUserToChangeRole(null)} - /> - - !isOpen && setUserToDelete(null)} - /> - - user.id)} - onSelectionChange={handleSelectionChange} - sortDescriptor={sortDescriptor} - onSortChange={handleSortChange} - aria-label={t`Users`} - > - - - Name - - - Email - - - Created - - - Modified - - - Role - - - Actions - - - - {users?.users.map((user) => ( - - -
- -
-
- {user.firstName} {user.lastName} - {user.emailConfirmed ? ( - "" - ) : ( - - Pending - - )} -
-
{user.title ?? ""}
-
-
-
- {user.email} - {formatDate(user.createdAt)} - {formatDate(user.modifiedAt)} - - {getUserRoleLabel(user.role)} - - -
- - { - if (isOpen) { - onSelectedUsersChange([user]); - } - }} - > - - - - - View profile - - setUserToChangeRole(user)} - > - - - Change role - - - - setUserToDelete(user)} - > - - - Delete - - - - -
-
-
- ))} -
-
+
+
+ user.id)} + onSelectionChange={handleSelectionChange} + sortDescriptor={sortDescriptor} + onSortChange={handleSortChange} + aria-label={t`Users`} + > + + + Name + + + Email + + + Created + + + Modified + + + Role + + + Actions + + + + {users?.users.map((user) => ( + + + + + + + {user.firstName} {user.lastName} + {user.emailConfirmed ? ( + "" + ) : ( + + Pending + + )} + + {user.title ?? ""} + + + + + {user.email} + + + + {formatDate(user.createdAt)} + + + + + {formatDate(user.modifiedAt)} + + + + + {getUserRoleLabel(user.role)} + + + + + { + if (isOpen) { + onSelectedUsersChange([user]); + } + }} + > + + + onViewProfile(user)}> + + View profile + + {userInfo?.role === "Owner" && ( + <> + onChangeRole(user)} + > + + Change role + + + onDeleteUser(user)} + > + + + Delete + + + + )} + + + + + + ))} + +
+
{users && ( -
+
)} - +
); } 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 1fe841970..20d31e3ff 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -1,6 +1,9 @@ import type { components } from "@/shared/lib/api/client"; +import { UserRole, api } from "@/shared/lib/api/client"; +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Button } from "@repo/ui/components/Button"; +import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; import { PlusIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { DeleteUserDialog } from "./DeleteUserDialog"; @@ -11,35 +14,73 @@ type UserDetails = components["schemas"]["UserDetails"]; interface UserToolbarProps { selectedUsers: UserDetails[]; + onSelectedUsersChange: (users: UserDetails[]) => void; } -export function UserToolbar({ selectedUsers }: Readonly) { +export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly) { + const { data: currentUser } = api.useQuery("get", "/api/account-management/users/me"); const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = 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 handleFilterStateChange = (isExpanded: boolean, hasFilters: boolean, useCompact: boolean) => { + setIsFilterBarExpanded(isExpanded); + setHasActiveFilters(hasFilters); + setShouldUseCompactButtons(useCompact); + }; return (
- + onSelectedUsersChange([])} />
- {selectedUsers.length === 0 && ( - + {selectedUsers.length < 2 && isOwner && ( + + + {shouldUseCompactButtons && ( + + Invite user + + )} + )} - {selectedUsers.length > 0 && ( - + {selectedUsers.length > 1 && isOwner && ( + + + {shouldUseCompactButtons && ( + + Delete {selectedUsers.length} users + + )} + )}
- - + {isOwner && } + onSelectedUsersChange([])} + />
); } diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index de6bda142..ea393fd02 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -1,13 +1,16 @@ import { SharedSideMenu } from "@/shared/components/SharedSideMenu"; import { TopMenu } from "@/shared/components/topMenu"; -import { SortOrder, SortableUserProperties, UserRole, UserStatus, type components } from "@/shared/lib/api/client"; +import { SortOrder, SortableUserProperties, UserRole, UserStatus, api, type components } from "@/shared/lib/api/client"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; import { Breadcrumb } from "@repo/ui/components/Breadcrumbs"; -import { createFileRoute } from "@tanstack/react-router"; -import { useState } from "react"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; import { z } from "zod"; +import { ChangeUserRoleDialog } from "./-components/ChangeUserRoleDialog"; +import { DeleteUserDialog } from "./-components/DeleteUserDialog"; +import { UserProfileSidePane } from "./-components/UserProfileSidePane"; import { UserTable } from "./-components/UserTable"; import { UserToolbar } from "./-components/UserToolbar"; @@ -21,7 +24,8 @@ const userPageSearchSchema = z.object({ endDate: z.string().optional(), orderBy: z.nativeEnum(SortableUserProperties).default(SortableUserProperties.Name).optional(), sortOrder: z.nativeEnum(SortOrder).default(SortOrder.Ascending).optional(), - pageOffset: z.number().default(0).optional() + pageOffset: z.number().default(0).optional(), + userId: z.string().optional() }); export const Route = createFileRoute("/admin/users/")({ @@ -31,11 +35,91 @@ export const Route = createFileRoute("/admin/users/")({ export default function UsersPage() { const [selectedUsers, setSelectedUsers] = useState([]); + const [profileUser, setProfileUser] = useState(null); + const [userToDelete, setUserToDelete] = useState(null); + const [userToChangeRole, setUserToChangeRole] = useState(null); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const [tableUsers, setTableUsers] = useState([]); + const navigate = useNavigate({ from: Route.fullPath }); + const { userId } = Route.useSearch(); + + const handleCloseProfile = () => { + setProfileUser(null); + setSelectedUsers([]); + navigate({ search: (prev) => ({ ...prev, userId: undefined }) }); + }; + + const handleViewProfile = (user: UserDetails | null) => { + setProfileUser(user); + if (user) { + navigate({ search: (prev) => ({ ...prev, userId: user.id }) }); + } else { + navigate({ search: (prev) => ({ ...prev, userId: undefined }) }); + } + }; + + const { data: userData, isLoading: isLoadingUser } = api.useQuery("get", "/api/account-management/users/{id}", { + params: { + path: { + id: userId || "" + } + }, + enabled: !!userId + }); + + useEffect(() => { + if (userId && userData) { + setProfileUser(userData); + if (isInitialLoad) { + setSelectedUsers([userData]); + setIsInitialLoad(false); + } + } else if (!userId && isInitialLoad) { + setIsInitialLoad(false); + } + }, [userId, userData, isInitialLoad]); + + const handleDeleteUser = (user: UserDetails) => { + setUserToDelete(user); + }; + + const handleChangeRole = (user: UserDetails) => { + setUserToChangeRole(user); + }; + + const handleUsersLoaded = (users: UserDetails[]) => { + setTableUsers(users); + }; + + const isUserInCurrentView = profileUser ? tableUsers.some((u) => u.id === profileUser.id) : true; + + // Check if the side pane data is different from table data + const tableUser = profileUser ? tableUsers.find((u) => u.id === profileUser.id) : null; + const isDataNewer = !!( + userData && + tableUser && + userData.modifiedAt && + tableUser.modifiedAt && + new Date(userData.modifiedAt).getTime() !== new Date(tableUser.modifiedAt).getTime() + ); return ( <> + ) : undefined + } topMenu={ @@ -47,16 +131,46 @@ export default function UsersPage() { } > -

- Users -

-

- Manage your users and permissions here. -

- - - +
+

+ Users +

+

+ Manage your users and permissions here. +

+ +
+ +
+
+ +
+
+ + !isOpen && setUserToChangeRole(null)} + /> + + !isOpen && setUserToDelete(null)} + onUsersDeleted={() => { + setSelectedUsers([]); + setProfileUser(null); + navigate({ search: (prev) => ({ ...prev, userId: undefined }) }); + }} + /> ); } diff --git a/application/account-management/WebApp/shared/components/SharedSideMenu.tsx b/application/account-management/WebApp/shared/components/SharedSideMenu.tsx index 0b8cb4042..cbcf15d28 100644 --- a/application/account-management/WebApp/shared/components/SharedSideMenu.tsx +++ b/application/account-management/WebApp/shared/components/SharedSideMenu.tsx @@ -26,8 +26,8 @@ import { UserIcon, UsersIcon } from "lucide-react"; -import { use, useContext, useState } from "react"; import type React from "react"; +import { use, useContext, useState } from "react"; import UserProfileModal from "./userModals/UserProfileModal"; type SharedSideMenuProps = { @@ -223,7 +223,7 @@ export function SharedSideMenu({ children, ariaLabel }: Readonly {/* Divider */} -
+
{/* Navigation Section for Mobile */}
@@ -246,7 +246,7 @@ export function SharedSideMenu({ children, ariaLabel }: Readonly - + Organization diff --git a/application/account-management/WebApp/shared/components/topMenu/index.tsx b/application/account-management/WebApp/shared/components/topMenu/index.tsx index 5a88735bf..c3e90feaa 100644 --- a/application/account-management/WebApp/shared/components/topMenu/index.tsx +++ b/application/account-management/WebApp/shared/components/topMenu/index.tsx @@ -11,27 +11,32 @@ import AvatarButton from "../AvatarButton"; interface TopMenuProps { children?: ReactNode; + sidePaneOpen?: boolean; } -export function TopMenu({ children }: Readonly) { +export function TopMenu({ children, sidePaneOpen = false }: Readonly) { return ( -