diff --git a/.node-version b/.node-version new file mode 100644 index 00000000..3f430af8 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v18 diff --git a/packages/browser-wallet/package.json b/packages/browser-wallet/package.json index d60a8c44..10279822 100644 --- a/packages/browser-wallet/package.json +++ b/packages/browser-wallet/package.json @@ -6,7 +6,7 @@ "author": "Concordium Software", "license": "Apache-2.0", "scripts": { - "build-base": "node --loader ts-node/esm -r tsconfig-paths/register ./build/build.ts", + "build-base": "tsx ./build/build.ts", "build:dev": "cross-env NODE_ENV=development yarn build-base", "build:prod": "cross-env NODE_ENV=production yarn build-base", "build": "yarn build:prod", @@ -95,6 +95,7 @@ "style-loader": "^3.3.1", "ts-jest": "^29.0.5", "tsconfig-paths-webpack-plugin": "^3.5.2", + "tsx": "^4.19.2", "typescript": "^5.2.2" } } diff --git a/packages/browser-wallet/src/assets/svgX/export.svg b/packages/browser-wallet/src/assets/svgX/export.svg new file mode 100644 index 00000000..edb696fe --- /dev/null +++ b/packages/browser-wallet/src/assets/svgX/export.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser-wallet/src/assets/svgX/import.svg b/packages/browser-wallet/src/assets/svgX/import.svg new file mode 100644 index 00000000..6c19aa49 --- /dev/null +++ b/packages/browser-wallet/src/assets/svgX/import.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3Id.scss b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3Id.scss index 65378555..b6662429 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3Id.scss +++ b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3Id.scss @@ -6,7 +6,8 @@ text-align: left; } - &.credentials { + &.credentials, + &.import { .page__main { display: flex; flex-direction: column; @@ -19,28 +20,6 @@ .page__main { height: 100%; - - .card-x.grey { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - border: 1px dashed $color-mineral-1; - background-color: rgba($color-grey-1, 0.5); - - .capture__main_small { - text-align: center; - } - - svg { - margin-bottom: rem(32px); - } - } - - .button__icon { - margin-top: rem(24px); - } } } } diff --git a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdCredentials.tsx b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdCredentials.tsx index cfbdb374..3ebdae6a 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdCredentials.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdCredentials.tsx @@ -3,7 +3,8 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useAtomValue } from 'jotai'; -import Plus from '@assets/svgX/plus.svg'; +import Export from '@assets/svgX/export.svg'; +import Import from '@assets/svgX/import.svg'; import Page from '@popup/popupX/shared/Page'; import Button from '@popup/popupX/shared/Button'; import Web3IdCard from '@popup/popupX/shared/Web3IdCard'; @@ -11,10 +12,13 @@ import { relativeRoutes, web3IdDetailsRoute } from '@popup/popupX/constants/rout import { storedVerifiableCredentialsAtom } from '@popup/store/verifiable-credential'; import { VerifiableCredential } from '@shared/storage/types'; import { parseCredentialDID } from '@shared/utils/verifiable-credential-helpers'; +import Text from '@popup/popupX/shared/Text'; + +import { useVerifiableCredentialExport } from './utils'; export default function Web3IdCredentials() { const { t } = useTranslation('x', { keyPrefix: 'web3Id.credentials' }); - const verifiableCredentials = useAtomValue(storedVerifiableCredentialsAtom); + const { value: verifiableCredentials, loading } = useAtomValue(storedVerifiableCredentialsAtom); const nav = useNavigate(); const toDetails = (vc: VerifiableCredential) => { @@ -22,13 +26,17 @@ export default function Web3IdCredentials() { nav(web3IdDetailsRoute(contract, id)); }; + const exportCredentials = useVerifiableCredentialExport(); + return ( - } onClick={() => nav(relativeRoutes.settings.web3Id.import.path)} /> + } onClick={() => nav(relativeRoutes.settings.web3Id.import.path)} /> + } onClick={exportCredentials} /> - {verifiableCredentials.value.map((vc) => ( + {verifiableCredentials.length === 0 && !loading && {t('noCredentials')}} + {verifiableCredentials.map((vc) => ( toDetails(vc)}> diff --git a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdImport.tsx b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdImport.tsx index 8e7adb79..9d4cc541 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdImport.tsx +++ b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdImport.tsx @@ -1,24 +1,146 @@ -import React from 'react'; -import File from '@assets/svgX/file.svg'; -import FolderOpen from '@assets/svgX/folder-open.svg'; +import React, { useState } from 'react'; import Page from '@popup/popupX/shared/Page'; -import Button from '@popup/popupX/shared/Button'; import { useTranslation } from 'react-i18next'; +import { FileInput } from '@popup/popupX/shared/Form/FileInput'; +import { FileInputValue } from '@popup/popupX/shared/Form/FileInput/FileInput'; +import { useAtom } from 'jotai'; +import { + storedVerifiableCredentialMetadataAtom, + storedVerifiableCredentialSchemasAtom, + storedVerifiableCredentialsAtom, +} from '@popup/store/verifiable-credential'; +import { EncryptedData, VerifiableCredential } from '@shared/storage/types'; +import { useHdWallet } from '@popup/shared/utils/account-helpers'; +import JSONBigInt from 'json-bigint'; +import { decrypt } from '@shared/utils/crypto'; +import Web3IdCard from '@popup/popupX/shared/Web3IdCard'; import Text from '@popup/popupX/shared/Text'; -import Card from '@popup/popupX/shared/Card'; +import Button from '@popup/popupX/shared/Button'; +import { useNavigate } from 'react-router-dom'; +import { absoluteRoutes } from '@popup/popupX/constants/routes'; +import { ExportFormat, VerifiableCredentialExport } from './utils'; + +async function parseExport(data: EncryptedData, encryptionKey: string): Promise { + const decrypted = await decrypt(data, encryptionKey); + const backup: ExportFormat = JSONBigInt({ + alwaysParseAsBig: true, + useNativeBigInt: true, + }).parse(decrypted); + // Change index to number, due to parse changing all numbers to bigints. + backup.value.verifiableCredentials = backup.value.verifiableCredentials.map((v) => ({ + ...v, + index: Number(v.index), + })); + // TODO validation + return backup.value; +} + +/** + * Adds items from toAdd that does not exist in stored, using the given update. Returns the items from toAdd that was actually added. + */ +function updateList(stored: T[], toAdd: T[], isEqual: (a: T, b: T) => boolean, update: (updated: T[]) => void): T[] { + const filtered = toAdd.filter((item) => stored.every((existing) => !isEqual(item, existing))); + update([...stored, ...filtered]); + return filtered; +} + +/** + * Adds items from toAdd that does not exist in stored, using the given update. + */ +function updateRecord( + stored: Record, + toAdd: Record, + update: (updated: Record) => Promise +) { + const updated = { ...stored }; + Object.entries(toAdd).forEach(([key, value]) => { + if (!stored[key]) { + updated[key] = value; + } + }); + + return update(updated); +} export default function Web3IdImport() { const { t } = useTranslation('x', { keyPrefix: 'web3Id.import' }); + const [files, setFiles] = useState(null); + const [storedVerifiableCredentials, setVerifiableCredentials] = useAtom(storedVerifiableCredentialsAtom); + const [imported, setImported] = useState(); + const [storedSchemas, setSchemas] = useAtom(storedVerifiableCredentialSchemasAtom); + const [storedMetadata, setMetadata] = useAtom(storedVerifiableCredentialMetadataAtom); + const wallet = useHdWallet(); + const [error, setError] = useState(); + const nav = useNavigate(); + + if (storedSchemas.loading || storedMetadata.loading || storedVerifiableCredentials.loading || !wallet) { + return null; + } + + const handleImport = async (imports: FileInputValue) => { + setFiles(imports); + + if (imports === null) { + return; + } + + try { + // eslint-disable-next-line no-plusplus + for (let i = 0; i < imports.length; i++) { + const file = imports[i]; + if (file) { + const encryptedBackup: EncryptedData = JSON.parse(await file.text()); + const key = wallet.getVerifiableCredentialBackupEncryptionKey().toString('hex'); + const { verifiableCredentials, schemas, metadata } = await parseExport(encryptedBackup, key); + const filteredCredentials = updateList( + storedVerifiableCredentials.value, + verifiableCredentials, + (a, b) => a.id === b.id, + setVerifiableCredentials + ); + await updateRecord(storedSchemas.value, schemas, setSchemas); + await updateRecord(storedMetadata.value, metadata, setMetadata); + setImported(filteredCredentials); + } + } + } catch (e) { + setError(t('error')); + } + }; + + if (imported !== undefined) { + return ( + + + + {imported.length === 0 && {t('noCreds')}} + {imported.map((cred) => ( + + ))} + + + nav(absoluteRoutes.settings.web3Id.path)} + /> + + + ); + } return ( - - - {t('dragAndDropFile')} - - } label={t('selectFile')} /> + ); diff --git a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/i18n/en.ts index d4a64077..7c29f466 100644 --- a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/i18n/en.ts +++ b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/i18n/en.ts @@ -5,8 +5,11 @@ const t = { }, import: { importWeb3Id: 'Import Web3 ID Credentials', - selectFile: 'or Select file to import', - dragAndDropFile: 'Drag and drop here\nyour Credentials file here', + success: 'Import successful', + noCreds: 'However, no new credentials were found.', + buttonDone: 'Done', + dragAndDropFile: 'Drag and drop\nyour Credentials file here', + error: 'Unable to import the chosen file. The file must be a backup created with the same seed phrase.', }, details: { title: 'Credential details', diff --git a/packages/browser-wallet/src/popup/popupX/pages/Web3Id/utils.ts b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/utils.ts new file mode 100644 index 00000000..56cd74ed --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/pages/Web3Id/utils.ts @@ -0,0 +1,70 @@ +import { useHdWallet } from '@popup/shared/utils/account-helpers'; +import { NetworkConfiguration, VerifiableCredential, VerifiableCredentialSchema } from '@shared/storage/types'; +import { VerifiableCredentialMetadata } from '@shared/utils/verifiable-credential-helpers'; +import { encrypt } from '@shared/utils/crypto'; +import { getNet } from '@shared/utils/network-helpers'; +import { useAtomValue } from 'jotai'; +import { + storedVerifiableCredentialSchemasAtom, + storedVerifiableCredentialsAtom, + storedVerifiableCredentialMetadataAtom, +} from '@popup/store/verifiable-credential'; +import { networkConfigurationAtom } from '@popup/store/settings'; +import { saveData } from '@popup/shared/utils/file-helpers'; +import { stringify } from 'json-bigint'; + +export type VerifiableCredentialExport = { + verifiableCredentials: VerifiableCredential[]; + schemas: Record; + metadata: Record; +}; + +export type ExportFormat = { + type: 'concordium-browser-wallet-verifiable-credentials'; + v: number; + environment: string; // 'testnet' or 'mainnet' + value: VerifiableCredentialExport; +}; + +function createExport( + verifiableCredentials: VerifiableCredential[], + schemas: Record, + metadata: Record, + network: NetworkConfiguration, + encryptionKey: string +) { + const exportContent: ExportFormat = { + type: 'concordium-browser-wallet-verifiable-credentials', + v: 0, + environment: getNet(network).toLowerCase(), + value: { + verifiableCredentials, + schemas, + metadata, + }, + }; + + // Use json-bigint to serialize bigints as json numbers. + // Ensure that Dates are stored as timestamps to not lose typing (otherwise they are serialized as strings). + return encrypt(stringify(exportContent), encryptionKey); +} + +export function useVerifiableCredentialExport() { + const verifiableCredentials = useAtomValue(storedVerifiableCredentialsAtom); + const schemas = useAtomValue(storedVerifiableCredentialSchemasAtom); + const metadata = useAtomValue(storedVerifiableCredentialMetadataAtom); + const network = useAtomValue(networkConfigurationAtom); + const wallet = useHdWallet(); + + if (schemas.loading || verifiableCredentials.loading || metadata.loading || !wallet) { + return undefined; + } + + const handleExport = async () => { + const key = wallet.getVerifiableCredentialBackupEncryptionKey().toString('hex'); + const data = await createExport(verifiableCredentials.value, schemas.value, metadata.value, network, key); + saveData(data, `web3IdCredentials.export`); + }; + + return handleExport; +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.scss b/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.scss new file mode 100644 index 00000000..8cc0370d --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.scss @@ -0,0 +1,80 @@ +.form-file-input-x { + position: relative; + z-index: 0; + font-size: rem(12px); + text-align: center; + + &, + &__wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border-radius: 10px; + } + + &__label { + margin-bottom: 5px; + } + + &--disabled { + cursor: default; + + .form-file-input-x__wrapper { + cursor: default; + } + } + + &--hovering { + .form-file-input-x__wrapper { + border-color: $color-mineral-3; + } + } + + &--invalid { + .form-file-input-x__wrapper { + border-color: $color-red-attention; + } + } + + &__wrapper { + flex: 1; + width: 100%; + padding: 20px 30px; + position: relative; + border: 1px dashed rgba($color: $color-mineral-1, $alpha: 0.5); + cursor: pointer; + } + + &__filename { + word-break: break-all; + text-align: center; + } + + &__empty { + color: $color-mineral-2; + } + + &__input { + z-index: -1; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + opacity: 0; + width: 100%; + } + + &__button { + cursor: pointer; + display: flex; + align-items: center; + font-size: 15px; + margin-top: rem(24px); + + svg { + margin-right: rem(5px); + } + } +} diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.stories.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.stories.tsx new file mode 100644 index 00000000..e3109199 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.stories.tsx @@ -0,0 +1,31 @@ +/* eslint-disable react/function-component-definition */ +import React, { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { FileInput, FileInputValue } from './FileInput'; + +const render: Story['render'] = (args) => { + const [value, setValue] = useState(null); + + return ; +}; + +export default { + title: 'X/Shared/Form/FileInput', + component: FileInput, + render, + beforeEach: () => { + const body = document.getElementsByTagName('body').item(0); + body?.classList.add('popup-x'); + + return () => { + body?.classList.remove('popup-x'); + }; + }, +} as Meta; + +type Story = StoryObj; +export const Main: Story = { + args: { + placeholder: 'This is the placeholder', + }, +}; diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.tsx b/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.tsx new file mode 100644 index 00000000..a51e4e40 --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/FileInput.tsx @@ -0,0 +1,122 @@ +import clsx from 'clsx'; +import React, { + forwardRef, + InputHTMLAttributes, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; + +import FolderIcon from '@assets/svgX/folder-open.svg'; +import FileIcon from '@assets/svgX/file.svg'; +import ErrorMessage from '../ErrorMessage'; + +export interface FileInputRef { + reset(): void; +} + +export type FileInputValue = FileList | null; + +export type FileInputProps = Pick< + InputHTMLAttributes, + 'accept' | 'multiple' | 'disabled' | 'className' +> & { + value: FileInputValue; + disableFileNames?: boolean; + onChange(files: FileInputValue): void; + valid: boolean; + placeholder: string; + error?: string; +}; + +/** + * @description + * Component for handling file input. Parsing of file should be done externally. Supports drag and drop + click to browse. + * + * @example + * + */ +export const FileInput = forwardRef( + ( + { value, onChange, valid, error, placeholder, className, disableFileNames = false, ...inputProps }, + ref + ): JSX.Element => { + const { t } = useTranslation('x', { keyPrefix: 'sharedX.form.fileInput' }); + const inputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + const files = useMemo(() => new Array(value?.length ?? 0).fill(0).map((_, i) => value?.item(i)), [value]); + const { disabled } = inputProps; + + useImperativeHandle(ref, () => ({ + reset: () => { + if (inputRef.current) { + inputRef.current.value = ''; + onChange(null); + } + }, + })); + + useEffect(() => { + const preventFileOpen = (e: DragEvent) => e.preventDefault(); + + window.addEventListener('dragover', preventFileOpen); + window.addEventListener('drop', preventFileOpen); + + return () => { + window.removeEventListener('dragover', preventFileOpen); + window.removeEventListener('drop', preventFileOpen); + }; + }, []); + + return ( + + ); + } +); diff --git a/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/index.ts b/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/index.ts new file mode 100644 index 00000000..8e0b9e8f --- /dev/null +++ b/packages/browser-wallet/src/popup/popupX/shared/Form/FileInput/index.ts @@ -0,0 +1 @@ +export { default, FileInput } from './FileInput'; diff --git a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts index 8d3e1698..cd31e11f 100644 --- a/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts +++ b/packages/browser-wallet/src/popup/popupX/shared/i18n/en.ts @@ -26,6 +26,9 @@ const t = { insufficientCcd: 'Not enough CCD in account to cover transaction fee', }, }, + fileInput: { + selectButton: 'or Select file to import', + }, }, utils: { address: { diff --git a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss index 94808ca2..601fa730 100644 --- a/packages/browser-wallet/src/popup/popupX/styles/_elements.scss +++ b/packages/browser-wallet/src/popup/popupX/styles/_elements.scss @@ -57,6 +57,7 @@ @import '../shared/ExternalLink/ExternalLink'; @import '../shared/Carousel/Carousel'; @import '../shared/Form/Form'; +@import '../shared/Form/FileInput/FileInput'; @import '../shared/Form/TokenAmount/TokenAmount'; @import '../shared/Form/Slider/Slider'; @import '../shared/FullscreenNotice/FullscreenNotice'; diff --git a/yarn.lock b/yarn.lock index 1b519e15..9c95a3a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2241,6 +2241,7 @@ __metadata: style-loader: ^3.3.1 ts-jest: ^29.0.5 tsconfig-paths-webpack-plugin: ^3.5.2 + tsx: ^4.19.2 typescript: ^5.2.2 uuid: ^10.0.0 languageName: unknown @@ -10439,7 +10440,7 @@ __metadata: languageName: node linkType: hard -"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0": +"esbuild@npm:^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0, esbuild@npm:~0.23.0": version: 0.23.1 resolution: "esbuild@npm:0.23.1" dependencies: @@ -11834,6 +11835,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: latest + checksum: 11e6ea6fea15e42461fc55b4b0e4a0a3c654faa567f1877dbd353f39156f69def97a69936d1746619d656c4b93de2238bf731f6085a03a50cabf287c9d024317 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@^1.2.7#~builtin": version: 1.2.13 resolution: "fsevents@patch:fsevents@npm%3A1.2.13#~builtin::version=1.2.13&hash=18f3a7" @@ -11853,6 +11864,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@~2.3.3#~builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#~builtin::version=2.3.3&hash=18f3a7" + dependencies: + node-gyp: latest + conditions: os=darwin + languageName: node + linkType: hard + "function-bind@npm:^1.1.1": version: 1.1.1 resolution: "function-bind@npm:1.1.1" @@ -11980,6 +12000,15 @@ __metadata: languageName: node linkType: hard +"get-tsconfig@npm:^4.7.5": + version: 4.8.1 + resolution: "get-tsconfig@npm:4.8.1" + dependencies: + resolve-pkg-maps: ^1.0.0 + checksum: 12df01672e691d2ff6db8cf7fed1ddfef90ed94a5f3d822c63c147a26742026d582acd86afcd6f65db67d809625d17dd7f9d34f4d3f38f69bc2f48e19b2bdd5b + languageName: node + linkType: hard + "get-value@npm:^2.0.3, get-value@npm:^2.0.6": version: 2.0.6 resolution: "get-value@npm:2.0.6" @@ -19412,6 +19441,22 @@ __metadata: languageName: node linkType: hard +"tsx@npm:^4.19.2": + version: 4.19.2 + resolution: "tsx@npm:4.19.2" + dependencies: + esbuild: ~0.23.0 + fsevents: ~2.3.3 + get-tsconfig: ^4.7.5 + dependenciesMeta: + fsevents: + optional: true + bin: + tsx: dist/cli.mjs + checksum: 7f9f1b338a73297725a9217cedaaad862f7c81d5264093c74b98a71491ad5413b11248d604c0e650f4f7da6f365249f1426fdb58a1325ab9e15448156b1edff6 + languageName: node + linkType: hard + "tty-browserify@npm:0.0.1": version: 0.0.1 resolution: "tty-browserify@npm:0.0.1"