diff --git a/.env b/.env index c27226af..94c96aee 100644 --- a/.env +++ b/.env @@ -7,5 +7,10 @@ NEXT_PUBLIC_CCDA_VALIDATOR_CURES_DOWNLOAD_URL=https://codeload.github.com/onc-he NEXT_PUBLIC_RELEASE_VERSION_URL=https://raw.githubusercontent.com/onc-healthit/site-content/master/site-ui-4/version.md NEXT_PUBLIC_RELEASE_DATE_URL=https://raw.githubusercontent.com/onc-healthit/site-content/master/site-ui-4/release-date.md -# TODO: Allow for this to be dynamic per environment file and delete from here. See: https://phase.dev/blog/nextjs-public-runtime-variables/ +# TODO: Allow for these to be dynamic per environment file and delete from here. +# For now though, these are STATIC as this file is injected in the build, +# and NEXT_PUBLIC env vars are not overridden by other env file definitions +# See: https://phase.dev/blog/nextjs-public-runtime-variables/ +NEXT_PUBLIC_IS_DEBUG_MODE=false +NEXT_PUBLIC_IS_EVENT_TRACKING=true NEXT_PUBLIC_SCORECARD_SAVESCORECARDSERVICE_API=https://ccda.healthit.gov/scorecard/savescorecardservice diff --git a/package-lock.json b/package-lock.json index 29a67fa1..48cbc452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "dompurify": "^3.1.6", "lodash": "^4.17.21", "marked": "^12.0.2", - "next": "14.2.3", + "next": "14.2.13", "next-auth": "^4.24.7", "nookies": "^2.5.2", "react": "^18", @@ -1048,9 +1048,9 @@ } }, "node_modules/@next/env": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.3.tgz", - "integrity": "sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==" + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.2.13.tgz", + "integrity": "sha512-s3lh6K8cbW1h5Nga7NNeXrbe0+2jIIYK9YaA9T7IufDWnZpozdFUp6Hf0d5rNWUKu4fEuSX2rCKlGjCrtylfDw==" }, "node_modules/@next/eslint-plugin-next": { "version": "14.0.4", @@ -1062,9 +1062,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.3.tgz", - "integrity": "sha512-3pEYo/RaGqPP0YzwnlmPN2puaF2WMLM3apt5jLW2fFdXD9+pqcoTzRk+iZsf8ta7+quAe4Q6Ms0nR0SFGFdS1A==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.2.13.tgz", + "integrity": "sha512-IkAmQEa2Htq+wHACBxOsslt+jMoV3msvxCn0WFSfJSkv/scy+i/EukBKNad36grRxywaXUYJc9mxEGkeIs8Bzg==", "cpu": [ "arm64" ], @@ -1077,9 +1077,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.3.tgz", - "integrity": "sha512-6adp7waE6P1TYFSXpY366xwsOnEXM+y1kgRpjSRVI2CBDOcbRjsJ67Z6EgKIqWIue52d2q/Mx8g9MszARj8IEA==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.2.13.tgz", + "integrity": "sha512-Dv1RBGs2TTjkwEnFMVL5XIfJEavnLqqwYSD6LXgTPdEy/u6FlSrLBSSfe1pcfqhFEXRAgVL3Wpjibe5wXJzWog==", "cpu": [ "x64" ], @@ -1092,9 +1092,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.3.tgz", - "integrity": "sha512-cuzCE/1G0ZSnTAHJPUT1rPgQx1w5tzSX7POXSLaS7w2nIUJUD+e25QoXD/hMfxbsT9rslEXugWypJMILBj/QsA==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.2.13.tgz", + "integrity": "sha512-yB1tYEFFqo4ZNWkwrJultbsw7NPAAxlPXURXioRl9SdW6aIefOLS+0TEsKrWBtbJ9moTDgU3HRILL6QBQnMevg==", "cpu": [ "arm64" ], @@ -1107,9 +1107,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.3.tgz", - "integrity": "sha512-0D4/oMM2Y9Ta3nGuCcQN8jjJjmDPYpHX9OJzqk42NZGJocU2MqhBq5tWkJrUQOQY9N+In9xOdymzapM09GeiZw==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.2.13.tgz", + "integrity": "sha512-v5jZ/FV/eHGoWhMKYrsAweQ7CWb8xsWGM/8m1mwwZQ/sutJjoFaXchwK4pX8NqwImILEvQmZWyb8pPTcP7htWg==", "cpu": [ "arm64" ], @@ -1122,9 +1122,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.3.tgz", - "integrity": "sha512-ENPiNnBNDInBLyUU5ii8PMQh+4XLr4pG51tOp6aJ9xqFQ2iRI6IH0Ds2yJkAzNV1CfyagcyzPfROMViS2wOZ9w==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.13.tgz", + "integrity": "sha512-aVc7m4YL7ViiRv7SOXK3RplXzOEe/qQzRA5R2vpXboHABs3w8vtFslGTz+5tKiQzWUmTmBNVW0UQdhkKRORmGA==", "cpu": [ "x64" ], @@ -1137,9 +1137,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.3.tgz", - "integrity": "sha512-BTAbq0LnCbF5MtoM7I/9UeUu/8ZBY0i8SFjUMCbPDOLv+un67e2JgyN4pmgfXBwy/I+RHu8q+k+MCkDN6P9ViQ==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.13.tgz", + "integrity": "sha512-4wWY7/OsSaJOOKvMsu1Teylku7vKyTuocvDLTZQq0TYv9OjiYYWt63PiE1nTuZnqQ4RPvME7Xai+9enoiN0Wrg==", "cpu": [ "x64" ], @@ -1152,9 +1152,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.3.tgz", - "integrity": "sha512-AEHIw/dhAMLNFJFJIJIyOFDzrzI5bAjI9J26gbO5xhAKHYTZ9Or04BesFPXiAYXDNdrwTP2dQceYA4dL1geu8A==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.2.13.tgz", + "integrity": "sha512-uP1XkqCqV2NVH9+g2sC7qIw+w2tRbcMiXFEbMihkQ8B1+V6m28sshBwAB0SDmOe0u44ne1vFU66+gx/28RsBVQ==", "cpu": [ "arm64" ], @@ -1167,9 +1167,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.3.tgz", - "integrity": "sha512-vga40n1q6aYb0CLrM+eEmisfKCR45ixQYXuBXxOOmmoV8sYST9k7E3US32FsY+CkkF7NtzdcebiFT4CHuMSyZw==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.2.13.tgz", + "integrity": "sha512-V26ezyjPqQpDBV4lcWIh8B/QICQ4v+M5Bo9ykLN+sqeKKBxJVDpEc6biDVyluTXTC40f5IqCU0ttth7Es2ZuMw==", "cpu": [ "ia32" ], @@ -1182,9 +1182,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.3.tgz", - "integrity": "sha512-Q1/zm43RWynxrO7lW4ehciQVj+5ePBhOK+/K2P7pLFX3JaJ/IZVC69SHidrmZSOkqz7ECIOhhy7XhAFG4JYyHA==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.2.13.tgz", + "integrity": "sha512-WwzOEAFBGhlDHE5Z73mNU8CO8mqMNLqaG+AO9ETmzdCQlJhVtWZnOl2+rqgVQS+YHunjOWptdFmNfbpwcUuEsw==", "cpu": [ "x64" ], @@ -6337,11 +6337,11 @@ "dev": true }, "node_modules/next": { - "version": "14.2.3", - "resolved": "https://registry.npmjs.org/next/-/next-14.2.3.tgz", - "integrity": "sha512-dowFkFTR8v79NPJO4QsBUtxv0g9BrS/phluVpMAt2ku7H+cbcBJlopXjkWlwxrk/xGqMemr7JkGPGemPrLLX7A==", + "version": "14.2.13", + "resolved": "https://registry.npmjs.org/next/-/next-14.2.13.tgz", + "integrity": "sha512-BseY9YNw8QJSwLYD7hlZzl6QVDoSFHL/URN5K64kVEVpCsSOWeyjbIGK+dZUaRViHTaMQX8aqmnn0PHBbGZezg==", "dependencies": { - "@next/env": "14.2.3", + "@next/env": "14.2.13", "@swc/helpers": "0.5.5", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -6356,15 +6356,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.2.3", - "@next/swc-darwin-x64": "14.2.3", - "@next/swc-linux-arm64-gnu": "14.2.3", - "@next/swc-linux-arm64-musl": "14.2.3", - "@next/swc-linux-x64-gnu": "14.2.3", - "@next/swc-linux-x64-musl": "14.2.3", - "@next/swc-win32-arm64-msvc": "14.2.3", - "@next/swc-win32-ia32-msvc": "14.2.3", - "@next/swc-win32-x64-msvc": "14.2.3" + "@next/swc-darwin-arm64": "14.2.13", + "@next/swc-darwin-x64": "14.2.13", + "@next/swc-linux-arm64-gnu": "14.2.13", + "@next/swc-linux-arm64-musl": "14.2.13", + "@next/swc-linux-x64-gnu": "14.2.13", + "@next/swc-linux-x64-musl": "14.2.13", + "@next/swc-win32-arm64-msvc": "14.2.13", + "@next/swc-win32-ia32-msvc": "14.2.13", + "@next/swc-win32-x64-msvc": "14.2.13" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", diff --git a/package.json b/package.json index f354e741..c3b36d7d 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "dompurify": "^3.1.6", "lodash": "^4.17.21", "marked": "^12.0.2", - "next": "14.2.3", + "next": "14.2.13", "next-auth": "^4.24.7", "nookies": "^2.5.2", "react": "^18", diff --git a/public/certificates/xdr-tls/keyAndCert.zip b/public/certificates/xdr-tls/keyAndCert.zip new file mode 100644 index 00000000..3923e70c Binary files /dev/null and b/public/certificates/xdr-tls/keyAndCert.zip differ diff --git a/public/shared/LogoBackgroundImage.png b/public/shared/LogoBackgroundImage.png new file mode 100644 index 00000000..9853742e Binary files /dev/null and b/public/shared/LogoBackgroundImage.png differ diff --git a/public/shared/ONCLogo-backgroundImage.png b/public/shared/ONCLogo-backgroundImage.png deleted file mode 100644 index f4a77fab..00000000 Binary files a/public/shared/ONCLogo-backgroundImage.png and /dev/null differ diff --git a/public/shared/SITEWhiteLogo.svg b/public/shared/SITEWhiteLogo.svg index 0e2ede48..6d6416e1 100644 --- a/public/shared/SITEWhiteLogo.svg +++ b/public/shared/SITEWhiteLogo.svg @@ -1,10 +1,36 @@ - - - - - - - - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/shared/site-nav-logo.svg b/public/shared/site-nav-logo.svg index 2c0123c4..d41a71e9 100644 --- a/public/shared/site-nav-logo.svg +++ b/public/shared/site-nav-logo.svg @@ -1,9 +1,36 @@ - - - - - - - - - + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/account/changepassword/page.tsx b/src/app/account/changepassword/page.tsx new file mode 100644 index 00000000..ecb2629e --- /dev/null +++ b/src/app/account/changepassword/page.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import ChangePasswordHome from '@/components/account/ChangePasswordHome' + +const ChangePassword = () => { + return ( + <> + + + ) +} + +export default ChangePassword diff --git a/src/app/account/info/page.tsx b/src/app/account/info/page.tsx new file mode 100644 index 00000000..e3f4ea88 --- /dev/null +++ b/src/app/account/info/page.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import InfoHome from '@/components/account/InfoHome' + +const AccountInfo = () => { + return ( + <> + + + ) +} + +export default AccountInfo diff --git a/src/assets/SMTPTestCases.tsx b/src/assets/SMTPTestCases.tsx index e5707db1..ee3cbcfa 100644 --- a/src/assets/SMTPTestCases.tsx +++ b/src/assets/SMTPTestCases.tsx @@ -2468,6 +2468,7 @@ const testCases = { sutHisp: true, criteria: "['h2-6']", sutEdge: false, + ccdaFileRequired: true, fields: [ { label: 'C-CDA Document Type', @@ -2475,7 +2476,6 @@ const testCases = { datatype: 'CCDAWidget', value: 'ccdaReferenceFilename', readOnly: false, - ccdaFileRequired: true, display: true, }, ], diff --git a/src/components/account/ChangePasswordHome.tsx b/src/components/account/ChangePasswordHome.tsx new file mode 100644 index 00000000..786be094 --- /dev/null +++ b/src/components/account/ChangePasswordHome.tsx @@ -0,0 +1,189 @@ +'use client' +import PageAlertBox from '@/components/shared/PageAlertBox' +import { + Alert, + Box, + Button, + Card, + Container, + Divider, + Grid, + IconButton, + InputAdornment, + LinearProgress, + TextField, + Typography, + CardContent, +} from '@mui/material' +import { useSession } from 'next-auth/react' +import { useState } from 'react' +import { changePassword } from './actions' +import _ from 'lodash' +import { VisibilityOff, Visibility } from '@mui/icons-material' + +const LoginButtonStyle = { + padding: '10px 0', + width: '100%', + margin: 1, +} + +const ChangePasswordHome = () => { + const { data: session, status } = useSession() + const [isLoading, setIsLoading] = useState(false) + const [oldPassword, setOldPassword] = useState('') + const [password, setPassword] = useState('') + const [repeatPassword, setRepeatPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) + const [message, setMessage] = useState({ message: '', severity: 'info' }) + + const handleChangePassword = async (e: React.FormEvent) => { + setIsLoading(true) + e.preventDefault() + if (password === repeatPassword) { + changePassword(oldPassword, password).then((data) => { + if (data === true) { + setMessage({ message: 'Password updated', severity: 'success' }) + setOldPassword('') + setPassword('') + setRepeatPassword('') + } else { + setMessage({ message: `${data}`, severity: 'error' }) + setOldPassword('') + setPassword('') + setRepeatPassword('') + } + }) + } else { + setMessage({ message: 'Passwords do not match', severity: 'error' }) + setOldPassword('') + setPassword('') + setRepeatPassword('') + } + setIsLoading(false) + } + + const handleClickShowPassword = () => { + setShowPassword((prev) => !prev) + } + + const ChangePasswordGrid = ( + handleChangePassword(e)} sx={{ mt: 5 }}> + + + setOldPassword(e.target.value)} + InputProps={{ + endAdornment: ( + + + {showPassword ? : } + + + ), + }} + /> + + + setPassword(e.target.value)} + InputProps={{ + endAdornment: ( + + + {showPassword ? : } + + + ), + }} + /> + + + setRepeatPassword(e.target.value)} + InputProps={{ + endAdornment: ( + + + {showPassword ? : } + + + ), + }} + /> + + + + + + + + + ) + + return status !== 'authenticated' ? ( + + + + ) : ( + <> + {isLoading ? ( + + ) : ( + <> + + + + + Change Password + + + + To keep your account secure, please enter your current password and your new password. Make sure your + new password includes a mix of letters, numbers, and symbols. + + + Choose a password that you haven't used before. Once you've filled in the fields, click{' '} + "Save" to update your password. If you need assistance, please contact our + support team. Stay secure! + + {ChangePasswordGrid} + + + {!_.isEmpty(message.message) && ( + setMessage({ message: '', severity: 'info' })} + > + {message.message} + + )} + + + )} + + ) +} + +export default ChangePasswordHome diff --git a/src/components/account/InfoHome.tsx b/src/components/account/InfoHome.tsx new file mode 100644 index 00000000..9c304bee --- /dev/null +++ b/src/components/account/InfoHome.tsx @@ -0,0 +1,108 @@ +'use client' +import PageAlertBox from '@/components/shared/PageAlertBox' +import { Box, Card, Container, Divider, LinearProgress, List, ListItem, ListItemText, Typography } from '@mui/material' +import { useSession } from 'next-auth/react' +import { useEffect, useState } from 'react' +import { fetchAccountInfo } from './actions' +import Profile from '../direct/shared/Profile' +import BannerBox from '../shared/BannerBox' +import { AccountCircleOutlined, EmailOutlined } from '@mui/icons-material' + +const InfoHome = () => { + const { data: session, status } = useSession() + const [directList, setDirectList] = useState([]) + const [smtpProfiles, setSmtpProfiles] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + async function fetchLoggedInUsersAccountInfo() { + const usersAccountInfo = await fetchAccountInfo() + if (usersAccountInfo && typeof usersAccountInfo !== 'string') { + setDirectList(usersAccountInfo.directList || []) + setSmtpProfiles(usersAccountInfo.smtpProfiles || []) + } + } + if (status === 'authenticated') { + setIsLoading(true) + fetchLoggedInUsersAccountInfo().then(() => setIsLoading(false)) + } + }, [session, status]) + + return status !== 'authenticated' ? ( + + + + ) : ( + <> + + Welcome to your Account Information page! Here, you can view your direct email address and SMTP account + details. Ensure these settings are correct for optimal email delivery. Please review this information + regularly to keep your account functioning smoothly. If you have any questions, feel free to reach out to + our support team. + + } + /> + + {isLoading ? ( + + ) : ( + + + My Account Info + + + + + + + My Direct Email Addresses + + + + {directList.map((directEmail, index) => { + return ( + + + + ) + })} + + + + + + + My SMTP Profiles + + + + {smtpProfiles.map((profile, index) => { + return ( + + + + ) + })} + + + + + )} + + + ) +} + +export default InfoHome diff --git a/src/components/account/actions.ts b/src/components/account/actions.ts new file mode 100644 index 00000000..7874ee18 --- /dev/null +++ b/src/components/account/actions.ts @@ -0,0 +1,120 @@ +'use server' +import { authOptions } from '@/lib/auth' +import { getServerSession } from 'next-auth' +import Profile from '../direct/shared/Profile' + +const ETT_API_URL = process.env.ETT_API_URL + +export interface AccountInfo { + smtpProfiles?: Profile[] + directList?: string[] +} + +export async function changePassword(oldPassword: string, newPassword: string) { + const session = await getServerSession(authOptions) + const jsessionid = session?.user?.jsessionid ?? '' + const ettAPIUrl = `${ETT_API_URL}/passwordManager/change` + try { + const response = await fetch(ettAPIUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `JSESSIONID=${jsessionid}`, + }, + body: JSON.stringify({ newPassword: newPassword, oldPassword: oldPassword }), + }) + const data = await response.json() + if (!response.ok) { + console.log(`Error: ${JSON.stringify(data)}`) + return data.message + } + return data + } catch (error: unknown) { + if (error instanceof Error) { + return JSON.stringify({ + status: 500, + success: false, + error: error.message || 'An error occurred', + }) + } + } +} + +export async function fetchAccountInfo() { + const session = await getServerSession(authOptions) + const jsessionid = session?.user?.jsessionid ?? '' + try { + const accountInfo: AccountInfo = {} + return Promise.allSettled([fetchProfiles(jsessionid), fetchDirectEmails(jsessionid)]).then( + ([profiles, directEmails]) => { + if (profiles.status === 'fulfilled') { + accountInfo.smtpProfiles = profiles.value.filter((profile: Profile) => profile.profileName !== null) + } + if (directEmails.status === 'fulfilled') { + accountInfo.directList = directEmails.value + } + return accountInfo + } + ) + } catch (error: unknown) { + if (error instanceof Error) { + return JSON.stringify({ + status: 500, + success: false, + error: error.message || 'An error occurred', + }) + } + } +} + +async function fetchProfiles(jsessionid: string) { + const ettAPIUrl = `${ETT_API_URL}/smtpProfile` + try { + const response = await fetch(ettAPIUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `JSESSIONID=${jsessionid}`, + }, + }) + const data = await response.json() + if (!response.ok) { + console.log(`Error: ${data}`) + } + return data + } catch (error: unknown) { + if (error instanceof Error) { + return JSON.stringify({ + status: 500, + success: false, + error: error.message || 'An error occurred', + }) + } + } +} + +async function fetchDirectEmails(jsessionid: string) { + const ettAPIUrl = `${ETT_API_URL}/registration/direct` + try { + const response = await fetch(ettAPIUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `JSESSIONID=${jsessionid}`, + }, + }) + const data = await response.json() + if (!response.ok) { + console.log(`Error: ${data}`) + } + return data + } catch (error: unknown) { + if (error instanceof Error) { + return JSON.stringify({ + status: 500, + success: false, + error: error.message || 'An error occurred', + }) + } + } +} diff --git a/src/components/archived/ArchiveHome.tsx b/src/components/archived/ArchiveHome.tsx index 27c0c33e..2025394d 100644 --- a/src/components/archived/ArchiveHome.tsx +++ b/src/components/archived/ArchiveHome.tsx @@ -8,6 +8,7 @@ import FAQCard from './ArchiveCard' import faq from './data/FAQ.json' import VideoItem from '../resources/VideoItem' import ForwardToInboxOutlinedIcon from '@mui/icons-material/ForwardToInboxOutlined' +import eventTrack from '@/services/analytics' export default function ArchiveHome() { const menuItems: menuProps[] = [ @@ -26,13 +27,18 @@ export default function ArchiveHome() { icon: , }, ] + function trackMenuItemClick(heading: string) { - if (typeof window.gtag === 'function') { - window.gtag('event', 'Click FAQs sub menu', { - event_category: 'Navigation', - event_label: heading, - }) - } + eventTrack('Sub Menu Anchor Link Click', 'Archived', `Navigated to ${heading} via sub menu in Archived page`) + // TODO: Decided if the new format for eventType, eventCategory, and eventLabel is appropriate. + // Remove the following code either way, but, if new format is not preferred, + // copy the old string labels into event track before deletion + // if (typeof window.gtag === 'function') { + // window.gtag('event', 'Click FAQs sub menu', { + // event_category: 'Navigation', + // event_label: heading, + // }) + // } } return ( diff --git a/src/components/c-cda/scorecard/ScorecardHome.tsx b/src/components/c-cda/scorecard/ScorecardHome.tsx index 84312674..50214091 100644 --- a/src/components/c-cda/scorecard/ScorecardHome.tsx +++ b/src/components/c-cda/scorecard/ScorecardHome.tsx @@ -29,9 +29,13 @@ import BannerBox from '@shared/BannerBox' import SectionHeader from '@shared/SectionHeader' import styles from '@shared/styles.module.css' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { useFormState } from 'react-dom' +import ErrorDisplayCard from '../validation/results/ErrorDisplay' +import ValidatorLoadingCard from '../validation/results/ResultsLoading' import ScorecardResultsDialog from './ScorecardResultsDialog' -import { getDemoSample } from './serverside/demoSampleService' +import { postToScorecardForValidation } from './serverside/actions' +import { allSampleOptions, allSampleOptionsExceptDebug, getDemoSample } from './serverside/demoSampleService' import { getDefaultReferenceResult, getFailingSectionSpecificErrorCount, @@ -45,80 +49,18 @@ import { SectionNameEnum, SORT_ORDER_STARTING_VALUE, } from './types/ScorecardConstants' -import ErrorDisplayCard from '../validation/results/ErrorDisplay' +import eventTrack from '@/services/analytics' export default function ScorecardHome() { const [resultsDialogState, setResultsDialogState] = useState(false) const handleCloseResultsDialog = () => { setResultsDialogState(false) } - const [isTryMeDemo, setIsTryMeDemo] = useState(false) - const [scorecardHomeError, setScorecardHomeError] = useState('') - - const demoSampleOptions: { label: string; value: string }[] = [ - { - label: 'High Scoring Sample', - value: 'highScoringSample.json', - }, - { - label: 'Low Scoring Sample (C-CDA R2.1)', - value: 'lowScoringSample_r21.json', - }, - { - label: 'Low Scoring Sample (C-CDA R1.0)', - value: 'lowScoringSample_r11.json', - }, - { - label: 'Sample With Errors', - value: 'sampleWithErrors.json', - }, - ] + const [isTryMeDemo, setIsTryMeDemo] = useState(false) - const newDemoSampleOptions: { label: string; value: string }[] = [ - { - label: 'Sample with IG Errors', - value: 'sampleWithIGErrors.json', - }, - { - label: 'Sample with Vocabulary Errors', - value: 'sampleWithVocabularyErrors.json', - }, - { - label: 'Sample with Empty Sections', - value: 'sampleWithEmptySections.json', - }, - { - label: 'Sample with Empty Sections and Errors', - value: 'sampleWithEmptySectionsAndErrors.json', - }, - ] - demoSampleOptions.push(...newDemoSampleOptions) - - const debugSampleOptions: { label: string; value: string }[] = [ - { - label: 'Schema Errors', - value: 'sampleWithSchemaErrors.json', - }, - { - label: 'No Content', - value: 'sampleWithoutAnyContent.json', - }, - { - label: 'SITE 3 High Scoring Sample', - value: 'site3-highScoringSample.json', - }, - { - label: 'SITE 3 Low Scoring Sample', - value: 'site3-lowScoringSample.json', - }, - { - label: 'SITE 3 Sample With Errors', - value: 'site3-sampleWithErrors.json', - }, - ] - // TODO: Tie this to a debug mode env var (if true, push, otherwise maybe don't as may not want in production) - demoSampleOptions.push(...debugSampleOptions) + const IS_DEBUG_MODE: boolean = process.env.NEXT_PUBLIC_IS_DEBUG_MODE === 'true' + const demoSampleOptions = IS_DEBUG_MODE ? allSampleOptions : allSampleOptionsExceptDebug const [demoSampleOption, setDemoSampleOption] = useState(demoSampleOptions[0].value) const [scorecardResponseJson, setScorecardResponseJson] = useState() @@ -130,20 +72,68 @@ export default function ScorecardHome() { getDefaultReferenceResult(ReferenceInstanceEnum.VOCAB) ) + const [scorecardHomeError, setScorecardHomeError] = useState('') + const [isDisableStartButton, setIsDisableStartButton] = useState(true) + const formRef = useRef(null) + const [formState, formAction] = useFormState(postToScorecardForValidation, { response: null }) + const [fileName, setFileName] = useState('') + // TODO: Consider setting this based off of file size? + // Right now, it is the regular validator's estimate for IG + Vocab (15), just like this, but it adds best practice + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [estimatedValidationTime, setEstimatedValidationTime] = useState(20) + const [isValidating, setIsValidating] = useState(false) + + /* Set API response when updated */ useEffect(() => { - if (scorecardResponseJson) { - console.log('Updated scorecardResponseJson:', scorecardResponseJson) + if (formState) { + if (formState.error) { + setScorecardHomeError( + formState.error + (formState.errorStatus ? ` Status Number: ${formState.errorStatus}` : '') + ) + setIsValidating(false) + } else if (formState.response) { + const newScorecardResponseJson: ScorecardJsonResponseType = formState.response + setScorecardResponseJson(newScorecardResponseJson) + } } - if (scResults) { - console.log('Updated scResults:', scResults) + }, [formState]) + + /* Handle results display after API call results returned */ + useEffect(() => { + if (scorecardResponseJson) { + const [isValidResults, errorMessage] = processResults(scorecardResponseJson) + setIsValidating(false) + displayResults(isValidResults, errorMessage) } - if (igResults) { - console.log('Updated igResults', igResults) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scorecardResponseJson]) + + /* Only enable START button if file is selected */ + useEffect(() => { + if (!fileName) { + setIsDisableStartButton(true) + } else { + setIsDisableStartButton(false) } - if (vocabResults) { - console.log('Updated vocabResults', vocabResults) + }, [fileName]) + + /* Debug logs */ + useEffect(() => { + if (IS_DEBUG_MODE) { + if (scorecardResponseJson) { + console.log('Updated scorecardResponseJson:', scorecardResponseJson) + } + if (scResults) { + console.log('Updated scResults:', scResults) + } + if (igResults) { + console.log('Updated igResults', igResults) + } + if (vocabResults) { + console.log('Updated vocabResults', vocabResults) + } } - }, [scorecardResponseJson, scResults, igResults, vocabResults]) + }, [scorecardResponseJson, scResults, igResults, vocabResults, IS_DEBUG_MODE]) const handleDemoSampleChange = (e: SelectChangeEvent) => { console.log('handleDemoSampleChange(e), event:', e) @@ -151,12 +141,7 @@ export default function ScorecardHome() { console.log(`Selected ${demoSampleSelected}`) setDemoSampleOption(demoSampleSelected) - if (typeof window.gtag === 'function') { - window.gtag('event', 'Select demo dropdown', { - event_category: 'dropdown', - event_label: `Selected ${demoSampleSelected}`, - }) - } + eventTrack('Dropdown Selection', 'Scorecard', `Selected ${demoSampleSelected} sample within Try Me Demo dropdown`) } const handleSubmitDemoStart = (e: React.FormEvent) => { @@ -164,12 +149,11 @@ export default function ScorecardHome() { console.log('handleSubmitDemoStart(e), event: ', e) console.log('Starting demo with sample: ' + demoSampleOption) + setIsTryMeDemo(true) + try { const newScorecardResponseJson: ScorecardJsonResponseType = getDemoSample(demoSampleOption) setScorecardResponseJson(newScorecardResponseJson) - - const [isValidResults, errorMessage]: [boolean, string | null] = processResults(newScorecardResponseJson) - displayResults(isValidResults, errorMessage, true) } catch (error) { const errorMessagePrefix = 'Error running Scorecard Demo' console.error( @@ -181,29 +165,59 @@ export default function ScorecardHome() { ${error}. Please try again later.`) } - if (typeof window.gtag === 'function') { - window.gtag('event', 'Try Me', { - event_category: 'Button', - event_label: 'Score card try me demo', - }) + eventTrack('Button Click Form Submission', 'Scorecard', 'Run the Try Me Demo with selected file and view results') + } + + const getFileName = (data: File[]) => { + console.log(data[0]?.name) + if (data) { + setFileName(data[0]?.name) + } else { + console.log('SC Filename is undefined...') } } + const resetResultsData = () => { + setScorecardResponseJson(undefined) + setScResults(undefined) + setIgResults(getDefaultReferenceResult(ReferenceInstanceEnum.IG_CONFORMANCE)) + setVocabResults(getDefaultReferenceResult(ReferenceInstanceEnum.VOCAB)) + setIsTryMeDemo(false) + } + const handleSubmitScorecardStart = (e: React.FormEvent) => { e.preventDefault() console.log('handleSubmitScorecardStart(e), event: ', e) + console.log('Starting C-CDA Scorecard validation with submitted C-CDA file: ' + fileName) + + resetResultsData() + setIsValidating(true) - // TODO: Support POST response from API - // const newScorecardResponseJson: ScorecardJsonResponseType = POST response from API - // setScorecardResponseJson(newScorecardResponseJson) - // - // const [isValidResults, errorMessage]: [boolean, string | null] = processResults(newScorecardResponseJson) - // displayResults(isValidResults, errorMessage, false) + try { + if (formRef.current) { + const formData = new FormData(formRef.current) + formAction(formData) + if (formState.error) { + throw new Error(formState.error) + } + } + } catch (error) { + const errorMessagePrefix = 'Error running C-CDA Scorecard validation' + console.error( + `${errorMessagePrefix} in handleSubmitScorecardStart(): + Failed to run C-CDA Scorecard validation in handleSubmitScorecardStart(), unable to load submitted file: `, + error + ) + setScorecardHomeError(`${errorMessagePrefix}: ${error instanceof Error ? error.message : String(error)}.`) + setIsValidating(false) + } + + eventTrack('Button Click Form Submission', 'Scorecard', 'Run SC validation with selected file and view results') } const processResults = (newJson: ScorecardJsonResponseType): [boolean, string | null] => { if (newJson) { - if (newJson.success == false) { + if (!newJson.success) { // Handle valid JSON but with an error returned from the server const error = newJson.errorMessage const file = newJson.filename @@ -302,7 +316,7 @@ export default function ScorecardHome() { */ const sortResultsOrderByGradeTypeAndNumberOfIssues = ( results: ScorecardResultsType | undefined, - isisAscending: boolean + isAscending: boolean ) => { if (results?.categoryList) { const gradeOrder: { [key: string]: number } = { @@ -320,20 +334,20 @@ export default function ScorecardHome() { const nullFlavorNIComparison: number = compareNullFlavorNI(a, b) if (nullFlavorNIComparison !== 0) return nullFlavorNIComparison - const conformanceComparison: number = compareConformance(a, b, isisAscending) + const conformanceComparison: number = compareConformance(a, b, isAscending) if (conformanceComparison !== 0) return conformanceComparison - const vocabularyComparison: number = compareVocabulary(a, b, isisAscending) + const vocabularyComparison: number = compareVocabulary(a, b, isAscending) if (vocabularyComparison !== 0) return vocabularyComparison const gradeComparison: number = compareGrades( gradeOrder, - isisAscending ? (b.categoryGrade as GradeEnum) : (a.categoryGrade as GradeEnum), - isisAscending ? (a.categoryGrade as GradeEnum) : (b.categoryGrade as GradeEnum) + isAscending ? (b.categoryGrade as GradeEnum) : (a.categoryGrade as GradeEnum), + isAscending ? (a.categoryGrade as GradeEnum) : (b.categoryGrade as GradeEnum) ) if (gradeComparison !== 0) return gradeComparison - const numberOfIssuesComparison: number = compareNumberOfIssues(a, b, isisAscending) + const numberOfIssuesComparison: number = compareNumberOfIssues(a, b, isAscending) return numberOfIssuesComparison // No check for 0 on last comparison because we have to return something }) @@ -382,9 +396,8 @@ export default function ScorecardHome() { : (a.numberOfIssues ?? 0) - (b.numberOfIssues ?? 0) } - const displayResults = (isValidResults: boolean, errorMessage: string | null, isTryMeButtonClick: boolean) => { + const displayResults = (isValidResults: boolean, errorMessage: string | null) => { if (isValidResults) { - setIsTryMeDemo(isTryMeButtonClick) setResultsDialogState(true) } else { const finalErrorMessage = `Error: ${errorMessage ? errorMessage : 'Unknown error message'} ` @@ -393,6 +406,7 @@ export default function ScorecardHome() { } } + // TODO: Separate out as much modal data and logic as possible into it's own component for cleaner code const modalUrls = [ 'https://raw.githubusercontent.com/onc-healthit/site-content/master/CCDAScorecardIntroduction.md', 'https://raw.githubusercontent.com/onc-healthit/site-content/master/CCDAScorecardResultsInterpretation.md', @@ -443,9 +457,9 @@ export default function ScorecardHome() { {/* Main Content */} - {/* Actual Scorecard Validation */} - + {/* Actual Scorecard Validation */} + - {/* Scorecard User File Upload */} - + - {/* Scorecard Validation Submit */} - @@ -583,6 +595,10 @@ export default function ScorecardHome() { + {isValidating && ( + + )} + { const demoSample = demoSampleMap[optionValue] + console.log('Value of demoSample: ', demoSample) if (!demoSample) { throw new Error('Invalid option value selected or sent from the Scorecard Try Me demo dropdown') } return demoSample } + +const currentDemoSampleOptions: { label: string; value: string }[] = [ + { + label: 'High Scoring Sample', + value: HIGH_SCORING, + }, + { + label: 'Low Scoring Sample (C-CDA R2.1)', + value: LOW_SCORING, + }, + { + label: 'Low Scoring Sample (C-CDA R1.0)', + value: LOW_SCORING_R11, + }, + { + label: 'Sample With Errors', + value: WITH_ERRORS, + }, +] + +const newDemoSampleOptions: { label: string; value: string }[] = [ + { + label: 'Sample with IG Errors', + value: WITH_IG_ERRORS, + }, + { + label: 'Sample with Vocabulary Errors', + value: WITH_VOCAB_ERRORS, + }, + { + label: 'Sample with Empty Sections', + value: WITH_EMPTY_SECTIONS, + }, + { + label: 'Sample with Empty Sections and Errors', + value: WITH_EMPTY_SECTIONS_AND_ERRORS, + }, +] + +const oldDemoSampleOptions: { label: string; value: string }[] = [ + { + label: 'SITE 3 High Scoring Sample', + value: SITE3_HIGH_SCORING, + }, + { + label: 'SITE 3 Low Scoring Sample', + value: SITE3_LOW_SCORING, + }, + { + label: 'SITE 3 Sample With Errors', + value: SITE3_WITH_ERRORS, + }, +] + +export const debugSampleOptions: { label: string; value: string }[] = [ + { + label: 'Schema Errors', + value: WITH_SCHEMA_ERRORS, + }, + { + label: 'No Content', + value: WITHOUT_CONTENT, + }, +] + +export const allSampleOptionsExceptDebug: { label: string; value: string }[] = [ + ...currentDemoSampleOptions, + ...newDemoSampleOptions, + ...oldDemoSampleOptions, +] + +export const allSampleOptions: { label: string; value: string }[] = [ + ...currentDemoSampleOptions, + ...newDemoSampleOptions, + ...oldDemoSampleOptions, + ...debugSampleOptions, +] diff --git a/src/components/c-cda/scorecard/summary/ScorecardCompareChartSummary.tsx b/src/components/c-cda/scorecard/summary/ScorecardCompareChartSummary.tsx index f28df6f9..ec48044b 100644 --- a/src/components/c-cda/scorecard/summary/ScorecardCompareChartSummary.tsx +++ b/src/components/c-cda/scorecard/summary/ScorecardCompareChartSummary.tsx @@ -1,4 +1,4 @@ -import SwitchWithLabel from '@/components/shared/SwitchWIthLabel' +import SwitchWithLabel from '@/components/shared/SwitchWithLabel' import { Box, Typography } from '@mui/material' import { BarChart } from '@mui/x-charts/BarChart' import { useState } from 'react' diff --git a/src/components/c-cda/scorecard/types/ScorecardJsonResponseType.ts b/src/components/c-cda/scorecard/types/ScorecardJsonResponseType.ts index 281b3ec3..2d617937 100644 --- a/src/components/c-cda/scorecard/types/ScorecardJsonResponseType.ts +++ b/src/components/c-cda/scorecard/types/ScorecardJsonResponseType.ts @@ -4,7 +4,6 @@ export interface ScorecardJsonResponseType { ccdaDocumentType: string | null results: ScorecardResultsType | null referenceResults: ScorecardReferenceResultType[] | [] - // referenceResults: ScorecardReferenceResultType[] schemaErrorList: SchemaErrorList[] | null schemaErrors: boolean success: boolean diff --git a/src/components/c-cda/validation/ValidatorForm.tsx b/src/components/c-cda/validation/ValidatorForm.tsx index b794a781..e5ac1ae5 100644 --- a/src/components/c-cda/validation/ValidatorForm.tsx +++ b/src/components/c-cda/validation/ValidatorForm.tsx @@ -202,6 +202,7 @@ export default function ValidatorForm({ {/* Buttons */} + {/* ValidationComponent returns the validate button, a loading dialog, and the validation results */} void + handleClose?: () => void estimatedValidationTime: number fileName: string } const ValidatorLoadingCard: FC = ({ open, + // TODO: Implement handleClose to maybe cancel the API call, or in some other way, or remove it + // eslint-disable-next-line @typescript-eslint/no-unused-vars handleClose, estimatedValidationTime, fileName, diff --git a/src/components/c-cda/validation/results/ValidationResultsSummary.tsx b/src/components/c-cda/validation/results/ValidationResultsSummary.tsx index 3f49bdd6..f17a5507 100644 --- a/src/components/c-cda/validation/results/ValidationResultsSummary.tsx +++ b/src/components/c-cda/validation/results/ValidationResultsSummary.tsx @@ -107,7 +107,6 @@ const ValidationResults = ({ const ccdaMDHTConformanceValidationResults = ccdaValidationResults.filter((result) => result?.type.includes('C-CDA MDHT Conformance') ) - const sccVocabularyValidationResults = ccdaValidationResults.filter((result) => result?.type.includes('S&CC Vocabulary Validation Conformance') ) diff --git a/src/components/direct/dcdt/DCDTCertificates.tsx b/src/components/direct/dcdt/DCDTCertificates.tsx index d321a5fc..c5bf1808 100644 --- a/src/components/direct/dcdt/DCDTCertificates.tsx +++ b/src/components/direct/dcdt/DCDTCertificates.tsx @@ -57,7 +57,9 @@ const DCDTCertificates = () => { {"Download the Testing Tool's trust anchor."}{' '} - Download Trust Anchor + Download Trust Anchor diff --git a/src/components/direct/hisp/HISPPortal.tsx b/src/components/direct/hisp/HISPPortal.tsx index 83f6c73e..9592003f 100644 --- a/src/components/direct/hisp/HISPPortal.tsx +++ b/src/components/direct/hisp/HISPPortal.tsx @@ -9,6 +9,7 @@ import IMAP from './IMAPTab' import POP3 from './POP3Tab' import XDR from './XDRTab' import ValidationResults from './ValidationResultsTab' +import ProfileProvider from './provider' const HISPPortal = () => { const hispTabs: TabInputs[] = [ @@ -41,7 +42,9 @@ const HISPPortal = () => { } /> {/* Main Content */} - + + + ) } diff --git a/src/components/direct/hisp/IMAPTab.tsx b/src/components/direct/hisp/IMAPTab.tsx index 1b91b10e..f3bacdd0 100644 --- a/src/components/direct/hisp/IMAPTab.tsx +++ b/src/components/direct/hisp/IMAPTab.tsx @@ -17,12 +17,15 @@ import * as React from 'react' import testCases from '@/assets/SMTPTestCases' import _ from 'lodash' import TestFilter from './TestFilter' +import { useContext } from 'react' +import { ProfileContext } from './context' const IMAP = () => { const [option, setOption] = React.useState('') const imapTestCases = testCases.tests.filter((test) => test.protocol === 'imap') const imapTestCasesSender = imapTestCases.filter((test) => test.sutRole === 'sender' && test.sutHisp) const imapTestCasesReceiver = imapTestCases.filter((test) => test.sutRole === 'receiver' && test.sutHisp) + const { hostname, email, password, tls, username } = useContext(ProfileContext) const handleChange = (event: SelectChangeEvent) => { setOption(event.target.value as string) } @@ -45,7 +48,7 @@ const IMAP = () => { Select sender or receiver for your system & fill in the additional fields to get started.{' '} - + @@ -86,7 +89,14 @@ const IMAP = () => { {imapTestCasesSender.map((test, i) => { return ( - + ) })} @@ -98,7 +108,15 @@ const IMAP = () => { {imapTestCasesReceiver.map((test, i) => { return ( - + ) })} diff --git a/src/components/direct/hisp/MessageTrackingTab.tsx b/src/components/direct/hisp/MessageTrackingTab.tsx index d57e1b6e..febae4e0 100644 --- a/src/components/direct/hisp/MessageTrackingTab.tsx +++ b/src/components/direct/hisp/MessageTrackingTab.tsx @@ -20,12 +20,15 @@ import testCases from '@/assets/SMTPTestCases' import DragandDropFile from '@/components/shared/DragandDropFile' import HelpIcon from '@mui/icons-material/Help' import TestFilter from './TestFilter' +import { useContext } from 'react' +import { ProfileContext } from './context' const MessageTracking = () => { const [option, setOption] = React.useState('') const mu2TestCases = testCases.tests.filter((test) => test.protocol === 'mu2') const mu2TestCasesSender = mu2TestCases.filter((test) => test.sutRole === 'sender' && test.sutHisp) const mu2TestCasesReceiver = mu2TestCases.filter((test) => test.sutRole === 'receiver' && test.sutHisp) + const { hostname, email, password, tls, username } = useContext(ProfileContext) const handleChange = (event: SelectChangeEvent) => { setOption(event.target.value as string) } @@ -48,7 +51,7 @@ const MessageTracking = () => { Select sender or receiver for your system & fill in the additional fields to get started.{' '} - + @@ -102,7 +105,14 @@ const MessageTracking = () => { {mu2TestCasesSender.map((test, i) => { return ( - + ) })} @@ -113,7 +123,15 @@ const MessageTracking = () => { {mu2TestCasesReceiver.map((test, i) => { return ( - + ) })} diff --git a/src/components/direct/hisp/POP3Tab.tsx b/src/components/direct/hisp/POP3Tab.tsx index 55ff0709..595140e5 100644 --- a/src/components/direct/hisp/POP3Tab.tsx +++ b/src/components/direct/hisp/POP3Tab.tsx @@ -17,12 +17,16 @@ import * as React from 'react' import testCases from '@/assets/SMTPTestCases' import _ from 'lodash' import TestFilter from './TestFilter' +import { useContext } from 'react' +import { ProfileContext } from './context' const POP3 = () => { const [option, setOption] = React.useState('') const popTestCases = testCases.tests.filter((test) => test.protocol === 'pop') const popTestCasesSender = popTestCases.filter((test) => test.sutRole === 'sender' && test.sutHisp) const popTestCasesReceiver = popTestCases.filter((test) => test.sutRole === 'receiver' && test.sutHisp) + const { hostname, email, password, tls, username } = useContext(ProfileContext) + const handleChange = (event: SelectChangeEvent) => { setOption(event.target.value as string) } @@ -45,7 +49,7 @@ const POP3 = () => { Select sender or receiver for your system & fill in the additional fields to get started.{' '} - + @@ -86,7 +90,14 @@ const POP3 = () => { {popTestCasesSender.map((test, i) => { return ( - + ) })} @@ -98,7 +109,15 @@ const POP3 = () => { {popTestCasesReceiver.map((test, i) => { return ( - + ) })} diff --git a/src/components/direct/hisp/ProfilesCard.tsx b/src/components/direct/hisp/ProfilesCard.tsx index 6a559ba3..809e2a48 100644 --- a/src/components/direct/hisp/ProfilesCard.tsx +++ b/src/components/direct/hisp/ProfilesCard.tsx @@ -5,8 +5,9 @@ interface DocsCardProps { smtpAddress: string emailAddress: string header: string + getProfileReport: (profileName: string) => void } -const ProfilesCard: React.FC = ({ smtpAddress, emailAddress, header }) => { +const ProfilesCard: React.FC = ({ smtpAddress, emailAddress, header, getProfileReport }) => { return ( = ({ smtpAddress, emailAddress, head {header} - - SUTE SMTP Address: + Vendor Hostname/IP: {smtpAddress} - SUTE Email Address: + Vendor Direct Email Address: {emailAddress} - diff --git a/src/components/direct/hisp/SMTPTab.tsx b/src/components/direct/hisp/SMTPTab.tsx index 2c7c8126..40f96935 100644 --- a/src/components/direct/hisp/SMTPTab.tsx +++ b/src/components/direct/hisp/SMTPTab.tsx @@ -16,12 +16,15 @@ import palette from '@/styles/palette' import * as React from 'react' import testCases from '@/assets/SMTPTestCases' import TestFilter from './TestFilter' +import { useContext } from 'react' +import { ProfileContext } from './context' const SMTP = () => { const [option, setOption] = React.useState('') const smtpTestCases = testCases.tests.filter((test) => test.protocol === 'smtp') const smtpTestCasesSender = smtpTestCases.filter((test) => test.sutRole === 'sender' && test.sutHisp) const smtpTestCasesReceiver = smtpTestCases.filter((test) => test.sutRole === 'receiver' && test.sutHisp) + const { hostname, email, password, tls, username } = useContext(ProfileContext) const handleChange = (event: SelectChangeEvent) => { setOption(event.target.value as string) } @@ -44,7 +47,7 @@ const SMTP = () => { Select sender or receiver for your system & fill in the additional fields to get started.{' '} - + @@ -85,7 +88,14 @@ const SMTP = () => { {smtpTestCasesSender.map((test, i) => { return ( - + ) })} @@ -96,7 +106,15 @@ const SMTP = () => { {smtpTestCasesReceiver.map((test, i) => { return ( - + ) })} diff --git a/src/components/direct/hisp/TestCard.tsx b/src/components/direct/hisp/TestCard.tsx index d3b6cf20..06efdf3e 100644 --- a/src/components/direct/hisp/TestCard.tsx +++ b/src/components/direct/hisp/TestCard.tsx @@ -6,6 +6,7 @@ import { handleAPICall } from '../test-by-criteria/ServerActions' import CheckCircleIcon from '@mui/icons-material/CheckCircle' import CancelIcon from '@mui/icons-material/Cancel' import LoadingButton from '../shared/LoadingButton' +import { APICallData, APICallResponse, TestRequestResponses } from '../test-by-criteria/ServerActions' import { Box, Button, @@ -22,8 +23,8 @@ import { Checkbox, TextField, SelectChangeEvent, - Popover, } from '@mui/material' +import AlertSnackbar from '../shared/AlertSnackbar' export type TestCaseFields = { name: string @@ -98,12 +99,7 @@ const TestCard = ({ username = 'defaultUsername', password = 'defaultPassword', tlsRequired = false, - receive, }: TestCardProps) => { - const [popoverAnchorEl, setPopoverAnchorEl] = useState(null) - const popoverOpen = Boolean(popoverAnchorEl) - const popoverId = popoverOpen ? 'ccda-file-required-popover' : undefined - const [autoCloseTimer, setAutoCloseTimer] = useState(null) const attachmentTypeTestIDs = [231, 331] const manualValidationCriteria = [ "['b1-5']", @@ -114,15 +110,24 @@ const TestCard = ({ "['b1-4','su1-4']", "['b1-4']", ] + const mdnTestIds = ['mu2'] + const clearButtonVisibleOnCriteriaSet = new Set(['TRUE', 'FALSE', 'ERROR', 'PASSED', 'PENDING', 'SUCCESS', 'STEP2']) + const [currentStep, setCurrentStep] = useState(1) + const [previousResult, setPreviousResult] = useState(null) + const [showDetail, setShowDetail] = useState(false) const [criteriaMet, setCriteriaMet] = useState('') - const [testRequestResponses, setTestRequestResponses] = useState('') + const [testRequestResponses, setTestRequestResponses] = useState({}) const [showLogs, setShowLogs] = useState(false) const [isLoading, setIsLoading] = useState(false) const [isFinished, setIsFinished] = useState(false) const [apiError, setApiError] = useState(false) const [attachmentType, setAttachmentType] = useState('') + const [alertOpen, setAlertOpen] = useState(false) + const [alertMessage, setAlertMessage] = useState('') + const [alertSeverity, setAlertSeverity] = useState<'success' | 'error' | 'warning' | 'info'>('error') + const handleDocumentConfirm = (selectedData: SelectedDocument) => { console.log('Confirmed Document', selectedData) setDocumentDetails(selectedData) @@ -146,12 +151,14 @@ const TestCard = ({ } const handleClearTest = () => { + setCurrentStep(1) setCriteriaMet('') - setTestRequestResponses('') + setTestRequestResponses({}) setIsFinished(false) setShowLogs(false) setDocumentDetails(null) setApiError(false) + setPreviousResult(null) } const handleAttachmentTypeChange = (event: SelectChangeEvent) => { @@ -163,12 +170,52 @@ const TestCard = ({ fileName: string fileLink: string } | null>(null) + const baseRequestData: APICallData = { + testCaseNumber: test.id, + sutSmtpAddress: hostname, + sutEmailAddress: email, + useTLS: tlsRequired, + sutCommandTimeoutInSeconds: 0, + sutUserName: username, + sutPassword: password, + tttUserName: '', + tttPassword: '', + startTlsPort: 0, + status: '', + ccdaReferenceFilename: documentDetails ? documentDetails.fileName : '', + ccdaValidationObjective: test.criteria || '', + ccdaFileLink: documentDetails ? documentDetails.fileLink : '', + cures: true, + year: '2021', + hostingcase: 'YES', + attachmentType: attachmentType, + previousResult: undefined, + } + + const createRequestData = (step: number, prevResult?: APICallResponse | null): APICallData => { + const requestData = { ...baseRequestData } + + if (step === 1) { + requestData.status = 'na' + } else if (step === 2 && prevResult) { + requestData.status = 'fetching' + requestData.previousResult = prevResult + } else { + requestData.status = '' + } + return requestData + } - const formattedLogs = Object.entries(testRequestResponses).map(([key, value]) => ( - - {value} - - )) + const formattedLogs = Object.entries(testRequestResponses).map(([key, value]) => { + const cleanedKey = key.trim() + const cleanedValue = value.trim() + + return ( + + {`${cleanedKey}: ${cleanedValue}`} + + ) + }) const [formData, setFormData] = useState<{ [key: string]: FieldValue }>(() => { const initialData: { [key: string]: FieldValue } = {} @@ -197,68 +244,73 @@ const TestCard = ({ setFormData((prev) => ({ ...prev, [name]: value })) } - const handleClosePopover = () => { - if (autoCloseTimer) { - clearTimeout(autoCloseTimer) - setAutoCloseTimer(null) + const handleRunTest = async () => { + const isMDNTest = test.protocol && mdnTestIds.includes(test.protocol) + + if (test.ccdaFileRequired && !documentDetails && !test.name.includes('MT')) { + setAlertMessage( + 'This test requires a CCDA document to be selected. Please select a document before running the test.' + ) + setAlertSeverity('error') + setAlertOpen(true) + return } - setPopoverAnchorEl(null) - } + try { + setIsLoading(true) + setIsFinished(false) + setCriteriaMet('') + + if (isMDNTest) { + const requestData = createRequestData(currentStep, previousResult) + + const response = await handleAPICall(requestData) + const result = response[0] + + setIsFinished(true) + setCriteriaMet(result.criteriaMet) + setTestRequestResponses(result.testRequestResponses) + + if (currentStep === 1) { + setPreviousResult(result) + if (result.criteriaMet.includes('STEP2')) { + setCurrentStep(2) + } + } else if (currentStep === 2) { + setPreviousResult(null) + setCurrentStep(1) + } + } else { + const requestData = createRequestData(0) + const response = await handleAPICall(requestData) + const result = response[0] - const handleRunTest = async () => { - if (test.ccdaFileRequired && !documentDetails) { - setPopoverAnchorEl(document.activeElement as HTMLButtonElement) - const timer = setTimeout(() => { - handleClosePopover() - }, 2500) - setAutoCloseTimer(timer) - } else { - try { - setIsLoading(true) - setIsFinished(false) - setCriteriaMet('') - const response = await handleAPICall({ - testCaseNumber: test.id, - sutSmtpAddress: hostname, - sutEmailAddress: email, - useTLS: tlsRequired, - sutCommandTimeoutInSeconds: 0, - sutUserName: username, - sutPassword: password, - tttUserName: '', - tttPassword: '', - startTlsPort: 0, - status: '', - ccdaReferenceFilename: documentDetails ? documentDetails.fileName : '', - ccdaValidationObjective: documentDetails ? documentDetails.directory : '', - ccdaFileLink: documentDetails ? documentDetails.fileLink : '', - cures: true, - year: '2021', - hostingcase: 'YES', - attachmentType: attachmentType, - }) setIsFinished(true) - setCriteriaMet(response.criteriaMet) - setTestRequestResponses(response.testRequestResponses) - console.log('Criteria met: ', response.criteriaMet) - console.log('Test Request Responses:', response.testRequestResponses) - } catch (error) { - console.error('Failed to run test:', error) - setApiError(true) - setCriteriaMet('FALSE') - } finally { - setIsLoading(false) - if (test.criteria && !manualValidationCriteria.includes(test.criteria)) { - setTimeout(() => { - setIsFinished(false) - }, 100) + setCriteriaMet(result.criteriaMet) + setTestRequestResponses(result.testRequestResponses) + + if (result.criteriaMet.includes('STEP2')) { + setCurrentStep(2) } } + } catch (error) { + console.error('Failed to run test:', error) + setApiError(true) + setAlertMessage('An error occurred while running the test.') + setAlertSeverity('error') + setAlertOpen(true) + setCriteriaMet('FALSE') + } finally { + setIsLoading(false) + if (test.criteria && !manualValidationCriteria.includes(test.criteria)) { + setTimeout(() => { + setIsFinished(false) + }, 100) + } } } const renderCriteriaMetIcon = () => { - if (criteriaMet === 'TRUE') { + if (criteriaMet === 'TRUE' || criteriaMet === 'PASSED') { return } else if (criteriaMet === 'FALSE') { return @@ -278,6 +330,10 @@ const TestCard = ({ setShowDetail((prev) => !prev) } + const handleAlertClose = () => { + setAlertOpen(false) + } + const renderAttachmentTypeDropdown = () => { if (test.fields) { const field = test.fields.find((f) => f.name === 'attachmentType') @@ -309,25 +365,9 @@ const TestCard = ({ - {} - - - This test requires a CCDA document to be selected. Please select a document before running the test. - - + + + {showDetail ? ( <> @@ -491,7 +531,19 @@ const TestCard = ({ variant="contained" color="primary" > - RUN + + {test.protocol && mdnTestIds.includes(test.protocol) + ? currentStep === 1 + ? 'RUN' + : 'CHECK MDN' + : 'RUN'} + - {test.criteria && - manualValidationCriteria.includes(test.criteria) && - (criteriaMet.includes('TRUE') || criteriaMet.includes('FALSE')) && ( - - - - )} + {((test.criteria && + criteriaMet && + Array.from(clearButtonVisibleOnCriteriaSet).some((status) => criteriaMet.includes(status))) || + isFinished) && ( + + + + )} {test.criteria && manualValidationCriteria.includes(test.criteria) && !apiError && isFinished && ( Waiting Validation )} diff --git a/src/components/direct/hisp/ValidationResultsTab.tsx b/src/components/direct/hisp/ValidationResultsTab.tsx index 59015da7..d8617757 100644 --- a/src/components/direct/hisp/ValidationResultsTab.tsx +++ b/src/components/direct/hisp/ValidationResultsTab.tsx @@ -1,41 +1,192 @@ import * as React from 'react' -import { Box, Typography, Container } from '@mui/material' +import { + Box, + Typography, + Container, + LinearProgress, + Button, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from '@mui/material' +import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import CancelIcon from '@mui/icons-material/Cancel' +import KeyboardBackspaceIcon from '@mui/icons-material/KeyboardBackspace' import ProfilesCard from './ProfilesCard' +import { useSession } from 'next-auth/react' +import PageAlertBox from '@/components/shared/PageAlertBox' +import { useEffect } from 'react' +import { fetchProfileReport, fetchProfiles } from './actions' +import Profile from '../shared/Profile' +import _ from 'lodash' + +const removeProfilesWithNullProfileName = (profiles: Profile[]) => { + return profiles.filter((profile) => profile.profileName !== null) +} + +interface ProfileReport { + smtpEdgeLogID: number + testCaseNumber: string + timestamp: number + criteriaMet: boolean +} const ValidationResults = () => { + const { data: session, status } = useSession() + const [profiles, setProfiles] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(false) + const [profileReport, setProfileReport] = React.useState([]) + const [profileName, setProfileName] = React.useState('') + const [open, setOpen] = React.useState(false) + + useEffect(() => { + async function fetchLoggedInUsersProfiles() { + setIsLoading(true) + const loggedInUsersProfiles = await fetchProfiles() + const filteredProfiles = removeProfilesWithNullProfileName(loggedInUsersProfiles) + if (!_.isEmpty(filteredProfiles)) { + setProfiles(filteredProfiles) + } + setIsLoading(false) + } + if (status === 'authenticated') { + fetchLoggedInUsersProfiles() + } + }, [status, session]) + + const handleGetProfileReport = async (profileName: string) => { + setIsLoading(true) + const profileReport = await fetchProfileReport(profileName) + setProfileReport(profileReport) + setProfileName(profileName) + setOpen(true) + setIsLoading(false) + } + + const handleCloseProfileReport = () => { + setProfileName('') + setProfileReport([]) + setOpen(false) + } + + const convertDate = (date: number) => { + const dateInt = _.toNumber(date) + return new Date(dateInt).toLocaleString() + } + return ( - - - - Below are the different profiles you are aligned to. Select one to see the validation results. - - - - - - - - - - - + <> + {status !== 'authenticated' ? ( + + + + ) : ( + + {isLoading ? ( + + ) : ( + + {open && ( + + + + Validation Report for {profileName} + + + + + + + + + Test Case + + Timestamp + Result + + + {!_.isEmpty(profileReport) ? ( + + {profileReport.map((row: ProfileReport) => ( + + + {row.testCaseNumber} + + {convertDate(row.timestamp)} + + {row.criteriaMet ? ( + + + + ) : ( + + + + )} + + + ))} + + ) : ( + + + + No validation results found for this profile. + + + + )} +
+
+
+ )} + {!open && ( + <> + + Below are your saved profiles. Select one to see the validation results. + + + {profiles.map((profile, index) => { + return ( + + ) + })} + + + )} +
+ )} +
+ )} + ) } diff --git a/src/components/direct/hisp/XDRTab.tsx b/src/components/direct/hisp/XDRTab.tsx index 8e612915..70842d03 100644 --- a/src/components/direct/hisp/XDRTab.tsx +++ b/src/components/direct/hisp/XDRTab.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react' import { Box, - Button, Container, Card, CardContent, @@ -14,9 +13,9 @@ import { SelectChangeEvent, } from '@mui/material' import XDRTestCard from '@components/direct/hisp/XDRTestCard' -import { Download } from '@mui/icons-material' import TestFilter from './TestFilter' import testCases from '@/assets/XDRTestCases' +import DownloadXDRCert from '../shared/DownloadXDRCert' const XDR = () => { const [option, setOption] = useState('') @@ -46,7 +45,7 @@ const XDR = () => {
{ - - - - You can also download XDR TLS certificates. - - - - + {option !== '' && ( diff --git a/src/components/direct/hisp/XDRTestCard.tsx b/src/components/direct/hisp/XDRTestCard.tsx index 8d89518f..9450005f 100644 --- a/src/components/direct/hisp/XDRTestCard.tsx +++ b/src/components/direct/hisp/XDRTestCard.tsx @@ -9,11 +9,10 @@ import { Tooltip, Typography, FormControl, - Popover, } from '@mui/material' import ContentPasteGoIcon from '@mui/icons-material/ContentPasteGo' import _ from 'lodash' -import React, { useState, useEffect, useRef } from 'react' +import React, { useState, useRef } from 'react' import DynamicTable from './DynamicTable' import { handleXDRAPICall, GetStatus } from '../test-by-criteria/ServerActions' import CheckCircleIcon from '@mui/icons-material/CheckCircle' @@ -22,7 +21,9 @@ import LoadingButton from '../shared/LoadingButton' import DocumentSelector from './DocumentSelector' import { useSession } from 'next-auth/react' import XMLDisplay from '../shared/colorizeXML' -import ValidatorMenu from '@/components/c-cda/validation/results/ValidationMenu' +import ValidatorResultsSummary from '@/components/c-cda/validation/results/ValidationResultsSummary' +import AlertSnackbar from '../shared/AlertSnackbar' + export type TestCaseFields = { name?: string id: string | number @@ -82,34 +83,26 @@ interface StepTextProps { inputs: InputFields[] role?: string endpointsGenerated: boolean + criteriaMet: string } -const senderText = 'Hit Run to generate your endpoint.' -const receiverText = 'Hit Run to send a XDR message.' -const StepText = ({ inputs, role, endpointsGenerated }: StepTextProps) => { - if (endpointsGenerated) { - return ( - - Step 2: Send XDR message to endpoint and refresh to check status. - - ) - } +interface ValidationResults { + resultsMetaData: ResultsMetaData + ccdaValidationResults: CCDAValidationResult[] +} - return ( - <> - - Step 1: Provide your{' '} - {inputs.map((input, i) => ( - - {input.name} - {inputs.length - 1 === i ? '. ' : ', '} - - ))} - {role === 'sender' ? senderText : receiverText} - - - ) +interface ResultsMetaData { + documentType: string +} + +interface CCDAValidationResult { + errorType: string + messageId: string } + +const senderText = 'Hit Run to generate your endpoint.' +const receiverText = 'Hit Run to send a XDR message.' + export type FieldValue = boolean | string | number export type ExtraFields = { label: string @@ -133,7 +126,8 @@ interface SelectedDocument { fileName: string fileLink: string } -const TestCard = ({ test, receive }: TestCardProps) => { + +const TestCard = ({ test }: TestCardProps) => { const defaultEndpoint = process.env.XDR_ENDPOINT_PREFIX || 'http://ett.healthit.gov:11084/xdstools/sim/edge-ttp__' + test.id + '/rep/xdrpr' const defaultEndpointTLS = @@ -151,15 +145,27 @@ const TestCard = ({ test, receive }: TestCardProps) => { const [endpointsGenerated, setEndpointsGenerated] = useState(false) const [endpoint, setEndpoint] = useState(defaultEndpoint) const [endpointTLS, setEndpointTLS] = useState(defaultEndpointTLS) + const [validationResults, setValidationResults] = useState(null) + + const scrollRef = useRef(null) + const summaryRef = useRef(null) + const mdhtErrorRef = useRef(null) + const mdhtWarningRef = useRef(null) + const mdhtInfoRef = useRef(null) + const vocabularyErrorRef = useRef(null) + const vocabularyWarningRef = useRef(null) + const vocabularyInfoRef = useRef(null) + const referenceErrorRef = useRef(null) + const referenceWarningRef = useRef(null) + const referenceInfoRef = useRef(null) + const originalCCDARef = useRef(null) - const [anchorEl, setAnchorEl] = useState(null) - const [popoverMessage, setPopoverMessage] = useState('') - const [autoCloseTimer, setAutoCloseTimer] = useState(null) - const open = Boolean(anchorEl) - const id = open ? 'simple-popover' : undefined - const hiddenAnchorRef = useRef(null) - const [logType, setLogType] = useState<'request' | 'response'>('request') - const manualValidationCriteria = ["['b1-3']", "['b1-3','su1-3']"] + const [alertOpen, setAlertOpen] = useState(false) + const [alertMessage, setAlertMessage] = useState('') + const [alertSeverity, setAlertSeverity] = useState<'success' | 'error' | 'warning' | 'info'>('info') + + const [logType, setLogType] = useState<'request' | 'response' | 'ccdaValidation'>('request') + const manualValidationIDs = ['4a', '4b', '20amu2', '20bmu2'] const { data: session } = useSession() const subHeader = 'Description' const subDesc = test['Purpose/Description'] @@ -171,32 +177,18 @@ const TestCard = ({ test, receive }: TestCardProps) => { const shouldDisplayInput = (input: InputFields) => { return !(input.key === 'payload' && input.type?.includes('CCDAWidget')) } + const handleClick = (event: React.MouseEvent, link: string) => { navigator.clipboard.writeText(link) - showPopover('Copied to clipboard!', event.currentTarget) - } - const showPopover = (message: string, anchor: HTMLButtonElement | null) => { - setPopoverMessage(message) - setAnchorEl(anchor || hiddenAnchorRef.current) - if (autoCloseTimer) clearTimeout(autoCloseTimer) - const timer = setTimeout(() => { - handleClosePopover() - }, 3000) - setAutoCloseTimer(timer) + setAlertMessage('Copied to clipboard!') + setAlertSeverity('success') + setAlertOpen(true) } - useEffect(() => { - return () => { - if (autoCloseTimer) clearTimeout(autoCloseTimer) - } - }, [autoCloseTimer, anchorEl]) - const handleClosePopover = () => { - if (autoCloseTimer) { - clearTimeout(autoCloseTimer) - setAutoCloseTimer(null) - } - setAnchorEl(null) + + const handleAlertClose = () => { + setAlertOpen(false) } - const toggleLogType = (type: 'request' | 'response') => { + const toggleLogType = (type: 'request' | 'response' | 'ccdaValidation') => { setLogType(type) } const endpointTestIds = [ @@ -216,7 +208,45 @@ const TestCard = ({ test, receive }: TestCardProps) => { '44mu2', ] const ccdaRequiredTestIds = ['1', '2', '3add'] + const sendEdgeTestsCriteria = ['b1-1'] const isCCDADocumentRequired = ccdaRequiredTestIds.includes(test.id.toString()) + const StepText = ({ inputs, role, endpointsGenerated, criteriaMet }: StepTextProps) => { + if (manualValidationIDs.includes(test.id.toString()) && isFinished) { + if (test.id == '20amu2' || test.id == '20bmu2') { + console.log('abc') + testRequest == 'Check your SUT logs and accept or reject' + testResponse == 'Check your SUT logs and accept or reject' + } + return ( + + Step 3: Check the logs to accept/reject the response + + ) + } + + if (endpointsGenerated) { + return ( + + Step 2: Send XDR message to endpoint and refresh to check status. + + ) + } + + return ( + <> + + Step 1: Provide your{' '} + {inputs.map((input, i) => ( + + {input.name} + {inputs.length - 1 === i ? '. ' : ', '} + + ))} + {role === 'sender' ? senderText : receiverText} + + + ) + } const [formData] = useState<{ [key: string]: FieldValue }>(() => { const initialData: { [key: string]: FieldValue } = {} test.moreInfo?.fields?.forEach((field) => { @@ -232,92 +262,118 @@ const TestCard = ({ test, receive }: TestCardProps) => { })) } } + + const fixEndpoint = (url: string): string => { + if (url && !url.startsWith('http://') && !url.startsWith('https://')) { + return 'https://' + url + } + return url + } + const handleRunTest = async () => { if (!session) { - showPopover('You must be logged in and have a valid session to perform this action.', null) + setAlertMessage('You must be logged in and have a valid session to perform this action.') + setAlertSeverity('error') + setAlertOpen(true) return } - console.log('ccda required' + test.ccdaFileRequired) - console.log('doc details ' + documentDetails) + if (isCCDADocumentRequired && !documentDetails) { - showPopover( - 'This test requires a CCDA document to be selected. Please select a document before running the test.', - null + setAlertMessage( + 'This test requires a CCDA document to be selected. Please select a document before running the test.' ) + setAlertSeverity('error') + setAlertOpen(true) return - } else { - const ip_address = fieldValues['ip_address'] || '' - const port = fieldValues['port'] || '' - const direct_to = fieldValues['direct_to'] || '' - const direct_from = fieldValues['direct_from'] || '' - const targetEndpointTLS = fieldValues['targetEndpointTLS'] || '' - const outgoing_from = fieldValues['outgoing_from'] || '' - try { - setIsLoading(true) - setIsFinished(false) - setCriteriaMet('') - if (endpointsGenerated) { - const status = await GetStatus(test.id.toString()) - console.log('Test status:', status) - setTestRequestRequest(status.testRequest) - setTestRequestResponse(status.testResponse) - setCriteriaMet(status.criteriaMet) + } + const ip_address = fieldValues['ip_address'] || '' + const port = fieldValues['port'] || '' + const direct_to = fieldValues['direct_to'] || '' + const direct_from = fieldValues['direct_from'] || '' + const targetEndpointTLS = fieldValues['targetEndpointTLS'] || '' + const outgoing_from = fieldValues['outgoing_from'] || '' + try { + setIsLoading(true) + setIsFinished(false) + setCriteriaMet('') + if (endpointsGenerated) { + const status = await GetStatus(test.id.toString()) + console.log('Test status:', status) + setTestRequestRequest(status.testRequest) + setTestRequestResponse(status.testResponse) + setCriteriaMet(status.criteriaMet) + setIsFinished(true) + if (status.results) { + setValidationResults(status.results) + setEndpointsGenerated(false) + } + } else { + const response = await handleXDRAPICall({ + ip_address: ip_address, + port: port, + direct_to: direct_to, + direct_from: direct_from, + targetEndpointTLS: targetEndpointTLS, + outgoing_from: outgoing_from, + name: documentDetails ? documentDetails.fileName : '', + path: documentDetails ? documentDetails.directory : '', + link: documentDetails ? documentDetails.fileLink : '', + id: test.id.toString(), + jsession: session.user.jsessionid, + cures: false, + itemNumber: '12', + selected: true, + svap: false, + uscdiv3: false, + }) + setTimeout(() => { setIsFinished(true) - console.log('criteriamet:', criteriaMet) - } else { - const response = await handleXDRAPICall({ - ip_address: ip_address, - port: port, - direct_to: direct_to, - direct_from: direct_from, - targetEndpointTLS: targetEndpointTLS, - outgoing_from: outgoing_from, - name: documentDetails ? documentDetails.fileName : '', - path: documentDetails ? documentDetails.directory : '', - link: documentDetails ? documentDetails.fileLink : '', - id: test.id.toString(), - jsession: session.user.jsessionid, - cures: false, - itemNumber: '12', - selected: true, - svap: false, - uscdiv3: false, - }) - setTimeout(() => { - setIsFinished(true) - if (test.criteria && !manualValidationCriteria.includes(test.criteria)) { - setCriteriaMet(response.criteriaMet) + if (test.criteria && !manualValidationIDs.includes(test.id.toString())) { + setCriteriaMet(response.criteriaMet) + } + if (!endpointTestIds.includes(test.id.toString())) { + let endpointSet = false + if (response.endpoint && response.endpoint.length > 10) { + setEndpoint(fixEndpoint(response.endpoint)) + endpointSet = true } - if ( - !endpointTestIds.includes(test.id.toString()) && - (response.endpoint.length > 10 || response.endpointTLS.length > 10) - ) { - setEndpointsGenerated(true) - setEndpoint(response.endpoint || defaultEndpoint) - setEndpointTLS(response.endpointTLS || defaultEndpointTLS) + if (response.endpointTLS && response.endpointTLS.length > 10) { + setEndpointTLS(fixEndpoint(response.endpointTLS)) + endpointSet = true } - setTestRequestRequest(response.testRequest) - setTestRequestResponse(response.testResponse) - if (!testRequest && !testResponse && test.criteria && !manualValidationCriteria.includes(test.criteria)) { - setCriteriaMet('FALSE') + if (endpointSet) { + console.log('setting endpoints generated') + setEndpointsGenerated(true) } - console.log('Criteria met: ', response.criteriaMet) - console.log('Test Request Responses:', response.testResponse) - }, 10) - } - } catch (error) { - console.error('Failed to run test:', error) - setApiError(true) - if (test.criteria && !manualValidationCriteria.includes(test.criteria)) { - setCriteriaMet('FALSE') - } - } finally { - setIsLoading(false) - if (test.criteria && !manualValidationCriteria.includes(test.criteria)) { - setTimeout(() => { - setIsFinished(false) - }, 100) - } + } + setTestRequestRequest(response.testRequest) + setTestRequestResponse(response.testResponse) + if ( + !testRequest && + !testResponse && + test.criteria && + !manualValidationIDs.includes(test.id.toString()) && + criteriaMet + ) { + console.log('Response null, setting criteria met false') + setCriteriaMet('FALSE') + } + console.log('Criteria met: ', response.criteriaMet) + console.log('Test Request Responses:', response.testResponse) + }, 10) + } + } catch (error) { + console.error('Failed to run test:', error) + setApiError(true) + if (test.criteria && !manualValidationIDs.includes(test.id.toString())) { + setCriteriaMet('FALSE') + } + } finally { + setIsLoading(false) + if (test.criteria && !manualValidationIDs.includes(test.id.toString())) { + setTimeout(() => { + setIsFinished(false) + }, 100) } } } @@ -342,12 +398,13 @@ const TestCard = ({ test, receive }: TestCardProps) => { setEndpoint(defaultEndpoint) setEndpointTLS(defaultEndpointTLS) setApiError(false) + setValidationResults(null) } + const renderCriteriaMetIcon = () => { - if (endpointsGenerated && criteriaMet != 'PASSED') { + if (endpointsGenerated) { return Pending - } - if (criteriaMet === 'TRUE' || criteriaMet === 'PASSED') { + } else if (criteriaMet === 'TRUE' || criteriaMet === 'PASSED' || criteriaMet === 'SUCCESS') { return } else if (criteriaMet === 'FALSE' || criteriaMet === 'ERROR') { return @@ -378,9 +435,40 @@ const TestCard = ({ test, receive }: TestCardProps) => { } | null>(null) const [showDocumentSelector, setShowDocumentSelector] = useState(false) const renderLogs = () => { - const content = logType === 'request' ? testRequest : testResponse - return + if (logType === 'ccdaValidation') { + if (validationResults) { + return ( + + ) + } else { + return No C-CDA Validation results available. + } + } else { + let content = logType === 'request' ? testRequest : testResponse + + if ((test.id === '20amu2' || test.id === '20bmu2') && isFinished && (!testRequest || !testResponse)) { + content = 'Check your SUT logs and accept or reject' + } + + return + } } + const renderMoreInfo = () => { const { moreInfo } = test return ( @@ -432,22 +520,7 @@ const TestCard = ({ test, receive }: TestCardProps) => { - - {popoverMessage} - + {showDetail ? ( renderMoreInfo() ) : showLogs ? ( @@ -468,27 +541,33 @@ const TestCard = ({ test, receive }: TestCardProps) => { > Response + + {renderLogs()} - {test.criteria && - manualValidationCriteria.includes(test.criteria) && - testRequest && - testRequest.length > 0 && ( - - - - - )} + {test.criteria && manualValidationIDs.includes(test.id.toString()) && isFinished && ( + + + + + )} @@ -501,14 +580,25 @@ const TestCard = ({ test, receive }: TestCardProps) => { {test.desc} {_.isEqual(test.sutRole, 'receiver') && _.has(test, 'inputs') && test.inputs !== undefined && ( - + )} {_.isEqual(test.sutRole, 'sender') && _.has(test, 'inputs') && test.inputs !== undefined && ( - + )} {_.has(test, 'inputs') && test.inputs && !endpointsGenerated && + !isFinished && test.inputs.filter(shouldDisplayInput).map((input) => ( @@ -548,32 +638,22 @@ const TestCard = ({ test, receive }: TestCardProps) => { Endpoint - - - - - {popoverMessage} - + + + )} )} {requiresCCDADocument() && !endpointsGenerated && ( @@ -615,31 +695,22 @@ const TestCard = ({ test, receive }: TestCardProps) => { > {endpointsGenerated ? 'REFRESH' : 'RUN'} -
- {test.criteria && - criteriaMet && - (criteriaMet.includes('TRUE') || - criteriaMet.includes('FALSE') || - criteriaMet.includes('ERROR') || - criteriaMet.includes('PASSED') || - criteriaMet.includes('SUCCESS')) && ( - - - - )} - {test.criteria && - manualValidationCriteria.includes(test.criteria) && - (testRequest || testResponse) && - isFinished && - !apiError && Waiting Validation} + {((test.criteria && criteriaMet) || isFinished) && ( + + + + )} + {test.criteria && manualValidationIDs.includes(test.id.toString()) && isFinished && !apiError && ( + Waiting Validation + )}
diff --git a/src/components/direct/hisp/actions.ts b/src/components/direct/hisp/actions.ts new file mode 100644 index 00000000..5edc486a --- /dev/null +++ b/src/components/direct/hisp/actions.ts @@ -0,0 +1,139 @@ +'use server' + +import { authOptions } from '@/lib/auth' +import { getServerSession } from 'next-auth' + +const ETT_API_URL = process.env.ETT_API_URL +export interface Profile { + hostname: string + email: string + username: string + password: string + istls: boolean + profilename: string + profileid: string +} + +export async function saveProfile(data: Profile) { + console.log(`'Saving profile:', ${JSON.stringify(data)}`) + const { hostname, email, username, password, istls, profilename } = data + const session = await getServerSession(authOptions) + const jsessionid = session?.user?.jsessionid ?? '' + const ettAPIUrl = `${ETT_API_URL}/smtpProfile` + try { + const response = await fetch(ettAPIUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `JSESSIONID=${jsessionid}`, + }, + body: JSON.stringify({ + sutSMTPAddress: hostname, + sutEmailAddress: email, + sutUsername: username, + sutPassword: password, + useTLS: istls, + profileName: profilename, + username: session?.user?.name, + }), + }) + const data = await response.json() + if (!response.ok) { + console.log(`Error: ${data}`) + } + return data + } catch (error: unknown) { + if (error instanceof Error) { + return JSON.stringify({ + status: 500, + success: false, + error: error.message || 'An error occurred', + }) + } + } +} + +export async function deleteProfile(profilename: string) { + console.log(`deleting profile: ${profilename}`) + const session = await getServerSession(authOptions) + const jsessionid = session?.user?.jsessionid ?? '' + const ettAPIUrl = `${ETT_API_URL}/smtpProfile/${profilename}` + try { + const response = await fetch(ettAPIUrl, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Cookie: `JSESSIONID=${jsessionid}`, + }, + }) + const data = await response.json() + if (!response.ok) { + console.log(`Error: ${data}`) + } + return data + } catch (error: unknown) { + if (error instanceof Error) { + return JSON.stringify({ + status: 500, + success: false, + error: error.message || 'An error occurred', + }) + } + } +} + +export async function fetchProfiles() { + const session = await getServerSession(authOptions) + const jsessionid = session?.user?.jsessionid ?? '' + const ettAPIUrl = `${ETT_API_URL}/smtpProfile` + try { + const response = await fetch(ettAPIUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `JSESSIONID=${jsessionid}`, + }, + }) + const data = await response.json() + if (!response.ok) { + console.log(`Error: ${data}`) + } + return data + } catch (error: unknown) { + if (error instanceof Error) { + return JSON.stringify({ + status: 500, + success: false, + error: error.message || 'An error occurred', + }) + } + } +} + +export async function fetchProfileReport(profilename: string) { + const session = await getServerSession(authOptions) + const jsessionid = session?.user?.jsessionid ?? '' + const ettAPIUrl = `${ETT_API_URL}/smtpLog/${profilename}` + try { + const response = await fetch(ettAPIUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Cookie: `JSESSIONID=${jsessionid}`, + }, + }) + const data = await response.json() + if (!response.ok) { + console.log(`Error: ${data}`) + } + return data + } catch (error: unknown) { + if (error instanceof Error) { + return JSON.stringify({ + status: 500, + success: false, + error: error.message || 'An error occurred', + }) + } + } +} diff --git a/src/components/direct/hisp/context/index.tsx b/src/components/direct/hisp/context/index.tsx new file mode 100644 index 00000000..db49f4a8 --- /dev/null +++ b/src/components/direct/hisp/context/index.tsx @@ -0,0 +1,36 @@ +import { createContext } from 'react' + +interface ProfileContextType { + profileid?: string + profilename: string + hostname: string + email: string + username: string + password: string + tls: boolean + setProfileid: (profileid: string) => void + setHostname: (hostname: string) => void + setEmail: (email: string) => void + setUsername: (username: string) => void + setPassword: (password: string) => void + setTls: (tls: boolean) => void + setProfilename: (profilename: string) => void +} +const defaultValue: ProfileContextType = { + profileid: '', + hostname: '', + email: '', + username: '', + password: '', + tls: false, + profilename: 'Default Profile', + setProfileid: () => {}, + setHostname: () => {}, + setEmail: () => {}, + setUsername: () => {}, + setPassword: () => {}, + setTls: () => {}, + setProfilename: () => {}, +} + +export const ProfileContext = createContext(defaultValue) diff --git a/src/components/direct/hisp/provider/index.tsx b/src/components/direct/hisp/provider/index.tsx new file mode 100644 index 00000000..e1885519 --- /dev/null +++ b/src/components/direct/hisp/provider/index.tsx @@ -0,0 +1,42 @@ +'use client' +import { useState } from 'react' +import { ProfileContext } from '../context' + +import { ReactNode } from 'react' + +interface ProfileProviderProps { + children: ReactNode +} + +export default function ProfileProvider({ children }: ProfileProviderProps) { + const [profileid, setProfileid] = useState('') + const [hostname, setHostname] = useState('') + const [email, setEmail] = useState('') + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [tls, setTls] = useState(false) + const [profilename, setProfilename] = useState('') + + return ( + + {children} + + ) +} diff --git a/src/components/direct/send-direct/DirectForm.tsx b/src/components/direct/send-direct/DirectForm.tsx index 57b35dc3..fd92e69e 100644 --- a/src/components/direct/send-direct/DirectForm.tsx +++ b/src/components/direct/send-direct/DirectForm.tsx @@ -1,6 +1,8 @@ +import SwitchWithLabel from '@/components/shared/SwitchWithLabel' +import DragandDropFile from '@components/shared/DragandDropFile' +import HelpIcon from '@mui/icons-material/Help' import { Box, - Button, Container, Divider, FormControlLabel, @@ -13,10 +15,11 @@ import { Tooltip, Typography, } from '@mui/material' -import DragandDropFile from '@components/shared/DragandDropFile' -import HelpIcon from '@mui/icons-material/Help' -import React, { useEffect, useState } from 'react' import _ from 'lodash' +import React, { useEffect, useState } from 'react' +import { useFormState } from 'react-dom' +import { handleSendDirectMessage } from './actions' +import SendDirectResults from './SendDirectResults' const documentDropdown = [ { @@ -51,32 +54,36 @@ export interface DirectFormProps { version: string certificateDropdown: certProps[] algorithmDropdown: algorithmProps[] + domainName: string } -const DirectForm = ({ version, certificateDropdown, algorithmDropdown }: DirectFormProps) => { - const [formErrors, setFormErrors] = useState({}) - const [formValues, setFormValues] = useState({}) - const [disableSendButton, setDisableSendButton] = React.useState(true) - /* TO-DO: Form submission, this would change when we work on functionality */ - const handleSubmit = (e: React.FormEvent) => { - const { name, value } = e.currentTarget - setFormValues({ - ...formValues, - [name]: value, - }) - console.log(formValues) - } - - /* Validation*/ +const DirectForm = ({ version, certificateDropdown, algorithmDropdown, domainName }: DirectFormProps) => { + const [formValues, setFormValues] = useState<{ [key: string]: string }>({}) + const [formErrors, setFormErrors] = useState<{ [key: string]: string }>({}) + const [disableSendButton, setDisableSendButton] = useState(true) + const [selectedDocument, setSelectedDocument] = useState('') + const [selectedCertificate, setSelectedCertificate] = useState('') + const [selectedAlgorithm, setSelectedAlgorithm] = useState('') + const [isMessageWrapped, setIsMessageWrapped] = useState(true) + const [isInvalidDigest, setIsInvalidDigest] = useState(false) + const [data, handleSubmit] = useFormState(handleSendDirectMessage, { response: {} }) + const [algorithmDropdownM, setAlgorithmDropdownM] = useState(algorithmDropdown) + + //Validation const handleValidation = (e: React.ChangeEvent) => { - let errors = {} + const errors = { ...formErrors } const { name, value } = e.target if (value === '') { - errors = { ...errors, [name]: 'Email Address is required' } - } - if (!/^[a-zA-Z0-9._:$!%-]+@[a-zA-Z0-9.-]+.[a-zA-Z]$/.test(value)) { - errors = { ...errors, [name]: 'Please enter a valid email' } + errors[name] = 'This field is required' + } else if (name === 'toAddress') { + if (!/^[a-zA-Z0-9._:$!%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]+$/.test(value)) { + errors[name] = 'Please enter a valid email' + } else { + delete errors[name] + } + } else { + delete errors[name] } setFormValues({ ...formValues, @@ -86,207 +93,238 @@ const DirectForm = ({ version, certificateDropdown, algorithmDropdown }: DirectF } useEffect(() => { - if (_.has(formValues, 'fromEmail') && _.has(formValues, 'toEmail') && _.isEmpty(formErrors)) { + if (_.has(formValues, 'fromAddress') && _.has(formValues, 'toAddress') && _.isEmpty(formErrors)) { setDisableSendButton(false) } - }, [disableSendButton, formErrors, formValues]) - + if (isInvalidDigest) { + setSelectedCertificate('') + } + if (_.isEqual(selectedCertificate, 'GOOD_ECDSA_CERT')) { + const filteredAlgo = _.filter(algorithmDropdown, function (o) { + return o.label.includes('ECDSA') + }) + setAlgorithmDropdownM(filteredAlgo) + } else { + setAlgorithmDropdownM(algorithmDropdown) + } + }, [algorithmDropdown, formErrors, formValues, isInvalidDigest, selectedCertificate]) + const handleChangeDocument = (e: React.ChangeEvent) => { + const { value } = e.target + setSelectedDocument(value) + } + const handleChangeCertificate = (e: React.ChangeEvent) => { + const { value } = e.target + setSelectedCertificate(value) + setIsInvalidDigest(false) + } + const handleMessageWrappedChange = (e: React.ChangeEvent) => { + setIsMessageWrapped(e.target.checked) + } + const handleInvalidDigestChange = (e: React.ChangeEvent) => { + setIsInvalidDigest(e.target.checked) + } return ( - - - Step 1 - - - Enter the fields below to send your direct message - +
+ + + Step 1 + + + Enter the fields below to send your direct message + - + + + @{domainName} + + ), + }} + required + onChange={handleValidation} + /> + + + + + {documentDropdown.map((option) => ( + + {option.label} + + ))} + + + {version === 'v12' && ( + + )} + {version === 'v13' && ( + } + label="Message Format: Wrapped" + /> + )} + + - @ett.healthit.gov - - ), - type: 'email', - }} - required - onChange={handleValidation} + id="subject" + name="subject" + label="Subject" + helperText="Message Subject or Test Session Name. This field is optional." /> - - - - {documentDropdown.map((option) => ( - - {option.label} - - ))} - - - {version === 'v12' && ( - } - label="Message Format: Wrapped" - name="wrapped" - /> - )} - {version === 'v13' && ( - } - label="Message Format: Wrapped" - name="wrapped" - /> - )} - - - - - - - - Upload your own C-CDA file - - - - - - - + + + + Upload your own C-CDA file + + + + + + + - - - Step 2 - - - Signing - + + + Step 2 + + + Signing + - + + + {certificateDropdown.map((option) => ( + + {option.label} + + ))} + + + + or select message with invalid digest (message which had been altered) + + + + + + + + setSelectedAlgorithm(e.target.value)} > - {certificateDropdown.map((option) => ( + {algorithmDropdownM.map((option) => ( {option.label} ))} - - - or select message with invalid digest (message which had been altered) - - - } - label="Invalid Digest" - name="invalidDigest" - /> - - - - - {algorithmDropdown.map((option) => ( - - {option.label} - - ))} - - - - - Encryption Certificate - - - - - + + + + Encryption Certificate + + + + + - + + + - - +
) } diff --git a/src/components/direct/send-direct/SendDirect.tsx b/src/components/direct/send-direct/SendDirect.tsx index f627bad6..2dce5c25 100644 --- a/src/components/direct/send-direct/SendDirect.tsx +++ b/src/components/direct/send-direct/SendDirect.tsx @@ -1,37 +1,20 @@ -'use client' - -import { useEffect, useState } from 'react' import BannerBox from '@/components/shared/BannerBox' import Link from 'next/link' import styles from '@shared/styles.module.css' -import TabsComponent, { TabInputs } from '@/components/shared/TabsComponent' -import Version13 from './Version13Tab' -import Version12 from './Version12Tab' -import MessageStatus from './message-status/MessageStatus' +import SendDirectTabs from './SendDirectTabs' +function getDomainName(url: string) { + try { + const parsedUrl = new URL(url) + return parsedUrl.hostname + } catch (error) { + console.error('Invalid URL:', error) + return null + } +} const SendDirect = () => { - const [selectedTab, setSelectedTab] = useState('VERSION V1.3') - - const sendDirectTabs: TabInputs[] = [ - { tabName: 'VERSION V1.3', tabIndex: 0, tabPanel: }, - { tabName: 'VERSION V1.2', tabIndex: 1, tabPanel: }, - { tabName: 'Message Status', tabIndex: 2, tabPanel: }, - ] - - useEffect(() => { - const handleRouteChange = () => { - const hash = window.location.hash.replace('#', '').replace(/-/g, ' ').toLowerCase() - const tab = sendDirectTabs.find((t) => t.tabName.toLowerCase() === hash) - setSelectedTab(tab ? tab.tabName : 'VERSION V1.3') - } - - handleRouteChange() - window.addEventListener('hashchange', handleRouteChange) - - return () => { - window.removeEventListener('hashchange', handleRouteChange) - } - }, []) + const apiUrl = process.env.SEND_DIRECT_API || 'https://ett.healthit.gov/ett/api' + const domainName = getDomainName(apiUrl) return ( <> @@ -41,13 +24,13 @@ const SendDirect = () => { Direct , - Direct Message + Send Direct , ]} heading={'Send Direct Message'} - description="Send a Direct message from this tool to a HISP of your choosing. Need more text here" + description="Send a Direct message from this tool to a HISP of your choosing." /> - + ) } diff --git a/src/components/direct/send-direct/SendDirectResults.tsx b/src/components/direct/send-direct/SendDirectResults.tsx new file mode 100644 index 00000000..d898ac83 --- /dev/null +++ b/src/components/direct/send-direct/SendDirectResults.tsx @@ -0,0 +1,99 @@ +import { Box, Button, Divider, Typography } from '@mui/material' +import _ from 'lodash' + +import ErrorDisplayCard from '@/components/c-cda/validation/results/ErrorDisplay' +import { useFormStatus } from 'react-dom' +import { useState, useEffect } from 'react' +import { CircularProgress } from '@mui/material' +import palette from '@/styles/palette' +import { Check } from '@mui/icons-material' +import ErrorIcon from '@mui/icons-material/Error' +interface ResultsComponentProps { + response: ContentProps + disabled?: boolean + buttonTitle: string +} +type ContentProps = { + result?: boolean + message?: string + error?: string + errorStatus?: number +} +const SendDirectResults = ({ response, buttonTitle, disabled }: ResultsComponentProps) => { + const [errorOpen, setErrorOpen] = useState(false) + const { pending } = useFormStatus() + + const handleErrorClose = () => { + setErrorOpen(false) + } + + useEffect(() => { + if (!pending && _.has(response, 'error')) { + setErrorOpen(true) + } + }, [pending, response]) + + return ( + <> + + + {!pending && _.has(response, 'error') && ( + + )} + + {!pending && !_.has(response, 'error') && !_.isEmpty(response) && ( + + + + {response.result && ( + + + + Message successfully sent! + + + )} + {response.result === undefined && ( + + + + Message failed to be sent! + + +
{response.message}
+
+
+ )} +
+ )} + + ) +} + +export default SendDirectResults diff --git a/src/components/direct/send-direct/SendDirectTabs.tsx b/src/components/direct/send-direct/SendDirectTabs.tsx new file mode 100644 index 00000000..bb0afe8d --- /dev/null +++ b/src/components/direct/send-direct/SendDirectTabs.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useEffect, useState } from 'react' +import TabsComponent, { TabInputs } from '@/components/shared/TabsComponent' +import Version13 from './Version13Tab' +import Version12 from './Version12Tab' +import MessageStatus from './message-status/MessageStatus' +export interface SendDirectTabsProps { + domainName: string +} +const SendDirectTabs = ({ domainName }: SendDirectTabsProps) => { + const [selectedTab, setSelectedTab] = useState('VERSION V1.3') + + const sendDirectTabs: TabInputs[] = [ + { tabName: 'VERSION V1.3', tabIndex: 0, tabPanel: }, + { tabName: 'VERSION V1.2', tabIndex: 1, tabPanel: }, + { tabName: 'Message Status', tabIndex: 2, tabPanel: }, + ] + + useEffect(() => { + const handleRouteChange = () => { + const hash = window.location.hash.replace('#', '').replace(/-/g, ' ').toLowerCase() + const tab = sendDirectTabs.find((t) => t.tabName.toLowerCase() === hash) + setSelectedTab(tab ? tab.tabName : 'VERSION V1.3') + } + + handleRouteChange() + window.addEventListener('hashchange', handleRouteChange) + + return () => { + window.removeEventListener('hashchange', handleRouteChange) + } + }, []) + + return +} + +export default SendDirectTabs diff --git a/src/components/direct/send-direct/Version12Tab.tsx b/src/components/direct/send-direct/Version12Tab.tsx index e1162df0..d0d7f29f 100644 --- a/src/components/direct/send-direct/Version12Tab.tsx +++ b/src/components/direct/send-direct/Version12Tab.tsx @@ -1,5 +1,5 @@ import DirectForm, { algorithmProps, certProps } from './DirectForm' - +import { SendDirectTabsProps } from './SendDirectTabs' const certificateDropdownV12: certProps[] = [ { value: 'GOOD', label: 'GOOD_CERT' }, { value: 'INVALID', label: 'INVALID_CERT' }, @@ -12,9 +12,14 @@ const algorithmDropdownV12: algorithmProps[] = [ { value: 'sha1', label: 'SHA-1' }, { value: 'sha256', label: 'SHA-256' }, ] -const Version12 = () => { +const Version12 = ({ domainName }: SendDirectTabsProps) => { return ( - + ) } diff --git a/src/components/direct/send-direct/Version13Tab.tsx b/src/components/direct/send-direct/Version13Tab.tsx index 2b6dc3f7..fc48322e 100644 --- a/src/components/direct/send-direct/Version13Tab.tsx +++ b/src/components/direct/send-direct/Version13Tab.tsx @@ -1,39 +1,47 @@ import React from 'react' import DirectForm, { algorithmProps, certProps } from './DirectForm' - +import { SendDirectTabsProps } from './SendDirectTabs' const certificateDropdown: certProps[] = [ - { value: 'GOOD_CERT', label: 'GOOD_CERT' }, - { value: 'INVALID_CERT', label: 'INVALID_CERT' }, - { value: 'EXPIRED_CERT', label: 'EXPIRED_CERT' }, - { value: 'DIFFERENT_TRUST_ANCHOR', label: 'DIFFERENT_TRUST_ANCHOR' }, - { value: 'BAD_AIA', label: 'BAD_AIA' }, - { value: 'WILD_CARD_DOMAIN_CERT', label: 'WILD_CARD_DOMAIN_CERT' }, - { value: 'CERT_WITH_EMAIL_ADDRESS', label: 'CERT_WITH_EMAIL_ADDRESS' }, - { value: 'CERT_LESS_THAN_2048_BITS', label: 'CERT_LESS_THAN_2048_BITS' }, - { value: 'CERT_WITH_NO_CRL', label: 'CERT_WITH_NO_CRL' }, - { value: 'CERT_WITH_NO_NOTBEFORE_ATTR', label: 'CERT_WITH_NO_NOTBEFORE_ATTR' }, - { value: 'CERT_WITH_NO_NOTAFTER_ATT', label: 'CERT_WITH_NO_NOTAFTER_ATT' }, - { value: 'CERT_WITH_3072_BITS', label: 'CERT_WITH_3072_BITS' }, - { value: 'CERT_WITH_4096_BITS', label: 'CERT_WITH_4096_BITS' }, + { value: 'GOOD', label: 'GOOD_CERT' }, + { value: 'GOOD_ECDSA_CERT', label: 'GOOD_ECDSA_CERT' }, + { value: 'INVALID', label: 'INVALID_CERT' }, + { value: 'EXPIRED', label: 'EXPIRED_CERT' }, + { value: 'DIFF', label: 'DIFFERENT_TRUST_ANCHOR' }, + { value: 'AIA', label: 'BAD_AIA' }, + { value: 'WILD_CARD', label: 'WILD_CARD_DOMAIN_CERT' }, + { value: 'EMAIL', label: 'CERT_WITH_EMAIL_ADDRESS' }, + { value: 'LESS_2048', label: 'CERT_LESS_THAN_2048_BITS' }, + { value: 'NO_CRL', label: 'CERT_WITH_NO_CRL' }, + { value: 'NO_NOTBEFORE', label: 'CERT_WITH_NO_NOTBEFORE_ATTR' }, + { value: 'NO_NOTAFTER', label: 'CERT_WITH_NO_NOTAFTER_ATT' }, + { value: 'CERT_3072', label: 'CERT_WITH_3072_BITS' }, + { value: 'CERT_4096', label: 'CERT_WITH_4096_BITS' }, ] const algorithmDropdown: algorithmProps[] = [ - { value: 'SHA-256', label: 'SHA-256' }, - { value: 'SHA-384', label: 'SHA-384' }, - { value: 'SHA-512', label: 'SHA-512' }, + { value: 'sha256', label: 'SHA-256' }, + { value: 'sha384', label: 'SHA-384' }, + { value: 'sha512', label: 'SHA-512' }, { - value: 'Optimal Asymmetric Encryption Padding (OAEP) for RSA encryption and decryption', + value: 'OAEP-RSA', label: 'Optimal Asymmetric Encryption Padding (OAEP) for RSA encryption and decryption', }, - { value: 'ECDSA with P-256', label: 'ECDSA with P-256' }, - { value: 'ECDSA with SHA-256', label: 'ECDSA with SHA-256' }, - { value: 'ECDSA with P-384', label: 'ECDSA with P-384' }, - { value: 'ECDSA with SHA-384', label: 'ECDSA with SHA-384' }, - { value: 'AES with CBC', label: 'AES with CBC' }, - { value: 'AES with GCM', label: 'AES with GCM' }, + { value: 'edsap256', label: 'ECDSA with P-256' }, + { value: 'edsasha256', label: 'ECDSA with SHA-256' }, + { value: 'edsap384', label: 'ECDSA with P-384' }, + { value: 'edsasha384', label: 'ECDSA with SHA-384' }, + { value: 'aescbc', label: 'AES with CBC' }, + { value: 'aesgcm', label: 'AES with GCM' }, ] -const Version13 = () => { - return +const Version13 = ({ domainName }: SendDirectTabsProps) => { + return ( + + ) } export default Version13 diff --git a/src/components/direct/send-direct/actions.ts b/src/components/direct/send-direct/actions.ts new file mode 100644 index 00000000..9ea626de --- /dev/null +++ b/src/components/direct/send-direct/actions.ts @@ -0,0 +1,124 @@ +'use server' +import { GENERIC_ERROR_MESSAGE } from '@/constants/errorConstants' +import axios from 'axios' + +export async function postTempUpload(file: File) { + const Api = process.env.ETT_TEMP_UPLOAD_API + const formData = new FormData() + formData.append('file', file) + formData.append('flowRelativePath', file.name) + formData.append('flowTotalSize', file.size.toString()) + formData.append('flowFilename', file.name) + const config = { + method: 'post', + url: Api, + data: formData, + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + try { + const response = await axios.request(config) + console.log('Temp Upload response status', response.status) + return { response: response.data.flowRelativePath } + } catch (error) { + if (axios.isAxiosError(error)) { + console.error(error.response?.data) + return { + response: { + error: GENERIC_ERROR_MESSAGE, + errorStatus: error.response?.status, + }, + } + } else { + console.error(error) + } + } +} + +// Send Direct Message API Post Call +export async function handleSendDirectMessage(prevState: object | undefined, formData: FormData) { + const sendDirectApi = process.env.SEND_DIRECT_API + const domainName = formData.get('domainName') as string + const version = formData.get('version') + const fromAddress = (formData.get('fromAddress') as string) + '@' + domainName + const toAddress = formData.get('toAddress') as string + const subject = formData.get('subject') !== '' ? (formData.get('subject') as string) : 'Test Message' + const textMessage = formData.get('textMessage') !== '' ? (formData.get('textMessage') as string) : 'Test Message' + const attachmentFile = + formData.get('attachmentFile') !== '' ? (formData.get('attachmentFile') as string) : 'CCDA_Ambulatory.xml' + const wrapped = version === 'v13' ? true : formData.get('wrapped') === 'on' ? true : false + const invalidDigest = formData.get('invalidDigest') === 'on' ? true : false + const signingCert = formData.get('signingCert') !== '' ? (formData.get('signingCert') as string) : '' + const signingCertPassword = '' + + const digestAlgo = + version === 'v13' + ? formData.get('digestAlgo') !== '' + ? (formData.get('digestAlgo') as string) + : 'sha256' + : formData.get('digestAlgo') !== '' + ? (formData.get('digestAlgo') as string) + : 'sha1' + const ownCcdaAttachment = formData.get('ownCcdaAttachment') as File + const encryptionCert = formData.get('encryptionCert') as File + + let ownCcdaAttachmentTempPath = '' + if (ownCcdaAttachment.size !== 0) { + const tempPath = await postTempUpload(ownCcdaAttachment) + ownCcdaAttachmentTempPath = tempPath?.response || '' + console.log('ownCcdaAttachmentTempPath', ownCcdaAttachmentTempPath) + } + let encryptionCertTempPath = '' + if (encryptionCert.size !== 0) { + const tempPath = await postTempUpload(encryptionCert) + encryptionCertTempPath = tempPath?.response || '' + console.log('encryptionCertTempPath', encryptionCertTempPath) + } + const data = JSON.stringify({ + fromAddress: fromAddress, + toAddress: toAddress, + subject: subject, + textMessage: textMessage, + attachmentFile: attachmentFile, + wrapped: wrapped, + invalidDigest: invalidDigest, + signingCert: signingCert, + signingCertPassword: signingCertPassword, + directVersion: version, + digestAlgo: digestAlgo, + ownCcdaAttachment: ownCcdaAttachmentTempPath, + encryptionCert: encryptionCertTempPath, + }) + const config = { + method: 'post', + url: sendDirectApi, + headers: { + 'Content-Type': 'application/json', + }, + data: data, + } + console.log('Submitted data for Send Direct ', config) + try { + const response = await axios.request(config) + console.log(JSON.stringify(response.data)) + return { response: response.data } + } catch (error) { + if (axios.isAxiosError(error)) { + console.error(error.response?.data) + return { + response: { + error: error.response?.data.message ? error.response?.data.message : GENERIC_ERROR_MESSAGE, + errorStatus: error.response?.status, + }, + } + } else { + console.error(error) + return { + response: { + error: GENERIC_ERROR_MESSAGE, + }, + } + } + } +} diff --git a/src/components/direct/shared/AlertSnackbar.tsx b/src/components/direct/shared/AlertSnackbar.tsx index de20380f..e7961b32 100644 --- a/src/components/direct/shared/AlertSnackbar.tsx +++ b/src/components/direct/shared/AlertSnackbar.tsx @@ -16,9 +16,9 @@ const AlertSnackbar = ({ open={open} autoHideDuration={6000} onClose={onClose} - anchorOrigin={{ vertical: 'top', horizontal: 'right' }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > - + {message} diff --git a/src/components/direct/shared/DownloadXDRCert.tsx b/src/components/direct/shared/DownloadXDRCert.tsx new file mode 100644 index 00000000..e291cdbc --- /dev/null +++ b/src/components/direct/shared/DownloadXDRCert.tsx @@ -0,0 +1,25 @@ +import { Box, Button, Card, CardContent, Typography } from '@mui/material' +import { Download } from '@mui/icons-material' + +const DownloadXDRCert = () => { + return ( + + + + Download XDR TLS certificates. + + + + + ) +} + +export default DownloadXDRCert diff --git a/src/components/direct/shared/Profile.tsx b/src/components/direct/shared/Profile.tsx index 9b6cab7b..ba9083e2 100644 --- a/src/components/direct/shared/Profile.tsx +++ b/src/components/direct/shared/Profile.tsx @@ -1,110 +1,301 @@ import palette from '@/styles/palette' -import { Box, TextField, Button, MenuItem, FormGroup, FormControlLabel, Switch } from '@mui/material' +import { + Box, + TextField, + Button, + FormGroup, + FormControlLabel, + Switch, + Select, + MenuItem, + SelectChangeEvent, + LinearProgress, +} from '@mui/material' +import { useContext, useEffect, useState } from 'react' +import { ProfileContext } from '../hisp/context' +import { deleteProfile, fetchProfiles, saveProfile } from '../hisp/actions' +import { useSession } from 'next-auth/react' +import AlertSnackbar from './AlertSnackbar' +import _ from 'lodash' -const dropdown = [ - { - value: 'Default Profile', - label: 'Default Profile', - }, -] +interface Profile { + profileName: string + sutSMTPAddress: string + sutEmailAddress: string + sutUsername: string + sutPassword: string + useTLS: boolean + smtpEdgeProfileID: string +} -interface ProfileProps { - setHostname?: (hostname: string) => void - setEmail?: (email: string) => void - setUsername?: (username: string) => void - setPassword?: (password: string) => void - setTls?: (tls: boolean) => void +const NEWPROFILENAME = '__new__' +const removeProfilesWithNullProfileName = (profiles: Profile[]) => { + return profiles.filter((profile) => profile.profileName !== null) } +const Profile = () => { + const { + setProfileid, + setProfilename, + setHostname, + setEmail, + setUsername, + setPassword, + setTls, + profileid, + profilename, + hostname, + email, + password, + tls, + username, + } = useContext(ProfileContext) + const { data: session, status } = useSession() + + const [profiles, setProfiles] = useState([]) + const [selectedProfileName, setSelectedProfileName] = useState(NEWPROFILENAME) + const [isLoading, setIsLoading] = useState(false) + interface Message { + text: string + severity: 'info' | 'error' | 'success' | 'warning' + } + + const [message, setMessage] = useState(null) + + useEffect(() => { + async function fetchLoggedInUsersProfiles() { + setIsLoading(true) + const loggedInUsersProfiles = await fetchProfiles() + const filteredProfiles = removeProfilesWithNullProfileName(loggedInUsersProfiles) + if (!_.isEmpty(filteredProfiles)) { + setProfiles(filteredProfiles) + setSelectedProfileName(filteredProfiles[0].profileName) + setProfilename(filteredProfiles[0].profileName) + setHostname(filteredProfiles[0].sutSMTPAddress) + setEmail(filteredProfiles[0].sutEmailAddress) + setUsername(filteredProfiles[0].sutUsername) + setPassword(filteredProfiles[0].sutPassword) + setTls(filteredProfiles[0].useTLS) + setProfileid(filteredProfiles[0].smtpEdgeProfileID) + } + setIsLoading(false) + } + if (status === 'authenticated') { + fetchLoggedInUsersProfiles() + } + }, [status, session]) + + const handleSaveProfile = () => { + setIsLoading(true) + saveProfile({ + profileid: profileid || '', + hostname: hostname, + email: email, + username: username, + password: password, + istls: tls, + profilename: profilename, + }).then(async (response) => { + if (response) { + setMessage({ text: `${profilename} saved`, severity: 'success' }) + const loggedInUsersProfiles = await fetchProfiles() + const filteredProfiles = removeProfilesWithNullProfileName(loggedInUsersProfiles) + setProfiles(filteredProfiles) + const savedProfile = filteredProfiles.filter((profile) => profile.profileName === profilename).pop() + if (savedProfile) { + setSelectedProfileName(savedProfile.profileName) + setProfilename(savedProfile.profileName) + setHostname(savedProfile.sutSMTPAddress) + setEmail(savedProfile.sutEmailAddress) + setUsername(savedProfile.sutUsername) + setPassword(savedProfile.sutPassword) + setTls(savedProfile.useTLS) + setProfileid(savedProfile.smtpEdgeProfileID) + } + } else { + setMessage({ text: `Failed to save ${profilename}`, severity: 'error' }) + } + setIsLoading(false) + }) + } -const noop = () => {} + const handleDeleteProfile = () => { + setIsLoading(true) + const profileNameToDelete = selectedProfileName + deleteProfile(profileNameToDelete).then(async (response) => { + if (response) { + setMessage({ text: `${profileNameToDelete} removed`, severity: 'success' }) + const loggedInUsersProfiles = await fetchProfiles() + const filteredProfiles = removeProfilesWithNullProfileName(loggedInUsersProfiles) + setProfiles(filteredProfiles || [{ smtpEdgeProfileID: NEWPROFILENAME } as Profile]) + const lastProfile = _.last(filteredProfiles) + setSelectedProfileName(lastProfile?.profileName || NEWPROFILENAME) + setProfilename(lastProfile?.profileName || '') + setHostname(lastProfile?.sutSMTPAddress || '') + setEmail(lastProfile?.sutEmailAddress || '') + setUsername(lastProfile?.sutUsername || '') + setPassword(lastProfile?.sutPassword || '') + setTls(lastProfile?.useTLS || false) + setProfileid(lastProfile?.smtpEdgeProfileID || NEWPROFILENAME) + } else { + setMessage({ text: `Failed to remove ${profileNameToDelete}`, severity: 'error' }) + } + setIsLoading(false) + }) + } + + const handleProfileChange = async (event: SelectChangeEvent) => { + const selectedProfileName = event.target.value + if (selectedProfileName === NEWPROFILENAME) { + console.log('New Profile') + setSelectedProfileName(selectedProfileName) + setProfilename('') + setHostname('') + setEmail('') + setUsername('') + setPassword('') + setTls(false) + setProfileid('') + return + } + const selectedProfile = profiles.find((profile) => profile.profileName === selectedProfileName) + if (selectedProfile) { + setSelectedProfileName(selectedProfile.profileName) + setProfilename(selectedProfile.profileName) + setHostname(selectedProfile.sutSMTPAddress) + setEmail(selectedProfile.sutEmailAddress) + setUsername(selectedProfile.sutUsername) + setPassword(selectedProfile.sutPassword) + setTls(selectedProfile.useTLS) + setProfileid(selectedProfile.smtpEdgeProfileID) + } + } -const Profile: React.FC = ({ - setHostname = noop, - setEmail = noop, - setUsername = noop, - setPassword = noop, - setTls = noop, -}) => { return ( - - - {dropdown.map((option) => ( - - {option.label} - - ))} - - - - setHostname(e.target.value)} - /> - setEmail(e.target.value)} - /> - - - setUsername(e.target.value)} - /> - setPassword(e.target.value)} - /> - - - setTls(e.target.checked)} />} - label="TLS REQUIRED" - name="tlsRequired" - /> - - - - - - + <> + {isLoading ? ( + + ) : ( + + {!_.isEmpty(profiles) && ( + <> + + + )} + + + setHostname(e.target.value)} + /> + setEmail(e.target.value)} + /> + + + setUsername(e.target.value)} + /> + setPassword(e.target.value)} + /> + + + setTls(e.target.checked)} />} + label="TLS REQUIRED" + name="tlsRequired" + /> + + + + + {status === 'authenticated' && ( + <> + setProfilename(e.target.value)} + /> + + + + + + )} + + {!_.isEmpty(message) && ( + setMessage(null)} + /> + )} - - - + )} + ) } export default Profile diff --git a/src/components/direct/test-by-criteria/B1Tab.tsx b/src/components/direct/test-by-criteria/B1Tab.tsx index 729bcf98..6415c746 100644 --- a/src/components/direct/test-by-criteria/B1Tab.tsx +++ b/src/components/direct/test-by-criteria/B1Tab.tsx @@ -17,15 +17,15 @@ import palette from '@/styles/palette' import React, { useState } from 'react' import testCases from '../../../assets/SMTPTestCases' import xdrTestCases from '../../../assets/XDRTestCases' +import { useContext } from 'react' +import { ProfileContext } from '../hisp/context' +import DownloadXDRCert from '../shared/DownloadXDRCert' const B1Component = () => { const [option, setOption] = useState('') const [showTestCard, setShowTestCard] = useState(false) - const [hostname, setHostname] = useState('') - const [email, setEmail] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [tlsRequired, setTlsRequired] = useState(false) + const { hostname, email, password, tls, username } = useContext(ProfileContext) + const [isXDR, setIsXDR] = React.useState(false) const criteriaA = xdrTestCases.filter((testXdr) => testXdr.criteria?.includes('b1-1')) const criteriaB = testCases.tests.filter((test) => test.criteria?.includes('b1-8')) @@ -40,6 +40,11 @@ const B1Component = () => { const newOption = event.target.value as string setShowTestCard(false) + if (newOption === 'A' || newOption === 'D') { + setIsXDR(true) + } else { + setIsXDR(false) + } setTimeout(() => { setOption(newOption) @@ -123,15 +128,12 @@ const B1Component = () => { - - - + {!isXDR && ( + + + + )} + {isXDR && }
{showTestCard && @@ -144,7 +146,7 @@ const B1Component = () => { email={email} username={username} password={password} - tlsRequired={tlsRequired} + tlsRequired={tls} receive={isReceiveOption()} /> diff --git a/src/components/direct/test-by-criteria/H1Tab.tsx b/src/components/direct/test-by-criteria/H1Tab.tsx index efaf294d..75792521 100644 --- a/src/components/direct/test-by-criteria/H1Tab.tsx +++ b/src/components/direct/test-by-criteria/H1Tab.tsx @@ -15,14 +15,12 @@ import Profile from '../shared/Profile' import palette from '@/styles/palette' import TestCard from '../hisp/TestCard' import testCases from '../../../assets/SMTPTestCases' +import { useContext } from 'react' +import { ProfileContext } from '../hisp/context' const H1Component = () => { const [option, setOption] = useState('') - const [hostname, setHostname] = useState('') - const [email, setEmail] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [tlsRequired, setTlsRequired] = useState(false) + const { hostname, email, password, tls, username } = useContext(ProfileContext) const dropdownOptions = [ { @@ -97,13 +95,7 @@ const H1Component = () => { - +
@@ -117,7 +109,7 @@ const H1Component = () => { email={email} username={username} password={password} - tlsRequired={tlsRequired} + tlsRequired={tls} receive={false} /> diff --git a/src/components/direct/test-by-criteria/H2Tab.tsx b/src/components/direct/test-by-criteria/H2Tab.tsx index 96816dc5..1bd1a550 100644 --- a/src/components/direct/test-by-criteria/H2Tab.tsx +++ b/src/components/direct/test-by-criteria/H2Tab.tsx @@ -2,55 +2,298 @@ import { Box, Typography, MenuItem, - Card, - CardContent, - Container, FormControl, InputLabel, Select, SelectChangeEvent, + Card, + CardContent, + Container, } from '@mui/material' +import React, { useState, useContext } from 'react' import Profile from '../shared/Profile' -import SMTPTestCard from '../shared/SMTPTestCard' import palette from '@/styles/palette' -import * as React from 'react' -import { useEffect } from 'react' -import criteria from '@/assets/Criteria' -import _ from 'lodash' +import TestCard from '../hisp/TestCard' +import XDRTestCard from '../hisp/XDRTestCard' +import testCases from '../../../assets/SMTPTestCases' +import xdrTestCases from '../../../assets/XDRTestCases' +import { ProfileContext } from '../hisp/context' +import DownloadXDRCert from '../shared/DownloadXDRCert' + +interface TestCase { + id: number + name: string + criteria?: string | string[] + protocol: string + description?: string +} + +interface XDRTestCase { + id: number + name: string + criteria?: string | string[] +} + +interface Subcategory { + value: string + label: string + categories: string[] + link?: string + testCard?: boolean + criteria?: string[] + testSources?: string[] + receive?: boolean +} const H2Component = () => { - const h2CriteriaList = criteria.filter((c) => c.testList === "['h2']") - const [firstDropdownSelectedOption, setFirstDropdownSelectedOption] = React.useState('All') - const [criteriaOptions, setCriteriaOptions] = React.useState(h2CriteriaList) - const [selectedCriteria, setSelectedCriteria] = React.useState('') - const [showTestCard, setShowTestCard] = React.useState(false) - - const h2FirstDropdown = [ - { name: 'All', testList: ['h2', 'sc2'], selectOption: 'ALL' }, - { name: 'Setup', testList: ['h2', 'sc2'], selectOption: 'A' }, - { name: 'Send', testList: ['h2', 'sc2'], selectOption: 'B' }, - { name: 'Send - Delivery Notification for Direct', testList: ['h2', 'sc2'], selectOption: '9' }, - { name: 'Send using Direct+XDM', testList: ['h2', 'sc2'], selectOption: '2' }, - { name: 'Send conversion XDR', testList: ['h2', 'sc2'], selectOption: '3' }, - { name: 'Send using Edge Protocol', testList: ['h2', 'sc2'], selectOption: '4' }, - { name: 'Receive', testList: ['h2', 'sc2'], selectOption: '5' }, - { name: 'Receive - Delivery Notification in Direct', testList: ['h2', 'sc2'], selectOption: '10' }, - { name: 'Receive using Direct+XDM', testList: ['h2', 'sc2'], selectOption: '6' }, - { name: 'Receive conversion XDR', testList: ['h2', 'sc2'], selectOption: '7' }, - { name: 'Receive using Edge Protocol', testList: ['h2', 'sc2'], selectOption: '8' }, + const [selectedCategory, setSelectedCategory] = useState('') + const [selectedSubcategory, setSelectedSubcategory] = useState('') + const { hostname, email, password, tls, username } = useContext(ProfileContext) + const [isXDR, setIsXDR] = React.useState(false) + + const categories = [ + { value: 'all', label: 'All' }, + { value: 'setup', label: 'Setup' }, + { value: 'send', label: 'Send' }, + { value: 'sendDeliveryNotification', label: 'Send - Delivery Notification for Direct' }, + { value: 'sendDirectXDM', label: 'Send using Direct+XDM' }, + { value: 'sendConversionXDR', label: 'Send conversion XDR' }, + { value: 'sendEdgeProtocol', label: 'Send using Edge Protocol' }, + { value: 'receive', label: 'Receive' }, + { value: 'receiveDeliveryNotification', label: 'Receive - Delivery Notification in Direct' }, + { value: 'receiveDirectXDM', label: 'Receive using Direct+XDM' }, + { value: 'receiveConversionXDR', label: 'Receive conversion XDR' }, + { value: 'receiveEdgeProtocol', label: 'Receive using Edge Protocol' }, ] - const handleFirstDropdownChange = (event: SelectChangeEvent) => { - setFirstDropdownSelectedOption(event.target.value as string) - const selectedOption = h2FirstDropdown.filter((o) => _.isEqual(o.name, event.target.value)) - const criteriaList = h2CriteriaList.filter((c) => c.selectOption?.includes(selectedOption[0].selectOption)) - setCriteriaOptions(criteriaList) - setSelectedCriteria('') + function parseCriteria(criteria: string | string[] | undefined): string[] { + if (!criteria) return [] + if (Array.isArray(criteria)) return criteria + if (typeof criteria === 'string') { + if (criteria.startsWith('[') && criteria.endsWith(']')) { + try { + const jsonCriteria = criteria.replace(/'/g, '"') + const parsed = JSON.parse(jsonCriteria) + if (Array.isArray(parsed)) { + return parsed + } + } catch (e) { + console.error('Failed to parse criteria:', criteria, e) + return [] + } + } + return [criteria] + } + return [] } - const handleCriteriaChange = (event: SelectChangeEvent) => { - setSelectedCriteria(event.target.value as string) + const subcategories: Subcategory[] = [ + { + value: 'certificateDiscoveryHosting', + label: 'Criteria (i) Certificate Discovery / Hosting - 2015 DCDT', + categories: ['all', 'setup'], + link: '/direct/dcdt#hosting', + }, + { + value: 'registerDirect', + label: 'Criteria (i) Register Direct', + categories: ['all', 'setup'], + link: '/direct/register', + }, + { + value: 'directHomeCertificates', + label: 'Criteria (i) Direct Home - Certificates', + categories: ['all', 'setup'], + link: '/direct#certification-download', + }, + { + value: 'sendDirectMessage', + label: 'Criteria (i) Send Direct Message', + categories: ['all', 'send'], + link: '/direct/senddirect', + }, + { + value: 'messageStatus', + label: 'Criteria (i) Message Status', + categories: ['all', 'receive'], + link: '/direct/senddirect#message-status', + }, + { + value: 'ccdaValidator', + label: 'Criteria (i) C-CDA R2.1 validator', + categories: ['all', 'setup'], + link: '/c-cda/uscdi-v3', + }, + { + value: 'xdmValidator', + label: 'Criteria (i) XDM Validator', + categories: ['all', 'setup'], + link: '/direct/xdm', + }, + { + value: 'sendConversionXDR', + label: 'Criteria Send conversion XDR', + categories: ['all', 'sendConversionXDR'], + testCard: true, + testSources: ['xdr'], + criteria: ['h2-1'], + }, + { + value: 'receiveConversionXDR', + label: 'Criteria Receive conversion XDR', + categories: ['all', 'receiveConversionXDR'], + testCard: true, + testSources: ['xdr'], + criteria: ['h2-2'], + receive: true, + }, + { + value: 'sendEdgeXDR', + label: 'Criteria (i)(C) Send using Edge Protocol - XDR', + categories: ['all', 'sendEdgeProtocol'], + testCard: true, + testSources: ['xdr'], + criteria: ['h2-3'], + }, + { + value: 'sendEdgeSMTP', + label: 'Criteria (i)(C) Send using Edge Protocol - SMTP', + categories: ['all', 'sendEdgeProtocol'], + testCard: true, + testSources: ['smtp'], + criteria: [''], + }, + { + value: 'sendEdgeDeliveryNotification', + label: 'Criteria (i)(C) Send using Edge Protocol - Delivery Notification', + categories: ['all', 'sendEdgeProtocol'], + testCard: true, + testSources: ['smtp'], + criteria: ['b1-8', 'sc2-4'], + }, + { + value: 'sendEdgeIMAP', + label: 'Criteria (i)(C) Send using Edge Protocol - IMAP', + categories: ['all', 'sendEdgeProtocol'], + testCard: true, + testSources: ['smtp'], + criteria: ['h2-5'], + }, + { + value: 'sendEdgePOP', + label: 'Criteria (i)(C) Send using Edge Protocol - POP', + categories: ['all', 'sendEdgeProtocol'], + testCard: true, + testSources: ['smtp'], + criteria: ['h2-6'], + }, + { + value: 'receiveEdgeXDR', + label: 'Criteria (i)(C) Receive using Edge Protocol - XDR', + categories: ['all', 'receiveEdgeProtocol'], + testCard: true, + testSources: ['xdr'], + criteria: ['h2-7'], + receive: true, + }, + { + value: 'receiveEdgeSMTP', + label: 'Criteria (i)(C) Receive using Edge Protocol - SMTP', + categories: ['all', 'receiveEdgeProtocol'], + testCard: true, + testSources: ['smtp'], + criteria: ['h2-8'], + receive: true, + }, + { + value: 'deliveryNotificationSMTP', + label: 'Criteria (ii) Delivery Notification in Direct - SMTP', + categories: ['all', 'sendDeliveryNotification'], + testCard: true, + testSources: ['smtp'], + criteria: ['h2-9'], + }, + { + value: 'receiveSMTPDispositionNotification', + label: 'Criteria (ii) Receive SMTP: Disposition-Notification', + categories: ['all', 'receiveDeliveryNotification'], + testCard: true, + testSources: ['smtp'], + criteria: ['h2-10'], + receive: true, + }, + { + value: 'deliveryNotificationXDR', + label: 'Criteria (ii)(C) Delivery Notification in Direct - XDR', + categories: ['all', 'sendDeliveryNotification'], + testCard: true, + testSources: ['xdr'], + criteria: ['h2-11'], + }, + { + value: 'receiveXDRDispositionNotification', + label: 'Criteria (ii)(C) Receive XDR: Disposition-Notification', + categories: ['all', 'receiveDeliveryNotification'], + testCard: true, + testSources: ['xdr'], + criteria: ['h2-12'], + receive: true, + }, + ] + + const handleCategoryChange = (event: SelectChangeEvent) => { + setSelectedCategory(event.target.value) + setSelectedSubcategory('') + if (event.target.value.includes('XDR')) { + setIsXDR(true) + } else { + setIsXDR(false) + } } + + const handleSubcategoryChange = (event: SelectChangeEvent) => { + const value = event.target.value + const selectedOption = subcategories.find((option) => option.value === value) + setSelectedSubcategory(value) + + if (selectedOption) { + if (selectedOption.testCard) { + } else if (selectedOption.link) { + window.location.href = selectedOption.link + } + } + if (event.target.value.includes('XDR')) { + setIsXDR(true) + } else { + setIsXDR(false) + } + } + + const filteredSubcategories = subcategories.filter( + (subcategory) => + selectedCategory === 'all' || !selectedCategory || subcategory.categories.includes(selectedCategory) + ) + + const selectedOption = subcategories.find((option) => option.value === selectedSubcategory) + + const showTestCard = selectedOption?.testCard + + const selectedTestCases: TestCase[] = + showTestCard && selectedOption?.testSources?.includes('smtp') + ? (testCases.tests as TestCase[]).filter((test: TestCase) => { + const testCriteriaArray = parseCriteria(test.criteria) + return selectedOption?.criteria?.some((criterion) => testCriteriaArray.includes(criterion)) + }) + : [] + + const selectedXDRTestCases: XDRTestCase[] = + showTestCard && selectedOption?.testSources?.includes('xdr') + ? (xdrTestCases as XDRTestCase[]).filter((test: XDRTestCase) => { + const testCriteriaArray = parseCriteria(test.criteria) + return selectedOption?.criteria?.some((criterion) => testCriteriaArray.includes(criterion)) + }) + : [] + return ( @@ -59,48 +302,41 @@ const H2Component = () => { - Use the menu to select what sub criteria you want to test for. + Use the menu to select the sub-criteria you want to test for. - - Choose a sub category + + Choose a category - - - - - - - - - Use the menu to select what sub criteria you want to test for. - - - Choose a sub category + Choose a sub-category @@ -109,22 +345,47 @@ const H2Component = () => { - - - + {isXDR && } + {!isXDR && ( + + + + )} - {showTestCard && ( - - )} + {showTestCard && + selectedTestCases.map((test: TestCase, i: number) => ( + + + + ))} + {showTestCard && + selectedXDRTestCases.map((test: XDRTestCase, i: number) => ( + + + + ))}
) } + export default H2Component diff --git a/src/components/direct/test-by-criteria/ServerActions.ts b/src/components/direct/test-by-criteria/ServerActions.ts index 79dd3194..c809156b 100644 --- a/src/components/direct/test-by-criteria/ServerActions.ts +++ b/src/components/direct/test-by-criteria/ServerActions.ts @@ -5,7 +5,7 @@ import _ from 'lodash' import { NextRequest, NextResponse } from 'next/server' import { getServerSession } from 'next-auth' -interface APICallData { +export interface APICallData { testCaseNumber: number | string sutSmtpAddress: string sutEmailAddress: string @@ -30,6 +30,7 @@ interface APICallData { targetEndpointTLS?: string outgoing_from?: string attachmentType?: string + previousResult?: APICallResponse } export interface Documents { @@ -57,6 +58,7 @@ interface XDRAPICallData { svap: boolean uscdiv3: boolean } + export interface FileDetail { svap: boolean cures: boolean @@ -71,9 +73,28 @@ export interface Directory { files: FileDetail[] } -interface APIResponse { +export interface APICallResponse { + didRequestTimeOut: boolean + timeElapsedInSeconds: number + proctored: boolean + attachments: Record + CCDAValidationReports: Record + MessageId: string + fetchType: string + searchType: string + startTime: string + lastTestResultStatus: number + lastTestResponse: string + testCaseId: number + testCaseDesc: string | null + messageId: string criteriaMet: string - testRequestResponses: string + testRequestResponses: TestRequestResponses + ccdavalidationReports: Record +} + +export interface TestRequestResponses { + [key: string]: string } interface XDRAPIResponse { @@ -84,15 +105,36 @@ interface XDRAPIResponse { endpointTLS: string } +interface ResultsMetaData { + documentType: string + validationObjective: string + ccdaVersion: string + curesUpdate: boolean + svap2022: boolean + uscdi: boolean + validationDate: string +} + +interface CCDAValidationResult { + errorType: string + messageId: string +} + +interface ValidationResults { + resultsMetaData: ResultsMetaData + ccdaValidationResults: CCDAValidationResult[] +} + interface StatusResponse { criteriaMet: string testRequest: string testResponse: string message: string status: string + results?: ValidationResults } -export async function handleAPICall(data: APICallData): Promise { +export async function handleAPICall(data: APICallData): Promise { const apiUrl = process.env.SMTP_TEST_BY_CRITERIA_ENDPOINT const config = { method: 'post', @@ -103,17 +145,16 @@ export async function handleAPICall(data: APICallData): Promise { try { const response = await axios(config) - return { - criteriaMet: response.data[0].criteriaMet, - testRequestResponses: response.data[0].testRequestResponses, - } + console.log('raw API call data: ', response) + const responseData: APICallResponse[] = response.data + return responseData } catch (error) { if (axios.isAxiosError(error) && error.response) { console.error('API Error Response:', error.response.data) console.error('Status:', error.response.status) console.error('Headers:', error.response.headers) } else { - console.error('Error Message:') + console.error('Error') } throw error } @@ -155,7 +196,7 @@ export async function handleXDRAPICall(data: XDRAPICallData): Promise { + const apiUrl = process.env.SMTP_TEST_BY_CRITERIA_ENDPOINT + const session = await getServerSession(authOptions) + const jsessionid = session?.user?.jsessionid ?? '' + + const config = { + method: 'post', + url: apiUrl, + headers: session + ? { 'Content-Type': 'application/json', Cookie: `JSESSIONID=${jsessionid}` } + : { 'Content-Type': 'application/json' }, + data: data, + } + + console.log('Sending data:', config) + + try { + const response = await axios(config) + console.log('MDN check raw content:', response.data) + return response.data[0] + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + console.error('API Error Response:', error.response.data) + console.error('Status:', error.response.status) + console.error('Headers:', error.response.headers) + } else { + console.error('MDN Check Error') + } + throw error + } +} + export async function GetStatus(testCaseId: string): Promise { const statusUrl = process.env.XDR_TEST_BY_CRITERIA_ENDPOINT + testCaseId + '/status' const session = await getServerSession(authOptions) const jsessionid = session?.user?.jsessionid ?? '' + try { const response = await axios.get(statusUrl, { headers: session @@ -207,11 +281,25 @@ export async function GetStatus(testCaseId: string): Promise { let testRequest = '' let testResponse = '' let criteriaMet = '' + let results: ValidationResults | undefined + if (content && content.content && content.content.value) { - testRequest = content.content.value.request || content.message - testResponse = content.content.value.response || content.message - criteriaMet = content.content.criteriaMet + testRequest = content.content.value.request || content.message || '' + testResponse = content.content.value.response || content.message || '' + criteriaMet = content.content.criteriaMet || '' + + const ccdaReport = content.content.value.ccdaReport + if (ccdaReport) { + const resultsMetaData: ResultsMetaData = ccdaReport.resultsMetaData + const ccdaValidationResults: CCDAValidationResult[] = ccdaReport.ccdaValidationResults + + results = { + resultsMetaData: resultsMetaData, + ccdaValidationResults: ccdaValidationResults, + } + } } + console.log('Status fetched: ', content) return { criteriaMet: criteriaMet, @@ -219,6 +307,7 @@ export async function GetStatus(testCaseId: string): Promise { testResponse: testResponse, message: content.message, status: content.status, + results: results, } } catch (error) { if (axios.isAxiosError(error)) { diff --git a/src/components/direct/test-by-criteria/TestByCriteria.tsx b/src/components/direct/test-by-criteria/TestByCriteria.tsx index 606b4d46..8eaa153e 100644 --- a/src/components/direct/test-by-criteria/TestByCriteria.tsx +++ b/src/components/direct/test-by-criteria/TestByCriteria.tsx @@ -7,6 +7,7 @@ import H2Component from './H2Tab' import Link from 'next/link' import styles from '@shared/styles.module.css' import TabsComponent, { TabInputs } from '@/components/shared/TabsComponent' +import ProfileProvider from '../hisp/provider' export interface criteriaProps { selectedTab: string } @@ -33,7 +34,9 @@ const TestByCriteria = ({ selectedTab }: criteriaProps) => { heading={'Test By Criteria'} description={<>New Helper Text} /> - + + + ) } diff --git a/src/components/direct/validators/actions.ts b/src/components/direct/validators/actions.ts index 5d15fb49..d45a8868 100644 --- a/src/components/direct/validators/actions.ts +++ b/src/components/direct/validators/actions.ts @@ -1,4 +1,5 @@ 'use server' +import { GENERIC_ERROR_MESSAGE } from '@/constants/errorConstants' import axios from 'axios' import _ from 'lodash' @@ -36,7 +37,7 @@ export async function handleXDMUpload(prevState: object | undefined, formData: F console.error(error.response?.data) return { response: { - error: 'There was an error completing the request, Please try again later!', + error: GENERIC_ERROR_MESSAGE, errorStatus: error.response?.status, }, } diff --git a/src/components/direct/xdr-edge/ResultsComponent.tsx b/src/components/direct/xdr-edge/ResultsComponent.tsx new file mode 100644 index 00000000..6c0c9068 --- /dev/null +++ b/src/components/direct/xdr-edge/ResultsComponent.tsx @@ -0,0 +1,145 @@ +import { useFormStatus } from 'react-dom' +import React, { useState, useEffect, FC } from 'react' +import { + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + LinearProgress, + Typography, +} from '@mui/material' +import _ from 'lodash' + +import ErrorDisplayCard from '@/components/c-cda/validation/results/ErrorDisplay' +import CloseIcon from '@mui/icons-material/Close' +import palette from '@/styles/palette' +interface ResultsComponentProps { + response: object + disabled?: boolean + buttonTitle: string + loadingDialogTitle: string + loadingDialogContent: React.ReactNode + resultsDialogTitle: string + resultsDialogContent: React.ReactNode +} + +interface ResultsDialogProps { + response?: object + open: boolean + handleClose: () => void + contentDisplay?: React.ReactNode + title: string +} +interface LoadingResultsProps { + open: boolean + title?: string + content?: React.ReactNode +} +const LoadingResults: FC = ({ open, title, content }) => { + return ( + + + {title} + + + + {content} + + + + ) +} +const ResultsDialog: FC = ({ open, handleClose, contentDisplay, title }) => { + //console.log(JSON.stringify(response)) + + return ( + + + {title} + + + + + + {contentDisplay} + + + + + + ) +} +const ResultsComponent = ({ + response, + disabled, + buttonTitle, + loadingDialogTitle, + loadingDialogContent, + resultsDialogTitle, + resultsDialogContent, +}: ResultsComponentProps) => { + const [loadingOpen, setLoadingOpen] = useState(false) + const [resultsOpen, setResultsOpen] = useState(false) + const [errorOpen, setErrorOpen] = useState(false) + const { pending } = useFormStatus() + //console.log(response) + const handleLoadingOpen = () => { + setLoadingOpen(true) + } + const handleCloseDialog = () => { + setResultsOpen(false) + } + + const handleErrorClose = () => { + setErrorOpen(false) + } + + useEffect(() => { + if (!pending && !_.isEmpty(response) && !_.has(response, 'error')) { + setResultsOpen(true) + } + if (!pending && _.has(response, 'error')) { + setErrorOpen(true) + } + }, [pending, response]) + + return ( + <> + + + {pending && } + {!pending && _.has(response, 'error') && ( + + )} + + {!pending && !_.has(response, 'error') && ( + + )} + + ) +} + +export default ResultsComponent diff --git a/src/components/direct/xdr-edge/SendTab.tsx b/src/components/direct/xdr-edge/SendTab.tsx index ad6a34a5..e942dbd0 100644 --- a/src/components/direct/xdr-edge/SendTab.tsx +++ b/src/components/direct/xdr-edge/SendTab.tsx @@ -5,11 +5,14 @@ import Content from './send/ContentTab' import bulletedList from '../shared/BulletList' import { Container, Typography, List, ListItem } from '@mui/material' -const SendTab = () => { +interface SendTabProps { + sampleCCDAFiles: string[] +} +const SendTab = ({ sampleCCDAFiles }: SendTabProps) => { const [selectedTab, setSelectedTab] = useState('Choose template') const sendTabs: TabInputs[] = [ - { tabName: 'Choose template', tabIndex: 0, tabPanel: