From cf51e547612d945038b00bfc98deaa980a2bd083 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 8 Jun 2025 19:13:59 +0200 Subject: [PATCH 01/88] Enable submit of user search by pressing Enter --- .../admin/users/-components/UserQuerying.tsx | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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..60d85f87a 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -41,6 +41,7 @@ export function UserQuerying() { const [showAllFilters, setShowAllFilters] = useState( Boolean(searchParams.userRole ?? searchParams.userStatus ?? searchParams.startDate ?? searchParams.endDate) ); + const [searchTimeoutId, setSearchTimeoutId] = useState(null); const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false); // Convert URL date strings to DateRange if they exist @@ -71,9 +72,14 @@ export function UserQuerying() { useEffect(() => { const timeoutId = setTimeout(() => { updateFilter({ search: (search as string) || undefined }); + setSearchTimeoutId(null); }, 500); + setSearchTimeoutId(timeoutId); - return () => clearTimeout(timeoutId); + return () => { + clearTimeout(timeoutId); + setSearchTimeoutId(null); + }; }, [search, updateFilter]); // Count active filters for badge @@ -121,7 +127,20 @@ export function UserQuerying() { return (
- + { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + updateFilter({ search: (search as string) || undefined }); + }} + label={t`Search`} + autoFocus={true} + /> {showAllFilters && ( <> From 902aea7229847985cd6bd9ab1a056a011a3f17f6 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 10 Jun 2025 16:21:05 +0200 Subject: [PATCH 02/88] =?UTF-8?q?Rename=20label=20of=20user=20actions=20in?= =?UTF-8?q?=20user=20row=20from=20"Menu=E2=80=9C=20to=20"User=20actions"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WebApp/routes/admin/users/-components/UserTable.tsx | 2 +- .../WebApp/shared/translations/locale/da-DK.po | 7 +++++-- .../WebApp/shared/translations/locale/en-US.po | 3 +++ .../WebApp/shared/translations/locale/nl-NL.po | 7 +++++-- 4 files changed, 14 insertions(+), 5 deletions(-) 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..02d3f1df5 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -204,7 +204,7 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly - diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index f370d9565..620a17e17 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -245,7 +245,7 @@ msgid "Member" msgstr "Medlem" msgid "Menu" -msgstr "Menu" +msgstr "" msgid "Modified" msgstr "Ændret" @@ -395,8 +395,11 @@ msgstr "Opdater dit profilbillede og personlige oplysninger her." msgid "Upload profile picture" msgstr "Upload profilbillede" +msgid "User actions" +msgstr "Brugerhandlinger" + msgid "User invited successfully" -msgstr "Bruger inviteret succesfuldt" +msgstr "" msgid "User profile" msgstr "Brugerprofil" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 99c4c9358..150beba99 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -395,6 +395,9 @@ msgstr "Update your profile picture and personal details here." msgid "Upload profile picture" msgstr "Upload profile picture" +msgid "User actions" +msgstr "User actions" + msgid "User invited successfully" msgstr "User invited successfully" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 953ca5a1c..d8f7e1b8a 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -245,7 +245,7 @@ msgid "Member" msgstr "Lid" msgid "Menu" -msgstr "Menu" +msgstr "" msgid "Modified" msgstr "Gewijzigd" @@ -395,8 +395,11 @@ msgstr "Werk hier je profielfoto en persoonlijke gegevens bij." msgid "Upload profile picture" msgstr "Profielfoto uploaden" +msgid "User actions" +msgstr "Gebruikersacties" + msgid "User invited successfully" -msgstr "Gebruiker succesvol uitgenodigd" +msgstr "" msgid "User profile" msgstr "Gebruikersprofiel" From 81188d869e9bda8abb3e1b6306baba62ab9f7e6e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 15 Jun 2025 10:55:20 +0200 Subject: [PATCH 03/88] Ensure UI state updates on user management when deleting users --- .../routes/admin/users/-components/UserTable.tsx | 1 + .../routes/admin/users/-components/UserToolbar.tsx | 10 ++++++++-- .../WebApp/routes/admin/users/index.tsx | 2 +- .../WebApp/shared/translations/locale/da-DK.po | 3 --- .../WebApp/shared/translations/locale/en-US.po | 3 --- .../WebApp/shared/translations/locale/nl-NL.po | 3 --- 6 files changed, 10 insertions(+), 12 deletions(-) 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 02d3f1df5..6603dcf8e 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -120,6 +120,7 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly !isOpen && setUserToDelete(null)} + onUsersDeleted={() => onSelectedUsersChange([])} /> void; } -export function UserToolbar({ selectedUsers }: Readonly) { +export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly) { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); @@ -39,7 +40,12 @@ export function UserToolbar({ selectedUsers }: Readonly) { )} - + 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..7b8a0c679 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -54,7 +54,7 @@ export default function UsersPage() { Manage your users and permissions here.

- + diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 620a17e17..2ea358e3d 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -244,9 +244,6 @@ msgstr "Administrer dine brugere og tilladelser her." msgid "Member" msgstr "Medlem" -msgid "Menu" -msgstr "" - msgid "Modified" msgstr "Ændret" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 150beba99..ce422eaef 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -244,9 +244,6 @@ msgstr "Manage your users and permissions here." msgid "Member" msgstr "Member" -msgid "Menu" -msgstr "Menu" - msgid "Modified" msgstr "Modified" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index d8f7e1b8a..aab350a84 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -244,9 +244,6 @@ msgstr "Beheer je gebruikers en rechten hier." msgid "Member" msgstr "Lid" -msgid "Menu" -msgstr "" - msgid "Modified" msgstr "Gewijzigd" From 286ccc7140bad4a2df8a94be502a4f3f7c347bb4 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 22 Jun 2025 15:32:21 +0200 Subject: [PATCH 04/88] Change filter icon to list filter for improved user list filtering --- .../WebApp/routes/admin/users/-components/UserQuerying.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 60d85f87a..ca79b6fbb 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -13,7 +13,7 @@ import { SearchField } from "@repo/ui/components/SearchField"; import { Select, SelectItem } from "@repo/ui/components/Select"; import { MEDIA_QUERIES } from "@repo/ui/utils/responsive"; import { useLocation, useNavigate } from "@tanstack/react-router"; -import { FilterIcon, FilterXIcon, XIcon } from "lucide-react"; +import { ListFilter, ListFilterPlus, XIcon } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; // SearchParams interface defines the structure of URL query parameters @@ -217,9 +217,9 @@ export function UserQuerying() { }} > {showAllFilters ? ( - + ) : ( - + )} {activeFilterCount > 0 && ( From fe7b223d16c17e043d2988efa808310eca62a141 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 2 Jul 2025 01:57:17 +0200 Subject: [PATCH 05/88] Rename Invite users button to Invite user --- .../WebApp/routes/admin/users/-components/UserToolbar.tsx | 2 +- .../WebApp/shared/translations/locale/da-DK.po | 5 +---- .../WebApp/shared/translations/locale/en-US.po | 3 --- .../WebApp/shared/translations/locale/nl-NL.po | 5 +---- 4 files changed, 3 insertions(+), 12 deletions(-) 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 7e1718629..db9eef215 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -26,7 +26,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly setIsInviteModalOpen(true)}> - Invite users + Invite user )} diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 2ea358e3d..334a173f9 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -208,9 +208,6 @@ msgstr "Billedet skal være mindre end 1 MB." msgid "Invite user" msgstr "Inviter bruger" -msgid "Invite users" -msgstr "Inviter brugere" - msgid "Invited users" msgstr "Inviterede brugere" @@ -396,7 +393,7 @@ msgid "User actions" msgstr "Brugerhandlinger" msgid "User invited successfully" -msgstr "" +msgstr "Bruger inviteret succesfuldt" msgid "User profile" msgstr "Brugerprofil" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index ce422eaef..e707ca310 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -208,9 +208,6 @@ msgstr "Image must be smaller than 1 MB." msgid "Invite user" msgstr "Invite user" -msgid "Invite users" -msgstr "Invite users" - msgid "Invited users" msgstr "Invited users" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index aab350a84..99ed05c30 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -208,9 +208,6 @@ msgstr "Afbeelding moet kleiner zijn dan 1 MB." msgid "Invite user" msgstr "Gebruiker uitnodigen" -msgid "Invite users" -msgstr "Gebruikers uitnodigen" - msgid "Invited users" msgstr "Uitgenodigde gebruikers" @@ -396,7 +393,7 @@ msgid "User actions" msgstr "Gebruikersacties" msgid "User invited successfully" -msgstr "" +msgstr "Gebruiker succesvol uitgenodigd" msgid "User profile" msgstr "Gebruikersprofiel" From edd1f900af70d9e2b32842aceb232f6b1896dbd0 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 29 Jun 2025 15:09:02 +0200 Subject: [PATCH 06/88] Create initial version of user menu --- .../users/-components/UserProfileSidePane.tsx | 289 ++++++++++++++++++ .../admin/users/-components/UserTable.tsx | 58 ++-- .../WebApp/routes/admin/users/index.tsx | 93 +++++- .../shared/translations/locale/da-DK.po | 36 +++ .../shared/translations/locale/en-US.po | 36 +++ .../shared/translations/locale/nl-NL.po | 36 +++ 6 files changed, 514 insertions(+), 34 deletions(-) create mode 100644 application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx new file mode 100644 index 000000000..caad3ae27 --- /dev/null +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -0,0 +1,289 @@ +import type { components } from "@/shared/lib/api/client"; +import { getUserRoleLabel } from "@/shared/lib/api/userRole"; +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { Avatar } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { Heading } from "@repo/ui/components/Heading"; +import { Separator } from "@repo/ui/components/Separator"; +import { Text } from "@repo/ui/components/Text"; +import { formatDate } from "@repo/utils/date/formatDate"; +import { getInitials } from "@repo/utils/string/getInitials"; +import { PencilIcon, Trash2Icon, XIcon } from "lucide-react"; +import { useEffect, useRef } from "react"; + +type UserDetails = components["schemas"]["UserDetails"]; + +interface UserProfileSidePaneProps { + user: UserDetails | null; + isOpen: boolean; + onClose: () => void; + onChangeRole: (user: UserDetails) => void; + onDeleteUser: (user: UserDetails) => void; +} + +export function UserProfileSidePane({ + user, + isOpen, + onClose, + onChangeRole, + onDeleteUser +}: Readonly) { + const userInfo = useUserInfo(); + const sidePaneRef = useRef(null); + const closeButtonRef = useRef(null); + + // Focus management and keyboard navigation - only focus close button on mobile/tablet + useEffect(() => { + if (isOpen && closeButtonRef.current) { + // Only auto-focus on mobile/tablet, not on 2xl desktop where it's part of the layout + const is2xlScreen = window.matchMedia("(min-width: 1536px)").matches; + if (!is2xlScreen) { + closeButtonRef.current.focus(); + } + } + }, [isOpen]); + + // Escape key handler + 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]); + + // Focus trapping - only on mobile/tablet, not on 2xl desktop + useEffect(() => { + if (!isOpen || !sidePaneRef.current) { + return; + } + + // Don't trap focus on 2xl screens where side pane is part of main layout + const is2xlScreen = window.matchMedia("(min-width: 1536px)").matches; + if (is2xlScreen) { + 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]); + + if (!isOpen || !user) { + return null; + } + + const isCurrentUser = user.id === userInfo?.id; + const canModifyUser = userInfo?.role === "Owner" && !isCurrentUser; + + return ( + <> + {/* Backdrop for tablet/mobile - only show when not in 2xl layout */} + {isOpen && ( +
{ + if (e.key === "Enter" || e.key === " ") { + onClose(); + } + }} + aria-label={t`Close user profile`} + role="button" + tabIndex={0} + /> + )} + + {/* Side pane */} +
+ {/* Header */} +
+ + User profile + + +
+ + {/* Content */} +
+ {/* User Avatar and Basic Info */} +
+ + + {user.firstName} {user.lastName} + + {user.title && {user.title}} +
+ + {/* Contact Information */} +
+ + Contact + +
+
+ + Email + +
+ {user.email} + {user.emailConfirmed ? ( + + Verified + + ) : ( + + Pending + + )} +
+
+
+
+ + + + {/* Role Information */} +
+ + Role + + {getUserRoleLabel(user.role)} +
+ + + + {/* Account Details */} +
+ + Account details + +
+
+ + Created + + {formatDate(user.createdAt)} +
+
+ + Modified + + {formatDate(user.modifiedAt)} +
+
+
+ + + + {/* Future Extensions Placeholders */} +
+ + Timezone + + + Not set + +
+ + + +
+ + Recent login history + + + No recent activity + +
+ + + +
+ + Team memberships + + + No team memberships + +
+
+ + {/* Quick Actions */} + {canModifyUser && ( +
+ + Quick actions + +
+ + +
+
+ )} +
+ + ); +} 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 6603dcf8e..1818a6e79 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -16,17 +16,24 @@ import { EllipsisVerticalIcon, PencilIcon, Trash2Icon, UserIcon } from "lucide-r 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; + onChangeRole: (user: UserDetails) => void; + onDeleteUser: (user: UserDetails) => void; } -export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly) { +export function UserTable({ + selectedUsers, + onSelectedUsersChange, + onViewProfile, + onChangeRole, + onDeleteUser +}: Readonly) { const navigate = useNavigate(); const { search, userRole, userStatus, startDate, endDate, orderBy, sortOrder, pageOffset } = useSearch({ strict: false @@ -53,9 +60,6 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly(null); - const [userToChangeRole, setUserToChangeRole] = useState(null); - const handlePageChange = useCallback( (page: number) => { navigate({ @@ -97,9 +101,18 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly 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) { @@ -110,19 +123,6 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly - !isOpen && setUserToChangeRole(null)} - /> - - !isOpen && setUserToDelete(null)} - onUsersDeleted={() => onSelectedUsersChange([])} - /> -
{users?.users.map((user) => ( - + { + onSelectedUsersChange([user]); + onViewProfile(user); + }} + className="cursor-pointer" + >
{ onSelectedUsersChange([user]); - setUserToDelete(user); + onDeleteUser(user); }} isDisabled={user.id === userInfo?.id} > @@ -209,14 +217,14 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly - + onViewProfile(user)}> View profile setUserToChangeRole(user)} + onAction={() => onChangeRole(user)} > @@ -227,7 +235,7 @@ export function UserTable({ selectedUsers, onSelectedUsersChange }: Readonly setUserToDelete(user)} + onAction={() => onDeleteUser(user)} > diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index 7b8a0c679..891dcc00a 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -8,6 +8,9 @@ import { Breadcrumb } from "@repo/ui/components/Breadcrumbs"; import { createFileRoute } from "@tanstack/react-router"; import { 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"; @@ -31,6 +34,27 @@ 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 handleCloseProfile = () => { + setProfileUser(null); + // Also clear selection when closing profile + setSelectedUsers([]); + }; + + const handleViewProfile = (user: UserDetails | null) => { + setProfileUser(user); + }; + + const handleChangeRole = (user: UserDetails) => { + setUserToChangeRole(user); + }; + + const handleDeleteUser = (user: UserDetails) => { + setUserToDelete(user); + }; return ( <> @@ -47,16 +71,67 @@ export default function UsersPage() { } > -

- Users -

-

- Manage your users and permissions here. -

- - - +
+ {/* Side pane for 2xl screens */} + {profileUser && ( +
+ +
+ )} + + {/* Main content */} +
+

+ Users +

+

+ Manage your users and permissions here. +

+ + + +
+
+ + {/* Side pane for mobile/tablet screens */} +
+ +
+ + !isOpen && setUserToChangeRole(null)} + /> + + !isOpen && setUserToDelete(null)} + onUsersDeleted={() => { + setSelectedUsers([]); + setProfileUser(null); + }} + /> ); } diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 334a173f9..276411b40 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -19,6 +19,9 @@ msgstr "En ny bekræftelseskode er blevet sendt til din e-mail." msgid "Account" msgstr "Konto" +msgid "Account details" +msgstr "" + msgid "Account information" msgstr "Kontoinformation" @@ -111,6 +114,12 @@ msgstr "Ryd" msgid "Clear filters" msgstr "Ryd filtre" +msgid "Close user profile" +msgstr "" + +msgid "Contact" +msgstr "" + msgid "Continue" msgstr "Fortsæt" @@ -256,6 +265,15 @@ msgstr "Navigation" msgid "Next" msgstr "Næste" +msgid "No recent activity" +msgstr "" + +msgid "No team memberships" +msgstr "" + +msgid "Not set" +msgstr "" + msgid "OK" msgstr "OK" @@ -295,6 +313,12 @@ msgstr "Profilbillede" msgid "Profile updated successfully" msgstr "Profil opdateret succesfuldt" +msgid "Quick actions" +msgstr "" + +msgid "Recent login history" +msgstr "" + msgid "Region" msgstr "Region" @@ -359,6 +383,9 @@ msgstr "Support" msgid "System" msgstr "System" +msgid "Team memberships" +msgstr "" + msgid "Terms of use" msgstr "Brugsvilkår" @@ -368,6 +395,9 @@ msgstr "Tema" msgid "This is the region where your data is stored" msgstr "Dette er den region, hvor dine data er lagret" +msgid "Timezone" +msgstr "" + msgid "Title" msgstr "Titel" @@ -398,6 +428,9 @@ msgstr "Bruger inviteret succesfuldt" msgid "User profile" msgstr "Brugerprofil" +msgid "User profile details" +msgstr "" + msgid "User profile menu" msgstr "Brugerprofilmenu" @@ -422,6 +455,9 @@ msgstr "Brugere, der ikke har bekræftet deres e-mail" msgid "Verification code sent" msgstr "Bekræftelseskode sendt" +msgid "Verified" +msgstr "" + msgid "Verify" msgstr "Bekræft" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index e707ca310..ffe8d5ca9 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -19,6 +19,9 @@ msgstr "A new verification code has been sent to your email." msgid "Account" msgstr "Account" +msgid "Account details" +msgstr "Account details" + msgid "Account information" msgstr "Account information" @@ -111,6 +114,12 @@ msgstr "Clear" msgid "Clear filters" msgstr "Clear filters" +msgid "Close user profile" +msgstr "Close user profile" + +msgid "Contact" +msgstr "Contact" + msgid "Continue" msgstr "Continue" @@ -256,6 +265,15 @@ msgstr "Navigation" msgid "Next" msgstr "Next" +msgid "No recent activity" +msgstr "No recent activity" + +msgid "No team memberships" +msgstr "No team memberships" + +msgid "Not set" +msgstr "Not set" + msgid "OK" msgstr "OK" @@ -295,6 +313,12 @@ msgstr "Profile picture" msgid "Profile updated successfully" msgstr "Profile updated successfully" +msgid "Quick actions" +msgstr "Quick actions" + +msgid "Recent login history" +msgstr "Recent login history" + msgid "Region" msgstr "Region" @@ -359,6 +383,9 @@ msgstr "Support" msgid "System" msgstr "System" +msgid "Team memberships" +msgstr "Team memberships" + msgid "Terms of use" msgstr "Terms of use" @@ -368,6 +395,9 @@ msgstr "Theme" msgid "This is the region where your data is stored" msgstr "This is the region where your data is stored" +msgid "Timezone" +msgstr "Timezone" + msgid "Title" msgstr "Title" @@ -398,6 +428,9 @@ msgstr "User invited successfully" msgid "User profile" msgstr "User profile" +msgid "User profile details" +msgstr "User profile details" + msgid "User profile menu" msgstr "User profile menu" @@ -422,6 +455,9 @@ msgstr "Users who haven't confirmed their email" msgid "Verification code sent" msgstr "Verification code sent" +msgid "Verified" +msgstr "Verified" + msgid "Verify" msgstr "Verify" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 99ed05c30..3b4731091 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -19,6 +19,9 @@ msgstr "Een nieuwe verificatiecode is naar je e-mail verzonden." msgid "Account" msgstr "Account" +msgid "Account details" +msgstr "" + msgid "Account information" msgstr "Accountinformatie" @@ -111,6 +114,12 @@ msgstr "Wissen" msgid "Clear filters" msgstr "Filters wissen" +msgid "Close user profile" +msgstr "" + +msgid "Contact" +msgstr "" + msgid "Continue" msgstr "Verder" @@ -256,6 +265,15 @@ msgstr "Navigatie" msgid "Next" msgstr "Volgende" +msgid "No recent activity" +msgstr "" + +msgid "No team memberships" +msgstr "" + +msgid "Not set" +msgstr "" + msgid "OK" msgstr "OK" @@ -295,6 +313,12 @@ msgstr "Profielfoto" msgid "Profile updated successfully" msgstr "Profiel succesvol bijgewerkt" +msgid "Quick actions" +msgstr "" + +msgid "Recent login history" +msgstr "" + msgid "Region" msgstr "Regio" @@ -359,6 +383,9 @@ msgstr "Ondersteuning" msgid "System" msgstr "Systeem" +msgid "Team memberships" +msgstr "" + msgid "Terms of use" msgstr "Gebruiksvoorwaarden" @@ -368,6 +395,9 @@ msgstr "Thema" msgid "This is the region where your data is stored" msgstr "Dit is de regio waar je gegevens zijn opgeslagen" +msgid "Timezone" +msgstr "" + msgid "Title" msgstr "Titel" @@ -398,6 +428,9 @@ msgstr "Gebruiker succesvol uitgenodigd" msgid "User profile" msgstr "Gebruikersprofiel" +msgid "User profile details" +msgstr "" + msgid "User profile menu" msgstr "Gebruikersprofielmenu" @@ -422,6 +455,9 @@ msgstr "Gebruikers die hun e-mail niet hebben bevestigd" msgid "Verification code sent" msgstr "Verificatiecode verzonden" +msgid "Verified" +msgstr "" + msgid "Verify" msgstr "Verifiëren" From d3d39e282030523fe60d8b17db88c8dc4b94f8d1 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 29 Jun 2025 15:26:08 +0200 Subject: [PATCH 07/88] Fix right positioning and overlap, improve layout on UserProfileSidePane --- .../users/-components/UserProfileSidePane.tsx | 104 +++++------------- .../WebApp/routes/admin/users/index.tsx | 26 ++--- .../shared/translations/locale/da-DK.po | 18 --- .../shared/translations/locale/en-US.po | 18 --- .../shared/translations/locale/nl-NL.po | 18 --- .../shared-webapp/utils/date/formatDate.ts | 18 ++- 6 files changed, 58 insertions(+), 144 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index caad3ae27..e59978955 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -115,32 +115,16 @@ export function UserProfileSidePane({ return ( <> - {/* Backdrop for tablet/mobile - only show when not in 2xl layout */} - {isOpen && ( -
{ - if (e.key === "Enter" || e.key === " ") { - onClose(); - } - }} - aria-label={t`Close user profile`} - role="button" - tabIndex={0} - /> - )} - {/* Side pane */}
{/* Header */}
- + User profile
@@ -161,28 +145,28 @@ export function UserProfileSidePane({ - + {user.firstName} {user.lastName} - {user.title && {user.title}} + {user.title && {user.title}}
{/* Contact Information */} -
- +
+ Contact
- + Email
- {user.email} + {user.email} {user.emailConfirmed ? ( Verified @@ -197,86 +181,54 @@ export function UserProfileSidePane({
- + {/* Role Information */} -
- +
+ Role - {getUserRoleLabel(user.role)} + + {getUserRoleLabel(user.role)} +
- + {/* Account Details */} -
- +
+ Account details -
+
- + Created - {formatDate(user.createdAt)} + {formatDate(user.createdAt, true)}
- + Modified - {formatDate(user.modifiedAt)} + {formatDate(user.modifiedAt, true)}
- - - - {/* Future Extensions Placeholders */} -
- - Timezone - - - Not set - -
- - - -
- - Recent login history - - - No recent activity - -
- - - -
- - Team memberships - - - No team memberships - -
{/* Quick Actions */} {canModifyUser && (
- + Quick actions
- - diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index 891dcc00a..195504372 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -72,19 +72,6 @@ export default function UsersPage() { } >
- {/* Side pane for 2xl screens */} - {profileUser && ( -
- -
- )} - {/* Main content */}

@@ -103,6 +90,19 @@ export default function UsersPage() { onDeleteUser={handleDeleteUser} />

+ + {/* Side pane for 2xl screens */} + {profileUser && ( +
+ +
+ )}
diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 276411b40..ad42b26a6 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -265,15 +265,6 @@ msgstr "Navigation" msgid "Next" msgstr "Næste" -msgid "No recent activity" -msgstr "" - -msgid "No team memberships" -msgstr "" - -msgid "Not set" -msgstr "" - msgid "OK" msgstr "OK" @@ -316,9 +307,6 @@ msgstr "Profil opdateret succesfuldt" msgid "Quick actions" msgstr "" -msgid "Recent login history" -msgstr "" - msgid "Region" msgstr "Region" @@ -383,9 +371,6 @@ msgstr "Support" msgid "System" msgstr "System" -msgid "Team memberships" -msgstr "" - msgid "Terms of use" msgstr "Brugsvilkår" @@ -395,9 +380,6 @@ msgstr "Tema" msgid "This is the region where your data is stored" msgstr "Dette er den region, hvor dine data er lagret" -msgid "Timezone" -msgstr "" - msgid "Title" msgstr "Titel" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index ffe8d5ca9..e7be9fa9c 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -265,15 +265,6 @@ msgstr "Navigation" msgid "Next" msgstr "Next" -msgid "No recent activity" -msgstr "No recent activity" - -msgid "No team memberships" -msgstr "No team memberships" - -msgid "Not set" -msgstr "Not set" - msgid "OK" msgstr "OK" @@ -316,9 +307,6 @@ msgstr "Profile updated successfully" msgid "Quick actions" msgstr "Quick actions" -msgid "Recent login history" -msgstr "Recent login history" - msgid "Region" msgstr "Region" @@ -383,9 +371,6 @@ msgstr "Support" msgid "System" msgstr "System" -msgid "Team memberships" -msgstr "Team memberships" - msgid "Terms of use" msgstr "Terms of use" @@ -395,9 +380,6 @@ msgstr "Theme" msgid "This is the region where your data is stored" msgstr "This is the region where your data is stored" -msgid "Timezone" -msgstr "Timezone" - msgid "Title" msgstr "Title" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 3b4731091..073713f94 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -265,15 +265,6 @@ msgstr "Navigatie" msgid "Next" msgstr "Volgende" -msgid "No recent activity" -msgstr "" - -msgid "No team memberships" -msgstr "" - -msgid "Not set" -msgstr "" - msgid "OK" msgstr "OK" @@ -316,9 +307,6 @@ msgstr "Profiel succesvol bijgewerkt" msgid "Quick actions" msgstr "" -msgid "Recent login history" -msgstr "" - msgid "Region" msgstr "Regio" @@ -383,9 +371,6 @@ msgstr "Ondersteuning" msgid "System" msgstr "Systeem" -msgid "Team memberships" -msgstr "" - msgid "Terms of use" msgstr "Gebruiksvoorwaarden" @@ -395,9 +380,6 @@ msgstr "Thema" msgid "This is the region where your data is stored" msgstr "Dit is de regio waar je gegevens zijn opgeslagen" -msgid "Timezone" -msgstr "" - msgid "Title" msgstr "Titel" diff --git a/application/shared-webapp/utils/date/formatDate.ts b/application/shared-webapp/utils/date/formatDate.ts index b03c2d4a6..2466ce2ae 100644 --- a/application/shared-webapp/utils/date/formatDate.ts +++ b/application/shared-webapp/utils/date/formatDate.ts @@ -1,12 +1,28 @@ /** * Format a date string to a consistent format across the application. * Format: "day month, year" (e.g., "15 Jan, 2024") + * Or with time: "day month, year at time" (e.g., "15 Jan, 2024 at 14:30") */ -export function formatDate(input: string | undefined | null): string { +export function formatDate(input: string | undefined | null, includeTime = false): string { if (!input) { return ""; } const date = new Date(input); + + if (includeTime) { + const dateStr = date.toLocaleDateString(undefined, { + day: "numeric", + month: "short", + year: "numeric" + }); + const timeStr = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false + }); + return `${dateStr} at ${timeStr}`; + } + return date.toLocaleDateString(undefined, { day: "numeric", month: "short", From c52c488a623a5b4e056ec7e813e71f273b940c7f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 29 Jun 2025 15:49:16 +0200 Subject: [PATCH 08/88] Apply additional UserProfileSidePane feedback improvements --- .../users/-components/UserProfileSidePane.tsx | 29 ++++++++----------- .../admin/users/-components/UserTable.tsx | 16 +++++----- .../WebApp/routes/admin/users/index.tsx | 18 +++++++----- .../shared/translations/locale/da-DK.po | 9 ------ .../shared/translations/locale/en-US.po | 9 ------ .../shared/translations/locale/nl-NL.po | 9 ------ 6 files changed, 31 insertions(+), 59 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index e59978955..e09264b8d 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -118,12 +118,12 @@ export function UserProfileSidePane({ {/* Side pane */}
{/* Header */} -
+
User profile @@ -157,15 +157,12 @@ export function UserProfileSidePane({ {/* Contact Information */}
- - Contact -
Email -
+
{user.email} {user.emailConfirmed ? ( @@ -197,21 +194,18 @@ export function UserProfileSidePane({ {/* Account Details */}
- - Account details -
Created - {formatDate(user.createdAt, true)} + {formatDate(user.createdAt, true)}
Modified - {formatDate(user.modifiedAt, true)} + {formatDate(user.modifiedAt, true)}
@@ -220,15 +214,16 @@ export function UserProfileSidePane({ {/* Quick Actions */} {canModifyUser && (
- - Quick actions - -
- - 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 1818a6e79..946ed5360 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -97,6 +97,8 @@ export function UserTable({ (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)) ?? []; @@ -159,12 +161,12 @@ export function UserTable({ key={user.id} id={user.id} onAction={() => { + // Switch to this user (unselect previous, select this one) onSelectedUsersChange([user]); onViewProfile(user); }} - className="cursor-pointer" > - +
- {user.email} - {formatDate(user.createdAt)} - {formatDate(user.modifiedAt)} - + {user.email} + {formatDate(user.createdAt)} + {formatDate(user.modifiedAt)} + {getUserRoleLabel(user.role)} @@ -252,7 +254,7 @@ export function UserTable({
{users && ( -
+
{/* Main content */} -
+

Users

@@ -82,13 +82,15 @@ export default function UsersPage() {

- +
+ +
{/* Side pane for 2xl screens */} diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index ad42b26a6..64d6e7374 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -19,9 +19,6 @@ msgstr "En ny bekræftelseskode er blevet sendt til din e-mail." msgid "Account" msgstr "Konto" -msgid "Account details" -msgstr "" - msgid "Account information" msgstr "Kontoinformation" @@ -117,9 +114,6 @@ msgstr "Ryd filtre" msgid "Close user profile" msgstr "" -msgid "Contact" -msgstr "" - msgid "Continue" msgstr "Fortsæt" @@ -304,9 +298,6 @@ msgstr "Profilbillede" msgid "Profile updated successfully" msgstr "Profil opdateret succesfuldt" -msgid "Quick actions" -msgstr "" - msgid "Region" msgstr "Region" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index e7be9fa9c..a4600e1c1 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -19,9 +19,6 @@ msgstr "A new verification code has been sent to your email." msgid "Account" msgstr "Account" -msgid "Account details" -msgstr "Account details" - msgid "Account information" msgstr "Account information" @@ -117,9 +114,6 @@ msgstr "Clear filters" msgid "Close user profile" msgstr "Close user profile" -msgid "Contact" -msgstr "Contact" - msgid "Continue" msgstr "Continue" @@ -304,9 +298,6 @@ msgstr "Profile picture" msgid "Profile updated successfully" msgstr "Profile updated successfully" -msgid "Quick actions" -msgstr "Quick actions" - msgid "Region" msgstr "Region" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 073713f94..4d7ae1532 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -19,9 +19,6 @@ msgstr "Een nieuwe verificatiecode is naar je e-mail verzonden." msgid "Account" msgstr "Account" -msgid "Account details" -msgstr "" - msgid "Account information" msgstr "Accountinformatie" @@ -117,9 +114,6 @@ msgstr "Filters wissen" msgid "Close user profile" msgstr "" -msgid "Contact" -msgstr "" - msgid "Continue" msgstr "Verder" @@ -304,9 +298,6 @@ msgstr "Profielfoto" msgid "Profile updated successfully" msgstr "Profiel succesvol bijgewerkt" -msgid "Quick actions" -msgstr "" - msgid "Region" msgstr "Regio" From ed9c51d771410cdc24dedc6598be006b117f0af5 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 29 Jun 2025 16:26:07 +0200 Subject: [PATCH 09/88] Fix table height issue preventing e2e tests from passing --- .../admin/users/-components/UserTable.tsx | 252 +++++++++--------- .../WebApp/routes/admin/users/index.tsx | 6 +- 2 files changed, 131 insertions(+), 127 deletions(-) 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 946ed5360..7b85a9a9f 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -124,134 +124,136 @@ export function UserTable({ const currentPage = (users?.currentPageOffset ?? 0) + 1; return ( - <> - user.id)} - onSelectionChange={handleSelectionChange} - sortDescriptor={sortDescriptor} - onSortChange={handleSortChange} - aria-label={t`Users`} - > - - - Name - - - Email - - - Created - - - Modified - - - Role - - - Actions - - - - {users?.users.map((user) => ( - { - // Switch to this user (unselect previous, select this one) - onSelectedUsersChange([user]); - onViewProfile(user); - }} - > - -
- -
-
- {user.firstName} {user.lastName} - {user.emailConfirmed ? ( - "" - ) : ( - - Pending - - )} +
+
+
user.id)} + onSelectionChange={handleSelectionChange} + sortDescriptor={sortDescriptor} + onSortChange={handleSortChange} + aria-label={t`Users`} + > + + + Name + + + Email + + + Created + + + Modified + + + Role + + + Actions + + + + {users?.users.map((user) => ( + { + // Switch to this user (unselect previous, select this one) + onSelectedUsersChange([user]); + onViewProfile(user); + }} + > + +
+ +
+
+ {user.firstName} {user.lastName} + {user.emailConfirmed ? ( + "" + ) : ( + + Pending + + )} +
+
{user.title ?? ""}
-
{user.title ?? ""}
- -
- {user.email} - {formatDate(user.createdAt)} - {formatDate(user.modifiedAt)} - - {getUserRoleLabel(user.role)} - - -
- - { - if (isOpen) { + + {user.email} + {formatDate(user.createdAt)} + {formatDate(user.modifiedAt)} + + {getUserRoleLabel(user.role)} + + +
+ - - onViewProfile(user)}> - - View profile - - onChangeRole(user)} - > - - - Change role - - - - onDeleteUser(user)} - > - - - Delete - - - - -
-
- - ))} - -
+ { + if (isOpen) { + onSelectedUsersChange([user]); + } + }} + > + + + onViewProfile(user)}> + + View profile + + onChangeRole(user)} + > + + + Change role + + + + onDeleteUser(user)} + > + + + Delete + + + + +
+ + + ))} + + +
{users && (
@@ -273,6 +275,6 @@ export function UserTable({ />
)} - +
); } diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index 2dec30b74..4ec320e8a 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -73,7 +73,9 @@ export default function UsersPage() { >
{/* Main content */} -
+

Users

@@ -82,7 +84,7 @@ export default function UsersPage() {

-
+
Date: Sun, 29 Jun 2025 17:00:00 +0200 Subject: [PATCH 10/88] Improve user profile side pane styling and table interaction --- .../users/-components/UserProfileSidePane.tsx | 23 +++++++++---------- .../WebApp/routes/admin/users/index.tsx | 12 ++++++---- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index e09264b8d..792e37d59 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -38,9 +38,9 @@ export function UserProfileSidePane({ // Focus management and keyboard navigation - only focus close button on mobile/tablet useEffect(() => { if (isOpen && closeButtonRef.current) { - // Only auto-focus on mobile/tablet, not on 2xl desktop where it's part of the layout - const is2xlScreen = window.matchMedia("(min-width: 1536px)").matches; - if (!is2xlScreen) { + // Only auto-focus on mobile/tablet, not on xl desktop where it's part of the layout + const isXlScreen = window.matchMedia("(min-width: 1280px)").matches; + if (!isXlScreen) { closeButtonRef.current.focus(); } } @@ -64,15 +64,15 @@ export function UserProfileSidePane({ }; }, [isOpen, onClose]); - // Focus trapping - only on mobile/tablet, not on 2xl desktop + // Focus trapping - only on mobile/tablet, not on xl desktop useEffect(() => { if (!isOpen || !sidePaneRef.current) { return; } - // Don't trap focus on 2xl screens where side pane is part of main layout - const is2xlScreen = window.matchMedia("(min-width: 1536px)").matches; - if (is2xlScreen) { + // Don't trap focus on xl screens where side pane is part of main layout + const isXlScreen = window.matchMedia("(min-width: 1280px)").matches; + if (isXlScreen) { return; } @@ -123,7 +123,7 @@ export function UserProfileSidePane({ aria-label={t`User profile details`} > {/* Header */} -
+
User profile @@ -132,7 +132,6 @@ export function UserProfileSidePane({ variant="icon" onPress={onClose} aria-label={t`Close user profile`} - className="2xl:hidden" > @@ -145,7 +144,7 @@ export function UserProfileSidePane({ @@ -199,13 +198,13 @@ export function UserProfileSidePane({ Created - {formatDate(user.createdAt, true)} + {formatDate(user.createdAt, true)}
Modified - {formatDate(user.modifiedAt, true)} + {formatDate(user.modifiedAt, true)}
diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index 4ec320e8a..fc9f7c5f3 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -74,7 +74,9 @@ export default function UsersPage() {
{/* Main content */}

Users @@ -84,7 +86,7 @@ export default function UsersPage() {

-
+
- {/* Side pane for 2xl screens */} + {/* Side pane for large screens */} {profileUser && ( -
+
{/* Side pane for mobile/tablet screens */} -
+
Date: Sun, 29 Jun 2025 18:34:57 +0200 Subject: [PATCH 11/88] Make user profile side pane always dock on tablet and desktop screens --- .../users/-components/UserProfileSidePane.tsx | 29 +++--- .../admin/users/-components/UserTable.tsx | 91 +++++++++++++------ .../admin/users/-components/UserToolbar.tsx | 4 +- .../WebApp/routes/admin/users/index.tsx | 22 ++--- .../shared/translations/locale/da-DK.po | 6 +- .../shared/translations/locale/en-US.po | 6 +- .../shared/translations/locale/nl-NL.po | 6 +- 7 files changed, 95 insertions(+), 69 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 792e37d59..3bc0b6d5b 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -35,12 +35,12 @@ export function UserProfileSidePane({ const sidePaneRef = useRef(null); const closeButtonRef = useRef(null); - // Focus management and keyboard navigation - only focus close button on mobile/tablet + // Focus management and keyboard navigation - only focus close button on mobile useEffect(() => { if (isOpen && closeButtonRef.current) { - // Only auto-focus on mobile/tablet, not on xl desktop where it's part of the layout - const isXlScreen = window.matchMedia("(min-width: 1280px)").matches; - if (!isXlScreen) { + // Only auto-focus on mobile, not on larger screens where it's part of the layout + const isMobileScreen = window.matchMedia("(max-width: 639px)").matches; + if (isMobileScreen) { closeButtonRef.current.focus(); } } @@ -64,15 +64,15 @@ export function UserProfileSidePane({ }; }, [isOpen, onClose]); - // Focus trapping - only on mobile/tablet, not on xl desktop + // Focus trapping - only on mobile, not on larger screens where side pane is part of layout useEffect(() => { if (!isOpen || !sidePaneRef.current) { return; } - // Don't trap focus on xl screens where side pane is part of main layout - const isXlScreen = window.matchMedia("(min-width: 1280px)").matches; - if (isXlScreen) { + // Don't trap focus on larger screens where side pane is part of main layout + const isMobileScreen = window.matchMedia("(max-width: 639px)").matches; + if (!isMobileScreen) { return; } @@ -118,7 +118,7 @@ export function UserProfileSidePane({ {/* Side pane */}
@@ -127,12 +127,7 @@ export function UserProfileSidePane({ User profile -
@@ -157,7 +152,7 @@ export function UserProfileSidePane({ {/* Contact Information */}
-
+
Email @@ -193,7 +188,7 @@ export function UserProfileSidePane({ {/* Account Details */}
-
+
Created 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 7b85a9a9f..40c48cd29 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -117,6 +117,24 @@ export function UserTable({ [users?.users, onSelectedUsersChange, onViewProfile] ); + const handleRowClick = useCallback( + (user: UserDetails) => { + // When clicking on a row (not checkbox), toggle selection of this user + const isCurrentlySelected = selectedUsers.some((selectedUser) => selectedUser.id === user.id); + + if (isCurrentlySelected && selectedUsers.length === 1) { + // If clicking on the only selected user, deselect it + onSelectedUsersChange([]); + onViewProfile(null); + } else { + // Otherwise, unselect all others and select only this user + onSelectedUsersChange([user]); + onViewProfile(user); + } + }, + [selectedUsers, onSelectedUsersChange, onViewProfile] + ); + if (isLoading) { return null; } @@ -158,17 +176,13 @@ export function UserTable({ {users?.users.map((user) => ( - { - // Switch to this user (unselect previous, select this one) - onSelectedUsersChange([user]); - onViewProfile(user); - }} - > - -
+ + +
-
+ - {user.email} - {formatDate(user.createdAt)} - {formatDate(user.modifiedAt)} - - {getUserRoleLabel(user.role)} + + -
- + + + + + + + + + +
{ if (isOpen) { 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 db9eef215..2d62ca7df 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -30,11 +30,11 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly )} - {selectedUsers.length > 0 && ( + {selectedUsers.length > 1 && ( )} diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index fc9f7c5f3..e654aefd6 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -71,13 +71,9 @@ export default function UsersPage() { } > -
+
{/* Main content */} -
+

Users

@@ -85,8 +81,10 @@ export default function UsersPage() { Manage your users and permissions here.

- -
+
+ +
+
- {/* Side pane for large screens */} + {/* Side pane - always dock on sm+ */} {profileUser && ( -
+
- {/* Side pane for mobile/tablet screens */} -
+ {/* Side pane for mobile screens - overlay on mobile only */} +
Date: Sun, 29 Jun 2025 18:58:32 +0200 Subject: [PATCH 12/88] Fix UserProfileSidePane styling issues --- .../routes/admin/users/-components/UserProfileSidePane.tsx | 4 ++-- .../account-management/WebApp/routes/admin/users/index.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 3bc0b6d5b..b67c640ed 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -118,7 +118,7 @@ export function UserProfileSidePane({ {/* Side pane */}
@@ -207,7 +207,7 @@ export function UserProfileSidePane({ {/* Quick Actions */} {canModifyUser && ( -
+
+ + {user.title ?? ""} + + - + - + - + - + -
+ { if (isOpen) { @@ -280,7 +244,7 @@ export function UserTable({

-
+ ))} 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 2d62ca7df..2ec9dfd85 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -22,7 +22,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly
- {selectedUsers.length === 0 && ( + {selectedUsers.length < 2 && ( )} + {selectedUsers.length === 1 && ( + + )} {selectedUsers.length > 1 && ( - -
+ )} 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 a1e7492de..805994c91 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -13,7 +13,7 @@ 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, 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"; @@ -24,7 +24,6 @@ interface UserTableProps { selectedUsers: UserDetails[]; onSelectedUsersChange: (users: UserDetails[]) => void; onViewProfile: (user: UserDetails | null) => void; - onChangeRole: (user: UserDetails) => void; onDeleteUser: (user: UserDetails) => void; } @@ -32,7 +31,6 @@ export function UserTable({ selectedUsers, onSelectedUsersChange, onViewProfile, - onChangeRole, onDeleteUser }: Readonly) { const navigate = useNavigate(); @@ -218,16 +216,6 @@ export function UserTable({ View profile - onChangeRole(user)} - > - - - Change role - - )} - {selectedUsers.length === 1 && ( - - )} {selectedUsers.length > 1 && ( {/* Content */} diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 8cccbe19f..d5a2e7ff6 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -360,7 +360,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly e.key === "Enter" && closeOverlay()} role="button" From 156bb14aaa4c758752c063b7adec6e2dbe1f12fd Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 01:35:06 +0200 Subject: [PATCH 28/88] Collapse filter buttons when there is no space --- .../admin/users/-components/UserQuerying.tsx | 126 +++++++++++++++--- 1 file changed, 108 insertions(+), 18 deletions(-) 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 173b91397..beb1d70f1 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -12,9 +12,10 @@ import { Modal } from "@repo/ui/components/Modal"; import { SearchField } from "@repo/ui/components/SearchField"; import { Select, SelectItem } from "@repo/ui/components/Select"; import { useSideMenuLayout } from "@repo/ui/hooks/useSideMenuLayout"; +import { MEDIA_QUERIES } from "@repo/ui/utils/responsive"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { ListFilter, ListFilterPlus, XIcon } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; // SearchParams interface defines the structure of URL query parameters interface SearchParams { @@ -37,13 +38,15 @@ interface SearchParams { export function UserQuerying() { const navigate = useNavigate(); const searchParams = (useLocation().search as SearchParams) ?? {}; - const { isOverlayOpen, isMobileMenuOpen, isCollapsed, isLargeScreen } = useSideMenuLayout(); + 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 = @@ -100,21 +103,73 @@ export function UserQuerying() { const activeFilterCount = getActiveFilterCount(); - // Handle screen size and side menu changes to show/hide filters appropriately + // Handle screen size and container space changes to show/hide filters appropriately useEffect(() => { - // Consider screen size AND side menu state for available space - // On XL screens: show inline filters only when side menu is collapsed - // On smaller screens: always use modal regardless of side menu state - const hasSpaceForInlineFilters = isLargeScreen && isCollapsed && !isOverlayOpen && !isMobileMenuOpen; + const checkFilterSpace = () => { + // Double-check screen size with direct media query for cross-browser consistency + const isXlScreenDirect = window.matchMedia(MEDIA_QUERIES.xl).matches; - if (hasSpaceForInlineFilters && activeFilterCount > 0 && !showAllFilters) { - // Show inline filters if there's space and active filters exist - setShowAllFilters(true); - } else if (!hasSpaceForInlineFilters && showAllFilters) { - // Hide inline filters if there's insufficient space - setShowAllFilters(false); - } - }, [activeFilterCount, showAllFilters, isOverlayOpen, isMobileMenuOpen, isCollapsed, isLargeScreen]); + if (!isXlScreenDirect || isOverlayOpen || isMobileMenuOpen) { + // On smaller screens or when overlays are open, always use modal + if (showAllFilters) { + setShowAllFilters(false); + } + return; + } + + if (!containerRef.current) return; + + // Measure the actual available space by finding the parent toolbar container + const toolbarContainer = containerRef.current.closest('.flex.items-center.justify-between') as HTMLElement; + if (!toolbarContainer) return; + + const toolbarWidth = toolbarContainer.offsetWidth; + const rightSideButtons = toolbarContainer.querySelector('.flex.items-center.gap-2:last-child') as HTMLElement; + const searchField = containerRef.current.querySelector('input[type="text"]') as HTMLElement; + const filterButton = containerRef.current.querySelector('[data-testid="filter-button"]') as HTMLElement; + + // Calculate space used by existing elements + const searchWidth = searchField?.offsetWidth || 300; + const filterButtonWidth = filterButton?.offsetWidth || 50; + const rightSideWidth = rightSideButtons?.offsetWidth || 200; + const gaps = 16; // gap-2 between main sections + const minimumFilterSpace = 450; // Minimum space needed for all three filter controls + + const usedSpace = searchWidth + filterButtonWidth + rightSideWidth + gaps; + const availableSpace = toolbarWidth - usedSpace; + + const hasSpaceForInlineFilters = availableSpace >= minimumFilterSpace; + + if (hasSpaceForInlineFilters && activeFilterCount > 0 && !showAllFilters) { + // Show inline filters if there's space and active filters exist + setShowAllFilters(true); + } else if (!hasSpaceForInlineFilters && showAllFilters) { + // Hide inline filters if there's insufficient space + setShowAllFilters(false); + } + }; + + // Run check immediately + checkFilterSpace(); + + // Also listen for resize events to handle browser-specific timing issues + const handleResize = () => { + // Small delay to ensure all hooks have updated + setTimeout(checkFilterSpace, 50); + }; + + // 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); + clearTimeout(timeoutId); + }; + }, [activeFilterCount, showAllFilters, isOverlayOpen, isMobileMenuOpen]); const clearAllFilters = () => { updateFilter({ userRole: undefined, userStatus: undefined, startDate: undefined, endDate: undefined }); @@ -123,7 +178,7 @@ export function UserQuerying() { }; return ( -
+
{ - // Determine if we have space for inline filters - const hasSpaceForInlineFilters = isLargeScreen && isCollapsed && !isOverlayOpen && !isMobileMenuOpen; + // Determine if we have space for inline filters with cross-browser check + const isXlScreenDirect = window.matchMedia(MEDIA_QUERIES.xl).matches; + + if (!isXlScreenDirect || isOverlayOpen || isMobileMenuOpen) { + // On smaller screens or when overlays are open, always use modal + setIsFilterPanelOpen(true); + return; + } + + if (!containerRef.current) { + setIsFilterPanelOpen(true); + return; + } + + // Measure the actual available space by finding the parent toolbar container + const toolbarContainer = containerRef.current.closest('.flex.items-center.justify-between') as HTMLElement; + if (!toolbarContainer) { + setIsFilterPanelOpen(true); + return; + } + + const toolbarWidth = toolbarContainer.offsetWidth; + const rightSideButtons = toolbarContainer.querySelector('.flex.items-center.gap-2:last-child') as HTMLElement; + const searchField = containerRef.current.querySelector('input[type="text"]') as HTMLElement; + const filterButton = containerRef.current.querySelector('[data-testid="filter-button"]') as HTMLElement; + + // Calculate space used by existing elements + const searchWidth = searchField?.offsetWidth || 300; + const filterButtonWidth = filterButton?.offsetWidth || 50; + const rightSideWidth = rightSideButtons?.offsetWidth || 200; + const gaps = 16; // gap-2 between main sections + const minimumFilterSpace = 450; // Minimum space needed for all three filter controls + + const usedSpace = searchWidth + filterButtonWidth + rightSideWidth + gaps; + const availableSpace = toolbarWidth - usedSpace; + + const hasSpaceForInlineFilters = availableSpace >= minimumFilterSpace; if (hasSpaceForInlineFilters && showAllFilters) { // If filters are showing and we have space, clear them From cdf65acf973b7af583c52c088ec6765bc0cfa6b4 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 10:31:27 +0200 Subject: [PATCH 29/88] Make user filter bar buttons collapse and expand depending on available space --- .../admin/users/-components/UserQuerying.tsx | 47 ++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) 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 beb1d70f1..78366a4c8 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -105,13 +105,24 @@ export function UserQuerying() { // Handle screen size and container space changes to show/hide filters appropriately useEffect(() => { + let debounceTimeout: NodeJS.Timeout | null = null; + let lastStateChange = 0; + const checkFilterSpace = () => { + const now = Date.now(); + + // Circuit breaker: prevent rapid state changes + if (now - lastStateChange < 200) { + return; + } + // Double-check screen size with direct media query for cross-browser consistency const isXlScreenDirect = window.matchMedia(MEDIA_QUERIES.xl).matches; if (!isXlScreenDirect || isOverlayOpen || isMobileMenuOpen) { // On smaller screens or when overlays are open, always use modal if (showAllFilters) { + lastStateChange = now; setShowAllFilters(false); } return; @@ -124,14 +135,13 @@ export function UserQuerying() { if (!toolbarContainer) return; const toolbarWidth = toolbarContainer.offsetWidth; - const rightSideButtons = toolbarContainer.querySelector('.flex.items-center.gap-2:last-child') as HTMLElement; const searchField = containerRef.current.querySelector('input[type="text"]') as HTMLElement; const filterButton = containerRef.current.querySelector('[data-testid="filter-button"]') as HTMLElement; - // Calculate space used by existing elements + // Calculate space used by existing elements - ALWAYS assume filters are hidden for measurement const searchWidth = searchField?.offsetWidth || 300; const filterButtonWidth = filterButton?.offsetWidth || 50; - const rightSideWidth = rightSideButtons?.offsetWidth || 200; + const rightSideWidth = 130; // Fixed width for action buttons (not affected by filter state) const gaps = 16; // gap-2 between main sections const minimumFilterSpace = 450; // Minimum space needed for all three filter controls @@ -141,21 +151,36 @@ export function UserQuerying() { const hasSpaceForInlineFilters = availableSpace >= minimumFilterSpace; if (hasSpaceForInlineFilters && activeFilterCount > 0 && !showAllFilters) { - // Show inline filters if there's space and active filters exist + lastStateChange = now; setShowAllFilters(true); } else if (!hasSpaceForInlineFilters && showAllFilters) { - // Hide inline filters if there's insufficient space + lastStateChange = now; setShowAllFilters(false); } }; + 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 = () => { - // Small delay to ensure all hooks have updated - setTimeout(checkFilterSpace, 50); + 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 @@ -165,9 +190,17 @@ export function UserQuerying() { }, 100); window.addEventListener("resize", handleResize); + 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, isOverlayOpen, isMobileMenuOpen]); From b3adddb0387e14cfaa92331331848db9dc139d56 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 10:35:23 +0200 Subject: [PATCH 30/88] Ensure space for filter buttons is calculated when showing/hiding user sidebar pane --- .../admin/users/-components/UserQuerying.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) 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 78366a4c8..32f75c933 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -103,6 +103,28 @@ export function UserQuerying() { const activeFilterCount = getActiveFilterCount(); + // Detect if side pane is open by checking DOM + const [isSidePaneOpen, setIsSidePaneOpen] = useState(false); + + useEffect(() => { + 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; @@ -202,7 +224,7 @@ export function UserQuerying() { clearTimeout(debounceTimeout); } }; - }, [activeFilterCount, showAllFilters, isOverlayOpen, isMobileMenuOpen]); + }, [activeFilterCount, showAllFilters, isOverlayOpen, isMobileMenuOpen, isSidePaneOpen]); const clearAllFilters = () => { updateFilter({ userRole: undefined, userStatus: undefined, startDate: undefined, endDate: undefined }); From 86727f6357113e605e1bc376bccdb70c91ca4b8e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 10:44:03 +0200 Subject: [PATCH 31/88] Remove logic for showing/hiding filter bar buttons based on screen size and rely exclusively on available space --- .../admin/users/-components/UserQuerying.tsx | 21 +++++++------------ 1 file changed, 7 insertions(+), 14 deletions(-) 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 32f75c933..190a305e0 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -138,11 +138,8 @@ export function UserQuerying() { return; } - // Double-check screen size with direct media query for cross-browser consistency - const isXlScreenDirect = window.matchMedia(MEDIA_QUERIES.xl).matches; - - if (!isXlScreenDirect || isOverlayOpen || isMobileMenuOpen) { - // On smaller screens or when overlays are open, always use modal + // Only force modal when overlays are open (blocks interaction) + if (isOverlayOpen || isMobileMenuOpen) { if (showAllFilters) { lastStateChange = now; setShowAllFilters(false); @@ -224,7 +221,7 @@ export function UserQuerying() { clearTimeout(debounceTimeout); } }; - }, [activeFilterCount, showAllFilters, isOverlayOpen, isMobileMenuOpen, isSidePaneOpen]); + }, [activeFilterCount, showAllFilters, isMobileMenuOpen, isSidePaneOpen]); const clearAllFilters = () => { updateFilter({ userRole: undefined, userStatus: undefined, startDate: undefined, endDate: undefined }); @@ -308,11 +305,8 @@ export function UserQuerying() { aria-label={showAllFilters ? t`Clear filters` : t`Show filters`} data-testid="filter-button" onPress={() => { - // Determine if we have space for inline filters with cross-browser check - const isXlScreenDirect = window.matchMedia(MEDIA_QUERIES.xl).matches; - - if (!isXlScreenDirect || isOverlayOpen || isMobileMenuOpen) { - // On smaller screens or when overlays are open, always use modal + // Force modal when overlays are open (blocks interaction) + if (isOverlayOpen || isMobileMenuOpen) { setIsFilterPanelOpen(true); return; } @@ -330,14 +324,13 @@ export function UserQuerying() { } const toolbarWidth = toolbarContainer.offsetWidth; - const rightSideButtons = toolbarContainer.querySelector('.flex.items-center.gap-2:last-child') as HTMLElement; const searchField = containerRef.current.querySelector('input[type="text"]') as HTMLElement; const filterButton = containerRef.current.querySelector('[data-testid="filter-button"]') as HTMLElement; - // Calculate space used by existing elements + // Calculate space used by existing elements - ALWAYS assume filters are hidden for measurement const searchWidth = searchField?.offsetWidth || 300; const filterButtonWidth = filterButton?.offsetWidth || 50; - const rightSideWidth = rightSideButtons?.offsetWidth || 200; + const rightSideWidth = 130; // Fixed width for action buttons (not affected by filter state) const gaps = 16; // gap-2 between main sections const minimumFilterSpace = 450; // Minimum space needed for all three filter controls From b614392858f4ca73bf9bdcc9b270485234454b5e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 11:22:45 +0200 Subject: [PATCH 32/88] Make Invite user button small when user filter bar is expanded --- .../routes/admin/users/-components/UserQuerying.tsx | 11 ++++++++++- .../routes/admin/users/-components/UserToolbar.tsx | 7 ++++--- 2 files changed, 14 insertions(+), 4 deletions(-) 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 190a305e0..64e7ca01b 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -29,13 +29,17 @@ interface SearchParams { pageOffset: number | undefined; } +interface UserQueryingProps { + onFilterStateChange?: (hasActiveFilters: boolean) => 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 }: UserQueryingProps = {}) { const navigate = useNavigate(); const searchParams = (useLocation().search as SearchParams) ?? {}; const { isOverlayOpen, isMobileMenuOpen } = useSideMenuLayout(); @@ -223,6 +227,11 @@ export function UserQuerying() { }; }, [activeFilterCount, showAllFilters, isMobileMenuOpen, isSidePaneOpen]); + // Notify parent component when active filter count changes + useEffect(() => { + onFilterStateChange?.(activeFilterCount > 0); + }, [activeFilterCount, onFilterStateChange]); + const clearAllFilters = () => { updateFilter({ userRole: undefined, userStatus: undefined, startDate: undefined, endDate: undefined }); setShowAllFilters(false); 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 c882b88f7..547816112 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -17,15 +17,16 @@ interface UserToolbarProps { export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly) { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [hasActiveFilters, setHasActiveFilters] = useState(false); return (
- +
{selectedUsers.length < 2 && ( @@ -33,7 +34,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly 1 && ( From dd946621aa8def08f18505bbf7a0f04e5ed119a4 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 12:10:56 +0200 Subject: [PATCH 33/88] Collapse invite users when filter bar is expanded without filters --- .../admin/users/-components/UserQuerying.tsx | 22 +++++++++++++------ .../admin/users/-components/UserToolbar.tsx | 12 +++++++--- 2 files changed, 24 insertions(+), 10 deletions(-) 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 64e7ca01b..20ddada75 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -30,7 +30,7 @@ interface SearchParams { } interface UserQueryingProps { - onFilterStateChange?: (hasActiveFilters: boolean) => void; + onFilterStateChange?: (isFilterBarExpanded: boolean, hasActiveFilters: boolean) => void; } /** @@ -164,9 +164,13 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { // Calculate space used by existing elements - ALWAYS assume filters are hidden for measurement const searchWidth = searchField?.offsetWidth || 300; const filterButtonWidth = filterButton?.offsetWidth || 50; - const rightSideWidth = 130; // Fixed width for action buttons (not affected by filter state) + + // For space calculation, assume buttons will be compact (130px) when filters are shown + // This accounts for the fact that showing filters makes buttons compact, freeing up space + const rightSideWidth = 130; + const gaps = 16; // gap-2 between main sections - const minimumFilterSpace = 450; // Minimum space needed for all three filter controls + const minimumFilterSpace = 300; // Minimum space needed for all three filter controls const usedSpace = searchWidth + filterButtonWidth + rightSideWidth + gaps; const availableSpace = toolbarWidth - usedSpace; @@ -227,10 +231,10 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { }; }, [activeFilterCount, showAllFilters, isMobileMenuOpen, isSidePaneOpen]); - // Notify parent component when active filter count changes + // Notify parent component when filter state changes useEffect(() => { - onFilterStateChange?.(activeFilterCount > 0); - }, [activeFilterCount, onFilterStateChange]); + onFilterStateChange?.(showAllFilters, activeFilterCount > 0); + }, [showAllFilters, activeFilterCount, onFilterStateChange]); const clearAllFilters = () => { updateFilter({ userRole: undefined, userStatus: undefined, startDate: undefined, endDate: undefined }); @@ -339,7 +343,11 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { // Calculate space used by existing elements - ALWAYS assume filters are hidden for measurement const searchWidth = searchField?.offsetWidth || 300; const filterButtonWidth = filterButton?.offsetWidth || 50; - const rightSideWidth = 130; // Fixed width for action buttons (not affected by filter state) + + // For space calculation, assume buttons will be compact (130px) when filters are shown + // This accounts for the fact that showing filters makes buttons compact, freeing up space + const rightSideWidth = 130; + const gaps = 16; // gap-2 between main sections const minimumFilterSpace = 450; // Minimum space needed for all three filter controls 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 547816112..0f56eae34 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -17,16 +17,22 @@ interface UserToolbarProps { export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly) { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isFilterBarExpanded, setIsFilterBarExpanded] = useState(false); const [hasActiveFilters, setHasActiveFilters] = useState(false); + const handleFilterStateChange = (isExpanded: boolean, hasFilters: boolean) => { + setIsFilterBarExpanded(isExpanded); + setHasActiveFilters(hasFilters); + }; + return (
- +
{selectedUsers.length < 2 && ( @@ -34,7 +40,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly 1 && ( From c73bf7ceaf6a261284ddf3f1827aa5afbd0c7d64 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 12:16:26 +0200 Subject: [PATCH 34/88] Show big invite user button on 2xl screens --- .../routes/admin/users/-components/UserQuerying.tsx | 8 ++++++-- .../WebApp/routes/admin/users/-components/UserToolbar.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 5 deletions(-) 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 20ddada75..e07d8d7af 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -30,7 +30,7 @@ interface SearchParams { } interface UserQueryingProps { - onFilterStateChange?: (isFilterBarExpanded: boolean, hasActiveFilters: boolean) => void; + onFilterStateChange?: (isFilterBarExpanded: boolean, hasActiveFilters: boolean, shouldUseCompactButtons: boolean) => void; } /** @@ -233,7 +233,11 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { // Notify parent component when filter state changes useEffect(() => { - onFilterStateChange?.(showAllFilters, activeFilterCount > 0); + // 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 = () => { 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 0f56eae34..cde3cfb58 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -19,10 +19,12 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly { + const handleFilterStateChange = (isExpanded: boolean, hasFilters: boolean, useCompact: boolean) => { setIsFilterBarExpanded(isExpanded); setHasActiveFilters(hasFilters); + setShouldUseCompactButtons(useCompact); }; return ( @@ -32,7 +34,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly setIsInviteModalOpen(true)}> - + Invite user @@ -40,7 +42,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly 1 && ( From d5f3118d048d78183d4bc48065135d958d94d88c Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 12:42:02 +0200 Subject: [PATCH 35/88] Remove checkboxes in UserTable to enable easy user switching while retaining multiselect via keyboard modifiers --- .../admin/users/-components/UserTable.tsx | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) 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 048c88572..bd6b07b04 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -94,6 +94,9 @@ export function UserTable({ onSelectedUsersChange([]); }, [onSelectedUsersChange]); + // Track the currently focused user for keyboard navigation + const [focusedUserId, setFocusedUserId] = useState(null); + const handleSelectionChange = useCallback( (keys: Selection) => { if (keys === "all") { @@ -118,6 +121,16 @@ export function UserTable({ [users?.users, onSelectedUsersChange, onViewProfile] ); + // Handle keyboard focus changes to set active user + useEffect(() => { + if (focusedUserId) { + const focusedUser = users?.users.find(user => user.id === focusedUserId); + if (focusedUser) { + onViewProfile(focusedUser); + } + } + }, [focusedUserId, users?.users, onViewProfile]); + if (isLoading) { return null; } @@ -130,7 +143,7 @@ export function UserTable({ user.id)} onSelectionChange={handleSelectionChange} sortDescriptor={sortDescriptor} @@ -159,7 +172,12 @@ export function UserTable({ {users?.users.map((user) => ( - + setFocusedUserId(user.id)} + onBlur={() => setFocusedUserId(null)} + > Date: Mon, 30 Jun 2025 13:02:28 +0200 Subject: [PATCH 36/88] Add tooltips to all buttons when displaying only the icon --- .../admin/users/-components/UserQuerying.tsx | 251 ++++++++++-------- .../admin/users/-components/UserTable.tsx | 20 +- .../admin/users/-components/UserToolbar.tsx | 43 ++- 3 files changed, 167 insertions(+), 147 deletions(-) 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 e07d8d7af..5b7ff7d5e 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -11,8 +11,8 @@ 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 { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; import { useSideMenuLayout } from "@repo/ui/hooks/useSideMenuLayout"; -import { MEDIA_QUERIES } from "@repo/ui/utils/responsive"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { ListFilter, ListFilterPlus, XIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -30,7 +30,11 @@ interface SearchParams { } interface UserQueryingProps { - onFilterStateChange?: (isFilterBarExpanded: boolean, hasActiveFilters: boolean, shouldUseCompactButtons: boolean) => void; + onFilterStateChange?: ( + isFilterBarExpanded: boolean, + hasActiveFilters: boolean, + shouldUseCompactButtons: boolean + ) => void; } /** @@ -109,7 +113,7 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { // Detect if side pane is open by checking DOM const [isSidePaneOpen, setIsSidePaneOpen] = useState(false); - + useEffect(() => { const checkSidePaneState = () => { const sidePane = document.querySelector('[class*="fixed"][class*="inset-0"][class*="z-[60]"]'); @@ -121,11 +125,11 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { // 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]); @@ -133,17 +137,54 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { 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 (!hasSpace && showAllFilters) { + lastStateChange = now; + setShowAllFilters(false); + } + }; + const checkFilterSpace = () => { const now = Date.now(); - - // Circuit breaker: prevent rapid state changes - if (now - lastStateChange < 200) { + + if (shouldSkipSpaceCheck(now)) { return; } - - // Only force modal when overlays are open (blocks interaction) - if (isOverlayOpen || isMobileMenuOpen) { + + if (shouldHideFiltersForOverlays()) { if (showAllFilters) { lastStateChange = now; setShowAllFilters(false); @@ -151,39 +192,16 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { return; } - if (!containerRef.current) return; + const toolbarContainer = getToolbarContainer(); + if (!toolbarContainer) { + return; + } - // Measure the actual available space by finding the parent toolbar container - const toolbarContainer = containerRef.current.closest('.flex.items-center.justify-between') as HTMLElement; - if (!toolbarContainer) return; - - 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; - - // Calculate space used by existing elements - ALWAYS assume filters are hidden for measurement - const searchWidth = searchField?.offsetWidth || 300; - const filterButtonWidth = filterButton?.offsetWidth || 50; - - // For space calculation, assume buttons will be compact (130px) when filters are shown - // This accounts for the fact that showing filters makes buttons compact, freeing up space - const rightSideWidth = 130; - - const gaps = 16; // gap-2 between main sections - const minimumFilterSpace = 300; // Minimum space needed for all three filter controls - - const usedSpace = searchWidth + filterButtonWidth + rightSideWidth + gaps; - const availableSpace = toolbarWidth - usedSpace; - + const availableSpace = calculateAvailableSpace(toolbarContainer); + const minimumFilterSpace = 300; const hasSpaceForInlineFilters = availableSpace >= minimumFilterSpace; - if (hasSpaceForInlineFilters && activeFilterCount > 0 && !showAllFilters) { - lastStateChange = now; - setShowAllFilters(true); - } else if (!hasSpaceForInlineFilters && showAllFilters) { - lastStateChange = now; - setShowAllFilters(false); - } + updateFiltersVisibility(hasSpaceForInlineFilters, now); }; const debouncedCheckFilterSpace = () => { @@ -219,7 +237,7 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { window.addEventListener("resize", handleResize); window.addEventListener("side-menu-toggle", handleSideMenuToggle); window.addEventListener("side-menu-resize", handleSideMenuResize); - + return () => { window.removeEventListener("resize", handleResize); window.removeEventListener("side-menu-toggle", handleSideMenuToggle); @@ -229,14 +247,14 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { clearTimeout(debounceTimeout); } }; - }, [activeFilterCount, showAllFilters, isMobileMenuOpen, isSidePaneOpen]); + }, [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 is2XlScreen = window.matchMedia("(min-width: 1536px)").matches; const shouldUseCompactButtons = !is2XlScreen && (showAllFilters || activeFilterCount > 0); - + onFilterStateChange?.(showAllFilters, activeFilterCount > 0, shouldUseCompactButtons); }, [showAllFilters, activeFilterCount, onFilterStateChange]); @@ -316,75 +334,80 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { )} {/* Filter button with responsive behavior */} - + }} + > + {showAllFilters ? ( + + ) : ( + + )} + {activeFilterCount > 0 && !showAllFilters && ( + + {activeFilterCount} + + )} + + {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 bd6b07b04..21bff7a43 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -94,9 +94,6 @@ export function UserTable({ onSelectedUsersChange([]); }, [onSelectedUsersChange]); - // Track the currently focused user for keyboard navigation - const [focusedUserId, setFocusedUserId] = useState(null); - const handleSelectionChange = useCallback( (keys: Selection) => { if (keys === "all") { @@ -121,16 +118,6 @@ export function UserTable({ [users?.users, onSelectedUsersChange, onViewProfile] ); - // Handle keyboard focus changes to set active user - useEffect(() => { - if (focusedUserId) { - const focusedUser = users?.users.find(user => user.id === focusedUserId); - if (focusedUser) { - onViewProfile(focusedUser); - } - } - }, [focusedUserId, users?.users, onViewProfile]); - if (isLoading) { return null; } @@ -172,12 +159,7 @@ export function UserTable({ {users?.users.map((user) => ( - setFocusedUserId(user.id)} - onBlur={() => setFocusedUserId(null)} - > + ) { const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [isFilterBarExpanded, setIsFilterBarExpanded] = useState(false); - const [hasActiveFilters, setHasActiveFilters] = useState(false); + const [_isFilterBarExpanded, setIsFilterBarExpanded] = useState(false); + const [_hasActiveFilters, setHasActiveFilters] = useState(false); const [shouldUseCompactButtons, setShouldUseCompactButtons] = useState(false); const handleFilterStateChange = (isExpanded: boolean, hasFilters: boolean, useCompact: boolean) => { @@ -32,20 +33,34 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly
{selectedUsers.length < 2 && ( - + + + {shouldUseCompactButtons && ( + + Invite user + + )} + )} {selectedUsers.length > 1 && ( - + + + {shouldUseCompactButtons && ( + + Delete {selectedUsers.length} users + + )} + )}
From 895722e6a782547764857b0687d81a89b95a2a46 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 13:02:43 +0200 Subject: [PATCH 37/88] Translate missing copy --- .../WebApp/shared/translations/locale/da-DK.po | 10 +++++----- .../WebApp/shared/translations/locale/nl-NL.po | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index c753abaf2..52e5b6dbd 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -97,7 +97,7 @@ msgid "Change profile picture" msgstr "Skift profilbillede" msgid "Change role" -msgstr "" +msgstr "Skift rolle" msgid "Change user role" msgstr "Skift brugerrolle" @@ -112,7 +112,7 @@ msgid "Clear filters" msgstr "Ryd filtre" msgid "Close user profile" -msgstr "" +msgstr "Luk brugerprofil" msgid "Continue" msgstr "Fortsæt" @@ -134,7 +134,7 @@ msgstr "Slet" #. placeholder {0}: selectedUsers.length msgid "Delete {0} users" -msgstr "" +msgstr "Slet {0} brugere" msgid "Delete account" msgstr "Slet konto" @@ -402,7 +402,7 @@ msgid "User profile" msgstr "Brugerprofil" msgid "User profile details" -msgstr "" +msgstr "Brugerprofiloplysninger" msgid "User profile menu" msgstr "Brugerprofilmenu" @@ -429,7 +429,7 @@ msgid "Verification code sent" msgstr "Bekræftelseskode sendt" msgid "Verified" -msgstr "" +msgstr "Bekræftet" msgid "Verify" msgstr "Bekræft" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 60eee7b20..a76f4f130 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -97,7 +97,7 @@ msgid "Change profile picture" msgstr "Profielfoto wijzigen" msgid "Change role" -msgstr "" +msgstr "Rol wijzigen" msgid "Change user role" msgstr "Gebruikersrol wijzigen" @@ -112,7 +112,7 @@ msgid "Clear filters" msgstr "Filters wissen" msgid "Close user profile" -msgstr "" +msgstr "Gebruikersprofiel sluiten" msgid "Continue" msgstr "Verder" @@ -134,7 +134,7 @@ msgstr "Verwijderen" #. placeholder {0}: selectedUsers.length msgid "Delete {0} users" -msgstr "" +msgstr "Verwijder {0} gebruikers" msgid "Delete account" msgstr "Account verwijderen" @@ -402,7 +402,7 @@ msgid "User profile" msgstr "Gebruikersprofiel" msgid "User profile details" -msgstr "" +msgstr "Details van gebruikersprofiel" msgid "User profile menu" msgstr "Gebruikersprofielmenu" @@ -429,7 +429,7 @@ msgid "Verification code sent" msgstr "Verificatiecode verzonden" msgid "Verified" -msgstr "" +msgstr "Geverifieerd" msgid "Verify" msgstr "Verifiëren" From 9fcc35a37df30f8f9e3a2d7611f5dd372df3fa80 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 15:41:58 +0200 Subject: [PATCH 38/88] Improve user sorting to prioritize names with email fallback --- .../Features/Users/Domain/UserRepository.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) 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; From aa2bf1bcd27b3b1146960bfa22fd4c378842e59f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 18:18:22 +0200 Subject: [PATCH 39/88] Add Owner permission guard to UpdateCurrentTenant backend command --- .../Tenants/Commands/UpdateCurrentTenant.cs | 14 ++++++++++++-- .../Tests/Tenants/UpdateCurrentTenantTests.cs | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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/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(); + } } From f1e5be2fed6284f602403ee9e0c76130f880e1ab Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 23:33:41 +0200 Subject: [PATCH 40/88] Fix account settings form permissions for non-owner users --- .../WebApp/routes/admin/account/index.tsx | 23 ++++++++++++------- .../shared/translations/locale/da-DK.po | 3 +++ .../shared/translations/locale/en-US.po | 3 +++ .../shared/translations/locale/nl-NL.po | 3 +++ 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/account/index.tsx b/application/account-management/WebApp/routes/admin/account/index.tsx index cafbdff78..39c67f4a4 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,12 +86,16 @@ 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 && ( + + )}
diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 52e5b6dbd..29ce307a2 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -262,6 +262,9 @@ msgstr "Næste" msgid "OK" msgstr "OK" +msgid "Only account owners can modify the account name" +msgstr "" + msgid "Organization" msgstr "Organisation" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 8e449f992..1cd2a2cb8 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -262,6 +262,9 @@ msgstr "Next" msgid "OK" msgstr "OK" +msgid "Only account owners can modify the account name" +msgstr "Only account owners can modify the account name" + msgid "Organization" msgstr "Organization" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index a76f4f130..7603d8802 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -262,6 +262,9 @@ msgstr "Volgende" msgid "OK" msgstr "OK" +msgid "Only account owners can modify the account name" +msgstr "" + msgid "Organization" msgstr "Organisatie" From 8790d55b65035d9e2cd5a406fdcf93525727c471 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 30 Jun 2025 23:41:15 +0200 Subject: [PATCH 41/88] Hide invite user button from non-Owners in UserToolbar --- .../WebApp/routes/admin/users/-components/UserToolbar.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 6f10648a2..992779ac9 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -1,4 +1,5 @@ import type { components } from "@/shared/lib/api/client"; +import { UserRole, api } from "@/shared/lib/api/client"; import { Trans } from "@lingui/react/macro"; import { Button } from "@repo/ui/components/Button"; import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; @@ -16,12 +17,15 @@ interface UserToolbarProps { } 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 handleFilterStateChange = (isExpanded: boolean, hasFilters: boolean, useCompact: boolean) => { setIsFilterBarExpanded(isExpanded); setHasActiveFilters(hasFilters); @@ -32,7 +36,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly
- {selectedUsers.length < 2 && ( + {selectedUsers.length < 2 && isOwner && (
- + {isOwner && } Date: Mon, 30 Jun 2025 23:46:51 +0200 Subject: [PATCH 42/88] Hide bulk delete button from non-Owners in UserToolbar --- .../WebApp/routes/admin/users/-components/UserToolbar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 992779ac9..3d1726150 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -51,7 +51,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly )} - {selectedUsers.length > 1 && ( + {selectedUsers.length > 1 && isOwner && ( + +
- + )} From 2c4eb0240d7d270962cb5f9141f148116d0038ef Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 1 Jul 2025 00:43:14 +0200 Subject: [PATCH 44/88] Change role badge in user side pane to button --- .../users/-components/UserProfileSidePane.tsx | 29 ++++++++++++++++--- .../shared/translations/locale/da-DK.po | 7 ++++- .../shared/translations/locale/en-US.po | 5 ++++ .../shared/translations/locale/nl-NL.po | 7 ++++- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 5b1cb3ca8..556bfba2e 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -12,7 +12,8 @@ import { Text } from "@repo/ui/components/Text"; import { formatDate } from "@repo/utils/date/formatDate"; import { getInitials } from "@repo/utils/string/getInitials"; import { Trash2Icon, XIcon } from "lucide-react"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState } from "react"; +import { ChangeUserRoleDialog } from "./ChangeUserRoleDialog"; type UserDetails = components["schemas"]["UserDetails"]; @@ -27,6 +28,7 @@ export function UserProfileSidePane({ user, isOpen, onClose, onDeleteUser }: Rea const userInfo = useUserInfo(); const sidePaneRef = useRef(null); const closeButtonRef = useRef(null); + const [isChangeRoleDialogOpen, setIsChangeRoleDialogOpen] = useState(false); // Focus management and keyboard navigation - only focus close button on mobile useEffect(() => { @@ -184,9 +186,25 @@ export function UserProfileSidePane({ user, isOpen, onClose, onDeleteUser }: Rea Role - - {getUserRoleLabel(user.role)} - + {canModifyUser ? ( + + ) : ( + + {getUserRoleLabel(user.role)} + + )} @@ -220,6 +238,9 @@ export function UserProfileSidePane({ user, isOpen, onClose, onDeleteUser }: Rea )} + + {/* Change User Role Dialog */} + ); } diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 29ce307a2..0945e13ef 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -102,6 +102,11 @@ msgstr "Skift rolle" msgid "Change user role" msgstr "Skift brugerrolle" +#. placeholder {0}: user.firstName +#. placeholder {1}: user.lastName +msgid "Change user role for {0} {1}" +msgstr "Skift brugerrolle for {0} {1}" + msgid "Check your spam folder." msgstr "Tjek din spammappe." @@ -263,7 +268,7 @@ msgid "OK" msgstr "OK" msgid "Only account owners can modify the account name" -msgstr "" +msgstr "Kun kontoejere kan ændre kontonavnet" msgid "Organization" msgstr "Organisation" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 1cd2a2cb8..f5b1e0a2b 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -102,6 +102,11 @@ msgstr "Change role" msgid "Change user role" msgstr "Change user role" +#. placeholder {0}: user.firstName +#. placeholder {1}: user.lastName +msgid "Change user role for {0} {1}" +msgstr "Change user role for {0} {1}" + msgid "Check your spam folder." msgstr "Check your spam folder." diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 7603d8802..8d9d52da7 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -102,6 +102,11 @@ msgstr "Rol wijzigen" msgid "Change user role" msgstr "Gebruikersrol wijzigen" +#. placeholder {0}: user.firstName +#. placeholder {1}: user.lastName +msgid "Change user role for {0} {1}" +msgstr "Gebruikersrol wijzigen voor {0} {1}" + msgid "Check your spam folder." msgstr "Controleer je spammap." @@ -263,7 +268,7 @@ msgid "OK" msgstr "OK" msgid "Only account owners can modify the account name" -msgstr "" +msgstr "Alleen accounteigenaren kunnen de accountnaam wijzigen" msgid "Organization" msgstr "Organisatie" From 3de24ccedb356ecf5f1defbcc49188602fe52e79 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 28 Jun 2025 13:29:14 +0200 Subject: [PATCH 45/88] Add tenant name to JWT and introduce UserInfoFactory for easy reuse --- .../Api/Endpoints/TenantEndpoints.cs | 2 +- .../account-management/Core/Configuration.cs | 3 +- .../Authentication/Commands/CompleteLogin.cs | 6 +-- .../Commands/RefreshAuthenticationTokens.cs | 7 ++-- .../Signups/Commands/CompleteSignup.cs | 7 ++-- .../Features/Users/Shared/UserInfoFactory.cs | 38 +++++++++++++++++++ .../shared/components/SharedSideMenu.tsx | 4 +- .../shared/components/SharedSideMenu.tsx | 5 ++- .../TokenGeneration/AccessTokenGenerator.cs | 1 + .../SharedKernel/Authentication/UserInfo.cs | 3 ++ .../shared-webapp/build/environment.d.ts | 4 ++ 11 files changed, 66 insertions(+), 14 deletions(-) create mode 100644 application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs 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/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/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/WebApp/shared/components/SharedSideMenu.tsx b/application/account-management/WebApp/shared/components/SharedSideMenu.tsx index 0b8cb4042..1d709c448 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 = { @@ -246,7 +246,7 @@ export function SharedSideMenu({ children, ariaLabel }: Readonly - + Organization diff --git a/application/back-office/WebApp/shared/components/SharedSideMenu.tsx b/application/back-office/WebApp/shared/components/SharedSideMenu.tsx index a789b1d01..8c5df1006 100644 --- a/application/back-office/WebApp/shared/components/SharedSideMenu.tsx +++ b/application/back-office/WebApp/shared/components/SharedSideMenu.tsx @@ -1,4 +1,5 @@ import { t } from "@lingui/core/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; import { MenuButton, SideMenu, SideMenuSpacer } from "@repo/ui/components/SideMenu"; import { BoxIcon, HomeIcon } from "lucide-react"; import type React from "react"; @@ -9,8 +10,10 @@ type SharedSideMenuProps = { }; export function SharedSideMenu({ children, ariaLabel }: Readonly) { + const userInfo = useUserInfo(); + return ( - + {children} diff --git a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs index 7b0c2ba49..cb75a76be 100644 --- a/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs +++ b/application/shared-kernel/SharedKernel/Authentication/TokenGeneration/AccessTokenGenerator.cs @@ -22,6 +22,7 @@ public string Generate(UserInfo userInfo) new Claim(JwtRegisteredClaimNames.FamilyName, userInfo.LastName ?? string.Empty), new Claim(ClaimTypes.Role, userInfo.Role!), new Claim("tenant_id", userInfo.TenantId!.ToString()), + new Claim("tenant_name", userInfo.TenantName ?? string.Empty), new Claim("title", userInfo.Title ?? string.Empty), new Claim("avatar_url", userInfo.AvatarUrl ?? string.Empty), new Claim("locale", userInfo.Locale!) diff --git a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs index 76397ff20..c0068ab05 100644 --- a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs +++ b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs @@ -41,6 +41,8 @@ public class UserInfo public string? AvatarUrl { get; init; } + public string? TenantName { get; init; } + public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale) { if (user?.Identity?.IsAuthenticated != true) @@ -65,6 +67,7 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale) LastName = user.FindFirstValue(ClaimTypes.Surname), Title = user.FindFirstValue("title"), AvatarUrl = user.FindFirstValue("avatar_url"), + TenantName = user.FindFirstValue("tenant_name"), Locale = GetValidLocale(user.FindFirstValue("locale")) }; } diff --git a/application/shared-webapp/build/environment.d.ts b/application/shared-webapp/build/environment.d.ts index f3650adf5..ab4212b28 100644 --- a/application/shared-webapp/build/environment.d.ts +++ b/application/shared-webapp/build/environment.d.ts @@ -80,6 +80,10 @@ export declare global { * Avatar url **/ avatarUrl?: string | null; + /** + * Tenant name + **/ + tenantName?: string; } /** From 2a0bb845db55aace9e44b040c4290013acccf365 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 28 Jun 2025 10:20:32 +0200 Subject: [PATCH 46/88] Make side menu resizable and save preferred size in local storage --- .../shared-webapp/ui/components/SideMenu.tsx | 215 ++++++++++++++++-- .../ui/hooks/useSideMenuLayout.ts | 58 +++-- .../shared-webapp/ui/utils/responsive.ts | 5 + 3 files changed, 245 insertions(+), 33 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index d5a2e7ff6..5e6d2fd20 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -8,7 +8,7 @@ import { tv } from "tailwind-variants"; import { useResponsiveMenu } from "../hooks/useResponsiveMenu"; import logoMarkUrl from "../images/logo-mark.svg"; import logoWrapUrl from "../images/logo-wrap.svg"; -import { MEDIA_QUERIES } from "../utils/responsive"; +import { MEDIA_QUERIES, SIDE_MENU_DEFAULT_WIDTH, SIDE_MENU_MAX_WIDTH, SIDE_MENU_MIN_WIDTH } from "../utils/responsive"; import { Button } from "./Button"; import { Tooltip, TooltipTrigger } from "./Tooltip"; import { focusRing } from "./focusRing"; @@ -44,7 +44,7 @@ const _handleFocusTrap = (e: KeyboardEvent, containerRef: React.RefObject )} @@ -185,7 +185,7 @@ const sideMenuStyles = tv({ variants: { isCollapsed: { true: "mr-2 w-[72px]", - false: "mr-2 w-72" + false: "mr-2" // Width will be set inline for resizable menu }, overlayMode: { true: "", @@ -221,7 +221,21 @@ type SideMenuProps = { export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly) { const { className, forceCollapsed, overlayMode, isHidden } = useResponsiveMenu(); const sideMenuRef = useRef(null); + const toggleButtonRef = useRef(null); const [isOverlayOpen, setIsOverlayOpen] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const [menuWidth, setMenuWidth] = useState(() => { + try { + const stored = localStorage.getItem("side-menu-size"); + if (stored) { + const width = Number.parseInt(stored, 10); + if (!Number.isNaN(width) && width >= SIDE_MENU_MIN_WIDTH && width <= SIDE_MENU_MAX_WIDTH) { + return width; + } + } + } catch {} + return SIDE_MENU_DEFAULT_WIDTH; + }); // Initialize collapsed state with synchronous check to prevent flicker const [isCollapsed, setIsCollapsed] = useState(() => { @@ -265,7 +279,10 @@ export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly { + // Check if we're on XL screen (for resize functionality) + const isXlScreen = !overlayMode && !forceCollapsed; + + const toggleMenu = useCallback(() => { if (overlayMode) { setIsOverlayOpen(!isOverlayOpen); // Dispatch event for layout hook @@ -288,7 +305,19 @@ export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly { + if (toggleButtonRef.current) { + if (toggleButtonRef.current instanceof HTMLButtonElement) { + toggleButtonRef.current.focus(); + } else { + // For ToggleButton wrapped in div, find the button inside + const button = toggleButtonRef.current.querySelector("button"); + button?.focus(); + } + } + }, 0); + }, [overlayMode, isOverlayOpen, forceCollapsed, isCollapsed]); const closeOverlay = useCallback(() => { if (overlayMode && isOverlayOpen) { @@ -355,6 +384,92 @@ export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly document.removeEventListener("keydown", handleKeyDown); }, [isOverlayOpen, overlayMode]); + // Track mouse movement to detect dragging + const dragStartPos = useRef<{ x: number; y: number } | null>(null); + const hasDraggedRef = useRef(false); + + // Handle resize drag + const handleResizeStart = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsResizing(true); + dragStartPos.current = { x: e.clientX, y: e.clientY }; + hasDraggedRef.current = false; + }, []); + + useEffect(() => { + if (!isResizing) { + return; + } + + // Add global cursor style + document.body.style.cursor = "col-resize"; + + let hasTriggeredCollapse = false; + + const handleMouseMove = (e: MouseEvent) => { + const mouseX = e.clientX - 8; + + // Check if mouse has moved more than 5px from start (indicates dragging) + if (dragStartPos.current && !hasDraggedRef.current) { + const distance = Math.abs(e.clientX - dragStartPos.current.x) + Math.abs(e.clientY - dragStartPos.current.y); + if (distance > 5) { + hasDraggedRef.current = true; + } + } + + // If dragging from collapsed state and mouse is past collapsed width, expand + if (isCollapsed && mouseX > 100) { + toggleMenu(); + setIsResizing(false); + document.body.style.cursor = ""; + return; + } + + // If dragging to edge from expanded state, collapse + if (!isCollapsed && mouseX < 20 && !hasTriggeredCollapse) { + hasTriggeredCollapse = true; + toggleMenu(); + setIsResizing(false); + document.body.style.cursor = ""; + return; + } + + // Normal resize when expanded + if (!isCollapsed && !hasTriggeredCollapse) { + const newWidth = Math.min(Math.max(mouseX, SIDE_MENU_MIN_WIDTH), SIDE_MENU_MAX_WIDTH); + setMenuWidth(newWidth); + // Dispatch event for layout hook during drag + window.dispatchEvent( + new CustomEvent("side-menu-resize", { + detail: { width: newWidth } + }) + ); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + document.body.style.cursor = ""; + // Only save if we didn't trigger collapse + if (!hasTriggeredCollapse && !isCollapsed) { + try { + localStorage.setItem("side-menu-size", menuWidth.toString()); + } catch {} + } + dragStartPos.current = null; + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + document.body.style.cursor = ""; + }; + }, [isResizing, menuWidth, isCollapsed, toggleMenu]); + return ( <> {/* Backdrop for overlay mode */} @@ -373,16 +488,25 @@ export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly
- {/* Vertical divider line */} -
+ {/* Vertical divider line - draggable on XL screens */} +
{/* Fixed header section with logo */}
@@ -396,20 +520,73 @@ export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly {/* Toggle button centered on divider, midway between logo and first menu item */} - - - + {isXlScreen ? ( + // Draggable button that acts as resize handle + + ) : ( +
}> + + + +
+ )} +
{/* Scrollable menu content */} -
+
{children}
diff --git a/application/shared-webapp/ui/hooks/useSideMenuLayout.ts b/application/shared-webapp/ui/hooks/useSideMenuLayout.ts index 4ffce9b9a..44bebc397 100644 --- a/application/shared-webapp/ui/hooks/useSideMenuLayout.ts +++ b/application/shared-webapp/ui/hooks/useSideMenuLayout.ts @@ -1,6 +1,12 @@ import type React from "react"; import { useEffect, useMemo, useState } from "react"; -import { MEDIA_QUERIES, SIDE_MENU_COLLAPSED_WIDTH, SIDE_MENU_EXPANDED_WIDTH } from "../utils/responsive"; +import { + MEDIA_QUERIES, + SIDE_MENU_COLLAPSED_WIDTH, + SIDE_MENU_DEFAULT_WIDTH, + SIDE_MENU_MAX_WIDTH, + SIDE_MENU_MIN_WIDTH +} from "../utils/responsive"; /** * Hook to provide proper layout styles for content when using a fixed side menu. @@ -16,20 +22,12 @@ export function useSideMenuLayout(): { isLargeScreen: boolean; } { // Track screen sizes - const [isSmallScreen, setIsSmallScreen] = useState(() => - typeof window !== "undefined" ? window.matchMedia(MEDIA_QUERIES.sm).matches : false - ); - const [isLargeScreen, setIsLargeScreen] = useState(() => - typeof window !== "undefined" ? window.matchMedia(MEDIA_QUERIES.xl).matches : false - ); + const [isSmallScreen, setIsSmallScreen] = useState(() => window.matchMedia(MEDIA_QUERIES.sm).matches); + const [isLargeScreen, setIsLargeScreen] = useState(() => window.matchMedia(MEDIA_QUERIES.xl).matches); // Track menu state const [isCollapsed, setIsCollapsed] = useState(() => { // Synchronous check to prevent flicker - if (typeof window === "undefined") { - return true; - } - const isSmallScreenSync = window.matchMedia(MEDIA_QUERIES.sm).matches; const isLargeScreenSync = window.matchMedia(MEDIA_QUERIES.xl).matches; @@ -45,6 +43,18 @@ export function useSideMenuLayout(): { const [isOverlayExpanded, setIsOverlayExpanded] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [customMenuWidth, setCustomMenuWidth] = useState(() => { + try { + const stored = localStorage.getItem("side-menu-size"); + if (stored) { + const width = Number.parseInt(stored, 10); + if (!Number.isNaN(width) && width >= SIDE_MENU_MIN_WIDTH && width <= SIDE_MENU_MAX_WIDTH) { + return width; + } + } + } catch {} + return SIDE_MENU_DEFAULT_WIDTH; + }); // Listen for screen size changes useEffect(() => { @@ -52,7 +62,14 @@ export function useSideMenuLayout(): { const xlQuery = window.matchMedia(MEDIA_QUERIES.xl); const handleSmChange = (e: MediaQueryListEvent) => setIsSmallScreen(e.matches); - const handleXlChange = (e: MediaQueryListEvent) => setIsLargeScreen(e.matches); + const handleXlChange = (e: MediaQueryListEvent) => { + setIsLargeScreen(e.matches); + // When transitioning to XL screen, sync collapsed state from localStorage + if (e.matches) { + const stored = localStorage.getItem("side-menu-collapsed"); + setIsCollapsed(stored === "true"); + } + }; smQuery.addEventListener("change", handleSmChange); xlQuery.addEventListener("change", handleXlChange); @@ -77,17 +94,30 @@ export function useSideMenuLayout(): { setIsMobileMenuOpen(event.detail.isOpen); }; + const handleMenuResize = (event: CustomEvent) => { + setCustomMenuWidth(event.detail.width); + }; + window.addEventListener("side-menu-toggle", handleMenuToggle as EventListener); window.addEventListener("side-menu-overlay-toggle", handleOverlayToggle as EventListener); window.addEventListener("mobile-menu-toggle", handleMobileMenuToggle as EventListener); + window.addEventListener("side-menu-resize", handleMenuResize as EventListener); return () => { window.removeEventListener("side-menu-toggle", handleMenuToggle as EventListener); window.removeEventListener("side-menu-overlay-toggle", handleOverlayToggle as EventListener); window.removeEventListener("mobile-menu-toggle", handleMobileMenuToggle as EventListener); + window.removeEventListener("side-menu-resize", handleMenuResize as EventListener); }; }, []); + // Reset overlay expanded state when leaving overlay mode + useEffect(() => { + if (isLargeScreen) { + setIsOverlayExpanded(false); + } + }, [isLargeScreen]); + // Calculate layout styles const className = "flex flex-col flex-1 min-h-0"; @@ -106,9 +136,9 @@ export function useSideMenuLayout(): { // Large screens: adjust based on menu state return { - marginLeft: isCollapsed ? SIDE_MENU_COLLAPSED_WIDTH : SIDE_MENU_EXPANDED_WIDTH + marginLeft: isCollapsed ? SIDE_MENU_COLLAPSED_WIDTH : `${customMenuWidth + 8}px` }; - }, [isSmallScreen, isLargeScreen, isCollapsed]); + }, [isSmallScreen, isLargeScreen, isCollapsed, customMenuWidth]); // Determine if in overlay mode const isOverlayMode = isSmallScreen && !isLargeScreen; diff --git a/application/shared-webapp/ui/utils/responsive.ts b/application/shared-webapp/ui/utils/responsive.ts index 732bd1e46..01e837d5c 100644 --- a/application/shared-webapp/ui/utils/responsive.ts +++ b/application/shared-webapp/ui/utils/responsive.ts @@ -22,3 +22,8 @@ export const MEDIA_QUERIES = { // Side menu width constants (including 8px right margin) export const SIDE_MENU_COLLAPSED_WIDTH = "80px"; // 72px + 8px margin export const SIDE_MENU_EXPANDED_WIDTH = "296px"; // 288px + 8px margin + +// Resizable menu constraints +export const SIDE_MENU_MIN_WIDTH = 150; +export const SIDE_MENU_MAX_WIDTH = 300; +export const SIDE_MENU_DEFAULT_WIDTH = 288; From 58a3465c93831c8d0382baeb1b415aa07d551993 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 28 Jun 2025 17:27:27 +0200 Subject: [PATCH 47/88] Show tenant name in side menu next to logo mark instead of PlatformPlatform logo --- .../shared-webapp/ui/components/SideMenu.tsx | 44 +++++++++++++++---- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 5e6d2fd20..050ec00e9 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -7,7 +7,6 @@ import { ToggleButton, composeRenderProps } from "react-aria-components"; import { tv } from "tailwind-variants"; import { useResponsiveMenu } from "../hooks/useResponsiveMenu"; import logoMarkUrl from "../images/logo-mark.svg"; -import logoWrapUrl from "../images/logo-wrap.svg"; import { MEDIA_QUERIES, SIDE_MENU_DEFAULT_WIDTH, SIDE_MENU_MAX_WIDTH, SIDE_MENU_MIN_WIDTH } from "../utils/responsive"; import { Button } from "./Button"; import { Tooltip, TooltipTrigger } from "./Tooltip"; @@ -199,7 +198,14 @@ const sideMenuStyles = tv({ true: "hidden", false: "flex" } - } + }, + compoundVariants: [ + { + overlayMode: true, + isOverlayOpen: true, + class: "w-[320px]" // Wider overlay for longer tenant names + } + ] }); const chevronStyles = tv({ @@ -216,9 +222,10 @@ type SideMenuProps = { children: React.ReactNode; ariaLabel: string; topMenuContent?: React.ReactNode; + tenantName?: string; }; -export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly) { +export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Readonly) { const { className, forceCollapsed, overlayMode, isHidden } = useResponsiveMenu(); const sideMenuRef = useRef(null); const toggleButtonRef = useRef(null); @@ -510,12 +517,31 @@ export function SideMenu({ children, ariaLabel, topMenuContent }: Readonly - {/* Logo container - fixed position */} -
- {actualIsCollapsed ? ( - Logo - ) : ( - Logo + {/* Logo and tenant name container */} +
+ Logo + {!actualIsCollapsed && ( + + {tenantName || "PlatformPlatform"} + )}
From e146013e45f6d8ca9a1c2210820ef1bbcd004b37 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 28 Jun 2025 17:35:22 +0200 Subject: [PATCH 48/88] Make active menu item bold --- application/shared-webapp/ui/components/SideMenu.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 050ec00e9..3bfa1582d 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -62,6 +62,10 @@ const menuTextStyles = tv({ isCollapsed: { true: "max-w-0 opacity-0", false: "max-w-[200px] opacity-100" + }, + isActive: { + true: "font-semibold", + false: "font-normal" } } }); @@ -167,7 +171,7 @@ export function MenuButton({
-
{label}
+
{label}
{isCollapsed && ( From 5a583d0f62de43ae353fa3abda2ed6ca68c25d79 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 29 Jun 2025 01:17:41 +0200 Subject: [PATCH 49/88] Fix location of sidemenu toggle button --- application/shared-webapp/ui/components/SideMenu.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 3bfa1582d..d0320d87c 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -551,9 +551,9 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re {/* Toggle button centered on divider, midway between logo and first menu item */}
{isXlScreen ? ( // Draggable button that acts as resize handle From eb1fbfca2c21ab2b54c9c6b624f6c282b5a4865c Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 29 Jun 2025 10:45:22 +0200 Subject: [PATCH 50/88] Make selected menu item icon slightly bolder when menu is collapsed --- application/shared-webapp/ui/components/SideMenu.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index d0320d87c..4e36071ed 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -169,7 +169,9 @@ export function MenuButton({ isDisabled={isDisabled} >
- +
{label}
From 2d47ffe32e0c3a1f6c4c6257da619736cf939d47 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 28 Jun 2025 10:20:19 +0200 Subject: [PATCH 51/88] Remove server-side rendering checks for window == undefined --- application/shared-webapp/ui/components/SideMenu.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 4e36071ed..3f9ff10a4 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -142,8 +142,7 @@ export function MenuButton({ }; // Check if we're in the mobile menu context - const isMobileMenu = - typeof window !== "undefined" && !window.matchMedia(MEDIA_QUERIES.sm).matches && overlayCtx?.isOpen; + const isMobileMenu = !window.matchMedia(MEDIA_QUERIES.sm).matches && overlayCtx?.isOpen; return (
@@ -252,10 +251,6 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re // Initialize collapsed state with synchronous check to prevent flicker const [isCollapsed, setIsCollapsed] = useState(() => { - if (typeof window === "undefined") { - return true; - } - // Force collapsed on medium screens if (forceCollapsed) { return true; From ba2339cc2f49c286bf0836c92023d70bb544d8a9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 14:45:40 +0200 Subject: [PATCH 52/88] Move side menu below top menu bar --- .../routes/admin/users/-components/UserProfileSidePane.tsx | 2 +- application/shared-webapp/ui/components/AppLayout.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 556bfba2e..57112049f 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -113,7 +113,7 @@ export function UserProfileSidePane({ user, isOpen, onClose, onDeleteUser }: Rea {/* Side pane */}
diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index ce9047fe4..6c34bd923 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -104,7 +104,7 @@ export function AppLayout({
{topMenu} @@ -113,7 +113,7 @@ export function AppLayout({
@@ -142,7 +142,7 @@ export function AppLayout({ {/* Side pane area - responsive behavior */} {sidePane && ( -
{sidePane}
+
{sidePane}
)}
); From 92710643f13287a7d6fe169949cb95e73714d736 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 14:48:32 +0200 Subject: [PATCH 53/88] Always show menu divider line --- application/shared-webapp/ui/components/SideMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 3f9ff10a4..574ad0eac 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -510,7 +510,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re > {/* Vertical divider line - draggable on XL screens */}
Date: Fri, 4 Jul 2025 14:50:43 +0200 Subject: [PATCH 54/88] Show horizontal divider line and remove blur transition to top menu --- application/shared-webapp/ui/components/AppLayout.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index 6c34bd923..f33fba32c 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -102,20 +102,13 @@ export function AppLayout({ > {/* Fixed TopMenu with blur effect */}
{topMenu}
- {/* Soft gradient fade below TopMenu */} -
{/* Main content area */}
Date: Fri, 4 Jul 2025 16:29:17 +0200 Subject: [PATCH 55/88] Fix alignment between sidemenu divider and topbar border --- application/shared-webapp/ui/components/AppLayout.tsx | 9 +++++---- application/shared-webapp/ui/components/SideMenu.tsx | 8 ++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index f33fba32c..23c47ab85 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -102,12 +102,13 @@ export function AppLayout({ > {/* Fixed TopMenu with blur effect */}
- {topMenu} +
+ {topMenu} +
{/* Main content area */} diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 574ad0eac..0f0dbfc20 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -188,8 +188,8 @@ const sideMenuStyles = tv({ base: "group fixed top-0 left-0 z-50 flex h-screen flex-col bg-background transition-[width] duration-100 md:z-[70]", variants: { isCollapsed: { - true: "mr-2 w-[72px]", - false: "mr-2" // Width will be set inline for resizable menu + true: "w-[72px]", + false: "" // Width will be set inline for resizable menu }, overlayMode: { true: "", @@ -504,14 +504,14 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re className })} ${isResizing ? "cursor-col-resize select-none" : ""}`} style={{ - width: isXlScreen && !isCollapsed ? `${menuWidth + 8}px` : undefined, + width: isXlScreen && !isCollapsed ? `${menuWidth}px` : undefined, transition: isResizing ? "none" : undefined }} > {/* Vertical divider line - draggable on XL screens */}
From efd80d76c6c542999cd4f6c6831ae0ae26e75c98 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 16:34:40 +0200 Subject: [PATCH 56/88] Make top menu same height as width of collapsed side menu --- .../routes/admin/users/-components/UserProfileSidePane.tsx | 2 +- application/shared-webapp/ui/components/AppLayout.tsx | 6 +++--- application/shared-webapp/ui/components/SideMenu.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 57112049f..bf1724599 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -133,7 +133,7 @@ export function UserProfileSidePane({ user, isOpen, onClose, onDeleteUser }: Rea aria-label={t`Close user profile`} /> -
+
User profile diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index 23c47ab85..49eb81422 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -102,7 +102,7 @@ export function AppLayout({ > {/* Fixed TopMenu with blur effect */}
@@ -113,7 +113,7 @@ export function AppLayout({ {/* Main content area */}
@@ -136,7 +136,7 @@ export function AppLayout({ {/* Side pane area - responsive behavior */} {sidePane && ( -
{sidePane}
+
{sidePane}
)}
); diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 0f0dbfc20..fff29f295 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -517,10 +517,10 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re /> {/* Fixed header section with logo */} -
+
{/* Logo and tenant name container */}
Date: Fri, 4 Jul 2025 16:59:30 +0200 Subject: [PATCH 57/88] Center side menu toggle button at the intersection of horizontal and vertical dividers --- application/shared-webapp/ui/components/SideMenu.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index fff29f295..bfb2983ac 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -546,11 +546,11 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re )}
- {/* Toggle button centered on divider, midway between logo and first menu item */} + {/* Toggle button centered on divider, at intersection with topbar border */}
{isXlScreen ? ( // Draggable button that acts as resize handle From 64a5e14e07b2e6663aaaa9c7d3ce40ccb8a82392 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 17:47:54 +0200 Subject: [PATCH 58/88] Center Account page and simplify logic for true centering --- .../shared-webapp/ui/components/AppLayout.tsx | 67 ++----------------- 1 file changed, 5 insertions(+), 62 deletions(-) diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index 49eb81422..9de10d832 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -1,7 +1,6 @@ import type React from "react"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useSideMenuLayout } from "../hooks/useSideMenuLayout"; -import { MEDIA_QUERIES } from "../utils/responsive"; type AppLayoutVariant = "full" | "center"; @@ -21,7 +20,7 @@ type AppLayoutProps = { * * Variants: * - full: Content takes full width with standard padding - * - center: Content is always centered with configurable max width (default: 640px). When SideMenu is expanded on large screens, content is shifted 50px left for better visual balance. + * - center: Content is always centered with configurable max width (default: 640px) */ export function AppLayout({ children, @@ -32,54 +31,6 @@ export function AppLayout({ }: Readonly) { const { className, style, isOverlayOpen, isMobileMenuOpen } = useSideMenuLayout(); - const [isLargeScreen, setIsLargeScreen] = useState(() => - typeof window !== "undefined" ? window.matchMedia(MEDIA_QUERIES.xl).matches : false - ); - const [isSideMenuCollapsed, setIsSideMenuCollapsed] = useState(() => { - if (typeof window === "undefined") { - return true; - } - // Check if we're on large screen - const isLarge = window.matchMedia(MEDIA_QUERIES.xl).matches; - if (!isLarge) { - return true; // Always collapsed on smaller screens - } - - const stored = localStorage.getItem("side-menu-collapsed"); - return stored === "true"; - }); - - // Listen for screen size changes - useEffect(() => { - const xlQuery = window.matchMedia(MEDIA_QUERIES.xl); - const handleXlChange = (e: MediaQueryListEvent) => setIsLargeScreen(e.matches); - - xlQuery.addEventListener("change", handleXlChange); - return () => xlQuery.removeEventListener("change", handleXlChange); - }, []); - - // Listen for side menu toggle events - useEffect(() => { - const handleMenuToggle = (event: CustomEvent) => { - if (isLargeScreen) { - setIsSideMenuCollapsed(event.detail.isCollapsed); - } - }; - - window.addEventListener("side-menu-toggle", handleMenuToggle as EventListener); - return () => window.removeEventListener("side-menu-toggle", handleMenuToggle as EventListener); - }, [isLargeScreen]); - - // Update side menu state when screen size changes - useEffect(() => { - if (!isLargeScreen) { - setIsSideMenuCollapsed(true); - } else { - const stored = localStorage.getItem("side-menu-collapsed"); - setIsSideMenuCollapsed(stored === "true"); - } - }, [isLargeScreen]); - // Prevent body scroll when overlay is open useEffect(() => { if (isOverlayOpen) { @@ -102,13 +53,11 @@ export function AppLayout({ > {/* Fixed TopMenu with blur effect */}
-
- {topMenu} -
+
{topMenu}
{/* Main content area */} @@ -119,13 +68,7 @@ export function AppLayout({ > {variant === "center" ? (
-
+
{children}
From 22a9651bb442e35d193e62e2ab235c186a4f5960 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 18:13:30 +0200 Subject: [PATCH 59/88] Ensure all border colors are consistent in the application --- .../WebApp/shared/components/SharedSideMenu.tsx | 2 +- application/shared-webapp/ui/components/AppLayout.tsx | 6 ++++-- application/shared-webapp/ui/components/SideMenu.tsx | 6 +++--- application/shared-webapp/ui/components/Toast.tsx | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/application/account-management/WebApp/shared/components/SharedSideMenu.tsx b/application/account-management/WebApp/shared/components/SharedSideMenu.tsx index 1d709c448..cbcf15d28 100644 --- a/application/account-management/WebApp/shared/components/SharedSideMenu.tsx +++ b/application/account-management/WebApp/shared/components/SharedSideMenu.tsx @@ -223,7 +223,7 @@ export function SharedSideMenu({ children, ariaLabel }: Readonly {/* Divider */} -
+
{/* Navigation Section for Mobile */}
diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index 9de10d832..b0b928f4c 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -53,7 +53,7 @@ export function AppLayout({ > {/* Fixed TopMenu with blur effect */}
@@ -79,7 +79,9 @@ export function AppLayout({ {/* Side pane area - responsive behavior */} {sidePane && ( -
{sidePane}
+
+ {sidePane} +
)}
); diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index bfb2983ac..54c5e883c 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -510,7 +510,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re > {/* Vertical divider line - draggable on XL screens */}
const isCollapsed = useContext(collapsedContext); return (
- {isCollapsed ?
: children} + {isCollapsed ?
: children}
); } @@ -747,7 +747,7 @@ function MobileMenu({ ariaLabel, topMenuContent }: { ariaLabel: string; topMenuC size="icon" onPress={() => setIsOpen(false)} aria-label="Close menu" - className="h-12 w-12 rounded-full border border-border/50 bg-background/80 shadow-lg backdrop-blur-sm hover:bg-background/90" + className="h-12 w-12 rounded-full border border-border bg-background/80 shadow-lg backdrop-blur-sm hover:bg-background/90" > diff --git a/application/shared-webapp/ui/components/Toast.tsx b/application/shared-webapp/ui/components/Toast.tsx index 31927fa76..2b52f2827 100644 --- a/application/shared-webapp/ui/components/Toast.tsx +++ b/application/shared-webapp/ui/components/Toast.tsx @@ -253,7 +253,7 @@ function isReactNode(toast: ToastContents): toast is React.ReactNode { const toastActionStyles = tv({ extend: focusRing, base: [ - "inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-border/50 px-3 font-medium text-sm transition-colors" + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border border-border px-3 font-medium text-sm transition-colors" ], variants: { variant: { From eb41a59f0b744e07a57acf539d81c00aa4aa3a1e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 18:43:44 +0200 Subject: [PATCH 60/88] Ensure spacing between side menu and main content aligns with application --- application/shared-webapp/ui/hooks/useSideMenuLayout.ts | 4 ++-- application/shared-webapp/ui/utils/responsive.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/application/shared-webapp/ui/hooks/useSideMenuLayout.ts b/application/shared-webapp/ui/hooks/useSideMenuLayout.ts index 44bebc397..9f9f4fc3e 100644 --- a/application/shared-webapp/ui/hooks/useSideMenuLayout.ts +++ b/application/shared-webapp/ui/hooks/useSideMenuLayout.ts @@ -130,13 +130,13 @@ export function useSideMenuLayout(): { // Medium screens (overlay mode): always use collapsed width if (isSmallScreen && !isLargeScreen) { return { - marginLeft: SIDE_MENU_COLLAPSED_WIDTH + marginLeft: `${SIDE_MENU_COLLAPSED_WIDTH}px` }; } // Large screens: adjust based on menu state return { - marginLeft: isCollapsed ? SIDE_MENU_COLLAPSED_WIDTH : `${customMenuWidth + 8}px` + marginLeft: isCollapsed ? `${SIDE_MENU_COLLAPSED_WIDTH}px` : `${customMenuWidth}px` }; }, [isSmallScreen, isLargeScreen, isCollapsed, customMenuWidth]); diff --git a/application/shared-webapp/ui/utils/responsive.ts b/application/shared-webapp/ui/utils/responsive.ts index 01e837d5c..06f597ac9 100644 --- a/application/shared-webapp/ui/utils/responsive.ts +++ b/application/shared-webapp/ui/utils/responsive.ts @@ -19,11 +19,8 @@ export const MEDIA_QUERIES = { "2xl": "(min-width: 1536px)" } as const; -// Side menu width constants (including 8px right margin) -export const SIDE_MENU_COLLAPSED_WIDTH = "80px"; // 72px + 8px margin -export const SIDE_MENU_EXPANDED_WIDTH = "296px"; // 288px + 8px margin - -// Resizable menu constraints +// Side menu width constants +export const SIDE_MENU_COLLAPSED_WIDTH = 72; export const SIDE_MENU_MIN_WIDTH = 150; export const SIDE_MENU_MAX_WIDTH = 300; export const SIDE_MENU_DEFAULT_WIDTH = 288; From d21725334a5b696fd301cd97a884044198e464ea Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 19:25:03 +0200 Subject: [PATCH 61/88] Add deep link to users --- .../WebApp/routes/admin/users/index.tsx | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index 50b11b93b..90c49f9c2 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -1,12 +1,12 @@ 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"; @@ -24,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/")({ @@ -37,17 +38,47 @@ export default function UsersPage() { const [profileUser, setProfileUser] = useState(null); const [userToDelete, setUserToDelete] = useState(null); const [userToChangeRole, setUserToChangeRole] = useState(null); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const navigate = useNavigate({ from: Route.fullPath }); + const { userId } = Route.useSearch(); const handleCloseProfile = () => { setProfileUser(null); - // Also clear selection when closing profile 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: usersData } = api.useQuery("get", "/api/account-management/users", { + params: { + query: { + PageSize: 1000 + } + }, + enabled: !!userId && isInitialLoad + }); + + useEffect(() => { + if (userId && usersData?.users && isInitialLoad) { + const userToOpen = usersData.users.find((u) => u.id === userId); + if (userToOpen) { + setProfileUser(userToOpen); + setSelectedUsers([userToOpen]); + } + setIsInitialLoad(false); + } else if (!userId && isInitialLoad) { + setIsInitialLoad(false); + } + }, [userId, usersData?.users, isInitialLoad]); + const handleDeleteUser = (user: UserDetails) => { setUserToDelete(user); }; From 85872e5bbd085d628baf7b8feb838f0655b9bdd6 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 21:22:26 +0200 Subject: [PATCH 62/88] Add API endpoint to get user by ID for deep linking support --- .../Api/Endpoints/UserEndpoints.cs | 4 + .../Features/Users/Queries/GetUserById.cs | 26 ++++++ .../Tests/Users/GetUserByIdTests.cs | 85 +++++++++++++++++++ .../shared/lib/api/AccountManagement.Api.json | 51 +++++++++-- 4 files changed, 160 insertions(+), 6 deletions(-) create mode 100644 application/account-management/Core/Features/Users/Queries/GetUserById.cs create mode 100644 application/account-management/Tests/Users/GetUserByIdTests.cs 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/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/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/shared/lib/api/AccountManagement.Api.json b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json index 2960c5a4d..c55e34560 100644 --- a/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json +++ b/application/account-management/WebApp/shared/lib/api/AccountManagement.Api.json @@ -491,12 +491,23 @@ } } }, - "/api/account-management/users/summary": { + "/api/account-management/users/{id}": { "get": { "tags": [ "Users" ], - "operationId": "GetApiAccountManagementUsersSummary", + "operationId": "GetApiAccountManagementUsers2", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/UserId" + }, + "x-position": 1 + } + ], "responses": { "400": { "description": "", @@ -513,15 +524,13 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UserSummaryResponse" + "$ref": "#/components/schemas/UserDetails" } } } } } - } - }, - "/api/account-management/users/{id}": { + }, "delete": { "tags": [ "Users" @@ -552,6 +561,36 @@ } } }, + "/api/account-management/users/summary": { + "get": { + "tags": [ + "Users" + ], + "operationId": "GetApiAccountManagementUsersSummary", + "responses": { + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HttpValidationProblemDetails" + } + } + } + }, + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserSummaryResponse" + } + } + } + } + } + } + }, "/api/account-management/users/bulk-delete": { "post": { "tags": [ From 748f15a82d80ec4f64e1f2a77a423dd10324f9a2 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 21:38:40 +0200 Subject: [PATCH 63/88] Fetch up-to-date user details for side pane and show warning when deep-linked user is not in list view --- .../users/-components/UserProfileSidePane.tsx | 193 ++++++++++-------- .../admin/users/-components/UserTable.tsx | 10 +- .../WebApp/routes/admin/users/index.tsx | 28 ++- .../shared/translations/locale/da-DK.po | 3 + .../shared/translations/locale/en-US.po | 3 + .../shared/translations/locale/nl-NL.po | 3 + 6 files changed, 145 insertions(+), 95 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index bf1724599..69803111c 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -11,7 +11,7 @@ import { Separator } from "@repo/ui/components/Separator"; import { Text } from "@repo/ui/components/Text"; import { formatDate } from "@repo/utils/date/formatDate"; import { getInitials } from "@repo/utils/string/getInitials"; -import { Trash2Icon, XIcon } from "lucide-react"; +import { InfoIcon, Trash2Icon, XIcon } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ChangeUserRoleDialog } from "./ChangeUserRoleDialog"; @@ -22,9 +22,16 @@ interface UserProfileSidePaneProps { isOpen: boolean; onClose: () => void; onDeleteUser: (user: UserDetails) => void; + isUserInCurrentView?: boolean; } -export function UserProfileSidePane({ user, isOpen, onClose, onDeleteUser }: Readonly) { +export function UserProfileSidePane({ + user, + isOpen, + onClose, + onDeleteUser, + isUserInCurrentView = true +}: Readonly) { const userInfo = useUserInfo(); const sidePaneRef = useRef(null); const closeButtonRef = useRef(null); @@ -101,11 +108,11 @@ export function UserProfileSidePane({ user, isOpen, onClose, onDeleteUser }: Rea }; }, [isOpen]); - if (!isOpen || !user) { + if (!isOpen) { return null; } - const isCurrentUser = user.id === userInfo?.id; + const isCurrentUser = user?.id === userInfo?.id; const canModifyUser = userInfo?.role === "Owner" && !isCurrentUser; return ( @@ -139,97 +146,115 @@ export function UserProfileSidePane({ user, isOpen, onClose, onDeleteUser }: Rea
- {/* Content */} -
- {/* User Avatar and Basic Info */} -
- - - {user.firstName} {user.lastName} - - {user.title && {user.title}} + {/* Notice when user is not in current filtered view */} + {!isUserInCurrentView && ( +
+
+ + + User not in current view + +
+ )} - {/* Contact Information */} -
-
-
- - Email - -
- {user.email} - {user.emailConfirmed ? ( - - Verified - + {/* Content */} +
+ {user && ( +
+ <> + {/* 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 ? ( + ) : ( - Pending + {getUserRoleLabel(user.role)} )}
-
-
-
- - - {/* Role Information */} -
- - Role - - {canModifyUser ? ( - - ) : ( - - {getUserRoleLabel(user.role)} - - )} -
+ - - - {/* Account Details */} -
-
-
- - Created - - {formatDate(user.createdAt, true)} -
-
- - Modified - - {formatDate(user.modifiedAt, true)} -
+ {/* Account Details */} +
+
+
+ + Created + + {formatDate(user.createdAt, true)} +
+
+ + Modified + + {formatDate(user.modifiedAt, true)} +
+
+
+
-
+ )}
{/* Quick Actions */} - {canModifyUser && ( + {canModifyUser && user && (
{/* Change User Role Dialog */} - + {user && ( + + )} ); } 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 21bff7a43..21dc865f7 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -26,6 +26,7 @@ interface UserTableProps { onViewProfile: (user: UserDetails | null) => void; onDeleteUser: (user: UserDetails) => void; onChangeRole: (user: UserDetails) => void; + onUsersLoaded?: (users: UserDetails[]) => void; } export function UserTable({ @@ -33,7 +34,8 @@ export function UserTable({ onSelectedUsersChange, onViewProfile, onDeleteUser, - onChangeRole + onChangeRole, + onUsersLoaded }: Readonly) { const navigate = useNavigate(); const { search, userRole, userStatus, startDate, endDate, orderBy, sortOrder, pageOffset } = useSearch({ @@ -94,6 +96,12 @@ export function UserTable({ onSelectedUsersChange([]); }, [onSelectedUsersChange]); + useEffect(() => { + if (users?.users) { + onUsersLoaded?.(users.users); + } + }, [users?.users, onUsersLoaded]); + const handleSelectionChange = useCallback( (keys: Selection) => { if (keys === "all") { diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index 90c49f9c2..ba554ec0d 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -39,6 +39,7 @@ export default function UsersPage() { 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(); @@ -57,27 +58,24 @@ export default function UsersPage() { } }; - const { data: usersData } = api.useQuery("get", "/api/account-management/users", { + const { data: userData } = api.useQuery("get", "/api/account-management/users/{id}", { params: { - query: { - PageSize: 1000 + path: { + id: userId || "" } }, enabled: !!userId && isInitialLoad }); useEffect(() => { - if (userId && usersData?.users && isInitialLoad) { - const userToOpen = usersData.users.find((u) => u.id === userId); - if (userToOpen) { - setProfileUser(userToOpen); - setSelectedUsers([userToOpen]); - } + if (userId && userData && isInitialLoad) { + setProfileUser(userData); + setSelectedUsers([userData]); setIsInitialLoad(false); } else if (!userId && isInitialLoad) { setIsInitialLoad(false); } - }, [userId, usersData?.users, isInitialLoad]); + }, [userId, userData, isInitialLoad]); const handleDeleteUser = (user: UserDetails) => { setUserToDelete(user); @@ -87,6 +85,12 @@ export default function UsersPage() { setUserToChangeRole(user); }; + const handleUsersLoaded = (users: UserDetails[]) => { + setTableUsers(users); + }; + + const isUserInCurrentView = profileUser ? tableUsers.some((u) => u.id === profileUser.id) : true; + return ( <> @@ -95,9 +99,10 @@ export default function UsersPage() { profileUser ? ( ) : undefined } @@ -130,6 +135,7 @@ export default function UsersPage() { onViewProfile={handleViewProfile} onDeleteUser={handleDeleteUser} onChangeRole={handleChangeRole} + onUsersLoaded={handleUsersLoaded} />
diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 0945e13ef..3f967acff 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -406,6 +406,9 @@ msgstr "Brugerhandlinger" msgid "User invited successfully" msgstr "Bruger inviteret succesfuldt" +msgid "User not in current view" +msgstr "Bruger ikke i nuværende visning" + msgid "User profile" msgstr "Brugerprofil" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index f5b1e0a2b..9276ca6cb 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -406,6 +406,9 @@ msgstr "User actions" msgid "User invited successfully" msgstr "User invited successfully" +msgid "User not in current view" +msgstr "User not in current view" + msgid "User profile" msgstr "User profile" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 8d9d52da7..10e4de177 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -406,6 +406,9 @@ msgstr "Gebruikersacties" msgid "User invited successfully" msgstr "Gebruiker succesvol uitgenodigd" +msgid "User not in current view" +msgstr "Gebruiker niet in huidige weergave" + msgid "User profile" msgstr "Gebruikersprofiel" From 7cb309c9167df665b5ad2eed4070e91eed23ee35 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 4 Jul 2025 22:04:03 +0200 Subject: [PATCH 64/88] Show data freshness warning when user data differs between side pane and table --- .../users/-components/UserProfileSidePane.tsx | 16 +++++++++++++++- .../WebApp/routes/admin/users/index.tsx | 11 +++++++++++ .../WebApp/shared/translations/locale/da-DK.po | 3 +++ .../WebApp/shared/translations/locale/en-US.po | 3 +++ .../WebApp/shared/translations/locale/nl-NL.po | 3 +++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 69803111c..87eb438d0 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -23,6 +23,7 @@ interface UserProfileSidePaneProps { onClose: () => void; onDeleteUser: (user: UserDetails) => void; isUserInCurrentView?: boolean; + isDataNewer?: boolean; } export function UserProfileSidePane({ @@ -30,7 +31,8 @@ export function UserProfileSidePane({ isOpen, onClose, onDeleteUser, - isUserInCurrentView = true + isUserInCurrentView = true, + isDataNewer = false }: Readonly) { const userInfo = useUserInfo(); const sidePaneRef = useRef(null); @@ -158,6 +160,18 @@ export function UserProfileSidePane({
)} + {/* Notice when data is different from table */} + {isDataNewer && ( +
+
+ + + User data updated + +
+
+ )} + {/* Content */}
{user && ( diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index ba554ec0d..75ecdb1d7 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -91,6 +91,16 @@ export default function UsersPage() { 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 ( <> @@ -103,6 +113,7 @@ export default function UsersPage() { onClose={handleCloseProfile} onDeleteUser={handleDeleteUser} isUserInCurrentView={isUserInCurrentView} + isDataNewer={isDataNewer} /> ) : undefined } diff --git a/application/account-management/WebApp/shared/translations/locale/da-DK.po b/application/account-management/WebApp/shared/translations/locale/da-DK.po index 3f967acff..b43402cbf 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -403,6 +403,9 @@ msgstr "Upload profilbillede" msgid "User actions" msgstr "Brugerhandlinger" +msgid "User data updated" +msgstr "Brugerdata opdateret" + msgid "User invited successfully" msgstr "Bruger inviteret succesfuldt" diff --git a/application/account-management/WebApp/shared/translations/locale/en-US.po b/application/account-management/WebApp/shared/translations/locale/en-US.po index 9276ca6cb..900a057f3 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -403,6 +403,9 @@ msgstr "Upload profile picture" msgid "User actions" msgstr "User actions" +msgid "User data updated" +msgstr "User data updated" + msgid "User invited successfully" msgstr "User invited successfully" diff --git a/application/account-management/WebApp/shared/translations/locale/nl-NL.po b/application/account-management/WebApp/shared/translations/locale/nl-NL.po index 10e4de177..ff5915b52 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -403,6 +403,9 @@ msgstr "Profielfoto uploaden" msgid "User actions" msgstr "Gebruikersacties" +msgid "User data updated" +msgstr "Gebruikersgegevens bijgewerkt" + msgid "User invited successfully" msgstr "Gebruiker succesvol uitgenodigd" From d2d0a7f3e1abde4ee58d2954f144d8fb6d73244b Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 5 Jul 2025 01:42:15 +0200 Subject: [PATCH 65/88] Reset pagination when filter is changed on users page --- .../admin/users/-components/UserQuerying.tsx | 41 +++++++++++-------- .../admin/users/-components/UserToolbar.tsx | 2 +- 2 files changed, 26 insertions(+), 17 deletions(-) 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 5b7ff7d5e..670e64ab9 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -35,6 +35,7 @@ interface UserQueryingProps { hasActiveFilters: boolean, shouldUseCompactButtons: boolean ) => void; + onFiltersUpdated?: () => void; } /** @@ -43,7 +44,7 @@ interface UserQueryingProps { * The only local state is for the search input, which is debounced * to prevent too many URL updates while typing. */ -export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { +export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQueryingProps = {}) { const navigate = useNavigate(); const searchParams = (useLocation().search as SearchParams) ?? {}; const { isOverlayOpen, isMobileMenuOpen } = useSideMenuLayout(); @@ -67,32 +68,40 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { // 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 }); - setSearchTimeoutId(null); - }, 500); - setSearchTimeoutId(timeoutId); - - return () => { - clearTimeout(timeoutId); - setSearchTimeoutId(null); - }; - }, [search, updateFilter]); + // 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); + setSearchTimeoutId(null); + }; + } + }, [search, searchParams.search, updateFilter]); // Count active filters for badge const getActiveFilterCount = () => { @@ -275,7 +284,7 @@ export function UserQuerying({ onFilterStateChange }: UserQueryingProps = {}) { clearTimeout(searchTimeoutId); setSearchTimeoutId(null); } - updateFilter({ search: (search as string) || undefined }); + updateFilter({ search: (search as string) || undefined }, true); }} label={t`Search`} autoFocus={true} 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 3d1726150..3de7aef73 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserToolbar.tsx @@ -34,7 +34,7 @@ export function UserToolbar({ selectedUsers, onSelectedUsersChange }: Readonly - + onSelectedUsersChange([])} />
{selectedUsers.length < 2 && isOwner && ( From 175e16f00feda711c6a5c6ebd7ab4ac58fb9903c Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 5 Jul 2025 12:53:41 +0200 Subject: [PATCH 66/88] Add skeleton when loading user profile to prevent displaying state data on slow connections --- .../users/-components/UserProfileSidePane.tsx | 20 ++++++++++++++++--- .../WebApp/routes/admin/users/index.tsx | 13 +++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 87eb438d0..8c6e363b7 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -24,6 +24,7 @@ interface UserProfileSidePaneProps { onDeleteUser: (user: UserDetails) => void; isUserInCurrentView?: boolean; isDataNewer?: boolean; + isLoading?: boolean; } export function UserProfileSidePane({ @@ -32,7 +33,8 @@ export function UserProfileSidePane({ onClose, onDeleteUser, isUserInCurrentView = true, - isDataNewer = false + isDataNewer = false, + isLoading = false }: Readonly) { const userInfo = useUserInfo(); const sidePaneRef = useRef(null); @@ -174,7 +176,19 @@ export function UserProfileSidePane({ {/* Content */}
- {user && ( + {isLoading ? ( +
+ {/* Avatar skeleton matching exact position */} +
+
+
+
+
+ + {/* Single block for all other content */} +
+
+ ) : user ? (
<> {/* User Avatar and Basic Info */} @@ -264,7 +278,7 @@ export function UserProfileSidePane({
- )} + ) : null}
{/* Quick Actions */} diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index 75ecdb1d7..a97d99d83 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -58,20 +58,22 @@ export default function UsersPage() { } }; - const { data: userData } = api.useQuery("get", "/api/account-management/users/{id}", { + const { data: userData, isLoading: isLoadingUser } = api.useQuery("get", "/api/account-management/users/{id}", { params: { path: { id: userId || "" } }, - enabled: !!userId && isInitialLoad + enabled: !!userId }); useEffect(() => { - if (userId && userData && isInitialLoad) { + if (userId && userData) { setProfileUser(userData); - setSelectedUsers([userData]); - setIsInitialLoad(false); + if (isInitialLoad) { + setSelectedUsers([userData]); + setIsInitialLoad(false); + } } else if (!userId && isInitialLoad) { setIsInitialLoad(false); } @@ -114,6 +116,7 @@ export default function UsersPage() { onDeleteUser={handleDeleteUser} isUserInCurrentView={isUserInCurrentView} isDataNewer={isDataNewer} + isLoading={isLoadingUser || !!(userId && profileUser.id !== userId)} /> ) : undefined } From 82305dbcbdbd8a8b566b6aeb0fc4bb81b3653dd9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 5 Jul 2025 15:08:26 +0200 Subject: [PATCH 67/88] Make borders in user table row rounded and change background color on hover of selected row --- application/shared-webapp/ui/components/Table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/shared-webapp/ui/components/Table.tsx b/application/shared-webapp/ui/components/Table.tsx index 45c33f08b..7424a0efb 100644 --- a/application/shared-webapp/ui/components/Table.tsx +++ b/application/shared-webapp/ui/components/Table.tsx @@ -134,7 +134,7 @@ const rowStyles = tv({ true: "text-muted-foreground/90" }, isSelected: { - true: "bg-active-background hover:bg-selected-hover-background" + true: "rounded-md bg-active-background hover:bg-active-background" } } }); From 55aa734cbeb817b59b32d3e32d39a502afcf6efd Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 6 Jul 2025 11:03:56 +0200 Subject: [PATCH 68/88] Make borders in user table row rounded and change background color on hover of selected row --- application/shared-webapp/ui/components/Table.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/shared-webapp/ui/components/Table.tsx b/application/shared-webapp/ui/components/Table.tsx index 7424a0efb..16c8d9488 100644 --- a/application/shared-webapp/ui/components/Table.tsx +++ b/application/shared-webapp/ui/components/Table.tsx @@ -161,7 +161,7 @@ export function Row({ id, columns, children, ...rowProps }: Re const cellStyles = tv({ extend: focusRing, - base: "-outline-offset-2 truncate border-b border-b-border p-2 group-first/row:border-y group-first/row:border-t-border group-last/row:border-b-0 group-selected/row:border-accent [:has(+[data-selected])_&]:border-accent" + base: "-outline-offset-2 truncate border-b border-b-border p-2 group-first/row:border-y group-first/row:border-t-border group-last/row:border-b-0" }); type CellProps = { From 822eb54d80d9291c0b402352793feffdb23e40a6 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 13 Jul 2025 16:32:57 -0700 Subject: [PATCH 69/88] Fix complexity warnings in SideMenu --- .../shared-webapp/ui/components/SideMenu.tsx | 411 ++++++++++-------- 1 file changed, 226 insertions(+), 185 deletions(-) diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 54c5e883c..dad2be13e 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -85,6 +85,21 @@ type MenuButtonProps = { } ); +// Helper function to get target path from href +const getTargetPath = (to: Href | string, router: ReturnType): string => { + if (typeof to === "string") { + return to; + } + try { + return router.buildLocation({ to: to as MakeRouteMatch }).pathname; + } catch { + return String(to); + } +}; + +// Helper function to normalize path +const normalizePath = (path: string): string => path.replace(/\/$/, "") || "/"; + export function MenuButton({ icon: Icon, label, @@ -99,25 +114,8 @@ export function MenuButton({ // Check if this menu item is active const currentPath = router.state.location.pathname; - let targetPath: string; - - if (typeof to === "string") { - targetPath = to; - } else { - try { - targetPath = router.buildLocation({ to: to as MakeRouteMatch }).pathname; - } catch { - // If buildLocation fails, fallback to string representation - targetPath = String(to); - } - } - - // Normalize paths by removing trailing slashes - const normalizedCurrentPath = currentPath.replace(/\/$/, "") || "/"; - const normalizedTargetPath = targetPath.replace(/\/$/, "") || "/"; - - // Check if current path matches the target path exactly - const isActive = normalizedCurrentPath === normalizedTargetPath; + const targetPath = getTargetPath(to, router); + const isActive = normalizePath(currentPath) === normalizePath(targetPath); const onPress = () => { if (to == null) { @@ -230,48 +228,199 @@ type SideMenuProps = { tenantName?: string; }; +// Helper function to get initial menu width from localStorage +const _getInitialMenuWidth = (): number => { + const stored = localStorage.getItem("side-menu-size"); + if (stored) { + const width = Number.parseInt(stored, 10); + if (!Number.isNaN(width) && width >= SIDE_MENU_MIN_WIDTH && width <= SIDE_MENU_MAX_WIDTH) { + return width; + } + } + return SIDE_MENU_DEFAULT_WIDTH; +}; + +// Helper function to get initial collapsed state +const _getInitialCollapsedState = (forceCollapsed: boolean): boolean => { + if (forceCollapsed) { + return true; + } + return localStorage.getItem("side-menu-collapsed") === "true"; +}; + +// Helper function to get user preference for collapsed state +const _getUserPreference = (): boolean => { + return localStorage.getItem("side-menu-collapsed") === "true"; +}; + +// Helper function to check if drag movement has started +const _checkDragStarted = ( + e: MouseEvent, + dragStartPos: React.MutableRefObject<{ x: number; y: number } | null>, + hasDraggedRef: React.MutableRefObject +): void => { + if (dragStartPos.current && !hasDraggedRef.current) { + const distance = Math.abs(e.clientX - dragStartPos.current.x) + Math.abs(e.clientY - dragStartPos.current.y); + if (distance > 5) { + hasDraggedRef.current = true; + } + } +}; + +// Helper function to handle resize actions +const _handleResizeAction = ( + mouseX: number, + isCollapsed: boolean, + hasTriggeredCollapse: boolean, + setMenuWidth: (width: number) => void +): void => { + if (!isCollapsed && !hasTriggeredCollapse) { + const newWidth = Math.min(Math.max(mouseX, SIDE_MENU_MIN_WIDTH), SIDE_MENU_MAX_WIDTH); + setMenuWidth(newWidth); + window.dispatchEvent(new CustomEvent("side-menu-resize", { detail: { width: newWidth } })); + } +}; + +// Helper function to dispatch menu toggle event +const _dispatchMenuToggleEvent = (overlayMode: boolean, isExpanded: boolean): void => { + if (overlayMode) { + window.dispatchEvent(new CustomEvent("side-menu-overlay-toggle", { detail: { isExpanded } })); + } else { + window.dispatchEvent(new CustomEvent("side-menu-toggle", { detail: { isCollapsed: !isExpanded } })); + } +}; + +// Helper function to save menu preference +const _saveMenuPreference = (isCollapsed: boolean): void => { + localStorage.setItem("side-menu-collapsed", isCollapsed.toString()); +}; + +// Helper function to focus toggle button +const _focusToggleButton = (toggleButtonRef: React.RefObject): void => { + if (!toggleButtonRef.current) { + return; + } + + if (toggleButtonRef.current instanceof HTMLButtonElement) { + toggleButtonRef.current.focus(); + } else { + const button = toggleButtonRef.current.querySelector("button"); + button?.focus(); + } +}; + +// Helper function to handle resize with arrow keys +const _handleArrowKeyResize = ( + e: React.KeyboardEvent, + direction: "left" | "right", + menuWidth: number, + setMenuWidth: (width: number) => void +): void => { + e.preventDefault(); + const delta = direction === "left" ? -10 : 10; + const newWidth = Math.min(Math.max(menuWidth + delta, SIDE_MENU_MIN_WIDTH), SIDE_MENU_MAX_WIDTH); + setMenuWidth(newWidth); + localStorage.setItem("side-menu-size", newWidth.toString()); + window.dispatchEvent(new CustomEvent("side-menu-resize", { detail: { width: newWidth } })); +}; + +// Backdrop component for overlay mode +const OverlayBackdrop = ({ closeOverlay }: { closeOverlay: () => void }) => ( +
e.key === "Enter" && closeOverlay()} + role="button" + tabIndex={0} + aria-label="Close menu" + /> +); + +// Logo and tenant name component +const LogoSection = ({ actualIsCollapsed, tenantName }: { actualIsCollapsed: boolean; tenantName?: string }) => ( +
+ Logo + {!actualIsCollapsed && ( + + {tenantName || "PlatformPlatform"} + + )} +
+); + +// Toggle button component for XL screens +const ResizableToggleButton = ({ + toggleButtonRef, + handleResizeStart, + hasDraggedRef, + toggleMenu, + menuWidth, + setMenuWidth, + ariaLabel, + actualIsCollapsed +}: { + toggleButtonRef: React.RefObject; + handleResizeStart: (e: React.MouseEvent) => void; + hasDraggedRef: React.MutableRefObject; + toggleMenu: () => void; + menuWidth: number; + setMenuWidth: (width: number) => void; + ariaLabel: string; + actualIsCollapsed: boolean; +}) => ( + +); + export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Readonly) { const { className, forceCollapsed, overlayMode, isHidden } = useResponsiveMenu(); const sideMenuRef = useRef(null); const toggleButtonRef = useRef(null); const [isOverlayOpen, setIsOverlayOpen] = useState(false); const [isResizing, setIsResizing] = useState(false); - const [menuWidth, setMenuWidth] = useState(() => { - try { - const stored = localStorage.getItem("side-menu-size"); - if (stored) { - const width = Number.parseInt(stored, 10); - if (!Number.isNaN(width) && width >= SIDE_MENU_MIN_WIDTH && width <= SIDE_MENU_MAX_WIDTH) { - return width; - } - } - } catch {} - return SIDE_MENU_DEFAULT_WIDTH; - }); - - // Initialize collapsed state with synchronous check to prevent flicker - const [isCollapsed, setIsCollapsed] = useState(() => { - // Force collapsed on medium screens - if (forceCollapsed) { - return true; - } - - // Check localStorage for large screens - try { - return localStorage.getItem("side-menu-collapsed") === "true"; - } catch { - return false; - } - }); - - // Save the user's preference before being forced collapsed - const [userPreference, setUserPreference] = useState(() => { - try { - return localStorage.getItem("side-menu-collapsed") === "true"; - } catch { - return false; - } - }); + const [menuWidth, setMenuWidth] = useState(_getInitialMenuWidth); + const [isCollapsed, setIsCollapsed] = useState(() => _getInitialCollapsedState(forceCollapsed)); + const [userPreference, setUserPreference] = useState(_getUserPreference); // Update collapsed state when screen size changes useEffect(() => { @@ -292,49 +441,24 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re const toggleMenu = useCallback(() => { if (overlayMode) { - setIsOverlayOpen(!isOverlayOpen); - // Dispatch event for layout hook - window.dispatchEvent( - new CustomEvent("side-menu-overlay-toggle", { - detail: { isExpanded: !isOverlayOpen } - }) - ); + const newIsOpen = !isOverlayOpen; + setIsOverlayOpen(newIsOpen); + _dispatchMenuToggleEvent(true, newIsOpen); } else if (!forceCollapsed) { const newCollapsed = !isCollapsed; setIsCollapsed(newCollapsed); setUserPreference(newCollapsed); - try { - localStorage.setItem("side-menu-collapsed", newCollapsed.toString()); - } catch {} - // Dispatch event for layout hook - window.dispatchEvent( - new CustomEvent("side-menu-toggle", { - detail: { isCollapsed: newCollapsed } - }) - ); + _saveMenuPreference(newCollapsed); + _dispatchMenuToggleEvent(false, !newCollapsed); } // Maintain focus on the toggle button after state change - setTimeout(() => { - if (toggleButtonRef.current) { - if (toggleButtonRef.current instanceof HTMLButtonElement) { - toggleButtonRef.current.focus(); - } else { - // For ToggleButton wrapped in div, find the button inside - const button = toggleButtonRef.current.querySelector("button"); - button?.focus(); - } - } - }, 0); + setTimeout(() => _focusToggleButton(toggleButtonRef), 0); }, [overlayMode, isOverlayOpen, forceCollapsed, isCollapsed]); const closeOverlay = useCallback(() => { if (overlayMode && isOverlayOpen) { setIsOverlayOpen(false); - window.dispatchEvent( - new CustomEvent("side-menu-overlay-toggle", { - detail: { isExpanded: false } - }) - ); + _dispatchMenuToggleEvent(true, false); } }, [overlayMode, isOverlayOpen]); @@ -419,12 +543,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re const mouseX = e.clientX - 8; // Check if mouse has moved more than 5px from start (indicates dragging) - if (dragStartPos.current && !hasDraggedRef.current) { - const distance = Math.abs(e.clientX - dragStartPos.current.x) + Math.abs(e.clientY - dragStartPos.current.y); - if (distance > 5) { - hasDraggedRef.current = true; - } - } + _checkDragStarted(e, dragStartPos, hasDraggedRef); // If dragging from collapsed state and mouse is past collapsed width, expand if (isCollapsed && mouseX > 100) { @@ -444,16 +563,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re } // Normal resize when expanded - if (!isCollapsed && !hasTriggeredCollapse) { - const newWidth = Math.min(Math.max(mouseX, SIDE_MENU_MIN_WIDTH), SIDE_MENU_MAX_WIDTH); - setMenuWidth(newWidth); - // Dispatch event for layout hook during drag - window.dispatchEvent( - new CustomEvent("side-menu-resize", { - detail: { width: newWidth } - }) - ); - } + _handleResizeAction(mouseX, isCollapsed, hasTriggeredCollapse, setMenuWidth); }; const handleMouseUp = () => { @@ -461,9 +571,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re document.body.style.cursor = ""; // Only save if we didn't trigger collapse if (!hasTriggeredCollapse && !isCollapsed) { - try { - localStorage.setItem("side-menu-size", menuWidth.toString()); - } catch {} + localStorage.setItem("side-menu-size", menuWidth.toString()); } dragStartPos.current = null; }; @@ -481,16 +589,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re return ( <> {/* Backdrop for overlay mode */} - {overlayMode && isOverlayOpen && ( -
e.key === "Enter" && closeOverlay()} - role="button" - tabIndex={0} - aria-label="Close menu" - /> - )} + {overlayMode && isOverlayOpen && } @@ -518,33 +617,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re {/* Fixed header section with logo */}
- {/* Logo and tenant name container */} -
- Logo - {!actualIsCollapsed && ( - - {tenantName || "PlatformPlatform"} - - )} -
+ {/* Toggle button centered on divider, at intersection with topbar border */}
{isXlScreen ? ( - // Draggable button that acts as resize handle - + } + handleResizeStart={handleResizeStart} + hasDraggedRef={hasDraggedRef} + toggleMenu={toggleMenu} + menuWidth={menuWidth} + setMenuWidth={setMenuWidth} + ariaLabel={ariaLabel} + actualIsCollapsed={actualIsCollapsed} + /> ) : (
}> Date: Mon, 21 Jul 2025 16:34:25 +0200 Subject: [PATCH 70/88] Reduce complexity in UserProfileSidePane and improve accessibility --- .../users/-components/UserProfileSidePane.tsx | 277 +++++++++--------- 1 file changed, 146 insertions(+), 131 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 8c6e363b7..579ecf5a4 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -9,9 +9,11 @@ import { Button } from "@repo/ui/components/Button"; import { Heading } from "@repo/ui/components/Heading"; import { Separator } from "@repo/ui/components/Separator"; import { Text } from "@repo/ui/components/Text"; +import { MEDIA_QUERIES } from "@repo/ui/utils/responsive"; import { formatDate } from "@repo/utils/date/formatDate"; import { getInitials } from "@repo/utils/string/getInitials"; import { InfoIcon, Trash2Icon, XIcon } from "lucide-react"; +import type React from "react"; import { useEffect, useRef, useState } from "react"; import { ChangeUserRoleDialog } from "./ChangeUserRoleDialog"; @@ -27,32 +29,119 @@ interface UserProfileSidePaneProps { isLoading?: boolean; } -export function UserProfileSidePane({ +function UserProfileContent({ 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); + canModifyUser, + onChangeRole +}: Readonly<{ + user: UserDetails; + canModifyUser: boolean; + onChangeRole: () => void; +}>) { + return ( + <> + {/* User Avatar and Basic Info */} +
+ + + {user.firstName} {user.lastName} + + {user.title && {user.title}} +
- // Focus management and keyboard navigation - only focus close button on mobile + {/* 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(() => { - if (isOpen && closeButtonRef.current) { - // Only auto-focus on mobile, not on larger screens where it's part of the layout - const isMobileScreen = window.matchMedia("(max-width: 639px)").matches; - if (isMobileScreen) { - closeButtonRef.current.focus(); - } + const isMobileScreen = !window.matchMedia(MEDIA_QUERIES.sm).matches; + if (isOpen && closeButtonRef.current && isMobileScreen) { + closeButtonRef.current.focus(); } - }, [isOpen]); + }, [isOpen, closeButtonRef]); - // Escape key handler useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && isOpen) { @@ -70,15 +159,9 @@ export function UserProfileSidePane({ }; }, [isOpen, onClose]); - // Focus trapping - only on mobile, not on larger screens where side pane is part of layout useEffect(() => { - if (!isOpen || !sidePaneRef.current) { - return; - } - - // Don't trap focus on larger screens where side pane is part of main layout - const isMobileScreen = window.matchMedia("(max-width: 639px)").matches; - if (!isMobileScreen) { + const isMobileScreen = !window.matchMedia(MEDIA_QUERIES.sm).matches; + if (!isOpen || !sidePaneRef.current || !isMobileScreen) { return; } @@ -106,11 +189,25 @@ export function UserProfileSidePane({ }; document.addEventListener("keydown", handleTabKey); + return () => document.removeEventListener("keydown", handleTabKey); + }, [isOpen, sidePaneRef]); +} - return () => { - document.removeEventListener("keydown", handleTabKey); - }; - }, [isOpen]); +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; @@ -122,26 +219,24 @@ export function UserProfileSidePane({ return ( <> {/* Side pane */} -
{/* Close button - positioned like modal dialogs */} { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClose(); } }} - tabIndex={0} - role="button" - className="absolute top-3 right-2 z-10 h-10 w-10 cursor-pointer p-2 hover:bg-muted focus:bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring" - aria-label={t`Close user profile`} />
@@ -188,97 +283,17 @@ export function UserProfileSidePane({ {/* Single block for all other content */}
- ) : user ? ( -
- <> - {/* 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)} -
-
-
- -
- ) : null} + ) : ( + user && ( +
+ setIsChangeRoleDialogOpen(true)} + /> +
+ ) + )}
{/* Quick Actions */} @@ -290,7 +305,7 @@ export function UserProfileSidePane({
)} -
+ {/* Change User Role Dialog */} {user && ( From 6a3d8a7555492938a54de39017f7b2493e76bfc0 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Mon, 21 Jul 2025 16:50:43 +0200 Subject: [PATCH 71/88] Ensure top menu is always hidden on mobile screens --- .../WebApp/shared/components/topMenu/index.tsx | 2 +- application/shared-webapp/ui/components/AppLayout.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/account-management/WebApp/shared/components/topMenu/index.tsx b/application/account-management/WebApp/shared/components/topMenu/index.tsx index dba94abfb..0a8cbb732 100644 --- a/application/account-management/WebApp/shared/components/topMenu/index.tsx +++ b/application/account-management/WebApp/shared/components/topMenu/index.tsx @@ -16,7 +16,7 @@ interface TopMenuProps { export function TopMenu({ children, sidePaneOpen = false }: Readonly) { return ( -
From 65036f0e73a3f89642aac98d63d06f9a38c1fef8 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 22 Jul 2025 22:10:34 +0200 Subject: [PATCH 83/88] Clear selected users when pagination changes --- .../WebApp/routes/admin/users/-components/UserTable.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 69ab2cd03..86fea6373 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -92,9 +92,10 @@ export function UserTable({ [navigate] ); + // biome-ignore lint/correctness/useExhaustiveDependencies: Clear selected users when page changes - pageOffset is needed to trigger the effect useEffect(() => { onSelectedUsersChange([]); - }, [onSelectedUsersChange]); + }, [onSelectedUsersChange, pageOffset]); useEffect(() => { if (users?.users) { From f5877608dc5af27155cf903763c913e27d2150a6 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 22 Jul 2025 23:52:42 +0200 Subject: [PATCH 84/88] Make AppLayout more accessible by adding main and secondary navigation markers and change menu items from toggle buttons to links --- .../shared-webapp/ui/components/AppLayout.tsx | 90 +++++---- .../shared-webapp/ui/components/Link.tsx | 4 +- .../shared-webapp/ui/components/SideMenu.tsx | 177 ++++++++++++------ 3 files changed, 182 insertions(+), 89 deletions(-) diff --git a/application/shared-webapp/ui/components/AppLayout.tsx b/application/shared-webapp/ui/components/AppLayout.tsx index 18f3b8369..a7c42e59a 100644 --- a/application/shared-webapp/ui/components/AppLayout.tsx +++ b/application/shared-webapp/ui/components/AppLayout.tsx @@ -14,9 +14,16 @@ type AppLayoutProps = { /** * AppLayout provides the fixed layout structure for applications with a side menu. - * - Fixed TopMenu that doesn't scroll with content + * - Fixed TopMenu that doesn't scroll with content (contains secondary navigation/functions) * - Scrollable content area that respects the side menu width * - Proper margin adjustments based on side menu state + * - SideMenu (rendered separately) contains the main navigation + * + * Accessibility landmarks: + * - SideMenu:
+ ); } diff --git a/application/shared-webapp/ui/components/Link.tsx b/application/shared-webapp/ui/components/Link.tsx index e11bb2814..02af4af46 100644 --- a/application/shared-webapp/ui/components/Link.tsx +++ b/application/shared-webapp/ui/components/Link.tsx @@ -7,7 +7,7 @@ import { tv } from "tailwind-variants"; import { focusRing } from "./focusRing"; interface LinkProps extends AriaLinkProps { - variant?: "primary" | "secondary"; + variant?: "primary" | "secondary" | "destructive" | "ghost"; underline?: boolean | "hover"; size?: "md" | "sm" | "lg"; } @@ -20,7 +20,7 @@ const styles = tv({ primary: "text-primary hover:text-primary/90", secondary: "text-secondary-foreground hover:text-secondary-foreground/90", destructive: "text-destructive hover:text-destructive/90", - ghost: "text-muted-foreground hover:text-muted-foreground/90" + ghost: "text-accent-foreground hover:bg-hover-background hover:text-accent-foreground/90" }, underline: { true: "underline disabled:no-underline", diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 169b8c5b9..27e75c4f9 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -1,14 +1,15 @@ import type { Href } from "@react-types/shared"; -import { type MakeRouteMatch, useRouter } from "@tanstack/react-router"; +import { type MakeRouteMatch, Link as RouterLink, useRouter } from "@tanstack/react-router"; import { ChevronsLeftIcon, type LucideIcon, Menu, X } from "lucide-react"; import type React from "react"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; -import { ToggleButton, composeRenderProps } from "react-aria-components"; +import { ToggleButton } from "react-aria-components"; import { tv } from "tailwind-variants"; import { useResponsiveMenu } from "../hooks/useResponsiveMenu"; import logoMarkUrl from "../images/logo-mark.svg"; import { MEDIA_QUERIES, SIDE_MENU_DEFAULT_WIDTH, SIDE_MENU_MAX_WIDTH, SIDE_MENU_MIN_WIDTH } from "../utils/responsive"; import { Button } from "./Button"; +import { Link } from "./Link"; import { Tooltip, TooltipTrigger } from "./Tooltip"; import { focusRing } from "./focusRing"; @@ -43,7 +44,7 @@ const _handleFocusTrap = (e: KeyboardEvent, containerRef: React.RefObject): // Helper function to normalize path const normalizePath = (path: string): string => path.replace(/\/$/, "") || "/"; +// Helper component for the menu link content +function MenuLinkContent({ + icon: Icon, + label, + isActive, + isCollapsed +}: { + icon: LucideIcon; + label: string; + isActive: boolean; + isCollapsed: boolean; +}) { + return ( + <> +
+ +
+
{label}
+ + ); +} + +// Helper component for active indicator +function ActiveIndicator({ + isActive, + isMobileMenu, + isCollapsed +}: { + isActive: boolean; + isMobileMenu: boolean; + isCollapsed: boolean; +}) { + if (!isActive) { + return null; + } + + return ( +
+ ); +} + export function MenuButton({ icon: Icon, label, @@ -110,74 +162,89 @@ export function MenuButton({ const isCollapsed = useContext(collapsedContext); const overlayCtx = useContext(overlayContext); const router = useRouter(); - const { navigate } = router; // Check if this menu item is active const currentPath = router.state.location.pathname; const targetPath = getTargetPath(to, router); const isActive = normalizePath(currentPath) === normalizePath(targetPath); - const onPress = () => { - if (to == null) { + // Check if we're in the mobile menu context + const isMobileMenu = !window.matchMedia(MEDIA_QUERIES.sm).matches && !!overlayCtx?.isOpen; + + const linkClassName = menuButtonStyles({ isCollapsed, isActive, isDisabled }); + + const handleClick = (e: React.MouseEvent) => { + if (isDisabled) { + e.preventDefault(); return; } - // Auto-close overlay after navigation (including when clicking active menu item) + // Auto-close overlay after navigation if (overlayCtx?.isOpen) { overlayCtx.close(); } - // If clicking on the current active page, still close menu but don't navigate - if (isActive) { + // Handle force reload + if (forceReload) { + e.preventDefault(); + window.location.href = to; + } + }; + + const handlePress = () => { + if (isDisabled) { return; } + // Auto-close overlay after navigation + if (overlayCtx?.isOpen) { + overlayCtx.close(); + } + + // Handle navigation for React Aria Link if (forceReload) { window.location.href = to; - } else { - navigate({ to }); } }; - // Check if we're in the mobile menu context - const isMobileMenu = !window.matchMedia(MEDIA_QUERIES.sm).matches && overlayCtx?.isOpen; - - return ( -
- {/* Active indicator bar - positioned outside button for proper visibility */} - {isActive && ( -
- )} - - - menuButtonStyles({ - ...renderProps, - className, - isCollapsed, - isActive - }) - )} - onPress={onPress} - isDisabled={isDisabled} - > -
- -
-
{label}
-
- {isCollapsed && ( + // For collapsed menu, wrap in TooltipTrigger + if (isCollapsed) { + return ( +
+ + + + + {label} - )} - + +
+ ); + } + + // For expanded menu, use TanStack Router Link + return ( +
+ + + +
); } @@ -593,7 +660,7 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re -
{/* Vertical divider line - draggable on XL screens */}
{children}
-
+
@@ -765,14 +833,17 @@ function MobileMenu({ ariaLabel, topMenuContent }: { ariaLabel: string; topMenuC )} {isOpen && ( setIsOpen(false) }}> -
-
-
-
+ +
)} From 167a3c67c16c08a0dce1c4b3555ad91a02cb183d Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 24 Jul 2025 21:58:41 +0200 Subject: [PATCH 85/88] Add minimum width to SearchField in UserQuerying component --- .../WebApp/routes/admin/users/-components/UserQuerying.tsx | 1 + 1 file changed, 1 insertion(+) 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 de4ca8e65..e648f8afc 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -288,6 +288,7 @@ export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQuer }} label={t`Search`} autoFocus={true} + className="min-w-32" /> {showAllFilters && ( From 6d1a1b127d4d91c469fc0ef76432dc68b4dda43a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 24 Jul 2025 22:05:34 +0200 Subject: [PATCH 86/88] Show disabled delete button for owners viewing their own profile --- .../admin/users/-components/UserProfileSidePane.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx index 1512f155c..323825fed 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -297,9 +297,14 @@ export function UserProfileSidePane({
{/* Quick Actions */} - {canModifyUser && user && ( + {userInfo?.role === "Owner" && user && (
- From aabaf47f3438665f1bb8c6b53e50507aca92786e Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 24 Jul 2025 22:27:16 +0200 Subject: [PATCH 87/88] Increase Actions column width in UserTable --- .../WebApp/routes/admin/users/-components/UserTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 86fea6373..a96185d20 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserTable.tsx @@ -162,7 +162,7 @@ export function UserTable({ Role - + Actions From 8c76dbc2719471afc678618a16f6ce8e47e522ad Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Tue, 29 Jul 2025 21:44:02 +0200 Subject: [PATCH 88/88] Clear userId from URL when deleting a user to prevent Not Found error --- .../account-management/WebApp/routes/admin/users/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index a97d99d83..ea393fd02 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -168,6 +168,7 @@ export default function UsersPage() { onUsersDeleted={() => { setSelectedUsers([]); setProfileUser(null); + navigate({ search: (prev) => ({ ...prev, userId: undefined }) }); }} />