diff --git a/ui/src/adapters/api/schemas.ts b/ui/src/adapters/api/schemas.ts index ea0bf1df1..5051dc026 100644 --- a/ui/src/adapters/api/schemas.ts +++ b/ui/src/adapters/api/schemas.ts @@ -5,7 +5,8 @@ import { Response, buildErrorResponse, buildSuccessResponse } from "src/adapters import { ID, IDParser, buildAuthorizationHeader } from "src/adapters/api"; import { datetimeParser, getListParser, getStrictParser } from "src/adapters/parsers"; import { ApiSchema, Env, JsonLdType } from "src/domain"; -import { API_VERSION, QUERY_SEARCH_PARAM } from "src/utils/constants"; +import { getStorageByKey } from "src/utils/browser"; +import { API_VERSION, IPFS_CUSTOM_GATEWAY_KEY, QUERY_SEARCH_PARAM } from "src/utils/constants"; import { List } from "src/utils/types"; type ApiSchemaInput = Omit & { @@ -126,10 +127,15 @@ export async function getApiSchemas({ } export const getIPFSGatewayUrl = (env: Env, ipfsUrl: string): Response => { + const ipfsGatewayUrl = getStorageByKey({ + defaultValue: env.ipfsGatewayUrl, + key: IPFS_CUSTOM_GATEWAY_KEY, + parser: z.string().url(), + }); const cid = ipfsUrl.split("ipfs://")[1]; return cid !== undefined - ? buildSuccessResponse(`${env.ipfsGatewayUrl}/ipfs/${cid}`) + ? buildSuccessResponse(`${ipfsGatewayUrl}/ipfs/${cid}`) : buildErrorResponse("Invalid IPFS URL"); }; diff --git a/ui/src/assets/icons/settings-01.svg b/ui/src/assets/icons/settings-01.svg new file mode 100644 index 000000000..434bf60ba --- /dev/null +++ b/ui/src/assets/icons/settings-01.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/src/components/shared/SettingsModal.tsx b/ui/src/components/shared/SettingsModal.tsx new file mode 100644 index 000000000..116b8468b --- /dev/null +++ b/ui/src/components/shared/SettingsModal.tsx @@ -0,0 +1,120 @@ +import { Form, Input, Modal, Typography } from "antd"; +import { useState } from "react"; +import { z } from "zod"; + +import IconClose from "src/assets/icons/x.svg?react"; +import { useEnvContext } from "src/contexts/Env"; +import { getStorageByKey, setStorageByKey } from "src/utils/browser"; +import { + CLOSE, + IPFS_CUSTOM_GATEWAY_KEY, + IPFS_PUBLIC_GATEWAY_CHECKER_URL, + SAVE, + URL_FIELD_ERROR_MESSAGE, +} from "src/utils/constants"; + +export function SettingsModal({ + afterOpenChange, + isOpen, + onClose, + onSave, +}: { + afterOpenChange: (isOpen: boolean) => void; + isOpen: boolean; + onClose: () => void; + onSave: () => void; +}) { + const env = useEnvContext(); + const [form] = Form.useForm(); + + const defaultIpfsGatewayUrl = getStorageByKey({ + defaultValue: env.ipfsGatewayUrl, + key: IPFS_CUSTOM_GATEWAY_KEY, + parser: z.string().url(), + }); + + const [areSettingsValid, setAreSettingsValid] = useState(false); + + const onResetToDefault = () => { + form.setFieldValue("ipfsGatewayUrl", env.ipfsGatewayUrl); + void form.validateFields(["ipfsGatewayUrl"]); + }; + + const onSaveClick = () => { + const parsedValue = z.string().url().safeParse(form.getFieldValue("ipfsGatewayUrl")); + if (parsedValue.success) { + const value = parsedValue.data.trim(); + const sanitizedValue = value.endsWith("/") ? value.slice(0, -1) : value; + setStorageByKey({ + key: IPFS_CUSTOM_GATEWAY_KEY, + value: sanitizedValue, + }); + onSave(); + } + }; + + const onFormChange = () => { + setAreSettingsValid(!form.getFieldsError().some(({ errors }) => errors.length)); + }; + + return ( + } + maskClosable + okButtonProps={{ disabled: !areSettingsValid }} + okText={SAVE} + onCancel={onClose} + onOk={onSaveClick} + open={isOpen} + style={{ maxWidth: 400 }} + title="Change IPFS gateway" + > +
+ + The IPFS gateway makes it possible to access files hosted on IPFS. You can customize the + IPFS gateway or continue using the default.{"\n"} + {"\n"}You can use sites like the{" "} + + IPFS Public Gateway Checker + {" "} + to choose a custom gateway. + + + Reset to default + + } + label="IPFS gateway URL" + name="ipfsGatewayUrl" + required + rules={[ + { + required: true, + }, + { + message: URL_FIELD_ERROR_MESSAGE, + validator: (_, value) => z.string().url().parseAsync(value), + }, + ]} + > + + +
+
+ ); +} diff --git a/ui/src/components/shared/SiderMenu.tsx b/ui/src/components/shared/SiderMenu.tsx index 8d933e1f3..e6e0ca02d 100644 --- a/ui/src/components/shared/SiderMenu.tsx +++ b/ui/src/components/shared/SiderMenu.tsx @@ -1,13 +1,16 @@ -import { Col, Divider, Menu, Row, Space, Tag, Typography } from "antd"; +import { Col, Divider, Menu, Row, Space, Tag, Typography, message } from "antd"; +import { useState } from "react"; import { generatePath, matchRoutes, useLocation, useNavigate } from "react-router-dom"; import IconCredentials from "src/assets/icons/credit-card-refresh.svg?react"; import IconFile from "src/assets/icons/file-05.svg?react"; import IconSchema from "src/assets/icons/file-search-02.svg?react"; import IconLink from "src/assets/icons/link-external-01.svg?react"; +import IconSettings from "src/assets/icons/settings-01.svg?react"; import IconIssuerState from "src/assets/icons/switch-horizontal.svg?react"; import IconConnections from "src/assets/icons/users-01.svg?react"; import { LogoLink } from "src/components/shared/LogoLink"; +import { SettingsModal } from "src/components/shared/SettingsModal"; import { UserDisplay } from "src/components/shared/UserDisplay"; import { useEnvContext } from "src/contexts/Env"; import { useIssuerStateContext } from "src/contexts/IssuerState"; @@ -33,6 +36,9 @@ export function SiderMenu({ const { status } = useIssuerStateContext(); const { pathname } = useLocation(); const navigate = useNavigate(); + const [messageAPI, messageContext] = message.useMessage(); + const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + const [isSettingsModalMounted, setIsSettingsModalMounted] = useState(false); const connectionsPath = ROUTES.connections.path; const credentialsPath = ROUTES.credentials.path; @@ -80,94 +86,127 @@ export function SiderMenu({ }; return ( - - - + <> + {messageContext} - + + + - , - key: schemasPath, - label: SCHEMAS, - onClick: () => onMenuClick(schemasPath), - title: "", - }, - { - icon: , - key: credentialsPath, - label: CREDENTIALS, - onClick: () => - onMenuClick( - generatePath(credentialsPath, { - tabID: CREDENTIALS_TABS[0].tabID, - }) + + + , + key: schemasPath, + label: SCHEMAS, + onClick: () => onMenuClick(schemasPath), + title: "", + }, + { + icon: , + key: credentialsPath, + label: CREDENTIALS, + onClick: () => + onMenuClick( + generatePath(credentialsPath, { + tabID: CREDENTIALS_TABS[0].tabID, + }) + ), + title: "", + }, + { + icon: , + key: connectionsPath, + label: CONNECTIONS, + onClick: () => onMenuClick(connectionsPath), + title: "", + }, + { + icon: , + key: issuerStatePath, + label: + isAsyncTaskDataAvailable(status) && status.data ? ( + + {ISSUER_STATE} + + Pending actions + + + ) : ( + ISSUER_STATE + ), + onClick: () => onMenuClick(issuerStatePath), + title: "", + }, + ]} + selectedKeys={getSelectedKey()} + /> + + + + , + key: "settings", + label: ( + { + setIsSettingsModalOpen(true); + setIsSettingsModalMounted(true); + }} + > + Settings + ), - title: "", - }, - { - icon: , - key: connectionsPath, - label: CONNECTIONS, - onClick: () => onMenuClick(connectionsPath), - title: "", - }, - { - icon: , - key: issuerStatePath, - label: - isAsyncTaskDataAvailable(status) && status.data ? ( - - {ISSUER_STATE} - - Pending actions - - - ) : ( - ISSUER_STATE + }, + { + icon: , + key: "documentation", + label: ( + + + Documentation + + + + ), - onClick: () => onMenuClick(issuerStatePath), - title: "", - }, - ]} - selectedKeys={getSelectedKey()} - /> - + }, + ]} + selectable={false} + /> + {isBreakpoint && ( + + - - , - key: "documentation", - label: ( - - - Documentation + {buildTag && {buildTag}} + + )} + + - - - - ), - }, - ]} + {isSettingsModalMounted && ( + { + setIsSettingsModalOpen(false); + }} + onSave={() => { + setIsSettingsModalOpen(false); + void messageAPI.success("IPFS gateway successfully changed"); + }} /> - {isBreakpoint && ( - - - - {buildTag && {buildTag}} - - )} - - + )} + ); } diff --git a/ui/src/utils/constants.ts b/ui/src/utils/constants.ts index 84f6d2ce3..71e601ac8 100644 --- a/ui/src/utils/constants.ts +++ b/ui/src/utils/constants.ts @@ -22,6 +22,7 @@ export const ISSUER_STATE = "Issuer state"; export const LINKS = "Links"; export const REVOCATION = "Revocation"; export const REVOKE = "Revoke"; +export const SAVE = "Save"; export const SCHEMA_HASH = "Schema hash"; export const SCHEMA_TYPE = "Schema type"; export const SCHEMAS = "Schemas"; @@ -73,3 +74,10 @@ export const WALLET_APP_STORE_URL = "https://apps.apple.com/us/app/polygon-id/id export const WALLET_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.polygonid.wallet"; + +export const IPFS_PUBLIC_GATEWAY_CHECKER_URL = "https://ipfs.github.io/public-gateway-checker/"; + +export const IPFS_CUSTOM_GATEWAY_KEY = "ipfsGatewayUrl"; + +export const URL_FIELD_ERROR_MESSAGE = + "Must be a valid URL that includes a scheme such as https://";