diff --git a/application/account-management/Core/Features/Users/Domain/User.cs b/application/account-management/Core/Features/Users/Domain/User.cs index 7f3db3035..327b5f900 100644 --- a/application/account-management/Core/Features/Users/Domain/User.cs +++ b/application/account-management/Core/Features/Users/Domain/User.cs @@ -1,4 +1,5 @@ using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Platform; namespace PlatformPlatform.AccountManagement.Features.Users.Domain; @@ -37,6 +38,8 @@ public string Email public string Locale { get; private set; } + public bool IsInternalUser => Email.EndsWith(Settings.Current.Identity.InternalEmailDomain, StringComparison.OrdinalIgnoreCase); + public TenantId TenantId { get; } public static User Create(TenantId tenantId, string email, UserRole role, bool emailConfirmed, string? locale) diff --git a/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs b/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs index 46a78941f..1945a393e 100644 --- a/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs +++ b/application/account-management/Core/Features/Users/Shared/UserInfoFactory.cs @@ -32,7 +32,8 @@ public async Task CreateUserInfoAsync(User user, CancellationToken can Title = user.Title, AvatarUrl = user.Avatar.Url, TenantName = tenant?.Name, - Locale = user.Locale + Locale = user.Locale, + IsInternalUser = user.IsInternalUser }; } } diff --git a/application/account-management/Tests/Users/Domain/UserTests.cs b/application/account-management/Tests/Users/Domain/UserTests.cs new file mode 100644 index 000000000..3b1016e35 --- /dev/null +++ b/application/account-management/Tests/Users/Domain/UserTests.cs @@ -0,0 +1,63 @@ +using FluentAssertions; +using PlatformPlatform.AccountManagement.Features.Users.Domain; +using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Platform; +using Xunit; + +namespace PlatformPlatform.AccountManagement.Tests.Users.Domain; + +public sealed class UserTests +{ + private readonly TenantId _tenantId = TenantId.NewId(); + + [Fact] + public void IsInternalUser_ShouldReturnTrueForInternalEmails() + { + // Arrange + var internalEmails = new[] + { + $"user{Settings.Current.Identity.InternalEmailDomain}", + $"admin{Settings.Current.Identity.InternalEmailDomain}", + $"test.user{Settings.Current.Identity.InternalEmailDomain}", + $"user+tag{Settings.Current.Identity.InternalEmailDomain}", + $"USER{Settings.Current.Identity.InternalEmailDomain.ToUpperInvariant()}" + }; + + foreach (var email in internalEmails) + { + // Arrange + var user = User.Create(_tenantId, email, UserRole.Member, true, "en-US"); + + // Act + var isInternal = user.IsInternalUser; + + // Assert + isInternal.Should().BeTrue($"Email {email} should be identified as internal"); + } + } + + [Fact] + public void IsInternalUser_ShouldReturnFalseForExternalEmails() + { + // Arrange + var externalEmails = new[] + { + "user@example.com", + "user@company.net", + $"{Settings.Current.Identity.InternalEmailDomain.Substring(1)}@example.com", + $"user@subdomain.{Settings.Current.Identity.InternalEmailDomain.Substring(1)}" + }; + + foreach (var email in externalEmails) + { + // Arrange + var user = User.Create(_tenantId, email, UserRole.Member, true, "en-US"); + + // Act + var isInternal = user.IsInternalUser; + + // Assert + isInternal.Should().BeFalse($"Email {email} should be identified as external"); + } + } +} diff --git a/application/account-management/WebApp/federated-modules/common/LocaleSwitcher.tsx b/application/account-management/WebApp/federated-modules/common/LocaleSwitcher.tsx new file mode 100644 index 000000000..c4591d624 --- /dev/null +++ b/application/account-management/WebApp/federated-modules/common/LocaleSwitcher.tsx @@ -0,0 +1,133 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import type { Key } from "@react-types/shared"; +import { useIsAuthenticated } from "@repo/infrastructure/auth/hooks"; +import { enhancedFetch } from "@repo/infrastructure/http/httpClient"; +import type { Locale } from "@repo/infrastructure/translations/TranslationContext"; +import localeMap from "@repo/infrastructure/translations/i18n.config.json"; +import { Button } from "@repo/ui/components/Button"; +import { Menu, MenuItem, MenuTrigger } from "@repo/ui/components/Menu"; +import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { CheckIcon, GlobeIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +const PREFERRED_LOCALE_KEY = "preferred-locale"; + +const locales = Object.entries(localeMap).map(([id, info]) => ({ + id: id as Locale, + label: info.label +})); + +async function updateLocaleOnBackend(locale: Locale) { + try { + const response = await enhancedFetch("/api/account-management/users/me/change-locale", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ Locale: locale }) + }); + + return response.ok || response.status === 401; + } catch { + return true; // Continue even if API call fails + } +} + +export default function LocaleSwitcher({ + variant = "icon", + onAction +}: { + variant?: "icon" | "mobile-menu"; + onAction?: () => void; +} = {}) { + const [currentLocale, setCurrentLocale] = useState("en-US"); + const isAuthenticated = useIsAuthenticated(); + + useEffect(() => { + // Get current locale from document or localStorage + const htmlLang = document.documentElement.lang as Locale; + const savedLocale = localStorage.getItem(PREFERRED_LOCALE_KEY) as Locale; + + if (savedLocale && locales.some((l) => l.id === savedLocale)) { + setCurrentLocale(savedLocale); + } else if (htmlLang && locales.some((l) => l.id === htmlLang)) { + setCurrentLocale(htmlLang); + } + }, []); + + const handleLocaleChange = async (key: Key) => { + const locale = key.toString() as Locale; + if (locale !== currentLocale) { + // Call onAction if provided (for closing mobile menu) + onAction?.(); + + // Save to localStorage + localStorage.setItem(PREFERRED_LOCALE_KEY, locale); + + // Only update backend if user is authenticated + if (isAuthenticated) { + await updateLocaleOnBackend(locale); + } + + // Reload page to apply new locale + window.location.reload(); + } + }; + + const currentLocaleLabel = locales.find((l) => l.id === currentLocale)?.label || currentLocale; + + if (variant === "mobile-menu") { + return ( + + + + {locales.map((locale) => ( + +
+ {locale.label} + {locale.id === currentLocale && } +
+
+ ))} +
+
+ ); + } + + // Icon variant + const menuContent = ( + + + + {locales.map((locale) => ( + +
+ {locale.label} + {locale.id === currentLocale && } +
+
+ ))} +
+
+ ); + + return ( + + {menuContent} + {t`Change language`} + + ); +} diff --git a/application/account-management/WebApp/federated-modules/common/SupportButton.tsx b/application/account-management/WebApp/federated-modules/common/SupportButton.tsx new file mode 100644 index 000000000..ac80064b8 --- /dev/null +++ b/application/account-management/WebApp/federated-modules/common/SupportButton.tsx @@ -0,0 +1,19 @@ +import { t } from "@lingui/core/macro"; +import { Button } from "@repo/ui/components/Button"; +import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { MailQuestion } from "lucide-react"; +import { SupportDialog } from "./SupportDialog"; +import "@repo/ui/tailwind.css"; + +export default function SupportButton() { + return ( + + + + {t`Contact support`} + + + ); +} diff --git a/application/account-management/WebApp/federated-modules/common/SupportDialog.tsx b/application/account-management/WebApp/federated-modules/common/SupportDialog.tsx new file mode 100644 index 000000000..887900815 --- /dev/null +++ b/application/account-management/WebApp/federated-modules/common/SupportDialog.tsx @@ -0,0 +1,44 @@ +import { t } from "@lingui/core/macro"; +import { Button } from "@repo/ui/components/Button"; +import { Dialog, DialogTrigger } from "@repo/ui/components/Dialog"; +import { Heading } from "@repo/ui/components/Heading"; +import { Modal } from "@repo/ui/components/Modal"; +import { MailIcon, XIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +interface SupportDialogProps { + children: ReactNode; +} + +export function SupportDialog({ children }: Readonly) { + return ( + + {children} + + + {({ close }) => ( + <> + + + {t`Contact support`} + +

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

+
+ +

{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 new file mode 100644 index 000000000..e4879032e --- /dev/null +++ b/application/account-management/WebApp/federated-modules/common/ThemeModeSelector.tsx @@ -0,0 +1,200 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import type { Key } from "@react-types/shared"; +import { Button } from "@repo/ui/components/Button"; +import { Menu, MenuItem, MenuTrigger } from "@repo/ui/components/Menu"; +import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { CheckIcon, MoonIcon, MoonStarIcon, SunIcon, SunMoonIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +const THEME_MODE_KEY = "preferred-theme"; + +enum ThemeMode { + System = "system", + Light = "light", + Dark = "dark" +} + +export default function ThemeModeSelector({ + variant = "icon", + onAction +}: { + variant?: "icon" | "mobile-menu"; + onAction?: () => void; +} = {}) { + const [themeMode, setThemeModeState] = useState(ThemeMode.System); + + useEffect(() => { + // Read initial theme mode from localStorage + const savedMode = localStorage.getItem(THEME_MODE_KEY) as ThemeMode; + const initialMode = savedMode && Object.values(ThemeMode).includes(savedMode) ? savedMode : ThemeMode.System; + setThemeModeState(initialMode); + + // Apply initial theme + const root = document.documentElement; + root.classList.remove("light", "dark"); + + if (initialMode === ThemeMode.Dark) { + root.classList.add("dark"); + root.style.colorScheme = "dark"; + } else if (initialMode === ThemeMode.Light) { + root.classList.add("light"); + root.style.colorScheme = "light"; + } else { + // System mode - check system preference + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (prefersDark) { + root.classList.add("dark"); + root.style.colorScheme = "dark"; + } else { + root.classList.add("light"); + root.style.colorScheme = "light"; + } + } + + // Listen for storage changes from other tabs/components + const handleStorageChange = (e: StorageEvent) => { + if (e.key === THEME_MODE_KEY && e.newValue) { + const newMode = e.newValue as ThemeMode; + if (Object.values(ThemeMode).includes(newMode)) { + setThemeModeState(newMode); + } + } + }; + + // Listen for theme changes from the same tab (e.g., mobile menu) + const handleThemeChange = (e: Event) => { + const customEvent = e as CustomEvent; + const newMode = customEvent.detail as ThemeMode; + if (Object.values(ThemeMode).includes(newMode)) { + setThemeModeState(newMode); + } + }; + + window.addEventListener("storage", handleStorageChange); + window.addEventListener("theme-mode-changed", handleThemeChange); + + return () => { + window.removeEventListener("storage", handleStorageChange); + window.removeEventListener("theme-mode-changed", handleThemeChange); + }; + }, []); + + const handleThemeChange = (key: Key) => { + const newMode = key as ThemeMode; + setThemeModeState(newMode); + localStorage.setItem(THEME_MODE_KEY, newMode); + + // Apply theme to DOM - matching the original implementation + const root = document.documentElement; + + // Remove both light and dark classes first + root.classList.remove("light", "dark"); + + if (newMode === ThemeMode.Dark) { + root.classList.add("dark"); + root.style.colorScheme = "dark"; + } else if (newMode === ThemeMode.Light) { + root.classList.add("light"); + root.style.colorScheme = "light"; + } else { + // System mode - check system preference + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + if (prefersDark) { + root.classList.add("dark"); + root.style.colorScheme = "dark"; + } else { + root.classList.add("light"); + root.style.colorScheme = "light"; + } + } + + // Dispatch event to notify other components + window.dispatchEvent(new CustomEvent("theme-mode-changed", { detail: newMode })); + + // Call onAction callback if provided (for mobile menu) + onAction?.(); + }; + + const getThemeIcon = () => { + switch (themeMode) { + case ThemeMode.Dark: + return ; + case ThemeMode.Light: + return ; + default: { + // For system mode, show icon based on actual system preference + const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + return prefersDark ? : ; + } + } + }; + + const menuContent = ( + + {variant === "icon" ? ( + + ) : ( + + )} + + +
+ {window.matchMedia("(prefers-color-scheme: dark)").matches ? ( + + ) : ( + + )} + System + {themeMode === ThemeMode.System && } +
+
+ +
+ + Light + {themeMode === ThemeMode.Light && } +
+
+ +
+ + Dark + {themeMode === ThemeMode.Dark && } +
+
+
+
+ ); + + if (variant === "icon") { + return ( + + {menuContent} + {t`Change theme`} + + ); + } + + return menuContent; +} diff --git a/application/account-management/WebApp/shared/components/userModals/UserProfileModal.tsx b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx similarity index 97% rename from application/account-management/WebApp/shared/components/userModals/UserProfileModal.tsx rename to application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx index 775612573..c5bc34da9 100644 --- a/application/account-management/WebApp/shared/components/userModals/UserProfileModal.tsx +++ b/application/account-management/WebApp/federated-modules/common/UserProfileModal.tsx @@ -13,6 +13,7 @@ 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"; const MAX_FILE_SIZE = 1024 * 1024; // 1MB in bytes const ALLOWED_FILE_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; // Align with backend @@ -33,10 +34,12 @@ export default function UserProfileModal({ isOpen, onOpenChange }: Readonly + // Use a portal to render the modal at the document body level to avoid overlay conflicts + return createPortal( + - + , + document.body ); } diff --git a/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx b/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx new file mode 100644 index 000000000..199ec0a8b --- /dev/null +++ b/application/account-management/WebApp/federated-modules/sideMenu/FederatedSideMenu.tsx @@ -0,0 +1,31 @@ +import { t } from "@lingui/core/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { SideMenu } from "@repo/ui/components/SideMenu"; +import { useState } from "react"; +import UserProfileModal from "../common/UserProfileModal"; +import { MobileMenu } from "./MobileMenu"; +import { NavigationMenuItems } from "./NavigationMenuItems"; +import "@repo/ui/tailwind.css"; + +export type FederatedSideMenuProps = { + currentSystem: "account-management" | "back-office"; // Add your self-contained system here +}; + +export default function FederatedSideMenu({ currentSystem }: Readonly) { + const userInfo = useUserInfo(); + const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); + + return ( + <> + setIsProfileModalOpen(true)} />} + tenantName={userInfo?.tenantName} + > + + + + + ); +} diff --git a/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx b/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx new file mode 100644 index 000000000..c0508abfd --- /dev/null +++ b/application/account-management/WebApp/federated-modules/sideMenu/MobileMenu.tsx @@ -0,0 +1,163 @@ +import { api } from "@/shared/lib/api/client"; +import { Trans } from "@lingui/react/macro"; +import { loginPath } from "@repo/infrastructure/auth/constants"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { createLoginUrlWithReturnPath } from "@repo/infrastructure/auth/util"; +import { Avatar } from "@repo/ui/components/Avatar"; +import { Button } from "@repo/ui/components/Button"; +import { SideMenuSeparator, overlayContext } from "@repo/ui/components/SideMenu"; +import { useQueryClient } from "@tanstack/react-query"; +import { LogOutIcon, MailQuestion, UserIcon } from "lucide-react"; +import { useContext } from "react"; +import LocaleSwitcher from "../common/LocaleSwitcher"; +import { SupportDialog } from "../common/SupportDialog"; +import ThemeModeSelector from "../common/ThemeModeSelector"; +import type { FederatedSideMenuProps } from "./FederatedSideMenu"; +import { NavigationMenuItems } from "./NavigationMenuItems"; + +// Mobile menu header section with user profile and settings +function MobileMenuHeader({ onEditProfile }: { onEditProfile: () => void }) { + const userInfo = useUserInfo(); + const queryClient = useQueryClient(); + const overlayCtx = useContext(overlayContext); + + const logoutMutation = api.useMutation("post", "/api/account-management/authentication/logout", { + onMutate: async () => { + await queryClient.cancelQueries(); + queryClient.clear(); + }, + onSuccess: () => { + window.location.href = createLoginUrlWithReturnPath(loginPath); + }, + meta: { + skipQueryInvalidation: true + } + }); + + return ( +
+ {/* User Profile Section */} +
+ {/* User Profile */} + {userInfo && ( +
+ +
+
{userInfo.fullName}
+
{userInfo.title ?? userInfo.email}
+
+
+ +
+
+ )} + + {/* Logout */} +
+ +
+ + {/* Theme Section - using ThemeModeSelector with mobile menu variant */} +
+ { + // Close mobile menu if it's open + if (overlayCtx?.isOpen) { + overlayCtx.close(); + } + }} + /> +
+ + {/* Language Section - using LocaleSwitcher with mobile menu variant */} +
+ { + // Close mobile menu if it's open + if (overlayCtx?.isOpen) { + overlayCtx.close(); + } + }} + /> +
+ + {/* Support Section - styled like menu item */} +
+ + + +
+
+
+ ); +} + +// Complete mobile menu including header and navigation +export function MobileMenu({ + currentSystem, + onEditProfile +}: Readonly<{ currentSystem: FederatedSideMenuProps["currentSystem"]; onEditProfile: () => void }>) { + return ( +
+ + + {/* Divider */} +
+ + {/* Navigation Section for Mobile */} +
+ + Navigation + + +
+ + {/* Spacer to push content up */} +
+
+ ); +} diff --git a/application/account-management/WebApp/federated-modules/sideMenu/NavigationMenuItems.tsx b/application/account-management/WebApp/federated-modules/sideMenu/NavigationMenuItems.tsx new file mode 100644 index 000000000..7515f2448 --- /dev/null +++ b/application/account-management/WebApp/federated-modules/sideMenu/NavigationMenuItems.tsx @@ -0,0 +1,56 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { FederatedMenuButton, SideMenuSeparator } from "@repo/ui/components/SideMenu"; +import { BoxIcon, CircleUserIcon, HomeIcon, UsersIcon } from "lucide-react"; +import type { FederatedSideMenuProps } from "./FederatedSideMenu"; + +// Navigation items shared between mobile and desktop menus +export function NavigationMenuItems({ + currentSystem +}: Readonly<{ currentSystem: FederatedSideMenuProps["currentSystem"] }>) { + const userInfo = useUserInfo(); + + return ( + <> + + + + Organization + + + + + + {userInfo?.isInternalUser && ( + <> + + Back Office + + + + + )} + + ); +} diff --git a/application/account-management/WebApp/shared/components/AvatarButton.tsx b/application/account-management/WebApp/federated-modules/topMenu/AvatarButton.tsx similarity index 85% rename from application/account-management/WebApp/shared/components/AvatarButton.tsx rename to application/account-management/WebApp/federated-modules/topMenu/AvatarButton.tsx index c519670f6..ffb98e369 100644 --- a/application/account-management/WebApp/shared/components/AvatarButton.tsx +++ b/application/account-management/WebApp/federated-modules/topMenu/AvatarButton.tsx @@ -1,5 +1,5 @@ -import UserProfileModal from "@/shared/components/userModals/UserProfileModal"; import { api } from "@/shared/lib/api/client"; +import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { loginPath } from "@repo/infrastructure/auth/constants"; import { useUserInfo } from "@repo/infrastructure/auth/hooks"; @@ -10,8 +10,10 @@ import { Menu, MenuHeader, MenuItem, MenuSeparator, MenuTrigger } from "@repo/ui import { useQueryClient } from "@tanstack/react-query"; import { LogOutIcon, UserIcon } from "lucide-react"; import { useEffect, useState } from "react"; +import UserProfileModal from "../common/UserProfileModal"; +import "@repo/ui/tailwind.css"; -export default function AvatarButton({ "aria-label": ariaLabel }: Readonly<{ "aria-label": string }>) { +export default function AvatarButton() { const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); const [hasAutoOpenedModal, setHasAutoOpenedModal] = useState(false); const userInfo = useUserInfo(); @@ -30,11 +32,6 @@ export default function AvatarButton({ "aria-label": ariaLabel }: Readonly<{ "ar } }, [userInfo, hasAutoOpenedModal, isProfileModalOpen]); - const handleProfileModalClose = (isOpen: boolean) => { - setIsProfileModalOpen(isOpen); - // No need to check userInfo state here - once modal is closed by user, don't auto-open again - }; - const logoutMutation = api.useMutation("post", "/api/account-management/authentication/logout", { onMutate: async () => { // Cancel all ongoing queries and remove them from cache to prevent 401 errors @@ -57,7 +54,7 @@ export default function AvatarButton({ "aria-label": ariaLabel }: Readonly<{ "ar return ( <> - @@ -82,7 +79,7 @@ export default function AvatarButton({ "aria-label": ariaLabel }: Readonly<{ "ar - + ); } diff --git a/application/account-management/WebApp/federated-modules/topMenu/FederatedTopMenu.tsx b/application/account-management/WebApp/federated-modules/topMenu/FederatedTopMenu.tsx new file mode 100644 index 000000000..504abbf4f --- /dev/null +++ b/application/account-management/WebApp/federated-modules/topMenu/FederatedTopMenu.tsx @@ -0,0 +1,29 @@ +import type { ReactNode } from "react"; +import LocaleSwitcher from "../common/LocaleSwitcher"; +import SupportButton from "../common/SupportButton"; +import ThemeModeSelector from "../common/ThemeModeSelector"; +import AvatarButton from "./AvatarButton"; +import "@repo/ui/tailwind.css"; + +interface FederatedTopMenuProps { + children?: ReactNode; +} + +export default function FederatedTopMenu({ children }: Readonly) { + return ( + + ); +} + +// Re-export AvatarButton for backward compatibility +export { default as AvatarButton } from "./AvatarButton"; diff --git a/application/account-management/WebApp/routes/(index)/-components/FeatureSection3.tsx b/application/account-management/WebApp/routes/(index)/-components/FeatureSection3.tsx index 5e8b08678..96633f37f 100644 --- a/application/account-management/WebApp/routes/(index)/-components/FeatureSection3.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/FeatureSection3.tsx @@ -1,5 +1,4 @@ -import { t } from "@lingui/core/macro"; -import { ThemeModeSelector } from "@repo/ui/theme/ThemeModeSelector"; +import ThemeModeSelector from "@/federated-modules/common/ThemeModeSelector"; import { createAccountUrl } from "./cdnImages"; // FeatureSection3: A functional component that displays the third feature section @@ -68,7 +67,7 @@ function FeatureList() {

Just click the - + button to switch to the Dark Side.
diff --git a/application/account-management/WebApp/routes/(index)/-components/HeroSection.tsx b/application/account-management/WebApp/routes/(index)/-components/HeroSection.tsx index 9f0a77a74..91715b2eb 100644 --- a/application/account-management/WebApp/routes/(index)/-components/HeroSection.tsx +++ b/application/account-management/WebApp/routes/(index)/-components/HeroSection.tsx @@ -1,5 +1,5 @@ +import ThemeModeSelector from "@/federated-modules/common/ThemeModeSelector"; import logoMark from "@/shared/images/logo-mark.svg"; -import { t } from "@lingui/core/macro"; import { LoginButton } from "@repo/infrastructure/auth/LoginButton"; import { SignUpButton } from "@repo/infrastructure/auth/SignUpButton"; import { Badge } from "@repo/ui/components/Badge"; @@ -7,7 +7,6 @@ import { Button } from "@repo/ui/components/Button"; import { Dialog } from "@repo/ui/components/Dialog"; import { Link } from "@repo/ui/components/Link"; import { Popover } from "@repo/ui/components/Popover"; -import { ThemeModeSelector } from "@repo/ui/theme/ThemeModeSelector"; import { ArrowRightIcon, ChevronDownIcon, GithubIcon } from "lucide-react"; import type React from "react"; import { DialogTrigger } from "react-aria-components"; @@ -32,7 +31,7 @@ export function HeroSection() { Github - + Log in Get started today
diff --git a/application/account-management/WebApp/routes/admin/account/-components/DeleteAccountConfirmation.tsx b/application/account-management/WebApp/routes/admin/account/-components/DeleteAccountConfirmation.tsx index 4e20054c1..0b98fde39 100644 --- a/application/account-management/WebApp/routes/admin/account/-components/DeleteAccountConfirmation.tsx +++ b/application/account-management/WebApp/routes/admin/account/-components/DeleteAccountConfirmation.tsx @@ -1,7 +1,9 @@ import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { AlertDialog } from "@repo/ui/components/AlertDialog"; +import { Button } from "@repo/ui/components/Button"; +import { Dialog } from "@repo/ui/components/Dialog"; +import { Heading } from "@repo/ui/components/Heading"; import { Modal } from "@repo/ui/components/Modal"; +import { MailIcon, XIcon } from "lucide-react"; type DeleteAccountConfirmationProps = { isOpen: boolean; @@ -10,20 +12,30 @@ type DeleteAccountConfirmationProps = { export default function DeleteAccountConfirmation({ isOpen, onOpenChange }: Readonly) { return ( - - onOpenChange(false)} - > - - You are about to permanently delete the account and the entire data environment via PlatformPlatform. -
-
- This action is permanent and irreversible. -
-
+ + + {({ close }) => ( + <> + + + {t`Delete account`} + +

{t`To delete your account, please contact our support team.`}

+
+ +

{t`Our support team will assist you with the account deletion process and ensure all your data is properly removed.`}

+
+ +
+
+ + )} +
); } diff --git a/application/account-management/WebApp/routes/admin/account/index.tsx b/application/account-management/WebApp/routes/admin/account/index.tsx index 23aa21891..a5ef85302 100644 --- a/application/account-management/WebApp/routes/admin/account/index.tsx +++ b/application/account-management/WebApp/routes/admin/account/index.tsx @@ -1,4 +1,4 @@ -import { SharedSideMenu } from "@/shared/components/SharedSideMenu"; +import FederatedSideMenu from "@/federated-modules/sideMenu/FederatedSideMenu"; import { TopMenu } from "@/shared/components/topMenu"; import logoWrap from "@/shared/images/logo-wrap.svg"; import { UserRole, api } from "@/shared/lib/api/client"; @@ -45,7 +45,7 @@ export function AccountSettings() { return ( <> - + - + }>

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

diff --git a/application/account-management/WebApp/routes/admin/users/index.tsx b/application/account-management/WebApp/routes/admin/users/index.tsx index ea393fd02..eb0ff777e 100644 --- a/application/account-management/WebApp/routes/admin/users/index.tsx +++ b/application/account-management/WebApp/routes/admin/users/index.tsx @@ -1,7 +1,6 @@ -import { SharedSideMenu } from "@/shared/components/SharedSideMenu"; +import FederatedSideMenu from "@/federated-modules/sideMenu/FederatedSideMenu"; import { TopMenu } from "@/shared/components/topMenu"; 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"; @@ -105,7 +104,7 @@ export default function UsersPage() { return ( <> - + - {t`Screenshots - {t`Screenshots - + {t`Screenshots ); } diff --git a/application/account-management/WebApp/shared/components/SharedSideMenu.tsx b/application/account-management/WebApp/shared/components/SharedSideMenu.tsx deleted file mode 100644 index cbcf15d28..000000000 --- a/application/account-management/WebApp/shared/components/SharedSideMenu.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { api } from "@/shared/lib/api/client"; -import { t } from "@lingui/core/macro"; -import { useLingui } from "@lingui/react"; -import { Trans } from "@lingui/react/macro"; -import { loginPath } from "@repo/infrastructure/auth/constants"; -import { useUserInfo } from "@repo/infrastructure/auth/hooks"; -import { createLoginUrlWithReturnPath } from "@repo/infrastructure/auth/util"; -import { type Locale, translationContext } from "@repo/infrastructure/translations/TranslationContext"; -import { Avatar } from "@repo/ui/components/Avatar"; -import { Button } from "@repo/ui/components/Button"; -import { Menu, MenuItem, MenuTrigger } from "@repo/ui/components/Menu"; -import { MenuButton, SideMenu, SideMenuSeparator, overlayContext } from "@repo/ui/components/SideMenu"; -import { useThemeMode } from "@repo/ui/theme/mode/ThemeMode"; -import { SystemThemeMode, ThemeMode } from "@repo/ui/theme/mode/utils"; -import { useQueryClient } from "@tanstack/react-query"; -import { - CheckIcon, - CircleUserIcon, - GlobeIcon, - HomeIcon, - LogOutIcon, - MoonIcon, - MoonStarIcon, - SunIcon, - SunMoonIcon, - UserIcon, - UsersIcon -} from "lucide-react"; -import type React from "react"; -import { use, useContext, useState } from "react"; -import UserProfileModal from "./userModals/UserProfileModal"; - -type SharedSideMenuProps = { - children?: React.ReactNode; - ariaLabel: string; -}; - -export function SharedSideMenu({ children, ariaLabel }: Readonly) { - const userInfo = useUserInfo(); - const { i18n } = useLingui(); - const { getLocaleInfo, locales, setLocale } = use(translationContext); - const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); - const queryClient = useQueryClient(); - const { themeMode, resolvedThemeMode, setThemeMode } = useThemeMode(); - - // Access mobile menu overlay context to close menu when needed - const overlayCtx = useContext(overlayContext); - - const currentLocale = i18n.locale as Locale; - const currentLocaleLabel = getLocaleInfo(currentLocale).label; - - const getThemeName = (mode: ThemeMode) => { - switch (mode) { - case ThemeMode.System: - return t`System`; - case ThemeMode.Light: - return t`Light`; - case ThemeMode.Dark: - return t`Dark`; - default: - return t`System`; - } - }; - - const getThemeIcon = (themeMode: ThemeMode, resolvedThemeMode: SystemThemeMode) => { - if (resolvedThemeMode === SystemThemeMode.Dark) { - return themeMode === ThemeMode.System ? ( - - ) : ( - - ); - } - return themeMode === ThemeMode.System ? ( - - ) : ( - - ); - }; - - const logoutMutation = api.useMutation("post", "/api/account-management/authentication/logout", { - onMutate: async () => { - await queryClient.cancelQueries(); - queryClient.clear(); - }, - onSuccess: () => { - window.location.href = createLoginUrlWithReturnPath(loginPath); - }, - meta: { - skipQueryInvalidation: true - } - }); - - const topMenuContent = ( -

- {/* User Profile Section */} -
- {/* User Profile */} - {userInfo && ( -
- -
-
{userInfo.fullName}
-
{userInfo.title || userInfo.email}
-
-
- -
-
- )} - - {/* Logout */} -
- -
- - {/* Theme Section - button that cycles themes */} -
- -
- - {/* Language Section - styled like menu item */} -
- - - { - const locale = key.toString() as Locale; - if (locale !== currentLocale) { - setLocale(locale); - } - }} - placement="bottom end" - > - {locales.map((locale) => ( - -
- {getLocaleInfo(locale).label} - {locale === currentLocale && } -
-
- ))} -
-
-
-
- - {/* Divider */} -
- - {/* Navigation Section for Mobile */} -
- - Navigation - - - - Organization - - - - {children} -
- - {/* Spacer to push content up */} -
-
- ); - - return ( - <> - - - - Organization - - - - {children} - - - - - ); -} diff --git a/application/account-management/WebApp/shared/components/topMenu/index.tsx b/application/account-management/WebApp/shared/components/topMenu/index.tsx index c3e90feaa..de1f6cb56 100644 --- a/application/account-management/WebApp/shared/components/topMenu/index.tsx +++ b/application/account-management/WebApp/shared/components/topMenu/index.tsx @@ -1,48 +1,21 @@ -import { t } from "@lingui/core/macro"; +import FederatedTopMenu from "@/federated-modules/topMenu/FederatedTopMenu"; import { Trans } from "@lingui/react/macro"; -import { LocaleSwitcher } from "@repo/infrastructure/translations/LocaleSwitcher"; import { Breadcrumb, Breadcrumbs } from "@repo/ui/components/Breadcrumbs"; -import { Button } from "@repo/ui/components/Button"; -import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; -import { ThemeModeSelector } from "@repo/ui/theme/ThemeModeSelector"; -import { LifeBuoyIcon } from "lucide-react"; import type { ReactNode } from "react"; -import AvatarButton from "../AvatarButton"; interface TopMenuProps { children?: ReactNode; - sidePaneOpen?: boolean; } -export function TopMenu({ children, sidePaneOpen = false }: Readonly) { +export function TopMenu({ children }: Readonly) { return ( - + ); } diff --git a/application/account-management/WebApp/shared/layouts/HorizontalHeroLayout.tsx b/application/account-management/WebApp/shared/layouts/HorizontalHeroLayout.tsx index d3797f4aa..7ea27d471 100644 --- a/application/account-management/WebApp/shared/layouts/HorizontalHeroLayout.tsx +++ b/application/account-management/WebApp/shared/layouts/HorizontalHeroLayout.tsx @@ -1,9 +1,8 @@ +import LocaleSwitcher from "@/federated-modules/common/LocaleSwitcher"; +import SupportButton from "@/federated-modules/common/SupportButton"; +import ThemeModeSelector from "@/federated-modules/common/ThemeModeSelector"; import { HeroImage } from "@/shared/components/HeroImage"; import { t } from "@lingui/core/macro"; -import { LocaleSwitcher } from "@repo/infrastructure/translations/LocaleSwitcher"; -import { Button } from "@repo/ui/components/Button"; -import { ThemeModeSelector } from "@repo/ui/theme/ThemeModeSelector"; -import { LifeBuoyIcon } from "lucide-react"; import type { ReactNode } from "react"; interface HorizontalHeroLayoutProps { @@ -14,15 +13,21 @@ export function HorizontalHeroLayout({ children }: Readonly
- - - + + +
-
{children}
-
+
+ {children} + {/* Mobile-only icon controls at bottom of form */} +
+ + + +
+
+
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 7ece3029d..343d4c44b 100644 --- a/application/account-management/WebApp/shared/translations/locale/da-DK.po +++ b/application/account-management/WebApp/shared/translations/locale/da-DK.po @@ -75,6 +75,9 @@ msgstr "Er du sikker på, at du vil slette <0>{0} brugere?" msgid "Are you sure you want to delete <0>{userDisplayName}?" msgstr "Er du sikker på, at du vil slette <0>{userDisplayName}?" +msgid "Back Office" +msgstr "Back Office" + msgid "Back to login" msgstr "Tilbage til login" @@ -99,6 +102,9 @@ msgstr "Skift profilbillede" msgid "Change role" msgstr "Skift rolle" +msgid "Change theme" +msgstr "Skift tema" + msgid "Change user role" msgstr "Skift brugerrolle" @@ -115,9 +121,15 @@ msgstr "Ryd" msgid "Clear filters" msgstr "Ryd filtre" +msgid "Close" +msgstr "Luk" + msgid "Close user profile" msgstr "Luk brugerprofil" +msgid "Contact support" +msgstr "Kontakt support" + msgid "Continue" msgstr "Fortsæt" @@ -133,6 +145,9 @@ msgstr "Farezone" msgid "Dark" msgstr "Mørk" +msgid "Dashboard" +msgstr "Dashboard" + msgid "Delete" msgstr "Slet" @@ -188,6 +203,9 @@ msgstr "Fejl: Noget gik galt!" msgid "Europe" msgstr "Europa" +msgid "Feel free to reach out with any questions or issues you may have." +msgstr "Du er velkommen til at kontakte os med eventuelle spørgsmål eller problemer, du måtte have." + msgid "Fetching data..." msgstr "Henter data..." @@ -197,9 +215,6 @@ msgstr "Filtre" msgid "First name" msgstr "Fornavn" -msgid "Help" -msgstr "Hjælp" - msgid "Here's your overview of what's happening." msgstr "Her er din oversigt over, hvad der sker." @@ -260,6 +275,9 @@ msgstr "Navn" msgid "Navigation" msgstr "Navigation" +msgid "Need help? Our support team is here to assist you." +msgstr "Har du brug for hjælp? Vores supportteam er klart til at hjælpe." + msgid "Next" msgstr "Næste" @@ -269,9 +287,15 @@ msgstr "OK" msgid "Only account owners can modify the account name" msgstr "Kun kontoejere kan ændre kontonavnet" +msgid "Open navigation menu" +msgstr "Åbn navigationsmenu" + msgid "Organization" msgstr "Organisation" +msgid "Our support team will assist you with the account deletion process and ensure all your data is properly removed." +msgstr "Vores supportteam hjælper dig med at slette din konto og sikrer, at alle dine data bliver fjernet." + msgid "Owner" msgstr "Ejer" @@ -326,9 +350,6 @@ msgstr "Gemmer..." msgid "Screenshots of the dashboard project with desktop and mobile versions" msgstr "Skærmbilleder af dashboard-projektet med desktop- og mobilversioner" -msgid "Screenshots of the dashboard project with mobile versions" -msgstr "Skærmbilleder af dashboard-projektet med mobilversioner" - msgid "Search" msgstr "Søg" @@ -339,9 +360,6 @@ msgstr "Vælg en ny rolle for <0>{0}" msgid "Select dates" msgstr "Vælg datoer" -msgid "Select language" -msgstr "Vælg sprog" - msgid "Send invite" msgstr "Send invitation" @@ -363,9 +381,6 @@ msgstr "Tilmeldingsbekræftelseskode" msgid "Success" msgstr "Succes" -msgid "Support" -msgstr "Support" - msgid "System" msgstr "System" @@ -381,11 +396,11 @@ msgstr "Dette er den region, hvor dine data er lagret" msgid "Title" msgstr "Titel" -msgid "Toggle collapsed menu" -msgstr "Skift kollapset menu" +msgid "To delete your account, please contact our support team." +msgstr "For at slette din konto bedes du kontakte vores supportteam." -msgid "Toggle theme" -msgstr "Skift tema" +msgid "Toggle sidebar" +msgstr "Skift sidepanel" msgid "Total users" msgstr "Totalt antal brugere" @@ -469,9 +484,6 @@ msgstr "Velkommen hjem" msgid "Welcome home, {0}" msgstr "Velkommen hjem, {0}" -msgid "You are about to permanently delete the account and the entire data environment via PlatformPlatform.<0/><1/>This action is permanent and irreversible." -msgstr "Du er ved at slette kontoen og hele dataomgivelserne permanent via PlatformPlatform.<0/><1/>Denne handling er permanent og kan ikke fortrydes." - msgid "Your verification code has expired" msgstr "Din bekræftelseskode er udløbet" 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 c129df86d..5cd3ed62b 100644 --- a/application/account-management/WebApp/shared/translations/locale/en-US.po +++ b/application/account-management/WebApp/shared/translations/locale/en-US.po @@ -75,6 +75,9 @@ msgstr "Are you sure you want to delete <0>{0} users?" msgid "Are you sure you want to delete <0>{userDisplayName}?" msgstr "Are you sure you want to delete <0>{userDisplayName}?" +msgid "Back Office" +msgstr "Back Office" + msgid "Back to login" msgstr "Back to login" @@ -99,6 +102,9 @@ msgstr "Change profile picture" msgid "Change role" msgstr "Change role" +msgid "Change theme" +msgstr "Change theme" + msgid "Change user role" msgstr "Change user role" @@ -115,9 +121,15 @@ msgstr "Clear" msgid "Clear filters" msgstr "Clear filters" +msgid "Close" +msgstr "Close" + msgid "Close user profile" msgstr "Close user profile" +msgid "Contact support" +msgstr "Contact support" + msgid "Continue" msgstr "Continue" @@ -133,6 +145,9 @@ msgstr "Danger zone" msgid "Dark" msgstr "Dark" +msgid "Dashboard" +msgstr "Dashboard" + msgid "Delete" msgstr "Delete" @@ -188,6 +203,9 @@ msgstr "Error: Something went wrong!" msgid "Europe" msgstr "Europe" +msgid "Feel free to reach out with any questions or issues you may have." +msgstr "Feel free to reach out with any questions or issues you may have." + msgid "Fetching data..." msgstr "Fetching data..." @@ -197,9 +215,6 @@ msgstr "Filters" msgid "First name" msgstr "First name" -msgid "Help" -msgstr "Help" - msgid "Here's your overview of what's happening." msgstr "Here's your overview of what's happening." @@ -260,6 +275,9 @@ msgstr "Name" msgid "Navigation" msgstr "Navigation" +msgid "Need help? Our support team is here to assist you." +msgstr "Need help? Our support team is here to assist you." + msgid "Next" msgstr "Next" @@ -269,9 +287,15 @@ msgstr "OK" msgid "Only account owners can modify the account name" msgstr "Only account owners can modify the account name" +msgid "Open navigation menu" +msgstr "Open navigation menu" + msgid "Organization" msgstr "Organization" +msgid "Our support team will assist you with the account deletion process and ensure all your data is properly removed." +msgstr "Our support team will assist you with the account deletion process and ensure all your data is properly removed." + msgid "Owner" msgstr "Owner" @@ -326,9 +350,6 @@ msgstr "Saving..." msgid "Screenshots of the dashboard project with desktop and mobile versions" msgstr "Screenshots of the dashboard project with desktop and mobile versions" -msgid "Screenshots of the dashboard project with mobile versions" -msgstr "Screenshots of the dashboard project with mobile versions" - msgid "Search" msgstr "Search" @@ -339,9 +360,6 @@ msgstr "Select a new role for <0>{0}" msgid "Select dates" msgstr "Select dates" -msgid "Select language" -msgstr "Select language" - msgid "Send invite" msgstr "Send invite" @@ -363,9 +381,6 @@ msgstr "Signup verification code" msgid "Success" msgstr "Success" -msgid "Support" -msgstr "Support" - msgid "System" msgstr "System" @@ -381,11 +396,11 @@ msgstr "This is the region where your data is stored" msgid "Title" msgstr "Title" -msgid "Toggle collapsed menu" -msgstr "Toggle collapsed menu" +msgid "To delete your account, please contact our support team." +msgstr "To delete your account, please contact our support team." -msgid "Toggle theme" -msgstr "Toggle theme" +msgid "Toggle sidebar" +msgstr "Toggle sidebar" msgid "Total users" msgstr "Total users" @@ -469,9 +484,6 @@ msgstr "Welcome home" msgid "Welcome home, {0}" msgstr "Welcome home, {0}" -msgid "You are about to permanently delete the account and the entire data environment via PlatformPlatform.<0/><1/>This action is permanent and irreversible." -msgstr "You are about to permanently delete the account and the entire data environment via PlatformPlatform.<0/><1/>This action is permanent and irreversible." - msgid "Your verification code has expired" msgstr "Your verification code has expired" 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 c88a20732..7bc27d81d 100644 --- a/application/account-management/WebApp/shared/translations/locale/nl-NL.po +++ b/application/account-management/WebApp/shared/translations/locale/nl-NL.po @@ -75,6 +75,9 @@ msgstr "Weet je zeker dat je <0>{0} gebruikers wilt verwijderen?" msgid "Are you sure you want to delete <0>{userDisplayName}?" msgstr "Weet je zeker dat je <0>{userDisplayName} wilt verwijderen?" +msgid "Back Office" +msgstr "Backoffice" + msgid "Back to login" msgstr "Terug naar inloggen" @@ -99,6 +102,9 @@ msgstr "Profielfoto wijzigen" msgid "Change role" msgstr "Rol wijzigen" +msgid "Change theme" +msgstr "Thema wijzigen" + msgid "Change user role" msgstr "Gebruikersrol wijzigen" @@ -115,9 +121,15 @@ msgstr "Wissen" msgid "Clear filters" msgstr "Filters wissen" +msgid "Close" +msgstr "Sluiten" + msgid "Close user profile" msgstr "Gebruikersprofiel sluiten" +msgid "Contact support" +msgstr "Ondersteuning contacteren" + msgid "Continue" msgstr "Verder" @@ -133,6 +145,9 @@ msgstr "Gevaarzone" msgid "Dark" msgstr "Donker" +msgid "Dashboard" +msgstr "Dashboard" + msgid "Delete" msgstr "Verwijderen" @@ -188,6 +203,9 @@ msgstr "Fout: Er is iets misgegaan!" msgid "Europe" msgstr "Europa" +msgid "Feel free to reach out with any questions or issues you may have." +msgstr "Voel je vrij om contact op te nemen met vragen of problemen die je hebt." + msgid "Fetching data..." msgstr "Gegevens ophalen..." @@ -197,9 +215,6 @@ msgstr "Filters" msgid "First name" msgstr "Voornaam" -msgid "Help" -msgstr "Help" - msgid "Here's your overview of what's happening." msgstr "Hier is je overzicht van wat er gebeurt." @@ -260,6 +275,9 @@ msgstr "Naam" msgid "Navigation" msgstr "Navigatie" +msgid "Need help? Our support team is here to assist you." +msgstr "Hulp nodig? Ons ondersteuningsteam staat voor je klaar." + msgid "Next" msgstr "Volgende" @@ -269,9 +287,15 @@ msgstr "OK" msgid "Only account owners can modify the account name" msgstr "Alleen accounteigenaren kunnen de accountnaam wijzigen" +msgid "Open navigation menu" +msgstr "Navigatiemenu openen" + msgid "Organization" msgstr "Organisatie" +msgid "Our support team will assist you with the account deletion process and ensure all your data is properly removed." +msgstr "Ons ondersteuningsteam helpt je met het verwijderen van je account en al je gegevens." + msgid "Owner" msgstr "Eigenaar" @@ -326,9 +350,6 @@ msgstr "Opslaan..." msgid "Screenshots of the dashboard project with desktop and mobile versions" msgstr "Schermafbeeldingen van het dashboardproject met desktop en mobiele versies" -msgid "Screenshots of the dashboard project with mobile versions" -msgstr "Schermafbeeldingen van het dashboardproject met mobiele versies" - msgid "Search" msgstr "Zoeken" @@ -339,9 +360,6 @@ msgstr "Selecteer een nieuwe rol voor <0>{0}" msgid "Select dates" msgstr "Selecteer datums" -msgid "Select language" -msgstr "Selecteer taal" - msgid "Send invite" msgstr "Uitnodiging verzenden" @@ -363,9 +381,6 @@ msgstr "Verificatiecode voor aanmelding" msgid "Success" msgstr "Succes" -msgid "Support" -msgstr "Ondersteuning" - msgid "System" msgstr "Systeem" @@ -381,11 +396,11 @@ msgstr "Dit is de regio waar je gegevens zijn opgeslagen" msgid "Title" msgstr "Titel" -msgid "Toggle collapsed menu" -msgstr "Ingeklapt menu wisselen" +msgid "To delete your account, please contact our support team." +msgstr "Neem contact op met ons ondersteuningsteam om je account te verwijderen." -msgid "Toggle theme" -msgstr "Thema wisselen" +msgid "Toggle sidebar" +msgstr "Zijbalk wisselen" msgid "Total users" msgstr "Totale gebruikers" @@ -469,9 +484,6 @@ msgstr "Welkom home" msgid "Welcome home, {0}" msgstr "Welkom home, {0}" -msgid "You are about to permanently delete the account and the entire data environment via PlatformPlatform.<0/><1/>This action is permanent and irreversible." -msgstr "Je staat op het punt om het account en de volledige gegevensomgeving permanent te verwijderen via PlatformPlatform.<0/><1/>Deze actie is definitief en onomkeerbaar." - msgid "Your verification code has expired" msgstr "Je verificatiecode is verlopen" diff --git a/application/back-office/WebApp/bootstrap.tsx b/application/back-office/WebApp/bootstrap.tsx index ada41486d..9a26128c9 100644 --- a/application/back-office/WebApp/bootstrap.tsx +++ b/application/back-office/WebApp/bootstrap.tsx @@ -2,13 +2,13 @@ import "@repo/ui/tailwind.css"; import { router } from "@/shared/lib/router/router"; import { ApplicationInsightsProvider } from "@repo/infrastructure/applicationInsights/ApplicationInsightsProvider"; import { setupGlobalErrorHandlers } from "@repo/infrastructure/http/errorHandler"; -import { Translation } from "@repo/infrastructure/translations/Translation"; +import { createFederatedTranslation } from "@repo/infrastructure/translations/createFederatedTranslation"; import { GlobalToastRegion } from "@repo/ui/components/Toast"; import { RouterProvider } from "@tanstack/react-router"; import React from "react"; import reactDom from "react-dom/client"; -const { TranslationProvider } = await Translation.create( +const { TranslationProvider } = await createFederatedTranslation( (locale) => import(`@/shared/translations/locale/${locale}.ts`) ); diff --git a/application/back-office/WebApp/routes/back-office/index.tsx b/application/back-office/WebApp/routes/back-office/index.tsx index 897b58977..c798ffdd5 100644 --- a/application/back-office/WebApp/routes/back-office/index.tsx +++ b/application/back-office/WebApp/routes/back-office/index.tsx @@ -1,9 +1,8 @@ -import { SharedSideMenu } from "@/shared/components/SharedSideMenu"; import { TopMenu } from "@/shared/components/topMenu"; -import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { AppLayout } from "@repo/ui/components/AppLayout"; import { createFileRoute } from "@tanstack/react-router"; +import FederatedSideMenu from "account-management/FederatedSideMenu"; export const Route = createFileRoute("/back-office/")({ component: Home @@ -12,7 +11,7 @@ export const Route = createFileRoute("/back-office/")({ export default function Home() { return ( <> - + }>

Welcome to the Back Office diff --git a/application/back-office/WebApp/shared/components/SharedSideMenu.tsx b/application/back-office/WebApp/shared/components/SharedSideMenu.tsx deleted file mode 100644 index 8c5df1006..000000000 --- a/application/back-office/WebApp/shared/components/SharedSideMenu.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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"; - -type SharedSideMenuProps = { - children?: React.ReactNode; - ariaLabel: string; -}; - -export function SharedSideMenu({ children, ariaLabel }: Readonly) { - const userInfo = useUserInfo(); - - return ( - - - {children} - - - - - - ); -} diff --git a/application/back-office/WebApp/shared/components/topMenu/index.tsx b/application/back-office/WebApp/shared/components/topMenu/index.tsx index 716c387a4..f3c0eff8e 100644 --- a/application/back-office/WebApp/shared/components/topMenu/index.tsx +++ b/application/back-office/WebApp/shared/components/topMenu/index.tsx @@ -1,14 +1,9 @@ -import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { LocaleSwitcher } from "@repo/infrastructure/translations/LocaleSwitcher"; import { Breadcrumb, Breadcrumbs } from "@repo/ui/components/Breadcrumbs"; -import { Button } from "@repo/ui/components/Button"; -import { ThemeModeSelector } from "@repo/ui/theme/ThemeModeSelector"; -import { LifeBuoyIcon } from "lucide-react"; import type { ReactNode } from "react"; -import { lazy } from "react"; +import { Suspense, lazy } from "react"; -const AvatarButton = lazy(() => import("account-management/AvatarButton")); +const FederatedTopMenu = lazy(() => import("account-management/FederatedTopMenu")); interface TopMenuProps { children?: ReactNode; @@ -16,23 +11,15 @@ interface TopMenuProps { export function TopMenu({ children }: Readonly) { return ( - + }> + + + + Home + + {children} + + + ); } diff --git a/application/back-office/WebApp/shared/translations/locale/da-DK.po b/application/back-office/WebApp/shared/translations/locale/da-DK.po index 3665bf5c4..2f551a5f4 100644 --- a/application/back-office/WebApp/shared/translations/locale/da-DK.po +++ b/application/back-office/WebApp/shared/translations/locale/da-DK.po @@ -13,29 +13,11 @@ msgstr "" "Plural-Forms: \n" "X-Generator: @lingui/cli\n" -msgid "Account management" -msgstr "Kontoadministration" - -msgid "Help" -msgstr "Hjælp" - msgid "Home" msgstr "Hjem" msgid "Manage tenants, view system data, see exceptions, and perform various tasks for operational and support teams." msgstr "Administrer lejere, se systemdata, se undtagelser og udfør forskellige opgaver for drifts- og supportteams." -msgid "Select language" -msgstr "Vælg sprog" - -msgid "Toggle collapsed menu" -msgstr "Skift kollapset menu" - -msgid "Toggle theme" -msgstr "Skift tema" - -msgid "User profile menu" -msgstr "Brugerprofilmenu" - msgid "Welcome to the Back Office" msgstr "Velkommen til Back Office" diff --git a/application/back-office/WebApp/shared/translations/locale/en-US.po b/application/back-office/WebApp/shared/translations/locale/en-US.po index 46e456001..b64e6f282 100644 --- a/application/back-office/WebApp/shared/translations/locale/en-US.po +++ b/application/back-office/WebApp/shared/translations/locale/en-US.po @@ -13,29 +13,11 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" -msgid "Account management" -msgstr "Account management" - -msgid "Help" -msgstr "Help" - msgid "Home" msgstr "Home" msgid "Manage tenants, view system data, see exceptions, and perform various tasks for operational and support teams." msgstr "Manage tenants, view system data, see exceptions, and perform various tasks for operational and support teams." -msgid "Select language" -msgstr "Select language" - -msgid "Toggle collapsed menu" -msgstr "Toggle collapsed menu" - -msgid "Toggle theme" -msgstr "Toggle theme" - -msgid "User profile menu" -msgstr "User profile menu" - msgid "Welcome to the Back Office" msgstr "Welcome to the Back Office" diff --git a/application/back-office/WebApp/shared/translations/locale/nl-NL.po b/application/back-office/WebApp/shared/translations/locale/nl-NL.po index 733610398..c43b1e885 100644 --- a/application/back-office/WebApp/shared/translations/locale/nl-NL.po +++ b/application/back-office/WebApp/shared/translations/locale/nl-NL.po @@ -13,29 +13,11 @@ msgstr "" "Plural-Forms: \n" "X-Generator: @lingui/cli\n" -msgid "Account management" -msgstr "Accountbeheer" - -msgid "Help" -msgstr "Help" - msgid "Home" msgstr "Home" msgid "Manage tenants, view system data, see exceptions, and perform various tasks for operational and support teams." msgstr "Beheer huurders, bekijk systeemgegevens, zie uitzonderingen en voer diverse taken uit voor operationele en ondersteuningsteams." -msgid "Select language" -msgstr "Selecteer taal" - -msgid "Toggle collapsed menu" -msgstr "Ingeklapt menu wisselen" - -msgid "Toggle theme" -msgstr "Schakel thema om" - -msgid "User profile menu" -msgstr "Gebruikersprofielmenu" - msgid "Welcome to the Back Office" msgstr "Welkom bij de Backoffice" diff --git a/application/biome.json b/application/biome.json index fc4b5ebbb..7985363d0 100644 --- a/application/biome.json +++ b/application/biome.json @@ -82,7 +82,7 @@ } } }, - "ignore": ["*/environment.d.ts", "*/tailwind-preset.ts"] + "ignore": ["*/environment.d.ts", "*/tailwind-preset.ts", "*/module-federation-types/*.d.ts"] }, "files": { "ignore": ["*.Api.json"] diff --git a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs index c0068ab05..44f017dc0 100644 --- a/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs +++ b/application/shared-kernel/SharedKernel/Authentication/UserInfo.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using PlatformPlatform.SharedKernel.Domain; +using PlatformPlatform.SharedKernel.Platform; using PlatformPlatform.SharedKernel.SinglePageApp; namespace PlatformPlatform.SharedKernel.Authentication; @@ -18,7 +19,8 @@ public class UserInfo public static readonly UserInfo System = new() { IsAuthenticated = false, - Locale = DefaultLocale + Locale = DefaultLocale, + IsInternalUser = false }; public bool IsAuthenticated { get; init; } @@ -43,6 +45,8 @@ public class UserInfo public string? TenantName { get; init; } + public bool IsInternalUser { get; init; } + public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale) { if (user?.Identity?.IsAuthenticated != true) @@ -50,25 +54,28 @@ public static UserInfo Create(ClaimsPrincipal? user, string? browserLocale) return new UserInfo { IsAuthenticated = user?.Identity?.IsAuthenticated ?? false, - Locale = GetValidLocale(browserLocale) + Locale = GetValidLocale(browserLocale), + IsInternalUser = false }; } var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); var tenantId = user.FindFirstValue("tenant_id"); + var email = user.FindFirstValue(ClaimTypes.Email); return new UserInfo { IsAuthenticated = true, Id = userId == null ? null : new UserId(userId), TenantId = tenantId == null ? null : new TenantId(long.Parse(tenantId)), Role = user.FindFirstValue(ClaimTypes.Role), - Email = user.FindFirstValue(ClaimTypes.Email), + Email = email, FirstName = user.FindFirstValue(ClaimTypes.GivenName), LastName = user.FindFirstValue(ClaimTypes.Surname), Title = user.FindFirstValue("title"), AvatarUrl = user.FindFirstValue("avatar_url"), TenantName = user.FindFirstValue("tenant_name"), - Locale = GetValidLocale(user.FindFirstValue("locale")) + Locale = GetValidLocale(user.FindFirstValue("locale")), + IsInternalUser = IsInternalUserEmail(email) }; } @@ -91,4 +98,9 @@ private static string GetValidLocale(string? locale) return foundLocale ?? DefaultLocale; } + + private static bool IsInternalUserEmail(string? email) + { + return email is not null && email.EndsWith(Settings.Current.Identity.InternalEmailDomain, StringComparison.OrdinalIgnoreCase); + } } diff --git a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs index 8f07faf25..2b8bfa50e 100644 --- a/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs +++ b/application/shared-kernel/SharedKernel/Configuration/SharedDependencyConfiguration.cs @@ -15,6 +15,7 @@ using PlatformPlatform.SharedKernel.Integrations.Email; using PlatformPlatform.SharedKernel.Persistence; using PlatformPlatform.SharedKernel.PipelineBehaviors; +using PlatformPlatform.SharedKernel.Platform; using PlatformPlatform.SharedKernel.Telemetry; namespace PlatformPlatform.SharedKernel.Configuration; @@ -39,6 +40,7 @@ public static IServiceCollection AddSharedServices(this IServiceCollection se return services .AddServiceDiscovery() .AddSingleton(GetTokenSigningService()) + .AddSingleton(Settings.Current) .AddAuthentication() .AddDefaultJsonSerializerOptions() .AddPersistenceHelpers() diff --git a/application/shared-kernel/SharedKernel/Platform/Settings.cs b/application/shared-kernel/SharedKernel/Platform/Settings.cs new file mode 100644 index 000000000..eb5135e49 --- /dev/null +++ b/application/shared-kernel/SharedKernel/Platform/Settings.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace PlatformPlatform.SharedKernel.Platform; + +public sealed class Settings +{ + private static readonly Lazy Instance = new(LoadFromEmbeddedResource); + + public static Settings Current => Instance.Value; + + public required IdentityConfig Identity { get; init; } + + public required BrandingConfig Branding { get; init; } + + private static Settings LoadFromEmbeddedResource() + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "PlatformPlatform.SharedKernel.Platform.platform-settings.jsonc"; + + using var stream = assembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Could not find embedded resource: {resourceName}"); + + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + return JsonSerializer.Deserialize(stream, options) + ?? throw new InvalidOperationException("Failed to deserialize platform settings"); + } + + public sealed class IdentityConfig + { + public required string InternalEmailDomain { get; init; } + } + + public sealed class BrandingConfig + { + public required string ProductName { get; init; } + + public required string SupportEmail { get; init; } + } +} diff --git a/application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc b/application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc new file mode 100644 index 000000000..ed280d9b4 --- /dev/null +++ b/application/shared-kernel/SharedKernel/Platform/platform-settings.jsonc @@ -0,0 +1,27 @@ +{ + // Platform-wide configuration settings + // This file is prepared for sharing between backend (.NET), frontend (TypeScript), and tests + // + // IMPORTANT: This configuration is embedded in the backend and injected at build time in the frontend + // Not all values are currently used - some are placeholders for future functionality + // + // Security Note: Only include non-sensitive configuration here + // Sensitive values (like API keys) should be stored in environment variables or key vaults + + "identity": { + // Email domain suffix that identifies internal users + // Users with this domain get access to BackOffice and other internal features + // Currently used by backend only - frontend relies on isInternalUser flag from backend + "internalEmailDomain": "@platformplatform.net" + }, + + "branding": { + // Product/platform name used throughout the application + // Placeholder for future use - currently hardcoded in various places + "productName": "PlatformPlatform", + + // Support email address for user inquiries + // Placeholder for future use - not currently referenced in the codebase + "supportEmail": "support@platformplatform.net" + } +} \ No newline at end of file diff --git a/application/shared-kernel/SharedKernel/SharedKernel.csproj b/application/shared-kernel/SharedKernel/SharedKernel.csproj index 7ce0d6a93..c1d87707c 100644 --- a/application/shared-kernel/SharedKernel/SharedKernel.csproj +++ b/application/shared-kernel/SharedKernel/SharedKernel.csproj @@ -57,4 +57,8 @@ + + + + diff --git a/application/shared-webapp/build/environment.d.ts b/application/shared-webapp/build/environment.d.ts index ab4212b28..8bd9d5ce5 100644 --- a/application/shared-webapp/build/environment.d.ts +++ b/application/shared-webapp/build/environment.d.ts @@ -84,6 +84,10 @@ export declare global { * Tenant name **/ tenantName?: string; + /** + * Is internal user (has access to BackOffice) + **/ + isInternalUser?: boolean; } /** diff --git a/application/shared-webapp/build/module-federation-types/account-management.d.ts b/application/shared-webapp/build/module-federation-types/account-management.d.ts index 137a5fe17..a84b904fc 100644 --- a/application/shared-webapp/build/module-federation-types/account-management.d.ts +++ b/application/shared-webapp/build/module-federation-types/account-management.d.ts @@ -1,5 +1,20 @@ // This file was auto-generated by the ModuleFederationPlugin -declare module "account-management/AvatarButton" { +declare module "account-management/FederatedSideMenu" { export default ReactNode; } +declare module "account-management/FederatedTopMenu" { + export default ReactNode; +} +declare module "account-management/translations/en-US" { + import type { Messages } from "@lingui/core"; + export const messages: Messages; +} +declare module "account-management/translations/da-DK" { + import type { Messages } from "@lingui/core"; + export const messages: Messages; +} +declare module "account-management/translations/nl-NL" { + import type { Messages } from "@lingui/core"; + export const messages: Messages; +} diff --git a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts index 8928a0431..b8529b83d 100644 --- a/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts +++ b/application/shared-webapp/build/plugin/ModuleFederationPlugin.ts @@ -18,7 +18,7 @@ const { dependencies } = require(applicationPackageJson); const SYSTEM_ID = getSystemId(); type ModuleFederationPluginOptions = { - exposes?: Record<`./${string}`, `./${string}.tsx` | `./${string}.ts`>; + exposes?: Record<`./${string}`, `./${string}.tsx` | `./${string}.ts` | `./${string}`>; remotes?: Record; }; @@ -109,6 +109,15 @@ function generateModuleFederationTypesFolder(system: string, exposes: Record { logger.info(`[Module Federation] Expose: ${exportPath} => ${importPath}`); + + // Pattern matching for different module types + // Translation files follow the pattern: ./translations/xx-XX (e.g., ./translations/en-US) + const translationPattern = /^\.\/translations\/[a-z]{2}-[A-Z]{2}$/; + if (translationPattern.test(exportPath)) { + return `declare module "${exportPath.replace(/^\./, system)}" {\n import type { Messages } from "@lingui/core";\n export const messages: Messages;\n}`; + } + + // Default to ReactNode export for components return `declare module "${exportPath.replace(/^\./, system)}" {\n export default ReactNode;\n}`; }) .join("\n"); diff --git a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx b/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx deleted file mode 100644 index e13e354a7..000000000 --- a/application/shared-webapp/infrastructure/translations/LocaleSwitcher.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useLingui } from "@lingui/react"; -import type { Key } from "@react-types/shared"; -import { AuthenticationContext } from "@repo/infrastructure/auth/AuthenticationProvider"; -import { enhancedFetch } from "@repo/infrastructure/http/httpClient"; -import { Button } from "@repo/ui/components/Button"; -import { Menu, MenuItem, MenuTrigger } from "@repo/ui/components/Menu"; -import { CheckIcon, GlobeIcon } from "lucide-react"; -import { use, useContext, useMemo } from "react"; -import { type Locale, translationContext } from "./TranslationContext"; -import { preferredLocaleKey } from "./constants"; - -export function LocaleSwitcher({ "aria-label": ariaLabel }: { "aria-label": string }) { - const { setLocale, getLocaleInfo, locales } = use(translationContext); - const { i18n } = useLingui(); - const { userInfo } = useContext(AuthenticationContext); - - const items = useMemo( - () => - locales.map((locale) => ({ - id: locale, - label: getLocaleInfo(locale).label - })), - [locales, getLocaleInfo] - ); - - const handleLocaleChange = (key: Key) => { - const locale = key.toString() as Locale; - if (locale !== currentLocale) { - if (userInfo?.isAuthenticated) { - enhancedFetch("/api/account-management/users/me/change-locale", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ locale }) - }).then(async (_: Response) => { - await setLocale(locale); - localStorage.setItem(preferredLocaleKey, locale); - }); - } else { - setLocale(locale).then(() => { - localStorage.setItem(preferredLocaleKey, locale); - }); - } - } - }; - - const currentLocale = i18n.locale as Locale; - - return ( - - - - {items.map((item) => ( - -
- {item.label} - {item.id === currentLocale && } -
-
- ))} -
-
- ); -} diff --git a/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts b/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts new file mode 100644 index 000000000..20fa98d29 --- /dev/null +++ b/application/shared-webapp/infrastructure/translations/createFederatedTranslation.ts @@ -0,0 +1,97 @@ +import type { Messages } from "@lingui/core"; +import { type Locale, type LocaleFile, Translation } from "./Translation"; + +// Module federation container type +type FederatedContainer = { + get(module: string): Promise<() => { messages: Messages }>; +}; + +// Cache for loaded translation modules +const translationModuleCache = new Map(); + +/** + * Configuration for federated translations + * Each application that consumes federated modules should configure + * which remotes might provide translations + */ +const FEDERATED_TRANSLATION_REMOTES = [ + "account-management" + // Add more remotes here as they are created +] as const; + +/** + * Creates a Translation instance that automatically loads and merges translations + * from all federated modules configured in the current application. + * + * This function: + * 1. Uses the base translation loader for the host application + * 2. Automatically discovers and loads translations from configured federated remotes + * 3. Merges all translations together with remote translations taking precedence + * + * @param baseLoader - Function to load base translations for the host application + * @returns Translation instance with federated translation support + */ +export function createFederatedTranslation(baseLoader: (locale: Locale) => Promise): Promise { + const federatedLoader = createFederatedLoader(baseLoader); + return Translation.create(federatedLoader); +} + +/** + * Try to load translations from a federated module + */ +async function loadRemoteTranslations(remoteName: string, locale: Locale): Promise { + // Check cache first + const cacheKey = `${remoteName}:${locale}`; + const cached = translationModuleCache.get(cacheKey); + if (cached) { + return cached; + } + + // Get container using RSBuild's naming convention (hyphens to underscores) + const containerName = remoteName.replace(/-/g, "_"); + const container = (window as unknown as Record)[containerName] as FederatedContainer | null; + + if (!container?.get) { + return null; + } + + try { + const factory = await container.get(`./translations/${locale}`); + const module = factory(); + + if (module?.messages) { + translationModuleCache.set(cacheKey, module.messages); + return module.messages; + } + } catch (_error) { + // Silently fail - the remote might not have translations for this locale + } + + return null; +} + +/** + * Creates a translation loader that merges translations from federated modules + */ +function createFederatedLoader( + baseLoader: (locale: Locale) => Promise +): (locale: Locale) => Promise { + return async (locale: Locale): Promise => { + // Load base translations first + const baseMessages = await baseLoader(locale); + + // Load and merge translations from all configured remotes + const allMessages = { ...baseMessages.messages }; + + await Promise.all( + FEDERATED_TRANSLATION_REMOTES.map(async (remoteName) => { + const remoteMessages = await loadRemoteTranslations(remoteName, locale); + if (remoteMessages) { + Object.assign(allMessages, remoteMessages); + } + }) + ); + + return { messages: allMessages }; + }; +} diff --git a/application/shared-webapp/ui/components/SideMenu.tsx b/application/shared-webapp/ui/components/SideMenu.tsx index 27e75c4f9..d1f3267d0 100644 --- a/application/shared-webapp/ui/components/SideMenu.tsx +++ b/application/shared-webapp/ui/components/SideMenu.tsx @@ -88,6 +88,10 @@ type MenuButtonProps = { forceReload: true; href: string; } + | { + federatedNavigation: true; + href: string; + } ); // Helper function to get target path from href @@ -152,13 +156,9 @@ function ActiveIndicator({ ); } -export function MenuButton({ - icon: Icon, - label, - href: to, - isDisabled = false, - forceReload = false -}: Readonly) { +export function MenuButton({ icon: Icon, label, href: to, isDisabled = false, ...props }: Readonly) { + const forceReload = "forceReload" in props ? props.forceReload : false; + const federatedNavigation = "federatedNavigation" in props ? props.federatedNavigation : false; const isCollapsed = useContext(collapsedContext); const overlayCtx = useContext(overlayContext); const router = useRouter(); @@ -201,8 +201,25 @@ export function MenuButton({ overlayCtx.close(); } - // Handle navigation for React Aria Link - if (forceReload) { + // Smart navigation for federated modules + if (federatedNavigation) { + // Check if the target route exists in the current router + try { + const matchResult = router.matchRoute({ to }); + if (matchResult !== false) { + // Route exists in current system - use SPA navigation + // Don't do anything, let React Aria handle the navigation + return; + } + } catch { + // Route doesn't exist in current system + } + + // Route doesn't exist in current system - force reload + window.location.href = to; + } + // Legacy forceReload behavior + else if (forceReload) { window.location.href = to; } }; @@ -214,7 +231,7 @@ export function MenuButton({ + + + + +

+ ); + } + + // For regular navigation, use TanStack Router Link return (
@@ -249,6 +287,122 @@ export function MenuButton({ ); } +// Federated menu button for module federation +type FederatedMenuButtonProps = { + icon: LucideIcon; + label: string; + href: string; + isCurrentSystem: boolean; + isDisabled?: boolean; +}; + +export function FederatedMenuButton({ + icon: Icon, + label, + href: to, + isCurrentSystem, + isDisabled = false +}: Readonly) { + const isCollapsed = useContext(collapsedContext); + const overlayCtx = useContext(overlayContext); + const router = useRouter(); + + // Check if this menu item is active + const currentPath = router.state.location.pathname; + const targetPath = to; + const isActive = normalizePath(currentPath) === normalizePath(targetPath); + + // 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 + if (overlayCtx?.isOpen) { + overlayCtx.close(); + } + + // Always prevent default to handle navigation ourselves + e.preventDefault(); + + if (isCurrentSystem) { + // Same system - use programmatic navigation + window.history.pushState({}, "", to); + // Dispatch a popstate event using the standard Event constructor + window.dispatchEvent(new Event("popstate")); + } else { + // Different system - force reload + window.location.href = to; + } + }; + + // For collapsed menu, wrap in TooltipTrigger + if (isCollapsed) { + return ( +
+ + + { + if (isDisabled) { + return; + } + + // Auto-close overlay after navigation + if (overlayCtx?.isOpen) { + overlayCtx.close(); + } + + if (isCurrentSystem) { + // Same system - use programmatic navigation + window.history.pushState({}, "", to); + // Dispatch a popstate event using the standard Event constructor + window.dispatchEvent(new Event("popstate")); + } else { + // Different system - force reload + window.location.href = to; + } + }} + > + + + + {label} + + +
+ ); + } + + // For expanded menu, use a regular anchor tag with onClick handler + return ( +
+ + + + +
+ ); +} + 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: { @@ -290,7 +444,8 @@ const chevronStyles = tv({ type SideMenuProps = { children: React.ReactNode; - ariaLabel: string; + sidebarToggleAriaLabel: string; + mobileMenuAriaLabel: string; topMenuContent?: React.ReactNode; tenantName?: string; }; @@ -479,7 +634,13 @@ const ResizableToggleButton = ({ ); -export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Readonly) { +export function SideMenu({ + children, + sidebarToggleAriaLabel, + mobileMenuAriaLabel, + topMenuContent, + tenantName +}: Readonly) { const { className, forceCollapsed, overlayMode, isHidden } = useResponsiveMenu(); const sideMenuRef = useRef(null); const toggleButtonRef = useRef(null); @@ -701,13 +862,13 @@ export function SideMenu({ children, ariaLabel, topMenuContent, tenantName }: Re toggleMenu={toggleMenu} menuWidth={menuWidth} setMenuWidth={setMenuWidth} - ariaLabel={ariaLabel} + ariaLabel={sidebarToggleAriaLabel} actualIsCollapsed={actualIsCollapsed} /> ) : (
}> - + ); diff --git a/application/shared-webapp/ui/components/Toast.tsx b/application/shared-webapp/ui/components/Toast.tsx index 2b52f2827..f89e3d223 100644 --- a/application/shared-webapp/ui/components/Toast.tsx +++ b/application/shared-webapp/ui/components/Toast.tsx @@ -40,12 +40,62 @@ type ToastContext = { const toastContext = createContext({}); -export const toastQueue = new ToastQueue({ +// Create a unique identifier for this instance +const instanceId = Math.random().toString(36).substring(7); + +// Create a custom ToastQueue that dispatches events for federation +class FederatedToastQueue extends ToastQueue { + private isFederatedAdd = false; + + add(content: T, options?: { timeout?: number; priority?: number }) { + // First add to the local queue + const result = super.add(content, options); + + // Only dispatch event if this isn't already a federated add + if (!this.isFederatedAdd) { + const event = new CustomEvent("federated-toast", { + detail: { content, options, sourceInstanceId: instanceId }, + bubbles: true, + composed: true + }); + window.dispatchEvent(event); + } + + return result; + } + + federatedAdd(content: T, options?: { timeout?: number; priority?: number }) { + this.isFederatedAdd = true; + const result = this.add(content, options); + this.isFederatedAdd = false; + return result; + } +} + +export const toastQueue = new FederatedToastQueue({ maxVisibleToasts: 5 }); export function GlobalToastRegion(props: AriaToastRegionProps) { const state = useToastQueue(toastQueue); + + // Listen for federated toast events from other modules + useEffect(() => { + const handleFederatedToast = (event: CustomEvent) => { + const { content, options, sourceInstanceId } = event.detail; + // Only process events from other instances + if (sourceInstanceId !== instanceId) { + // Use the federatedAdd method to add without re-dispatching the event + (toastQueue as FederatedToastQueue).federatedAdd(content, options); + } + }; + + window.addEventListener("federated-toast", handleFederatedToast as EventListener); + return () => { + window.removeEventListener("federated-toast", handleFederatedToast as EventListener); + }; + }, []); + return state.visibleToasts.length > 0 ? createPortal(, document.body) : null; } diff --git a/application/shared-webapp/ui/theme/ThemeModeSelector.tsx b/application/shared-webapp/ui/theme/ThemeModeSelector.tsx deleted file mode 100644 index 2d9fd609d..000000000 --- a/application/shared-webapp/ui/theme/ThemeModeSelector.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Button } from "@repo/ui/components/Button"; -import { Tooltip, TooltipTrigger } from "@repo/ui/components/Tooltip"; -import { MoonIcon, MoonStarIcon, SunIcon, SunMoonIcon } from "lucide-react"; -import { toggleThemeMode, useThemeMode } from "./mode/ThemeMode"; -import { SystemThemeMode, ThemeMode } from "./mode/utils"; - -/** - * A button that toggles the theme mode between system, light and dark. - */ -export function ThemeModeSelector({ "aria-label": ariaLabel }: { "aria-label": string }) { - const { themeMode, resolvedThemeMode, setThemeMode } = useThemeMode(); - - const tooltipText = getTooltipText(themeMode, resolvedThemeMode); - - return ( - - - {tooltipText} - - ); -} - -function getTooltipText(themeMode: ThemeMode, resolvedThemeMode: SystemThemeMode): string { - if (resolvedThemeMode === SystemThemeMode.Dark) { - return themeMode === ThemeMode.System ? "System mode (dark)" : "Dark mode"; - } - return themeMode === ThemeMode.System ? "System mode (light)" : "Light mode"; -} - -function ThemeModeIcon({ themeMode, resolvedThemeMode }: { themeMode: ThemeMode; resolvedThemeMode: SystemThemeMode }) { - if (resolvedThemeMode === SystemThemeMode.Dark) { - return themeMode === ThemeMode.System ? : ; - } - return themeMode === ThemeMode.System ? : ; -}