From e41eb980ad5c10838468bb88d533e5264860ea85 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Tue, 17 Mar 2026 15:22:11 -0400 Subject: [PATCH 01/17] update title and favicon --- examples/web-example/public/favicon.ico | Bin 3870 -> 0 bytes examples/web-example/public/favicon.png | Bin 0 -> 817 bytes examples/web-example/public/index.html | 4 ++-- examples/web-example/src/App.js | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 examples/web-example/public/favicon.ico create mode 100644 examples/web-example/public/favicon.png diff --git a/examples/web-example/public/favicon.ico b/examples/web-example/public/favicon.ico deleted file mode 100644 index a11777cc471a4344702741ab1c8a588998b1311a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/examples/web-example/public/favicon.png b/examples/web-example/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..1e501f039e99bdf1a5b248544c1b48f7a7517c04 GIT binary patch literal 817 zcmV-11J3-3P)(*1K~#7F#aF#< z+b|HmBq&n!R}N~RC^|50QJ`5WZ`tChv-b=(x^=SOz2U#&9#**=q@jcNsB7)&D zVuq{%KUsTgaS~#AM|gCTDQgjoumN(7kZNSZZJ68)!mT0~J0iErgtKFEGl<-tXrkfj zG-gIbT_?lgP{wgA!!VTN@%T1D2r~e`%(&n0E28;)E`<9fev$v#X<%_AbY)D zRRB{>?vxoij*}tseP7OIv#kK22ecqNSX<&77n`$B*VpTobh}-eOeVy;%II6I7DZ9C zWxrmpi3!vHF$PAX5pg#1DD>5ypMInq!VV+{qQ=l6&DfcqH}!zlV-42oY!qYx+!W+Z zPN&nBgu;csJDQjED(S-9a)o z8*a6_{`6m7tTJ{lc>VK}0aZz136RQ1oK`)4^~sk{-u5!}#qM~1o;19+pR1? zKozyBaB`P50ZQV~9&e z-xa7mY;1fbNIQ9Iv*2mcfVdBIAevau>Ax26%~S~6sBzx84-^r;e4gj2lLDO?lm!HB zh*cI$==VU~b!2>sO{x7P(;udq{YX3Ad vMKESHN#lfAR5{-xd(m&WF5@FL^z-}*{UDjLCM>RU00000NkvXXu0mjf{c&vh literal 0 HcmV?d00001 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/src/App.js b/examples/web-example/src/App.js index 2e1cce53..07064663 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -278,7 +278,7 @@ function App() {
-

Truvera Wallet React Example

+

Truvera Demo Web Wallet

{/* Action Buttons */} From 25907a1757c499e043a311951a9d12a6d8fe7632 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Tue, 17 Mar 2026 15:40:40 -0400 Subject: [PATCH 02/17] added a "card view" for web example --- examples/web-example/src/App.css | 223 +++++++++++++++++++++++++++---- examples/web-example/src/App.js | 176 +++++++++++++++++++++--- 2 files changed, 360 insertions(+), 39 deletions(-) diff --git a/examples/web-example/src/App.css b/examples/web-example/src/App.css index 980e7ff2..0190c256 100644 --- a/examples/web-example/src/App.css +++ b/examples/web-example/src/App.css @@ -149,15 +149,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 +170,214 @@ .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-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; +} + +/* 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: 6px; +} + +.credential-raw-json { + padding: 12px 18px; + background: #f8fafc; } -.credential-subject pre { +.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; + 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 */ diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 07064663..6531bcb9 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -12,6 +12,154 @@ import { generateCloudWalletMasterKey } from "@docknetwork/wallet-sdk-core/lib/c setLocalStorageImpl(global.localStorage); +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 CredentialCard({ document, rawDocument, selectable, selected, onClick }) { + const [expanded, setExpanded] = useState(false); + const [showJson, setShowJson] = useState(false); + const subject = document?.credentialSubject || {}; + const subjectEntries = Object.entries(subject).filter(([k]) => k !== '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} +
+ + {showJson ? ( +
+
{JSON.stringify(rawDocument || document, null, 2)}
+
+ ) : ( + subjectEntries.length > 0 && ( +
+ {visibleEntries.map(([key, value]) => ( +
+ {humanizeKey(key)} + {formatAttributeValue(value)} +
+ ))} + {hasMore && ( + + )} +
+ ) + )} + +
+ {issuerName && ( +
+ Issued By +
+ {issuerLogoUrl && ( + {issuerName} + )} + {issuerName} +
+
+ )} +
+ {issuanceDate && ( +
+ Issued + {issuanceDate} +
+ )} + {expirationDate && ( +
+ Expires + {expirationDate} +
+ )} +
+ ID + {document.id} +
+
+
+ +
+
+
+ ); +} + function App() { const [loading, setLoading] = useState(false); const [documents, setDocuments] = useState([]); @@ -391,14 +539,12 @@ function App() {
) : (
- {formattedCredentials.map((document) => ( -
-
{document.id}
-
{document.humanizedType || 'Unknown Type'}
-
-
{JSON.stringify(document?.credentialSubject, null, 2)}
-
-
+ {formattedCredentials.map((document, idx) => ( + ))}
)} @@ -499,16 +645,14 @@ function App() {

Select Credential to Present

{formattedCredentials.map((document, idx) => ( -
setSelectedCredential(documents[idx])} - > -
{document.humanizedType || 'Unknown Type'}
-
-
{JSON.stringify(document.credentialSubject, null, 2)}
-
-
+ /> ))}
From a4fe11973cc200be6ea2e591b393afa1b5facfa9 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Tue, 17 Mar 2026 15:51:21 -0400 Subject: [PATCH 03/17] switch to icons for non-core functionality --- examples/web-example/src/App.css | 50 +++++++++++++++ examples/web-example/src/App.js | 105 ++++++++++++++++++++++--------- 2 files changed, 127 insertions(+), 28 deletions(-) diff --git a/examples/web-example/src/App.css b/examples/web-example/src/App.css index 0190c256..5b294ab4 100644 --- a/examples/web-example/src/App.css +++ b/examples/web-example/src/App.css @@ -85,6 +85,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); @@ -119,6 +144,31 @@ 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; +} + /* Credentials section */ .credentials-section { background: rgba(255, 255, 255, 0.95); diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 6531bcb9..1af2e48a 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 { Box, Button, Menu, MenuItem, Modal, TextField } from "@mui/material"; import "./App.css"; import { createVerificationController } from "@docknetwork/wallet-sdk-core/lib/verification-controller"; import { getVCData } from "@docknetwork/prettyvc"; @@ -120,7 +120,7 @@ function CredentialCard({ document, rawDocument, selectable, selected, onClick } Issued By
{issuerLogoUrl && ( - {issuerName} + {issuerName} )} {issuerName}
@@ -172,6 +172,7 @@ function App() { const [selectedCredential, setSelectedCredential] = useState(null); const [walletKeys, setWalletKeys] = useState(null); const [uploadError, setUploadError] = useState(null); + const [settingsAnchorEl, setSettingsAnchorEl] = useState(null); // Styles for the modals const modalStyle = { @@ -223,6 +224,16 @@ function App() { provisionNewWallet, } = useCloudWallet(walletKeys); + const settingsMenuOpen = Boolean(settingsAnchorEl); + + const handleOpenSettingsMenu = (event) => { + setSettingsAnchorEl(event.currentTarget); + }; + + const handleCloseSettingsMenu = () => { + setSettingsAnchorEl(null); + }; + const handleImportCredential = async () => { if (!credentialProvider) { @@ -374,6 +385,25 @@ function App() { setLoading(false); }; + const handleClearWallet = () => { + const currentKeysStr = localStorage.getItem("keys"); + const currentKeys = currentKeysStr ? JSON.parse(currentKeysStr) : null; + localStorage.clear(); + if (currentKeys) { + localStorage.setItem("keys", JSON.stringify({ + masterKey: currentKeys.masterKey, + mnemonic: currentKeys.mnemonic, + })); + } + window.location.reload(); + }; + + const handleClearEdv = () => { + if (cloudWallet) { + cloudWallet.clearEdvDocuments(); + } + }; + console.log({ walletKeys, loading, @@ -454,40 +484,51 @@ function App() { Verify Credential +
+ + { - const currentKeysStr = localStorage.getItem("keys"); - const currentKeys = currentKeysStr ? JSON.parse(currentKeysStr) : null; - localStorage.clear(); - if (currentKeys) { - localStorage.setItem("keys", JSON.stringify({ - masterKey: currentKeys.masterKey, - mnemonic: currentKeys.mnemonic, - })); - } - window.location.reload(); + handleCloseSettingsMenu(); + handleClearWallet(); }} > Clear Wallet - + {cloudWallet && ( - + )} -
+ {/* DID Management */}
@@ -506,23 +547,31 @@ function App() { Default DID: {defaultDID}
From addd775fb4c53f0d2355d9e3393dc1949d442949 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Tue, 17 Mar 2026 17:26:28 -0400 Subject: [PATCH 04/17] added Truvera logo and changed background black colour --- examples/web-example/public/docklogo.png | Bin 0 -> 1482 bytes .../web-example/public/truveralogoblack.png | Bin 0 -> 3756 bytes .../web-example/public/truveralogoround.png | Bin 0 -> 2319 bytes examples/web-example/src/App.css | 20 ++++++++++++++++-- examples/web-example/src/App.js | 5 +++++ 5 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 examples/web-example/public/docklogo.png create mode 100644 examples/web-example/public/truveralogoblack.png create mode 100644 examples/web-example/public/truveralogoround.png diff --git a/examples/web-example/public/docklogo.png b/examples/web-example/public/docklogo.png new file mode 100644 index 0000000000000000000000000000000000000000..3c04d0e0556f7e089bb7c6bb1d34c9479bce21ef GIT binary patch literal 1482 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 literal 0 HcmV?d00001 diff --git a/examples/web-example/src/App.css b/examples/web-example/src/App.css index 5b294ab4..d9b5d22a 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); } @@ -573,4 +584,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 1af2e48a..c3581b59 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -456,6 +456,11 @@ function App() {
+ Truvera

Truvera Demo Web Wallet

From 90ca0db022f03bd9d509eff90118b0ccf9aa9f54 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 18 Mar 2026 15:51:08 -0400 Subject: [PATCH 05/17] improve nested credential object display in the web example cards --- examples/web-example/src/App.css | 27 +++++++++++ examples/web-example/src/App.js | 82 ++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/examples/web-example/src/App.css b/examples/web-example/src/App.css index d9b5d22a..a3a78803 100644 --- a/examples/web-example/src/App.css +++ b/examples/web-example/src/App.css @@ -282,6 +282,33 @@ /* Attributes */ .credential-attributes { padding: 12px 18px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.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; diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index c3581b59..4afb73af 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -55,11 +55,82 @@ function formatAttributeValue(value) { 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 }) { const [expanded, setExpanded] = useState(false); const [showJson, setShowJson] = useState(false); const subject = document?.credentialSubject || {}; - const subjectEntries = Object.entries(subject).filter(([k]) => k !== 'id'); + 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; @@ -97,10 +168,11 @@ function CredentialCard({ document, rawDocument, selectable, selected, onClick } subjectEntries.length > 0 && (
{visibleEntries.map(([key, value]) => ( -
- {humanizeKey(key)} - {formatAttributeValue(value)} -
+ ))} {hasMore && ( - +
+
+ +
+
+ + setAutoCheckMessages(event.target.checked)} + /> + )} + label="Auto-check every 30s" + /> +
+
)} From 3e33d7048e7b956368c72a992bdc946fa5b22a2b Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Thu, 19 Mar 2026 17:08:29 -0400 Subject: [PATCH 07/17] filter credential list during verification in web example --- examples/web-example/src/App.js | 84 ++++++++++++++++++++++++++------- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 9e51311f..2c8d9cf2 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -248,8 +248,11 @@ function App() { const [verifyModalOpen, setVerifyModalOpen] = useState(false); const [credentialUrl, setCredentialUrl] = useState(""); 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 [walletKeys, setWalletKeys] = useState(null); const [uploadError, setUploadError] = useState(null); const [settingsAnchorEl, setSettingsAnchorEl] = useState(null); @@ -411,7 +414,7 @@ function App() { } setLoading(true); - const { data: proofRequest } = await axios.get(proofRequestUrl); + const proofRequest = proofRequestTemplate || (await axios.get(proofRequestUrl)).data; const controller = createVerificationController({ wallet, credentialProvider, @@ -452,9 +455,48 @@ function App() { setVerifyModalOpen(false); setVerifyStep(1); setProofRequestUrl(""); + setProofRequestTemplate(null); + setMatchingCredentialIds([]); setSelectedCredential(null); }; + const handleLoadMatchingCredentials = 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); + } + }; + + const matchingCredentials = formattedCredentials + .map((document, idx) => ({ + document, + rawDocument: documents[idx], + })) + .filter((item) => item.rawDocument && matchingCredentialIds.includes(item.rawDocument.id)); + const handleWalletKeyUpload = (event) => { const file = event.target.files[0]; if (file) { @@ -587,6 +629,8 @@ function App() { setVerifyModalOpen(true); setVerifyStep(1); setProofRequestUrl(""); + setProofRequestTemplate(null); + setMatchingCredentialIds([]); setSelectedCredential(null); }} > @@ -773,6 +817,8 @@ function App() { setVerifyModalOpen(false); setVerifyStep(1); setProofRequestUrl(""); + setProofRequestTemplate(null); + setMatchingCredentialIds([]); setSelectedCredential(null); }} > @@ -807,10 +853,10 @@ function App() {
@@ -818,18 +864,24 @@ function App() { {verifyStep === 2 && ( <>

Select Credential to Present

-
- {formattedCredentials.map((document, idx) => ( - setSelectedCredential(documents[idx])} - /> - ))} -
+ {matchingCredentials.length === 0 ? ( +
+ No matching credentials found for this proof request. +
+ ) : ( +
+ {matchingCredentials.map((item) => ( + setSelectedCredential(item.rawDocument)} + /> + ))} +
+ )}
)} + + )} + setVerifyToast((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: "bottom", horizontal: "right" }} + > + setVerifyToast((prev) => ({ ...prev, open: false }))} + > + {verifyToast.message} + + ); } From c6cc93b8225add5afd4f8372e8fda8775f72c2aa Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Thu, 19 Mar 2026 17:20:41 -0400 Subject: [PATCH 09/17] improve credential import UX in web example --- examples/web-example/src/App.js | 161 +++++++++++++++++++++++--------- 1 file changed, 115 insertions(+), 46 deletions(-) diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 5cdbcccc..21402dda 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -254,11 +254,17 @@ function App() { const [matchingCredentialIds, setMatchingCredentialIds] = useState([]); const [loadingMatchingCredentials, setLoadingMatchingCredentials] = useState(false); const [isVerifying, setIsVerifying] = useState(false); + const [isImporting, setIsImporting] = useState(false); const [verifyToast, setVerifyToast] = useState({ open: false, severity: "success", message: "", }); + const [importToast, setImportToast] = useState({ + open: false, + severity: "success", + message: "", + }); const [walletKeys, setWalletKeys] = useState(null); const [uploadError, setUploadError] = useState(null); const [settingsAnchorEl, setSettingsAnchorEl] = useState(null); @@ -334,6 +340,12 @@ function App() { setIsVerifying(false); }; + const resetImportFlow = () => { + setImportModalOpen(false); + setCredentialUrl(""); + setIsImporting(false); + }; + const handleImportCredential = async () => { if (!credentialProvider) { @@ -346,14 +358,30 @@ function App() { return; } - await credentialProvider.importCredentialFromURI({ - uri: credentialUrl, - didProvider, - }); + try { + setIsImporting(true); - refreshDocuments(); - setImportModalOpen(false); - setCredentialUrl(""); + await credentialProvider.importCredentialFromURI({ + uri: credentialUrl, + didProvider, + }); + + 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); + } }; const refreshDocuments = useCallback(async () => { @@ -398,11 +426,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(); + setImportToast({ + open: true, + severity: "success", + message: `Imported ${incomingCredentials.length} credential${incomingCredentials.length === 1 ? "" : "s"} from messages.`, }); } }); @@ -640,6 +677,7 @@ function App() { onClick={() => { setImportModalOpen(true); setCredentialUrl(""); + setIsImporting(false); }} > Import Credential @@ -794,42 +832,59 @@ function App() { {/* Import Credential Modal */} - setImportModalOpen(false)}> + { + if (isImporting) { + return; + } + resetImportFlow(); + }} + > -

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)', - }, - }, - }} - /> -
-
- - -
+ {isImporting ? ( +
+ +

Importing credential offer and processing response...

+
+ ) : ( + <> +

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)', + }, + }, + }} + /> +
+
+ + +
+ + )}
@@ -945,6 +1000,20 @@ function App() { {verifyToast.message} + setImportToast((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + > + setImportToast((prev) => ({ ...prev, open: false }))} + > + {importToast.message} + + ); } From 26e59ba14511315addf3385c0887103899d01819 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Thu, 19 Mar 2026 20:31:23 -0400 Subject: [PATCH 10/17] allow user to switch wallets in web wallet example --- examples/web-example/src/App.js | 416 ++++++++++++++++-- .../web-example/src/hooks/useCloudWallet.js | 15 +- 2 files changed, 393 insertions(+), 38 deletions(-) diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 21402dda..6e49622a 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -1,17 +1,99 @@ import React, { useState, useEffect, useCallback } from "react"; -import { Alert, Box, Button, CircularProgress, FormControlLabel, Menu, MenuItem, Modal, Snackbar, Switch, 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"; import axios from "axios"; import { setLocalStorageImpl } from "@docknetwork/wallet-sdk-data-store-web/lib/localStorageJSON"; +import { createDataStore } from "@docknetwork/wallet-sdk-data-store-web/lib/index"; import useCloudWallet from './hooks/useCloudWallet'; -import { generateCloudWalletMasterKey } from "@docknetwork/wallet-sdk-core/lib/cloud-wallet"; +import { generateCloudWalletMasterKey, initializeCloudWallet } from "@docknetwork/wallet-sdk-core/lib/cloud-wallet"; setLocalStorageImpl(global.localStorage); +const WALLET_PROFILES_KEY = "walletProfiles"; +const ACTIVE_WALLET_ID_KEY = "activeWalletId"; +const EDV_URL = "https://edv.dock.io"; +const EDV_AUTH_KEY = "DOCKWALLET-TEST"; + +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, + }; +} + +function serializeWalletKeys(keys) { + return { + masterKey: Array.from(keys.masterKey || []), + mnemonic: keys.mnemonic, + }; +} + +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, + }; +} + +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; + }, + }; +} + +function applyWalletStorageScope(walletId) { + setLocalStorageImpl(createScopedLocalStorage(walletId || 'default')); +} + +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)); +} + function humanizeKey(key) { return key .replace(/([A-Z])/g, ' $1') @@ -265,7 +347,14 @@ function App() { severity: "success", message: "", }); + const [walletToast, setWalletToast] = useState({ + open: false, + severity: "success", + message: "", + }); const [walletKeys, setWalletKeys] = useState(null); + const [walletProfiles, setWalletProfiles] = useState([]); + const [activeWalletId, setActiveWalletId] = useState(null); const [uploadError, setUploadError] = useState(null); const [settingsAnchorEl, setSettingsAnchorEl] = useState(null); const [autoCheckMessages, setAutoCheckMessages] = useState(false); @@ -282,32 +371,80 @@ function App() { p: 4, }; + const persistWalletProfiles = useCallback((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))); + } + } + }, []); + 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 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 resolvedActiveWalletId = normalizedProfiles.some((profile) => profile.id === storedActiveWalletId) + ? storedActiveWalletId + : normalizedProfiles[0].id; + const activeProfile = normalizedProfiles.find((profile) => profile.id === resolvedActiveWalletId); + + applyWalletStorageScope(resolvedActiveWalletId); + setWalletProfiles(normalizedProfiles); + setActiveWalletId(resolvedActiveWalletId); + setWalletKeys(activeProfile?.keys || null); + return; } + } - const _walletKeys = { - masterKey: new Uint8Array(masterKeyArray), - mnemonic: parsedKeys.mnemonic, - }; - - setWalletKeys(_walletKeys); + const legacyKeys = localStorage.getItem("keys"); + if (legacyKeys) { + const normalizedKeys = normalizeWalletKeys(JSON.parse(legacyKeys)); + const migratedProfiles = [createWalletProfile(normalizedKeys, 1)]; + + applyWalletStorageScope(migratedProfiles[0].id); + setWalletProfiles(migratedProfiles); + setActiveWalletId(migratedProfiles[0].id); + setWalletKeys(migratedProfiles[0].keys); + persistWalletProfiles(migratedProfiles, migratedProfiles[0].id); + } else { + applyWalletStorageScope('default'); } } catch (err) { console.error("Error fetching wallet keys:", err); } - }, []); + }, [persistWalletProfiles]); const { loading: cloudWalletLoading, @@ -318,9 +455,10 @@ function App() { defaultDID, messageProvider, provisionNewWallet, - } = useCloudWallet(walletKeys); + } = useCloudWallet(walletKeys, activeWalletId); const settingsMenuOpen = Boolean(settingsAnchorEl); + const activeWalletProfile = walletProfiles.find((profile) => profile.id === activeWalletId) || null; const handleOpenSettingsMenu = (event) => { setSettingsAnchorEl(event.currentTarget); @@ -563,10 +701,17 @@ function App() { const reader = new FileReader(); reader.onload = (e) => { try { - const keys = JSON.parse(e.target.result); - localStorage.setItem("keys", JSON.stringify(keys)); - setWalletKeys(keys); + 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."); @@ -580,25 +725,151 @@ function App() { const handleCreateWallet = async () => { setLoading(true); try { - const newKeys = await generateCloudWalletMasterKey(); + const generatedKeys = await generateCloudWalletMasterKey(); + const normalizedKeys = normalizeWalletKeys(generatedKeys); + const newProfile = createWalletProfile(normalizedKeys, walletProfiles.length + 1); + const updatedProfiles = [...walletProfiles, newProfile]; + console.log("generated new keys for the wallet"); - localStorage.setItem("keys", JSON.stringify(newKeys)); - setWalletKeys(newKeys); + 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); + setDocuments([]); + setFormattedCredentials([]); + setSelectedCredential(null); + 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 clearWalletEdvForProfile = async (profile) => { + const previousScope = activeWalletId || '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); + } + }; + + 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); + } 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); + setDocuments([]); + setFormattedCredentials([]); + persistWalletProfiles(updatedProfiles, nextActiveProfile.id); + } + + setWalletToast({ + open: true, + severity: "success", + message: `Deleted wallet \"${profile.name}\" and cleared its EDV documents.`, + }); + setLoading(false); + }; + const handleClearWallet = () => { - const currentKeysStr = localStorage.getItem("keys"); - const currentKeys = currentKeysStr ? JSON.parse(currentKeysStr) : null; + const currentProfiles = walletProfiles; + const currentActiveWalletId = activeWalletId; localStorage.clear(); - if (currentKeys) { - localStorage.setItem("keys", JSON.stringify({ - masterKey: currentKeys.masterKey, - mnemonic: currentKeys.mnemonic, - })); + if (currentProfiles.length) { + persistWalletProfiles(currentProfiles, currentActiveWalletId); } window.location.reload(); }; @@ -724,6 +995,67 @@ function App() { open={settingsMenuOpen} onClose={handleCloseSettingsMenu} > + { + handleCloseSettingsMenu(); + await handleCreateWallet(); + }} + > + Create New Wallet + + {walletProfiles.length > 0 && } + {walletProfiles.length > 0 && ( + + Switch Wallet + + )} + {walletProfiles.map((profile) => ( + { + handleCloseSettingsMenu(); + handleSwitchWallet(profile.id); + }} + > + {profile.name}{profile.id === activeWalletId ? " (Current)" : ""} + + ))} + {walletProfiles.length > 0 && } + {walletProfiles.length > 0 && ( + + Rename Wallet + + )} + {walletProfiles.map((profile) => ( + { + handleCloseSettingsMenu(); + handleRenameWallet(profile.id); + }} + > + Rename {profile.name} + + ))} + {walletProfiles.length > 0 && } + {walletProfiles.length > 0 && ( + + Delete Wallet + + )} + {walletProfiles.map((profile) => ( + { + handleCloseSettingsMenu(); + await handleDeleteWallet(profile.id); + }} + > + Delete {profile.name} + + ))} + {(walletProfiles.length > 0) && } { handleCloseSettingsMenu(); @@ -758,7 +1090,7 @@ function App() { ) : (
- Default DID: + {activeWalletProfile?.name || "Wallet"}: {defaultDID}
@@ -1014,6 +1346,20 @@ function App() { {importToast.message} + setWalletToast((prev) => ({ ...prev, open: false }))} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + > + setWalletToast((prev) => ({ ...prev, open: false }))} + > + {walletToast.message} + +
); } 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, From f5bbb2f8510f846c63c723fa506a64950e5537a0 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Tue, 24 Mar 2026 10:22:28 -0400 Subject: [PATCH 11/17] add delete credential functionality to web example --- examples/web-example/src/App.css | 37 +++++++++ examples/web-example/src/App.js | 134 ++++++++++++++++++++++++++++++- 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/examples/web-example/src/App.css b/examples/web-example/src/App.css index 6dafd8ae..c18a4876 100644 --- a/examples/web-example/src/App.css +++ b/examples/web-example/src/App.css @@ -273,6 +273,12 @@ gap: 12px; } +.credential-header-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + .credential-type-badge { font-size: 14px; font-weight: 700; @@ -300,6 +306,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; @@ -443,6 +478,8 @@ .footer-bottom { display: flex; + align-items: center; + gap: 14px; justify-content: flex-end; } diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 6e49622a..b39176d1 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -216,7 +216,7 @@ function AttributeNode({ label, value, depth = 0 }) { ); } -function CredentialCard({ document, rawDocument, selectable, selected, onClick }) { +function CredentialCard({ document, rawDocument, selectable, selected, onClick, onDelete, deleting }) { const [expanded, setExpanded] = useState(false); const [showJson, setShowJson] = useState(false); const subject = document?.credentialSubject || {}; @@ -245,9 +245,28 @@ function CredentialCard({ document, rawDocument, selectable, selected, onClick } >
{document.humanizedType || (document.type?.slice(-1)[0]) || 'Credential'} - {isExpired - ? Expired - : Valid} +
+ {isExpired + ? Expired + : Valid} + {onDelete && ( + + )} +
{showJson ? ( @@ -358,6 +377,7 @@ function App() { const [uploadError, setUploadError] = useState(null); const [settingsAnchorEl, setSettingsAnchorEl] = useState(null); const [autoCheckMessages, setAutoCheckMessages] = useState(false); + const [deletingCredentialId, setDeletingCredentialId] = useState(null); // Styles for the modals const modalStyle = { @@ -541,6 +561,110 @@ function App() { setDocuments(creds); }, [credentialProvider]); + const waitForCloudWalletSync = useCallback(async () => { + if (!cloudWallet || typeof cloudWallet.waitForEdvIdle !== 'function') { + throw new Error('Cloud wallet connection is required to safely delete credentials.'); + } + + await cloudWallet.waitForEdvIdle(); + }, [cloudWallet]); + + const hasCredentialArtifacts = useCallback(async (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); + }, [wallet]); + + const removeCredentialArtifactsFromWallet = useCallback(async (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); + } + } + }, [wallet]); + + const handleDeleteCredential = useCallback(async (credentialId) => { + if (!credentialProvider || !wallet || !credentialId) { + return; + } + + 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); + + try { + if (!cloudWallet || typeof cloudWallet.pullDocuments !== 'function') { + throw new Error('Cloud wallet is unavailable, so EDV deletion cannot be confirmed.'); + } + + await credentialProvider.removeCredential(credentialId); + await waitForCloudWalletSync(); + + // Re-pull from EDV and re-check local state to confirm the delete propagated to cloud. + await cloudWallet.pullDocuments(); + await waitForCloudWalletSync(); + + if (await hasCredentialArtifacts(credentialId)) { + await removeCredentialArtifactsFromWallet(credentialId); + await waitForCloudWalletSync(); + await cloudWallet.pullDocuments(); + await waitForCloudWalletSync(); + } + + if (await hasCredentialArtifacts(credentialId)) { + throw new Error('Credential still exists after synchronization with EDV.'); + } + + if (selectedCredential?.id === credentialId) { + setSelectedCredential(null); + } + + await refreshDocuments(); + setImportToast({ + open: true, + severity: 'success', + message: 'Credential deleted from local wallet and EDV.', + }); + } catch (err) { + console.error('Error deleting credential', err); + setImportToast({ + open: true, + severity: 'error', + message: `Delete failed: ${err?.message || 'Unable to delete credential.'}`, + }); + } finally { + setDeletingCredentialId(null); + } + }, [ + cloudWallet, + credentialProvider, + documents, + hasCredentialArtifacts, + refreshDocuments, + removeCredentialArtifactsFromWallet, + selectedCredential?.id, + waitForCloudWalletSync, + wallet, + ]); + const handleFetchMessages = useCallback(async () => { if (!messageProvider) { return; @@ -1156,6 +1280,8 @@ function App() { key={document.id} document={document} rawDocument={documents[idx] || document} + onDelete={handleDeleteCredential} + deleting={deletingCredentialId === document.id} /> ))}
From 0f7c2f3443ce32671eefbe8fb1b43f818d184d87 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 25 Mar 2026 15:17:18 -0400 Subject: [PATCH 12/17] allow switching wallet from dropdown --- examples/web-example/src/App.css | 41 ++++++++++++++++++++++++++++++++ examples/web-example/src/App.js | 20 +++++++++++++--- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/examples/web-example/src/App.css b/examples/web-example/src/App.css index c18a4876..4c0692af 100644 --- a/examples/web-example/src/App.css +++ b/examples/web-example/src/App.css @@ -146,6 +146,36 @@ 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; @@ -677,6 +707,17 @@ 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; } diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index b39176d1..303036ae 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -478,8 +478,6 @@ function App() { } = useCloudWallet(walletKeys, activeWalletId); const settingsMenuOpen = Boolean(settingsAnchorEl); - const activeWalletProfile = walletProfiles.find((profile) => profile.id === activeWalletId) || null; - const handleOpenSettingsMenu = (event) => { setSettingsAnchorEl(event.currentTarget); }; @@ -1214,7 +1212,23 @@ function App() { ) : (
- {activeWalletProfile?.name || "Wallet"}: +
+ + +
{defaultDID}
From b2d721dd0478f7b9b128a4accb88f3f9c8d68880 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 25 Mar 2026 16:36:33 -0400 Subject: [PATCH 13/17] extract components in web example --- examples/web-example/src/App.js | 606 ++---------------- .../src/components/ActionButtons.js | 50 ++ .../src/components/CredentialCard.js | 242 +++++++ .../src/components/CredentialsSection.js | 35 + .../web-example/src/components/DidSection.js | 105 +++ .../src/components/ImportCredentialModal.js | 72 +++ .../src/components/VerifyCredentialModal.js | 122 ++++ 7 files changed, 688 insertions(+), 544 deletions(-) create mode 100644 examples/web-example/src/components/ActionButtons.js create mode 100644 examples/web-example/src/components/CredentialCard.js create mode 100644 examples/web-example/src/components/CredentialsSection.js create mode 100644 examples/web-example/src/components/DidSection.js create mode 100644 examples/web-example/src/components/ImportCredentialModal.js create mode 100644 examples/web-example/src/components/VerifyCredentialModal.js diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 303036ae..5be62d78 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -9,6 +9,11 @@ import { createDataStore } from "@docknetwork/wallet-sdk-data-store-web/lib/inde import useCloudWallet from './hooks/useCloudWallet'; import { generateCloudWalletMasterKey, initializeCloudWallet } from "@docknetwork/wallet-sdk-core/lib/cloud-wallet"; +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"; setLocalStorageImpl(global.localStorage); @@ -94,252 +99,7 @@ function clearScopedWalletStorage(walletId) { scopedKeys.forEach((key) => localStorage.removeItem(key)); } -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 FetchMessagesIcon({ className, title }) { - return ( - - ); -} - -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} -
-
-
- -
-
-
- ); -} function App() { const [loading, setLoading] = useState(false); @@ -1063,55 +823,24 @@ function App() { {/* Action Buttons */} -
- - - - -
+ { + setImportModalOpen(true); + setCredentialUrl(""); + setIsImporting(false); + }} + onVerifyClick={() => { + setVerifyModalOpen(true); + setVerifyStep(1); + setProofRequestUrl(""); + setProofRequestTemplate(null); + setMatchingCredentialIds([]); + setSelectedCredential(null); + setIsVerifying(false); + }} + onRefreshClick={refreshDocuments} + onSettingsClick={handleOpenSettingsMenu} + /> {/* DID Management */} -
- {!defaultDID ? ( -
- -
- ) : ( -
-
-
- - -
- {defaultDID} -
-
- -
-
- - setAutoCheckMessages(event.target.checked)} - /> - )} - label="Auto-check every 30s" - /> -
-
-
-
- )} -
+ {/* Credentials List */} -
-

Credentials ({formattedCredentials.length})

- - {formattedCredentials.length === 0 ? ( -
- No credentials found. Import some credentials to get started. -
- ) : ( -
- {formattedCredentials.map((document, idx) => ( - - ))} -
- )} -
+
{/* Import Credential Modal */} - { - if (isImporting) { - return; - } - resetImportFlow(); - }} - > - - {isImporting ? ( -
- -

Importing credential offer and processing response...

-
- ) : ( - <> -

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)', - }, - }, - }} - /> -
-
- - -
- - )} -
-
+ isImporting={isImporting} + credentialUrl={credentialUrl} + onCredentialUrlChange={setCredentialUrl} + onImport={handleImportCredential} + onClose={resetImportFlow} + modalStyle={modalStyle} + /> {/* Verify Credential Modal */} - { - if (isVerifying) { - return; - } - resetVerifyFlow(); - }} - > - - {isVerifying ? ( -
- -

Checking credential and submitting verification...

-
- ) : ( - <> - {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

- {matchingCredentials.length === 0 ? ( -
- No matching credentials found for this proof request. -
- ) : ( -
- {matchingCredentials.map((item) => ( - setSelectedCredential(item.rawDocument)} - /> - ))} -
- )} -
- - -
- - )} - - )} -
-
+ isVerifying={isVerifying} + verifyStep={verifyStep} + proofRequestUrl={proofRequestUrl} + loadingMatchingCredentials={loadingMatchingCredentials} + matchingCredentials={matchingCredentials} + selectedCredential={selectedCredential} + onProofRequestUrlChange={setProofRequestUrl} + onLoadMatchingCredentials={handleLoadMatchingCredentials} + onVerifyCredential={handleVerifyCredential} + onBackStep={() => setVerifyStep(1)} + onClose={resetVerifyFlow} + onSelectCredential={setSelectedCredential} + modalStyle={modalStyle} + /> + + + + +
+ ); +} + +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; From 8eb8944e5ba5c75b49462096b87c5c20854f4d2f Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 25 Mar 2026 16:44:30 -0400 Subject: [PATCH 14/17] extract hooks in web example --- examples/web-example/src/App.js | 261 ++++-------------- .../web-example/src/hooks/useModalFlows.js | 216 +++++++++++++++ 2 files changed, 265 insertions(+), 212 deletions(-) create mode 100644 examples/web-example/src/hooks/useModalFlows.js diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 5be62d78..86fccf04 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -14,6 +14,7 @@ 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); @@ -105,27 +106,6 @@ 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 [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 [isImporting, setIsImporting] = useState(false); - const [verifyToast, setVerifyToast] = useState({ - open: false, - severity: "success", - message: "", - }); - const [importToast, setImportToast] = useState({ - open: false, - severity: "success", - message: "", - }); const [walletToast, setWalletToast] = useState({ open: false, severity: "success", @@ -237,69 +217,6 @@ function App() { provisionNewWallet, } = useCloudWallet(walletKeys, activeWalletId); - const settingsMenuOpen = Boolean(settingsAnchorEl); - const handleOpenSettingsMenu = (event) => { - setSettingsAnchorEl(event.currentTarget); - }; - - const handleCloseSettingsMenu = () => { - setSettingsAnchorEl(null); - }; - - const resetVerifyFlow = () => { - setVerifyModalOpen(false); - setVerifyStep(1); - setProofRequestUrl(""); - setProofRequestTemplate(null); - setMatchingCredentialIds([]); - setSelectedCredential(null); - setIsVerifying(false); - }; - - const resetImportFlow = () => { - setImportModalOpen(false); - setCredentialUrl(""); - setIsImporting(false); - }; - - - 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; - } - - try { - setIsImporting(true); - - await credentialProvider.importCredentialFromURI({ - uri: credentialUrl, - didProvider, - }); - - 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); - } - }; - const refreshDocuments = useCallback(async () => { if (!credentialProvider) { return; @@ -319,6 +236,19 @@ function App() { setDocuments(creds); }, [credentialProvider]); + // Extract modal flows using custom hooks + const importFlow = useImportFlow(credentialProvider, didProvider, refreshDocuments); + const verifyFlow = useVerifyFlow(wallet, credentialProvider, didProvider); + + const settingsMenuOpen = Boolean(settingsAnchorEl); + const handleOpenSettingsMenu = (event) => { + setSettingsAnchorEl(event.currentTarget); + }; + + const handleCloseSettingsMenu = () => { + setSettingsAnchorEl(null); + }; + const waitForCloudWalletSync = useCallback(async () => { if (!cloudWallet || typeof cloudWallet.waitForEdvIdle !== 'function') { throw new Error('Cloud wallet connection is required to safely delete credentials.'); @@ -456,7 +386,7 @@ function App() { incomingCredentials.map((credential) => credentialProvider.addCredential(credential)) ); await refreshDocuments(); - setImportToast({ + importFlow.setImportToast({ open: true, severity: "success", message: `Imported ${incomingCredentials.length} credential${incomingCredentials.length === 1 ? "" : "s"} from messages.`, @@ -481,100 +411,12 @@ function App() { return () => window.clearInterval(intervalId); }, [autoCheckMessages, defaultDID, messageProvider, handleFetchMessages]); - const handleVerifyCredential = async () => { - if (!wallet || !credentialProvider || !didProvider) { - 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(); - } - }; - - const handleLoadMatchingCredentials = 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); - } - }; - const matchingCredentials = formattedCredentials .map((document, idx) => ({ document, rawDocument: documents[idx], })) - .filter((item) => item.rawDocument && matchingCredentialIds.includes(item.rawDocument.id)); + .filter((item) => item.rawDocument && verifyFlow.matchingCredentialIds.includes(item.rawDocument.id)); const handleWalletKeyUpload = (event) => { const file = event.target.files[0]; @@ -825,18 +667,13 @@ function App() { {/* Action Buttons */} { - setImportModalOpen(true); - setCredentialUrl(""); - setIsImporting(false); + importFlow.setImportModalOpen(true); + importFlow.setCredentialUrl(""); }} onVerifyClick={() => { - setVerifyModalOpen(true); - setVerifyStep(1); - setProofRequestUrl(""); - setProofRequestTemplate(null); - setMatchingCredentialIds([]); - setSelectedCredential(null); - setIsVerifying(false); + verifyFlow.setVerifyModalOpen(true); + verifyFlow.setVerifyStep(1); + verifyFlow.setProofRequestUrl(""); }} onRefreshClick={refreshDocuments} onSettingsClick={handleOpenSettingsMenu} @@ -950,58 +787,58 @@ function App() { {/* Import Credential Modal */} {/* Verify Credential Modal */} setVerifyStep(1)} - onClose={resetVerifyFlow} - onSelectCredential={setSelectedCredential} + selectedCredential={verifyFlow.selectedCredential} + onProofRequestUrlChange={verifyFlow.setProofRequestUrl} + onLoadMatchingCredentials={verifyFlow.handleLoadMatchingCredentials} + onVerifyCredential={verifyFlow.handleVerifyCredential} + onBackStep={() => verifyFlow.setVerifyStep(1)} + onClose={verifyFlow.resetVerifyFlow} + onSelectCredential={verifyFlow.setSelectedCredential} modalStyle={modalStyle} /> setVerifyToast((prev) => ({ ...prev, open: false }))} + onClose={() => verifyFlow.setVerifyToast((prev) => ({ ...prev, open: false }))} anchorOrigin={{ vertical: "bottom", horizontal: "right" }} > setVerifyToast((prev) => ({ ...prev, open: false }))} + onClose={() => verifyFlow.setVerifyToast((prev) => ({ ...prev, open: false }))} > - {verifyToast.message} + {verifyFlow.verifyToast.message} setImportToast((prev) => ({ ...prev, open: false }))} + onClose={() => importFlow.setImportToast((prev) => ({ ...prev, open: false }))} anchorOrigin={{ vertical: "bottom", horizontal: "left" }} > setImportToast((prev) => ({ ...prev, open: false }))} + onClose={() => importFlow.setImportToast((prev) => ({ ...prev, open: false }))} > - {importToast.message} + {importFlow.importToast.message} { + 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, + }; +} From 2b1eb5bfcf1d7368199e1876d7d03321e91e4c70 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 25 Mar 2026 16:59:51 -0400 Subject: [PATCH 15/17] pull wallet and credential management out into hooks --- examples/web-example/src/App.js | 548 ++---------------- .../src/hooks/useCredentialManagement.js | 116 ++++ .../web-example/src/hooks/useWalletManager.js | 368 ++++++++++++ 3 files changed, 536 insertions(+), 496 deletions(-) create mode 100644 examples/web-example/src/hooks/useCredentialManagement.js create mode 100644 examples/web-example/src/hooks/useWalletManager.js diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 86fccf04..352a6b38 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -8,7 +8,8 @@ import { setLocalStorageImpl } from "@docknetwork/wallet-sdk-data-store-web/lib/ import { createDataStore } from "@docknetwork/wallet-sdk-data-store-web/lib/index"; import useCloudWallet from './hooks/useCloudWallet'; -import { generateCloudWalletMasterKey, initializeCloudWallet } 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"; @@ -19,105 +20,16 @@ import { useImportFlow, useVerifyFlow } from "./hooks/useModalFlows"; setLocalStorageImpl(global.localStorage); -const WALLET_PROFILES_KEY = "walletProfiles"; -const ACTIVE_WALLET_ID_KEY = "activeWalletId"; -const EDV_URL = "https://edv.dock.io"; -const EDV_AUTH_KEY = "DOCKWALLET-TEST"; - -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, - }; -} - -function serializeWalletKeys(keys) { - return { - masterKey: Array.from(keys.masterKey || []), - mnemonic: keys.mnemonic, - }; -} - -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, - }; -} - -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; - }, - }; -} - -function applyWalletStorageScope(walletId) { - setLocalStorageImpl(createScopedLocalStorage(walletId || 'default')); -} - -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)); -} - function App() { - const [loading, setLoading] = useState(false); const [documents, setDocuments] = useState([]); const [formattedCredentials, setFormattedCredentials] = useState([]); - const [walletToast, setWalletToast] = useState({ - open: false, - severity: "success", - message: "", - }); - const [walletKeys, setWalletKeys] = useState(null); - const [walletProfiles, setWalletProfiles] = useState([]); - const [activeWalletId, setActiveWalletId] = useState(null); - const [uploadError, setUploadError] = useState(null); const [settingsAnchorEl, setSettingsAnchorEl] = useState(null); const [autoCheckMessages, setAutoCheckMessages] = useState(false); - const [deletingCredentialId, setDeletingCredentialId] = useState(null); + + // Initialize wallet management + const walletManager = useWalletManager(); // Styles for the modals const modalStyle = { @@ -131,81 +43,6 @@ function App() { p: 4, }; - const persistWalletProfiles = useCallback((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))); - } - } - }, []); - - useEffect(() => { - 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 resolvedActiveWalletId = normalizedProfiles.some((profile) => profile.id === storedActiveWalletId) - ? storedActiveWalletId - : normalizedProfiles[0].id; - const activeProfile = normalizedProfiles.find((profile) => profile.id === resolvedActiveWalletId); - - applyWalletStorageScope(resolvedActiveWalletId); - setWalletProfiles(normalizedProfiles); - setActiveWalletId(resolvedActiveWalletId); - setWalletKeys(activeProfile?.keys || null); - return; - } - } - - const legacyKeys = localStorage.getItem("keys"); - if (legacyKeys) { - const normalizedKeys = normalizeWalletKeys(JSON.parse(legacyKeys)); - const migratedProfiles = [createWalletProfile(normalizedKeys, 1)]; - - applyWalletStorageScope(migratedProfiles[0].id); - setWalletProfiles(migratedProfiles); - setActiveWalletId(migratedProfiles[0].id); - setWalletKeys(migratedProfiles[0].keys); - persistWalletProfiles(migratedProfiles, migratedProfiles[0].id); - } else { - applyWalletStorageScope('default'); - } - } catch (err) { - console.error("Error fetching wallet keys:", err); - } - }, [persistWalletProfiles]); - const { loading: cloudWalletLoading, cloudWallet, @@ -215,7 +52,7 @@ function App() { defaultDID, messageProvider, provisionNewWallet, - } = useCloudWallet(walletKeys, activeWalletId); + } = useCloudWallet(walletManager.walletKeys, walletManager.activeWalletId); const refreshDocuments = useCallback(async () => { if (!credentialProvider) { @@ -240,118 +77,14 @@ function App() { const importFlow = useImportFlow(credentialProvider, didProvider, refreshDocuments); const verifyFlow = useVerifyFlow(wallet, credentialProvider, didProvider); - const settingsMenuOpen = Boolean(settingsAnchorEl); - const handleOpenSettingsMenu = (event) => { - setSettingsAnchorEl(event.currentTarget); - }; - - const handleCloseSettingsMenu = () => { - setSettingsAnchorEl(null); - }; - - const waitForCloudWalletSync = useCallback(async () => { - if (!cloudWallet || typeof cloudWallet.waitForEdvIdle !== 'function') { - throw new Error('Cloud wallet connection is required to safely delete credentials.'); - } - - await cloudWallet.waitForEdvIdle(); - }, [cloudWallet]); - - const hasCredentialArtifacts = useCallback(async (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); - }, [wallet]); - - const removeCredentialArtifactsFromWallet = useCallback(async (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); - } - } - }, [wallet]); - - const handleDeleteCredential = useCallback(async (credentialId) => { - if (!credentialProvider || !wallet || !credentialId) { - return; - } - - 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); - - try { - if (!cloudWallet || typeof cloudWallet.pullDocuments !== 'function') { - throw new Error('Cloud wallet is unavailable, so EDV deletion cannot be confirmed.'); - } - - await credentialProvider.removeCredential(credentialId); - await waitForCloudWalletSync(); - - // Re-pull from EDV and re-check local state to confirm the delete propagated to cloud. - await cloudWallet.pullDocuments(); - await waitForCloudWalletSync(); - - if (await hasCredentialArtifacts(credentialId)) { - await removeCredentialArtifactsFromWallet(credentialId); - await waitForCloudWalletSync(); - await cloudWallet.pullDocuments(); - await waitForCloudWalletSync(); - } - - if (await hasCredentialArtifacts(credentialId)) { - throw new Error('Credential still exists after synchronization with EDV.'); - } - - if (selectedCredential?.id === credentialId) { - setSelectedCredential(null); - } - - await refreshDocuments(); - setImportToast({ - open: true, - severity: 'success', - message: 'Credential deleted from local wallet and EDV.', - }); - } catch (err) { - console.error('Error deleting credential', err); - setImportToast({ - open: true, - severity: 'error', - message: `Delete failed: ${err?.message || 'Unable to delete credential.'}`, - }); - } finally { - setDeletingCredentialId(null); - } - }, [ - cloudWallet, + // Extract credential management + const credentialManagement = useCredentialManagement( credentialProvider, - documents, - hasCredentialArtifacts, - refreshDocuments, - removeCredentialArtifactsFromWallet, - selectedCredential?.id, - waitForCloudWalletSync, wallet, - ]); + cloudWallet, + documents, + refreshDocuments + ); const handleFetchMessages = useCallback(async () => { if (!messageProvider) { @@ -418,201 +151,24 @@ function App() { })) .filter((item) => item.rawDocument && verifyFlow.matchingCredentialIds.includes(item.rawDocument.id)); - 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 generatedKeys = await generateCloudWalletMasterKey(); - const normalizedKeys = normalizeWalletKeys(generatedKeys); - 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); - setDocuments([]); - setFormattedCredentials([]); - setSelectedCredential(null); - 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 clearWalletEdvForProfile = async (profile) => { - const previousScope = activeWalletId || '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); - } - }; - - 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); - } 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); - setDocuments([]); - setFormattedCredentials([]); - 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(); + const settingsMenuOpen = Boolean(settingsAnchorEl); + const handleOpenSettingsMenu = (event) => { + setSettingsAnchorEl(event.currentTarget); }; - const handleClearEdv = () => { - if (cloudWallet) { - cloudWallet.clearEdvDocuments(); - } + 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 (
@@ -622,13 +178,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}
}
@@ -686,68 +242,68 @@ function App() { { handleCloseSettingsMenu(); - await handleCreateWallet(); + await walletManager.handleCreateWallet(); }} > Create New Wallet - {walletProfiles.length > 0 && } - {walletProfiles.length > 0 && ( + {walletManager.walletProfiles.length > 0 && } + {walletManager.walletProfiles.length > 0 && ( Switch Wallet )} - {walletProfiles.map((profile) => ( + {walletManager.walletProfiles.map((profile) => ( { handleCloseSettingsMenu(); - handleSwitchWallet(profile.id); + walletManager.handleSwitchWallet(profile.id); }} > - {profile.name}{profile.id === activeWalletId ? " (Current)" : ""} + {profile.name}{profile.id === walletManager.activeWalletId ? " (Current)" : ""} ))} - {walletProfiles.length > 0 && } - {walletProfiles.length > 0 && ( + {walletManager.walletProfiles.length > 0 && } + {walletManager.walletProfiles.length > 0 && ( Rename Wallet )} - {walletProfiles.map((profile) => ( + {walletManager.walletProfiles.map((profile) => ( { handleCloseSettingsMenu(); - handleRenameWallet(profile.id); + walletManager.handleRenameWallet(profile.id); }} > Rename {profile.name} ))} - {walletProfiles.length > 0 && } - {walletProfiles.length > 0 && ( + {walletManager.walletProfiles.length > 0 && } + {walletManager.walletProfiles.length > 0 && ( Delete Wallet )} - {walletProfiles.map((profile) => ( + {walletManager.walletProfiles.map((profile) => ( { handleCloseSettingsMenu(); - await handleDeleteWallet(profile.id); + await walletManager.handleDeleteWallet(profile.id); }} > Delete {profile.name} ))} - {(walletProfiles.length > 0) && } + {(walletManager.walletProfiles.length > 0) && } { handleCloseSettingsMenu(); - handleClearWallet(); + walletManager.handleClearWallet(); }} > Clear Wallet @@ -756,7 +312,7 @@ function App() { { handleCloseSettingsMenu(); - handleClearEdv(); + cloudWallet.clearEdvDocuments(); }} > Clear EDV @@ -767,11 +323,11 @@ function App() { {/* DID Management */} @@ -780,8 +336,8 @@ function App() {
@@ -842,17 +398,17 @@ function App() { setWalletToast((prev) => ({ ...prev, open: false }))} + onClose={() => walletManager.setWalletToast((prev) => ({ ...prev, open: false }))} anchorOrigin={{ vertical: "top", horizontal: "center" }} > setWalletToast((prev) => ({ ...prev, open: false }))} + onClose={() => walletManager.setWalletToast((prev) => ({ ...prev, open: false }))} > - {walletToast.message} + {walletManager.walletToast.message}
diff --git a/examples/web-example/src/hooks/useCredentialManagement.js b/examples/web-example/src/hooks/useCredentialManagement.js new file mode 100644 index 00000000..36ae9d23 --- /dev/null +++ b/examples/web-example/src/hooks/useCredentialManagement.js @@ -0,0 +1,116 @@ +import { useState, useCallback } from 'react'; + +export function useCredentialManagement( + credentialProvider, + wallet, + cloudWallet, + documents, + refreshDocuments +) { + const [deletingCredentialId, setDeletingCredentialId] = useState(null); + + const waitForCloudWalletSync = useCallback(async () => { + if (!cloudWallet || typeof cloudWallet.waitForEdvIdle !== 'function') { + throw new Error('Cloud wallet connection is required to safely delete credentials.'); + } + + await cloudWallet.waitForEdvIdle(); + }, [cloudWallet]); + + const hasCredentialArtifacts = useCallback(async (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); + }, [wallet]); + + const removeCredentialArtifactsFromWallet = useCallback(async (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); + } + } + }, [wallet]); + + const handleDeleteCredential = useCallback(async (credentialId) => { + if (!credentialProvider || !wallet || !credentialId) { + return; + } + + 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); + + try { + if (!cloudWallet || typeof cloudWallet.pullDocuments !== 'function') { + throw new Error('Cloud wallet is unavailable, so EDV deletion cannot be confirmed.'); + } + + await credentialProvider.removeCredential(credentialId); + await waitForCloudWalletSync(); + + // Re-pull from EDV and re-check local state to confirm the delete propagated to cloud. + await cloudWallet.pullDocuments(); + await waitForCloudWalletSync(); + + if (await hasCredentialArtifacts(credentialId)) { + await removeCredentialArtifactsFromWallet(credentialId); + await waitForCloudWalletSync(); + await cloudWallet.pullDocuments(); + await waitForCloudWalletSync(); + } + + if (await hasCredentialArtifacts(credentialId)) { + throw new Error('Credential still exists after synchronization with EDV.'); + } + + await refreshDocuments(); + + 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.'}`, + }; + } finally { + setDeletingCredentialId(null); + } + }, [ + cloudWallet, + credentialProvider, + documents, + hasCredentialArtifacts, + refreshDocuments, + removeCredentialArtifactsFromWallet, + waitForCloudWalletSync, + wallet, + ]); + + return { + // State + deletingCredentialId, + // Handlers + handleDeleteCredential, + }; +} diff --git a/examples/web-example/src/hooks/useWalletManager.js b/examples/web-example/src/hooks/useWalletManager.js new file mode 100644 index 00000000..176c6ce9 --- /dev/null +++ b/examples/web-example/src/hooks/useWalletManager.js @@ -0,0 +1,368 @@ +import { useState, useEffect, useCallback } from 'react'; +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'; + +const WALLET_PROFILES_KEY = 'walletProfiles'; +const ACTIVE_WALLET_ID_KEY = 'activeWalletId'; +const EDV_URL = 'https://edv.dock.io'; +const EDV_AUTH_KEY = 'DOCKWALLET-TEST'; + +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, + }; +} + +function serializeWalletKeys(keys) { + return { + masterKey: Array.from(keys.masterKey || []), + mnemonic: keys.mnemonic, + }; +} + +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, + }; +} + +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; + }, + }; +} + +function applyWalletStorageScope(walletId) { + setLocalStorageImpl(createScopedLocalStorage(walletId || 'default')); +} + +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 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: '', + }); + + const persistWalletProfiles = useCallback((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))); + } + } + }, []); + + // Initialize wallets from localStorage + useEffect(() => { + 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 resolvedActiveWalletId = normalizedProfiles.some((profile) => profile.id === storedActiveWalletId) + ? storedActiveWalletId + : normalizedProfiles[0].id; + const activeProfile = normalizedProfiles.find((profile) => profile.id === resolvedActiveWalletId); + + applyWalletStorageScope(resolvedActiveWalletId); + setWalletProfiles(normalizedProfiles); + setActiveWalletId(resolvedActiveWalletId); + setWalletKeys(activeProfile?.keys || null); + return; + } + } + + const legacyKeys = localStorage.getItem('keys'); + if (legacyKeys) { + const normalizedKeys = normalizeWalletKeys(JSON.parse(legacyKeys)); + const migratedProfiles = [createWalletProfile(normalizedKeys, 1)]; + + applyWalletStorageScope(migratedProfiles[0].id); + setWalletProfiles(migratedProfiles); + setActiveWalletId(migratedProfiles[0].id); + setWalletKeys(migratedProfiles[0].keys); + persistWalletProfiles(migratedProfiles, migratedProfiles[0].id); + } else { + applyWalletStorageScope('default'); + } + } catch (err) { + console.error('Error fetching wallet keys:', err); + } + }, [persistWalletProfiles]); + + 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 generatedKeys = await generateCloudWalletMasterKey(); + const normalizedKeys = normalizeWalletKeys(generatedKeys); + 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 clearWalletEdvForProfile = async (profile) => { + const previousScope = activeWalletId || '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); + } + }; + + 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); + } 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, + }; +} From a7bd3257570d4d8c70806c18141300511dada5d8 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 25 Mar 2026 17:07:19 -0400 Subject: [PATCH 16/17] switch to services rather than large hooks --- examples/web-example/src/App.js | 15 +- .../src/hooks/useCredentialManagement.js | 97 ++------ .../web-example/src/hooks/useWalletManager.js | 212 ++---------------- .../src/services/credentialService.js | 70 ++++++ .../web-example/src/services/walletService.js | 190 ++++++++++++++++ 5 files changed, 311 insertions(+), 273 deletions(-) create mode 100644 examples/web-example/src/services/credentialService.js create mode 100644 examples/web-example/src/services/walletService.js diff --git a/examples/web-example/src/App.js b/examples/web-example/src/App.js index 352a6b38..44dfdf3b 100644 --- a/examples/web-example/src/App.js +++ b/examples/web-example/src/App.js @@ -5,7 +5,6 @@ import { createVerificationController } from "@docknetwork/wallet-sdk-core/lib/v import { getVCData } from "@docknetwork/prettyvc"; import axios from "axios"; import { setLocalStorageImpl } from "@docknetwork/wallet-sdk-data-store-web/lib/localStorageJSON"; -import { createDataStore } from "@docknetwork/wallet-sdk-data-store-web/lib/index"; import useCloudWallet from './hooks/useCloudWallet'; import { useWalletManager } from './hooks/useWalletManager'; @@ -411,6 +410,20 @@ function App() { {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/hooks/useCredentialManagement.js b/examples/web-example/src/hooks/useCredentialManagement.js index 36ae9d23..eb25a2fd 100644 --- a/examples/web-example/src/hooks/useCredentialManagement.js +++ b/examples/web-example/src/hooks/useCredentialManagement.js @@ -1,4 +1,5 @@ import { useState, useCallback } from 'react'; +import { deleteCredential } from '../services/credentialService'; export function useCredentialManagement( credentialProvider, @@ -8,44 +9,13 @@ export function useCredentialManagement( refreshDocuments ) { const [deletingCredentialId, setDeletingCredentialId] = useState(null); - - const waitForCloudWalletSync = useCallback(async () => { - if (!cloudWallet || typeof cloudWallet.waitForEdvIdle !== 'function') { - throw new Error('Cloud wallet connection is required to safely delete credentials.'); - } - - await cloudWallet.waitForEdvIdle(); - }, [cloudWallet]); - - const hasCredentialArtifacts = useCallback(async (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); - }, [wallet]); - - const removeCredentialArtifactsFromWallet = useCallback(async (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); - } - } - }, [wallet]); + const [credentialToast, setCredentialToast] = useState({ + open: false, + severity: 'success', + message: '', + }); const handleDeleteCredential = useCallback(async (credentialId) => { - if (!credentialProvider || !wallet || !credentialId) { - return; - } - const credential = documents.find((doc) => doc?.id === credentialId); const credentialLabel = credential?.id || credentialId; const confirmed = window.confirm( @@ -57,59 +27,22 @@ export function useCredentialManagement( } setDeletingCredentialId(credentialId); + const result = await deleteCredential({ credentialProvider, wallet, cloudWallet, credentialId }); + setDeletingCredentialId(null); - try { - if (!cloudWallet || typeof cloudWallet.pullDocuments !== 'function') { - throw new Error('Cloud wallet is unavailable, so EDV deletion cannot be confirmed.'); - } - - await credentialProvider.removeCredential(credentialId); - await waitForCloudWalletSync(); - - // Re-pull from EDV and re-check local state to confirm the delete propagated to cloud. - await cloudWallet.pullDocuments(); - await waitForCloudWalletSync(); - - if (await hasCredentialArtifacts(credentialId)) { - await removeCredentialArtifactsFromWallet(credentialId); - await waitForCloudWalletSync(); - await cloudWallet.pullDocuments(); - await waitForCloudWalletSync(); - } - - if (await hasCredentialArtifacts(credentialId)) { - throw new Error('Credential still exists after synchronization with EDV.'); - } - + if (result.success) { await refreshDocuments(); - - 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.'}`, - }; - } finally { - setDeletingCredentialId(null); } - }, [ - cloudWallet, - credentialProvider, - documents, - hasCredentialArtifacts, - refreshDocuments, - removeCredentialArtifactsFromWallet, - waitForCloudWalletSync, - wallet, - ]); + + 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/useWalletManager.js b/examples/web-example/src/hooks/useWalletManager.js index 176c6ce9..506cf366 100644 --- a/examples/web-example/src/hooks/useWalletManager.js +++ b/examples/web-example/src/hooks/useWalletManager.js @@ -1,88 +1,16 @@ -import { useState, useEffect, useCallback } from 'react'; -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'; - -const WALLET_PROFILES_KEY = 'walletProfiles'; -const ACTIVE_WALLET_ID_KEY = 'activeWalletId'; -const EDV_URL = 'https://edv.dock.io'; -const EDV_AUTH_KEY = 'DOCKWALLET-TEST'; - -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, - }; -} - -function serializeWalletKeys(keys) { - return { - masterKey: Array.from(keys.masterKey || []), - mnemonic: keys.mnemonic, - }; -} - -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, - }; -} - -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; - }, - }; -} - -function applyWalletStorageScope(walletId) { - setLocalStorageImpl(createScopedLocalStorage(walletId || 'default')); -} - -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)); -} +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); @@ -96,81 +24,14 @@ export function useWalletManager() { message: '', }); - const persistWalletProfiles = useCallback((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))); - } - } - }, []); - - // Initialize wallets from localStorage useEffect(() => { - 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 resolvedActiveWalletId = normalizedProfiles.some((profile) => profile.id === storedActiveWalletId) - ? storedActiveWalletId - : normalizedProfiles[0].id; - const activeProfile = normalizedProfiles.find((profile) => profile.id === resolvedActiveWalletId); - - applyWalletStorageScope(resolvedActiveWalletId); - setWalletProfiles(normalizedProfiles); - setActiveWalletId(resolvedActiveWalletId); - setWalletKeys(activeProfile?.keys || null); - return; - } - } - - const legacyKeys = localStorage.getItem('keys'); - if (legacyKeys) { - const normalizedKeys = normalizeWalletKeys(JSON.parse(legacyKeys)); - const migratedProfiles = [createWalletProfile(normalizedKeys, 1)]; - - applyWalletStorageScope(migratedProfiles[0].id); - setWalletProfiles(migratedProfiles); - setActiveWalletId(migratedProfiles[0].id); - setWalletKeys(migratedProfiles[0].keys); - persistWalletProfiles(migratedProfiles, migratedProfiles[0].id); - } else { - applyWalletStorageScope('default'); - } - } catch (err) { - console.error('Error fetching wallet keys:', err); + const { profiles, activeWalletId: resolvedId } = loadInitialWalletState(); + if (profiles.length) { + setWalletProfiles(profiles); + setActiveWalletId(resolvedId); + setWalletKeys(profiles.find((p) => p.id === resolvedId)?.keys || null); } - }, [persistWalletProfiles]); + }, []); const handleWalletKeyUpload = (event) => { const file = event.target.files[0]; @@ -203,8 +64,7 @@ export function useWalletManager() { const handleCreateWallet = async () => { setLoading(true); try { - const generatedKeys = await generateCloudWalletMasterKey(); - const normalizedKeys = normalizeWalletKeys(generatedKeys); + const normalizedKeys = await generateNewWalletKeys(); const newProfile = createWalletProfile(normalizedKeys, walletProfiles.length + 1); const updatedProfiles = [...walletProfiles, newProfile]; @@ -249,35 +109,7 @@ export function useWalletManager() { setWalletProfiles(updatedProfiles); persistWalletProfiles(updatedProfiles, activeWalletId); - setWalletToast({ - open: true, - severity: 'success', - message: `Renamed wallet to "${nextName}".`, - }); - }; - - const clearWalletEdvForProfile = async (profile) => { - const previousScope = activeWalletId || '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); - } + setWalletToast({ open: true, severity: 'success', message: `Renamed wallet to "${nextName}".` }); }; const handleDeleteWallet = async (walletId) => { @@ -295,7 +127,7 @@ export function useWalletManager() { setLoading(true); try { - await clearWalletEdvForProfile(profile); + await clearWalletEdvForProfile(profile, activeWalletId); } catch (err) { console.error('Error clearing EDV for wallet', err); setWalletToast({ 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); + } +} From a324d5f1568df30e94af16aa102914ab12657d6e Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Thu, 2 Apr 2026 12:03:43 -0300 Subject: [PATCH 17/17] Fix e2e tests to match updated web wallet UI --- .../e2e/credential-management.spec.ts | 13 ++++----- .../web-example/e2e/wallet-creation.spec.ts | 29 ++++++++++--------- 2 files changed, 20 insertions(+), 22 deletions(-) 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();