move login to common ui

This commit is contained in:
mengstabketemaw
2026-06-13 11:21:32 +03:00
parent cf72572944
commit de6d5a01cb
29 changed files with 230 additions and 115 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

View File

@@ -1,5 +1,6 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react';
import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider';
@@ -14,7 +15,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<MantineThemeProvider>{children}</MantineThemeProvider>
<AuthConfigProvider
value={{
appName: 'Backoffice',
storagePrefix: 'ema-backoffice',
loginRedirectPath: '/dashboard',
enableSignup: false,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</QueryClientProvider>
</Provider>
);

View File

@@ -1,23 +1,20 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
import {
Login,
SetPasswordPage,
} from '@tria-plc/iamui-common';
LoginPage,
ForgotPasswordPage,
OTPVerificationPage,
} from '@ema-platform/auth';
import { AuthLayout } from '../layouts/AuthLayout';
import { AuthProviders } from '../iam/IamProviders';
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
const router = createBrowserRouter([
{
element: (
<AuthProviders>
<AuthLayout />
</AuthProviders>
),
element: <AuthLayout />,
children: [
{ path: '/login', element: <Login /> },
{ path: '/otp-verify', element: <SetPasswordPage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
{ path: '/otp-verify', element: <OTPVerificationPage /> },
],
},
{ path: '/um/*', element: <UserManagementHostPage /> },

View File

@@ -1,10 +1,26 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api';
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth';
configureAuthStorage('ema-backoffice');
const preloadedAuth = (() => {
const token = authStorage.getToken();
const user = authStorage.getUser<AuthUser>();
if (token && user) {
return { token, user, isAuthenticated: true };
}
return undefined;
})();
export const store = configureStore({
reducer: {
auth: authReducer,
signup: signupReducer,
[baseApi.reducerPath]: baseApi.reducer,
},
preloadedState: preloadedAuth ? { auth: preloadedAuth } : undefined,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(baseApi.middleware),
});

View File

@@ -1,23 +0,0 @@
import type { AuthUser } from '../types/auth.types';
const KEYS = {
token: 'ema-portal-auth-token',
refreshToken: 'ema-portal-refresh-token',
user: 'ema-portal-auth-user',
} as const;
export const authStorage = {
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
setRefreshToken: (t: string) => localStorage.setItem(KEYS.refreshToken, t),
getUser: (): AuthUser | null => {
try {
return JSON.parse(localStorage.getItem(KEYS.user) ?? 'null') as AuthUser | null;
} catch {
return null;
}
},
setUser: (u: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(u)),
clear: () => Object.values(KEYS).forEach((k) => localStorage.removeItem(k)),
};

View File

@@ -45,7 +45,7 @@ import { useApiMutation } from '@ema-platform/api';
import { PageHeader } from '../../../components/PageHeader';
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { setUser } from '../../auth/store/auth.slice';
import { setUser } from '@ema-platform/auth';
import type { AuthUser } from '../../auth/types/auth.types';
import classes from './ProfilePage.module.css';

View File

@@ -1,6 +1,7 @@
import { Provider } from 'react-redux';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '@tria-plc/iamui-common';
import { AuthConfigProvider } from '@ema-platform/auth';
import type { ReactNode } from 'react';
import { store } from '../store';
import { MantineThemeProvider } from './MantineThemeProvider';
@@ -16,7 +17,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<MantineThemeProvider>{children}</MantineThemeProvider>
<AuthConfigProvider
value={{
appName: 'Portal',
storagePrefix: 'ema-portal',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
}}
>
<MantineThemeProvider>{children}</MantineThemeProvider>
</AuthConfigProvider>
</AuthProvider>
</QueryClientProvider>
</Provider>

View File

@@ -5,10 +5,7 @@ import { i18n } from './i18n/config';
import { PortalLayout } from './layouts/PortalLayout';
// Auth (standalone pages, no portal chrome)
import { LoginPage } from './features/auth/pages/LoginPage';
import { SignupPage } from './features/auth/pages/SignupPage';
import { OTPVerificationPage } from './features/auth/pages/OTPVerificationPage';
import { ForgotPasswordPage } from './features/auth/pages/ForgotPasswordPage';
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage } from '@ema-platform/auth';
// Portal feature pages
import { DashboardPage } from './features/dashboard/pages/DashboardPage';

View File

@@ -1,13 +1,14 @@
import { configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@ema-platform/api';
import { authReducer } from '../features/auth/store/auth.slice';
import { signupReducer } from '../features/auth/store/signup.slice';
import { authReducer, signupReducer, configureAuthStorage, authStorage } from '@ema-platform/auth';
import { licensesReducer } from '../features/licenses/store/licenses.slice';
import { authStorage } from '../features/auth/utils/auth-storage';
import type { AuthUser } from '@ema-platform/auth';
configureAuthStorage('ema-portal');
const preloadedAuth = (() => {
const token = authStorage.getToken();
const user = authStorage.getUser();
const user = authStorage.getUser<AuthUser>();
if (token && user) {
return { token, user, isAuthenticated: true };
}

View File

@@ -29,7 +29,7 @@ import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { notify } from '@ema-platform/ui';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { ColorSchemeToggle } from '../components/ColorSchemeToggle';
import { BrandMark } from '../features/auth/components/AuthShell';
import { BrandMark } from '@ema-platform/auth';
import { BrandAvatar } from './components/ui';
interface NavItem {

7
libs/auth/project.json Normal file
View File

@@ -0,0 +1,7 @@
{
"name": "@ema-platform/auth",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/auth/src",
"projectType": "library",
"tags": []
}

11
libs/auth/src/index.ts Normal file
View File

@@ -0,0 +1,11 @@
export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
export type { AuthConfigValue } from './lib/AuthConfig';
export { AuthShell, BrandMark } from './lib/components/AuthShell';
export { LoginPage } from './lib/pages/LoginPage';
export { SignupPage } from './lib/pages/SignupPage';
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
export type { AuthUser, AuthState, LoginPayload } from './lib/types/auth.types';

View File

@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from 'react';
export interface AuthConfigValue {
appName: string;
storagePrefix: string;
loginRedirectPath: string;
enableSignup: boolean;
enableForgotPassword: boolean;
logoUrl: string;
}
const defaultConfig: AuthConfigValue = {
appName: 'Portal',
storagePrefix: 'ema-auth',
loginRedirectPath: '/dashboard',
enableSignup: true,
enableForgotPassword: true,
logoUrl: '/brand/ema-white.png',
};
const AuthConfigContext = createContext<AuthConfigValue>(defaultConfig);
export function AuthConfigProvider({
children,
value,
}: {
children: ReactNode;
value: Partial<AuthConfigValue>;
}) {
const merged = { ...defaultConfig, ...value };
return (
<AuthConfigContext.Provider value={merged}>
{children}
</AuthConfigContext.Provider>
);
}
export function useAuthConfig() {
return useContext(AuthConfigContext);
}

View File

@@ -9,9 +9,25 @@ import {
Title,
rem,
useMantineTheme,
type BoxProps,
} from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
import { Logo } from '../../../components/Logo';
import { useAuthConfig } from '../AuthConfig';
export function BrandMark({ size = 44, ...boxProps }: BoxProps & { size?: number }) {
const { logoUrl } = useAuthConfig();
return (
<Box
component="img"
src={logoUrl}
alt="EMA"
w={size}
h={size}
style={{ display: 'block', objectFit: 'contain', flexShrink: 0 }}
{...boxProps}
/>
);
}
const FEATURES = [
'Submit applications online, 24/7',
@@ -19,36 +35,19 @@ const FEATURES = [
'Available in English & አማርኛ',
];
/**
* EMA brand mark the app logo, kept in sync across the portal via `<Logo>`.
* Use `variant="white"` on dark backgrounds (e.g. the auth brand panel).
*/
export function BrandMark({
size = 44,
variant = 'gradient',
}: {
size?: number;
variant?: 'gradient' | 'white';
}) {
return <Logo variant={variant === 'white' ? 'white' : 'color'} size={size} />;
}
interface AuthShellProps {
children: ReactNode;
brandTitle?: string;
brandSubtitle?: string;
}
/**
* Two-pane auth card: a centered card with a form panel (left) and a branded
* gradient panel (right, hidden below `lg`). Inspired by the UM login layout.
*/
export function AuthShell({
children,
brandTitle = 'Maritime licensing, made simple.',
brandSubtitle = 'Apply for vessel and seafarer licenses, upload documents, and track every application in one secure portal.',
}: AuthShellProps) {
const theme = useMantineTheme();
const { logoUrl } = useAuthConfig();
const heroGradient = theme.other.heroGradient as string;
return (
@@ -72,17 +71,15 @@ export function AuthShell({
}}
>
<Flex direction={{ base: 'column', lg: 'row' }}>
{/* ---- Form panel (left) ---------------------------------------- */}
<Box
w={{ base: '100%', lg: '50%' }}
px="xl"
py={32}
px={48}
py={48}
style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}
>
{children}
</Box>
{/* ---- Brand panel (right) -------------------------------------- */}
<Box
visibleFrom="lg"
w="50%"
@@ -90,7 +87,6 @@ export function AuthShell({
pos="relative"
style={{ background: heroGradient, overflow: 'hidden' }}
>
{/* Decorative blurred orbs */}
<Box
pos="absolute"
top={-80}
@@ -118,7 +114,7 @@ export function AuthShell({
<Center>
<Box
component="img"
src="/brand/ema-white.png"
src={logoUrl}
alt="EMA Portal"
style={{ display: 'block', maxWidth: '60%', height: 'auto' }}
/>

View File

@@ -23,6 +23,7 @@ import { Link } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
@@ -31,6 +32,7 @@ const schema = z.object({
type FormValues = z.infer<typeof schema>;
export function ForgotPasswordPage() {
const { appName } = useAuthConfig();
const [forgotTrigger, { isLoading }] = useApiMutation();
const [sentTo, setSentTo] = useState<string | null>(null);
@@ -90,7 +92,7 @@ export function ForgotPasswordPage() {
return (
<AuthShell
brandTitle="Reset your password securely."
brandSubtitle="We'll email you a secure link to set a new password and get you back into your licensing portal."
brandSubtitle={`We'll email you a secure link to set a new password and get you back into ${appName}.`}
>
<Stack gap="lg">
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
@@ -145,7 +147,7 @@ export function ForgotPasswordPage() {
return (
<AuthShell
brandTitle="Reset your password securely."
brandSubtitle="We'll email you a secure link to set a new password and get you back into your licensing portal."
brandSubtitle={`We'll email you a secure link to set a new password and get you back into ${appName}.`}
>
<Stack gap="lg">
<div>

View File

@@ -21,12 +21,13 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useAppDispatch } from '../../../store/hooks';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { LoginPayload, AuthUser } from '../types/auth.types';
import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { LoginPayload, AuthUser } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
@@ -37,7 +38,9 @@ type FormValues = z.infer<typeof schema>;
export function LoginPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const dispatch = useDispatch();
const { appName, loginRedirectPath, enableSignup, enableForgotPassword } =
useAuthConfig();
const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [loginTrigger] = useApiMutation<LoginPayload>();
@@ -68,7 +71,7 @@ export function LoginPage() {
dispatch(setUser(me));
if (me.isPhoneNumberVerified) {
navigate('/dashboard');
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: { email: me.email, phoneNumber: me.phoneNumber },
@@ -86,10 +89,10 @@ export function LoginPage() {
<Stack gap="lg">
<div>
<Title order={2} fz={30}>
Welcome back
Welcome to {appName}
</Title>
<Text c="dimmed" mt={6}>
Sign in to access your maritime licensing dashboard.
Sign in to access your account.
</Text>
</div>
@@ -119,14 +122,16 @@ export function LoginPage() {
checked={rememberMe}
onChange={(e) => setRememberMe(e.currentTarget.checked)}
/>
<Anchor
component={Link}
to="/forgot-password"
size="sm"
fw={600}
>
Forgot password?
</Anchor>
{enableForgotPassword && (
<Anchor
component={Link}
to="/forgot-password"
size="sm"
fw={600}
>
Forgot password?
</Anchor>
)}
</Group>
<Button
@@ -153,12 +158,14 @@ export function LoginPage() {
Sign in with phone number
</Button>
<Text ta="center" size="sm" c="dimmed">
Don&apos;t have an account?{' '}
<Anchor component={Link} to="/signup" fw={700}>
Create one
</Anchor>
</Text>
{enableSignup && (
<Text ta="center" size="sm" c="dimmed">
Don&apos;t have an account?{' '}
<Anchor component={Link} to="/signup" fw={700}>
Create one
</Anchor>
</Text>
)}
</Stack>
</AuthShell>
);

View File

@@ -19,6 +19,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
const CODE_LENGTH = 6;
const RESEND_SECONDS = 30;
@@ -34,6 +35,7 @@ type FormValues = z.infer<typeof schema>;
export function OTPVerificationPage() {
const navigate = useNavigate();
const location = useLocation();
const { loginRedirectPath } = useAuthConfig();
const state = location.state as
| { email?: string; phoneNumber?: string }
| null;
@@ -68,7 +70,7 @@ export function OTPVerificationPage() {
}).unwrap();
notify.success('Phone number verified successfully');
navigate('/dashboard');
navigate(loginRedirectPath);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Something went wrong';
notify.error(msg);
@@ -95,7 +97,7 @@ export function OTPVerificationPage() {
return (
<AuthShell
brandTitle="One last step to secure your account."
brandSubtitle="We use a one-time code to confirm it's really you before granting access to the licensing portal."
brandSubtitle="We use a one-time code to confirm it's really you before granting access."
>
<Stack gap="lg">
<ThemeIcon size={56} radius="md" variant="light" color="emaPrimary">
@@ -164,7 +166,7 @@ export function OTPVerificationPage() {
variant="light"
fullWidth
size="md"
onClick={() => navigate('/dashboard')}
onClick={() => navigate(loginRedirectPath)}
>
Skip verification for now
</Button>

View File

@@ -23,11 +23,12 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useAppDispatch } from '../../../store/hooks';
import { loginSuccess } from '../store/auth.slice';
import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess } from '../store/auth.slice';
import { useAuthConfig } from '../AuthConfig';
const schema = z
.object({
@@ -62,7 +63,8 @@ interface SignupPayload {
export function SignupPage() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const dispatch = useDispatch();
const { appName, loginRedirectPath } = useAuthConfig();
const [agreed, setAgreed] = useState(false);
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string;
@@ -106,7 +108,7 @@ export function SignupPage() {
);
if (data.isPhoneNumberVerified) {
navigate('/dashboard');
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
state: { email: values.email, phoneNumber: values.phoneNumber },
@@ -120,8 +122,8 @@ export function SignupPage() {
return (
<AuthShell
brandTitle="Join Ethiopia's maritime community."
brandSubtitle="Create your account to apply for licenses, manage documents, and track approvals from anywhere."
brandTitle={`Join ${appName}'s community.`}
brandSubtitle={`Create your account to access ${appName} features.`}
>
<Stack gap="lg">
<div>

View File

@@ -30,7 +30,7 @@ const authSlice = createSlice({
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser();
const user = authStorage.getUser<AuthUser>();
if (token && user) {
state.token = token;
state.user = user;

View File

@@ -0,0 +1,29 @@
let _prefix = 'ema-auth';
export function configureAuthStorage(prefix: string) {
_prefix = prefix;
}
function key(k: string) {
return `${_prefix}-${k}`;
}
export const authStorage = {
getToken: () => localStorage.getItem(key('auth-token')) ?? undefined,
setToken: (token: string) => localStorage.setItem(key('auth-token'), token),
getRefreshToken: () => localStorage.getItem(key('refresh-token')) ?? undefined,
setRefreshToken: (t: string) => localStorage.setItem(key('refresh-token'), t),
getUser: <T = unknown>(): T | null => {
try {
return JSON.parse(localStorage.getItem(key('auth-user')) ?? 'null') as T | null;
} catch {
return null;
}
},
setUser: <T>(u: T) => localStorage.setItem(key('auth-user'), JSON.stringify(u)),
clear: () => {
[key('auth-token'), key('refresh-token'), key('auth-user')].forEach((k) =>
localStorage.removeItem(k),
);
},
};

5
libs/auth/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "../../dist/out-tsc" },
"include": ["src/**/*.ts", "src/**/*.tsx"]
}

View File

@@ -31,4 +31,7 @@ export const emaTheme = createTheme({
md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)',
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},
});

View File

@@ -19,7 +19,8 @@
"paths": {
"@ema-platform/shared": ["libs/shared/src/index.ts"],
"@ema-platform/ui": ["libs/ui/src/index.ts"],
"@ema-platform/api": ["libs/api/src/index.ts"]
"@ema-platform/api": ["libs/api/src/index.ts"],
"@ema-platform/auth": ["libs/auth/src/index.ts"]
}
},
"exclude": ["node_modules", "tmp"]

View File

@@ -39,7 +39,7 @@ export const FHC_COLORS = {
"#96BDEB",
"#6FA4E0",
"#4A90E2",
"#357ABD",
"#4b7fe5",
"#2C669D",
"#224F7A",
"#173654",
@@ -120,7 +120,7 @@ export const FHC_LAYOUT = {
brick: "#1A3A5C",
brickDark: "#0F2440",
blue: "#4A90E2",
blueDark: "#357ABD",
blueDark: "#4b7fe5",
gold: "#4A90E2",
text: "#1F2937",
},
@@ -216,7 +216,7 @@ export const fhcMantineTheme: MantineThemeOverride = {
* color while still getting the fhc* palettes + layout tokens via `mantineTheme`.
*/
export const fhcDesignPreset = {
colors: { primary: "#357ABD" },
colors: { primary: "#4b7fe5" },
typography: { fontFamily: "Plus Jakarta Sans, sans-serif" },
shape: { radius: "10px" },
mantineTheme: fhcMantineTheme,

View File

@@ -40,7 +40,7 @@ export const projectTheme: DesignConfig = {
// still have different colors.
// ─────────────────────────────────────────────────────────────────────────
colors: {
primary: "#357ABD", // FHC blue (fhcBlue-6) — buttons, links, active states
primary: "#4b7fe5", // FHC blue (fhcBlue-6) — buttons, links, active states
// // "#5D2E1F" brick (FHC chrome) is used by the sidebar/modal skin below
// // "#2563eb" blue | "#7c3aed" purple
// // "#16a34a" green | "#dc2626" red
@@ -174,18 +174,18 @@ export const projectTheme: DesignConfig = {
// ── Top tab bar skin (legacy view) ─────────────────────────────────────
menuBackground: "#ffffff", // TODO: tab bar background
menuColor: "#334155", // inactive tab text
menuActiveColor: "#357ABD", // active tab text (FHC blue)
menuActiveBorderColor: "#357ABD", // active tab underline
menuHoverColor: "#357ABD", // tab hover text
menuActiveColor: "#4b7fe5", // active tab text (FHC blue)
menuActiveBorderColor: "#4b7fe5", // active tab underline
menuHoverColor: "#4b7fe5", // tab hover text
// ── Create / edit modal skin (shared BackofficeModal) ──────────────────
modalAccentColor: "#357ABD", // blue top strip
modalAccentColor: "#4b7fe5", // blue top strip
modalHeaderBackground: "#EEF4FC", // header bg (view)
modalHeaderEditBackground: "#D9E8FA", // header bg (edit)
modalIconBackground: "#D9E8FA", // header icon chip bg
modalIconColor: "#357ABD", // header icon chip color
modalIconColor: "#4b7fe5", // header icon chip color
modalTitleColor: "#1F2937", // modal title text
modalFocusColor: "#357ABD", // input focus ring inside modals
modalFocusColor: "#4b7fe5", // input focus ring inside modals
modalSurface: "#ffffff", // modal body surface
},