diff --git a/application/AppGateway/appsettings.json b/application/AppGateway/appsettings.json index 7595556c9..bc3966aca 100644 --- a/application/AppGateway/appsettings.json +++ b/application/AppGateway/appsettings.json @@ -11,6 +11,50 @@ "AllowedHosts": "*", "ReverseProxy": { "Routes": { + "favicon": { + "ClusterId": "account-management-static", + "Match": { + "Path": "/favicon.ico" + }, + "Transforms": [ + { + "ResponseHeader": "Cache-Control", + "Set": "public, max-age=604800" + }, + { + "ResponseHeader": "Content-Type", + "Set": "image/x-icon" + } + ] + }, + "apple-touch-icon": { + "ClusterId": "account-management-static", + "Match": { + "Path": "/apple-touch-icon.png" + }, + "Transforms": [ + { + "ResponseHeader": "Cache-Control", + "Set": "public, max-age=604800" + } + ] + }, + "manifest": { + "ClusterId": "account-management-static", + "Match": { + "Path": "/manifest.json" + }, + "Transforms": [ + { + "ResponseHeader": "Cache-Control", + "Set": "public, max-age=604800" + }, + { + "ResponseHeader": "Content-Type", + "Set": "application/manifest+json" + } + ] + }, "root": { "ClusterId": "account-management-api", "Match": { diff --git a/application/account-management/WebApp/federated-modules/common/SupportDialog.tsx b/application/account-management/WebApp/federated-modules/common/SupportDialog.tsx index 887900815..4507bd914 100644 --- a/application/account-management/WebApp/federated-modules/common/SupportDialog.tsx +++ b/application/account-management/WebApp/federated-modules/common/SupportDialog.tsx @@ -1,6 +1,7 @@ import { t } from "@lingui/core/macro"; import { Button } from "@repo/ui/components/Button"; import { Dialog, DialogTrigger } from "@repo/ui/components/Dialog"; +import { DialogContent, DialogFooter, DialogHeader } from "@repo/ui/components/DialogFooter"; import { Heading } from "@repo/ui/components/Heading"; import { Modal } from "@repo/ui/components/Modal"; import { MailIcon, XIcon } from "lucide-react"; @@ -15,15 +16,16 @@ export function SupportDialog({ children }: Readonly) { {children} - + {({ close }) => ( <> - - {t`Contact support`} - -

{t`Need help? Our support team is here to assist you.`}

-
+ + + {t`Contact support`} + + +

{t`Feel free to reach out with any questions or issues you may have.`}

-
- -
-
+ + + + )}
diff --git a/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx b/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx index e4879032e..6fd4cb433 100644 --- a/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx +++ b/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx @@ -15,6 +15,20 @@ enum ThemeMode { Dark = "dark" } +function updateThemeColorMeta() { + requestAnimationFrame(() => { + const root = document.documentElement; + const computedStyle = window.getComputedStyle(root); + const backgroundHsl = computedStyle.getPropertyValue("--background").trim(); + const backgroundColor = backgroundHsl ? `hsl(${backgroundHsl.replace(/\s+/g, ", ")})` : "#000000"; + + const themeColorMetas = document.querySelectorAll('meta[name="theme-color"]'); + themeColorMetas.forEach((meta) => { + meta.setAttribute("content", backgroundColor); + }); + }); +} + export default function ThemeModeSelector({ variant = "icon", onAction @@ -52,6 +66,8 @@ export default function ThemeModeSelector({ } } + updateThemeColorMeta(); + // Listen for storage changes from other tabs/components const handleStorageChange = (e: StorageEvent) => { if (e.key === THEME_MODE_KEY && e.newValue) { @@ -109,6 +125,8 @@ export default function ThemeModeSelector({ } } + updateThemeColorMeta(); + // Dispatch event to notify other components window.dispatchEvent(new CustomEvent("theme-mode-changed", { detail: newMode })); @@ -157,7 +175,11 @@ export default function ThemeModeSelector({ )} - +
{window.matchMedia("(prefers-color-scheme: dark)").matches ? ( diff --git a/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx index c5bc34da9..2e132c6af 100644 --- a/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx +++ b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx @@ -4,6 +4,8 @@ import { Trans } from "@lingui/react/macro"; import { AuthenticationContext } from "@repo/infrastructure/auth/AuthenticationProvider"; import { Button } from "@repo/ui/components/Button"; import { Dialog } from "@repo/ui/components/Dialog"; +import { DialogContent, DialogFooter, DialogHeader } from "@repo/ui/components/DialogFooter"; +import { Heading } from "@repo/ui/components/Heading"; import { Menu, MenuItem, MenuSeparator, MenuTrigger } from "@repo/ui/components/Menu"; import { Modal } from "@repo/ui/components/Modal"; import { TextField } from "@repo/ui/components/TextField"; @@ -12,8 +14,7 @@ import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { useMutation } from "@tanstack/react-query"; import { CameraIcon, MailIcon, Trash2Icon, XIcon } from "lucide-react"; import { useCallback, useContext, useEffect, useRef, useState } from "react"; -import { FileTrigger, Form, Heading, Label } from "react-aria-components"; -import { createPortal } from "react-dom"; +import { FileTrigger, Form, Label } from "react-aria-components"; const MAX_FILE_SIZE = 1024 * 1024; // 1MB in bytes const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; // Align with backend @@ -23,27 +24,8 @@ type ProfileModalProps = { onOpenChange: (isOpen: boolean) => void; }; -type ProfileDialogProps = ProfileModalProps & { - onIsLoadingChange: (isLoading: boolean) => void; -}; - export default function UserProfileModal({ isOpen, onOpenChange }: Readonly) { const [isLoading, setIsLoading] = useState(false); - - if (!isOpen) { - return null; - } - - // Use a portal to render the modal at the document body level to avoid overlay conflicts - return createPortal( - - - , - document.body - ); -} - -function UserProfileDialog({ onOpenChange, onIsLoadingChange }: Readonly) { const [selectedAvatarFile, setSelectedAvatarFile] = useState(null); const [avatarPreviewUrl, setAvatarPreviewUrl] = useState(null); const [avatarMenuOpen, setAvatarMenuOpen] = useState(false); @@ -53,11 +35,16 @@ function UserProfileDialog({ onOpenChange, onIsLoadingChange }: Readonly { - onIsLoadingChange(isLoading); - }, [onIsLoadingChange, isLoading]); + setIsLoading(isLoadingUser); + }, [isLoadingUser]); // Close dialog and cleanup const closeDialog = useCallback(() => { @@ -130,134 +117,143 @@ function UserProfileDialog({ onOpenChange, onIsLoadingChange }: Readonly - - {isLoading && Fetching data...} - {error && JSON.stringify(error)} - -
- ); + if (!isOpen) { + return null; } return ( - - - - User profile - -

- Update your profile picture and personal details here. -

- -
- { - setAvatarMenuOpen(false); - onFileSelect(files); - }} - acceptedFileTypes={ALLOWED_FILE_TYPES} - /> - - + + {!user ? ( + + + {isLoadingUser && Fetching data...} + {error && JSON.stringify(error)} + + + ) : ( + + + Update your profile picture and personal details here.}> + + User profile + + - - - - { - avatarFileInputRef.current?.click(); - }} - > - - Upload profile picture - - {(user.avatarUrl || avatarPreviewUrl) && ( - <> - - { - setAvatarMenuOpen(false); - setRemoveAvatarFlag(true); - setSelectedAvatarFile(null); - setAvatarPreviewUrl(null); - user.avatarUrl = null; - }} + + + + + - + {user.avatarUrl || avatarPreviewUrl ? ( + {t`Preview + ) : ( + + )} + + + { + avatarFileInputRef.current?.click(); + }} + > + + Upload profile picture + + {(user.avatarUrl || avatarPreviewUrl) && ( + <> + + { + setAvatarMenuOpen(false); + setRemoveAvatarFlag(true); + setSelectedAvatarFile(null); + setAvatarPreviewUrl(null); + user.avatarUrl = null; + }} + > + + + Remove profile picture + + + + )} + + -
- - -
- } - /> - +
+ + +
+ } + /> + + -
- - -
- -
+ + + + + +
+ )} +
); } diff --git a/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx b/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx index c0508abfd..74227f2db 100644 --- a/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx +++ b/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx @@ -49,14 +49,22 @@ function MobileMenuHeader({ onEditProfile }: { onEditProfile: () => void }) {
@@ -120,7 +136,7 @@ function MobileMenuHeader({ onEditProfile }: { onEditProfile: () => void }) { - - + + + + )} diff --git a/application/account-management/WebApp/routes/admin/account/index.tsx b/application/account-management/WebApp/routes/admin/account/index.tsx index a5ef85302..344bf4ff6 100644 --- a/application/account-management/WebApp/routes/admin/account/index.tsx +++ b/application/account-management/WebApp/routes/admin/account/index.tsx @@ -58,14 +58,9 @@ export function AccountSettings() { } + title={t`Account settings`} + subtitle={t`Manage your account here.`} > -

- Account settings -

-

- Manage your account here. -

-
- }> -

{userInfo?.firstName ? Welcome home, {userInfo.firstName} : Welcome home}

-

- Here's your overview of what's happening. -

+ } + title={userInfo?.firstName ? t`Welcome home, ${userInfo.firstName}` : t`Welcome home`} + subtitle={t`Here's your overview of what's happening.`} + >
-

- - Select a new role for{" "} - {user ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email : ""} - -

+
+ +

+ + Select a new role for{" "} + {user ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() || user.email : ""} + +

-
- +
+ +
+ -
+ -
+
diff --git a/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx b/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx index 65674d353..068f0c06e 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/InviteUserDialog.tsx @@ -3,6 +3,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { Button } from "@repo/ui/components/Button"; import { Dialog } from "@repo/ui/components/Dialog"; +import { DialogContent, DialogFooter, DialogHeader } from "@repo/ui/components/DialogFooter"; import { Form } from "@repo/ui/components/Form"; import { Heading } from "@repo/ui/components/Heading"; import { Modal } from "@repo/ui/components/Modal"; @@ -34,29 +35,33 @@ export default function InviteUserDialog({ isOpen, onOpenChange }: Readonly - onOpenChange(false)} className="absolute top-2 right-2 h-10 w-10 p-2 hover:bg-muted" /> - - Invite user - -

- An email with login instructions will be sent to the user. -

+ onOpenChange(false)} + className="absolute top-2 right-2 h-10 w-10 cursor-pointer p-2 hover:bg-muted" + /> + An email with login instructions will be sent to the user.}> + + Invite user + + - -
+ + + + -
+
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 323825fed..281988b22 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserProfileSidePane.tsx @@ -133,14 +133,29 @@ function useSidePaneAccessibility( isOpen: boolean, onClose: () => void, sidePaneRef: React.RefObject, - closeButtonRef: React.RefObject + _closeButtonRef: React.RefObject ) { + const previouslyFocusedElement = useRef(null); + useEffect(() => { - const isMobileScreen = !window.matchMedia(MEDIA_QUERIES.sm).matches; - if (isOpen && closeButtonRef.current && isMobileScreen) { - closeButtonRef.current.focus(); + const isSmallScreen = !window.matchMedia(MEDIA_QUERIES.md).matches; + if (isOpen && isSmallScreen) { + // Store the currently focused element before moving focus + previouslyFocusedElement.current = document.activeElement as HTMLElement; } - }, [isOpen, closeButtonRef]); + }, [isOpen]); + + // Prevent body scroll on small screens when side pane is open + useEffect(() => { + const isSmallScreen = !window.matchMedia(MEDIA_QUERIES.md).matches; + if (isOpen && isSmallScreen) { + const originalStyle = window.getComputedStyle(document.body).overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = originalStyle; + }; + } + }, [isOpen]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -160,8 +175,8 @@ function useSidePaneAccessibility( }, [isOpen, onClose]); useEffect(() => { - const isMobileScreen = !window.matchMedia(MEDIA_QUERIES.sm).matches; - if (!isOpen || !sidePaneRef.current || !isMobileScreen) { + const isSmallScreen = !window.matchMedia(MEDIA_QUERIES.md).matches; + if (!isOpen || !sidePaneRef.current || !isSmallScreen) { return; } @@ -206,6 +221,18 @@ export function UserProfileSidePane({ const sidePaneRef = useRef(null); const closeButtonRef = useRef(null); const [isChangeRoleDialogOpen, setIsChangeRoleDialogOpen] = useState(false); + const [isSmallScreen, setIsSmallScreen] = useState(false); + + // Check screen size for backdrop rendering + useEffect(() => { + const checkScreenSize = () => { + setIsSmallScreen(!window.matchMedia(MEDIA_QUERIES.md).matches); + }; + + checkScreenSize(); + window.addEventListener("resize", checkScreenSize); + return () => window.removeEventListener("resize", checkScreenSize); + }, []); useSidePaneAccessibility(isOpen, onClose, sidePaneRef, closeButtonRef); @@ -218,25 +245,22 @@ export function UserProfileSidePane({ return ( <> + {/* Backdrop for small screens */} + {isSmallScreen && {/* Change User Role Dialog */} {user && ( diff --git a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx index e648f8afc..34a5e691f 100644 --- a/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx +++ b/application/account-management/WebApp/routes/admin/users/-components/UserQuerying.tsx @@ -7,6 +7,7 @@ import { Trans } from "@lingui/react/macro"; import { Button } from "@repo/ui/components/Button"; import { DateRangePicker } from "@repo/ui/components/DateRangePicker"; import { Dialog } from "@repo/ui/components/Dialog"; +import { DialogContent, DialogFooter, DialogHeader } from "@repo/ui/components/DialogFooter"; import { Heading } from "@repo/ui/components/Heading"; import { Modal } from "@repo/ui/components/Modal"; import { SearchField } from "@repo/ui/components/SearchField"; @@ -88,10 +89,14 @@ export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQuer // Debounce search updates to avoid too many URL changes while typing useEffect(() => { + // Normalize empty string and undefined to prevent unnecessary updates + const normalizedSearch = search || undefined; + const normalizedParamSearch = searchParams.search || undefined; + // Only update if search value actually changed from URL params - if (search !== searchParams.search) { + if (normalizedSearch !== normalizedParamSearch) { const timeoutId = setTimeout(() => { - updateFilter({ search: (search as string) || undefined }, true); + updateFilter({ search: normalizedSearch }, true); setSearchTimeoutId(null); }, 500); setSearchTimeoutId(timeoutId); @@ -262,13 +267,25 @@ export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQuer useEffect(() => { // On 2XL+ screens, keep full buttons even with filters const is2XlScreen = window.matchMedia("(min-width: 1536px)").matches; - const shouldUseCompactButtons = !is2XlScreen && (showAllFilters || activeFilterCount > 0); + const isMobileScreen = window.matchMedia("(max-width: 639px)").matches; // sm breakpoint + const shouldUseCompactButtons = (!is2XlScreen && (showAllFilters || activeFilterCount > 0)) || isMobileScreen; onFilterStateChange?.(showAllFilters, activeFilterCount > 0, shouldUseCompactButtons); }, [showAllFilters, activeFilterCount, onFilterStateChange]); const clearAllFilters = () => { - updateFilter({ userRole: undefined, userStatus: undefined, startDate: undefined, endDate: undefined }); + // Set search to empty string first to ensure UI updates immediately + setSearch(""); + + // Then update the filter which will set search to undefined in URL + updateFilter({ + search: undefined, + userRole: undefined, + userStatus: undefined, + startDate: undefined, + endDate: undefined + }); + setShowAllFilters(false); setIsFilterPanelOpen(false); }; @@ -287,7 +304,6 @@ export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQuer updateFilter({ search: (search as string) || undefined }, true); }} label={t`Search`} - autoFocus={true} className="min-w-32" /> @@ -426,11 +442,28 @@ export function UserQuerying({ onFilterStateChange, onFiltersUpdated }: UserQuer onClick={() => setIsFilterPanelOpen(false)} className="absolute top-2 right-2 h-10 w-10 p-2 hover:bg-muted" /> - - Filters - + + + Filters + + + + + { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + updateFilter({ search: (search as string) || undefined }, true); + }} + label={t`Search`} + className="w-full" + /> -