Skip to content

Commit

Permalink
Merge pull request #579 from Concordium/ui-update/web3-import-export
Browse files Browse the repository at this point in the history
UI update/web3 import export
  • Loading branch information
soerenbf authored Dec 10, 2024
2 parents 37937b8 + eed75c4 commit 3373c0f
Show file tree
Hide file tree
Showing 16 changed files with 514 additions and 41 deletions.
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v18
3 changes: 2 additions & 1 deletion packages/browser-wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
3 changes: 3 additions & 0 deletions packages/browser-wallet/src/assets/svgX/export.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions packages/browser-wallet/src/assets/svgX/import.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 2 additions & 23 deletions packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3Id.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
text-align: left;
}

&.credentials {
&.credentials,
&.import {
.page__main {
display: flex;
flex-direction: column;
Expand All @@ -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);
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,40 @@ 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';
import { relativeRoutes, web3IdDetailsRoute } from '@popup/popupX/constants/routes';
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) => {
const [contract, id] = parseCredentialDID(vc.id);
nav(web3IdDetailsRoute(contract, id));
};

const exportCredentials = useVerifiableCredentialExport();

return (
<Page className="web3-id-x credentials">
<Page.Top heading={t('title')}>
<Button.Icon icon={<Plus />} onClick={() => nav(relativeRoutes.settings.web3Id.import.path)} />
<Button.Icon icon={<Import />} onClick={() => nav(relativeRoutes.settings.web3Id.import.path)} />
<Button.Icon icon={<Export />} onClick={exportCredentials} />
</Page.Top>
<Page.Main>
{verifiableCredentials.value.map((vc) => (
{verifiableCredentials.length === 0 && !loading && <Text.Capture>{t('noCredentials')}</Text.Capture>}
{verifiableCredentials.map((vc) => (
<Button.Base key={vc.id} className="web3-id-x__card" onClick={() => toDetails(vc)}>
<Web3IdCard credential={vc} />
</Button.Base>
Expand Down
142 changes: 132 additions & 10 deletions packages/browser-wallet/src/popup/popupX/pages/Web3Id/Web3IdImport.tsx
Original file line number Diff line number Diff line change
@@ -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<VerifiableCredentialExport> {
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<T>(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<T>(
stored: Record<string, T>,
toAdd: Record<string, T>,
update: (updated: Record<string, T>) => Promise<void>
) {
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<FileInputValue>(null);
const [storedVerifiableCredentials, setVerifiableCredentials] = useAtom(storedVerifiableCredentialsAtom);
const [imported, setImported] = useState<VerifiableCredential[]>();
const [storedSchemas, setSchemas] = useAtom(storedVerifiableCredentialSchemasAtom);
const [storedMetadata, setMetadata] = useAtom(storedVerifiableCredentialMetadataAtom);
const wallet = useHdWallet();
const [error, setError] = useState<string>();
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 (
<Page className="web3-id-x import">
<Page.Top heading={t('success')} />
<Page.Main>
{imported.length === 0 && <Text.Capture>{t('noCreds')}</Text.Capture>}
{imported.map((cred) => (
<Web3IdCard credential={cred} />
))}
</Page.Main>
<Page.Footer>
<Button.Main
className="m-t-10"
label={t('buttonDone')}
onClick={() => nav(absoluteRoutes.settings.web3Id.path)}
/>
</Page.Footer>
</Page>
);
}

return (
<Page className="web3-id-x import">
<Page.Top heading={t('importWeb3Id')} />
<Page.Main>
<Card>
<File />
<Text.Capture>{t('dragAndDropFile')}</Text.Capture>
</Card>
<Button.IconText icon={<FolderOpen />} label={t('selectFile')} />
<FileInput
className="flex-child-fill"
value={files}
onChange={handleImport}
valid={error === undefined}
error={error}
placeholder={t('dragAndDropFile')}
/>
</Page.Main>
</Page>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
70 changes: 70 additions & 0 deletions packages/browser-wallet/src/popup/popupX/pages/Web3Id/utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, VerifiableCredentialSchema>;
metadata: Record<string, VerifiableCredentialMetadata>;
};

export type ExportFormat = {
type: 'concordium-browser-wallet-verifiable-credentials';
v: number;
environment: string; // 'testnet' or 'mainnet'
value: VerifiableCredentialExport;
};

function createExport(
verifiableCredentials: VerifiableCredential[],
schemas: Record<string, VerifiableCredentialSchema>,
metadata: Record<string, VerifiableCredentialMetadata>,
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;
}
Loading

0 comments on commit 3373c0f

Please sign in to comment.