diff --git a/examples/web-example/e2e/credential-management.spec.ts b/examples/web-example/e2e/credential-management.spec.ts index e4c357f5..1f2a3328 100644 --- a/examples/web-example/e2e/credential-management.spec.ts +++ b/examples/web-example/e2e/credential-management.spec.ts @@ -12,10 +12,10 @@ test.describe('Credential Management', () => { // Create new wallet await page.getByTestId('create-wallet-button').click(); - await page.waitForSelector('.App-header:has-text("Truvera Wallet React Example")', { timeout: 30000 }); + await page.waitForSelector('.App-header:has-text("Truvera Demo Web Wallet")', { timeout: 30000 }); // Wait for default DID to be created automatically - await page.waitForSelector('text=Default DID:', { timeout: 30000 }); + await page.waitForSelector('.did-value', { timeout: 30000 }); }); test('should open import credential modal', async ({ page }) => { @@ -126,7 +126,7 @@ test.describe('Credential Management', () => { await page.getByTestId('fetch-messages-button').click(); // Should not show any errors - await expect(page.locator('text=Default DID:')).toBeVisible(); + await expect(page.locator('.did-value')).toBeVisible(); // The button should remain clickable await expect(page.getByTestId('fetch-messages-button')).toBeEnabled(); @@ -136,11 +136,8 @@ test.describe('Credential Management', () => { // Grant clipboard permissions await context.grantPermissions(['clipboard-read', 'clipboard-write']); - // Get the DID text - it's between "Default DID:" and the buttons - const didContainer = await page.locator('text=Default DID:').locator('..'); - const didText = await didContainer.textContent(); - // Extract just the DID (remove "Default DID:", "Copy", and "Fetch Messages") - const did = didText.replace('Default DID:', '').replace('Copy', '').replace('Fetch Messages', '').trim(); + // Get the DID text from the did-value element + const did = await page.locator('.did-value').textContent(); // Click copy button await page.getByTestId('copy-did-button').click(); diff --git a/examples/web-example/e2e/wallet-creation.spec.ts b/examples/web-example/e2e/wallet-creation.spec.ts index 8a15ffad..69f9ebe8 100644 --- a/examples/web-example/e2e/wallet-creation.spec.ts +++ b/examples/web-example/e2e/wallet-creation.spec.ts @@ -13,7 +13,7 @@ test.describe('Wallet Creation', () => { await page.getByRole('button', { name: 'Create New Wallet' }).click(); // Wait for wallet creation to complete - check for header - await page.waitForSelector('.App-header:has-text("Truvera Wallet React Example")', { timeout: 30000 }); + await page.waitForSelector('.App-header:has-text("Truvera Demo Web Wallet")', { timeout: 30000 }); // Should show the main app interface await expect(page.getByText('Credentials (')).toBeVisible(); @@ -22,10 +22,10 @@ test.describe('Wallet Creation', () => { await expect(page.getByTestId('import-credential-button')).toBeVisible(); await expect(page.getByTestId('verify-credential-button')).toBeVisible(); await expect(page.getByTestId('refresh-button')).toBeVisible(); - await expect(page.getByRole('button', { name: 'Clear Wallet' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible(); // Should automatically create and display default DID - await expect(page.getByText('Default DID:')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('.did-value')).toBeVisible({ timeout: 10000 }); await expect(page.getByTestId('copy-did-button')).toBeVisible(); await expect(page.getByTestId('fetch-messages-button')).toBeVisible(); @@ -40,13 +40,13 @@ test.describe('Wallet Creation', () => { test('should display default DID after wallet creation', async ({ page }) => { // Create new wallet first await page.getByRole('button', { name: 'Create New Wallet' }).click(); - await page.waitForSelector('.App-header:has-text("Truvera Wallet React Example")', { timeout: 30000 }); + await page.waitForSelector('.App-header:has-text("Truvera Demo Web Wallet")', { timeout: 30000 }); // Wait for DID to be displayed (it's created automatically) - await page.waitForSelector('text=Default DID:', { timeout: 30000 }); + await page.waitForSelector('.did-value', { timeout: 30000 }); // Should show the DID - await expect(page.getByText('Default DID:')).toBeVisible(); + await expect(page.locator('.did-value')).toBeVisible(); // Should show Copy button await expect(page.getByTestId('copy-did-button')).toBeVisible(); @@ -55,17 +55,17 @@ test.describe('Wallet Creation', () => { await expect(page.getByTestId('fetch-messages-button')).toBeVisible(); // The DID should start with 'did:' - const didElement = await page.locator('text=Default DID:').locator('..').textContent(); - expect(didElement).toContain('did:'); + const didText = await page.locator('.did-value').textContent(); + expect(didText).toContain('did:'); }); test('should clear wallet data but preserve keys', async ({ page }) => { // Create new wallet first await page.getByRole('button', { name: 'Create New Wallet' }).click(); - await page.waitForSelector('.App-header:has-text("Truvera Wallet React Example")', { timeout: 30000 }); + await page.waitForSelector('.App-header:has-text("Truvera Demo Web Wallet")', { timeout: 30000 }); // Wait for DID to be created - await page.waitForSelector('text=Default DID:', { timeout: 30000 }); + await page.waitForSelector('.did-value', { timeout: 30000 }); // Get the wallet keys before clearing const keysBefore = await page.evaluate(() => { @@ -73,11 +73,12 @@ test.describe('Wallet Creation', () => { return keys ? JSON.parse(keys) : null; }); - // Click Clear Wallet button - await page.getByRole('button', { name: 'Clear Wallet' }).click(); + // Open settings menu and click Clear Wallet + await page.getByRole('button', { name: 'Settings' }).click(); + await page.getByRole('menuitem', { name: 'Clear Wallet' }).click(); // Page should reload and still show the wallet interface (keys are preserved) - await page.waitForSelector('.App-header:has-text("Truvera Wallet React Example")', { timeout: 10000 }); + await page.waitForSelector('.App-header:has-text("Truvera Demo Web Wallet")', { timeout: 10000 }); // Verify keys are still present and have same values const keysAfter = await page.evaluate(() => { @@ -95,7 +96,7 @@ test.describe('Wallet Creation', () => { test('should persist wallet after page reload', async ({ page }) => { // Create new wallet await page.getByRole('button', { name: 'Create New Wallet' }).click(); - await page.waitForSelector('.App-header:has-text("Truvera Wallet React Example")', { timeout: 30000 }); + await page.waitForSelector('.App-header:has-text("Truvera Demo Web Wallet")', { timeout: 30000 }); // Reload the page await page.reload(); diff --git a/examples/web-example/public/docklogo.png b/examples/web-example/public/docklogo.png new file mode 100644 index 00000000..3c04d0e0 Binary files /dev/null and b/examples/web-example/public/docklogo.png differ diff --git a/examples/web-example/public/favicon.ico b/examples/web-example/public/favicon.ico deleted file mode 100644 index a11777cc..00000000 Binary files a/examples/web-example/public/favicon.ico and /dev/null differ diff --git a/examples/web-example/public/favicon.png b/examples/web-example/public/favicon.png new file mode 100644 index 00000000..1e501f03 Binary files /dev/null and b/examples/web-example/public/favicon.png differ diff --git a/examples/web-example/public/index.html b/examples/web-example/public/index.html index 7e9a0603..6cd82ecf 100644 --- a/examples/web-example/public/index.html +++ b/examples/web-example/public/index.html @@ -2,7 +2,7 @@ - + - React App + Truvera Demo Web Wallet diff --git a/examples/web-example/public/truveralogoblack.png b/examples/web-example/public/truveralogoblack.png new file mode 100644 index 00000000..356955a1 Binary files /dev/null and b/examples/web-example/public/truveralogoblack.png differ diff --git a/examples/web-example/public/truveralogoround.png b/examples/web-example/public/truveralogoround.png new file mode 100644 index 00000000..2616cd1d Binary files /dev/null and b/examples/web-example/public/truveralogoround.png differ diff --git a/examples/web-example/src/App.css b/examples/web-example/src/App.css index 980e7ff2..4c0692af 100644 --- a/examples/web-example/src/App.css +++ b/examples/web-example/src/App.css @@ -1,23 +1,34 @@ /* Main container */ .App { min-height: 100vh; - background-color: #282c34; + background-color: #000000; padding: 20px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } /* Header */ .App-header { - text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 14px; margin-bottom: 40px; padding: 0; } +.header-logo { + width: 56px; + height: 56px; + object-fit: contain; + flex-shrink: 0; +} + .App-header h1 { color: white; font-size: 36px; font-weight: 700; margin: 0; + line-height: 1.1; text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } @@ -85,6 +96,31 @@ margin-bottom: 40px; } +.icon-action-btn { + width: 44px; + height: 44px; + border: none; + border-radius: 8px; + background: #e2e8f0; + color: #4a5568; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.icon-action-btn:hover { + background: #cbd5e0; + transform: translateY(-1px); +} + +.action-icon { + width: 20px; + height: 20px; + fill: currentColor; +} + /* DID section */ .did-section { background: rgba(255, 255, 255, 0.95); @@ -110,6 +146,55 @@ gap: 12px; } +.did-wallet-switcher { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.did-wallet-switcher-label { + font-size: 13px; + font-weight: 600; + color: #4a5568; +} + +.did-wallet-selector { + min-width: 170px; + max-width: 240px; + border: 1px solid #cbd5e0; + border-radius: 8px; + background: #f7fafc; + color: #2d3748; + font-size: 14px; + font-weight: 600; + padding: 7px 28px 7px 10px; + cursor: pointer; +} + +.did-wallet-selector:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.did-controls { + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} + +.did-control-group { + display: flex; + align-items: center; + gap: 10px; +} + +.did-messages-group { + margin-left: 2px; + padding-left: 12px; + border-left: 1px solid #cbd5e0; +} + .did-value { font-family: 'Monaco', 'Consolas', monospace; background: #f7fafc; @@ -119,6 +204,41 @@ word-break: break-all; } +.did-icon-btn { + width: 34px; + height: 34px; + border: none; + border-radius: 8px; + background: #e2e8f0; + color: #4a5568; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; +} + +.did-icon-btn:hover { + background: #cbd5e0; + transform: translateY(-1px); +} + +.did-action-icon { + width: 18px; + height: 18px; + fill: currentColor; +} + +.did-auto-check-toggle { + margin: 0; + color: #4a5568; +} + +.did-auto-check-toggle .MuiFormControlLabel-label { + font-size: 14px; + font-weight: 500; +} + /* Credentials section */ .credentials-section { background: rgba(255, 255, 255, 0.95); @@ -149,15 +269,15 @@ .credential-card { background: white; border: 1px solid #e2e8f0; - border-radius: 8px; - padding: 20px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06); transition: all 0.2s ease; } .credential-card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + transform: translateY(-2px); } .credential-card.selectable { @@ -170,37 +290,278 @@ .credential-card.selected { border-color: #4c51bf; - background: #f7faff; + box-shadow: 0 0 0 2px rgba(76, 81, 191, 0.25); } -.credential-id { - font-size: 12px; - color: #a0aec0; - font-family: 'Monaco', 'Consolas', monospace; - margin-bottom: 8px; - word-break: break-all; +/* Card header */ +.credential-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: linear-gradient(135deg, #4c51bf 0%, #6b46c1 100%); + gap: 12px; } -.credential-type { - font-size: 18px; +.credential-header-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.credential-type-badge { + font-size: 14px; + font-weight: 700; + color: white; + letter-spacing: 0.02em; +} + +.credential-status { + font-size: 11px; + font-weight: 600; + padding: 3px 10px; + border-radius: 20px; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; +} + +.credential-status.valid { + background: rgba(255, 255, 255, 0.25); + color: white; +} + +.credential-status.expired { + background: rgba(255, 80, 80, 0.35); + color: #ffe0e0; +} + +.delete-credential-icon-btn { + width: 26px; + height: 26px; + border: none; + border-radius: 999px; + background: rgba(255, 255, 255, 0.18); + color: #ffffff; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.2s ease; +} + +.delete-credential-icon-btn:hover { + background: rgba(229, 62, 62, 0.7); +} + +.delete-credential-icon-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.delete-credential-icon { + width: 15px; + height: 15px; + fill: currentColor; +} + +/* Card name */ +.credential-name { + padding: 12px 18px 0; + font-size: 17px; font-weight: 600; color: #2d3748; - margin-bottom: 12px; } -.credential-subject { - background: #f7fafc; - border-radius: 6px; - padding: 12px; - overflow-x: auto; +/* Attributes */ +.credential-attributes { + padding: 12px 18px; + display: flex; + flex-direction: column; + gap: 10px; } -.credential-subject pre { +.attribute-group { + display: flex; + flex-direction: column; + gap: 8px; + padding-left: 12px; + border-left: 2px solid #e2e8f0; +} + +.attribute-group.depth-0 { + padding-left: 0; + border-left: none; +} + +.attribute-group-title { + color: #2d3748; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.attribute-group-body { + display: flex; + flex-direction: column; + gap: 6px; +} + +.credential-raw-json { + padding: 12px 18px; + background: #f8fafc; +} + +.credential-raw-json pre { margin: 0; + max-height: 280px; + overflow: auto; + font-size: 12px; + line-height: 1.4; + color: #2d3748; + font-family: 'Monaco', 'Consolas', monospace; +} + +.credential-attribute-row { + display: flex; + gap: 10px; + align-items: baseline; font-size: 14px; +} + +.attribute-label { + flex: 0 0 140px; + color: #718096; + font-weight: 500; + font-size: 13px; +} + +.attribute-value { + color: #2d3748; + word-break: break-word; + flex: 1; +} + +.expand-btn { + background: none; + border: none; + color: #4c51bf; + font-size: 13px; + font-weight: 600; + cursor: pointer; + padding: 4px 0; + margin-top: 4px; + text-align: left; +} + +.expand-btn:hover { + text-decoration: underline; +} + +/* Card footer */ +.credential-card-footer { + border-top: 1px solid #f0f4f8; + padding: 12px 18px; + display: flex; + flex-direction: column; + gap: 8px; + background: #f7fafc; +} + +.footer-section { + display: flex; + flex-direction: column; + gap: 4px; +} + +.footer-section-label { + color: #a0aec0; + font-weight: 600; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.06em; +} + +.issuer-identity { + display: flex; + align-items: center; + gap: 8px; +} + +.footer-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 20px; +} + +.credential-footer-item { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 12px; +} + +.credential-footer-item.id-row { + width: 100%; +} + +.footer-bottom { + display: flex; + align-items: center; + gap: 14px; + justify-content: flex-end; +} + +.view-toggle-btn { + border: none; + background: none; + color: #4c51bf; + font-size: 12px; + font-weight: 600; + cursor: pointer; + padding: 0; +} + +.view-toggle-btn:hover { + text-decoration: underline; +} + +.footer-label { + color: #a0aec0; + font-weight: 600; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.06em; + white-space: nowrap; +} + +.footer-value { color: #4a5568; - white-space: pre-wrap; - word-wrap: break-word; +} + +.issuer-logo { + width: 20px; + height: 20px; + object-fit: contain; + border-radius: 3px; + flex-shrink: 0; +} + +.issuer-value { + font-weight: 600; + color: #2d3748; +} + +.credential-id-value { + font-family: 'Monaco', 'Consolas', monospace; + font-size: 11px; + color: #a0aec0; + word-break: break-all; +} + +.expired-text { + color: #e53e3e; } /* Modal styles */ @@ -254,6 +615,22 @@ margin-top: 32px; } +.verify-loading-state { + min-height: 180px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 14px; + text-align: center; + color: #4a5568; +} + +.verify-loading-state p { + margin: 0; + font-size: 14px; +} + .credential-selection { display: grid; gap: 12px; @@ -330,6 +707,27 @@ text-align: center; } + .did-wallet-switcher { + flex-direction: column; + align-items: center; + gap: 6px; + } + + .did-wallet-selector { + width: 100%; + max-width: 100%; + } + + .did-controls { + justify-content: center; + } + + .did-messages-group { + margin-left: 0; + padding-left: 0; + border-left: none; + } + .did-value { max-width: 100%; } @@ -346,4 +744,9 @@ .App-header h1 { font-size: 28px; } + + .header-logo { + width: 44px; + height: 44px; + } } diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 2e1cce53..44dfdf3b 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback } from "react"; -import { Box, Button, Modal, TextField } from "@mui/material"; +import { Alert, Box, Button, CircularProgress, Divider, FormControlLabel, Menu, MenuItem, Modal, Snackbar, Switch, TextField } from "@mui/material"; import "./App.css"; import { createVerificationController } from "@docknetwork/wallet-sdk-core/lib/verification-controller"; import { getVCData } from "@docknetwork/prettyvc"; @@ -7,23 +7,28 @@ import axios from "axios"; import { setLocalStorageImpl } from "@docknetwork/wallet-sdk-data-store-web/lib/localStorageJSON"; import useCloudWallet from './hooks/useCloudWallet'; -import { generateCloudWalletMasterKey } from "@docknetwork/wallet-sdk-core/lib/cloud-wallet"; +import { useWalletManager } from './hooks/useWalletManager'; +import { useCredentialManagement } from './hooks/useCredentialManagement'; +import ActionButtons from "./components/ActionButtons"; +import DidSection from "./components/DidSection"; +import CredentialsSection from "./components/CredentialsSection"; +import ImportCredentialModal from "./components/ImportCredentialModal"; +import VerifyCredentialModal from "./components/VerifyCredentialModal"; +import { useImportFlow, useVerifyFlow } from "./hooks/useModalFlows"; setLocalStorageImpl(global.localStorage); + + function App() { - const [loading, setLoading] = useState(false); const [documents, setDocuments] = useState([]); const [formattedCredentials, setFormattedCredentials] = useState([]); - const [importModalOpen, setImportModalOpen] = useState(false); - const [verifyModalOpen, setVerifyModalOpen] = useState(false); - const [credentialUrl, setCredentialUrl] = useState(""); - const [proofRequestUrl, setProofRequestUrl] = useState(); - const [verifyStep, setVerifyStep] = useState(1); - const [selectedCredential, setSelectedCredential] = useState(null); - const [walletKeys, setWalletKeys] = useState(null); - const [uploadError, setUploadError] = useState(null); + const [settingsAnchorEl, setSettingsAnchorEl] = useState(null); + const [autoCheckMessages, setAutoCheckMessages] = useState(false); + + // Initialize wallet management + const walletManager = useWalletManager(); // Styles for the modals const modalStyle = { @@ -37,33 +42,6 @@ function App() { p: 4, }; - useEffect(() => { - try { - const jsonKeys = localStorage.getItem("keys"); - if (jsonKeys) { - let masterKeyArray; - const parsedKeys = JSON.parse(jsonKeys); - if (parsedKeys.masterKey && typeof parsedKeys.masterKey === 'object' && !Array.isArray(parsedKeys.masterKey)) { - masterKeyArray = Object.values(parsedKeys.masterKey); - } else if (Array.isArray(parsedKeys.masterKey)) { - masterKeyArray = parsedKeys.masterKey; - } else { - console.log('Master key', parsedKeys.masterKey); - throw new Error('Invalid master key format'); - } - - const _walletKeys = { - masterKey: new Uint8Array(masterKeyArray), - mnemonic: parsedKeys.mnemonic, - }; - - setWalletKeys(_walletKeys); - } - } catch (err) { - console.error("Error fetching wallet keys:", err); - } - }, []); - const { loading: cloudWalletLoading, cloudWallet, @@ -73,29 +51,7 @@ function App() { defaultDID, messageProvider, provisionNewWallet, - } = useCloudWallet(walletKeys); - - - const handleImportCredential = async () => { - if (!credentialProvider) { - return - } - - // check if the URL is a valid openid-credential-offer - if (!credentialUrl.startsWith("openid-credential-offer:")) { - alert("Invalid credential offer URL. Check https://docs.truvera.io/truvera-api/openid#credential-offers for more details."); - return; - } - - await credentialProvider.importCredentialFromURI({ - uri: credentialUrl, - didProvider, - }); - - refreshDocuments(); - setImportModalOpen(false); - setCredentialUrl(""); - }; + } = useCloudWallet(walletManager.walletKeys, walletManager.activeWalletId); const refreshDocuments = useCallback(async () => { if (!credentialProvider) { @@ -116,6 +72,28 @@ function App() { setDocuments(creds); }, [credentialProvider]); + // Extract modal flows using custom hooks + const importFlow = useImportFlow(credentialProvider, didProvider, refreshDocuments); + const verifyFlow = useVerifyFlow(wallet, credentialProvider, didProvider); + + // Extract credential management + const credentialManagement = useCredentialManagement( + credentialProvider, + wallet, + cloudWallet, + documents, + refreshDocuments + ); + + const handleFetchMessages = useCallback(async () => { + if (!messageProvider) { + return; + } + + await messageProvider.fetchMessages(); + await messageProvider.processDIDCommMessages(); + }, [messageProvider]); + useEffect(() => { if (credentialProvider) { refreshDocuments(); @@ -130,11 +108,20 @@ function App() { const unsubscribe = messageProvider.addMessageListener(async (message) => { console.log("Message received", message); - if (message.body.credentials) { + const incomingCredentials = Array.isArray(message?.body?.credentials) + ? message.body.credentials + : []; + + if (incomingCredentials.length) { console.log("adding credential to the wallet"); - message.body.credentials.forEach(async (credential) => { - await credentialProvider.addCredential(credential); - refreshDocuments(); + await Promise.all( + incomingCredentials.map((credential) => credentialProvider.addCredential(credential)) + ); + await refreshDocuments(); + importFlow.setImportToast({ + open: true, + severity: "success", + message: `Imported ${incomingCredentials.length} credential${incomingCredentials.length === 1 ? "" : "s"} from messages.`, }); } }); @@ -142,99 +129,45 @@ function App() { return () => unsubscribe && unsubscribe(); }, [messageProvider, credentialProvider, refreshDocuments]); - const handleVerifyCredential = async () => { - if (!wallet || !credentialProvider || !didProvider) { + useEffect(() => { + if (!autoCheckMessages || !defaultDID || !messageProvider) { return; } - setLoading(true); - const { data: proofRequest } = await axios.get(proofRequestUrl); - const controller = createVerificationController({ - wallet, - credentialProvider, - didProvider, - }); + void handleFetchMessages(); - const credential = selectedCredential; + const intervalId = window.setInterval(() => { + void handleFetchMessages(); + }, 30000); - await controller.start({ template: proofRequest }); + return () => window.clearInterval(intervalId); + }, [autoCheckMessages, defaultDID, messageProvider, handleFetchMessages]); - const attributesToReveal = ["credentialSubject.name"]; + const matchingCredentials = formattedCredentials + .map((document, idx) => ({ + document, + rawDocument: documents[idx], + })) + .filter((item) => item.rawDocument && verifyFlow.matchingCredentialIds.includes(item.rawDocument.id)); - controller.selectedCredentials.set(credential.id, { - credential, - attributesToReveal, - }); - - const presentation = await controller.createPresentation(); - - console.log(presentation); - - try { - const { data: verificationResult } = await axios - .post(proofRequest.response_url, presentation) - .then((res) => res.data); - - console.log("Verification sent", { - verificationResult, - }); - - alert("Verification sent successfully"); - } catch (err) { - console.error("Error sending verification", err); - alert("Error sending verification: " + err.response.data.error); - } - - setLoading(false); - setVerifyModalOpen(false); - setVerifyStep(1); - setProofRequestUrl(""); - setSelectedCredential(null); + const settingsMenuOpen = Boolean(settingsAnchorEl); + const handleOpenSettingsMenu = (event) => { + setSettingsAnchorEl(event.currentTarget); }; - const handleWalletKeyUpload = (event) => { - const file = event.target.files[0]; - if (file) { - setLoading(true); - const reader = new FileReader(); - reader.onload = (e) => { - try { - const keys = JSON.parse(e.target.result); - localStorage.setItem("keys", JSON.stringify(keys)); - setWalletKeys(keys); - setLoading(false); - } catch (error) { - console.error("Error parsing wallet keys:", error); - setUploadError("Invalid wallet key file."); - setLoading(false); - } - }; - reader.readAsText(file); - } - }; - - const handleCreateWallet = async () => { - setLoading(true); - try { - const newKeys = await generateCloudWalletMasterKey(); - console.log("generated new keys for the wallet"); - localStorage.setItem("keys", JSON.stringify(newKeys)); - setWalletKeys(newKeys); - } catch (err) { - console.error("Error generating keys", err); - } - setLoading(false); + const handleCloseSettingsMenu = () => { + setSettingsAnchorEl(null); }; console.log({ - walletKeys, - loading, + walletKeys: walletManager.walletKeys, + loading: walletManager.loading, cloudWalletLoading, documents, formattedCredentials, }); - if (cloudWalletLoading || loading) { + if (cloudWalletLoading || walletManager.loading) { return (
@@ -244,13 +177,13 @@ function App() { ); } - if (!walletKeys) { + if (!walletManager.walletKeys) { return (

Welcome to the Wallet App

Please upload your wallet key file or create a new wallet.

- {uploadError &&
{uploadError}
} + {walletManager.uploadError &&
{walletManager.uploadError}
}
@@ -278,258 +211,219 @@ function App() {
-

Truvera Wallet React Example

+ Truvera +

Truvera Demo Web Wallet

{/* Action Buttons */} -
- - - - + {cloudWallet && ( - + )} -
+ {/* DID Management */} -
- {!defaultDID ? ( -
- -
- ) : ( -
-
- Default DID: - {defaultDID} - - -
-
- )} -
+ {/* Credentials List */} -
-

Credentials ({formattedCredentials.length})

- - {formattedCredentials.length === 0 ? ( -
- No credentials found. Import some credentials to get started. -
- ) : ( -
- {formattedCredentials.map((document) => ( -
-
{document.id}
-
{document.humanizedType || 'Unknown Type'}
-
-
{JSON.stringify(document?.credentialSubject, null, 2)}
-
-
- ))} -
- )} -
+
{/* Import Credential Modal */} - setImportModalOpen(false)}> - -

Import Credential

-
- - setCredentialUrl(e.target.value)} - placeholder="Enter credential offer URL" - InputProps={{ - sx: { - borderRadius: '8px', - '&.Mui-focused': { - boxShadow: '0 0 0 3px rgba(76, 81, 191, 0.1)', - }, - }, - }} - /> -
-
- - -
-
-
+ {/* Verify Credential Modal */} - { - setVerifyModalOpen(false); - setVerifyStep(1); - setProofRequestUrl(""); - setSelectedCredential(null); - }} + verifyFlow.setVerifyStep(1)} + onClose={verifyFlow.resetVerifyFlow} + onSelectCredential={verifyFlow.setSelectedCredential} + modalStyle={modalStyle} + /> + verifyFlow.setVerifyToast((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} > - - {verifyStep === 1 && ( - <> -

Verify Credential

-
- - setProofRequestUrl(e.target.value)} - placeholder="Enter proof request URL" - InputProps={{ - sx: { - borderRadius: '8px', - '&.Mui-focused': { - boxShadow: '0 0 0 3px rgba(76, 81, 191, 0.1)', - }, - }, - }} - /> -
-
- - -
- - )} - {verifyStep === 2 && ( - <> -

Select Credential to Present

-
- {formattedCredentials.map((document, idx) => ( -
setSelectedCredential(documents[idx])} - > -
{document.humanizedType || 'Unknown Type'}
-
-
{JSON.stringify(document.credentialSubject, null, 2)}
-
-
- ))} -
-
- - -
- - )} -
-
+ verifyFlow.setVerifyToast((prev) => ({ ...prev, open: false }))} + > + {verifyFlow.verifyToast.message} + + + importFlow.setImportToast((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + > + importFlow.setImportToast((prev) => ({ ...prev, open: false }))} + > + {importFlow.importToast.message} + + + walletManager.setWalletToast((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + > + walletManager.setWalletToast((prev) => ({ ...prev, open: false }))} + > + {walletManager.walletToast.message} + + + credentialManagement.setCredentialToast((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + > + credentialManagement.setCredentialToast((prev) => ({ ...prev, open: false }))} + > + {credentialManagement.credentialToast.message} + +
); } diff --git a/examples/web-example/src/components/ActionButtons.js b/examples/web-example/src/components/ActionButtons.js new file mode 100644 index 00000000..d99faca0 --- /dev/null +++ b/examples/web-example/src/components/ActionButtons.js @@ -0,0 +1,50 @@ +import React from "react"; + +function ActionButtons({ + onImportClick, + onVerifyClick, + onRefreshClick, + onSettingsClick, +}) { + return ( +
+ + + + +
+ ); +} + +export default ActionButtons; diff --git a/examples/web-example/src/components/CredentialCard.js b/examples/web-example/src/components/CredentialCard.js new file mode 100644 index 00000000..d5f315f3 --- /dev/null +++ b/examples/web-example/src/components/CredentialCard.js @@ -0,0 +1,242 @@ +import React, { useState } from "react"; + +function humanizeKey(key) { + return key + .replace(/([A-Z])/g, ' $1') + .replace(/^./, (s) => s.toUpperCase()) + .trim(); +} + +function formatDate(value) { + if (!value) return null; + const clean = String(value).replace(/^"|"$/g, '').trim(); + try { + const d = new Date(clean); + if (!isNaN(d)) return d.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); + } catch (_) {} + return clean; +} + +function formatAttributeValue(value) { + if (value === null || value === undefined) return '—'; + if (typeof value === 'boolean') return value ? 'Yes' : 'No'; + if (Array.isArray(value)) return value.map(formatAttributeValue).join(', '); + if (typeof value === 'object') { + // Prefer a human-readable scalar property over raw JSON + const pick = value.name ?? value.value ?? value.id ?? value.label; + if (pick !== undefined && typeof pick !== 'object') return String(pick); + // Single-key object — just show its value + const entries = Object.entries(value); + if (entries.length === 1) return formatAttributeValue(entries[0][1]); + // Multi-key: show "Key: Value" pairs joined by space + return entries.map(([k, v]) => `${humanizeKey(k)}: ${formatAttributeValue(v)}`).join(' · '); + } + if (typeof value === 'string') { + const stripped = value.replace(/^"|"$/g, ''); + if (/^\d{4}-\d{2}-\d{2}/.test(stripped)) { + try { + return new Date(stripped).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' }); + } catch (_) {} + } + return stripped; + } + return String(value); +} + +function isPrimitiveValue(value) { + return value === null || value === undefined || typeof value !== 'object'; +} + +function AttributeNode({ label, value, depth = 0 }) { + const normalizedLabel = label ? humanizeKey(label) : null; + + if (Array.isArray(value)) { + if (value.length === 0 || value.every(isPrimitiveValue)) { + return ( +
+ {normalizedLabel} + {formatAttributeValue(value)} +
+ ); + } + + return ( +
+ {normalizedLabel &&
{normalizedLabel}
} +
+ {value.map((item, index) => ( + + ))} +
+
+ ); + } + + if (value && typeof value === 'object') { + const entries = Object.entries(value).filter(([key]) => key !== 'id'); + + if (entries.length === 0) { + return ( +
+ {normalizedLabel} + +
+ ); + } + + return ( +
+ {normalizedLabel &&
{normalizedLabel}
} +
+ {entries.map(([key, nestedValue]) => ( + + ))} +
+
+ ); + } + + return ( +
+ {normalizedLabel} + {formatAttributeValue(value)} +
+ ); +} + +function CredentialCard({ document, rawDocument, selectable, selected, onClick, onDelete, deleting }) { + const [expanded, setExpanded] = useState(false); + const [showJson, setShowJson] = useState(false); + const subject = document?.credentialSubject || {}; + const subjectEntries = Object.entries(subject).filter(([key]) => key !== 'id'); + const PREVIEW_COUNT = 4; + const visibleEntries = expanded ? subjectEntries : subjectEntries.slice(0, PREVIEW_COUNT); + const hasMore = subjectEntries.length > PREVIEW_COUNT; + + const issuer = document?.issuer; + const issuerName = typeof issuer === 'string' + ? issuer + : issuer?.name || issuer?.id || null; + const issuerLogo = typeof issuer === 'object' + ? (issuer?.image?.id || issuer?.image || issuer?.logo?.id || issuer?.logo || null) + : null; + const issuerLogoUrl = typeof issuerLogo === 'string' ? issuerLogo : null; + + const issuanceDate = formatDate(document?.issuanceDate); + const expirationDate = formatDate(document?.expirationDate); + const isExpired = document?.expirationDate && new Date(String(document.expirationDate).replace(/^"|"$/g, '').trim()) < new Date(); + + return ( +
+
+ {document.humanizedType || (document.type?.slice(-1)[0]) || 'Credential'} +
+ {isExpired + ? Expired + : Valid} + {onDelete && ( + + )} +
+
+ + {showJson ? ( +
+
{JSON.stringify(rawDocument || document, null, 2)}
+
+ ) : ( + subjectEntries.length > 0 && ( +
+ {visibleEntries.map(([key, value]) => ( + + ))} + {hasMore && ( + + )} +
+ ) + )} + +
+ {issuerName && ( +
+ Issued By +
+ {issuerLogoUrl && ( + {issuerName} + )} + {issuerName} +
+
+ )} +
+ {issuanceDate && ( +
+ Issued + {issuanceDate} +
+ )} + {expirationDate && ( +
+ Expires + {expirationDate} +
+ )} +
+ ID + {document.id} +
+
+
+ +
+
+
+ ); +} + +export default CredentialCard; diff --git a/examples/web-example/src/components/CredentialsSection.js b/examples/web-example/src/components/CredentialsSection.js new file mode 100644 index 00000000..be9806a0 --- /dev/null +++ b/examples/web-example/src/components/CredentialsSection.js @@ -0,0 +1,35 @@ +import React from "react"; +import CredentialCard from "./CredentialCard"; + +function CredentialsSection({ + formattedCredentials, + documents, + deletingCredentialId, + onDeleteCredential, +}) { + return ( +
+

Credentials ({formattedCredentials.length})

+ + {formattedCredentials.length === 0 ? ( +
+ No credentials found. Import some credentials to get started. +
+ ) : ( +
+ {formattedCredentials.map((document, idx) => ( + + ))} +
+ )} +
+ ); +} + +export default CredentialsSection; diff --git a/examples/web-example/src/components/DidSection.js b/examples/web-example/src/components/DidSection.js new file mode 100644 index 00000000..3e6166bf --- /dev/null +++ b/examples/web-example/src/components/DidSection.js @@ -0,0 +1,105 @@ +import React from "react"; +import { FormControlLabel, Switch } from "@mui/material"; + +function FetchMessagesIcon({ className, title }) { + return ( + + ); +} + +function DidSection({ + defaultDID, + walletProfiles, + activeWalletId, + autoCheckMessages, + onProvisionNewWallet, + onSwitchWallet, + onFetchMessages, + onAutoCheckToggle, +}) { + return ( +
+ {!defaultDID ? ( +
+ +
+ ) : ( +
+
+
+ + +
+ {defaultDID} +
+
+ +
+
+ + onAutoCheckToggle(event.target.checked)} + /> + )} + label="Auto-check every 30s" + /> +
+
+
+
+ )} +
+ ); +} + +export default DidSection; diff --git a/examples/web-example/src/components/ImportCredentialModal.js b/examples/web-example/src/components/ImportCredentialModal.js new file mode 100644 index 00000000..336614fc --- /dev/null +++ b/examples/web-example/src/components/ImportCredentialModal.js @@ -0,0 +1,72 @@ +import React from "react"; +import { Box, Modal, TextField, CircularProgress } from "@mui/material"; + +function ImportCredentialModal({ + open, + isImporting, + credentialUrl, + onCredentialUrlChange, + onImport, + onClose, + modalStyle, +}) { + return ( + { + if (isImporting) { + return; + } + onClose(); + }} + > + + {isImporting ? ( +
+ +

Importing credential offer and processing response...

+
+ ) : ( + <> +

Import Credential

+
+ + onCredentialUrlChange(e.target.value)} + placeholder="Enter credential offer URL" + InputProps={{ + sx: { + borderRadius: '8px', + '&.Mui-focused': { + boxShadow: '0 0 0 3px rgba(76, 81, 191, 0.1)', + }, + }, + }} + /> +
+
+ + +
+ + )} +
+
+ ); +} + +export default ImportCredentialModal; diff --git a/examples/web-example/src/components/VerifyCredentialModal.js b/examples/web-example/src/components/VerifyCredentialModal.js new file mode 100644 index 00000000..1efc26f6 --- /dev/null +++ b/examples/web-example/src/components/VerifyCredentialModal.js @@ -0,0 +1,122 @@ +import React from "react"; +import { Box, Modal, TextField, CircularProgress } from "@mui/material"; +import CredentialCard from "./CredentialCard"; + +function VerifyCredentialModal({ + open, + isVerifying, + verifyStep, + proofRequestUrl, + loadingMatchingCredentials, + matchingCredentials, + selectedCredential, + onProofRequestUrlChange, + onLoadMatchingCredentials, + onVerifyCredential, + onBackStep, + onClose, + onSelectCredential, + modalStyle, +}) { + return ( + { + if (isVerifying) { + return; + } + onClose(); + }} + > + + {isVerifying ? ( +
+ +

Checking credential and submitting verification...

+
+ ) : ( + <> + {verifyStep === 1 && ( + <> +

Verify Credential

+
+ + onProofRequestUrlChange(e.target.value)} + placeholder="Enter proof request URL" + InputProps={{ + sx: { + borderRadius: '8px', + '&.Mui-focused': { + boxShadow: '0 0 0 3px rgba(76, 81, 191, 0.1)', + }, + }, + }} + /> +
+
+ + +
+ + )} + {verifyStep === 2 && ( + <> +

Select Credential to Present

+ {matchingCredentials.length === 0 ? ( +
+ No matching credentials found for this proof request. +
+ ) : ( +
+ {matchingCredentials.map((item) => ( + onSelectCredential(item.rawDocument)} + /> + ))} +
+ )} +
+ + +
+ + )} + + )} +
+
+ ); +} + +export default VerifyCredentialModal; diff --git a/examples/web-example/src/hooks/useCloudWallet.js b/examples/web-example/src/hooks/useCloudWallet.js index 231cdb1a..05ac0d0e 100644 --- a/examples/web-example/src/hooks/useCloudWallet.js +++ b/examples/web-example/src/hooks/useCloudWallet.js @@ -9,7 +9,7 @@ import { createMessageProvider } from '@docknetwork/wallet-sdk-core/lib/message- const EDV_URL = 'https://edv.dock.io'; const EDV_AUTH_KEY = 'DOCKWALLET-TEST'; -function useCloudWallet(walletKeys) { +function useCloudWallet(walletKeys, walletProfileId) { const [loading, setLoading] = useState(false); const [cloudWallet, setCloudWallet] = useState(null); const [wallet, setWallet] = useState(null); @@ -58,9 +58,18 @@ function useCloudWallet(walletKeys) { } setLoading(true); + setCloudWallet(null); + setWallet(null); + setCredentialProvider(null); + setDidProvider(null); + setDefaultDID(null); + setMessageProvider(null); + setDataStore(null); + try { const _dataStore = await createDataStore({ - databasePath: 'dock-wallet', + // Keep each wallet profile in an isolated local datastore. + databasePath: `dock-wallet-${walletProfileId || 'default'}`, defaultNetwork: 'testnet', }); setDataStore(_dataStore); @@ -107,7 +116,7 @@ function useCloudWallet(walletKeys) { cloudWallet.unsubscribeEventListeners(); } }; - }, [walletKeys]); + }, [walletKeys, walletProfileId]); return { loading, diff --git a/examples/web-example/src/hooks/useCredentialManagement.js b/examples/web-example/src/hooks/useCredentialManagement.js new file mode 100644 index 00000000..eb25a2fd --- /dev/null +++ b/examples/web-example/src/hooks/useCredentialManagement.js @@ -0,0 +1,49 @@ +import { useState, useCallback } from 'react'; +import { deleteCredential } from '../services/credentialService'; + +export function useCredentialManagement( + credentialProvider, + wallet, + cloudWallet, + documents, + refreshDocuments +) { + const [deletingCredentialId, setDeletingCredentialId] = useState(null); + const [credentialToast, setCredentialToast] = useState({ + open: false, + severity: 'success', + message: '', + }); + + const handleDeleteCredential = useCallback(async (credentialId) => { + const credential = documents.find((doc) => doc?.id === credentialId); + const credentialLabel = credential?.id || credentialId; + const confirmed = window.confirm( + `Delete this credential? This will remove it from local wallet storage and EDV.\n\n${credentialLabel}` + ); + + if (!confirmed) { + return; + } + + setDeletingCredentialId(credentialId); + const result = await deleteCredential({ credentialProvider, wallet, cloudWallet, credentialId }); + setDeletingCredentialId(null); + + if (result.success) { + await refreshDocuments(); + } + + setCredentialToast({ open: true, severity: result.success ? 'success' : 'error', message: result.message }); + }, [cloudWallet, credentialProvider, documents, refreshDocuments, wallet]); + + return { + // State + deletingCredentialId, + credentialToast, + // Setters + setCredentialToast, + // Handlers + handleDeleteCredential, + }; +} diff --git a/examples/web-example/src/hooks/useModalFlows.js b/examples/web-example/src/hooks/useModalFlows.js new file mode 100644 index 00000000..29cdb04d --- /dev/null +++ b/examples/web-example/src/hooks/useModalFlows.js @@ -0,0 +1,216 @@ +import { useState, useCallback } from "react"; +import axios from "axios"; +import { createVerificationController } from "@docknetwork/wallet-sdk-core/lib/verification-controller"; + +/** + * useImportFlow - Manages import credential modal state and logic + */ +export function useImportFlow(credentialProvider, didProvider, refreshDocuments) { + const [importModalOpen, setImportModalOpen] = useState(false); + const [credentialUrl, setCredentialUrl] = useState(""); + const [isImporting, setIsImporting] = useState(false); + const [importToast, setImportToast] = useState({ + open: false, + severity: "success", + message: "", + }); + + const resetImportFlow = useCallback(() => { + setImportModalOpen(false); + setCredentialUrl(""); + setIsImporting(false); + }, []); + + const handleImportCredential = useCallback(async () => { + if (!credentialProvider) { + return; + } + + if (!credentialUrl.startsWith("openid-credential-offer:")) { + alert("Invalid credential offer URL. Check https://docs.truvera.io/truvera-api/openid#credential-offers for more details."); + return; + } + + try { + setIsImporting(true); + + await credentialProvider.importCredentialFromURI({ + uri: credentialUrl, + didProvider, + }); + + if (refreshDocuments) { + await refreshDocuments(); + } + + setImportToast({ + open: true, + severity: "success", + message: "Credential imported successfully.", + }); + + resetImportFlow(); + } catch (err) { + console.error("Error importing credential", err); + setImportToast({ + open: true, + severity: "error", + message: `Import failed: ${err?.message || "Unable to import credential."}`, + }); + setIsImporting(false); + } + }, [credentialProvider, didProvider, credentialUrl, refreshDocuments, resetImportFlow]); + + return { + importModalOpen, + credentialUrl, + isImporting, + importToast, + // Setters + setImportModalOpen, + setCredentialUrl, + setImportToast, + // Handlers + resetImportFlow, + handleImportCredential, + }; +} + +/** + * useVerifyFlow - Manages verify credential modal state and logic + */ +export function useVerifyFlow(wallet, credentialProvider, didProvider) { + const [verifyModalOpen, setVerifyModalOpen] = useState(false); + const [proofRequestUrl, setProofRequestUrl] = useState(""); + const [proofRequestTemplate, setProofRequestTemplate] = useState(null); + const [verifyStep, setVerifyStep] = useState(1); + const [selectedCredential, setSelectedCredential] = useState(null); + const [matchingCredentialIds, setMatchingCredentialIds] = useState([]); + const [loadingMatchingCredentials, setLoadingMatchingCredentials] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [verifyToast, setVerifyToast] = useState({ + open: false, + severity: "success", + message: "", + }); + + const resetVerifyFlow = useCallback(() => { + setVerifyModalOpen(false); + setVerifyStep(1); + setProofRequestUrl(""); + setProofRequestTemplate(null); + setMatchingCredentialIds([]); + setSelectedCredential(null); + setIsVerifying(false); + }, []); + + const handleLoadMatchingCredentials = useCallback(async () => { + if (!wallet || !credentialProvider || !didProvider || !proofRequestUrl) { + return; + } + + try { + setLoadingMatchingCredentials(true); + const proofRequest = (await axios.get(proofRequestUrl)).data; + const controller = createVerificationController({ + wallet, + credentialProvider, + didProvider, + }); + + await controller.start({ template: proofRequest }); + const filteredCredentials = controller.getFilteredCredentials() || []; + const filteredIds = filteredCredentials.map((credential) => credential.id); + + setProofRequestTemplate(proofRequest); + setMatchingCredentialIds(filteredIds); + setSelectedCredential(null); + setVerifyStep(2); + } catch (err) { + console.error("Error loading matching credentials", err); + alert("Unable to load matching credentials for this proof request."); + } finally { + setLoadingMatchingCredentials(false); + } + }, [wallet, credentialProvider, didProvider, proofRequestUrl]); + + const handleVerifyCredential = useCallback(async () => { + if (!wallet || !credentialProvider || !didProvider || !selectedCredential) { + return; + } + + try { + setIsVerifying(true); + const proofRequest = proofRequestTemplate || (await axios.get(proofRequestUrl)).data; + const controller = createVerificationController({ + wallet, + credentialProvider, + didProvider, + }); + + const credential = selectedCredential; + + await controller.start({ template: proofRequest }); + + const attributesToReveal = ["credentialSubject.name"]; + + controller.selectedCredentials.set(credential.id, { + credential, + attributesToReveal, + }); + + const presentation = await controller.createPresentation(); + + console.log(presentation); + + const { data: verificationResult } = await axios + .post(proofRequest.response_url, presentation) + .then((res) => res.data); + + console.log("Verification sent", { + verificationResult, + }); + + setVerifyToast({ + open: true, + severity: "success", + message: "Verification sent successfully.", + }); + } catch (err) { + console.error("Error sending verification", err); + + const errorMessage = err?.response?.data?.error || err?.message || "Unable to verify credential."; + setVerifyToast({ + open: true, + severity: "error", + message: `Verification failed: ${errorMessage}`, + }); + } finally { + setIsVerifying(false); + resetVerifyFlow(); + } + }, [wallet, credentialProvider, didProvider, selectedCredential, proofRequestTemplate, proofRequestUrl, resetVerifyFlow]); + + return { + // State + verifyModalOpen, + proofRequestUrl, + proofRequestTemplate, + verifyStep, + selectedCredential, + matchingCredentialIds, + loadingMatchingCredentials, + isVerifying, + verifyToast, + // Setters + setVerifyModalOpen, + setProofRequestUrl, + setVerifyStep, + setSelectedCredential, + setVerifyToast, + // Handlers + resetVerifyFlow, + handleLoadMatchingCredentials, + handleVerifyCredential, + }; +} diff --git a/examples/web-example/src/hooks/useWalletManager.js b/examples/web-example/src/hooks/useWalletManager.js new file mode 100644 index 00000000..506cf366 --- /dev/null +++ b/examples/web-example/src/hooks/useWalletManager.js @@ -0,0 +1,200 @@ +import { useState, useEffect } from 'react'; +import { + applyWalletStorageScope, + clearScopedWalletStorage, + clearWalletEdvForProfile, + createWalletProfile, + generateNewWalletKeys, + loadInitialWalletState, + normalizeWalletKeys, + persistWalletProfiles, + WALLET_PROFILES_KEY, + ACTIVE_WALLET_ID_KEY, +} from '../services/walletService'; + +export function useWalletManager() { + const [loading, setLoading] = useState(false); + const [walletProfiles, setWalletProfiles] = useState([]); + const [activeWalletId, setActiveWalletId] = useState(null); + const [walletKeys, setWalletKeys] = useState(null); + const [uploadError, setUploadError] = useState(null); + const [walletToast, setWalletToast] = useState({ + open: false, + severity: 'success', + message: '', + }); + + useEffect(() => { + const { profiles, activeWalletId: resolvedId } = loadInitialWalletState(); + if (profiles.length) { + setWalletProfiles(profiles); + setActiveWalletId(resolvedId); + setWalletKeys(profiles.find((p) => p.id === resolvedId)?.keys || null); + } + }, []); + + const handleWalletKeyUpload = (event) => { + const file = event.target.files[0]; + if (file) { + setLoading(true); + const reader = new FileReader(); + reader.onload = (e) => { + try { + const normalizedKeys = normalizeWalletKeys(JSON.parse(e.target.result)); + const newProfile = createWalletProfile(normalizedKeys, walletProfiles.length + 1); + const updatedProfiles = [...walletProfiles, newProfile]; + + applyWalletStorageScope(newProfile.id); + setWalletProfiles(updatedProfiles); + setActiveWalletId(newProfile.id); + setWalletKeys(newProfile.keys); + persistWalletProfiles(updatedProfiles, newProfile.id); + setLoading(false); + setUploadError(null); + } catch (error) { + console.error('Error parsing wallet keys:', error); + setUploadError('Invalid wallet key file.'); + setLoading(false); + } + }; + reader.readAsText(file); + } + }; + + const handleCreateWallet = async () => { + setLoading(true); + try { + const normalizedKeys = await generateNewWalletKeys(); + const newProfile = createWalletProfile(normalizedKeys, walletProfiles.length + 1); + const updatedProfiles = [...walletProfiles, newProfile]; + + console.log('generated new keys for the wallet'); + applyWalletStorageScope(newProfile.id); + setWalletProfiles(updatedProfiles); + setActiveWalletId(newProfile.id); + setWalletKeys(newProfile.keys); + persistWalletProfiles(updatedProfiles, newProfile.id); + } catch (err) { + console.error('Error generating keys', err); + } + setLoading(false); + }; + + const handleSwitchWallet = (walletId) => { + const targetProfile = walletProfiles.find((profile) => profile.id === walletId); + if (!targetProfile) { + return; + } + + applyWalletStorageScope(walletId); + setActiveWalletId(walletId); + setWalletKeys(targetProfile.keys); + persistWalletProfiles(walletProfiles, walletId); + }; + + const handleRenameWallet = (walletId) => { + const profile = walletProfiles.find((item) => item.id === walletId); + if (!profile) { + return; + } + + const nextName = window.prompt('Set wallet nickname', profile.name)?.trim(); + if (!nextName || nextName === profile.name) { + return; + } + + const updatedProfiles = walletProfiles.map((item) => + item.id === walletId ? { ...item, name: nextName } : item + ); + + setWalletProfiles(updatedProfiles); + persistWalletProfiles(updatedProfiles, activeWalletId); + setWalletToast({ open: true, severity: 'success', message: `Renamed wallet to "${nextName}".` }); + }; + + const handleDeleteWallet = async (walletId) => { + const profile = walletProfiles.find((item) => item.id === walletId); + if (!profile) { + return; + } + + const confirmed = window.confirm( + `Delete "${profile.name}"? This will remove its local data and clear its EDV documents.` + ); + if (!confirmed) { + return; + } + + setLoading(true); + try { + await clearWalletEdvForProfile(profile, activeWalletId); + } catch (err) { + console.error('Error clearing EDV for wallet', err); + setWalletToast({ + open: true, + severity: 'error', + message: `Failed to clear EDV for ${profile.name}. Wallet was not deleted.`, + }); + setLoading(false); + return; + } + + clearScopedWalletStorage(profile.id); + const updatedProfiles = walletProfiles.filter((item) => item.id !== walletId); + + if (!updatedProfiles.length) { + setWalletProfiles([]); + setActiveWalletId(null); + setWalletKeys(null); + localStorage.removeItem(WALLET_PROFILES_KEY); + localStorage.removeItem(ACTIVE_WALLET_ID_KEY); + localStorage.removeItem('keys'); + applyWalletStorageScope('default'); + } else { + const nextActiveId = walletId === activeWalletId ? updatedProfiles[0].id : activeWalletId; + const nextActiveProfile = updatedProfiles.find((item) => item.id === nextActiveId) || updatedProfiles[0]; + + applyWalletStorageScope(nextActiveProfile.id); + setWalletProfiles(updatedProfiles); + setActiveWalletId(nextActiveProfile.id); + setWalletKeys(nextActiveProfile.keys); + persistWalletProfiles(updatedProfiles, nextActiveProfile.id); + } + + setWalletToast({ + open: true, + severity: 'success', + message: `Deleted wallet "${profile.name}" and cleared its EDV documents.`, + }); + setLoading(false); + }; + + const handleClearWallet = () => { + const currentProfiles = walletProfiles; + const currentActiveWalletId = activeWalletId; + localStorage.clear(); + if (currentProfiles.length) { + persistWalletProfiles(currentProfiles, currentActiveWalletId); + } + window.location.reload(); + }; + + return { + // State + loading, + walletProfiles, + activeWalletId, + walletKeys, + uploadError, + walletToast, + // Setters + setWalletToast, + // Handlers + handleWalletKeyUpload, + handleCreateWallet, + handleSwitchWallet, + handleRenameWallet, + handleDeleteWallet, + handleClearWallet, + }; +} diff --git a/examples/web-example/src/services/credentialService.js b/examples/web-example/src/services/credentialService.js new file mode 100644 index 00000000..da36bde4 --- /dev/null +++ b/examples/web-example/src/services/credentialService.js @@ -0,0 +1,70 @@ +export async function waitForCloudWalletSync(cloudWallet) { + if (!cloudWallet || typeof cloudWallet.waitForEdvIdle !== 'function') { + throw new Error('Cloud wallet connection is required to safely delete credentials.'); + } + + await cloudWallet.waitForEdvIdle(); +} + +export async function hasCredentialArtifacts(wallet, credentialId) { + if (!wallet || !credentialId) { + return false; + } + + const candidateIds = [credentialId, `${credentialId}#witness`, `${credentialId}#status`]; + const docs = await Promise.all(candidateIds.map((id) => wallet.getDocumentById(id))); + return docs.some(Boolean); +} + +export async function removeCredentialArtifacts(wallet, credentialId) { + if (!wallet || !credentialId) { + return; + } + + const candidateIds = [credentialId, `${credentialId}#witness`, `${credentialId}#status`]; + for (const id of candidateIds) { + const existingDoc = await wallet.getDocumentById(id); + if (existingDoc) { + await wallet.removeDocument(id); + } + } +} + +/** + * Removes a credential from local wallet storage and EDV, with full sync verification. + * Returns { success: boolean, message: string }. + */ +export async function deleteCredential({ credentialProvider, wallet, cloudWallet, credentialId }) { + if (!credentialProvider || !wallet || !credentialId) { + return { success: false, message: 'Missing required dependencies for credential deletion.' }; + } + + if (!cloudWallet || typeof cloudWallet.pullDocuments !== 'function') { + return { success: false, message: 'Cloud wallet is unavailable, so EDV deletion cannot be confirmed.' }; + } + + try { + await credentialProvider.removeCredential(credentialId); + await waitForCloudWalletSync(cloudWallet); + + // Re-pull from EDV and re-check to confirm delete propagated to cloud. + await cloudWallet.pullDocuments(); + await waitForCloudWalletSync(cloudWallet); + + if (await hasCredentialArtifacts(wallet, credentialId)) { + await removeCredentialArtifacts(wallet, credentialId); + await waitForCloudWalletSync(cloudWallet); + await cloudWallet.pullDocuments(); + await waitForCloudWalletSync(cloudWallet); + } + + if (await hasCredentialArtifacts(wallet, credentialId)) { + throw new Error('Credential still exists after synchronization with EDV.'); + } + + return { success: true, message: 'Credential deleted from local wallet and EDV.' }; + } catch (err) { + console.error('Error deleting credential', err); + return { success: false, message: `Delete failed: ${err?.message || 'Unable to delete credential.'}` }; + } +} diff --git a/examples/web-example/src/services/walletService.js b/examples/web-example/src/services/walletService.js new file mode 100644 index 00000000..b9adec2d --- /dev/null +++ b/examples/web-example/src/services/walletService.js @@ -0,0 +1,190 @@ +import { generateCloudWalletMasterKey, initializeCloudWallet } from '@docknetwork/wallet-sdk-core/lib/cloud-wallet'; +import { createDataStore } from '@docknetwork/wallet-sdk-data-store-web/lib/index'; +import { setLocalStorageImpl } from '@docknetwork/wallet-sdk-data-store-web/lib/localStorageJSON'; + +export const WALLET_PROFILES_KEY = 'walletProfiles'; +export const ACTIVE_WALLET_ID_KEY = 'activeWalletId'; +const EDV_URL = 'https://edv.dock.io'; +const EDV_AUTH_KEY = 'DOCKWALLET-TEST'; + +export function normalizeWalletKeys(rawKeys) { + if (!rawKeys) { + throw new Error('Missing wallet keys'); + } + + let masterKeyArray; + if (rawKeys.masterKey instanceof Uint8Array) { + masterKeyArray = Array.from(rawKeys.masterKey); + } else if (Array.isArray(rawKeys.masterKey)) { + masterKeyArray = rawKeys.masterKey; + } else if (rawKeys.masterKey && typeof rawKeys.masterKey === 'object') { + masterKeyArray = Object.values(rawKeys.masterKey); + } else { + throw new Error('Invalid master key format'); + } + + return { + masterKey: new Uint8Array(masterKeyArray), + mnemonic: rawKeys.mnemonic, + }; +} + +export function serializeWalletKeys(keys) { + return { + masterKey: Array.from(keys.masterKey || []), + mnemonic: keys.mnemonic, + }; +} + +export function createWalletProfile(keys, index = 1) { + return { + id: `wallet-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + name: `Wallet ${index}`, + createdAt: new Date().toISOString(), + keys, + }; +} + +export function createScopedLocalStorage(scope) { + const prefix = `walletScope:${scope}:`; + + return { + getItem: (key) => localStorage.getItem(`${prefix}${key}`), + setItem: (key, value) => localStorage.setItem(`${prefix}${key}`, value), + removeItem: (key) => localStorage.removeItem(`${prefix}${key}`), + getData: () => { + const scopedData = {}; + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + scopedData[key.replace(prefix, '')] = localStorage.getItem(key); + } + } + return scopedData; + }, + }; +} + +export function applyWalletStorageScope(walletId) { + setLocalStorageImpl(createScopedLocalStorage(walletId || 'default')); +} + +export function clearScopedWalletStorage(walletId) { + const prefix = `walletScope:${walletId}:`; + const scopedKeys = []; + + for (let i = 0; i < localStorage.length; i += 1) { + const key = localStorage.key(i); + if (key && key.startsWith(prefix)) { + scopedKeys.push(key); + } + } + + scopedKeys.forEach((key) => localStorage.removeItem(key)); +} + +export function persistWalletProfiles(profiles, nextActiveWalletId) { + const serializedProfiles = profiles.map((profile) => ({ + id: profile.id, + name: profile.name, + createdAt: profile.createdAt, + keys: serializeWalletKeys(profile.keys), + })); + + localStorage.setItem(WALLET_PROFILES_KEY, JSON.stringify(serializedProfiles)); + + const resolvedActiveWalletId = nextActiveWalletId || profiles[0]?.id; + if (resolvedActiveWalletId) { + localStorage.setItem(ACTIVE_WALLET_ID_KEY, resolvedActiveWalletId); + const activeProfile = profiles.find((profile) => profile.id === resolvedActiveWalletId); + if (activeProfile) { + localStorage.setItem('keys', JSON.stringify(serializeWalletKeys(activeProfile.keys))); + } + } +} + +/** + * Reads wallet profiles from localStorage, handles legacy key migration, + * and applies the correct storage scope. Returns the resolved initial state. + */ +export function loadInitialWalletState() { + try { + const storedProfiles = localStorage.getItem(WALLET_PROFILES_KEY); + const storedActiveWalletId = localStorage.getItem(ACTIVE_WALLET_ID_KEY); + + if (storedProfiles) { + const parsedProfiles = JSON.parse(storedProfiles); + const normalizedProfiles = parsedProfiles + .map((profile, index) => { + try { + return { + id: profile.id || `wallet-${index + 1}`, + name: profile.name || `Wallet ${index + 1}`, + createdAt: profile.createdAt || new Date().toISOString(), + keys: normalizeWalletKeys(profile.keys), + }; + } catch (err) { + console.error('Invalid stored wallet profile', profile, err); + return null; + } + }) + .filter(Boolean); + + if (normalizedProfiles.length) { + const activeWalletId = normalizedProfiles.some((p) => p.id === storedActiveWalletId) + ? storedActiveWalletId + : normalizedProfiles[0].id; + + applyWalletStorageScope(activeWalletId); + return { profiles: normalizedProfiles, activeWalletId }; + } + } + + // Legacy migration: single keys entry, no profiles list yet + const legacyKeys = localStorage.getItem('keys'); + if (legacyKeys) { + const normalizedKeys = normalizeWalletKeys(JSON.parse(legacyKeys)); + const migratedProfile = createWalletProfile(normalizedKeys, 1); + + applyWalletStorageScope(migratedProfile.id); + persistWalletProfiles([migratedProfile], migratedProfile.id); + + return { profiles: [migratedProfile], activeWalletId: migratedProfile.id }; + } + + applyWalletStorageScope('default'); + return { profiles: [], activeWalletId: null }; + } catch (err) { + console.error('Error loading wallet state:', err); + return { profiles: [], activeWalletId: null }; + } +} + +export async function generateNewWalletKeys() { + const generatedKeys = await generateCloudWalletMasterKey(); + return normalizeWalletKeys(generatedKeys); +} + +export async function clearWalletEdvForProfile(profile, currentActiveWalletId) { + const previousScope = currentActiveWalletId || 'default'; + + try { + applyWalletStorageScope(profile.id); + const dataStore = await createDataStore({ + databasePath: `dock-wallet-${profile.id}`, + defaultNetwork: 'testnet', + }); + const tempCloudWallet = await initializeCloudWallet({ + dataStore, + edvUrl: EDV_URL, + masterKey: profile.keys.masterKey, + authKey: EDV_AUTH_KEY, + }); + await tempCloudWallet.clearEdvDocuments(); + if (typeof tempCloudWallet.unsubscribeEventListeners === 'function') { + tempCloudWallet.unsubscribeEventListeners(); + } + } finally { + applyWalletStorageScope(previousScope); + } +}