diff --git a/.github/workflows/test_deploy_ui.yml b/.github/workflows/test_deploy_ui.yml index 3296559a..46dae7ad 100644 --- a/.github/workflows/test_deploy_ui.yml +++ b/.github/workflows/test_deploy_ui.yml @@ -3,7 +3,7 @@ name: Deploy Issuer Node UI to Testing AWS Environment on: push: branches: - - develop + - feature/implement-new-design env: AWS_ACCOUNT_ID: ${{ secrets.TEST_AWS_ACCOUNT_ID }} @@ -12,7 +12,7 @@ env: jobs: deploy: - name: Build and Deploy UI to Testing AWS Environment + name: Build and Deploy Issuer Node UI to Testing AWS Environment runs-on: ubuntu-latest permissions: id-token: write diff --git a/README.md b/README.md index 453013fa..a4b46a7b 100644 --- a/README.md +++ b/README.md @@ -272,4 +272,5 @@ This [Quick Start Demo](https://devs.polygonid.com/docs/quick-start-demo/) will ## License -See [LICENSE](LICENSE.md). \ No newline at end of file +See [LICENSE](LICENSE.md). + diff --git a/ui/package.json b/ui/package.json index 65949eb1..8071a6c8 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,8 +5,8 @@ "ajv": "^8.12.0", "ajv-formats": "^2.1.1", "ajv-formats-draft2019": "^1.6.1", - "antd": "^5.18.0", - "axios": "^1.6.1", + "antd": "^5.20.4", + "axios": "^1.7.4", "copy-to-clipboard": "^3.3.3", "dayjs": "^1.11.10", "js-sha3": "^0.9.2", diff --git a/ui/public/fonts/RobotoMono-Regular.ttf b/ui/public/fonts/RobotoMono-Regular.ttf new file mode 100644 index 00000000..6df2b253 Binary files /dev/null and b/ui/public/fonts/RobotoMono-Regular.ttf differ diff --git a/ui/scripts/deploy.sh b/ui/scripts/deploy.sh index 7569cb44..4f367b9e 100755 --- a/ui/scripts/deploy.sh +++ b/ui/scripts/deploy.sh @@ -10,7 +10,6 @@ echo "VITE_API_PASSWORD=$ISSUER_API_AUTH_PASSWORD" >> $ENV_FILENAME echo "VITE_API_USERNAME=$ISSUER_API_AUTH_USER" >> $ENV_FILENAME echo "VITE_ISSUER_LOGO=$ISSUER_ISSUER_LOGO" >> $ENV_FILENAME echo "VITE_ISSUER_NAME=$ISSUER_ISSUER_NAME" >> $ENV_FILENAME - echo "VITE_BLOCK_EXPLORER_URL=$ISSUER_UI_BLOCK_EXPLORER_URL" >> $ENV_FILENAME echo "VITE_BUILD_TAG=$ISSUER_UI_BUILD_TAG" >> $ENV_FILENAME echo "VITE_WARNING_MESSAGE=$ISSUER_UI_WARNING_MESSAGE" >> $ENV_FILENAME @@ -20,7 +19,6 @@ echo "VITE_SCHEMA_EXPLORER_AND_BUILDER_URL=$ISSUER_UI_SCHEMA_EXPLORER_AND_BUILDE # TODO: Remove this envs: echo "VITE_ISSUER_DID=VITE_ISSUER_DID" >> $ENV_FILENAME - # Build app cd /app && npm run build diff --git a/ui/src/adapters/api/credentials.ts b/ui/src/adapters/api/credentials.ts index ef5116ba..b885cadc 100644 --- a/ui/src/adapters/api/credentials.ts +++ b/ui/src/adapters/api/credentials.ts @@ -18,10 +18,10 @@ import { getStrictParser, } from "src/adapters/parsers"; import { - AuthBJJCredentialStatus, Credential, CredentialDetail, CredentialProofType, + CredentialStatusType, Env, IssuedQRCode, IssuerIdentifier, @@ -36,19 +36,17 @@ import { API_VERSION, QUERY_SEARCH_PARAM, STATUS_SEARCH_PARAM } from "src/utils/ import { getSchemaHash } from "src/utils/iden3"; import { List, Resource } from "src/utils/types"; -type ProofTypeInput = "BJJSignature2021" | "SparseMerkleTreeProof"; - -const proofTypeParser = getStrictParser()( +const proofTypeParser = getStrictParser()( z - .array(z.union([z.literal("BJJSignature2021"), z.literal("SparseMerkleTreeProof")])) + .array(z.nativeEnum(CredentialProofType)) .min(1) .transform((values) => values.map((value) => { switch (value) { - case "BJJSignature2021": { + case CredentialProofType.BJJSignature2021: { return "SIG"; } - case "SparseMerkleTreeProof": { + case CredentialProofType.Iden3SparseMerkleTreeProof: { return "MTP"; } } @@ -87,7 +85,7 @@ type CredentialInput = Omit< > & { createdAt: string; expiresAt: string | null; - proofTypes: ProofTypeInput[]; + proofTypes: CredentialProofType[]; refreshService?: RefreshService | null; }; @@ -126,7 +124,7 @@ export const credentialDetailParser = getStrictParser type); - const schemaHash = getSchemaHash({ id: `${context.at(-1)}#${credentialSubject.type}`, name: credentialSubject.type, @@ -293,13 +282,13 @@ export async function getRevocationStatus({ export async function getCredentials({ env, issuerIdentifier, - params: { did, maxResults, page, query, sorters, status }, + params: { credentialSubject, maxResults, page, query, sorters, status }, signal, }: { env: Env; issuerIdentifier: IssuerIdentifier; params: { - did?: string; + credentialSubject?: string; maxResults?: number; page?: number; query?: string; @@ -316,7 +305,7 @@ export async function getCredentials({ }, method: "GET", params: new URLSearchParams({ - ...(did !== undefined ? { did } : {}), + ...(credentialSubject !== undefined ? { credentialSubject } : {}), ...(query !== undefined ? { [QUERY_SEARCH_PARAM]: query } : {}), ...(status !== undefined && status !== "all" ? { [STATUS_SEARCH_PARAM]: status } : {}), ...(maxResults !== undefined ? { max_results: maxResults.toString() } : {}), @@ -424,7 +413,7 @@ type LinkInput = Omit()( @@ -433,6 +422,7 @@ const linkParser = getStrictParser()( createdAt: datetimeParser, credentialExpiration: datetimeParser.nullable(), credentialSubject: z.record(z.unknown()), + deepLink: z.string(), expiration: datetimeParser.nullable(), id: z.string(), issuedClaims: z.number(), @@ -442,6 +432,7 @@ const linkParser = getStrictParser()( schemaType: z.string(), schemaUrl: z.string(), status: linkStatusParser, + universalLink: z.string(), }) ); @@ -603,22 +594,22 @@ export async function createLink({ } type AuthQRCodeInput = Omit & { - linkDetail: { proofTypes: ProofTypeInput[]; schemaType: string }; + linkDetail: { proofTypes: CredentialProofType[]; schemaType: string }; }; export type AuthQRCode = { + deepLink: string; linkDetail: { proofTypes: ProofType[]; schemaType: string }; - qrCodeLink: string; qrCodeRaw: string; - sessionID: string; + universalLink: string; }; const authQRCodeParser = getStrictParser()( z.object({ + deepLink: z.string(), linkDetail: z.object({ proofTypes: proofTypeParser, schemaType: z.string() }), - qrCodeLink: z.string(), qrCodeRaw: z.string(), - sessionID: z.string(), + universalLink: z.string(), }) ); @@ -647,17 +638,20 @@ export async function createAuthQRCode({ } type IssuedQRCodeInput = { - qrCodeLink: string; schemaType: string; + universalLink: string; }; const issuedQRCodeParser = getStrictParser()( z .object({ - qrCodeLink: z.string(), schemaType: z.string(), + universalLink: z.string(), }) - .transform(({ qrCodeLink, schemaType }) => ({ qrCode: qrCodeLink, schemaType: schemaType })) + .transform(({ schemaType, universalLink }) => ({ + qrCode: universalLink, + schemaType: schemaType, + })) ); export async function getIssuedQRCodes({ @@ -679,7 +673,7 @@ export async function getIssuedQRCodes({ Authorization: buildAuthorizationHeader(env), }, method: "GET", - params: { type: "link" }, + params: { type: "deepLink" }, signal, url: `${API_VERSION}/identities/${issuerIdentifier}/credentials/${credentialID}/qrcode`, }), @@ -703,41 +697,3 @@ export async function getIssuedQRCodes({ return buildErrorResponse(error); } } - -export type ImportQRCode = { - qrCode?: string; - status: "done" | "pending" | "pendingPublish"; -}; - -const importQRCodeParser = getStrictParser()( - z.object({ - qrCode: z.string().optional(), - status: z.union([z.literal("done"), z.literal("pendingPublish"), z.literal("pending")]), - }) -); - -export async function getImportQRCode({ - env, - issuerIdentifier, - linkID, - sessionID, -}: { - env: Env; - issuerIdentifier: IssuerIdentifier; - linkID: string; - sessionID: string; -}): Promise> { - try { - const response = await axios({ - baseURL: env.api.url, - method: "GET", - params: { - sessionID, - }, - url: `${API_VERSION}/identities/${issuerIdentifier}/credentials/links/${linkID}/qrcode`, - }); - return buildSuccessResponse(importQRCodeParser.parse(response.data)); - } catch (error) { - return buildErrorResponse(error); - } -} diff --git a/ui/src/adapters/api/issuer-state.ts b/ui/src/adapters/api/issuer-state.ts index 59ef90b1..cfa244b2 100644 --- a/ui/src/adapters/api/issuer-state.ts +++ b/ui/src/adapters/api/issuer-state.ts @@ -2,11 +2,11 @@ import axios from "axios"; import { z } from "zod"; import { Response, buildErrorResponse, buildSuccessResponse } from "src/adapters"; -import { buildAuthorizationHeader } from "src/adapters/api"; -import { datetimeParser, getListParser, getStrictParser } from "src/adapters/parsers"; +import { Sorter, buildAuthorizationHeader, serializeSorters } from "src/adapters/api"; +import { datetimeParser, getResourceParser, getStrictParser } from "src/adapters/parsers"; import { Env, IssuerIdentifier, IssuerStatus, Transaction, TransactionStatus } from "src/domain"; -import { API_VERSION } from "src/utils/constants"; -import { List } from "src/utils/types"; +import { API_VERSION, QUERY_SEARCH_PARAM } from "src/utils/constants"; +import { Resource } from "src/utils/types"; const transactionStatusParser = getStrictParser()( z.union([ @@ -108,12 +108,19 @@ const issuerStatusParser = getStrictParser()( export async function getTransactions({ env, issuerIdentifier, + params: { maxResults, page, query, sorters }, signal, }: { env: Env; issuerIdentifier: IssuerIdentifier; + params: { + maxResults?: number; + page?: number; + query?: string; + sorters?: Sorter[]; + }; signal?: AbortSignal; -}): Promise>> { +}): Promise>> { try { const response = await axios({ baseURL: env.api.url, @@ -121,17 +128,17 @@ export async function getTransactions({ Authorization: buildAuthorizationHeader(env), }, method: "GET", + params: new URLSearchParams({ + ...(query !== undefined ? { [QUERY_SEARCH_PARAM]: query } : {}), + ...(maxResults !== undefined ? { max_results: maxResults.toString() } : {}), + ...(page !== undefined ? { page: page.toString() } : {}), + ...(sorters !== undefined && sorters.length ? { sort: serializeSorters(sorters) } : {}), + }), signal, url: `${API_VERSION}/identities/${issuerIdentifier}/state/transactions`, }); - return buildSuccessResponse( - getListParser(transactionParser) - .transform(({ failed, successful }) => ({ - failed, - successful: successful.sort((a, b) => b.publishDate.getTime() - a.publishDate.getTime()), - })) - .parse(response.data) - ); + + return buildSuccessResponse(getResourceParser(transactionParser).parse(response.data)); } catch (error) { return buildErrorResponse(error); } diff --git a/ui/src/adapters/api/issuers.ts b/ui/src/adapters/api/issuers.ts index ee07623e..792f0e10 100644 --- a/ui/src/adapters/api/issuers.ts +++ b/ui/src/adapters/api/issuers.ts @@ -4,21 +4,27 @@ import { z } from "zod"; import { Response, buildErrorResponse, buildSuccessResponse } from "src/adapters"; import { buildAuthorizationHeader } from "src/adapters/api"; import { getListParser, getStrictParser } from "src/adapters/parsers"; -import { AuthBJJCredentialStatus, Env, Issuer, IssuerIdentifier, IssuerType } from "src/domain"; +import { + CredentialStatusType, + Env, + Issuer, + IssuerIdentifier, + IssuerInfo, + IssuerType, + Method, + SupportedNetwork, +} from "src/domain"; import { API_VERSION } from "src/utils/constants"; import { List } from "src/utils/types"; const apiIssuerParser = getStrictParser()( z.object({ - authBJJCredentialStatus: z.enum([ - AuthBJJCredentialStatus.Iden3OnchainSparseMerkleTreeProof2023, - AuthBJJCredentialStatus.Iden3ReverseSparseMerkleTreeProof, - AuthBJJCredentialStatus["Iden3commRevocationStatusV1.0"], - ]), blockchain: z.string(), + credentialStatusType: z.nativeEnum(CredentialStatusType), + displayName: z.string(), identifier: z.string(), - method: z.string(), + method: z.nativeEnum(Method), network: z.string(), }) ); @@ -51,16 +57,21 @@ export async function getIssuers({ export type CreateIssuer = { blockchain: string; + credentialStatusType: CredentialStatusType; + displayName: string; method: string; network: string; -} & ( - | { - authBJJCredentialStatus: AuthBJJCredentialStatus; - type: IssuerType.BJJ; - } - | { - type: IssuerType.ETH; - } + type: IssuerType; +}; + +type CreatedIssuer = { + identifier: string; +}; + +export const createIssuerParser = getStrictParser()( + z.object({ + identifier: z.string(), + }) ); export async function createIssuer({ @@ -69,11 +80,12 @@ export async function createIssuer({ }: { env: Env; payload: CreateIssuer; -}): Promise> { +}): Promise> { try { - await axios({ + const { credentialStatusType, displayName, ...didMetadata } = payload; + const response = await axios({ baseURL: env.api.url, - data: { didMetadata: payload }, + data: { credentialStatusType, didMetadata, displayName }, headers: { Authorization: buildAuthorizationHeader(env), }, @@ -81,8 +93,101 @@ export async function createIssuer({ url: `${API_VERSION}/identities`, }); + return buildSuccessResponse(createIssuerParser.parse(response.data)); + } catch (error) { + return buildErrorResponse(error); + } +} + +export const issuerDetailsParser = getStrictParser()( + z.object({ + credentialStatusType: z.nativeEnum(CredentialStatusType), + displayName: z.string(), + identifier: z.string(), + keyType: z.nativeEnum(IssuerType), + }) +); + +export async function getIssuerDetails({ + env, + identifier, + signal, +}: { + env: Env; + identifier: IssuerIdentifier; + signal?: AbortSignal; +}): Promise> { + try { + const response = await axios({ + baseURL: env.api.url, + + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "GET", + signal, + url: `${API_VERSION}/identities/${identifier}/details`, + }); + + return buildSuccessResponse(issuerDetailsParser.parse(response.data)); + } catch (error) { + return buildErrorResponse(error); + } +} + +export async function updateIssuerDisplayName({ + displayName, + env, + identifier, +}: { + displayName: string; + env: Env; + identifier: IssuerIdentifier; +}): Promise> { + try { + await axios({ + baseURL: env.api.url, + data: { displayName }, + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "PATCH", + url: `${API_VERSION}/identities/${identifier}`, + }); + return buildSuccessResponse(undefined); } catch (error) { return buildErrorResponse(error); } } + +export const supportedNetworkParser = getStrictParser()( + z.object({ + blockchain: z.string(), + networks: z.array(z.string()).nonempty(), + }) +); + +export async function getSupportedNetwork({ + env, + signal, +}: { + env: Env; + signal: AbortSignal; +}): Promise>> { + try { + const response = await axios({ + baseURL: env.api.url, + headers: { + Authorization: buildAuthorizationHeader(env), + }, + method: "GET", + signal, + url: `${API_VERSION}/supported-networks`, + }); + + return buildSuccessResponse(getListParser(supportedNetworkParser).parse(response.data)); + } catch (error) { + return buildErrorResponse(error); + } +} diff --git a/ui/src/adapters/api/schemas.ts b/ui/src/adapters/api/schemas.ts index a9856576..6ad607fe 100644 --- a/ui/src/adapters/api/schemas.ts +++ b/ui/src/adapters/api/schemas.ts @@ -117,7 +117,7 @@ export async function getApiSchemas({ ...(query !== undefined ? { [QUERY_SEARCH_PARAM]: query } : {}), }), signal, - url: `${API_VERSION}/${issuerIdentifier}/schemas`, + url: `${API_VERSION}/identities/${issuerIdentifier}/schemas`, }); return buildSuccessResponse( getListParser(apiSchemaParser) diff --git a/ui/src/adapters/parsers/view.ts b/ui/src/adapters/parsers/view.ts index a280e300..e457d3c5 100644 --- a/ui/src/adapters/parsers/view.ts +++ b/ui/src/adapters/parsers/view.ts @@ -9,12 +9,13 @@ import { getAttributeValueParser } from "src/adapters/parsers/jsonSchemas"; import { Attribute, AttributeValue, - AuthBJJCredentialStatus, CredentialProofType, + CredentialStatusType, IssuerType, Json, JsonLiteral, JsonObject, + Method, ObjectAttribute, ProofType, } from "src/domain"; @@ -45,14 +46,28 @@ export type CredentialLinkIssuance = CredentialIssuance & { type: "credentialLink"; }; -export type IssuerFormData = { blockchain: string; method: string; network: string } & ( - | { - authBJJCredentialStatus: AuthBJJCredentialStatus; - type: IssuerType.BJJ; - } - | { - type: IssuerType.ETH; - } +export type IssuerDetailsFormData = { + displayName: string; +}; + +export type IssuerFormData = { + blockchain: string; + credentialStatusType: CredentialStatusType; + displayName: string; + method: Method; + network: string; + type: IssuerType; +}; + +export const issuerFormDataParser = getStrictParser()( + z.object({ + blockchain: z.string(), + credentialStatusType: z.nativeEnum(CredentialStatusType), + displayName: z.string(), + method: z.nativeEnum(Method), + network: z.string(), + type: z.nativeEnum(IssuerType), + }) ); // Parsers diff --git a/ui/src/assets/icons/chevron-selector-vertical.svg b/ui/src/assets/icons/chevron-selector-vertical.svg new file mode 100644 index 00000000..45977d40 --- /dev/null +++ b/ui/src/assets/icons/chevron-selector-vertical.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/assets/icons/credential-card.svg b/ui/src/assets/icons/credential-card.svg new file mode 100644 index 00000000..ecada721 --- /dev/null +++ b/ui/src/assets/icons/credential-card.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/assets/icons/download-01.svg b/ui/src/assets/icons/download-01.svg new file mode 100644 index 00000000..5dd14b91 --- /dev/null +++ b/ui/src/assets/icons/download-01.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/assets/icons/edit-02.svg b/ui/src/assets/icons/edit-02.svg new file mode 100644 index 00000000..54f82015 --- /dev/null +++ b/ui/src/assets/icons/edit-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/assets/icons/fingerprint-02.svg b/ui/src/assets/icons/fingerprint-02.svg new file mode 100644 index 00000000..928956ac --- /dev/null +++ b/ui/src/assets/icons/fingerprint-02.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/assets/icons/x-close.svg b/ui/src/assets/icons/x-close.svg new file mode 100644 index 00000000..cd6d050c --- /dev/null +++ b/ui/src/assets/icons/x-close.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/ui/src/components/connections/CredentialsTable.tsx b/ui/src/components/connections/CredentialsTable.tsx index 5e7a4a29..c3a2d43d 100644 --- a/ui/src/components/connections/CredentialsTable.tsx +++ b/ui/src/components/connections/CredentialsTable.tsx @@ -188,7 +188,7 @@ export function CredentialsTable({ userID }: { userID: string }) { env, issuerIdentifier, params: { - did: userID, + credentialSubject: userID, query: query || undefined, status: credentialStatus, }, diff --git a/ui/src/components/credentials/CredentialLinkQR.tsx b/ui/src/components/credentials/CredentialLinkQR.tsx deleted file mode 100644 index 95b1968b..00000000 --- a/ui/src/components/credentials/CredentialLinkQR.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import { Avatar, Button, Space, Typography, message } from "antd"; -import { useCallback, useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; - -import { - AuthQRCode, - ImportQRCode, - createAuthQRCode, - getImportQRCode, -} from "src/adapters/api/credentials"; -import AlertIcon from "src/assets/icons/alert-circle.svg?react"; -import CheckIcon from "src/assets/icons/check.svg?react"; -import QRIcon from "src/assets/icons/qr-code.svg?react"; -import IconRefresh from "src/assets/icons/refresh-ccw-01.svg?react"; -import { ClaimCredentialModal } from "src/components/credentials/ClaimCredentialModal"; -import { CredentialQR } from "src/components/credentials/CredentialQR"; -import { ErrorResult } from "src/components/shared/ErrorResult"; -import { LoadingResult } from "src/components/shared/LoadingResult"; -import { useEnvContext } from "src/contexts/Env"; -import { useIssuerContext } from "src/contexts/Issuer"; -import { AppError } from "src/domain"; -import { AsyncTask, hasAsyncTaskFailed, isAsyncTaskDataAvailable } from "src/utils/async"; -import { isAbortedError, makeRequestAbortable } from "src/utils/browser"; -import { POLLING_INTERVAL } from "src/utils/constants"; - -const PUSH_NOTIFICATIONS_REMINDER = - "Please ensure that you have enabled push notifications on your wallet app."; - -export function CredentialLinkQR() { - const env = useEnvContext(); - const { issuerIdentifier } = useIssuerContext(); - - const [isModalOpen, setIsModalOpen] = useState(false); - const [authQRCode, setAuthQRCode] = useState>({ - status: "pending", - }); - const [importQRCheck, setImportQRCheck] = useState>({ - status: "pending", - }); - - const [messageAPI, messageContext] = message.useMessage(); - const { linkID } = useParams(); - - const createCredentialQR = useCallback( - async (signal: AbortSignal) => { - if (linkID) { - setAuthQRCode({ status: "loading" }); - - const response = await createAuthQRCode({ env, issuerIdentifier, linkID, signal }); - - if (response.success) { - setAuthQRCode({ data: response.data, status: "successful" }); - } else { - if (!isAbortedError(response.error)) { - setAuthQRCode({ error: response.error, status: "failed" }); - } - } - } - }, - [linkID, env, issuerIdentifier] - ); - - useEffect(() => { - const { aborter } = makeRequestAbortable(createCredentialQR); - - return aborter; - }, [createCredentialQR]); - - useEffect(() => { - const checkCredentialQRCode = async () => { - if (isAsyncTaskDataAvailable(authQRCode) && linkID) { - const response = await getImportQRCode({ - env, - issuerIdentifier, - linkID, - sessionID: authQRCode.data.sessionID, - }); - - if (response.success) { - if (response.data.status !== "pending") { - setImportQRCheck({ data: response.data, status: "successful" }); - - const { proofTypes } = authQRCode.data.linkDetail; - - if (proofTypes.includes("MTP")) { - void messageAPI.info("Issuance process started"); - } - - if (proofTypes.includes("SIG")) { - void messageAPI.success("Credential sent"); - } - } - } else { - setImportQRCheck({ error: response.error, status: "failed" }); - - void messageAPI.error(response.error.message); - } - } - }; - - const checkQRCredentialStatusTimer = setInterval(() => { - if ( - (isAsyncTaskDataAvailable(importQRCheck) && importQRCheck.data.status !== "pending") || - hasAsyncTaskFailed(importQRCheck) - ) { - clearInterval(checkQRCredentialStatusTimer); - } else { - void checkCredentialQRCode(); - } - }, POLLING_INTERVAL); - - return () => clearInterval(checkQRCredentialStatusTimer); - }, [authQRCode, env, importQRCheck, linkID, messageAPI, issuerIdentifier]); - - const onStartAgain = () => { - makeRequestAbortable(createCredentialQR); - setImportQRCheck({ status: "pending" }); - }; - - const appError = hasAsyncTaskFailed(authQRCode) - ? authQRCode.error - : hasAsyncTaskFailed(importQRCheck) - ? importQRCheck.error - : undefined; - - return ( - <> - {messageContext} - - {(() => { - if (appError) { - if (appError.type === "request-error" && appError.error.response?.status === 404) { - return ( - - } size={56} /> - - Credential link is invalid - - - If you think this is an error, please contact the issuer of this credential. - - - ); - } else if (appError.type === "request-error" && appError.error.response?.status === 400) { - return ( - - } size={56} /> - - - The credential link has expired, please start again - - - - - ); - } - - return ( - - ); - } - - if (!isAsyncTaskDataAvailable(authQRCode)) { - return ; - } else { - if (isAsyncTaskDataAvailable(importQRCheck) && importQRCheck.data.status !== "pending") { - const { proofTypes } = authQRCode.data.linkDetail; - - if (proofTypes.length > 1) { - return ( - <> - - } size={56} /> - - - Credential sent via notification. On-chain capabilities are pending. - - - - You will receive an additional version of the credential containing an MTP - proof. -
- {PUSH_NOTIFICATIONS_REMINDER} -
- - - - {isModalOpen && importQRCheck.data.qrCode && ( - setIsModalOpen(false)} - qrCode={importQRCheck.data.qrCode} - /> - )} -
- - ); - } - - return proofTypes[0] === "SIG" ? ( - <> - - } size={56} /> - - Credential sent via notification - - - - {isModalOpen && importQRCheck.data.qrCode && ( - setIsModalOpen(false)} - qrCode={importQRCheck.data.qrCode} - /> - )} - - - ) : ( - <> - - } size={56} /> - - - You will receive your credential via a notification - - - - {PUSH_NOTIFICATIONS_REMINDER} - - - - - - ); - } - - return ( - - Scan the QR code with your Polygon ID wallet to accept it. -
- {PUSH_NOTIFICATIONS_REMINDER} - - } - /> - ); - } - })()} - - ); -} diff --git a/ui/src/components/credentials/CredentialsTable.tsx b/ui/src/components/credentials/CredentialsTable.tsx index 9f39ed5e..f43b40f8 100644 --- a/ui/src/components/credentials/CredentialsTable.tsx +++ b/ui/src/components/credentials/CredentialsTable.tsx @@ -173,11 +173,12 @@ export function CredentialsTable() { key: "details", label: DETAILS, onClick: () => - navigate( - `${generatePath(ROUTES.credentialDetails.path, { + navigate({ + pathname: generatePath(ROUTES.credentialDetails.path, { credentialID: id, - })}?${new URLSearchParams({ revoked: `${credential.revoked}` }).toString()}` - ), + }), + search: new URLSearchParams({ revoked: `${credential.revoked}` }).toString(), + }), }, { key: "divider1", diff --git a/ui/src/components/credentials/IssueCredential.tsx b/ui/src/components/credentials/IssueCredential.tsx index aef34cac..6dab5c4c 100644 --- a/ui/src/components/credentials/IssueCredential.tsx +++ b/ui/src/components/credentials/IssueCredential.tsx @@ -2,7 +2,12 @@ import { Card, Space, message } from "antd"; import { useCallback, useEffect, useState } from "react"; import { generatePath, useNavigate, useSearchParams } from "react-router-dom"; -import { createCredential, createLink } from "src/adapters/api/credentials"; +import { + AuthQRCode, + createAuthQRCode, + createCredential, + createLink, +} from "src/adapters/api/credentials"; import { CredentialDirectIssuance, CredentialFormInput, @@ -19,15 +24,10 @@ import { SiderLayoutContent } from "src/components/shared/SiderLayoutContent"; import { useEnvContext } from "src/contexts/Env"; import { useIssuerContext } from "src/contexts/Issuer"; import { useIssuerStateContext } from "src/contexts/IssuerState"; -import { ApiSchema, JsonSchema } from "src/domain"; +import { ApiSchema, AppError, JsonSchema } from "src/domain"; import { ROUTES } from "src/routes"; import { AsyncTask, isAsyncTaskDataAvailable } from "src/utils/async"; -import { - CREDENTIALS_TABS, - DID_SEARCH_PARAM, - ISSUE_CREDENTIAL, - SCHEMA_SEARCH_PARAM, -} from "src/utils/constants"; +import { DID_SEARCH_PARAM, ISSUE_CREDENTIAL, SCHEMA_SEARCH_PARAM } from "src/utils/constants"; import { notifyParseError } from "src/utils/error"; import { extractCredentialSubjectAttribute, @@ -72,9 +72,10 @@ export function IssueCredential() { } ); - const [linkID, setLinkID] = useState>({ + const [authQRCode, setAuthQRCode] = useState>({ status: "pending", }); + const [isLoading, setIsLoading] = useState(false); const onChangeDid = (did?: string) => { @@ -123,7 +124,6 @@ export function IssueCredential() { extractCredentialSubjectAttributeWithoutId(jsonSchema); if (schemaID && credentialSubjectAttributeWithoutId) { - setLinkID({ status: "loading" }); setIsLoading(true); const serializedCredentialForm = serializeCredentialLinkIssuance({ attribute: credentialSubjectAttributeWithoutId, @@ -132,20 +132,30 @@ export function IssueCredential() { }); if (serializedCredentialForm.success) { - const response = await createLink({ + const linkResponse = await createLink({ env, issuerIdentifier, payload: serializedCredentialForm.data, }); - if (response.success) { - setLinkID({ data: response.data.id, status: "successful" }); - setStep("summary"); + + if (linkResponse.success) { + const authQRResponse = await createAuthQRCode({ + env, + issuerIdentifier, + linkID: linkResponse.data.id, + }); + + if (authQRResponse.success) { + setAuthQRCode({ data: authQRResponse.data, status: "successful" }); + setStep("summary"); + } else { + setAuthQRCode({ error: authQRResponse.error, status: "failed" }); + void messageAPI.error(authQRResponse.error.message); + } void messageAPI.success("Credential link created"); } else { - setLinkID({ error: null, status: "failed" }); - - void messageAPI.error(response.error.message); + void messageAPI.error(linkResponse.error.message); } } else { notifyParseError(serializedCredentialForm.error); @@ -182,8 +192,8 @@ export function IssueCredential() { }); if (response.success) { navigate( - generatePath(ROUTES.credentials.path, { - tabID: CREDENTIALS_TABS[0].tabID, + generatePath(ROUTES.credentialDetails.path, { + credentialID: response.data.id, }) ); @@ -291,7 +301,14 @@ export function IssueCredential() { } case "summary": { - return isAsyncTaskDataAvailable(linkID) && ; + return ( + isAsyncTaskDataAvailable(authQRCode) && ( + + ) + ); } } })()} diff --git a/ui/src/components/credentials/LinkDetails.tsx b/ui/src/components/credentials/LinkDetails.tsx index bf90c2d4..92ab01b3 100644 --- a/ui/src/components/credentials/LinkDetails.tsx +++ b/ui/src/components/credentials/LinkDetails.tsx @@ -170,12 +170,16 @@ export function LinkDetails() { ); } else { - const { createdAt, credentialExpiration, proofTypes, schemaHash, schemaType, status } = - link.data; - - const linkURL = `${window.location.origin}${generatePath(ROUTES.credentialLinkQR.path, { - linkID, - })}`; + const { + createdAt, + credentialExpiration, + deepLink, + proofTypes, + schemaHash, + schemaType, + status, + universalLink, + } = link.data; const [tag, text]: [TagProps, string] = (() => { switch (status) { @@ -219,7 +223,21 @@ export function LinkDetails() { - + + + diff --git a/ui/src/components/credentials/Summary.tsx b/ui/src/components/credentials/Summary.tsx index efabb4d0..674990f1 100644 --- a/ui/src/components/credentials/Summary.tsx +++ b/ui/src/components/credentials/Summary.tsx @@ -1,19 +1,77 @@ -import { Button, Card, Divider, Form, Input, Row, Space, message } from "antd"; -import copy from "copy-to-clipboard"; +import { Button, Card, Divider, Flex, Row, Tabs, TabsProps, Typography, theme } from "antd"; import { generatePath, useNavigate } from "react-router-dom"; -import IconCopy from "src/assets/icons/copy-01.svg?react"; -import ExternalLinkIcon from "src/assets/icons/link-external-01.svg?react"; +import DownloadIcon from "src/assets/icons/download-01.svg?react"; +import { DownloadQRLink } from "src/components/shared/DownloadQRLink"; +import { HighlightLink } from "src/components/shared/HighlightLink"; import { ROUTES } from "src/routes"; import { CREDENTIALS_TABS, CREDENTIAL_LINK } from "src/utils/constants"; -export function Summary({ linkID }: { linkID: string }) { - const [messageAPI, messageContext] = message.useMessage(); +function QRTab({ + description, + fileName, + link, + openable, +}: { + description: string; + fileName: string; + link: string; + openable: boolean; +}) { + const { token } = theme.useToken(); + + return ( + + {description} + + + } + style={{ borderColor: token.colorTextSecondary, color: token.colorTextSecondary }} + > + Download QR + + } + fileName={fileName} + hidden={false} + link={link} + /> + + + ); +} + +export function Summary({ deepLink, universalLink }: { deepLink: string; universalLink: string }) { const navigate = useNavigate(); - const linkURL = `${window.location.origin}${generatePath(ROUTES.credentialLinkQR.path, { - linkID, - })}`; + const items: TabsProps["items"] = [ + { + children: ( + + ), + key: "1", + label: "Universal link", + }, + { + children: ( + + ), + key: "2", + label: "Deep link", + }, + ]; const navigateToLinks = () => { navigate( @@ -23,45 +81,14 @@ export function Summary({ linkID }: { linkID: string }) { ); }; - const onCopyToClipboard = () => { - const hasCopied = copy(linkURL, { - format: "text/plain", - }); - - if (hasCopied) { - void messageAPI.success("Credential link copied to clipboard."); - } else { - void messageAPI.error("Couldn't copy credential link. Please try again."); - } - }; - return ( <> - {messageContext} - } - target="_blank" - type="link" - > - View link - - } + styles={{ body: { paddingTop: 0 }, header: { border: "none" } }} title={CREDENTIAL_LINK} > -
- - - - - - - - - - - - - + <> + {messageContext} + {(() => { + if (hasAsyncTaskFailed(supportedNetworks)) { + return ( + + + + ); + } else if (isAsyncTaskStarting(supportedNetworks)) { + return ( + + + + ); + } else { + const blockchainOptions = supportedNetworks.data.map(({ blockchain }) => blockchain); + const networkOptions = supportedNetworks.data.find( + ({ blockchain }) => blockchain === formData.blockchain + )?.networks; + + return ( + blockchainOptions.length && + networkOptions?.length && ( +
, allValues) => { + const updatedFormData = { ...allValues }; + + if (changedValue.blockchain) { + const networks = supportedNetworks.data.find( + ({ blockchain }) => blockchain === changedValue.blockchain + )?.networks; + updatedFormData.network = networks?.[0] || ""; + } + + const parsedIssuerFormData = issuerFormDataParser.safeParse(updatedFormData); + + if (parsedIssuerFormData.success) { + setFormData(parsedIssuerFormData.data); + form.setFieldsValue(parsedIssuerFormData.data); + } + }} + > + + + + + + Give your issuer a descriptive name, e.g. “Age credential testing”. This name is + only seen locally. + + + + + + + + + The protocol or system used to create, resolve, and manage the DID. + + + + + + + + + + + + + + + + + + + + + Identity signing key's credential status is checked by clients to generate + zero-knowledge proofs using signed credentials. + + + + <> + + + + + + + + + ) + ); + } + })()} + ); } diff --git a/ui/src/components/issuers/Issuers.tsx b/ui/src/components/issuers/Issuers.tsx index c9f71515..a9e29405 100644 --- a/ui/src/components/issuers/Issuers.tsx +++ b/ui/src/components/issuers/Issuers.tsx @@ -1,61 +1,25 @@ -import { Divider, Modal, Space, message } from "antd"; -import { useCallback, useEffect, useState } from "react"; +import { Button, Divider, Space, message } from "antd"; +import { useCallback, useEffect } from "react"; import { useNavigate } from "react-router-dom"; - -import { createIssuer } from "src/adapters/api/issuers"; -import { IssuerFormData } from "src/adapters/parsers/view"; -import IconClose from "src/assets/icons/x.svg?react"; -import { IssuerForm } from "src/components/issuers/IssuerForm"; +import IconPlus from "src/assets/icons/plus.svg?react"; import { IssuersTable } from "src/components/issuers/IssuersTable"; import { SiderLayoutContent } from "src/components/shared/SiderLayoutContent"; -import { useEnvContext } from "src/contexts/Env"; import { useIssuerContext } from "src/contexts/Issuer"; import { ROUTES } from "src/routes"; -import { isAsyncTaskDataAvailable } from "src/utils/async"; import { makeRequestAbortable } from "src/utils/browser"; import { ISSUERS, ISSUER_ADD } from "src/utils/constants"; export function Issuers() { - const env = useEnvContext(); - const { fetchIssuers, issuersList } = useIssuerContext(); + const { fetchIssuers } = useIssuerContext(); - const [messageAPI, messageContext] = message.useMessage(); - const [isModalOpen, setIsModalOpen] = useState(false); + const [, messageContext] = message.useMessage(); const navigate = useNavigate(); - const closeModal = () => { - setIsModalOpen(false); - }; - - const handleAddIssuer = useCallback(() => { - if (isAsyncTaskDataAvailable(issuersList)) { - if (issuersList.data.length) { - navigate(ROUTES.createIssuer.path); - } else { - setIsModalOpen(true); - } - } - }, [issuersList, navigate]); - const fetchData = useCallback(() => { const { aborter } = makeRequestAbortable(fetchIssuers); return aborter; }, [fetchIssuers]); - const handleSubmit = useCallback( - (formValues: IssuerFormData) => - void createIssuer({ env, payload: formValues }).then((response) => { - if (response.success) { - closeModal(); - fetchData(); - void messageAPI.success("Issuer added"); - } else { - void messageAPI.error(response.error.message); - } - }), - [fetchData, messageAPI, env] - ); - useEffect(() => { fetchData(); }, [fetchData]); @@ -64,24 +28,22 @@ export function Issuers() { <> {messageContext} - + } + onClick={() => navigate(ROUTES.createIssuer.path)} + type="primary" + > + {ISSUER_ADD} + + } + title={ISSUERS} + > - - {isModalOpen && ( - } - footer={null} - maskClosable - onCancel={closeModal} - open - title={ISSUER_ADD} - > - - - )} + navigate(ROUTES.createIssuer.path)} /> diff --git a/ui/src/components/issuers/IssuersTable.tsx b/ui/src/components/issuers/IssuersTable.tsx index ca4182b0..940d8a77 100644 --- a/ui/src/components/issuers/IssuersTable.tsx +++ b/ui/src/components/issuers/IssuersTable.tsx @@ -2,8 +2,7 @@ import { Avatar, Button, Card, - Radio, - RadioChangeEvent, + Dropdown, Row, Space, Table, @@ -11,21 +10,26 @@ import { Typography, } from "antd"; import { useCallback, useEffect, useState } from "react"; -import { useSearchParams } from "react-router-dom"; -import { identifierParser } from "src/adapters/api/issuers"; +import { generatePath, useNavigate, useSearchParams } from "react-router-dom"; import IconIssuers from "src/assets/icons/building-08.svg?react"; +import IconDots from "src/assets/icons/dots-vertical.svg?react"; +import IconInfoCircle from "src/assets/icons/info-circle.svg?react"; import IconPlus from "src/assets/icons/plus.svg?react"; import { ErrorResult } from "src/components/shared/ErrorResult"; import { NoResults } from "src/components/shared/NoResults"; import { TableCard } from "src/components/shared/TableCard"; import { useIssuerContext } from "src/contexts/Issuer"; import { Issuer } from "src/domain"; +import { ROUTES } from "src/routes"; import { isAsyncTaskDataAvailable, isAsyncTaskStarting } from "src/utils/async"; -import { ISSUER_ADD, QUERY_SEARCH_PARAM } from "src/utils/constants"; +import { DETAILS, DOTS_DROPDOWN_WIDTH, ISSUER_ADD, QUERY_SEARCH_PARAM } from "src/utils/constants"; +import { formatIdentifier } from "src/utils/forms"; export function IssuersTable({ handleAddIssuer }: { handleAddIssuer: () => void }) { - const { handleChange, issuerIdentifier, issuersList } = useIssuerContext(); + const { issuersList } = useIssuerContext(); + const navigate = useNavigate(); + const [filteredIdentifiers, setFilteredIdentifiers] = useState(() => isAsyncTaskDataAvailable(issuersList) ? issuersList.data : [] ); @@ -33,16 +37,6 @@ export function IssuersTable({ handleAddIssuer }: { handleAddIssuer: () => void const queryParam = searchParams.get(QUERY_SEARCH_PARAM); - const handleIssuerChange = ({ target: { value } }: RadioChangeEvent) => { - const parsedIdentifier = identifierParser.safeParse(value); - - if (parsedIdentifier.success) { - handleChange(parsedIdentifier.data); - } else { - handleChange(""); - } - }; - useEffect(() => { if (isAsyncTaskDataAvailable(issuersList)) { if (!queryParam) { @@ -76,45 +70,67 @@ export function IssuersTable({ handleAddIssuer }: { handleAddIssuer: () => void const tableColumns: TableColumnsType = [ { - align: "center", - dataIndex: "identifier", - key: "identifier", - render: (identifier: Issuer["identifier"]) => , + dataIndex: "displayName", + key: "displayName", + render: (displayName: Issuer["displayName"]) => ( + {displayName} + ), + sorter: ({ displayName: a }, { displayName: b }) => a.localeCompare(b), + title: "Name", }, { dataIndex: "identifier", key: "identifier", render: (identifier: Issuer["identifier"]) => ( - {identifier} + {formatIdentifier(identifier)} ), sorter: ({ identifier: a }, { identifier: b }) => a.localeCompare(b), - title: "Did", + title: "DID", }, { - dataIndex: "authBJJCredentialStatus", - key: "authBJJCredentialStatus", - render: (credentialStatus: Issuer["authBJJCredentialStatus"]) => ( - {credentialStatus} + dataIndex: "blockchain", + key: "blockchain", + render: (blockchain: Issuer["blockchain"]) => ( + {blockchain} ), - sorter: ({ authBJJCredentialStatus: a }, { authBJJCredentialStatus: b }) => - a.localeCompare(b), - - title: "Credential Status", + sorter: ({ blockchain: a }, { blockchain: b }) => a.localeCompare(b), + title: "Blockchain", }, { dataIndex: "network", key: "network", - render: (network: Issuer["network"], { blockchain }: Issuer) => ( - - {blockchain}-{network} - - ), - sorter: ( - { blockchain: blockchainA, network: networkA }, - { blockchain: blockchainB, network: networkB } - ) => `${blockchainA}-${networkA}`.localeCompare(`${blockchainB}-${networkB}`), + render: (network: Issuer["network"]) => {network}, + sorter: ({ network: a }, { network: b }) => a.localeCompare(b), title: "Network", }, + { + dataIndex: "identifier", + key: "identifier", + render: (identifier: Issuer["identifier"]) => ( + , + key: "details", + label: DETAILS, + onClick: () => + navigate( + generatePath(ROUTES.issuerDetails.path, { + issuerID: identifier, + }) + ), + }, + ], + }} + > + + + + + ), + width: DOTS_DROPDOWN_WIDTH, + }, ]; const addButton = ( @@ -124,70 +140,61 @@ export function IssuersTable({ handleAddIssuer }: { handleAddIssuer: () => void ); return ( - - - } size={48} /> + + } size={48} /> - No issuers + No issuers - - Add a new issuer to get the required credential - + + Add a new issuer to get the required credential + - {addButton} - - } - extraButton={addButton} - isLoading={isAsyncTaskStarting(issuersList)} - onSearch={onSearch} - query={queryParam} - searchPlaceholder="Search Issuer" - showDefaultContents={ - issuersList.status === "successful" && - filteredIdentifiers.length === 0 && - queryParam === null - } - table={ - ({ - title: ( - - <>{title} - + {addButton} + + } + isLoading={isAsyncTaskStarting(issuersList)} + onSearch={onSearch} + query={queryParam} + searchPlaceholder="Search Issuer" + showDefaultContents={ + issuersList.status === "successful" && + filteredIdentifiers.length === 0 && + queryParam === null + } + table={ +
({ + title: ( + + <>{title} + + ), + ...column, + }))} + dataSource={filteredIdentifiers} + locale={{ + emptyText: + issuersList.status === "failed" ? ( + + ) : ( + ), - ...column, - }))} - dataSource={filteredIdentifiers} - locale={{ - emptyText: - issuersList.status === "failed" ? ( - - ) : ( - - ), - }} - pagination={false} - rowKey="identifier" - showSorterTooltip - sortDirections={["ascend", "descend"]} - /> - } - title={ - - - - - - } - /> - + }} + pagination={false} + rowKey="identifier" + showSorterTooltip + sortDirections={["ascend", "descend"]} + /> + } + title={ + + + + + + } + /> ); } diff --git a/ui/src/components/issuers/Onboarding.tsx b/ui/src/components/issuers/Onboarding.tsx new file mode 100644 index 00000000..32ebe2a1 --- /dev/null +++ b/ui/src/components/issuers/Onboarding.tsx @@ -0,0 +1,138 @@ +import { Avatar, Card, Divider, Flex, Grid, Typography, message } from "antd"; +import React from "react"; +import { useNavigate } from "react-router-dom"; +import { createIssuer } from "../../adapters/api/issuers"; +import { IssuerFormData } from "src/adapters/parsers/view"; +import IconCheck from "src/assets/icons/check.svg?react"; +import IconIssue from "src/assets/icons/credential-card.svg?react"; +import IconSchema from "src/assets/icons/file-search-02.svg?react"; +import IconIdentity from "src/assets/icons/fingerprint-02.svg?react"; + +import { IssuerForm } from "src/components/issuers/IssuerForm"; +import { useEnvContext } from "src/contexts/Env"; + +import { useIssuerContext } from "src/contexts/Issuer"; +import { ROUTES } from "src/routes"; + +import { FINALISE_SETUP } from "src/utils/constants"; + +const cards = [ + { + icon: , + text: "Issue verifiable credentials directly or via links", + title: "Issue credentials", + }, + { + icon: , + text: "Add new identities with different DIDs and settings", + title: "Manage identities", + }, + { + icon: , + text: "Import custom schemas and use them to issue verifiable credentials", + title: "Import custom schemas", + }, +]; + +export function Onboarding() { + const env = useEnvContext(); + const { handleChange } = useIssuerContext(); + const navigate = useNavigate(); + const [messageAPI, messageContext] = message.useMessage(); + + const { lg } = Grid.useBreakpoint(); + + const handleSubmit = (formValues: IssuerFormData) => + void createIssuer({ env, payload: formValues }).then((response) => { + if (response.success) { + const { + data: { identifier }, + } = response; + void messageAPI.success("Identity added successfully"); + handleChange(identifier); + navigate(ROUTES.schemas.path); + } else { + void messageAPI.error(response.error.message); + } + }); + + return ( + <> + {messageContext} + + + + } + size={48} + style={{ marginBottom: 16 }} + /> + + + You successfully installed Issuer Node + + + Here's what you're going to be able to do with the issuer node, once you + finalise your setup + + + + + {cards.map(({ icon, text, title }, index) => { + const isLastCard = index + 1 === cards.length; + return ( + + + + + + + + {title} + + + {text} + + + + + {!isLastCard && lg &&
} + + ); + })} + + + + + + + Finalise the setup by adding a new identity + + + + + + + ); +} diff --git a/ui/src/components/issuers/SelectedIssuer.tsx b/ui/src/components/issuers/SelectedIssuer.tsx deleted file mode 100644 index ccdc86cd..00000000 --- a/ui/src/components/issuers/SelectedIssuer.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Typography } from "antd"; -import { useIssuerContext } from "src/contexts/Issuer"; -import { IssuerIdentifier } from "src/domain"; - -function formatIdentifier(identifier: IssuerIdentifier): string { - if (identifier) { - const parts = identifier.split(":"); - const id = parts.at(-1); - const shortId = `${id?.slice(0, 5)}...${id?.slice(-4)}`; - return parts.toSpliced(-1, 1, shortId).join(":"); - } - - return "Select issuer"; -} - -export function SelectedIssuer() { - const { issuerIdentifier } = useIssuerContext(); - - return ( - - {formatIdentifier(issuerIdentifier)} - - ); -} diff --git a/ui/src/components/shared/CredentialDeleteModal.tsx b/ui/src/components/shared/CredentialDeleteModal.tsx index 3f77d350..8d6c44bb 100644 --- a/ui/src/components/shared/CredentialDeleteModal.tsx +++ b/ui/src/components/shared/CredentialDeleteModal.tsx @@ -1,4 +1,4 @@ -import { Alert, Button, Modal, Row, Space, Typography, message } from "antd"; +import { Alert, Button, Flex, Modal, Space, Typography, message } from "antd"; import { useState } from "react"; import { deleteCredential, revokeCredential } from "src/adapters/api/credentials"; @@ -71,7 +71,7 @@ export function CredentialDeleteModal({ closable closeIcon={} footer={ - + )} - + } maskClosable onCancel={onClose} diff --git a/ui/src/components/shared/Detail.tsx b/ui/src/components/shared/Detail.tsx index 3262add9..a888536e 100644 --- a/ui/src/components/shared/Detail.tsx +++ b/ui/src/components/shared/Detail.tsx @@ -1,10 +1,16 @@ -import { Col, Grid, Row, Tag, TagProps, Typography } from "antd"; +import { Button, Col, Flex, Grid, Row, Tag, TagProps, Typography, message } from "antd"; +import copy from "copy-to-clipboard"; +import { useRef } from "react"; import IconCheckMark from "src/assets/icons/check.svg?react"; import IconCopy from "src/assets/icons/copy-01.svg?react"; +import IconDownload from "src/assets/icons/download-01.svg?react"; +import { DownloadQRLink } from "src/components/shared/DownloadQRLink"; export function Detail({ copyable, + copyableText, + donwloadLink, ellipsisPosition, href, label, @@ -12,6 +18,8 @@ export function Detail({ text, }: { copyable?: boolean; + copyableText?: string; + donwloadLink?: boolean; ellipsisPosition?: number; href?: string; label: string; @@ -19,13 +27,27 @@ export function Detail({ text: string; }) { const { sm } = Grid.useBreakpoint(); + const [messageAPI, messageContext] = message.useMessage(); + const donwloadLinkRef = useRef(null); + + const onCopyToClipboard = (link: string) => { + const hasCopied = copy(link, { + format: "text/plain", + }); + + if (hasCopied) { + void messageAPI.success("Link copied to clipboard."); + } else { + void messageAPI.error("Couldn't copy link. Please try again."); + } + }; const value = ellipsisPosition ? text.slice(0, text.length - ellipsisPosition) : text; const element = ( , ], - text, + text: copyableText || text, } } ellipsis={ellipsisPosition ? { suffix: text.slice(-ellipsisPosition) } : true} @@ -42,19 +64,58 @@ export function Detail({ ); return ( - -
- {label} - - - {href ? ( - - {element} - - ) : ( - element - )} - - + <> + {messageContext} + + + {label} + + + {(() => { + if (donwloadLink && href) { + return ( + + } + iconPosition="end" + ref={donwloadLinkRef} + style={{ height: "auto", padding: 0 }} + type="link" + > + Download QR + + } + fileName={label} + hidden + link={href} + /> + {copyable && ( + + )} + + ); + } else if (href) { + return ( + + {element} + + ); + } else { + return element; + } + })()} + + + ); } diff --git a/ui/src/components/shared/DownloadQRLink.tsx b/ui/src/components/shared/DownloadQRLink.tsx new file mode 100644 index 00000000..9fdec0a6 --- /dev/null +++ b/ui/src/components/shared/DownloadQRLink.tsx @@ -0,0 +1,44 @@ +import { Flex, Space, message } from "antd"; +import { QRCodeCanvas } from "qrcode.react"; +import { useRef } from "react"; +import { downloadQRCanvas } from "src/utils/browser"; + +export function DownloadQRLink({ + button, + fileName, + hidden, + link, +}: { + button: JSX.Element; + fileName: string; + hidden: boolean; + link: string; +}) { + const ref = useRef(null); + const [messageAPI, messageContext] = message.useMessage(); + + const onDownload = () => { + const canvas = ref.current?.querySelector("canvas"); + if (canvas) { + downloadQRCanvas(canvas, fileName); + void messageAPI.success("QR code downloaded successfully."); + } + }; + + return ( + <> + {messageContext} + + + {button} + + + ); +} diff --git a/ui/src/components/shared/HighlightLink.tsx b/ui/src/components/shared/HighlightLink.tsx new file mode 100644 index 00000000..d9b55e78 --- /dev/null +++ b/ui/src/components/shared/HighlightLink.tsx @@ -0,0 +1,56 @@ +import { Button, Card, Flex, Typography, message, theme } from "antd"; +import copy from "copy-to-clipboard"; +import IconCopy from "src/assets/icons/copy-01.svg?react"; +import IconLink from "src/assets/icons/link-external-01.svg?react"; + +export function HighlightLink({ link, openable }: { link: string; openable: boolean }) { + const { token } = theme.useToken(); + const [messageAPI, messageContext] = message.useMessage(); + + const onCopyToClipboard = () => { + const hasCopied = copy(link, { + format: "text/plain", + }); + + if (hasCopied) { + void messageAPI.success("Link copied to clipboard."); + } else { + void messageAPI.error("Couldn't copy link. Please try again."); + } + }; + + return ( + <> + {messageContext} + + + + {link} + + + + {openable && ( + - - - - - + {!showDefaultContents && onSearch && searchPlaceholder && query !== undefined && ( diff --git a/ui/src/components/shared/UserDisplay.tsx b/ui/src/components/shared/UserDisplay.tsx index 5b484541..3c7a3987 100644 --- a/ui/src/components/shared/UserDisplay.tsx +++ b/ui/src/components/shared/UserDisplay.tsx @@ -1,18 +1,91 @@ -import { Avatar, Row, Space, Typography } from "antd"; +import { Avatar, Dropdown, Flex, Row, Tooltip, Typography } from "antd"; +import { useNavigate } from "react-router-dom"; +import { formatIdentifier } from "../../utils/forms"; +import IconCheck from "src/assets/icons/check.svg?react"; +import IconChevron from "src/assets/icons/chevron-selector-vertical.svg?react"; +import IconPlus from "src/assets/icons/plus.svg?react"; import { useEnvContext } from "src/contexts/Env"; +import { useIssuerContext } from "src/contexts/Issuer"; +import { ROUTES } from "src/routes"; +import { isAsyncTaskDataAvailable } from "src/utils/async"; +import { ISSUER_ADD } from "src/utils/constants"; export function UserDisplay() { const { issuer } = useEnvContext(); + const { handleChange, issuerDisplayName, issuerIdentifier, issuersList } = useIssuerContext(); + const navigate = useNavigate(); + + const issuerItems = isAsyncTaskDataAvailable(issuersList) + ? issuersList.data + .toSorted((item) => (item.identifier === issuerIdentifier ? -1 : 0)) + .map(({ displayName, identifier }, index) => { + const currentIssuer = identifier === issuerIdentifier; + return { + key: `${index}`, + label: ( + + + + {displayName} + + + {currentIssuer && } + + ), + onClick: () => handleChange(identifier), + }; + }) + : []; + + const items = [ + { + key: "test", + label: ( + + + {ISSUER_ADD} + + ), + onClick: () => navigate(ROUTES.createIssuer.path), + }, + ...issuerItems, + ]; return ( - - + + + + + + + + {issuerDisplayName} + + {formatIdentifier(issuerIdentifier)} + - - - {issuer.name} - - - + + + + + + + + ); } diff --git a/ui/src/contexts/Issuer.tsx b/ui/src/contexts/Issuer.tsx index 631040a5..1912bfe1 100644 --- a/ui/src/contexts/Issuer.tsx +++ b/ui/src/contexts/Issuer.tsx @@ -23,6 +23,7 @@ import { IDENTIFIER_LOCAL_STORAGE_KEY } from "src/utils/constants"; type IssuerState = { fetchIssuers: (signal: AbortSignal) => void; handleChange: (identifier: IssuerIdentifier) => void; + issuerDisplayName: string; issuerIdentifier: IssuerIdentifier; issuersList: AsyncTask; }; @@ -30,6 +31,7 @@ type IssuerState = { const defaultIssuerState: IssuerState = { fetchIssuers: () => void {}, handleChange: () => void {}, + issuerDisplayName: "", issuerIdentifier: "", issuersList: { status: "pending" }, }; @@ -43,6 +45,10 @@ export function IssuerProvider(props: PropsWithChildren) { status: "pending", }); const [issuerIdentifier, setIssuerIdentifier] = useState(""); + const issuer = + issuersList.status === "successful" && + issuersList.data.find(({ identifier }) => identifier === issuerIdentifier); + const issuerDisplayName = issuer ? issuer.displayName : ""; const fetchIssuers = useCallback( async (signal: AbortSignal) => { @@ -59,7 +65,6 @@ export function IssuerProvider(props: PropsWithChildren) { if (response.success) { const issuers = response.data.successful; - const [firstIssuer] = issuers; const savedIdentifier = getStorageByKey({ defaultValue: "", key: IDENTIFIER_LOCAL_STORAGE_KEY, @@ -67,15 +72,10 @@ export function IssuerProvider(props: PropsWithChildren) { }); setIssuersList({ data: issuers, status: "successful" }); - - if (issuers.length === 1 && firstIssuer) { - setIssuerIdentifier(firstIssuer.identifier); - } else if ( - issuers.length > 1 && - savedIdentifier && - issuers.some(({ identifier }) => identifier === savedIdentifier) - ) { + if (issuers.some(({ identifier }) => identifier === savedIdentifier)) { setIssuerIdentifier(savedIdentifier); + } else if (issuers.length > 0 && issuers[0]) { + setIssuerIdentifier(issuers[0].identifier); } } else { if (!isAbortedError(response.error)) { @@ -104,8 +104,14 @@ export function IssuerProvider(props: PropsWithChildren) { }, [fetchIssuers]); const value = useMemo(() => { - return { fetchIssuers, handleChange, issuerIdentifier, issuersList }; - }, [issuerIdentifier, issuersList, handleChange, fetchIssuers]); + return { + fetchIssuers, + handleChange, + issuerDisplayName, + issuerIdentifier, + issuersList, + }; + }, [issuerIdentifier, issuerDisplayName, issuersList, handleChange, fetchIssuers]); return ( <> diff --git a/ui/src/domain/credential.ts b/ui/src/domain/credential.ts index 063d46c3..eb34ebdb 100644 --- a/ui/src/domain/credential.ts +++ b/ui/src/domain/credential.ts @@ -1,4 +1,4 @@ -import { AuthBJJCredentialStatus } from "src/domain/issuer"; +import { CredentialStatusType } from "src/domain/issuer"; export type CredentialsTabIDs = "issued" | "links"; @@ -15,9 +15,6 @@ export type RefreshService = { }; export type Proof = { - coreClaim: string; - issuerData: Record; - signature: string; type: CredentialProofType; }; @@ -29,7 +26,7 @@ export type CredentialSchema = { export type CredentialStatus = { id: string; revocationNonce: number; - type: AuthBJJCredentialStatus; + type: CredentialStatusType; }; export type CredentialDetail = { @@ -43,7 +40,7 @@ export type CredentialDetail = { id: string; issuanceDate: string; issuer: string; - proof: Proof[]; + proofTypes: CredentialProofType[]; refreshService: RefreshService | null; }; @@ -92,6 +89,7 @@ export type Link = { createdAt: Date; credentialExpiration: Date | null; credentialSubject: Record; + deepLink: string; expiration: Date | null; id: string; issuedClaims: number; @@ -101,4 +99,5 @@ export type Link = { schemaType: string; schemaUrl: string; status: LinkStatus; + universalLink: string; }; diff --git a/ui/src/domain/index.ts b/ui/src/domain/index.ts index 9565fb8c..717c9bfe 100644 --- a/ui/src/domain/index.ts +++ b/ui/src/domain/index.ts @@ -61,6 +61,6 @@ export type { export type { Schema as ApiSchema } from "src/domain/schema"; -export type { IssuerIdentifier, Issuer } from "src/domain/issuer"; +export type { IssuerIdentifier, Issuer, IssuerInfo, SupportedNetwork } from "src/domain/issuer"; -export { IssuerType, AuthBJJCredentialStatus } from "src/domain/issuer"; +export { IssuerType, CredentialStatusType, Method } from "src/domain/issuer"; diff --git a/ui/src/domain/issuer.ts b/ui/src/domain/issuer.ts index 78d75d9a..81432d81 100644 --- a/ui/src/domain/issuer.ts +++ b/ui/src/domain/issuer.ts @@ -5,16 +5,34 @@ export enum IssuerType { ETH = "ETH", } -export enum AuthBJJCredentialStatus { +export enum CredentialStatusType { "Iden3OnchainSparseMerkleTreeProof2023" = "Iden3OnchainSparseMerkleTreeProof2023", "Iden3ReverseSparseMerkleTreeProof" = "Iden3ReverseSparseMerkleTreeProof", "Iden3commRevocationStatusV1.0" = "Iden3commRevocationStatusV1.0", } +export enum Method { + iden3 = "iden3", + polygonid = "polygonid", +} + +export type SupportedNetwork = { + blockchain: string; + networks: [string, ...string[]]; +}; + export type Issuer = { - authBJJCredentialStatus: AuthBJJCredentialStatus; blockchain: string; + credentialStatusType: CredentialStatusType; + displayName: string; identifier: string; - method: string; + method: Method; network: string; }; + +export type IssuerInfo = { + credentialStatusType: CredentialStatusType; + displayName: string; + identifier: IssuerIdentifier; + keyType: IssuerType; +}; diff --git a/ui/src/routes.ts b/ui/src/routes.ts index 6d41ad10..d0f44eb8 100644 --- a/ui/src/routes.ts +++ b/ui/src/routes.ts @@ -3,7 +3,6 @@ export type RouteID = | "connections" | "credentialDetails" | "credentialIssuedQR" - | "credentialLinkQR" | "credentials" | "importSchema" | "issueCredential" @@ -13,7 +12,9 @@ export type RouteID = | "schemaDetails" | "schemas" | "issuers" - | "createIssuer"; + | "createIssuer" + | "issuerDetails" + | "onboarding"; export type Layout = "fullWidth" | "fullWidthGrey" | "sider"; @@ -46,10 +47,6 @@ export const ROUTES: Routes = { layout: "fullWidthGrey", path: "/credentials/scan-issued/:credentialID", }, - credentialLinkQR: { - layout: "fullWidthGrey", - path: "/credentials/scan-link/:linkID", - }, credentials: { layout: "sider", path: "/credentials/:tabID", @@ -62,6 +59,10 @@ export const ROUTES: Routes = { layout: "sider", path: "/credentials/issue", }, + issuerDetails: { + layout: "sider", + path: "/issuers/details/:issuerID", + }, issuers: { layout: "sider", path: "/issuers", @@ -78,6 +79,10 @@ export const ROUTES: Routes = { layout: "fullWidth", path: "/*", }, + onboarding: { + layout: "fullWidth", + path: "/onboarding", + }, schemaDetails: { layout: "sider", path: "/schemas/:schemaID", diff --git a/ui/src/styles/index.scss b/ui/src/styles/index.scss index f3b2f0b4..ae23f3ac 100644 --- a/ui/src/styles/index.scss +++ b/ui/src/styles/index.scss @@ -10,6 +10,15 @@ url("/fonts/Matter-Regular.woff") format("woff"); } +@font-face { + font-family: RobotoMono-Regular; + font-style: normal; + font-weight: normal; + src: + local("RobotoMono-Regular"), + url("/fonts/RobotoMono-Regular.ttf") format("truetype"); +} + /* OVERRIDES */ .ant-avatar { @@ -23,6 +32,10 @@ justify-content: center; gap: 8px; + &-link { + color: $primary-color; + } + .ant-btn-loading-icon { height: 20px; } @@ -199,6 +212,16 @@ margin-left: 4px; } } + + &:has(+ span) { + margin-bottom: 6px; + } + } + + .ant-select-disabled { + .ant-select-arrow { + display: none; + } } } @@ -246,7 +269,7 @@ > .ant-menu-item, .ant-menu-submenu-title { - padding-left: 16px !important; + padding: 8px 12px !important; } } @@ -289,9 +312,23 @@ background: transparent; } -.ant-typography-copy svg { - vertical-align: text-bottom; - width: 16px; +.ant-typography { + &:has(.ant-typography-copy) { + display: inline-flex; + justify-content: flex-end; + } + + .ant-typography-copy { + margin-inline-start: 8px; + } +} + +.ant-typography-copy { + svg { + color: $text-color-secondary; + vertical-align: text-bottom; + width: 20px; + } } .ant-result-subtitle { @@ -302,24 +339,6 @@ padding-inline-end: 24px; } -.ant-btn-primary { - border: 1px solid #54db06; - box-shadow: none; - - &:not(:disabled .ant-btn-disable) { - background: $primary-color-light; - - &:hover { - color: $text-color; - } - - &:active { - color: $text-color; - background: $primary-color-light; - } - } -} - .ant-typography.ant-typography-secondary { color: $text-color-secondary; } @@ -432,3 +451,76 @@ svg { } } } + +.dotted-divider { + width: 2px; + height: inherit; + background-image: radial-gradient($border-color 1px, transparent 0); + background-size: 24px 24px; + background-position: center; +} + +.onboarding { + &-check-icon { + background-color: $success-light-color; + + path { + stroke: $success-color; + } + } + + &-divider { + border-color: $icon-color; + } +} + +.issuers-dropdown { + .ant-dropdown-menu { + border-radius: 8px; + border: 1px solid $border-color; + padding: 0; + max-height: 336px; + overflow: scroll; + transform: translateX(-116px); + + @media screen and (width <= 576px) { + transform: translateX(0); + } + + .ant-dropdown-menu-item { + border-radius: 0; + font-size: 12px; + padding: 16px; + + &:first-child { + padding: 10px 16px; + } + + &:not(:last-child) { + border-bottom: 1px solid $border-color; + background-color: $bg-light; + } + + &:has(.active) { + &::before { + content: ""; + position: absolute; + left: 0; + top: 0; + width: 4px; + height: 100%; + background-color: $primary-color; + } + + svg { + width: 20px; + height: 20px; + + path { + stroke: $primary-color; + } + } + } + } + } +} diff --git a/ui/src/styles/theme.ts b/ui/src/styles/theme.ts index a2008c41..362fc58a 100644 --- a/ui/src/styles/theme.ts +++ b/ui/src/styles/theme.ts @@ -15,8 +15,11 @@ type StyleVariables = { iconColor: string; primaryBg: string; primaryColor: string; + primaryColorDark: string; + primaryColorLight: string; successBg: string; successColor: string; + successLightColor: string; tagBg: string; tagBgSuccess: string; tagColor: string; @@ -37,8 +40,11 @@ const parsedStyleVariables = getStrictParser()( iconColor: z.string(), primaryBg: z.string(), primaryColor: z.string(), + primaryColorDark: z.string(), + primaryColorLight: z.string(), successBg: z.string(), successColor: z.string(), + successLightColor: z.string(), tagBg: z.string(), tagBgSuccess: z.string(), tagColor: z.string(), @@ -54,6 +60,8 @@ const { borderColor, errorColor, primaryColor, + primaryColorDark, + primaryColorLight, successBg, successColor, tagBg, @@ -67,14 +75,15 @@ export const theme: ThemeConfig = { components: { Avatar: { colorBgBase: avatarBg }, Button: { - colorBgContainerDisabled: successBg, - colorPrimaryBg: primaryColor, - colorPrimaryHover: "#74F526", + colorPrimary: primaryColorLight, + colorPrimaryBorder: primaryColor, + colorPrimaryText: textColorSecondary, controlHeight: 40, - defaultHoverBorderColor: primaryColor, - defaultHoverColor: primaryColor, + defaultBorderColor: primaryColorDark, + defaultColor: primaryColorDark, paddingContentHorizontal: 16, primaryColor: textColor, + primaryShadow: "none", }, Card: { colorBgBase: primaryColor, diff --git a/ui/src/styles/variables.module.scss b/ui/src/styles/variables.module.scss index 01b07f5b..6bba50f9 100644 --- a/ui/src/styles/variables.module.scss +++ b/ui/src/styles/variables.module.scss @@ -11,6 +11,7 @@ $primary-color: #2f8507; $success-bg: #f1ffe5; $selected-bg: #f1ffe5; $success-color: #2f8507; +$success-light-color: #d1fadf; $tag-bg: #f2f4f7; $tag-bg-success: #ecfdf3; $tag-color: #344054; @@ -18,6 +19,7 @@ $text-color: #131313; $text-color-secondary: #667085; $text-color-warning: #b54708; $primary-color-light: #93f558; +$primary-color-dark: #24580f; :export { avatarBg: $avatar-bg; @@ -34,10 +36,12 @@ $primary-color-light: #93f558; successBg: $success-bg; selectedBg: $selected-bg; successColor: $success-color; + successLightColor: $success-light-color; tagBg: $tag-bg; tagBgSuccess: $tag-bg-success; tagColor: $tag-color; textColor: $text-color; textColorSecondary: $text-color-secondary; textColorWarning: $text-color-warning; + primaryColorDark: $primary-color-dark; } diff --git a/ui/src/utils/browser.ts b/ui/src/utils/browser.ts index 06aca2b8..661ed6e4 100644 --- a/ui/src/utils/browser.ts +++ b/ui/src/utils/browser.ts @@ -56,3 +56,15 @@ export function setStorageByKey({ key, value }: { key: string; value: T }) { return value; } + +export function downloadQRCanvas(canvas: HTMLCanvasElement, fileName: string) { + const imageURL = canvas.toDataURL("image/png"); + + const downloadLink = document.createElement("a"); + downloadLink.href = imageURL; + downloadLink.download = `${fileName}.png`; + + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +} diff --git a/ui/src/utils/constants.ts b/ui/src/utils/constants.ts index fab99698..3f6f16f7 100644 --- a/ui/src/utils/constants.ts +++ b/ui/src/utils/constants.ts @@ -19,9 +19,10 @@ export const ISSUE_DATE = "Issue date"; export const ISSUED = "Issued"; export const ISSUED_CREDENTIALS = "Issued credentials"; export const ISSUER_STATE = "Issuer state"; -export const ISSUER_ADD = "Add new issuer"; -export const ISSUER_DETAILS = "Issuer details"; -export const ISSUERS = "Issuers"; +export const ISSUER_ADD_NEW = "Add new identity"; +export const ISSUER_ADD = "Add identity"; +export const ISSUER_DETAILS = "Identity details"; +export const ISSUERS = "Identities"; export const LINKS = "Links"; export const REVOCATION = "Revocation"; export const REVOKE = "Revoke"; @@ -32,6 +33,7 @@ export const SCHEMAS = "Schemas"; export const STATUS = "Status"; export const VALUE_REQUIRED = "Value required"; export const NOT_PUBLISHED_STATE = "State not published"; +export const FINALISE_SETUP = "Finalise setup"; // URL params export const DID_SEARCH_PARAM = "did"; @@ -47,7 +49,7 @@ export const DEFAULT_PAGINATION_PAGE = 1; export const DEFAULT_PAGINATION_MAX_RESULTS = 10; export const DEFAULT_PAGINATION_TOTAL = 0; -export const API_VERSION = "v1"; +export const API_VERSION = "v2"; type CredentialsTab = { id: CredentialsTabIDs; tabID: string; title: string }; diff --git a/ui/src/utils/forms.ts b/ui/src/utils/forms.ts index 2901b8c2..6a48cbd1 100644 --- a/ui/src/utils/forms.ts +++ b/ui/src/utils/forms.ts @@ -1,4 +1,5 @@ import dayjs from "dayjs"; +import { IssuerIdentifier } from "src/domain"; export function formatDate( date: dayjs.Dayjs | Date, @@ -9,3 +10,10 @@ export function formatDate( return dayjs(date).format(template); } + +export function formatIdentifier(identifier: IssuerIdentifier): string { + const parts = identifier.split(":"); + const id = parts.at(-1); + const shortId = `${id?.slice(0, 5)}...${id?.slice(-4)}`; + return parts.toSpliced(-1, 1, shortId).join(":"); +}