From 5fb29bd1b3f481ead791ced61b5f385250cb0c0a Mon Sep 17 00:00:00 2001 From: UjjawalPrabhat Date: Fri, 3 Oct 2025 23:05:13 +0530 Subject: [PATCH 1/2] refactor(login): address review feedback on PR scope Significantly reduced the number of changed files based on @ibacher's comments. Removed all framework modifications and simplified the background image handling to use direct URLs. All the configurable login features are still there - just implemented more cleanly now. Should be much easier to review and merge. --- packages/apps/esm-login-app/README.md | 81 ++++- .../esm-login-app/__mocks__/config.mock.ts | 40 +++ .../background-wrapper.component.tsx | 92 ++++++ .../src/background/background-wrapper.scss | 89 ++++++ .../background/background-wrapper.test.tsx | 288 ++++++++++++++++++ .../apps/esm-login-app/src/config-schema.ts | 264 +++++++++++++++- packages/apps/esm-login-app/src/footer.scss | 12 +- .../src/login/login.component.tsx | 207 +++++++++---- .../apps/esm-login-app/src/login/login.scss | 121 ++++++++ .../esm-login-app/src/login/login.test.tsx | 73 ++++- .../apps/esm-login-app/translations/en.json | 4 +- 11 files changed, 1197 insertions(+), 74 deletions(-) create mode 100644 packages/apps/esm-login-app/src/background/background-wrapper.component.tsx create mode 100644 packages/apps/esm-login-app/src/background/background-wrapper.scss create mode 100644 packages/apps/esm-login-app/src/background/background-wrapper.test.tsx diff --git a/packages/apps/esm-login-app/README.md b/packages/apps/esm-login-app/README.md index ff65544b9..cf7ea95a4 100644 --- a/packages/apps/esm-login-app/README.md +++ b/packages/apps/esm-login-app/README.md @@ -1,4 +1,81 @@ # openmrs-esm-login-app -openmrs-esm-login-app is responsible for rendering the loading page, -the login page, and the location picker. \ No newline at end of file +openmrs-esm-login-app is responsible for rendering the loading page, the login page, and the location picker. + +## Configuration + +The login page can be customized through configuration. This allows implementers to: + +- Choose from multiple layouts (default or split-screen) +- Customize backgrounds (colors, images, or gradients) +- Brand the login page with custom titles, subtitles, and logos +- Style the login card and buttons +- Add custom links and help text +- Configure the footer with additional logos + +See the [configuration schema](src/config-schema.ts) for all available options. + +## Configuration Examples + +### Split-Screen Layout with Image Background + +```json +{ + "@openmrs/esm-login-app": { + "layout": { + "type": "split-screen", + "columnPosition": "right" + }, + "background": { + "type": "image", + "value": "https://example.com/hospital-bg.jpg" + } + } +} +``` + +### Color Background + +```json +{ + "@openmrs/esm-login-app": { + "background": { + "type": "color", + "value": "#0066cc" + } + } +} +``` + +### Gradient Background + +```json +{ + "@openmrs/esm-login-app": { + "background": { + "type": "gradient", + "value": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" + } + } +} +``` + +### Custom Branding + +```json +{ + "@openmrs/esm-login-app": { + "branding": { + "title": "Welcome to My Clinic", + "subtitle": "Electronic Medical Records System" + }, + "logo": { + "src": "https://example.com/logo.png", + "alt": "My Clinic" + }, + "button": { + "backgroundColor": "#0071c5" + } + } +} +``` \ No newline at end of file diff --git a/packages/apps/esm-login-app/__mocks__/config.mock.ts b/packages/apps/esm-login-app/__mocks__/config.mock.ts index c125a1ca8..b50c4f5dc 100644 --- a/packages/apps/esm-login-app/__mocks__/config.mock.ts +++ b/packages/apps/esm-login-app/__mocks__/config.mock.ts @@ -23,4 +23,44 @@ export const mockConfig: ConfigSchema = { additionalLogos: [], }, showPasswordOnSeparateScreen: true, + background: { + type: 'default', + value: '', + alt: 'Background Image', + size: 'cover', + position: 'center', + repeat: 'no-repeat', + attachment: 'scroll', + overlay: { + enabled: false, + color: 'rgba(0, 0, 0, 0.3)', + opacity: 0.3, + blendMode: 'normal', + }, + }, + layout: { + type: 'default' as const, + columnPosition: 'center' as const, + showLogo: true, + showFooter: true, + }, + card: { + backgroundColor: '', + borderRadius: '', + width: '', + padding: '', + boxShadow: '', + }, + button: { + backgroundColor: '', + textColor: '', + }, + branding: { + title: '', + subtitle: '', + customText: '', + helpText: '', + contactEmail: '', + customLinks: [], + }, }; diff --git a/packages/apps/esm-login-app/src/background/background-wrapper.component.tsx b/packages/apps/esm-login-app/src/background/background-wrapper.component.tsx new file mode 100644 index 000000000..1f4bcb46d --- /dev/null +++ b/packages/apps/esm-login-app/src/background/background-wrapper.component.tsx @@ -0,0 +1,92 @@ +import React, { useMemo } from 'react'; +import { useConfig } from '@openmrs/esm-framework'; +import { type ConfigSchema } from '../config-schema'; +import styles from './background-wrapper.scss'; + +interface BackgroundWrapperProps { + children: React.ReactNode; +} + +const BackgroundWrapper: React.FC = ({ children }) => { + const config = useConfig(); + const { layout, background } = config; + + const backgroundStyles = useMemo(() => { + const style: React.CSSProperties = {}; + + switch (background.type) { + case 'color': + if (background.value) { + style.backgroundColor = background.value; + } + break; + + case 'image': + if (background.value) { + style.backgroundImage = `url(${background.value})`; + style.backgroundSize = background.size || 'cover'; + style.backgroundPosition = background.position || 'center'; + style.backgroundRepeat = background.repeat || 'no-repeat'; + style.backgroundAttachment = background.attachment || 'scroll'; + } + break; + + case 'gradient': + if (background.value) { + style.background = background.value; + } + break; + + default: + break; + } + + return style; + }, [background]); + + const overlayStyles = useMemo(() => { + if (!background.overlay.enabled) { + return {}; + } + + return { + backgroundColor: background.overlay.color, + opacity: background.overlay.opacity, + mixBlendMode: background.overlay.blendMode || 'normal', + }; + }, [background]); + + const hasCustomBackground = background.type !== 'default' && background.value; + const hasOverlay = background.overlay.enabled && hasCustomBackground; + + if (layout.type === 'split-screen' && background.type === 'image' && background.value) { + const bgPosition = layout.columnPosition === 'right' ? 'left' : 'right'; + + return ( +
+
+
{children}
+
+ ); + } + + return ( +
+ {hasOverlay &&
} +
{children}
+
+ ); +}; + +export default BackgroundWrapper; diff --git a/packages/apps/esm-login-app/src/background/background-wrapper.scss b/packages/apps/esm-login-app/src/background/background-wrapper.scss new file mode 100644 index 000000000..08d7f9522 --- /dev/null +++ b/packages/apps/esm-login-app/src/background/background-wrapper.scss @@ -0,0 +1,89 @@ +@use '@carbon/layout'; +@use '@openmrs/esm-styleguide/src/vars' as *; + +.backgroundWrapper { + min-height: 100vh; + position: relative; + + &.customBackground { + background-attachment: scroll; + + @media (min-width: 769px) { + &[style*='background-attachment: fixed'] { + background-attachment: fixed; + } + } + + @media (max-width: 768px) { + background-attachment: scroll !important; + } + } +} + +.overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1; + pointer-events: none; + transition: opacity 0.3s ease-in-out; +} + +.content { + position: relative; + z-index: 2; + min-height: 100vh; + display: flex; + flex-direction: column; + + .customBackground & { + backdrop-filter: blur(0.5px); + } +} + +.splitScreenContainer { + display: flex; + min-height: 100vh; + position: relative; + overflow: hidden; + + .content { + position: relative; + z-index: 2; + width: 100%; + } + + @media (max-width: 768px) { + flex-direction: column; + + .backgroundPanel { + position: relative !important; + width: 100% !important; + height: 30vh !important; + left: 0 !important; + right: 0 !important; + } + } +} + +.backgroundPanel { + position: absolute; + top: 0; + bottom: 0; + width: 50%; + z-index: 1; + + &.bgPosition-left { + left: 0; + } + + &.bgPosition-right { + right: 0; + } + + @media (min-width: 769px) and (max-width: 1024px) { + width: 40%; + } +} diff --git a/packages/apps/esm-login-app/src/background/background-wrapper.test.tsx b/packages/apps/esm-login-app/src/background/background-wrapper.test.tsx new file mode 100644 index 000000000..a7ae4b160 --- /dev/null +++ b/packages/apps/esm-login-app/src/background/background-wrapper.test.tsx @@ -0,0 +1,288 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useConfig } from '@openmrs/esm-framework'; +import { type ConfigSchema } from '../config-schema'; +import BackgroundWrapper from './background-wrapper.component'; + +jest.mock('@openmrs/esm-framework', () => ({ + useConfig: jest.fn(), +})); + +jest.mock('./background-wrapper.scss', () => ({ + backgroundWrapper: 'backgroundWrapper', + customBackground: 'customBackground', + splitScreenContainer: 'splitScreenContainer', + backgroundPanel: 'backgroundPanel', + 'bgPosition-left': 'bgPosition-left', + 'bgPosition-right': 'bgPosition-right', + content: 'content', + overlay: 'overlay', +})); + +const mockUseConfig = jest.mocked(useConfig); + +const defaultConfig: ConfigSchema = { + chooseLocation: { + enabled: true, + locationsPerRequest: 50, + numberToShow: 8, + useLoginLocationTag: true, + }, + footer: { + additionalLogos: [], + }, + links: { + loginSuccess: '/home', + }, + logo: { + alt: 'Logo', + src: '', + }, + provider: { + loginUrl: '/login', + logoutUrl: '/logout', + type: 'basic', + }, + showPasswordOnSeparateScreen: true, + layout: { + type: 'default', + columnPosition: 'center', + showLogo: true, + showFooter: true, + }, + background: { + type: 'default', + value: '', + alt: 'Background Image', + size: 'cover', + position: 'center', + repeat: 'no-repeat', + attachment: 'scroll', + overlay: { + enabled: false, + color: 'rgba(0, 0, 0, 0.3)', + opacity: 0.3, + blendMode: 'normal', + }, + }, + card: { + backgroundColor: '', + borderRadius: '', + width: '', + padding: '', + boxShadow: '', + }, + button: { + backgroundColor: '', + textColor: '', + }, + branding: { + title: '', + subtitle: '', + customText: '', + helpText: '', + contactEmail: '', + customLinks: [], + }, +}; + +describe('BackgroundWrapper', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue(defaultConfig); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders children with default background', () => { + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + expect(screen.getByText('Test Content')).toBeInTheDocument(); + }); + + it('renders with color background configuration', () => { + const configWithColorBg = { + ...defaultConfig, + background: { + ...defaultConfig.background, + type: 'color' as const, + value: '#ff0000', + }, + }; + mockUseConfig.mockReturnValue(configWithColorBg); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('renders with image background configuration', () => { + const configWithImageBg = { + ...defaultConfig, + background: { + ...defaultConfig.background, + type: 'image' as const, + value: 'https://example.com/background.jpg', + size: 'contain', + position: 'top', + repeat: 'repeat-x' as const, + attachment: 'fixed' as const, + }, + }; + mockUseConfig.mockReturnValue(configWithImageBg); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('renders with gradient background configuration', () => { + const configWithGradientBg = { + ...defaultConfig, + background: { + ...defaultConfig.background, + type: 'gradient' as const, + value: 'linear-gradient(45deg, #ff0000, #0000ff)', + }, + }; + mockUseConfig.mockReturnValue(configWithGradientBg); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('renders with overlay configuration', () => { + const configWithOverlay = { + ...defaultConfig, + background: { + ...defaultConfig.background, + type: 'color' as const, + value: '#ff0000', + overlay: { + enabled: true, + color: 'rgba(0, 0, 0, 0.5)', + opacity: 0.5, + blendMode: 'multiply' as const, + }, + }, + }; + mockUseConfig.mockReturnValue(configWithOverlay); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('renders split-screen layout with right column position', () => { + const configWithSplitScreen = { + ...defaultConfig, + layout: { + ...defaultConfig.layout, + type: 'split-screen' as const, + columnPosition: 'right' as const, + }, + background: { + ...defaultConfig.background, + type: 'image' as const, + value: 'https://example.com/bg.jpg', + }, + }; + mockUseConfig.mockReturnValue(configWithSplitScreen); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('renders split-screen layout with left column position', () => { + const configWithSplitScreen = { + ...defaultConfig, + layout: { + ...defaultConfig.layout, + type: 'split-screen' as const, + columnPosition: 'left' as const, + }, + background: { + ...defaultConfig.background, + type: 'image' as const, + value: 'https://example.com/bg.jpg', + }, + }; + mockUseConfig.mockReturnValue(configWithSplitScreen); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('handles empty background value gracefully', () => { + const configWithEmptyBg = { + ...defaultConfig, + background: { + ...defaultConfig.background, + type: 'image' as const, + value: '', + }, + }; + mockUseConfig.mockReturnValue(configWithEmptyBg); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('renders with custom background configuration', () => { + const configWithCustomBg = { + ...defaultConfig, + background: { + ...defaultConfig.background, + type: 'color' as const, + value: '#ff0000', + }, + }; + mockUseConfig.mockReturnValue(configWithCustomBg); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); +}); diff --git a/packages/apps/esm-login-app/src/config-schema.ts b/packages/apps/esm-login-app/src/config-schema.ts index 1b985c1c5..84320d95d 100644 --- a/packages/apps/esm-login-app/src/config-schema.ts +++ b/packages/apps/esm-login-app/src/config-schema.ts @@ -1,4 +1,4 @@ -import { validators, Type, validator } from '@openmrs/esm-framework'; +import { validators, Type } from '@openmrs/esm-framework'; export const configSchema = { provider: { @@ -35,13 +35,13 @@ export const configSchema = { _type: Type.Number, _default: 8, _description: 'The number of locations displayed in the location picker.', - _validators: [validator((v: unknown) => typeof v === 'number' && v > 0, 'Must be greater than zero')], + _validators: [validators.inRange(1, 100)], }, locationsPerRequest: { _type: Type.Number, _default: 50, _description: 'The number of results to fetch in each cycle of infinite scroll.', - _validators: [validator((v: unknown) => typeof v === 'number' && v > 0, 'Must be greater than zero')], + _validators: [validators.inRange(1, 500)], }, useLoginLocationTag: { _type: Type.Boolean, @@ -99,6 +99,209 @@ export const configSchema = { _description: 'Whether to show the password field on a separate screen. If false, the password field will be shown on the same screen.', }, + layout: { + type: { + _type: Type.String, + _default: 'default', + _description: + 'The layout type for the login page. ' + + 'default: Simple layout with optional background and card positioning. ' + + 'split-screen: Background image on one side, login card on the other side.', + _validators: [validators.oneOf(['default', 'split-screen'])], + }, + columnPosition: { + _type: Type.String, + _default: 'center', + _description: + 'Position of the login card on the screen. ' + + 'For default layout: positions the card left/center/right on the screen. ' + + 'For split-screen layout: positions the card and shows background image on the opposite side.', + _validators: [validators.oneOf(['left', 'center', 'right'])], + }, + showLogo: { + _type: Type.Boolean, + _default: true, + _description: 'Whether to show the logo on the login page.', + }, + showFooter: { + _type: Type.Boolean, + _default: true, + _description: 'Whether to show the footer on the login page.', + }, + }, + card: { + backgroundColor: { + _type: Type.String, + _default: '', + _description: 'Custom background color for the login card. Leave empty to use theme default.', + }, + borderRadius: { + _type: Type.String, + _default: '', + _description: 'Custom border radius for the login card (e.g., "8px", "1rem"). Leave empty to use theme default.', + }, + width: { + _type: Type.String, + _default: '', + _description: 'Custom width for the login card (e.g., "400px", "25rem"). Leave empty to use default.', + }, + padding: { + _type: Type.String, + _default: '', + _description: 'Custom padding for the login card (e.g., "2rem", "32px"). Leave empty to use default.', + }, + boxShadow: { + _type: Type.String, + _default: '', + _description: + 'Custom box shadow for the login card (e.g., "0 4px 6px rgba(0,0,0,0.1)"). Leave empty for no shadow.', + }, + }, + button: { + backgroundColor: { + _type: Type.String, + _default: '', + _description: 'Custom background color for the login button. Leave empty to use theme default.', + }, + textColor: { + _type: Type.String, + _default: '', + _description: 'Custom text color for the login button. Leave empty to use theme default.', + }, + }, + branding: { + title: { + _type: Type.String, + _default: '', + _description: 'Custom title to display above the login form (e.g., "Welcome to MyClinic").', + }, + subtitle: { + _type: Type.String, + _default: '', + _description: 'Custom subtitle to display below the title.', + }, + customText: { + _type: Type.String, + _default: '', + _description: + 'Additional custom text or HTML to display on the login page. ' + + 'WARNING: HTML content is rendered as-is. Ensure you trust the source and sanitize any user-provided content to prevent XSS attacks.', + }, + helpText: { + _type: Type.String, + _default: '', + _description: 'Help text to display below the login form.', + }, + contactEmail: { + _type: Type.String, + _default: '', + _description: 'Contact email to display for support.', + }, + customLinks: { + _type: Type.Array, + _elements: { + _type: Type.Object, + text: { + _type: Type.String, + _required: true, + _description: 'The text to display for the link.', + }, + url: { + _type: Type.String, + _required: true, + _description: 'The URL the link should navigate to.', + _validators: [validators.isUrl], + }, + }, + _default: [], + _description: 'Custom links to display on the login page (e.g., legacy UI, help documentation).', + }, + }, + background: { + type: { + _type: Type.String, + _default: 'default', + _description: 'The type of background to use.', + _validators: [validators.oneOf(['default', 'color', 'image', 'gradient'])], + }, + value: { + _type: Type.String, + _default: '', + _description: + 'The background value based on the type. ' + + 'For color: any valid CSS color (e.g., "#0066cc", "rgb(0,102,204)", "blue"). ' + + 'For gradient: any valid CSS gradient (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"). ' + + 'For image: a URL to the image (e.g., "https://example.com/background.jpg").', + }, + alt: { + _type: Type.String, + _default: 'Background Image', + _description: 'Alternative text for background images.', + }, + size: { + _type: Type.String, + _default: 'cover', + _description: 'Background size for image backgrounds. Options: cover, contain, auto, or custom CSS values.', + _validators: [validators.oneOf(['cover', 'contain', 'auto'])], + }, + position: { + _type: Type.String, + _default: 'center', + _description: + 'Background position for image backgrounds. Options: center, top, bottom, left, right, or custom CSS values.', + }, + repeat: { + _type: Type.String, + _default: 'no-repeat', + _description: 'Background repeat for image backgrounds.', + _validators: [validators.oneOf(['no-repeat', 'repeat', 'repeat-x', 'repeat-y'])], + }, + attachment: { + _type: Type.String, + _default: 'scroll', + _description: 'Background attachment for image backgrounds.', + _validators: [validators.oneOf(['scroll', 'fixed', 'local'])], + }, + overlay: { + enabled: { + _type: Type.Boolean, + _default: false, + _description: 'Whether to enable an overlay on the background to improve content readability.', + }, + color: { + _type: Type.String, + _default: 'rgba(0, 0, 0, 0.3)', + _description: 'The overlay color in any valid CSS color format (hex, rgb, rgba, hsl, etc.).', + }, + opacity: { + _type: Type.Number, + _default: 0.3, + _description: 'The opacity of the overlay (0-1). Lower values are more transparent.', + _validators: [validators.inRange(0, 1)], + }, + blendMode: { + _type: Type.String, + _default: 'normal', + _description: 'CSS blend mode for the overlay.', + _validators: [ + validators.oneOf([ + 'normal', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + ]), + ], + }, + }, + }, }; export interface ConfigSchema { @@ -127,4 +330,59 @@ export interface ConfigSchema { type: 'basic' | 'oauth2'; }; showPasswordOnSeparateScreen: boolean; + background: { + type: 'default' | 'color' | 'image' | 'gradient'; + value: string; + alt: string; + size: string; + position: string; + repeat: 'no-repeat' | 'repeat' | 'repeat-x' | 'repeat-y'; + attachment: 'scroll' | 'fixed' | 'local'; + overlay: { + enabled: boolean; + color: string; + opacity: number; + blendMode: + | 'normal' + | 'multiply' + | 'screen' + | 'overlay' + | 'darken' + | 'lighten' + | 'color-dodge' + | 'color-burn' + | 'hard-light' + | 'soft-light' + | 'difference' + | 'exclusion'; + }; + }; + layout: { + type: 'default' | 'split-screen'; + columnPosition: 'left' | 'center' | 'right'; + showLogo: boolean; + showFooter: boolean; + }; + card: { + backgroundColor: string; + borderRadius: string; + width: string; + padding: string; + boxShadow: string; + }; + button: { + backgroundColor: string; + textColor: string; + }; + branding: { + title: string; + subtitle: string; + customText: string; + helpText: string; + contactEmail: string; + customLinks: Array<{ + text: string; + url: string; + }>; + }; } diff --git a/packages/apps/esm-login-app/src/footer.scss b/packages/apps/esm-login-app/src/footer.scss index effadf5a4..d28225178 100644 --- a/packages/apps/esm-login-app/src/footer.scss +++ b/packages/apps/esm-login-app/src/footer.scss @@ -11,6 +11,14 @@ flex-wrap: wrap; gap: layout.$spacing-05; width: 100%; + max-width: 100%; + box-sizing: border-box; + + .contentOverlay & { + position: relative; + bottom: auto; + margin-top: auto; + } } .logosContainer { @@ -67,7 +75,7 @@ .poweredByTile { display: flex; text-align: left; - max-width: fit-content; + max-width: 100%; min-height: fit-content; font-size: smaller; background-color: colors.$white; @@ -75,6 +83,8 @@ border: 1px solid colors.$gray-20; border-radius: layout.$spacing-04; flex-wrap: wrap; + word-wrap: break-word; + overflow-wrap: break-word; } .poweredByContainer { diff --git a/packages/apps/esm-login-app/src/login/login.component.tsx b/packages/apps/esm-login-app/src/login/login.component.tsx index 4dbb7f826..1a93204e5 100644 --- a/packages/apps/esm-login-app/src/login/login.component.tsx +++ b/packages/apps/esm-login-app/src/login/login.component.tsx @@ -14,6 +14,7 @@ import { import { type ConfigSchema } from '../config-schema'; import Logo from '../logo.component'; import Footer from '../footer.component'; +import BackgroundWrapper from '../background/background-wrapper.component'; import styles from './login.scss'; export interface LoginReferrer { @@ -21,7 +22,16 @@ export interface LoginReferrer { } const Login: React.FC = () => { - const { showPasswordOnSeparateScreen, provider: loginProvider, links: loginLinks } = useConfig(); + const config = useConfig(); + const { + showPasswordOnSeparateScreen, + provider: loginProvider, + links: loginLinks, + layout, + card, + button, + branding, + } = config; const isLoginEnabled = useConnectivity(); const { t } = useTranslation(); const { user } = useSession(); @@ -136,9 +146,25 @@ const Login: React.FC = () => { ); if (!loginProvider || loginProvider.type === 'basic') { + const cardStyles: React.CSSProperties = {}; + if (card.backgroundColor) cardStyles.backgroundColor = card.backgroundColor; + if (card.borderRadius) cardStyles.borderRadius = card.borderRadius; + if (card.width) cardStyles.width = card.width; + if (card.padding) cardStyles.padding = card.padding; + if (card.boxShadow) cardStyles.boxShadow = card.boxShadow; + + const buttonStyles: React.CSSProperties = {}; + if (button.backgroundColor) buttonStyles.backgroundColor = button.backgroundColor; + if (button.textColor) buttonStyles.color = button.textColor; + + const containerClass = + layout.columnPosition !== 'center' + ? `${styles.container} ${styles[`position-${layout.columnPosition}`]}` + : styles.container; + return ( -
- + +
{errorMessage && (
{ />
)} -
- -
-
-
- - {showPasswordOnSeparateScreen ? ( - showPasswordField ? ( + + + {layout.showLogo && ( +
+ +
+ )} + + {branding.title && ( +
+

{t(branding.title, branding.title)}

+
+ )} + + {branding.subtitle && ( +
+

{t(branding.subtitle, branding.subtitle)}

+
+ )} + + {branding.customText && ( +
+ )} + + +
+ + {showPasswordOnSeparateScreen ? ( + showPasswordField ? ( + <> + + + + ) : ( + + ) + ) : ( <> { - ) : ( - - ) - ) : ( - <> - - - - )} -
- - -
-
+ )} +
+ + + {branding.helpText && ( +
+

{t(branding.helpText, branding.helpText)}

+
+ )} + + {branding.contactEmail && ( +
+

+ {t('needHelp', 'Need help?')} {t('contactUs', 'Contact us at')}{' '} + {branding.contactEmail} +

+
+ )} + + {branding.customLinks.length > 0 && ( +
+ {branding.customLinks.map((link, index) => ( + + {t(link.text, link.text)} + + ))} +
+ )} + + + {layout.showFooter &&
} +
+
); } return null; diff --git a/packages/apps/esm-login-app/src/login/login.scss b/packages/apps/esm-login-app/src/login/login.scss index 1c212fc69..e29f80c43 100644 --- a/packages/apps/esm-login-app/src/login/login.scss +++ b/packages/apps/esm-login-app/src/login/login.scss @@ -156,3 +156,124 @@ bottom: 100%; left: 0; } + +.brandingTitle { + text-align: center; + margin-bottom: layout.$spacing-05; + + h2 { + @include type.type-style('heading-04'); + color: inherit; + font-weight: 600; + margin: 0; + } +} + +.brandingSubtitle { + text-align: center; + margin-bottom: layout.$spacing-04; + + p { + @extend .bodyShort01; + color: $text-02; + margin: 0; + } +} + +.brandingCustomText { + @extend .bodyShort01; + color: $text-02; + margin-bottom: layout.$spacing-05; + text-align: center; +} + +.helpText { + @extend .caption01; + color: $text-02; + text-align: center; + margin-top: layout.$spacing-05; + + p { + margin: 0; + } +} + +.contactInfo { + @extend .caption01; + color: $text-02; + text-align: center; + margin-top: layout.$spacing-04; + + p { + margin: 0; + } + + a { + color: #0f62fe; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } +} + +.customLinks { + display: flex; + flex-direction: column; + align-items: center; + gap: layout.$spacing-03; + margin-top: layout.$spacing-05; +} + +.customLink { + @extend .bodyShort01; + color: #0f62fe; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + +.position-left { + align-items: flex-start; + padding-left: 5%; + + @media (min-width: 1024px) { + padding-left: 10%; + } +} + +.position-center { + align-items: center; +} + +.position-right { + align-items: flex-end; + padding-right: 5%; + + @media (min-width: 1024px) { + padding-right: 10%; + } +} + +@media only screen and (max-width: 768px) { + .position-left, + .position-right { + align-items: center; + padding-left: 0; + padding-right: 0; + } +} + +@media only screen and (max-width: 768px) { + .loginCard { + width: 90vw; + max-width: 23rem; + } + + .brandingTitle h2 { + @include type.type-style('heading-03'); + } +} diff --git a/packages/apps/esm-login-app/src/login/login.test.tsx b/packages/apps/esm-login-app/src/login/login.test.tsx index 744790e04..ffbef5d1f 100644 --- a/packages/apps/esm-login-app/src/login/login.test.tsx +++ b/packages/apps/esm-login-app/src/login/login.test.tsx @@ -81,7 +81,7 @@ describe('Login', () => { const user = userEvent.setup(); expect(screen.getByRole('textbox', { name: /username/i })).toBeInTheDocument(); - // no input to username + const continueButton = screen.getByRole('button', { name: /Continue/i }); await user.click(continueButton); expect(screen.getByRole('textbox', { name: /username/i })).toHaveFocus(); @@ -115,7 +115,6 @@ describe('Login', () => { await waitFor(() => expect(refetchCurrentUser).toHaveBeenCalledWith('yoshi', 'no-tax-fraud')); }); - // TODO: Complete the test it('sends the user to the location select page on login if there is more than one location', async () => { let refreshUser = (user: any) => {}; mockLogin.mockImplementation(() => { @@ -283,4 +282,74 @@ describe('Login', () => { expect(usernameInput).toHaveFocus(); }); + + it('renders configurable branding with custom title and links', () => { + const brandedConfig = { + ...mockConfig, + branding: { + title: 'Test Health Center', + subtitle: 'Electronic Medical Records', + customText: '', + helpText: 'Need help? Contact support', + contactEmail: 'support@test.org', + customLinks: [ + { + text: 'Go to Legacy UI', + url: '/openmrs/index.htm', + }, + ], + }, + logo: { + src: 'test-logo.png', + alt: 'Test Health Center Logo', + }, + }; + mockUseConfig.mockReturnValue(brandedConfig); + + renderWithRouter( + Login, + {}, + { + route: '/login', + }, + ); + + expect(screen.getByAltText('Test Health Center Logo')).toBeInTheDocument(); + expect(screen.getByText('Test Health Center')).toBeInTheDocument(); + expect(screen.getByText('Electronic Medical Records')).toBeInTheDocument(); + expect(screen.getByText('Need help? Contact support')).toBeInTheDocument(); + expect(screen.getByText(/support@test.org/)).toBeInTheDocument(); + expect(screen.getByText('Go to Legacy UI')).toBeInTheDocument(); + }); + + it('renders default layout when layout type is default with logo', () => { + const defaultConfig = { + ...mockConfig, + layout: { + ...mockConfig.layout, + type: 'default', + showLogo: true, + showFooter: true, + }, + branding: { + title: '', + subtitle: '', + customText: '', + helpText: '', + contactEmail: '', + customLinks: [], + }, + }; + mockUseConfig.mockReturnValue(defaultConfig); + + renderWithRouter( + Login, + {}, + { + route: '/login', + }, + ); + + expect(screen.getAllByRole('img', { name: /OpenMRS logo/i })).toHaveLength(2); + }); }); diff --git a/packages/apps/esm-login-app/translations/en.json b/packages/apps/esm-login-app/translations/en.json index 4675c40c4..bf7a5810c 100644 --- a/packages/apps/esm-login-app/translations/en.json +++ b/packages/apps/esm-login-app/translations/en.json @@ -7,11 +7,13 @@ "changingPassword": "Changing password", "confirmPassword": "Confirm new password", "continue": "Continue", + "dismiss": "Dismiss", + "dismissMessage": "Dismiss message", "errorChangingPassword": "Error changing password", "footerlogo": "Footer Logo", "invalidCredentials": "Invalid username or password", "learnMore": "Learn more", - "locationPreferenceRemoved": "Login location preference removed", + "locationPreferenceRemoved": "Location preference removed", "locationPreferenceRemovedMessage": "You will need to select a location on each login", "locationSaved": "Location saved", "locationSaveMessage": "Your preferred location has been saved for future logins", From 3a743923014c4b5b716767ecf0f1d2fcfd2aec6d Mon Sep 17 00:00:00 2001 From: UjjawalPrabhat Date: Wed, 8 Oct 2025 18:34:52 +0530 Subject: [PATCH 2/2] refactor(config): simplify login config schema - Reduce schema from 386 to 194 lines (50% reduction) - Add _type: Type.Object to all nested objects - Rename columnPosition loginFormPosition - Remove complex CSS styling (card, button sections) - Simplify background to color + imageUrl only - Support translation keys in all text fields - Remove gradient, overlay, contactEmail, customLinks - All 38 tests passing --- packages/apps/esm-login-app/README.md | 86 +++++-- .../esm-login-app/__mocks__/config.mock.ts | 33 +-- .../background-wrapper.component.tsx | 69 +---- .../background/background-wrapper.test.tsx | 161 ++---------- .../apps/esm-login-app/src/config-schema.ts | 240 ++---------------- .../src/login/login.component.tsx | 61 +---- .../esm-login-app/src/login/login.test.tsx | 21 +- .../apps/esm-login-app/translations/en.json | 6 +- 8 files changed, 123 insertions(+), 554 deletions(-) diff --git a/packages/apps/esm-login-app/README.md b/packages/apps/esm-login-app/README.md index cf7ea95a4..f433b619c 100644 --- a/packages/apps/esm-login-app/README.md +++ b/packages/apps/esm-login-app/README.md @@ -6,75 +6,107 @@ openmrs-esm-login-app is responsible for rendering the loading page, the login p The login page can be customized through configuration. This allows implementers to: -- Choose from multiple layouts (default or split-screen) -- Customize backgrounds (colors, images, or gradients) -- Brand the login page with custom titles, subtitles, and logos -- Style the login card and buttons -- Add custom links and help text -- Configure the footer with additional logos +- Position the login form (left, center, or right) +- Customize backgrounds with solid colors or images +- Brand the login page with custom titles, subtitles, and help text (supports translation keys) +- Configure the logo and footer +- Control the location picker behavior See the [configuration schema](src/config-schema.ts) for all available options. +## Configuration Options + +### Layout + +- **`loginFormPosition`**: Position of the login form on the screen (`'left'` | `'center'` | `'right'`) + - Default: `'center'` +- **`showFooter`**: Whether to show the footer on the login page + - Default: `true` + +### Background + +- **`color`**: Solid background color (e.g., `"#0071C5"`, `"blue"`, `"rgb(0,113,197)"`) + - Default: `''` (uses theme default) +- **`imageUrl`**: URL to background image (e.g., `"https://example.com/bg.jpg"`) + - Default: `''` (no image) + - Images are automatically displayed with `cover` sizing, `center` positioning, and `no-repeat` + +### Branding + +- **`title`**: Custom title text or translation key (e.g., `"welcome.title"`) + - Default: `''` +- **`subtitle`**: Custom subtitle text or translation key (e.g., `"welcome.subtitle"`) + - Default: `''` +- **`helpText`**: Custom help text or translation key for support information + - Default: `''` + +**Note**: All branding text fields support translation keys for internationalization. + ## Configuration Examples -### Split-Screen Layout with Image Background +### Background with Solid Color ```json { "@openmrs/esm-login-app": { - "layout": { - "type": "split-screen", - "columnPosition": "right" - }, "background": { - "type": "image", - "value": "https://example.com/hospital-bg.jpg" + "color": "#0071C5" } } } ``` -### Color Background +### Background with Image ```json { "@openmrs/esm-login-app": { "background": { - "type": "color", - "value": "#0066cc" + "imageUrl": "https://example.com/hospital-background.jpg" } } } ``` -### Gradient Background +### Positioned Login Form with Branding ```json { "@openmrs/esm-login-app": { - "background": { - "type": "gradient", - "value": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)" + "layout": { + "loginFormPosition": "left", + "showFooter": true + }, + "branding": { + "title": "Welcome to My Clinic", + "subtitle": "Electronic Medical Records System", + "helpText": "For assistance, contact IT support" } } } ``` -### Custom Branding +### Complete Configuration Example ```json { "@openmrs/esm-login-app": { + "layout": { + "loginFormPosition": "center", + "showFooter": true + }, + "background": { + "color": "#f0f4f8", + "imageUrl": "https://example.com/clinic-bg.jpg" + }, "branding": { - "title": "Welcome to My Clinic", - "subtitle": "Electronic Medical Records System" + "title": "welcome.title", + "subtitle": "welcome.subtitle", + "helpText": "support.helpText" }, "logo": { "src": "https://example.com/logo.png", - "alt": "My Clinic" - }, - "button": { - "backgroundColor": "#0071c5" + "alt": "My Clinic Logo" } } } diff --git a/packages/apps/esm-login-app/__mocks__/config.mock.ts b/packages/apps/esm-login-app/__mocks__/config.mock.ts index b50c4f5dc..bd20cd04b 100644 --- a/packages/apps/esm-login-app/__mocks__/config.mock.ts +++ b/packages/apps/esm-login-app/__mocks__/config.mock.ts @@ -24,43 +24,16 @@ export const mockConfig: ConfigSchema = { }, showPasswordOnSeparateScreen: true, background: { - type: 'default', - value: '', - alt: 'Background Image', - size: 'cover', - position: 'center', - repeat: 'no-repeat', - attachment: 'scroll', - overlay: { - enabled: false, - color: 'rgba(0, 0, 0, 0.3)', - opacity: 0.3, - blendMode: 'normal', - }, + color: '', + imageUrl: '', }, layout: { - type: 'default' as const, - columnPosition: 'center' as const, - showLogo: true, + loginFormPosition: 'center' as const, showFooter: true, }, - card: { - backgroundColor: '', - borderRadius: '', - width: '', - padding: '', - boxShadow: '', - }, - button: { - backgroundColor: '', - textColor: '', - }, branding: { title: '', subtitle: '', - customText: '', helpText: '', - contactEmail: '', - customLinks: [], }, }; diff --git a/packages/apps/esm-login-app/src/background/background-wrapper.component.tsx b/packages/apps/esm-login-app/src/background/background-wrapper.component.tsx index 1f4bcb46d..3f32ca468 100644 --- a/packages/apps/esm-login-app/src/background/background-wrapper.component.tsx +++ b/packages/apps/esm-login-app/src/background/background-wrapper.component.tsx @@ -9,81 +9,32 @@ interface BackgroundWrapperProps { const BackgroundWrapper: React.FC = ({ children }) => { const config = useConfig(); - const { layout, background } = config; + const { background } = config; const backgroundStyles = useMemo(() => { const style: React.CSSProperties = {}; - switch (background.type) { - case 'color': - if (background.value) { - style.backgroundColor = background.value; - } - break; - - case 'image': - if (background.value) { - style.backgroundImage = `url(${background.value})`; - style.backgroundSize = background.size || 'cover'; - style.backgroundPosition = background.position || 'center'; - style.backgroundRepeat = background.repeat || 'no-repeat'; - style.backgroundAttachment = background.attachment || 'scroll'; - } - break; - - case 'gradient': - if (background.value) { - style.background = background.value; - } - break; - - default: - break; + if (background.color) { + style.backgroundColor = background.color; } - return style; - }, [background]); - - const overlayStyles = useMemo(() => { - if (!background.overlay.enabled) { - return {}; + if (background.imageUrl) { + style.backgroundImage = `url(${background.imageUrl})`; + style.backgroundSize = 'cover'; + style.backgroundPosition = 'center'; + style.backgroundRepeat = 'no-repeat'; } - return { - backgroundColor: background.overlay.color, - opacity: background.overlay.opacity, - mixBlendMode: background.overlay.blendMode || 'normal', - }; + return style; }, [background]); - const hasCustomBackground = background.type !== 'default' && background.value; - const hasOverlay = background.overlay.enabled && hasCustomBackground; - - if (layout.type === 'split-screen' && background.type === 'image' && background.value) { - const bgPosition = layout.columnPosition === 'right' ? 'left' : 'right'; - - return ( -
-
-
{children}
-
- ); - } + const hasCustomBackground = background.color || background.imageUrl; return (
- {hasOverlay &&
}
{children}
); diff --git a/packages/apps/esm-login-app/src/background/background-wrapper.test.tsx b/packages/apps/esm-login-app/src/background/background-wrapper.test.tsx index a7ae4b160..25af2bf43 100644 --- a/packages/apps/esm-login-app/src/background/background-wrapper.test.tsx +++ b/packages/apps/esm-login-app/src/background/background-wrapper.test.tsx @@ -45,44 +45,17 @@ const defaultConfig: ConfigSchema = { }, showPasswordOnSeparateScreen: true, layout: { - type: 'default', - columnPosition: 'center', - showLogo: true, + loginFormPosition: 'center', showFooter: true, }, background: { - type: 'default', - value: '', - alt: 'Background Image', - size: 'cover', - position: 'center', - repeat: 'no-repeat', - attachment: 'scroll', - overlay: { - enabled: false, - color: 'rgba(0, 0, 0, 0.3)', - opacity: 0.3, - blendMode: 'normal', - }, - }, - card: { - backgroundColor: '', - borderRadius: '', - width: '', - padding: '', - boxShadow: '', - }, - button: { - backgroundColor: '', - textColor: '', + color: '', + imageUrl: '', }, branding: { title: '', subtitle: '', - customText: '', helpText: '', - contactEmail: '', - customLinks: [], }, }; @@ -110,9 +83,8 @@ describe('BackgroundWrapper', () => { const configWithColorBg = { ...defaultConfig, background: { - ...defaultConfig.background, - type: 'color' as const, - value: '#ff0000', + color: '#ff0000', + imageUrl: '', }, }; mockUseConfig.mockReturnValue(configWithColorBg); @@ -130,13 +102,8 @@ describe('BackgroundWrapper', () => { const configWithImageBg = { ...defaultConfig, background: { - ...defaultConfig.background, - type: 'image' as const, - value: 'https://example.com/background.jpg', - size: 'contain', - position: 'top', - repeat: 'repeat-x' as const, - attachment: 'fixed' as const, + color: '', + imageUrl: 'https://example.com/background.jpg', }, }; mockUseConfig.mockReturnValue(configWithImageBg); @@ -150,92 +117,15 @@ describe('BackgroundWrapper', () => { expect(screen.getByTestId('test-child')).toBeInTheDocument(); }); - it('renders with gradient background configuration', () => { - const configWithGradientBg = { - ...defaultConfig, - background: { - ...defaultConfig.background, - type: 'gradient' as const, - value: 'linear-gradient(45deg, #ff0000, #0000ff)', - }, - }; - mockUseConfig.mockReturnValue(configWithGradientBg); - - render( - -
Test Content
-
, - ); - - expect(screen.getByTestId('test-child')).toBeInTheDocument(); - }); - - it('renders with overlay configuration', () => { - const configWithOverlay = { - ...defaultConfig, - background: { - ...defaultConfig.background, - type: 'color' as const, - value: '#ff0000', - overlay: { - enabled: true, - color: 'rgba(0, 0, 0, 0.5)', - opacity: 0.5, - blendMode: 'multiply' as const, - }, - }, - }; - mockUseConfig.mockReturnValue(configWithOverlay); - - render( - -
Test Content
-
, - ); - - expect(screen.getByTestId('test-child')).toBeInTheDocument(); - }); - - it('renders split-screen layout with right column position', () => { - const configWithSplitScreen = { + it('renders with both color and image background', () => { + const configWithBothBg = { ...defaultConfig, - layout: { - ...defaultConfig.layout, - type: 'split-screen' as const, - columnPosition: 'right' as const, - }, - background: { - ...defaultConfig.background, - type: 'image' as const, - value: 'https://example.com/bg.jpg', - }, - }; - mockUseConfig.mockReturnValue(configWithSplitScreen); - - render( - -
Test Content
-
, - ); - - expect(screen.getByTestId('test-child')).toBeInTheDocument(); - }); - - it('renders split-screen layout with left column position', () => { - const configWithSplitScreen = { - ...defaultConfig, - layout: { - ...defaultConfig.layout, - type: 'split-screen' as const, - columnPosition: 'left' as const, - }, background: { - ...defaultConfig.background, - type: 'image' as const, - value: 'https://example.com/bg.jpg', + color: '#0071C5', + imageUrl: 'https://example.com/background.jpg', }, }; - mockUseConfig.mockReturnValue(configWithSplitScreen); + mockUseConfig.mockReturnValue(configWithBothBg); render( @@ -246,13 +136,12 @@ describe('BackgroundWrapper', () => { expect(screen.getByTestId('test-child')).toBeInTheDocument(); }); - it('handles empty background value gracefully', () => { + it('handles empty background values gracefully', () => { const configWithEmptyBg = { ...defaultConfig, background: { - ...defaultConfig.background, - type: 'image' as const, - value: '', + color: '', + imageUrl: '', }, }; mockUseConfig.mockReturnValue(configWithEmptyBg); @@ -265,24 +154,4 @@ describe('BackgroundWrapper', () => { expect(screen.getByTestId('test-child')).toBeInTheDocument(); }); - - it('renders with custom background configuration', () => { - const configWithCustomBg = { - ...defaultConfig, - background: { - ...defaultConfig.background, - type: 'color' as const, - value: '#ff0000', - }, - }; - mockUseConfig.mockReturnValue(configWithCustomBg); - - render( - -
Test Content
-
, - ); - - expect(screen.getByTestId('test-child')).toBeInTheDocument(); - }); }); diff --git a/packages/apps/esm-login-app/src/config-schema.ts b/packages/apps/esm-login-app/src/config-schema.ts index 84320d95d..93ada7b92 100644 --- a/packages/apps/esm-login-app/src/config-schema.ts +++ b/packages/apps/esm-login-app/src/config-schema.ts @@ -59,6 +59,8 @@ export const configSchema = { }, }, logo: { + _type: Type.Object, + _description: 'Configure the logo displayed on the login page.', src: { _type: Type.String, _default: '', @@ -73,6 +75,8 @@ export const configSchema = { }, }, footer: { + _type: Type.Object, + _description: 'Configure the footer section of the login page.', additionalLogos: { _type: Type.Array, _elements: { @@ -100,206 +104,52 @@ export const configSchema = { 'Whether to show the password field on a separate screen. If false, the password field will be shown on the same screen.', }, layout: { - type: { - _type: Type.String, - _default: 'default', - _description: - 'The layout type for the login page. ' + - 'default: Simple layout with optional background and card positioning. ' + - 'split-screen: Background image on one side, login card on the other side.', - _validators: [validators.oneOf(['default', 'split-screen'])], - }, - columnPosition: { + _type: Type.Object, + _description: 'Configure the position and layout of the login form on the page.', + loginFormPosition: { _type: Type.String, _default: 'center', - _description: - 'Position of the login card on the screen. ' + - 'For default layout: positions the card left/center/right on the screen. ' + - 'For split-screen layout: positions the card and shows background image on the opposite side.', + _description: 'Position of the login form on the screen.', _validators: [validators.oneOf(['left', 'center', 'right'])], }, - showLogo: { - _type: Type.Boolean, - _default: true, - _description: 'Whether to show the logo on the login page.', - }, showFooter: { _type: Type.Boolean, _default: true, _description: 'Whether to show the footer on the login page.', }, }, - card: { - backgroundColor: { - _type: Type.String, - _default: '', - _description: 'Custom background color for the login card. Leave empty to use theme default.', - }, - borderRadius: { - _type: Type.String, - _default: '', - _description: 'Custom border radius for the login card (e.g., "8px", "1rem"). Leave empty to use theme default.', - }, - width: { - _type: Type.String, - _default: '', - _description: 'Custom width for the login card (e.g., "400px", "25rem"). Leave empty to use default.', - }, - padding: { - _type: Type.String, - _default: '', - _description: 'Custom padding for the login card (e.g., "2rem", "32px"). Leave empty to use default.', - }, - boxShadow: { - _type: Type.String, - _default: '', - _description: - 'Custom box shadow for the login card (e.g., "0 4px 6px rgba(0,0,0,0.1)"). Leave empty for no shadow.', - }, - }, - button: { - backgroundColor: { + background: { + _type: Type.Object, + _description: 'Configure the login page background with either a solid color or background image.', + color: { _type: Type.String, _default: '', - _description: 'Custom background color for the login button. Leave empty to use theme default.', + _description: 'Solid background color (e.g., "#0071C5", "blue", "rgb(0,113,197)"). Leave empty for default.', }, - textColor: { + imageUrl: { _type: Type.String, _default: '', - _description: 'Custom text color for the login button. Leave empty to use theme default.', + _description: 'URL to background image (e.g., "https://example.com/bg.jpg"). Leave empty for no image.', + _validators: [validators.isUrl], }, }, branding: { + _type: Type.Object, + _description: 'Configure custom branding text for the login page.', title: { _type: Type.String, _default: '', - _description: 'Custom title to display above the login form (e.g., "Welcome to MyClinic").', + _description: 'Custom title text or translation key (e.g., "welcome.title").', }, subtitle: { _type: Type.String, _default: '', - _description: 'Custom subtitle to display below the title.', - }, - customText: { - _type: Type.String, - _default: '', - _description: - 'Additional custom text or HTML to display on the login page. ' + - 'WARNING: HTML content is rendered as-is. Ensure you trust the source and sanitize any user-provided content to prevent XSS attacks.', + _description: 'Custom subtitle text or translation key (e.g., "welcome.subtitle").', }, helpText: { _type: Type.String, _default: '', - _description: 'Help text to display below the login form.', - }, - contactEmail: { - _type: Type.String, - _default: '', - _description: 'Contact email to display for support.', - }, - customLinks: { - _type: Type.Array, - _elements: { - _type: Type.Object, - text: { - _type: Type.String, - _required: true, - _description: 'The text to display for the link.', - }, - url: { - _type: Type.String, - _required: true, - _description: 'The URL the link should navigate to.', - _validators: [validators.isUrl], - }, - }, - _default: [], - _description: 'Custom links to display on the login page (e.g., legacy UI, help documentation).', - }, - }, - background: { - type: { - _type: Type.String, - _default: 'default', - _description: 'The type of background to use.', - _validators: [validators.oneOf(['default', 'color', 'image', 'gradient'])], - }, - value: { - _type: Type.String, - _default: '', - _description: - 'The background value based on the type. ' + - 'For color: any valid CSS color (e.g., "#0066cc", "rgb(0,102,204)", "blue"). ' + - 'For gradient: any valid CSS gradient (e.g., "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"). ' + - 'For image: a URL to the image (e.g., "https://example.com/background.jpg").', - }, - alt: { - _type: Type.String, - _default: 'Background Image', - _description: 'Alternative text for background images.', - }, - size: { - _type: Type.String, - _default: 'cover', - _description: 'Background size for image backgrounds. Options: cover, contain, auto, or custom CSS values.', - _validators: [validators.oneOf(['cover', 'contain', 'auto'])], - }, - position: { - _type: Type.String, - _default: 'center', - _description: - 'Background position for image backgrounds. Options: center, top, bottom, left, right, or custom CSS values.', - }, - repeat: { - _type: Type.String, - _default: 'no-repeat', - _description: 'Background repeat for image backgrounds.', - _validators: [validators.oneOf(['no-repeat', 'repeat', 'repeat-x', 'repeat-y'])], - }, - attachment: { - _type: Type.String, - _default: 'scroll', - _description: 'Background attachment for image backgrounds.', - _validators: [validators.oneOf(['scroll', 'fixed', 'local'])], - }, - overlay: { - enabled: { - _type: Type.Boolean, - _default: false, - _description: 'Whether to enable an overlay on the background to improve content readability.', - }, - color: { - _type: Type.String, - _default: 'rgba(0, 0, 0, 0.3)', - _description: 'The overlay color in any valid CSS color format (hex, rgb, rgba, hsl, etc.).', - }, - opacity: { - _type: Type.Number, - _default: 0.3, - _description: 'The opacity of the overlay (0-1). Lower values are more transparent.', - _validators: [validators.inRange(0, 1)], - }, - blendMode: { - _type: Type.String, - _default: 'normal', - _description: 'CSS blend mode for the overlay.', - _validators: [ - validators.oneOf([ - 'normal', - 'multiply', - 'screen', - 'overlay', - 'darken', - 'lighten', - 'color-dodge', - 'color-burn', - 'hard-light', - 'soft-light', - 'difference', - 'exclusion', - ]), - ], - }, + _description: 'Custom help text or translation key for support information.', }, }, }; @@ -331,58 +181,16 @@ export interface ConfigSchema { }; showPasswordOnSeparateScreen: boolean; background: { - type: 'default' | 'color' | 'image' | 'gradient'; - value: string; - alt: string; - size: string; - position: string; - repeat: 'no-repeat' | 'repeat' | 'repeat-x' | 'repeat-y'; - attachment: 'scroll' | 'fixed' | 'local'; - overlay: { - enabled: boolean; - color: string; - opacity: number; - blendMode: - | 'normal' - | 'multiply' - | 'screen' - | 'overlay' - | 'darken' - | 'lighten' - | 'color-dodge' - | 'color-burn' - | 'hard-light' - | 'soft-light' - | 'difference' - | 'exclusion'; - }; + color: string; + imageUrl: string; }; layout: { - type: 'default' | 'split-screen'; - columnPosition: 'left' | 'center' | 'right'; - showLogo: boolean; + loginFormPosition: 'left' | 'center' | 'right'; showFooter: boolean; }; - card: { - backgroundColor: string; - borderRadius: string; - width: string; - padding: string; - boxShadow: string; - }; - button: { - backgroundColor: string; - textColor: string; - }; branding: { title: string; subtitle: string; - customText: string; helpText: string; - contactEmail: string; - customLinks: Array<{ - text: string; - url: string; - }>; }; } diff --git a/packages/apps/esm-login-app/src/login/login.component.tsx b/packages/apps/esm-login-app/src/login/login.component.tsx index 1a93204e5..c2887d7c4 100644 --- a/packages/apps/esm-login-app/src/login/login.component.tsx +++ b/packages/apps/esm-login-app/src/login/login.component.tsx @@ -23,15 +23,7 @@ export interface LoginReferrer { const Login: React.FC = () => { const config = useConfig(); - const { - showPasswordOnSeparateScreen, - provider: loginProvider, - links: loginLinks, - layout, - card, - button, - branding, - } = config; + const { showPasswordOnSeparateScreen, provider: loginProvider, links: loginLinks, layout, branding } = config; const isLoginEnabled = useConnectivity(); const { t } = useTranslation(); const { user } = useSession(); @@ -146,20 +138,9 @@ const Login: React.FC = () => { ); if (!loginProvider || loginProvider.type === 'basic') { - const cardStyles: React.CSSProperties = {}; - if (card.backgroundColor) cardStyles.backgroundColor = card.backgroundColor; - if (card.borderRadius) cardStyles.borderRadius = card.borderRadius; - if (card.width) cardStyles.width = card.width; - if (card.padding) cardStyles.padding = card.padding; - if (card.boxShadow) cardStyles.boxShadow = card.boxShadow; - - const buttonStyles: React.CSSProperties = {}; - if (button.backgroundColor) buttonStyles.backgroundColor = button.backgroundColor; - if (button.textColor) buttonStyles.color = button.textColor; - const containerClass = - layout.columnPosition !== 'center' - ? `${styles.container} ${styles[`position-${layout.columnPosition}`]}` + layout.loginFormPosition !== 'center' + ? `${styles.container} ${styles[`position-${layout.loginFormPosition}`]}` : styles.container; return ( @@ -176,12 +157,10 @@ const Login: React.FC = () => {
)} - - {layout.showLogo && ( -
- -
- )} + +
+ +
{branding.title && (
@@ -195,10 +174,6 @@ const Login: React.FC = () => {
)} - {branding.customText && ( -
- )} -
{
)} - - {branding.contactEmail && ( -
-

- {t('needHelp', 'Need help?')} {t('contactUs', 'Contact us at')}{' '} - {branding.contactEmail} -

-
- )} - - {branding.customLinks.length > 0 && ( -
- {branding.customLinks.map((link, index) => ( - - {t(link.text, link.text)} - - ))} -
- )} {layout.showFooter &&