diff --git a/packages/apps/esm-login-app/README.md b/packages/apps/esm-login-app/README.md index ff65544b9..f433b619c 100644 --- a/packages/apps/esm-login-app/README.md +++ b/packages/apps/esm-login-app/README.md @@ -1,4 +1,113 @@ # 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: + +- 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 + +### Background with Solid Color + +```json +{ + "@openmrs/esm-login-app": { + "background": { + "color": "#0071C5" + } + } +} +``` + +### Background with Image + +```json +{ + "@openmrs/esm-login-app": { + "background": { + "imageUrl": "https://example.com/hospital-background.jpg" + } + } +} +``` + +### Positioned Login Form with Branding + +```json +{ + "@openmrs/esm-login-app": { + "layout": { + "loginFormPosition": "left", + "showFooter": true + }, + "branding": { + "title": "Welcome to My Clinic", + "subtitle": "Electronic Medical Records System", + "helpText": "For assistance, contact IT support" + } + } +} +``` + +### 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.title", + "subtitle": "welcome.subtitle", + "helpText": "support.helpText" + }, + "logo": { + "src": "https://example.com/logo.png", + "alt": "My Clinic Logo" + } + } +} +``` \ 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..bd20cd04b 100644 --- a/packages/apps/esm-login-app/__mocks__/config.mock.ts +++ b/packages/apps/esm-login-app/__mocks__/config.mock.ts @@ -23,4 +23,17 @@ export const mockConfig: ConfigSchema = { additionalLogos: [], }, showPasswordOnSeparateScreen: true, + background: { + color: '', + imageUrl: '', + }, + layout: { + loginFormPosition: 'center' as const, + showFooter: true, + }, + branding: { + title: '', + subtitle: '', + helpText: '', + }, }; 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..3f32ca468 --- /dev/null +++ b/packages/apps/esm-login-app/src/background/background-wrapper.component.tsx @@ -0,0 +1,43 @@ +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 { background } = config; + + const backgroundStyles = useMemo(() => { + const style: React.CSSProperties = {}; + + if (background.color) { + style.backgroundColor = background.color; + } + + if (background.imageUrl) { + style.backgroundImage = `url(${background.imageUrl})`; + style.backgroundSize = 'cover'; + style.backgroundPosition = 'center'; + style.backgroundRepeat = 'no-repeat'; + } + + return style; + }, [background]); + + const hasCustomBackground = background.color || background.imageUrl; + + return ( +
+
{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..25af2bf43 --- /dev/null +++ b/packages/apps/esm-login-app/src/background/background-wrapper.test.tsx @@ -0,0 +1,157 @@ +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: { + loginFormPosition: 'center', + showFooter: true, + }, + background: { + color: '', + imageUrl: '', + }, + branding: { + title: '', + subtitle: '', + helpText: '', + }, +}; + +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: { + color: '#ff0000', + imageUrl: '', + }, + }; + mockUseConfig.mockReturnValue(configWithColorBg); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('renders with image background configuration', () => { + const configWithImageBg = { + ...defaultConfig, + background: { + color: '', + imageUrl: 'https://example.com/background.jpg', + }, + }; + mockUseConfig.mockReturnValue(configWithImageBg); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('renders with both color and image background', () => { + const configWithBothBg = { + ...defaultConfig, + background: { + color: '#0071C5', + imageUrl: 'https://example.com/background.jpg', + }, + }; + mockUseConfig.mockReturnValue(configWithBothBg); + + render( + +
Test Content
+
, + ); + + expect(screen.getByTestId('test-child')).toBeInTheDocument(); + }); + + it('handles empty background values gracefully', () => { + const configWithEmptyBg = { + ...defaultConfig, + background: { + color: '', + imageUrl: '', + }, + }; + mockUseConfig.mockReturnValue(configWithEmptyBg); + + 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..93ada7b92 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, @@ -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: { @@ -99,6 +103,55 @@ 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.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 form on the screen.', + _validators: [validators.oneOf(['left', 'center', 'right'])], + }, + showFooter: { + _type: Type.Boolean, + _default: true, + _description: 'Whether to show the footer on the login page.', + }, + }, + background: { + _type: Type.Object, + _description: 'Configure the login page background with either a solid color or background image.', + color: { + _type: Type.String, + _default: '', + _description: 'Solid background color (e.g., "#0071C5", "blue", "rgb(0,113,197)"). Leave empty for default.', + }, + imageUrl: { + _type: Type.String, + _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 text or translation key (e.g., "welcome.title").', + }, + subtitle: { + _type: Type.String, + _default: '', + _description: 'Custom subtitle text or translation key (e.g., "welcome.subtitle").', + }, + helpText: { + _type: Type.String, + _default: '', + _description: 'Custom help text or translation key for support information.', + }, + }, }; export interface ConfigSchema { @@ -127,4 +180,17 @@ export interface ConfigSchema { type: 'basic' | 'oauth2'; }; showPasswordOnSeparateScreen: boolean; + background: { + color: string; + imageUrl: string; + }; + layout: { + loginFormPosition: 'left' | 'center' | 'right'; + showFooter: boolean; + }; + branding: { + title: string; + subtitle: string; + helpText: 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..c2887d7c4 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,8 @@ export interface LoginReferrer { } const Login: React.FC = () => { - const { showPasswordOnSeparateScreen, provider: loginProvider, links: loginLinks } = useConfig(); + const config = useConfig(); + const { showPasswordOnSeparateScreen, provider: loginProvider, links: loginLinks, layout, branding } = config; const isLoginEnabled = useConnectivity(); const { t } = useTranslation(); const { user } = useSession(); @@ -136,9 +138,14 @@ const Login: React.FC = () => { ); if (!loginProvider || loginProvider.type === 'basic') { + const containerClass = + layout.loginFormPosition !== 'center' + ? `${styles.container} ${styles[`position-${layout.loginFormPosition}`]}` + : styles.container; + return ( -
- + +
{errorMessage && (
{ />
)} -
- -
-
-
- - {showPasswordOnSeparateScreen ? ( - showPasswordField ? ( + + +
+ +
+ + {branding.title && ( +
+

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

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

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

+
+ )} + + +
+ + {showPasswordOnSeparateScreen ? ( + showPasswordField ? ( + <> + + + + ) : ( + + ) + ) : ( <> { type="submit" className={styles.continueButton} renderIcon={(props) => } - iconDescription={t('loginButtonIconDescription', 'Log in button')} + iconDescription="Log in" disabled={!isLoginEnabled || isLoggingIn} > {isLoggingIn ? ( @@ -192,50 +252,20 @@ const Login: React.FC = () => { )} - ) : ( - - ) - ) : ( - <> - - - - )} -
- -
-
-
+ )} +
+ + + {branding.helpText && ( +
+

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

+
+ )} +
+ + {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..e3988bda4 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,59 @@ describe('Login', () => { expect(usernameInput).toHaveFocus(); }); + + it('renders configurable branding with custom title and help text', () => { + const brandedConfig = { + ...mockConfig, + branding: { + title: 'Test Health Center', + subtitle: 'Electronic Medical Records', + helpText: 'Need help? Contact support', + }, + 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(); + }); + + it('renders default layout', () => { + const defaultConfig = { + ...mockConfig, + layout: { + loginFormPosition: 'center' as const, + showFooter: true, + }, + branding: { + title: '', + subtitle: '', + helpText: '', + }, + }; + 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..5c4816214 100644 --- a/packages/apps/esm-login-app/translations/en.json +++ b/packages/apps/esm-login-app/translations/en.json @@ -38,4 +38,4 @@ "username": "Username", "validValueRequired": "A valid value is required", "welcome": "Welcome" -} +} \ No newline at end of file