From 04590913d69631557f041570d84cec3d818ad21a Mon Sep 17 00:00:00 2001 From: Emmanuel Nyachoke Date: Wed, 4 Jun 2025 14:13:46 +0300 Subject: [PATCH] (feat) Adds support forTOTP 2FA to O3. --- packages/apps/esm-login-app/src/index.ts | 3 + .../src/login/login-with-secret.component.tsx | 57 ++++ .../login/login-with-totp.component.test.tsx | 154 ++++++++++ .../src/login/login-with-totp.component.tsx | 97 ++++++ .../src/login/login.component.tsx | 2 + .../src/login/totp-setup-link-extension.tsx | 26 ++ .../src/login/totp-setup-link.scss | 17 ++ .../src/login/totp-setup.component.tsx | 126 ++++++++ .../src/login/totp-setup.test.tsx | 278 ++++++++++++++++++ .../apps/esm-login-app/src/root.component.tsx | 4 + packages/apps/esm-login-app/src/routes.json | 13 + .../apps/esm-login-app/translations/en.json | 15 + .../framework/esm-api/src/openmrs-fetch.ts | 10 + packages/framework/esm-framework/docs/API.md | 2 +- .../docs/classes/OpenmrsFetchError.md | 6 +- .../docs/interfaces/FetchConfig.md | 15 +- .../docs/interfaces/FetchError.md | 4 +- 17 files changed, 821 insertions(+), 8 deletions(-) create mode 100644 packages/apps/esm-login-app/src/login/login-with-secret.component.tsx create mode 100644 packages/apps/esm-login-app/src/login/login-with-totp.component.test.tsx create mode 100644 packages/apps/esm-login-app/src/login/login-with-totp.component.tsx create mode 100644 packages/apps/esm-login-app/src/login/totp-setup-link-extension.tsx create mode 100644 packages/apps/esm-login-app/src/login/totp-setup-link.scss create mode 100644 packages/apps/esm-login-app/src/login/totp-setup.component.tsx create mode 100644 packages/apps/esm-login-app/src/login/totp-setup.test.tsx diff --git a/packages/apps/esm-login-app/src/index.ts b/packages/apps/esm-login-app/src/index.ts index 0efa735b5..1e843ec6c 100644 --- a/packages/apps/esm-login-app/src/index.ts +++ b/packages/apps/esm-login-app/src/index.ts @@ -2,9 +2,11 @@ import { defineConfigSchema, getAsyncLifecycle, getSyncLifecycle } from '@openmr import { configSchema } from './config-schema'; import changeLocationLinkComponent from './change-location-link/change-location-link.extension'; import changePasswordLinkComponent from './change-password/change-password-link.extension'; +import setupMfaLinkComponent from './login/totp-setup-link-extension'; import locationPickerComponent from './location-picker/location-picker-view.component'; import logoutButtonComponent from './logout/logout.extension'; import rootComponent from './root.component'; +import LoginWithTotp from './login/login-with-totp.component'; const moduleName = '@openmrs/esm-login-app'; @@ -24,4 +26,5 @@ export const locationPicker = getSyncLifecycle(locationPickerComponent, options) export const logoutButton = getSyncLifecycle(logoutButtonComponent, options); export const changeLocationLink = getSyncLifecycle(changeLocationLinkComponent, options); export const changePasswordLink = getSyncLifecycle(changePasswordLinkComponent, options); +export const setupMfaLink = getSyncLifecycle(setupMfaLinkComponent, options); export const changePasswordModal = getAsyncLifecycle(() => import('./change-password/change-password.modal'), options); diff --git a/packages/apps/esm-login-app/src/login/login-with-secret.component.tsx b/packages/apps/esm-login-app/src/login/login-with-secret.component.tsx new file mode 100644 index 000000000..25df5e672 --- /dev/null +++ b/packages/apps/esm-login-app/src/login/login-with-secret.component.tsx @@ -0,0 +1,57 @@ +import React, { useState } from 'react'; +import { TextInput, Button, InlineNotification } from '@carbon/react'; + +const LoginWithSecret: React.FC = () => { + const [username, setUsername] = useState(''); + const [question, setQuestion] = useState(''); + const [answer, setAnswer] = useState(''); + const [error, setError] = useState(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + // TODO: Implement secret question authentication logic + setError('Not implemented yet.'); + }; + + return ( +
+

