mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -1,11 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
@import "@edr/ui-common/theme.css" layer(theme);
|
||||
|
||||
/* The app toggles dark mode by setting the `dark` class on <html> (see
|
||||
main.tsx / FreightDashboardLayout). Without this, Tailwind v4 compiles
|
||||
`dark:` utilities to `@media (prefers-color-scheme: dark)` and they follow
|
||||
the OS setting instead of the in-app toggle. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
/* The backoffice is light-only — there is no theme toggle and nothing sets the
|
||||
`dark` class. This override is load-bearing: without it Tailwind v4 compiles
|
||||
`dark:` utilities to `@media (prefers-color-scheme: dark)`, so every
|
||||
leftover `dark:` class (the vendored IAM UI is full of them) would activate
|
||||
on an OS-level dark setting. Binding the variant to a class that is never
|
||||
rendered keeps those utilities inert no matter what the OS says. */
|
||||
@custom-variant dark (&:where(.edr-dark-disabled));
|
||||
|
||||
/* Bridge the central Mantine theme into Tailwind. freightMantineTheme
|
||||
(createTheme) is the single source of truth; these just alias its generated
|
||||
|
||||
@@ -143,7 +143,6 @@ const DashboardShell = () => {
|
||||
sidebarSections={sidebarSections}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
userName={displayName}
|
||||
userEmail={user?.email}
|
||||
onLogout={logout}
|
||||
|
||||
@@ -15,9 +15,7 @@ import {
|
||||
FileSignature,
|
||||
Languages,
|
||||
LogOut,
|
||||
Moon,
|
||||
Search,
|
||||
Sun,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
@@ -31,13 +29,10 @@ import type { PageMeta } from "./types";
|
||||
export interface FreightDashboardHeaderProps {
|
||||
pageMeta: PageMeta;
|
||||
headerRight?: ReactNode;
|
||||
enableThemeToggle?: boolean;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
userInitials?: string;
|
||||
onLogout?: () => void;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
mobileOpened: boolean;
|
||||
onToggleMobile: () => void;
|
||||
/** Hide the mobile burger when the shell has no sidebar to open. */
|
||||
@@ -51,13 +46,10 @@ const ISLAND =
|
||||
|
||||
const FreightDashboardHeader = ({
|
||||
headerRight,
|
||||
enableThemeToggle = false,
|
||||
userName = "User",
|
||||
userEmail,
|
||||
userInitials,
|
||||
onLogout,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
mobileOpened,
|
||||
onToggleMobile,
|
||||
hideSidebarBurger = false,
|
||||
@@ -129,26 +121,6 @@ const FreightDashboardHeader = ({
|
||||
|
||||
<NotificationBellContainer />
|
||||
|
||||
{enableThemeToggle && (
|
||||
<Tooltip
|
||||
label={theme === "dark" ? "Light mode" : "Dark mode"}
|
||||
withArrow
|
||||
openDelay={300}
|
||||
>
|
||||
<UnstyledButton
|
||||
className={ISLAND}
|
||||
onClick={onToggleTheme}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun size={17} strokeWidth={1.8} />
|
||||
) : (
|
||||
<Moon size={17} strokeWidth={1.8} />
|
||||
)}
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Avatar pill */}
|
||||
<Menu width={240} position="bottom-end" withinPortal shadow="md" offset={8} radius="md">
|
||||
<Menu.Target>
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import { AppShell, Box } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import FreightDashboardHeader from "./FreightDashboardHeader";
|
||||
import FreightSidebar from "./FreightSidebar";
|
||||
import { getPageMeta } from "./route-meta";
|
||||
import type { SidebarSection } from "./types";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
const HEADER_HEIGHT = 64;
|
||||
const NAVBAR_WIDTH = 280;
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (stored === "dark" || stored === "light") return stored;
|
||||
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
export interface FreightDashboardLayoutProps {
|
||||
sidebarSections: SidebarSection[];
|
||||
/** Render the shell with no navbar at all (used by GL clearance-only users). */
|
||||
@@ -28,7 +17,6 @@ export interface FreightDashboardLayoutProps {
|
||||
activeHref?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
headerRight?: ReactNode;
|
||||
enableThemeToggle?: boolean;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
userInitials?: string;
|
||||
@@ -42,7 +30,6 @@ const FreightDashboardLayout = ({
|
||||
activeHref = "",
|
||||
onNavigate,
|
||||
headerRight,
|
||||
enableThemeToggle = false,
|
||||
userName,
|
||||
userEmail,
|
||||
userInitials,
|
||||
@@ -53,20 +40,6 @@ const FreightDashboardLayout = ({
|
||||
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>(() =>
|
||||
enableThemeToggle ? getInitialTheme() : "light",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableThemeToggle) return;
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", theme === "dark");
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
}, [theme, enableThemeToggle]);
|
||||
|
||||
const toggleTheme = () =>
|
||||
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||
|
||||
const navigate = (href: string) => {
|
||||
closeMobile();
|
||||
onNavigate?.(href);
|
||||
@@ -93,13 +66,10 @@ const FreightDashboardLayout = ({
|
||||
<FreightDashboardHeader
|
||||
pageMeta={pageMeta}
|
||||
headerRight={headerRight}
|
||||
enableThemeToggle={enableThemeToggle}
|
||||
userName={userName}
|
||||
userEmail={userEmail}
|
||||
userInitials={userInitials}
|
||||
onLogout={onLogout}
|
||||
theme={theme}
|
||||
onToggleTheme={toggleTheme}
|
||||
mobileOpened={mobileOpened}
|
||||
onToggleMobile={toggleMobile}
|
||||
hideSidebarBurger={hideSidebar}
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
LogOut,
|
||||
User,
|
||||
Key,
|
||||
Moon,
|
||||
Sun,
|
||||
Home,
|
||||
Menu,
|
||||
} from "lucide-react";
|
||||
@@ -25,7 +23,6 @@ import { NavLink, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { FiBell } from "react-icons/fi";
|
||||
import { useUser } from "@/shared/context/UserContext";
|
||||
import { useDarkMode } from "@/shared/hooks/useDarkMode";
|
||||
import {
|
||||
UI_LANGUAGE_OPTIONS,
|
||||
getUiLanguageLabel,
|
||||
@@ -49,7 +46,6 @@ export const TopBar = () => {
|
||||
const { pathname } = useLocation();
|
||||
const { logout } = useAuthUser();
|
||||
const userDetails = useUser();
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
const moduleConfig = resolveModuleConfig(tenantConfig);
|
||||
const isAdminModuleVisible = userDetails?.roles?.some(
|
||||
@@ -66,7 +62,6 @@ export const TopBar = () => {
|
||||
moduleConfig.siteManagement && isAdminModuleVisible,
|
||||
].filter(Boolean).length;
|
||||
const shouldShowHomePageLink = visibleModuleCount > 1;
|
||||
const isObjectiveManagementRoute = pathname.startsWith("/objective-management");
|
||||
const fullName = userDetails?.name?.en || t("user");
|
||||
const splittedName = fullName.trim().split(" ");
|
||||
const initials =
|
||||
@@ -269,19 +264,6 @@ export const TopBar = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={toggleDarkMode}
|
||||
aria-label="Toggle Dark Mode"
|
||||
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200"
|
||||
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
|
||||
style={{ display: isObjectiveManagementRoute ? "none" : "block" }}>
|
||||
{isDarkMode ? (
|
||||
<Sun className="h-5 w-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 text-gray-600" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -18,11 +18,8 @@ import {
|
||||
Info,
|
||||
Menu,
|
||||
X,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useUser } from "@/shared/context/UserContext";
|
||||
import { useDarkMode } from "@/shared/hooks/useDarkMode";
|
||||
import { useTenantConfig, resolveModuleConfig } from "@/layout/components/TenantConfig";
|
||||
import {
|
||||
UI_LANGUAGE_OPTIONS,
|
||||
@@ -55,7 +52,6 @@ const Header = () => {
|
||||
const location = useLocation();
|
||||
const isComplaintContext = isComplaintAuthContext(location.pathname);
|
||||
const { isAuthenticated, showExternalPortalChrome } = useExternalPortalSession();
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const { config: tenantConfig } = useTenantConfig();
|
||||
const moduleConfig = resolveModuleConfig(tenantConfig);
|
||||
|
||||
@@ -480,22 +476,6 @@ const Header = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Dark Mode Toggle */}
|
||||
<motion.button
|
||||
onClick={toggleDarkMode}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200"
|
||||
title={
|
||||
isDarkMode ? "Switch to light mode" : "Switch to dark mode"
|
||||
}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="h-5 w-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 text-gray-600" />
|
||||
)}
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={handleSignIn}
|
||||
whileHover={{
|
||||
@@ -646,26 +626,6 @@ const Header = () => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Dark Mode Toggle for Mobile */}
|
||||
<motion.button
|
||||
onClick={toggleDarkMode}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="block w-full px-4 py-3 text-base font-medium text-center text-gray-700 dark:text-gray-200 bg-gray-100 dark:bg-gray-700 rounded-lg shadow hover:bg-gray-200 dark:hover:bg-gray-600 transition-all duration-200 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<>
|
||||
<Sun className="h-5 w-5 text-yellow-500" />
|
||||
{t("landingPage.lightMode")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="h-5 w-5 text-gray-600" />
|
||||
{t("landingPage.darkMode")}
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => {
|
||||
handleSignIn();
|
||||
|
||||
@@ -31,24 +31,19 @@ import { QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
|
||||
const applyStoredTheme = () => {
|
||||
const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||
const theme = storedTheme === "dark" || storedTheme === "light"
|
||||
? storedTheme
|
||||
: prefersDark
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
if (theme === "dark") {
|
||||
document.documentElement.classList.add("dark");
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backoffice is light-only. This runs before render for two reasons:
|
||||
* strip the `dark` class in case anything ever adds it, and evict the
|
||||
* `edr-theme` key that earlier builds persisted — without the eviction, a user
|
||||
* who had toggled dark before this change keeps a "dark" value in
|
||||
* localStorage that any future re-read would resurrect.
|
||||
*/
|
||||
const forceLightTheme = () => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
window.localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
};
|
||||
|
||||
applyStoredTheme();
|
||||
forceLightTheme();
|
||||
|
||||
// Must run before render so replay and exception capture cover startup errors.
|
||||
// No-ops when VITE_POSTHOG_KEY is unset.
|
||||
@@ -63,7 +58,7 @@ if (!rootElement) {
|
||||
createRoot(rootElement).render(
|
||||
<PostHogProvider client={posthog}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider theme={freightMantineTheme}>
|
||||
<MantineProvider theme={freightMantineTheme} forceColorScheme="light">
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
|
||||
@@ -25,8 +25,6 @@ import {
|
||||
Cog,
|
||||
Bolt,
|
||||
Globe,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -39,7 +37,6 @@ import { Button } from "@/shared/common/ui/button";
|
||||
import { useTheme } from "@/user-management/web-Management/hooks/useTheme";
|
||||
import { useLocalizedName } from "@/shared/common/localizedName";
|
||||
import { useModules } from "@/user-management/web-Management/hooks/useModules";
|
||||
import { useDarkMode } from "@/shared/hooks/useDarkMode";
|
||||
import {
|
||||
useTenantConfig,
|
||||
resolveModuleConfig,
|
||||
@@ -98,7 +95,6 @@ const Header = () => {
|
||||
logout();
|
||||
};
|
||||
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!localStorage.getItem("i18nextLng")) {
|
||||
@@ -440,22 +436,6 @@ const Header = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-3 lg:gap-4">
|
||||
<motion.button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-lg text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
aria-label={
|
||||
isDarkMode ? t("header.lightMode") : t("header.darkMode")
|
||||
}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<div className="w-32 lg:w-36">
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
@@ -544,21 +524,6 @@ const Header = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center md:hidden gap-2">
|
||||
<motion.button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-lg text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 transition-colors"
|
||||
whileTap={{ scale: 0.9 }}
|
||||
aria-label={
|
||||
isDarkMode ? t("header.lightMode") : t("header.darkMode")
|
||||
}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="inline-flex items-center justify-center p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500 transition-all"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Editor } from "@tinymce/tinymce-react";
|
||||
import type { Editor as TinyMCEEditor } from "tinymce";
|
||||
|
||||
@@ -34,16 +34,10 @@ export default function RichTextEditor({
|
||||
shouldClear?: boolean;
|
||||
}) {
|
||||
const editorRef = useRef<TinyMCEEditor | null>(null);
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const root = document.documentElement;
|
||||
return (
|
||||
root.classList.contains("dark") || root.getAttribute("data-theme") === "dark"
|
||||
);
|
||||
});
|
||||
// The app is light-only — nothing sets `dark` / data-theme on <html>, so this
|
||||
// is a constant rather than observed state. Kept as a named flag so the
|
||||
// TinyMCE skin/content-style branches below stay readable.
|
||||
const isDarkMode = false;
|
||||
|
||||
// ✅ When shouldClear becomes true → clear editor + remove draft
|
||||
useEffect(() => {
|
||||
@@ -54,28 +48,6 @@ export default function RichTextEditor({
|
||||
}
|
||||
}, [shouldClear]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const updateThemeMode = () => {
|
||||
setIsDarkMode(
|
||||
root.classList.contains("dark") ||
|
||||
root.getAttribute("data-theme") === "dark",
|
||||
);
|
||||
};
|
||||
|
||||
updateThemeMode();
|
||||
|
||||
const observer = new MutationObserver(updateThemeMode);
|
||||
observer.observe(root, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "data-theme"],
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const init = useMemo(
|
||||
() => ({
|
||||
// Point self-hosted TinyMCE at the static assets copied into /public/tinymce.
|
||||
|
||||
@@ -21,8 +21,6 @@ import {
|
||||
Key,
|
||||
LogOut,
|
||||
LucideIcon,
|
||||
Moon,
|
||||
Sun,
|
||||
User,
|
||||
UserPen,
|
||||
UsersRound,
|
||||
@@ -37,7 +35,6 @@ import UserApprovalDropdown from "../PendingUsersList";
|
||||
import { usePendingUsers } from "@/user-management/userManagement/hooks/usePendingUsersHook";
|
||||
import { useUserDetail } from "../hooks/useUserDetail";
|
||||
import { MeDto } from "@/shared/dto/user/meDto";
|
||||
import { useDarkMode } from "@/shared/hooks/useDarkMode";
|
||||
import {
|
||||
UI_LANGUAGE_OPTIONS,
|
||||
getUiLanguageLabel,
|
||||
@@ -133,7 +130,6 @@ const Top: React.FC<HeaderProps> = ({
|
||||
logout();
|
||||
};
|
||||
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const { pendingUsers } = usePendingUsers({ take: 5, skip: 0 });
|
||||
const pendingUsersCount = pendingUsers?.count || 0;
|
||||
|
||||
@@ -359,21 +355,6 @@ const Top: React.FC<HeaderProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-[56vw] flex-shrink-0 items-center space-x-1.5 sm:max-w-none sm:space-x-2.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-xl bg-white/90 text-gray-600 shadow-sm ring-1 ring-inset ring-black/5 transition-colors hover:bg-primary-50 hover:text-primary-700 hover:ring-primary-200 dark:bg-gray-800/90 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-primary-900/20 dark:hover:text-primary-300 dark:hover:ring-primary-700/40 sm:h-10 sm:w-10"
|
||||
aria-label={isDarkMode ? "Light mode" : "Dark mode"}
|
||||
onClick={toggleDarkMode}
|
||||
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4 text-gray-700 dark:text-gray-300" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{canActivateUsers && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, ToasterProps } from "sonner"
|
||||
|
||||
// Pinned to "light": the app is light-only. This previously read next-themes'
|
||||
// useTheme, which falls back to "system" when no ThemeProvider is mounted (and
|
||||
// none is) — sonner then resolves "system" against prefers-color-scheme and
|
||||
// rendered dark toasts over a light UI on any OS set to dark.
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
|
||||
@@ -13,8 +13,6 @@ import {
|
||||
Mail,
|
||||
Phone,
|
||||
User,
|
||||
Moon,
|
||||
Sun,
|
||||
} from "lucide-react";
|
||||
import { useAuthUser } from "@/shared/hooks/useAuthUser";
|
||||
import { useTenantConfig } from "@/layout/components/TenantConfig";
|
||||
@@ -26,13 +24,11 @@ import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "@/shared/context/AuthContext";
|
||||
import OTPModal from "./OTPModal";
|
||||
import { useDarkMode } from "@/shared/hooks/useDarkMode";
|
||||
import { getRememberMePreference } from "@/shared/utils/authPersistence";
|
||||
|
||||
export const Login = () => {
|
||||
const { login: authLogin, isLoggingIn } = useAuthUser();
|
||||
const { config } = useTenantConfig();
|
||||
const { isDarkMode, toggleDarkMode } = useDarkMode();
|
||||
const detectLoginMethod = (value: string): "email" | "phone" | "username" => {
|
||||
if (value.includes("@") && value.includes(".com")) return "email";
|
||||
if (/^\+?\d+$/.test(value)) return "phone";
|
||||
@@ -121,19 +117,6 @@ export const Login = () => {
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="fixed top-4 right-4 md:top-6 md:right-6 z-50 p-2 rounded-full bg-white dark:bg-gray-800 shadow-md hover:shadow-lg transition-all duration-300 hover:scale-105 border border-gray-200 dark:border-gray-700"
|
||||
title={isDarkMode ? "Switch to light mode" : "Switch to dark mode"}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="w-full max-w-6xl bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col lg:flex-row">
|
||||
{/* Left Panel - Login Form */}
|
||||
<div className="lg:w-1/2 w-full p-6 sm:p-8 md:p-10 lg:p-12 xl:p-16 flex flex-col justify-center">
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Same storage key/values as main.tsx and FreightDashboardLayout so the
|
||||
// vendored IAM UI toggle and the host dashboard toggle stay in sync.
|
||||
const THEME_STORAGE_KEY = "edr-theme";
|
||||
|
||||
export const useDarkMode = () => {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() => {
|
||||
// Initialize from localStorage or system preference
|
||||
const savedTheme = localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (savedTheme === "dark" || savedTheme === "light") {
|
||||
return savedTheme === "dark";
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Apply dark mode class on mount and when isDarkMode changes
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
localStorage.setItem(THEME_STORAGE_KEY, "dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
localStorage.setItem(THEME_STORAGE_KEY, "light");
|
||||
}
|
||||
}, [isDarkMode]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setIsDarkMode((prev) => !prev);
|
||||
};
|
||||
|
||||
return { isDarkMode, toggleDarkMode };
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState, useEffect } from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { Editor as TinyMCEEditor } from "@/record-management/common/editor/rte";
|
||||
import type { Editor as TinyMCEEditorType } from "tinymce";
|
||||
|
||||
@@ -21,18 +21,11 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
placeholders,
|
||||
}) => {
|
||||
const hasPlaceholders = placeholders && placeholders.length > 0;
|
||||
const [isDarkMode, setIsDarkMode] = useState(() =>
|
||||
typeof window !== "undefined" && document.documentElement.classList.contains("dark")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// The app is light-only — nothing sets the `dark` class on <html>, so this is
|
||||
// a constant rather than observed state. Kept as a named flag so the TinyMCE
|
||||
// skin/content-style branches below stay readable.
|
||||
const isDarkMode = false;
|
||||
|
||||
const init = useMemo(() => {
|
||||
return {
|
||||
height: 600,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
@import "tailwindcss";
|
||||
@import "@edr/ui-common/theme.css" layer(theme);
|
||||
|
||||
/* Dark mode is toggled via the `dark` class on <html>. Without this, Tailwind
|
||||
v4 compiles `dark:` utilities to `@media (prefers-color-scheme: dark)` and
|
||||
they follow the OS setting instead of the in-app toggle. */
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
/* The portal is light-only — there is no theme toggle and nothing sets the
|
||||
`dark` class. This override is load-bearing: without it Tailwind v4 compiles
|
||||
`dark:` utilities to `@media (prefers-color-scheme: dark)`, so every
|
||||
leftover `dark:` class in the tree would activate on an OS-level dark
|
||||
setting. Binding the variant to a class that is never rendered keeps those
|
||||
utilities inert no matter what the OS (or a stray `.dark` class) says. */
|
||||
@custom-variant dark (&:where(.edr-dark-disabled));
|
||||
|
||||
/* Bridge the central Mantine theme into Tailwind. Mantine (createTheme) is the
|
||||
single source of truth; these just alias its generated CSS variables so
|
||||
|
||||
@@ -317,7 +317,6 @@ const App = () => {
|
||||
sidebarItems={sidebarItems}
|
||||
activeHref={location.pathname}
|
||||
onNavigate={navigate}
|
||||
enableThemeToggle
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
companyProfiles={companyProfiles}
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
useComputedColorScheme,
|
||||
useMantineColorScheme,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
@@ -26,17 +24,15 @@ import {
|
||||
FileSignature,
|
||||
LogOut,
|
||||
Menu as MenuIcon,
|
||||
Moon,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
Upload,
|
||||
User,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { type CSSProperties, Fragment, type ReactNode, useState } from "react";
|
||||
import { Fragment, type ReactNode, useState } from "react";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||
import SupportWidget from "@/features/support/SupportWidget";
|
||||
@@ -59,7 +55,6 @@ export interface AppLayoutProps {
|
||||
* to the new-booking wizard. Compatible with react-router's `navigate`.
|
||||
*/
|
||||
onNavigate?: (href: string, options?: { state?: unknown }) => void;
|
||||
enableThemeToggle?: boolean;
|
||||
userName?: string;
|
||||
userEmail?: string;
|
||||
/** Operational profiles for the company — surfaced as reference chips in the account menu. */
|
||||
@@ -154,7 +149,6 @@ export function AppLayout({
|
||||
sidebarItems,
|
||||
activeHref = "",
|
||||
onNavigate,
|
||||
enableThemeToggle = false,
|
||||
userName = "User",
|
||||
userEmail,
|
||||
companyProfiles = [],
|
||||
@@ -165,9 +159,6 @@ export function AppLayout({
|
||||
}: AppLayoutProps) {
|
||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||
const theme = useMantineTheme();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme("light");
|
||||
|
||||
const borderColor = theme.colors["edr-border"][6];
|
||||
const mutedColor = theme.colors["edr-muted"][6];
|
||||
const textColor = theme.colors["edr-text"][6];
|
||||
@@ -178,10 +169,6 @@ export function AppLayout({
|
||||
const navigate = (href: string, options?: { state?: unknown }) =>
|
||||
onNavigate?.(href, options);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setColorScheme(computedColorScheme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
const initials = getInitials(userName);
|
||||
const activePage = getActivePage(sidebarItems, activePath);
|
||||
|
||||
@@ -279,20 +266,6 @@ export function AppLayout({
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(item.href.toLowerCase() + "/");
|
||||
|
||||
// Shared header "island" styles — every control is a consistent 36px chip.
|
||||
const islandStyle: CSSProperties = {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${borderColor}`,
|
||||
backgroundColor: "#fff",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
cursor: "pointer",
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
layout="alt"
|
||||
@@ -457,20 +430,6 @@ export function AppLayout({
|
||||
{/* Notifications */}
|
||||
<NotificationBellContainer />
|
||||
|
||||
{enableThemeToggle && (
|
||||
<UnstyledButton
|
||||
onClick={toggleTheme}
|
||||
style={islandStyle}
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{computedColorScheme === "dark" ? (
|
||||
<Sun size={17} color={textColor} strokeWidth={1.8} />
|
||||
) : (
|
||||
<Moon size={17} color={textColor} strokeWidth={1.8} />
|
||||
)}
|
||||
</UnstyledButton>
|
||||
)}
|
||||
|
||||
{/* Avatar pill */}
|
||||
<Menu
|
||||
width={220}
|
||||
|
||||
@@ -48,7 +48,10 @@ if (!rootElement) {
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<PostHogProvider client={posthog}>
|
||||
<MantineProvider theme={mantineTheme} defaultColorScheme="light">
|
||||
{/* forceColorScheme (not defaultColorScheme) — the app is light-only.
|
||||
`default` still lets Mantine flip to dark via useMantineColorScheme
|
||||
or a persisted color-scheme value; `force` pins it. */}
|
||||
<MantineProvider theme={mantineTheme} forceColorScheme="light">
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<PostHogErrorBoundary fallback={<AppErrorFallback />}>
|
||||
|
||||
@@ -355,28 +355,33 @@ export default function CompanyProfileForm({
|
||||
// in flight), so the initial state above freezes at `false` — adopt the
|
||||
// server's declaration the moment it lands, or a resumed draft shows an
|
||||
// unticked box over a GM that is linked server-side.
|
||||
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
|
||||
identity?.poaSameAsOwner ?? false,
|
||||
);
|
||||
const identityLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!identity || identityLoaded.current) return;
|
||||
identityLoaded.current = true;
|
||||
setGmSameAsOwner(identity.gmSameAsOwner);
|
||||
setPoaSameAsOwner(identity.poaSameAsOwner);
|
||||
}, [identity]);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
|
||||
// A Fayda-verified owner outranks eTrade's registered owner — it's the
|
||||
// higher-trust source, and the whole point of proving identity is to stop
|
||||
// trusting typed/looked-up data for this.
|
||||
const gmSourceName = firstPresent(
|
||||
// Where the owner's details come from when they are copied onto someone else
|
||||
// — the GM, or the representative. A Fayda-verified owner outranks eTrade's
|
||||
// registered owner: it's the higher-trust source, and the whole point of
|
||||
// proving identity is to stop trusting typed/looked-up data for this.
|
||||
const ownerSourceName = firstPresent(
|
||||
identity?.owner.name,
|
||||
etradeOwner?.name,
|
||||
user.name?.en,
|
||||
);
|
||||
|
||||
const gmSourceEmail = firstValidEmail(identity?.owner.email, user.email);
|
||||
const ownerSourceEmail = firstValidEmail(identity?.owner.email, user.email);
|
||||
// Same reason as `derivedPhone`: this value is written into
|
||||
// `generalManagerPhone`, which the API validates with `@IsValidPhone()`, so an
|
||||
// unusable eTrade number here 400s the personnel step instead.
|
||||
const gmSourcePhone = firstValidPhone(
|
||||
// `generalManagerPhone` / `poaPhone`, which the API validates with
|
||||
// `@IsValidPhone()`, so an unusable eTrade number here 400s the step instead.
|
||||
const ownerSourcePhone = firstValidPhone(
|
||||
identity?.owner.phone,
|
||||
etradeOwner?.phone,
|
||||
user.phoneNumber,
|
||||
@@ -388,13 +393,30 @@ export default function CompanyProfileForm({
|
||||
// `identity.gm`; mirroring it into form fields here would send typed
|
||||
// values for something the API already owns.
|
||||
if (identity?.owner.verified) return;
|
||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", gmSourceEmail, { shouldValidate: true });
|
||||
setValue("generalManagerPhone", gmSourcePhone, {
|
||||
setValue("generalManagerName", ownerSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", ownerSourceEmail, { shouldValidate: true });
|
||||
setValue("generalManagerPhone", ownerSourcePhone, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gmSameAsOwner, gmSourceName, gmSourceEmail, gmSourcePhone]);
|
||||
}, [gmSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
|
||||
|
||||
// The representative's half of the same copy. A verified owner's identity is
|
||||
// copied server-side and read back from `identity.poa`, so only an owner
|
||||
// backed by a typed passport is mirrored into form fields here — the same
|
||||
// split the GM makes above, for the same reason.
|
||||
//
|
||||
// Only non-empty sources are written. A source the owner does not have is a
|
||||
// gap the step renders an input for (see `poaGaps`), and this effect re-runs
|
||||
// whenever any *other* source changes — so blanking here would wipe what the
|
||||
// customer is typing into that input the moment an eTrade lookup lands.
|
||||
useEffect(() => {
|
||||
if (!poaSameAsOwner || identity?.owner.verified) return;
|
||||
if (ownerSourceName) setValue("poaName", ownerSourceName, { shouldValidate: true });
|
||||
if (ownerSourceEmail) setValue("poaEmail", ownerSourceEmail, { shouldValidate: true });
|
||||
if (ownerSourcePhone) setValue("poaPhone", ownerSourcePhone, { shouldValidate: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [poaSameAsOwner, ownerSourceName, ownerSourceEmail, ownerSourcePhone]);
|
||||
|
||||
/**
|
||||
* "Same as owner" has two meanings depending on what backs the owner.
|
||||
@@ -439,6 +461,44 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The representative is the owner. Unlike the GM's card this always goes to
|
||||
* the API, whichever backs the owner: the declaration itself is what waives
|
||||
* the DARS delegation paper, so it has to be recorded server-side even when
|
||||
* there is no proven identity to copy and the details are mirrored locally.
|
||||
*/
|
||||
const [poaLinkPending, setPoaLinkPending] = useState(false);
|
||||
const togglePoaSameAsOwner = async (checked: boolean) => {
|
||||
setSaveError(null);
|
||||
setPoaSameAsOwner(checked);
|
||||
setPoaLinkPending(true);
|
||||
try {
|
||||
if (checked) await verifaydaService.setPoaSameAsOwner();
|
||||
else {
|
||||
await verifaydaService.clearPoaSameAsOwner();
|
||||
// Only the locally mirrored values are ours to clear; a copied identity
|
||||
// is cleared by the call above.
|
||||
if (!identity?.owner.verified) {
|
||||
setValue("poaName", "");
|
||||
setValue("poaEmail", "");
|
||||
setValue("poaPhone", "");
|
||||
}
|
||||
}
|
||||
onIdentityChange?.();
|
||||
} catch (err) {
|
||||
setPoaSameAsOwner(!checked);
|
||||
setSaveError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error
|
||||
? err.message
|
||||
: "Could not update the Power of Attorney"),
|
||||
);
|
||||
} finally {
|
||||
setPoaLinkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Where the GM's details come from depends on how they were established: a
|
||||
// Fayda verification (or a "same as owner" declaration) owns them outright,
|
||||
// and only a company that may still type them falls back to form state.
|
||||
@@ -704,7 +764,12 @@ export default function CompanyProfileForm({
|
||||
// verify with Fayda — the API demands it at completion either way. Keying
|
||||
// this on the verification alone hid the upload from a foreign forwarder and
|
||||
// then failed them on submit for a file they were never shown.
|
||||
const delegationRequired = poaProvided || requirePoa;
|
||||
//
|
||||
// Unless the owner represents the company themselves: nobody delegates to
|
||||
// themselves, so there is no delegation to evidence. Mirrors the API's own
|
||||
// waiver in `assertPoaDelegationSatisfied` — the two must agree, or this
|
||||
// demands a file the server would accept the submission without.
|
||||
const delegationRequired = (poaProvided || requirePoa) && !poaSameAsOwner;
|
||||
const delegationPresent =
|
||||
(uploadedDocumentKeys ?? []).includes(POA_DELEGATION_FILE_KEY) ||
|
||||
(() => {
|
||||
@@ -735,12 +800,38 @@ export default function CompanyProfileForm({
|
||||
// which is also the only case the API refuses to let anyone overwrite.
|
||||
const poaGap = (v?: string | null) =>
|
||||
poaTypedAllowed && (!identity?.poa.verified || !v?.trim());
|
||||
const poaGaps = {
|
||||
name: poaGap(identity?.poa.name),
|
||||
email: poaGap(identity?.poa.email),
|
||||
phone: poaGap(identity?.poa.phone),
|
||||
address: poaGap(identity?.poa.address),
|
||||
};
|
||||
/**
|
||||
* "Same as owner" answers each field only as far as the owner actually has
|
||||
* one. Fayda's name, email and phone claims are all optional, the account and
|
||||
* eTrade fallbacks can be empty or unusable, and `REQUIRED_POA_FIELDS` still
|
||||
* demands a name, an email and a phone — so anything the copy could not
|
||||
* supply stays askable. Assuming the copy filled everything is what dead-ends
|
||||
* the submit on "Add your poa phone" with no input anywhere to satisfy it.
|
||||
*
|
||||
* Keyed on the *source*, never on the field's current value: an input that
|
||||
* disappears the moment the first character is typed into it is unusable.
|
||||
* A verified owner's identity is copied server-side, so `identity.poa` is the
|
||||
* source there; otherwise it is the same owner-derived values the mirror
|
||||
* effect writes.
|
||||
*/
|
||||
const poaCopyGap = (copied?: string | null, mirrored?: string | null) =>
|
||||
identity?.owner.verified ? !copied?.trim() : !mirrored?.trim();
|
||||
const poaGaps = poaSameAsOwner
|
||||
? {
|
||||
name: poaCopyGap(identity?.poa.name, ownerSourceName),
|
||||
email: poaCopyGap(identity?.poa.email, ownerSourceEmail),
|
||||
phone: poaCopyGap(identity?.poa.phone, ownerSourcePhone),
|
||||
// The location is the one detail the API never demands, so a blank one
|
||||
// dead-ends nothing — and asking for the owner's city under a card that
|
||||
// says "same as owner" reads as a contradiction.
|
||||
address: false,
|
||||
}
|
||||
: {
|
||||
name: poaGap(identity?.poa.name),
|
||||
email: poaGap(identity?.poa.email),
|
||||
phone: poaGap(identity?.poa.phone),
|
||||
address: poaGap(identity?.poa.address),
|
||||
};
|
||||
// The GM's own verification never falls back to the signed-in account — that
|
||||
// account is the person onboarding, not necessarily the manager — so a GM
|
||||
// verified with no email claim has nowhere else for one to come from. The
|
||||
@@ -1024,6 +1115,10 @@ export default function CompanyProfileForm({
|
||||
form={form}
|
||||
identity={identity}
|
||||
requirePoa={requirePoa}
|
||||
poaSameAsOwner={poaSameAsOwner}
|
||||
onTogglePoaSameAsOwner={togglePoaSameAsOwner}
|
||||
poaLinkPending={poaLinkPending}
|
||||
etradeOwner={etradeOwner}
|
||||
gaps={poaGaps}
|
||||
onRemovePoa={removePoa}
|
||||
removePending={poaRemovePending}
|
||||
|
||||
@@ -10,20 +10,27 @@ export function LinkCheckboxCard({
|
||||
onToggle,
|
||||
title,
|
||||
description,
|
||||
disabled = false,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onToggle: (checked: boolean) => void;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Greys the card out and refuses the toggle — the link is not available yet. */
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => onToggle(!checked)}
|
||||
onClick={() => !disabled && onToggle(!checked)}
|
||||
role="checkbox"
|
||||
aria-checked={checked}
|
||||
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${checked
|
||||
aria-disabled={disabled}
|
||||
className={`w-full rounded-lg border! p-3! text-left transition-colors! ${disabled ? "cursor-not-allowed! opacity-60!" : ""
|
||||
} ${checked
|
||||
? "border-[var(--mantine-color-edr-green-6)]! bg-[var(--mantine-color-edr-green-0)]!"
|
||||
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!"
|
||||
: disabled
|
||||
? "border-[var(--mantine-color-gray-3)]!"
|
||||
: "border-[var(--mantine-color-gray-3)]! hover:border-[var(--mantine-color-edr-green-4)]! hover:bg-[var(--mantine-color-edr-green-0)]!"
|
||||
}`}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start">
|
||||
|
||||
@@ -9,12 +9,20 @@ import type { FileUploadSetting } from "@/types/fileUploadSettings";
|
||||
import type { CompanyIdentityState } from "@/services/verifayda.service";
|
||||
|
||||
import type { FormData } from "../schema";
|
||||
import { LinkCheckboxCard } from "../LinkCheckboxCard";
|
||||
|
||||
export interface PoaStepProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
identity?: CompanyIdentityState;
|
||||
/** This company holds a freight-forwarder profile, so the PoA is mandatory. */
|
||||
requirePoa: boolean;
|
||||
/** The owner represents the company themselves. */
|
||||
poaSameAsOwner: boolean;
|
||||
onTogglePoaSameAsOwner: (checked: boolean) => void;
|
||||
/** A server-side "same as owner" declaration is in flight. */
|
||||
poaLinkPending: boolean;
|
||||
/** eTrade-registered owner, once a TIN lookup has succeeded. */
|
||||
etradeOwner: { name: string; phone: string } | null;
|
||||
/**
|
||||
* Which of the representative's details the Fayda verification did not
|
||||
* supply, and are therefore typed here. Computed by CompanyProfileForm, which
|
||||
@@ -39,6 +47,10 @@ export default function PoaStep({
|
||||
form,
|
||||
identity,
|
||||
requirePoa,
|
||||
poaSameAsOwner,
|
||||
onTogglePoaSameAsOwner,
|
||||
poaLinkPending,
|
||||
etradeOwner,
|
||||
gaps,
|
||||
onRemovePoa,
|
||||
removePending,
|
||||
@@ -65,26 +77,64 @@ export default function PoaStep({
|
||||
const needsEmail = gaps.email;
|
||||
const needsPhone = gaps.phone;
|
||||
|
||||
// Fayda is mandatory for an Ethiopian company's representative, so there the
|
||||
// link can only reuse a proven owner — with none there would be nothing to
|
||||
// copy and the declaration could never satisfy the gate. A foreign company's
|
||||
// owner is backed by a typed passport, so it prefills instead.
|
||||
const linkNeedsVerifiedOwner =
|
||||
(identity?.faydaRequired ?? false) && !identity?.owner.verified;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" c="edr-muted">
|
||||
{requirePoa
|
||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details and the DARS delegation paper are required."
|
||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue. If you do enter a representative, upload the delegation paper authenticated by DARS."}{" "}
|
||||
{/* The API refuses an owner who delegates to themselves — say so here,
|
||||
or the customer only finds out after being sent to Fayda and back. */}
|
||||
The representative must be someone other than the company's owner.
|
||||
? "As a freight forwarder you act on other companies' behalf, so Power of Attorney details are required."
|
||||
: "Power of Attorney details are optional. Fill them in if you have them, or skip to continue."}{" "}
|
||||
{poaSameAsOwner
|
||||
? "You represent the company yourself, so no delegation paper is needed."
|
||||
: "If the representative is someone other than the owner, upload the delegation paper authenticated by DARS."}
|
||||
</Text>
|
||||
|
||||
{/* An owner who represents their own company is the ordinary
|
||||
small-business case. Where the owner is Fayda-verified this reuses
|
||||
that proven identity outright rather than sending the same human
|
||||
through Fayda twice; where they are backed by a typed passport there
|
||||
is nothing proven to copy, so it stays a local prefill. Either way it
|
||||
is the declaration that waives the DARS paper. */}
|
||||
{identity && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsOwner}
|
||||
onToggle={onTogglePoaSameAsOwner}
|
||||
disabled={poaLinkPending || (linkNeedsVerifiedOwner && !poaSameAsOwner)}
|
||||
title={
|
||||
identity.owner.verified
|
||||
? "Same as verified owner"
|
||||
: "Same as business owner"
|
||||
}
|
||||
description={
|
||||
linkNeedsVerifiedOwner
|
||||
? "Verify the company owner with Fayda first — then you can reuse that identity here."
|
||||
: identity.owner.verified
|
||||
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
: etradeOwner
|
||||
? "You represent the company yourself. Reuses the eTrade-registered owner's name plus the company email and phone as you entered them, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
: "You represent the company yourself. Reuses your account's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* A representative acts for the company inside Ethiopia
|
||||
whoever owns it, so the PoA is proven with Fayda regardless of
|
||||
nationality — their name, email, phone and address all come
|
||||
from the verification and are never typed here. */}
|
||||
{identity && (
|
||||
from the verification and are never typed here. Verifying a second
|
||||
person is only meaningful when the representative is not the owner. */}
|
||||
{identity && !poaSameAsOwner && (
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={requirePoa}
|
||||
disabled={poaLinkPending}
|
||||
/>
|
||||
)}
|
||||
{/* A verification cannot be undone by clearing the form — it owns those
|
||||
@@ -92,7 +142,7 @@ export default function PoaStep({
|
||||
then blocks the submit. So an optional representative needs a way
|
||||
back out, here rather than only in settings (unreachable until
|
||||
onboarding finishes). */}
|
||||
{identity?.poa.verified && !requirePoa && (
|
||||
{identity?.poa.verified && !requirePoa && !poaSameAsOwner && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from "@/services/companies.service";
|
||||
import FaydaVerifyPanel from "@/components/FaydaVerifyPanel";
|
||||
import { verifaydaService } from "@/services/verifayda.service";
|
||||
import { LinkCheckboxCard } from "@/pages/accounts/companyProfileForm/LinkCheckboxCard";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
|
||||
// The representative's name, email, phone and address all come from their
|
||||
@@ -135,13 +136,21 @@ export default function TabPowerOfAttorney({
|
||||
// company inside Ethiopia either way. A PoA therefore exists exactly when one
|
||||
// has been verified.
|
||||
const identity = profile.identity;
|
||||
const owner = identity?.owner;
|
||||
const poaProvided = identity?.poa.verified ?? false;
|
||||
const [poaSameAsOwner, setPoaSameAsOwner] = useState(
|
||||
identity?.poaSameAsOwner ?? false,
|
||||
);
|
||||
// The paper authorises the representative named above, so there is nothing
|
||||
// for it to authorise until one has been verified — the upload is hidden
|
||||
// until then, and requiring it while hidden would block the save on a
|
||||
// control the customer cannot see. A freight forwarder is still held to
|
||||
// having a PoA at all, by the verification gate on the panel and by the API.
|
||||
const letterRequired = poaProvided;
|
||||
//
|
||||
// And nobody delegates to themselves: an owner representing their own company
|
||||
// has no delegation to evidence, which is the same waiver the API applies in
|
||||
// `assertPoaDelegationSatisfied`.
|
||||
const letterRequired = poaProvided && !poaSameAsOwner;
|
||||
const letterMissing = letterRequired && !hasLetterAfterSave;
|
||||
|
||||
const fileDirty = Boolean(pickedFile) || removeIds.length > 0;
|
||||
@@ -198,6 +207,46 @@ export default function TabPowerOfAttorney({
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* "Same as owner": the owner represents the company themselves. Always goes
|
||||
* to the API, whichever credential backs the owner — the declaration is what
|
||||
* waives the DARS paper, so it has to be recorded server-side even when there
|
||||
* is no proven identity to copy.
|
||||
*
|
||||
* Unchecking undoes the declaration only. It leaves the paper on file and is
|
||||
* allowed for a freight forwarder, which is how one changes who represents
|
||||
* it; "Remove representative" below is the harder action that takes the paper
|
||||
* with it and is refused to a forwarder.
|
||||
*/
|
||||
const [linkPending, setLinkPending] = useState(false);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
const togglePoaSameAsOwner = async (checked: boolean) => {
|
||||
setPoaSameAsOwner(checked);
|
||||
setLinkError(null);
|
||||
setLinkPending(true);
|
||||
try {
|
||||
if (checked) await verifaydaService.setPoaSameAsOwner();
|
||||
else await verifaydaService.clearPoaSameAsOwner();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.getProfile.queryKey(),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: api.companies.poaDelegation.queryKey(),
|
||||
});
|
||||
} catch (err) {
|
||||
setPoaSameAsOwner(!checked);
|
||||
setLinkError(
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ??
|
||||
(err instanceof Error
|
||||
? err.message
|
||||
: "Could not update the Power of Attorney"),
|
||||
);
|
||||
} finally {
|
||||
setLinkPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
// The letter lives outside the form state, so it's gated here rather than
|
||||
// in the zod resolver.
|
||||
@@ -247,17 +296,60 @@ export default function TabPowerOfAttorney({
|
||||
</Group>
|
||||
<Text c="edr-muted" size="sm" mb="lg">
|
||||
{requirePoa
|
||||
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney and its DARS delegation paper are required."
|
||||
: "Power of Attorney details are optional. If you name a representative, upload the DARS delegation paper authorising them."}
|
||||
? "As a freight forwarder you act on other companies' behalf, so a Power of Attorney is required."
|
||||
: "Power of Attorney details are optional."}{" "}
|
||||
{poaSameAsOwner
|
||||
? "You represent the company yourself, so no delegation paper is needed."
|
||||
: "If you name a representative, upload the DARS delegation paper authorising them."}
|
||||
</Text>
|
||||
|
||||
{/* The owner representing their own company is the ordinary
|
||||
small-business case: a verified owner's identity is reused outright,
|
||||
and either way the declaration waives the DARS paper. Where Fayda is
|
||||
mandatory it needs a verified owner first — there would be nothing
|
||||
proven to copy, and a representative who could never satisfy the
|
||||
gate. */}
|
||||
{identity && (
|
||||
<LinkCheckboxCard
|
||||
checked={poaSameAsOwner}
|
||||
onToggle={togglePoaSameAsOwner}
|
||||
disabled={
|
||||
linkPending ||
|
||||
mutation.isPending ||
|
||||
(!poaSameAsOwner &&
|
||||
(identity.faydaRequired ?? false) &&
|
||||
!owner?.verified)
|
||||
}
|
||||
title={
|
||||
owner?.verified
|
||||
? "Same as verified owner"
|
||||
: "Same as business owner"
|
||||
}
|
||||
description={
|
||||
(identity.faydaRequired ?? false) && !owner?.verified
|
||||
? "Verify the company owner with Fayda first — then you can reuse that identity here."
|
||||
: owner?.verified
|
||||
? "You represent the company yourself. Reuses the Fayda-verified owner's identity, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
: "You represent the company yourself. Reuses the owner's name, email and phone, and no DARS delegation paper is needed. Uncheck to name someone else."
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{linkError && (
|
||||
<Alert color="red" variant="light" icon={<XCircle size={18} />} mt="md">
|
||||
{linkError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Verifying a second person only means something when the
|
||||
representative is someone other than the owner. */}
|
||||
{identity && !poaSameAsOwner && (
|
||||
<FaydaVerifyPanel
|
||||
subject="poa"
|
||||
title="Power of Attorney"
|
||||
state={identity.poa}
|
||||
required={requirePoa}
|
||||
disabled={mutation.isPending}
|
||||
disabled={mutation.isPending || linkPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -284,7 +376,7 @@ export default function TabPowerOfAttorney({
|
||||
{/* ------------------------ Delegation letter ------------------------ */}
|
||||
{/* The paper authorises the representative the verification named,
|
||||
so it only has meaning once one exists. */}
|
||||
{poaProvided && (
|
||||
{poaProvided && !poaSameAsOwner && (
|
||||
<Stack gap="sm" mt="xl">
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
@@ -459,8 +551,12 @@ export default function TabPowerOfAttorney({
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
{/* Not offered against a "same as owner" declaration: unchecking
|
||||
the card above is the way out of that one, and it leaves the
|
||||
paper alone. */}
|
||||
{mode === "edit" &&
|
||||
identity?.poa.verified &&
|
||||
!poaSameAsOwner &&
|
||||
!requirePoa && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -162,6 +162,11 @@ export interface OnboardingLicenseProfile {
|
||||
export interface OnboardingPoaState {
|
||||
required: boolean;
|
||||
provided: boolean;
|
||||
/**
|
||||
* True when the DARS delegation paper is owed. False when the owner
|
||||
* represents the company themselves — nobody delegates to themselves.
|
||||
*/
|
||||
delegationLetterRequired: boolean;
|
||||
delegationLetterUploaded: boolean;
|
||||
/** True when a reviewer sent the DARS delegation paper back for correction. */
|
||||
delegationLetterFlagged: boolean;
|
||||
|
||||
@@ -41,6 +41,13 @@ export interface CompanyIdentityState {
|
||||
passportRequired: boolean;
|
||||
owner: OwnerIdentityState;
|
||||
poa: IdentityVerificationState;
|
||||
/**
|
||||
* True when the representative is the owner themselves, declared through
|
||||
* "same as owner". Waives the DARS delegation paper — nobody delegates to
|
||||
* themselves — and, where the owner is Fayda-verified, backs `poa.verified`
|
||||
* with the owner's sub.
|
||||
*/
|
||||
poaSameAsOwner: boolean;
|
||||
/**
|
||||
* General manager. `verified` covers both routes: the GM verifying in their
|
||||
* own right, and the company declaring the GM is the owner (in which case
|
||||
@@ -143,6 +150,34 @@ export const verifaydaService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Declare the Power of Attorney is the company's owner. A Fayda-verified
|
||||
* owner's identity is copied server-side (the portal never supplies it); a
|
||||
* foreign company's owner has nothing proven to copy, so the API records the
|
||||
* declaration and the form types the representative's details as usual.
|
||||
*
|
||||
* Either way the declaration is what waives the DARS delegation paper.
|
||||
*/
|
||||
setPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.post<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/poa/same-as-owner",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Undo that declaration and the identity it copied, leaving the
|
||||
* representative open to be verified in their own right. Unlike
|
||||
* {@link removePoa} this is allowed for a freight forwarder — it is how they
|
||||
* change who represents them — and leaves the delegation paper on file.
|
||||
*/
|
||||
clearPoaSameAsOwner: async (): Promise<CompanyIdentityState> => {
|
||||
const response = await client.delete<ApiResponse<CompanyIdentityState>>(
|
||||
"/api/companies/identity/poa/same-as-owner",
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Drop the Power of Attorney — verified identity, details and delegation
|
||||
* paper together. A verified person's fields are locked, so blanking the form
|
||||
|
||||
Reference in New Issue
Block a user