diff --git a/frontends/api/package.json b/frontends/api/package.json index bcca068050..3a1e11726f 100644 --- a/frontends/api/package.json +++ b/frontends/api/package.json @@ -28,7 +28,7 @@ "ol-test-utilities": "0.0.0" }, "dependencies": { - "@mitodl/mitxonline-api-axios": "^2025.11.24", + "@mitodl/mitxonline-api-axios": "^2025.12.18", "@tanstack/react-query": "^5.66.0", "axios": "^1.12.2", "tiny-invariant": "^1.3.3" diff --git a/frontends/api/src/test-utils/factories/mitxonline.ts b/frontends/api/src/test-utils/factories/mitxonline.ts index 8c96326bf4..1eef51db58 100644 --- a/frontends/api/src/test-utils/factories/mitxonline.ts +++ b/frontends/api/src/test-utils/factories/mitxonline.ts @@ -5,6 +5,63 @@ import type { } from "@mitodl/mitxonline-api-axios/v2" import { PartialFactory } from "ol-test-utilities" +/** + * VerifiableCredential type matching the structure defined in DigitalCredentialDialog. + * This interface defines the structure of a VerifiableCredential (Open Badges v3.0). + */ +export interface VerifiableCredential { + id: string + type: string[] + proof?: { + type: string + created: string + proofValue: string + cryptosuite: string + proofPurpose: string + verificationMethod: string + } + issuer: { + id: string + name: string + type: string[] + image?: { + id: string + type: string + caption?: string + } + } + "@context": string[] + validFrom: string + validUntil: string + credentialSubject: { + type: string[] + identifier: Array<{ + salt: string + type: string + hashed: boolean + identityHash: string + identityType: string + }> + achievement: { + id: string + name: string + type: string[] + image?: { + id: string + type: string + caption?: string + } + criteria?: { + narrative: string + } + description: string + achievementType: string + } + activityEndDate?: string + activityStartDate?: string + } +} + export const courseCertificate: PartialFactory = ( overrides = {}, ) => { @@ -55,6 +112,7 @@ export const courseCertificate: PartialFactory = ( }, ], }, + verifiable_credential_json: verifiableCredential(), } return { ...base, ...overrides } as V2CourseRunCertificate @@ -104,7 +162,60 @@ export const programCertificate: PartialFactory = ( }, ], }, + verifiable_credential_json: verifiableCredential(), } return { ...base, ...overrides } as V2ProgramCertificate } + +export const verifiableCredential: PartialFactory = ( + overrides = {}, +) => { + const base: VerifiableCredential = { + id: `https://example.com/credentials/${faker.string.uuid()}`, + type: ["VerifiableCredential", "OpenBadgeCredential"], + proof: { + type: "DataIntegrityProof", + created: faker.date.past().toISOString(), + proofValue: `z${faker.string.alphanumeric(16)}`, + cryptosuite: "eddsa-rdfc-2022", + proofPurpose: "assertionMethod", + verificationMethod: `https://example.com/keys/${faker.number.int()}`, + }, + issuer: { + id: "https://example.com/issuer", + name: "MIT Open Learning", + type: ["Profile"], + }, + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json", + ], + validFrom: faker.date.past().toISOString(), + validUntil: faker.date.future().toISOString(), + credentialSubject: { + type: ["AchievementSubject"], + identifier: [ + { + salt: faker.string.alphanumeric(6), + type: "IdentityHash", + hashed: true, + identityHash: faker.person.fullName(), + identityType: "email", + }, + ], + achievement: { + id: `https://example.com/achievements/${faker.number.int()}`, + name: faker.lorem.words(3), + type: ["Achievement"], + description: faker.lorem.sentence(), + achievementType: "Certificate", + criteria: { + narrative: faker.lorem.sentence(), + }, + }, + }, + } + + return { ...base, ...overrides } as VerifiableCredential +} diff --git a/frontends/main/public/images/icons/verify.svg b/frontends/main/public/images/icons/verify.svg new file mode 100644 index 0000000000..1bef78ae4f --- /dev/null +++ b/frontends/main/public/images/icons/verify.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx index 734c3305af..c3ea72a137 100644 --- a/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx +++ b/frontends/main/src/app-pages/CertificatePage/CertificatePage.tsx @@ -19,6 +19,10 @@ import type { SignatoryItem, } from "@mitodl/mitxonline-api-axios/v2" import SharePopover from "./SharePopover" +import { + DigitalCredentialDialog, + type VerifiableCredential, +} from "./DigitalCredentialDialog" const Page = styled.div(({ theme }) => ({ backgroundImage: `url(${backgroundImage.src})`, @@ -662,6 +666,9 @@ const CertificatePage: React.FC<{ uuid: string pageUrl: string }> = ({ certificateType, uuid, pageUrl }) => { + const [digitalCredentialDialogOpen, setDigitalCredentialDialogOpen] = + useState(false) + const { data: courseCertificateData, isLoading: isCourseLoading, @@ -750,6 +757,11 @@ const CertificatePage: React.FC<{ ? "Module Certificate" : `${programCertificateData?.program.program_type} Certificate` + const verifiableCredential = + certificateType === CertificateType.Course + ? courseCertificateData?.verifiable_credential_json + : programCertificateData?.verifiable_credential_json + return ( setShareOpen(false)} pageUrl={pageUrl} /> + setDigitalCredentialDialogOpen(false)} + /> <Typography variant="h3"> <strong>{title}</strong> {displayType} @@ -772,6 +789,13 @@ const CertificatePage: React.FC<{ > Download PDF </Button> + <Button + variant="bordered" + startIcon={<RiDownloadLine />} + onClick={() => setDigitalCredentialDialogOpen(true)} + > + Download Digital Credential + </Button> <Button variant="bordered" startIcon={<RiShareLine />} diff --git a/frontends/main/src/app-pages/CertificatePage/DigitalCredentialDialog.test.tsx b/frontends/main/src/app-pages/CertificatePage/DigitalCredentialDialog.test.tsx new file mode 100644 index 0000000000..2c01ceb2f4 --- /dev/null +++ b/frontends/main/src/app-pages/CertificatePage/DigitalCredentialDialog.test.tsx @@ -0,0 +1,506 @@ +/** + * @jest-environment jsdom + */ +import React from "react" +import { screen, renderWithProviders, user } from "@/test-utils" +import { factories } from "api/test-utils" +import { + DigitalCredentialDialog, + VerifiableCredential, +} from "./DigitalCredentialDialog" + +const createMockVerifiableCredential = ( + overrides?: Partial<VerifiableCredential>, +): VerifiableCredential => factories.mitxonline.verifiableCredential(overrides) + +describe("DigitalCredentialDialog", () => { + const mockOnClose = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + global.URL.createObjectURL = jest.fn(() => "blob:mock-url") + global.URL.revokeObjectURL = jest.fn() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + describe("Dialog visibility", () => { + it("renders dialog when open is true", () => { + const credential = createMockVerifiableCredential() + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + expect( + screen.getByRole("dialog", { name: "Download Digital Credential" }), + ).toBeInTheDocument() + }) + + it("does not render dialog when open is false", () => { + const credential = createMockVerifiableCredential() + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={false} + onClose={mockOnClose} + />, + ) + + expect( + screen.queryByRole("dialog", { name: "Download Digital Credential" }), + ).not.toBeInTheDocument() + }) + }) + + describe("Content rendering", () => { + it("renders issuer name", () => { + const credential = createMockVerifiableCredential({ + issuer: { ...createMockVerifiableCredential().issuer, name: "MIT" }, + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + expect(screen.getByText("MIT")).toBeInTheDocument() + }) + + it("renders issuance date formatted correctly", () => { + const credential = createMockVerifiableCredential({ + validFrom: "2024-01-15T10:00:00Z", + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + const issuanceDate = new Date("2024-01-15T10:00:00Z").toLocaleDateString() + expect(screen.getByText(issuanceDate)).toBeInTheDocument() + }) + + it("renders expiration date formatted correctly", () => { + const credential = createMockVerifiableCredential({ + validFrom: "2024-01-15T10:00:00Z", + validUntil: "2027-12-31T23:59:59Z", + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + const expirationDate = new Date( + "2027-12-31T23:59:59Z", + ).toLocaleDateString() + expect(screen.getByText(expirationDate)).toBeInTheDocument() + }) + + it("renders N/A for expiration date when validUntil is empty", () => { + const credential = createMockVerifiableCredential({ + validUntil: "", + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + const expirationDetail = screen + .getByText("Expiration Date:") + .closest("dl") + ?.querySelector("dd") + expect(expirationDetail).toBeInTheDocument() + }) + + it("renders formatted expiration date when validUntil is provided", () => { + const credential = createMockVerifiableCredential({ + validUntil: "2027-12-31T23:59:59Z", + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + const expirationDate = new Date( + "2027-12-31T23:59:59Z", + ).toLocaleDateString() + expect(screen.getByText(expirationDate)).toBeInTheDocument() + }) + + it("renders identity hash", () => { + const credential = createMockVerifiableCredential({ + credentialSubject: { + ...createMockVerifiableCredential().credentialSubject, + identifier: [ + { + salt: "xyz", + type: "IdentityHash", + hashed: true, + identityHash: "test-hash-12345", + identityType: "email", + }, + ], + }, + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + expect(screen.getByText("test-hash-12345")).toBeInTheDocument() + }) + + it("renders achievement description", () => { + const credential = createMockVerifiableCredential({ + credentialSubject: { + ...createMockVerifiableCredential().credentialSubject, + achievement: { + ...createMockVerifiableCredential().credentialSubject.achievement, + description: "Completed advanced machine learning course", + }, + }, + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + expect( + screen.getByText("Completed advanced machine learning course"), + ).toBeInTheDocument() + }) + + it("renders criteria narrative when present", () => { + const credential = createMockVerifiableCredential({ + credentialSubject: { + ...createMockVerifiableCredential().credentialSubject, + achievement: { + ...createMockVerifiableCredential().credentialSubject.achievement, + criteria: { + narrative: "Passed all exams with 90% or higher", + }, + }, + }, + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + expect( + screen.getByText("Passed all exams with 90% or higher"), + ).toBeInTheDocument() + }) + + it("renders empty criteria when criteria is missing", () => { + const credential = createMockVerifiableCredential({ + credentialSubject: { + ...createMockVerifiableCredential().credentialSubject, + achievement: { + ...createMockVerifiableCredential().credentialSubject.achievement, + criteria: undefined, + }, + }, + }) + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + // The criteria field should still be rendered but empty + const criteriaTerm = screen.getByText("Criteria:") + expect(criteriaTerm).toBeInTheDocument() + }) + }) + + describe("Verify Credential link", () => { + it("renders verify credential link with correct href and target", () => { + const credential = createMockVerifiableCredential() + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + const verifyLink = screen.getByRole("link", { + name: /Verify Credential/i, + }) + expect(verifyLink).toHaveAttribute("href", "https://verifierplus.org/") + expect(verifyLink).toHaveAttribute("target", "_blank") + }) + }) + + describe("Download functionality", () => { + it("downloads credential as JSON when download button is clicked", async () => { + const credential = createMockVerifiableCredential() + const mockLink = { + href: "", + download: "", + click: jest.fn(), + } + + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + // Mock document methods after rendering to avoid interfering with React's DOM setup + const originalAppendChild = document.body.appendChild.bind(document.body) + const originalRemoveChild = document.body.removeChild.bind(document.body) + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") { + return mockLink as unknown as HTMLElement + } + return document.createElement(tagName) + }) + const appendChildSpy = jest + .spyOn(document.body, "appendChild") + .mockImplementation((node) => { + // Call original for React Testing Library's container + if ( + node instanceof HTMLElement && + (node.id === "root" || node.hasAttribute("data-reactroot")) + ) { + return originalAppendChild(node) + } + return node as unknown as Node + }) + const removeChildSpy = jest + .spyOn(document.body, "removeChild") + .mockImplementation((node) => { + // Call original for React Testing Library's container + if ( + node instanceof HTMLElement && + (node.id === "root" || node.hasAttribute("data-reactroot")) + ) { + return originalRemoveChild(node) + } + return node as unknown as Node + }) + + const downloadButton = screen.getByRole("button", { + name: "Download Digital Credential", + }) + await user.click(downloadButton) + + expect(createElementSpy).toHaveBeenCalledWith("a") + expect(mockLink.download).toBe("digital-credential.json") + expect(mockLink.click).toHaveBeenCalled() + expect(appendChildSpy).toHaveBeenCalledWith(mockLink) + expect(removeChildSpy).toHaveBeenCalledWith(mockLink) + expect(global.URL.createObjectURL).toHaveBeenCalled() + expect(global.URL.revokeObjectURL).toHaveBeenCalledWith("blob:mock-url") + + createElementSpy.mockRestore() + appendChildSpy.mockRestore() + removeChildSpy.mockRestore() + }) + + it("creates blob with correct JSON content", async () => { + const credential = createMockVerifiableCredential() + const mockBlob = jest.fn() + global.Blob = mockBlob as unknown as typeof Blob + + // Mock console.error to suppress navigation error from link.click() + const consoleErrorSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}) + + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + // Mock document.createElement to return a mock link after rendering + const mockLink = { + href: "", + download: "", + click: jest.fn(), + } + const originalAppendChild = document.body.appendChild.bind(document.body) + const originalRemoveChild = document.body.removeChild.bind(document.body) + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") { + return mockLink as unknown as HTMLElement + } + return document.createElement(tagName) + }) + const appendChildSpy = jest + .spyOn(document.body, "appendChild") + .mockImplementation((node) => { + if ( + node instanceof HTMLElement && + (node.id === "root" || node.hasAttribute("data-reactroot")) + ) { + return originalAppendChild(node) + } + return node as unknown as Node + }) + const removeChildSpy = jest + .spyOn(document.body, "removeChild") + .mockImplementation((node) => { + if ( + node instanceof HTMLElement && + (node.id === "root" || node.hasAttribute("data-reactroot")) + ) { + return originalRemoveChild(node) + } + return node as unknown as Node + }) + + const downloadButton = screen.getByRole("button", { + name: "Download Digital Credential", + }) + await user.click(downloadButton) + + expect(mockBlob).toHaveBeenCalledWith( + [JSON.stringify(credential, null, 2)], + { type: "application/json" }, + ) + + createElementSpy.mockRestore() + appendChildSpy.mockRestore() + removeChildSpy.mockRestore() + consoleErrorSpy.mockRestore() + }) + }) + + describe("Dialog interactions", () => { + it("calls onClose when dialog is closed", async () => { + const credential = createMockVerifiableCredential() + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + const closeButton = screen.getByRole("button", { name: "Close" }) + await user.click(closeButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it("calls onClose after download is confirmed", async () => { + const credential = createMockVerifiableCredential() + const mockLink = { + href: "", + download: "", + click: jest.fn(), + } + + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + // Mock document methods after rendering to avoid interfering with React's DOM setup + const originalAppendChild = document.body.appendChild.bind(document.body) + const originalRemoveChild = document.body.removeChild.bind(document.body) + const createElementSpy = jest + .spyOn(document, "createElement") + .mockImplementation((tagName) => { + if (tagName === "a") { + return mockLink as unknown as HTMLElement + } + return document.createElement(tagName) + }) + jest.spyOn(document.body, "appendChild").mockImplementation((node) => { + // Call original for React Testing Library's container + if ( + node instanceof HTMLElement && + (node.id === "root" || node.hasAttribute("data-reactroot")) + ) { + return originalAppendChild(node) + } + return node as unknown as Node + }) + jest.spyOn(document.body, "removeChild").mockImplementation((node) => { + // Call original for React Testing Library's container + if ( + node instanceof HTMLElement && + (node.id === "root" || node.hasAttribute("data-reactroot")) + ) { + return originalRemoveChild(node) + } + return node as unknown as Node + }) + + const downloadButton = screen.getByRole("button", { + name: "Download Digital Credential", + }) + await user.click(downloadButton) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + + createElementSpy.mockRestore() + }) + }) + + describe("Field labels", () => { + it("renders all field labels correctly", () => { + const credential = createMockVerifiableCredential() + renderWithProviders( + <DigitalCredentialDialog + verifiableCredential={credential} + open={true} + onClose={mockOnClose} + />, + ) + + expect(screen.getByText("Issuer:")).toBeInTheDocument() + expect(screen.getByText("Issuance Date:")).toBeInTheDocument() + expect(screen.getByText("Expiration Date:")).toBeInTheDocument() + expect(screen.getByText("Issued To:")).toBeInTheDocument() + expect(screen.getByText("Description:")).toBeInTheDocument() + expect(screen.getByText("Criteria:")).toBeInTheDocument() + }) + }) +}) diff --git a/frontends/main/src/app-pages/CertificatePage/DigitalCredentialDialog.tsx b/frontends/main/src/app-pages/CertificatePage/DigitalCredentialDialog.tsx new file mode 100644 index 0000000000..7fa5ccdcd2 --- /dev/null +++ b/frontends/main/src/app-pages/CertificatePage/DigitalCredentialDialog.tsx @@ -0,0 +1,209 @@ +import React from "react" +import Image from "next/image" +import { Dialog, Typography, styled } from "ol-components" +import { ButtonLink } from "@mitodl/smoot-design" +import VerifyIcon from "@/public/images/icons/verify.svg" + +const Content = styled.div({}) + +const InfoPanel = styled.div(({ theme }) => ({ + backgroundColor: theme.custom.colors.lightGray1, + padding: "16px", + marginTop: "28px", + borderRadius: "8px", + boxShadow: + "0 3px 8px 0 rgba(37, 38, 43, 0.12), 0 2px 4px 0 rgba(37, 38, 43, 0.10)", +})) + +const Info = styled.dl({ + display: "flex", + flexDirection: "row", + gap: "16px", + alignItems: "stretch", + marginBottom: "28px", +}) + +const InfoColumn = styled.dl(({ theme }) => ({ + flexGrow: 2, + alignSelf: "stretch", + ":last-child": { + borderLeft: `1px solid ${theme.custom.colors.lightGray2}`, + paddingLeft: "16px", + flexGrow: 1, + }, +})) + +const InfoTerm = styled.dt(({ theme }) => ({ + ...theme.typography.subtitle2, + textWrap: "nowrap", +})) + +const InfoDetail = styled.dd(({ theme }) => ({ + ...theme.typography.body2, + margin: "0 0 16px 0", + wordBreak: "break-word", +})) + +const Verify = styled.div(({ theme }) => ({ + display: "flex", + justifyContent: "center", + borderTop: `1px solid ${theme.custom.colors.lightGray2}`, + paddingTop: "16px", +})) + +const StyledButtonLink = styled(ButtonLink)(({ theme }) => ({ + backgroundColor: theme.custom.colors.white, +})) + +/* + * This interface defines the structure of a VerifiableCredential (Open Badges v3.0). + * While this type is not explicitly defined / unknown in our API, we can rely on its stability + * because the structure and fields are mandated by the Open Badges v3.0 specification. + * + * References: + * - https://www.imsglobal.org/spec/ob/v3p0#verifiablecredential + * - https://www.imsglobal.org/spec/ob/v3p0#complete-openbadgecredential + * - https://github.com/digitalcredentials/mit-learn-obv3-template/tree/main?tab=readme-ov-file#recommended-properties-for-obv3 + * + * We may type it on the API in a later revision. + */ +export interface VerifiableCredential { + id: string + type: string[] + proof?: { + type: string + created: string + proofValue: string + cryptosuite: string + proofPurpose: string + verificationMethod: string + } + issuer: { + id: string + name: string + type: string[] + image?: { + id: string + type: string + caption?: string + } + } + "@context": string[] + validFrom: string + validUntil: string + credentialSubject: { + type: string[] + identifier: Array<{ + salt: string + type: string + hashed: boolean + identityHash: string + identityType: string + }> + achievement: { + id: string + name: string + type: string[] + image?: { + id: string + type: string + caption?: string + } + criteria?: { + narrative: string + } + description: string + achievementType: string + } + activityEndDate?: string + activityStartDate?: string + } +} + +export const DigitalCredentialDialog = ({ + verifiableCredential, + open, + onClose, +}: { + verifiableCredential: VerifiableCredential + open: boolean + onClose: () => void +}) => { + const { issuer, validFrom, validUntil, credentialSubject } = + verifiableCredential + const { identifier, achievement } = credentialSubject + return ( + <Dialog + open={open} + title="Download Digital Credential" + confirmText="Download Digital Credential" + onClose={onClose} + onConfirm={() => { + const jsonString = JSON.stringify(verifiableCredential, null, 2) + const blob = new Blob([jsonString], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = "digital-credential.json" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + }} + contentCss={{ + margin: "24px 28px", + }} + scroll="body" + > + <Content> + <Typography variant="body2"> + A <strong>Digital Credential</strong> is a portable, digitally-signed + file describing your achievement, that you can add to social profiles + and digital resumes. + </Typography> + + <Typography variant="body2"> + Learners choose this option if they need to add a verified credential + to a social profile like LinkedIn. + </Typography> + + <InfoPanel> + <Typography variant="subtitle1">Digital Credential</Typography> + <Info> + <InfoColumn> + <InfoTerm>Issuer:</InfoTerm> + <InfoDetail>{issuer.name}</InfoDetail> + <InfoTerm>Issuance Date:</InfoTerm> + <InfoDetail> + {validFrom ? new Date(validFrom).toLocaleDateString() : "N/A"} + </InfoDetail> + <InfoTerm>Expiration Date:</InfoTerm> + <InfoDetail> + {validFrom ? new Date(validUntil).toLocaleDateString() : "N/A"} + </InfoDetail> + </InfoColumn> + <InfoColumn> + <InfoTerm>Issued To:</InfoTerm> + <InfoDetail>{identifier[0].identityHash}</InfoDetail> + <InfoTerm>Description:</InfoTerm> + <InfoDetail>{achievement.description}</InfoDetail> + <InfoTerm>Criteria:</InfoTerm> + <InfoDetail>{achievement.criteria?.narrative}</InfoDetail> + </InfoColumn> + </Info> + <Verify> + <StyledButtonLink + variant="secondary" + size="large" + href="https://verifierplus.org/" + target="_blank" + endIcon={<Image src={VerifyIcon} alt="" aria-hidden />} + > + Verify Credential + </StyledButtonLink> + </Verify> + </InfoPanel> + </Content> + </Dialog> + ) +} diff --git a/frontends/ol-components/src/components/Dialog/Dialog.tsx b/frontends/ol-components/src/components/Dialog/Dialog.tsx index fce3aef048..b9029931b3 100644 --- a/frontends/ol-components/src/components/Dialog/Dialog.tsx +++ b/frontends/ol-components/src/components/Dialog/Dialog.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useId, useState } from "react" import styled from "@emotion/styled" +import type { CSSObject } from "@emotion/react" import { theme } from "../ThemeProvider/ThemeProvider" import { default as MuiDialog } from "@mui/material/Dialog" import type { DialogProps as MuiDialogProps } from "@mui/material/Dialog" @@ -22,11 +23,12 @@ const Header = styled.div` padding: 20px 58px 20px 28px; ` -const Content = styled.div` - margin: 28px; - min-height: 0; - overflow: auto; -` +const Content = styled.div<{ css?: CSSObject }>(({ css }) => ({ + margin: "28px", + minHeight: 0, + overflow: "auto", + ...css, +})) const DialogActions = styled(MuiDialogActions)` margin: 0 28px 28px; @@ -58,6 +60,7 @@ const Transition = React.forwardRef( type DialogProps = { className?: string + contentCss?: CSSObject open: boolean onClose: () => void onConfirm?: () => void | Promise<void> @@ -77,6 +80,7 @@ type DialogProps = { disableEnforceFocus?: MuiDialogProps["disableEnforceFocus"] maxWidth?: MuiDialogProps["maxWidth"] disabled?: boolean + scroll?: MuiDialogProps["scroll"] } /** @@ -97,12 +101,14 @@ const Dialog: React.FC<DialogProps> = ({ confirmText = "Confirm", fullWidth, className, + contentCss, actions, isSubmitting = false, PaperProps, disableEnforceFocus, maxWidth, disabled = false, + scroll, }) => { const [confirming, setConfirming] = useState(isSubmitting) const titleId = useId() @@ -126,10 +132,13 @@ const Dialog: React.FC<DialogProps> = ({ open={open} onClose={onClose} disableEnforceFocus={disableEnforceFocus} - PaperProps={PaperProps} + slotProps={{ + paper: PaperProps, + }} TransitionComponent={Transition} aria-labelledby={titleId} maxWidth={maxWidth} + scroll={scroll} > <Close> <ActionButton variant="text" onClick={onClose} aria-label="Close"> @@ -143,7 +152,7 @@ const Dialog: React.FC<DialogProps> = ({ </Typography> </Header> )} - <Content> + <Content css={contentCss}> {message && <Typography variant="body1">{message}</Typography>} {children} </Content> diff --git a/yarn.lock b/yarn.lock index 76b59fdf39..6605380f6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3288,6 +3288,16 @@ __metadata: languageName: node linkType: hard +"@mitodl/mitxonline-api-axios@npm:^2025.12.18": + version: 2025.12.18 + resolution: "@mitodl/mitxonline-api-axios@npm:2025.12.18" + dependencies: + "@types/node": "npm:^20.11.19" + axios: "npm:^1.6.5" + checksum: 10/05f542d45c0be5f4fff523c615fc68dd42c11451ccf7ab033ede9beb46e19e2bbfdf90b882ccbee5392c313c29a9cf0b0796f29eb026e15e4ab89117800069f4 + languageName: node + linkType: hard + "@mitodl/smoot-design@npm:^6.19.0": version: 6.19.0 resolution: "@mitodl/smoot-design@npm:6.19.0" @@ -8530,7 +8540,7 @@ __metadata: resolution: "api@workspace:frontends/api" dependencies: "@faker-js/faker": "npm:^10.0.0" - "@mitodl/mitxonline-api-axios": "npm:^2025.11.24" + "@mitodl/mitxonline-api-axios": "npm:^2025.12.18" "@tanstack/react-query": "npm:^5.66.0" "@testing-library/react": "npm:^16.3.0" axios: "npm:^1.12.2"