Login with Secret Question

+ setUsername(e.target.value)} + required + /> + setQuestion(e.target.value)} + required + /> + setAnswer(e.target.value)} + required + /> + + {error && ( + setError(null)} + style={{ marginTop: 16 }} + /> + )} + + ); +}; + +export default LoginWithSecret; diff --git a/packages/apps/esm-login-app/src/login/login-with-totp.component.test.tsx b/packages/apps/esm-login-app/src/login/login-with-totp.component.test.tsx new file mode 100644 index 000000000..7093e2868 --- /dev/null +++ b/packages/apps/esm-login-app/src/login/login-with-totp.component.test.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import { openmrsFetch, sessionEndpoint } from '@openmrs/esm-framework'; +import LoginWithTotp from './login-with-totp.component'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); + +describe('LoginWithTotp', () => { + beforeEach(() => { + mockOpenmrsFetch.mockClear(); + }); + + it('should render the TOTP login form', () => { + render(); + + expect(screen.getByText(/MFA Code/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Verify/i })).toBeInTheDocument(); + }); + + it('should handle successful TOTP verification', async () => { + const mockTotpCode = '123456'; + const mockResponse = { + data: { + authenticated: true, + sessionLocation: { uuid: 'location-uuid' }, + }, + headers: new Headers(), + ok: true, + redirected: false, + status: 200, + statusText: 'OK', + type: 'cors' as ResponseType, + url: sessionEndpoint, + body: null, + bodyUsed: false, + bytes: () => Promise.resolve(new Uint8Array(0)), + clone: () => mockResponse, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve(mockResponse.data), + text: () => Promise.resolve(JSON.stringify(mockResponse.data)), + }; + mockOpenmrsFetch.mockResolvedValue(mockResponse); + + const user = userEvent.setup(); + render(); + + // Enter TOTP code + await user.type(screen.getByLabelText(/MFA Code/i), mockTotpCode); + + // Submit form + await user.click(screen.getByRole('button', { name: /Verify/i })); + + // Verify API call + expect(mockOpenmrsFetch).toHaveBeenCalledWith( + expect.stringContaining(sessionEndpoint), + expect.objectContaining({ + method: 'POST', + body: { + redirect: '/openmrs/spa/home', + }, + }), + ); + + // Check URL parameters + const url = mockOpenmrsFetch.mock.calls[0][0]; + expect(url).toContain(`code=${mockTotpCode}`); + }); + + it('should handle failed TOTP verification', async () => { + const mockTotpCode = '123456'; + const mockResponse = { + data: { + authenticated: false, + }, + headers: new Headers(), + ok: true, + redirected: false, + status: 200, + statusText: 'OK', + type: 'cors' as ResponseType, + url: sessionEndpoint, + body: null, + bodyUsed: false, + bytes: () => Promise.resolve(new Uint8Array(0)), + clone: () => mockResponse, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve(mockResponse.data), + text: () => Promise.resolve(JSON.stringify(mockResponse.data)), + }; + mockOpenmrsFetch.mockResolvedValue(mockResponse); + + const user = userEvent.setup(); + render(); + + // Enter TOTP code + await user.type(screen.getByLabelText(/MFA Code/i), mockTotpCode); + + // Submit form + await user.click(screen.getByRole('button', { name: /Verify/i })); + + // Check error message + await waitFor(() => { + expect(screen.getByText(/Invalid MFA code/i)).toBeInTheDocument(); + }); + + // Verify input is cleared + expect(screen.getByLabelText(/MFA Code/i)).toHaveValue(''); + }); + + it('should handle API errors', async () => { + const mockTotpCode = '123456'; + mockOpenmrsFetch.mockRejectedValue(new Error('API Error')); + + const user = userEvent.setup(); + render(); + + // Enter TOTP code + await user.type(screen.getByLabelText(/MFA Code/i), mockTotpCode); + + // Submit form + await user.click(screen.getByRole('button', { name: /Verify/i })); + + // Check error message + await waitFor(() => { + expect(screen.getByText(/Failed to verify MFA code/i)).toBeInTheDocument(); + }); + + // Verify input is cleared + expect(screen.getByLabelText(/MFA Code/i)).toHaveValue(''); + }); + + it('should show loading state during verification', async () => { + const mockTotpCode = '123456'; + mockOpenmrsFetch.mockImplementation(() => new Promise(() => {})); // Never resolves + + const user = userEvent.setup(); + render(); + + // Enter TOTP code + await user.type(screen.getByLabelText(/MFA Code/i), mockTotpCode); + + // Submit form + await user.click(screen.getByRole('button', { name: /Verify/i })); + + // Check loading state + expect(screen.getByText(/Verifying/i)).toBeInTheDocument(); + expect(screen.getByRole('button')).toBeDisabled(); + }); +}); diff --git a/packages/apps/esm-login-app/src/login/login-with-totp.component.tsx b/packages/apps/esm-login-app/src/login/login-with-totp.component.tsx new file mode 100644 index 000000000..6ad265099 --- /dev/null +++ b/packages/apps/esm-login-app/src/login/login-with-totp.component.tsx @@ -0,0 +1,97 @@ +import React, { useState } from 'react'; +import { TextInput, Button, InlineLoading, InlineNotification, Tile } from '@carbon/react'; +import { openmrsFetch, sessionEndpoint, ArrowRightIcon, getCoreTranslation } from '@openmrs/esm-framework'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import Logo from '../logo.component'; +import Footer from '../footer.component'; +import styles from './login.scss'; + +const LoginWithTotp: React.FC = () => { + const [totp, setTotp] = useState(''); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const navigate = useNavigate(); + const { t } = useTranslation(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsSubmitting(true); + + try { + const searchParams = new URLSearchParams(); + searchParams.append('code', totp); + searchParams.append('redirect', 'spa/home'); + const url = `${sessionEndpoint}?${searchParams.toString()}`; + + const response = await openmrsFetch(url); + const session = response.data; + const authenticated = session?.authenticated; + if (authenticated) { + if (session.sessionLocation) { + setTimeout(() => navigate('/openmrs/spa/home'), 0); + } else { + setTimeout(() => navigate('/login/location'), 0); + } + } else { + setError('Invalid MFA code'); + setTotp(''); + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to verify MFA code'); + setTotp(''); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+ + {error && ( +
+ setError(null)} + /> +
+ )} +
+ +
+
+
+ setTotp(e.target.value)} + required + maxLength={6} + autoFocus + /> + +
+
+
+
+
+ ); +}; + +export default LoginWithTotp; 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..5de2b7510 100644 --- a/packages/apps/esm-login-app/src/login/login.component.tsx +++ b/packages/apps/esm-login-app/src/login/login.component.tsx @@ -10,6 +10,8 @@ import { useConfig, useConnectivity, useSession, + openmrsFetch, + sessionEndpoint, } from '@openmrs/esm-framework'; import { type ConfigSchema } from '../config-schema'; import Logo from '../logo.component'; diff --git a/packages/apps/esm-login-app/src/login/totp-setup-link-extension.tsx b/packages/apps/esm-login-app/src/login/totp-setup-link-extension.tsx new file mode 100644 index 000000000..705bbbf03 --- /dev/null +++ b/packages/apps/esm-login-app/src/login/totp-setup-link-extension.tsx @@ -0,0 +1,26 @@ +import { HeaderGlobalAction } from '@carbon/react'; +import { navigate, useSession } from '@openmrs/esm-framework'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './totp-setup-link.scss'; +import { TwoFactorAuthentication } from '@carbon/react/icons'; + +const TotpSetupLink: React.FC = () => { + const { t } = useTranslation(); + const session = useSession(); + + const setupTotp = () => { + navigate({ + to: `\${openmrsSpaBase}/totp-setup?returnToUrl=${window.location.pathname}`, + }); + }; + + return ( + + + {t('setupMfa', 'Setup 2FA')} + + ); +}; + +export default TotpSetupLink; diff --git a/packages/apps/esm-login-app/src/login/totp-setup-link.scss b/packages/apps/esm-login-app/src/login/totp-setup-link.scss new file mode 100644 index 000000000..3b34170e4 --- /dev/null +++ b/packages/apps/esm-login-app/src/login/totp-setup-link.scss @@ -0,0 +1,17 @@ +@use '@carbon/layout'; + +.setupMfaButton { + width: fit-content; + background-color: transparent; + color: white; + font-size: 14px; + padding: layout.$spacing-04 !important; // this gets unset in rtl language without !important + + &:hover { + color: white; + } +} + +.setupMfaText { + padding-inline-start: layout.$spacing-03; +} diff --git a/packages/apps/esm-login-app/src/login/totp-setup.component.tsx b/packages/apps/esm-login-app/src/login/totp-setup.component.tsx new file mode 100644 index 000000000..c2d313c8a --- /dev/null +++ b/packages/apps/esm-login-app/src/login/totp-setup.component.tsx @@ -0,0 +1,126 @@ +import React, { useState, useEffect } from 'react'; +import { TextInput, Button, InlineNotification, SkeletonText } from '@carbon/react'; +import { openmrsFetch, restBaseUrl, useSession } from '@openmrs/esm-framework'; +import { useTranslation } from 'react-i18next'; + +const TOTPSetup: React.FC = () => { + const { t } = useTranslation(); + const session = useSession(); + const user = session?.user; + const [secret, setSecret] = useState(''); + const [qrCode, setQrCode] = useState(''); + const [verificationCode, setVerificationCode] = useState(''); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + const [loading, setLoading] = useState(true); + const [verifying, setVerifying] = useState(false); + + useEffect(() => { + setLoading(true); + openmrsFetch(`${restBaseUrl}/authentication/totp/secret`, { + method: 'GET', + }) + .then(({ data }) => { + setSecret(data.secret); + setQrCode(data.qrCode); + setLoading(false); + }) + .catch((err) => { + setError(err.message || t('failedToFetchSecret', 'Failed to fetch TOTP secret.')); + setLoading(false); + }); + }, [t]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setSuccess(false); + setVerifying(true); + try { + // 1. Validate the TOTP code + const { data } = await openmrsFetch(`${restBaseUrl}/authentication/totp/validate`, { + method: 'POST', + body: JSON.stringify({ secret, code: verificationCode }), + headers: { 'Content-Type': 'application/json' }, + }); + if (!data.valid) { + setError(t('invalidCode', 'Invalid code. Please try again.')); + setVerifying(false); + return; + } + // 2. Update user properties + if (!user?.uuid) { + setError(t('userSessionNotFound', 'User session not found.')); + setVerifying(false); + return; + } + const updatedUserProperties = { + ...(user.userProperties ?? {}), + 'authentication.secondaryType': 'totp', + 'authentication.totp.secret': secret, + }; + await openmrsFetch(`${restBaseUrl}/user/${user.uuid}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userProperties: updatedUserProperties }), + }); + setSuccess(true); + } catch (err: any) { + setError(err.message || t('verificationFailed', 'Failed to verify code or update user.')); + } finally { + setVerifying(false); + } + }; + + return ( +
+

{t('setupTwoFactorAuth', 'Set up Two-Factor Authentication')}

+

{t('scanQrCode', 'Scan the QR code below with your authenticator app, or enter the secret manually.')}

+ {loading ? ( + + ) : qrCode ? ( + {t('totpQrCode', + ) : null} + + setVerificationCode(e.target.value)} + required + maxLength={6} + style={{ marginBottom: 16 }} + disabled={loading || verifying} + /> + + {error && ( + setError(null)} + style={{ marginTop: 16 }} + /> + )} + {success && ( + + )} + + ); +}; + +export default TOTPSetup; diff --git a/packages/apps/esm-login-app/src/login/totp-setup.test.tsx b/packages/apps/esm-login-app/src/login/totp-setup.test.tsx new file mode 100644 index 000000000..37bbf0369 --- /dev/null +++ b/packages/apps/esm-login-app/src/login/totp-setup.test.tsx @@ -0,0 +1,278 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from '@testing-library/react'; +import { openmrsFetch, useSession } from '@openmrs/esm-framework'; +import TOTPSetup from './totp-setup.component'; + +const mockOpenmrsFetch = jest.mocked(openmrsFetch); +const mockUseSession = jest.mocked(useSession); + +describe('TOTPSetup', () => { + beforeEach(() => { + mockUseSession.mockReturnValue({ + authenticated: true, + sessionId: 'test-session-id', + user: { + uuid: 'user-uuid', + display: 'Test User', + username: 'testuser', + systemId: 'testuser', + person: { + uuid: 'person-uuid', + display: 'Test User', + links: [], + }, + privileges: [], + roles: [], + retired: false, + userProperties: {}, + links: [], + resourceVersion: '1.8', + locale: 'en', + allowedLocales: ['en'], + }, + }); + }); + + it('should render the initial setup form', () => { + render(); + + expect(screen.getByText(/Set up Two-Factor Authentication/i)).toBeInTheDocument(); + expect(screen.getByText(/Scan the QR code/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Verify and Enable/i })).toBeInTheDocument(); + }); + + it('should fetch and display TOTP secret and QR code', async () => { + const mockSecret = 'JBSWY3DPEHPK3PXP'; + const mockQrCode = 'data:image/png;base64,mock-qr-code'; + + mockOpenmrsFetch.mockImplementation((url) => { + if (url.includes('/secret')) { + return Promise.resolve({ + data: { secret: mockSecret, qrCode: mockQrCode }, + headers: new Headers(), + ok: true, + redirected: false, + status: 200, + statusText: 'OK', + type: 'cors' as ResponseType, + url: url, + body: null, + bodyUsed: false, + bytes: () => Promise.resolve(new Uint8Array(0)), + clone: () => mockOpenmrsFetch.mock.results[0].value, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve({ secret: mockSecret, qrCode: mockQrCode }), + text: () => Promise.resolve(JSON.stringify({ secret: mockSecret, qrCode: mockQrCode })), + }); + } + return Promise.reject(new Error('Not found')); + }); + + render(); + + // Wait for secret to load + await waitFor(() => { + expect(screen.getByDisplayValue(mockSecret)).toBeInTheDocument(); + }); + + // Check QR code separately + const qrCodeImage = screen.getByAltText('2FA QR Code'); + expect(qrCodeImage).toHaveAttribute('src', mockQrCode); + }); + + it('should handle TOTP verification and enablement', async () => { + const mockSecret = 'JBSWY3DPEHPK3PXP'; + const mockQrCode = 'data:image/png;base64,mock-qr-code'; + const mockVerificationCode = '123456'; + + mockOpenmrsFetch.mockImplementation((url, options) => { + if (url.includes('/secret')) { + return Promise.resolve({ + data: { secret: mockSecret, qrCode: mockQrCode }, + headers: new Headers(), + ok: true, + redirected: false, + status: 200, + statusText: 'OK', + type: 'cors' as ResponseType, + url: url, + body: null, + bodyUsed: false, + bytes: () => Promise.resolve(new Uint8Array(0)), + clone: () => mockOpenmrsFetch.mock.results[0].value, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve({ secret: mockSecret, qrCode: mockQrCode }), + text: () => Promise.resolve(JSON.stringify({ secret: mockSecret, qrCode: mockQrCode })), + }); + } + if (url.includes('/validate')) { + return Promise.resolve({ + data: { valid: true }, + headers: new Headers(), + ok: true, + redirected: false, + status: 200, + statusText: 'OK', + type: 'cors' as ResponseType, + url: url, + body: null, + bodyUsed: false, + bytes: () => Promise.resolve(new Uint8Array(0)), + clone: () => mockOpenmrsFetch.mock.results[0].value, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve({ valid: true }), + text: () => Promise.resolve(JSON.stringify({ valid: true })), + }); + } + if (url.includes('/user/')) { + return Promise.resolve({ + data: { userProperties: { 'authentication.secondaryType': 'totp' } }, + headers: new Headers(), + ok: true, + redirected: false, + status: 200, + statusText: 'OK', + type: 'cors' as ResponseType, + url: url, + body: null, + bodyUsed: false, + bytes: () => Promise.resolve(new Uint8Array(0)), + clone: () => mockOpenmrsFetch.mock.results[0].value, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve({ userProperties: { 'authentication.secondaryType': 'totp' } }), + text: () => Promise.resolve(JSON.stringify({ userProperties: { 'authentication.secondaryType': 'totp' } })), + }); + } + return Promise.reject(new Error('Not found')); + }); + + const user = userEvent.setup(); + render(); + + // Wait for secret to load + await waitFor(() => { + expect(screen.getByDisplayValue(mockSecret)).toBeInTheDocument(); + }); + + // Enter verification code + await user.type(screen.getByLabelText(/Enter code from your app/i), mockVerificationCode); + + // Submit form + await user.click(screen.getByText(/Verify and Enable/i)); + + // Check success message + await waitFor(() => { + expect(screen.getByText(/2FA has been set up successfully/i)).toBeInTheDocument(); + }); + + // Verify API calls + expect(mockOpenmrsFetch).toHaveBeenCalledWith( + expect.stringContaining('/validate'), + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ secret: mockSecret, code: mockVerificationCode }), + }), + ); + + expect(mockOpenmrsFetch).toHaveBeenCalledWith( + expect.stringContaining('/user/user-uuid'), + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('authentication.secondaryType'), + }), + ); + }); + + it('should handle validation errors', async () => { + const mockSecret = 'JBSWY3DPEHPK3PXP'; + const mockQrCode = 'data:image/png;base64,mock-qr-code'; + const mockVerificationCode = '123456'; + + mockOpenmrsFetch.mockImplementation((url, options) => { + if (url.includes('/secret')) { + return Promise.resolve({ + data: { secret: mockSecret, qrCode: mockQrCode }, + headers: new Headers(), + ok: true, + redirected: false, + status: 200, + statusText: 'OK', + type: 'cors' as ResponseType, + url: url, + body: null, + bodyUsed: false, + bytes: () => Promise.resolve(new Uint8Array(0)), + clone: () => mockOpenmrsFetch.mock.results[0].value, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve({ secret: mockSecret, qrCode: mockQrCode }), + text: () => Promise.resolve(JSON.stringify({ secret: mockSecret, qrCode: mockQrCode })), + }); + } + if (url.includes('/validate')) { + return Promise.resolve({ + data: { valid: false }, + headers: new Headers(), + ok: true, + redirected: false, + status: 200, + statusText: 'OK', + type: 'cors' as ResponseType, + url: url, + body: null, + bodyUsed: false, + bytes: () => Promise.resolve(new Uint8Array(0)), + clone: () => mockOpenmrsFetch.mock.results[0].value, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + json: () => Promise.resolve({ valid: false }), + text: () => Promise.resolve(JSON.stringify({ valid: false })), + }); + } + return Promise.reject(new Error('Not found')); + }); + + const user = userEvent.setup(); + render(); + + // Wait for secret to load + await waitFor(() => { + expect(screen.getByDisplayValue(mockSecret)).toBeInTheDocument(); + }); + + // Enter verification code + await user.type(screen.getByLabelText(/Enter code from your app/i), mockVerificationCode); + + // Submit form + await user.click(screen.getByText(/Verify and Enable/i)); + + // Check error message + await waitFor(() => { + expect(screen.getByText(/Invalid code. Please try again/i)).toBeInTheDocument(); + }); + }); + + it('should handle API errors', async () => { + mockOpenmrsFetch.mockImplementation(() => { + return Promise.reject(new Error('API Error')); + }); + + render(); + + // Check error message + await waitFor(() => { + expect(screen.getByText(/Failed to fetch TOTP secret/i)).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/apps/esm-login-app/src/root.component.tsx b/packages/apps/esm-login-app/src/root.component.tsx index 4dfdd7d36..e9e9f17ef 100644 --- a/packages/apps/esm-login-app/src/root.component.tsx +++ b/packages/apps/esm-login-app/src/root.component.tsx @@ -4,6 +4,8 @@ import ChangePassword from './change-password/change-password.component'; import LocationPickerView from './location-picker/location-picker-view.component'; import Login from './login/login.component'; import RedirectLogout from './redirect-logout/redirect-logout.component'; +import TOTPSetup from './login/totp-setup.component'; +import LoginWithTotp from './login/login-with-totp.component'; const Root: React.FC = () => { return ( @@ -14,6 +16,8 @@ const Root: React.FC = () => { } /> } /> } /> + } /> + } /> ); diff --git a/packages/apps/esm-login-app/src/routes.json b/packages/apps/esm-login-app/src/routes.json index d10fbfb5e..3bbdeb3f9 100644 --- a/packages/apps/esm-login-app/src/routes.json +++ b/packages/apps/esm-login-app/src/routes.json @@ -21,6 +21,12 @@ "route": "change-password", "online": true, "offline": true + }, + { + "component": "root", + "route": "totp-setup", + "online": true, + "offline": true } ], "extensions": [ @@ -52,6 +58,13 @@ "online": true, "offline": true, "order": 1 + }, + { + "name": "setup-mfa-link", + "slot": "user-panel-slot", + "component": "setupMfaLink", + "online": true, + "offline": true } ], "modals": [ diff --git a/packages/apps/esm-login-app/translations/en.json b/packages/apps/esm-login-app/translations/en.json index 4675c40c4..a5a47a078 100644 --- a/packages/apps/esm-login-app/translations/en.json +++ b/packages/apps/esm-login-app/translations/en.json @@ -7,8 +7,12 @@ "changingPassword": "Changing password", "confirmPassword": "Confirm new password", "continue": "Continue", + "enterCodeFromApp": "Enter code from your app", + "error": "Error", "errorChangingPassword": "Error changing password", + "failedToFetchSecret": "Failed to fetch 2FA secret.", "footerlogo": "Footer Logo", + "invalidCode": "Invalid code. Please try again.", "invalidCredentials": "Invalid username or password", "learnMore": "Learn more", "locationPreferenceRemoved": "Login location preference removed", @@ -21,6 +25,7 @@ "login": "Log in", "loginButtonIconDescription": "Log in button", "Logout": "Logout", + "mfaCode": "MFA Code", "newPassword": "New password", "newPasswordRequired": "New password is required", "oldPassword": "Old password", @@ -32,10 +37,20 @@ "passwordsDoNotMatch": "Passwords do not match", "poweredBySubtext": "An open-source medical record system and global community", "rememberLocationForFutureLogins": "Remember my location for future logins", + "scanQrCode": "Scan the QR code below with your authenticator app, or enter the secret manually.", + "secret": "Secret", "selectYourLocation": "Select your location from the list below. Use the search bar to find your location.", + "setupTwoFactorAuth": "Set up Two-Factor Authentication", "showPassword": "Show password", "submitting": "Submitting", + "success": "Success", + "totpQrCode": "2FA QR Code", + "totpSetupSuccess": "2FA has been set up successfully!", "username": "Username", + "userSessionNotFound": "User session not found.", "validValueRequired": "A valid value is required", + "verificationFailed": "Failed to verify code or update user.", + "verifyAndEnable": "Verify and Enable", + "verifying": "Verifying...", "welcome": "Welcome" } diff --git a/packages/framework/esm-api/src/openmrs-fetch.ts b/packages/framework/esm-api/src/openmrs-fetch.ts index aee55bf6b..61d8bb65b 100644 --- a/packages/framework/esm-api/src/openmrs-fetch.ts +++ b/packages/framework/esm-api/src/openmrs-fetch.ts @@ -178,6 +178,15 @@ export function openmrsFetch(path: string, fetchInit: FetchConfig = {}) // Server didn't respond with json } return response; + }) + .then((response) => { + if (response.headers.has('location')) { + const location = response.headers.get('location'); + if (location) { + setTimeout(() => navigate({ to: location }), 0); + } + } + return response; }); } } else { @@ -314,6 +323,7 @@ export class OpenmrsFetchError extends Error implements FetchError { export interface FetchConfig extends Omit { headers?: FetchHeaders; body?: FetchBody | string; + params?: Record; } type ResponseBody = string | FetchResponseJson; diff --git a/packages/framework/esm-framework/docs/API.md b/packages/framework/esm-framework/docs/API.md index e4821752d..d38080394 100644 --- a/packages/framework/esm-framework/docs/API.md +++ b/packages/framework/esm-framework/docs/API.md @@ -2942,7 +2942,7 @@ To cancel the network request, simply call `subscription.unsubscribe();` #### Defined in -[packages/framework/esm-api/src/openmrs-fetch.ts:269](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L269) +[packages/framework/esm-api/src/openmrs-fetch.ts:278](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L278) ___ diff --git a/packages/framework/esm-framework/docs/classes/OpenmrsFetchError.md b/packages/framework/esm-framework/docs/classes/OpenmrsFetchError.md index 4baf2e1d0..f930d8f6f 100644 --- a/packages/framework/esm-framework/docs/classes/OpenmrsFetchError.md +++ b/packages/framework/esm-framework/docs/classes/OpenmrsFetchError.md @@ -57,7 +57,7 @@ Error.constructor #### Defined in -[packages/framework/esm-api/src/openmrs-fetch.ts:302](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L302) +[packages/framework/esm-api/src/openmrs-fetch.ts:311](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L311) ## API Properties @@ -71,7 +71,7 @@ Error.constructor #### Defined in -[packages/framework/esm-api/src/openmrs-fetch.ts:310](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L310) +[packages/framework/esm-api/src/openmrs-fetch.ts:319](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L319) ___ @@ -85,7 +85,7 @@ ___ #### Defined in -[packages/framework/esm-api/src/openmrs-fetch.ts:311](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L311) +[packages/framework/esm-api/src/openmrs-fetch.ts:320](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L320) ___ diff --git a/packages/framework/esm-framework/docs/interfaces/FetchConfig.md b/packages/framework/esm-framework/docs/interfaces/FetchConfig.md index 4dc7d3505..feb5b243e 100644 --- a/packages/framework/esm-framework/docs/interfaces/FetchConfig.md +++ b/packages/framework/esm-framework/docs/interfaces/FetchConfig.md @@ -14,6 +14,7 @@ - [body](FetchConfig.md#body) - [headers](FetchConfig.md#headers) +- [params](FetchConfig.md#params) ### Other Properties @@ -37,7 +38,7 @@ #### Defined in -[packages/framework/esm-api/src/openmrs-fetch.ts:316](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L316) +[packages/framework/esm-api/src/openmrs-fetch.ts:325](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L325) ___ @@ -47,7 +48,17 @@ ___ #### Defined in -[packages/framework/esm-api/src/openmrs-fetch.ts:315](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L315) +[packages/framework/esm-api/src/openmrs-fetch.ts:324](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L324) + +___ + +### params + +• `Optional` **params**: `Record`<`string`, `string`\> + +#### Defined in + +[packages/framework/esm-api/src/openmrs-fetch.ts:326](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L326) ___ diff --git a/packages/framework/esm-framework/docs/interfaces/FetchError.md b/packages/framework/esm-framework/docs/interfaces/FetchError.md index 4ea9d3c5c..da055ffe8 100644 --- a/packages/framework/esm-framework/docs/interfaces/FetchError.md +++ b/packages/framework/esm-framework/docs/interfaces/FetchError.md @@ -21,7 +21,7 @@ #### Defined in -[packages/framework/esm-api/src/openmrs-fetch.ts:334](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L334) +[packages/framework/esm-api/src/openmrs-fetch.ts:344](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L344) ___ @@ -31,4 +31,4 @@ ___ #### Defined in -[packages/framework/esm-api/src/openmrs-fetch.ts:335](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L335) +[packages/framework/esm-api/src/openmrs-fetch.ts:345](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/openmrs-fetch.ts#L345)