diff --git a/docs/api/encryption.md b/docs/api/encryption.md index fa8f0f8..9ebbf9d 100644 --- a/docs/api/encryption.md +++ b/docs/api/encryption.md @@ -63,3 +63,31 @@ Returns: `Promise` (base64) - AES-256-GCM provides both confidentiality and integrity (authenticated encryption) - Never store the passphrase alongside the ciphertext - For production use, consider hardware wallet signing instead of local key storage + +--- + +## Encrypted Template Export Format (Issue #178) + +Transaction template export/import uses `encrypt()` / `decrypt()` and produces a portable JSON file. + +**File shape (v1):** + +```json +{ + "format": "stellar-dev-dashboard.transaction-templates", + "version": 1, + "encrypted": { "ciphertext": "...", "iv": "...", "salt": "..." }, + "exportedAt": "2026-05-29T12:34:56.000Z" +} +``` + +Decrypted plaintext is a JSON pack: + +```json +{ "version": 1, "templates": [/*...*/], "updatedAt": "..." } +``` + +**Storage-at-rest** + +Locally saved templates are stored encrypted in IndexedDB via `setEncryptedValue()` / `getEncryptedValue()` in `src/lib/storage.js`. +No plaintext templates or passphrases are persisted. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb4a87a..90fd48f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ importers: specifier: ^4.5.4 version: 4.5.7(@types/react@18.3.28)(react@18.3.1) devDependencies: + '@axe-core/playwright': + specifier: ^4.10.0 + version: 4.11.3(playwright-core@1.60.0) '@babel/preset-env': specifier: ^7.29.3 version: 7.29.7(@babel/core@7.29.7) @@ -157,6 +160,11 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@axe-core/playwright@4.11.3': + resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -1348,66 +1356,79 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -1856,6 +1877,10 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axe-core@4.11.4: + resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} + engines: {node: '>=4'} + axios@1.15.2: resolution: {integrity: sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==} @@ -3881,6 +3906,11 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@axe-core/playwright@4.11.3(playwright-core@1.60.0)': + dependencies: + axe-core: 4.11.4 + playwright-core: 1.60.0 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -5707,6 +5737,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + axe-core@4.11.4: {} + axios@1.15.2: dependencies: follow-redirects: 1.16.0 diff --git a/src/components/accessibility/KeyboardNavigation.jsx b/src/components/accessibility/KeyboardNavigation.jsx index a22341a..dd23d9b 100644 --- a/src/components/accessibility/KeyboardNavigation.jsx +++ b/src/components/accessibility/KeyboardNavigation.jsx @@ -15,7 +15,7 @@ function CommandPalette({ isOpen, onClose }) { const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const inputRef = useRef(null); - const { setConnectedAddress, setActiveTab } = useStore(); + const { setConnectedAddress, setActiveTab, setSelectedTemplateId } = useStore(); const commands = [ // Navigation @@ -29,7 +29,7 @@ function CommandPalette({ isOpen, onClose }) { { id: "nav-settings", label: "Go to Settings", category: "Navigation", action: () => setActiveTab("settings") }, // Quick Actions - { id: "action-builder", label: "Open Transaction Builder", category: "Actions", action: () => setActiveTab("builder") }, + { id: "action-builder", label: "Open Transaction Builder", category: "Actions", action: () => setActiveTab("txBuilder") }, { id: "action-faucet", label: "Request Testnet Funds", category: "Actions", action: () => setActiveTab("faucet") }, { id: "action-compare", label: "Compare Accounts", category: "Actions", action: () => setActiveTab("compare") }, @@ -49,11 +49,11 @@ function CommandPalette({ isOpen, onClose }) { // Templates ...Object.values(getTransactionTemplates()).map((template) => ({ id: `template-${template.id}`, - label: `Load Template: ${template.name}`, + label: `Load Template: ${template.label || template.name || template.id}`, category: "Templates", action: () => { - // Here we'd ideally set some state in the store for the builder - setActiveTab("builder"); + setSelectedTemplateId(template.id); + setActiveTab("txBuilder"); onClose(); }, })), diff --git a/src/components/dashboard/TransactionBuilder.jsx b/src/components/dashboard/TransactionBuilder.jsx index f6f17b7..39c7c5c 100644 --- a/src/components/dashboard/TransactionBuilder.jsx +++ b/src/components/dashboard/TransactionBuilder.jsx @@ -1,6 +1,11 @@ -import React, { useState, useMemo } from "react"; +import React, { useState, useMemo, useEffect } from "react"; import { useStore } from "../../lib/store"; import { OPERATION_TYPES, simulateTransaction, buildTransaction } from "../../lib/transactionBuilder"; +import { TRANSACTION_TEMPLATES } from "../../lib/transactionTemplates.js"; +import { + getCachedUserTransactionTemplates, + upsertUserTransactionTemplate, +} from "../../lib/transactionTemplateVault.ts"; import { Copy, Play, Download, AlertCircle, CheckCircle, ArrowDown, GripVertical, Trash2, Plus, Zap } from "lucide-react"; function Panel({ title, subtitle, children }) { @@ -116,36 +121,19 @@ function ActionButton({ label, onClick, disabled, tone = "primary" }) { ); } -// Operation templates for quick start -const OPERATION_TEMPLATES = { - simplePayment: { - name: "Simple Payment", - description: "Send XLM to another account", - operations: [{ type: "payment", params: { destination: "", amount: "", assetType: "native" } }] - }, - trustlineSetup: { - name: "Trustline Setup", - description: "Establish trust for a new asset", - operations: [{ type: "changeTrust", params: { assetCode: "", assetIssuer: "", limit: "" } }] - }, - accountCreation: { - name: "Account Creation", - description: "Create and fund a new account", - operations: [{ type: "createAccount", params: { destination: "", startingBalance: "2" } }] - }, - multiPayment: { - name: "Multi-Payment", - description: "Send to multiple recipients", - operations: [ - { type: "payment", params: { destination: "", amount: "", assetType: "native" } }, - { type: "payment", params: { destination: "", amount: "", assetType: "native" } }, - { type: "payment", params: { destination: "", amount: "", assetType: "native" } } - ] - } -}; +function getAllTransactionTemplates() { + const user = getCachedUserTransactionTemplates(); + const byId = new Map(); + [...user, ...TRANSACTION_TEMPLATES].forEach((t) => { + if (!t?.id) return; + byId.set(t.id, t); + }); + return Array.from(byId.values()); +} export default function TransactionBuilder() { - const { connectedAddress, network } = useStore(); + const { connectedAddress, network, selectedTemplateId, setSelectedTemplateId } = useStore(); + const availableTemplates = useMemo(() => getAllTransactionTemplates(), [selectedTemplateId]); const [sourceAccount, setSourceAccount] = useState(connectedAddress || ""); const [memo, setMemo] = useState(""); @@ -202,10 +190,23 @@ export default function TransactionBuilder() { } function loadTemplate(templateKey) { - const template = OPERATION_TEMPLATES[templateKey]; + const template = availableTemplates.find((t) => t.id === templateKey); if (!template) return; - setOperations(template.operations.map(op => ({ ...op, id: Date.now() + Math.random() }))); + setMemo(template.memo || ""); + setMemoType(template.memoType || "text"); + setOperations( + (template.operations || []).map((op) => ({ + ...op, + id: Date.now() + Math.random(), + })), + ); } + + useEffect(() => { + if (!selectedTemplateId) return; + loadTemplate(selectedTemplateId); + setSelectedTemplateId(null); + }, [selectedTemplateId]); // Drag and drop handlers function handleDragStart(index) { @@ -308,6 +309,30 @@ export default function TransactionBuilder() { } } + async function handleSaveAsTemplate() { + const label = window.prompt("Template name (will be shown in command palette):", "My Template"); + if (!label) return; + + const passphrase = window.prompt("Password to encrypt and store templates (not saved):"); + if (!passphrase) return; + + const template = { + id: `user_tpl_${Date.now()}`, + label, + description: `Saved from Transaction Builder (${new Date().toLocaleString()})`, + operations: operations.map((op) => ({ type: op.type, params: op.params })), + memo, + memoType, + }; + + try { + await upsertUserTransactionTemplate(passphrase, template); + window.alert("Template saved (encrypted). You can export it from Contract Templates → Transaction Templates."); + } catch (error) { + window.alert(`Save failed: ${error.message}`); + } + } + function renderOperationFields(op) { const hasErrors = validationErrors[op.id]; @@ -563,10 +588,10 @@ export default function TransactionBuilder() { {/* Quick Templates */}
- {Object.entries(OPERATION_TEMPLATES).map(([key, template]) => ( + {availableTemplates.map((template) => ( + +
{/* Simulation Results */} diff --git a/src/components/templates/TemplateLibrary.jsx b/src/components/templates/TemplateLibrary.jsx index 832f66d..eff11f0 100644 --- a/src/components/templates/TemplateLibrary.jsx +++ b/src/components/templates/TemplateLibrary.jsx @@ -1,6 +1,14 @@ import React, { useState } from 'react' import { getAllTemplates } from '../../lib/templateManager' import { getContractTemplates, downloadScaffold } from '../../lib/contractDevelopment' +import { + clearCachedUserTransactionTemplates, + deleteUserTransactionTemplate, + exportUserTemplatesEncryptedFile, + getCachedUserTransactionTemplates, + importUserTemplatesEncryptedFile, + loadUserTransactionTemplates, +} from '../../lib/transactionTemplateVault.ts' import TemplateCard from './TemplateCard' import TemplateDeployer from './TemplateDeployer' @@ -12,6 +20,9 @@ export default function TemplateLibrary() { const [selected, setSelected] = useState(null) const [filter, setFilter] = useState('all') const [scaffoldDownloaded, setScaffoldDownloaded] = useState(null) + const [templatePassphrase, setTemplatePassphrase] = useState('') + const [vaultStatus, setVaultStatus] = useState({ unlocked: false, count: 0, error: '' }) + const [importMode, setImportMode] = useState('replace') const filtered = filter === 'all' ? templates @@ -19,6 +30,8 @@ export default function TemplateLibrary() { ? [] // Scaffold section is separate : templates.filter((t) => t.category === filter) + const unlockedUserTemplates = getCachedUserTransactionTemplates() + const handleScaffoldDownload = (templateId) => { try { const bundle = downloadScaffold(templateId) @@ -29,8 +42,252 @@ export default function TemplateLibrary() { } } + const unlockTemplateVault = async () => { + setVaultStatus({ unlocked: false, count: 0, error: '' }) + if (!templatePassphrase) { + setVaultStatus({ unlocked: false, count: 0, error: 'Enter a password to unlock templates.' }) + return + } + try { + const templates = await loadUserTransactionTemplates(templatePassphrase) + setVaultStatus({ unlocked: true, count: templates.length, error: '' }) + } catch (e) { + setVaultStatus({ unlocked: false, count: 0, error: e?.message || 'Failed to unlock template vault.' }) + } + } + + const lockTemplateVault = () => { + clearCachedUserTransactionTemplates() + setVaultStatus({ unlocked: false, count: 0, error: '' }) + } + + const downloadEncryptedExport = async () => { + setVaultStatus((s) => ({ ...s, error: '' })) + if (!templatePassphrase) { + setVaultStatus((s) => ({ ...s, error: 'Enter a password to export templates.' })) + return + } + try { + const { file, filename } = await exportUserTemplatesEncryptedFile(templatePassphrase) + const blob = new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } catch (e) { + setVaultStatus((s) => ({ ...s, error: e?.message || 'Export failed.' })) + } + } + + const onImportFilePicked = async (file) => { + setVaultStatus((s) => ({ ...s, error: '' })) + if (!templatePassphrase) { + setVaultStatus((s) => ({ ...s, error: 'Enter a password to import templates.' })) + return + } + try { + const text = await file.text() + await importUserTemplatesEncryptedFile(templatePassphrase, text, { mode: importMode }) + const templates = await loadUserTransactionTemplates(templatePassphrase) + setVaultStatus({ unlocked: true, count: templates.length, error: '' }) + } catch (e) { + setVaultStatus((s) => ({ ...s, error: e?.message || 'Import failed.' })) + } + } + return (
+ {/* Transaction Template Vault (Encrypted) */} +
+

+ 🔐 Transaction Templates (Encrypted Backup) +

+

+ Unlock your locally saved transaction templates using a password, then export/import an encrypted template pack. + Nothing is stored in plaintext in IndexedDB. +

+ +
+ setTemplatePassphrase(e.target.value)} + placeholder="Password (not stored)" + style={{ + flex: 1, + minWidth: '220px', + padding: '8px 10px', + background: 'var(--bg-elevated)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-sm)', + color: 'var(--text-primary)', + fontFamily: 'var(--font-mono)', + fontSize: '12px', + }} + /> + + + + + +
+ +
+ Status: {vaultStatus.unlocked ? `Unlocked (${vaultStatus.count} templates)` : 'Locked'} + {vaultStatus.error && ( + {vaultStatus.error} + )} +
+ + {vaultStatus.unlocked && unlockedUserTemplates.length > 0 && ( +
+ {unlockedUserTemplates.map((tpl) => ( +
+
+ {tpl.label} +
+ {tpl.description && ( +
+ {tpl.description} +
+ )} + +
+ ))} +
+ )} +
+ {/* Category filter */}
{CATEGORIES.map((cat) => ( @@ -175,4 +432,4 @@ export default function TemplateLibrary() { )}
) -} \ No newline at end of file +} diff --git a/src/lib/transactionTemplateVault.ts b/src/lib/transactionTemplateVault.ts new file mode 100644 index 0000000..383507b --- /dev/null +++ b/src/lib/transactionTemplateVault.ts @@ -0,0 +1,161 @@ +import { encrypt, decrypt } from './encryption.js' +import { getEncryptedValue, setEncryptedValue } from './storage.js' + +export interface TransactionTemplateOperation { + type: string + params: Record +} + +export interface TransactionTemplate { + id: string + label: string + description?: string + operations: TransactionTemplateOperation[] + memo?: string + memoType?: string + createdAt?: string + updatedAt?: string +} + +interface TransactionTemplatePackV1 { + version: 1 + templates: TransactionTemplate[] + updatedAt: string +} + +export interface EncryptedTemplateExportFileV1 { + format: 'stellar-dev-dashboard.transaction-templates' + version: 1 + encrypted: { ciphertext: string; iv: string; salt: string } + exportedAt: string +} + +const STORAGE_KEY = 'transaction-template-pack-v1' + +let cachedTemplates: TransactionTemplate[] | null = null + +function normalizeTemplate(template: TransactionTemplate): TransactionTemplate { + const now = new Date().toISOString() + return { + ...template, + id: template.id || `tpl-${Date.now()}`, + label: template.label || 'Untitled Template', + operations: Array.isArray(template.operations) ? template.operations : [], + createdAt: template.createdAt || now, + updatedAt: now, + } +} + +function buildPack(templates: TransactionTemplate[]): TransactionTemplatePackV1 { + return { + version: 1, + templates: templates.map(normalizeTemplate), + updatedAt: new Date().toISOString(), + } +} + +export function getCachedUserTransactionTemplates(): TransactionTemplate[] { + return cachedTemplates || [] +} + +export function clearCachedUserTransactionTemplates() { + cachedTemplates = null +} + +export async function loadUserTransactionTemplates(passphrase: string): Promise { + const raw = await getEncryptedValue(STORAGE_KEY, passphrase) + if (!raw) { + cachedTemplates = [] + return [] + } + + const parsed = JSON.parse(raw) as Partial + const templates = Array.isArray(parsed.templates) ? (parsed.templates as TransactionTemplate[]) : [] + cachedTemplates = templates + return templates +} + +export async function saveUserTransactionTemplates( + passphrase: string, + templates: TransactionTemplate[] +): Promise { + const pack = buildPack(templates) + await setEncryptedValue(STORAGE_KEY, JSON.stringify(pack), passphrase) + cachedTemplates = pack.templates +} + +export async function upsertUserTransactionTemplate( + passphrase: string, + template: TransactionTemplate +): Promise { + const existing = await loadUserTransactionTemplates(passphrase) + const next = [...existing] + const normalized = normalizeTemplate(template) + const idx = next.findIndex((t) => t.id === normalized.id) + if (idx >= 0) next[idx] = normalized + else next.unshift(normalized) + await saveUserTransactionTemplates(passphrase, next) +} + +export async function deleteUserTransactionTemplate(passphrase: string, id: string): Promise { + const existing = await loadUserTransactionTemplates(passphrase) + const next = existing.filter((t) => t.id !== id) + await saveUserTransactionTemplates(passphrase, next) +} + +export async function exportUserTemplatesEncryptedFile( + passphrase: string +): Promise<{ file: EncryptedTemplateExportFileV1; filename: string }> { + const templates = await loadUserTransactionTemplates(passphrase) + const plaintext = JSON.stringify(buildPack(templates)) + const encrypted = await encrypt(plaintext, passphrase) + + const file: EncryptedTemplateExportFileV1 = { + format: 'stellar-dev-dashboard.transaction-templates', + version: 1, + encrypted, + exportedAt: new Date().toISOString(), + } + + const date = new Date().toISOString().slice(0, 10) + return { file, filename: `transaction-templates.${date}.enc.json` } +} + +export async function importUserTemplatesEncryptedFile( + passphrase: string, + fileText: string, + opts: { mode?: 'replace' | 'merge' } = {} +): Promise<{ imported: number }> { + const mode = opts.mode || 'replace' + const parsed = JSON.parse(fileText) as Partial + + if (parsed.format !== 'stellar-dev-dashboard.transaction-templates' || parsed.version !== 1) { + throw new Error('Invalid template export file format') + } + if (!parsed.encrypted?.ciphertext || !parsed.encrypted?.iv || !parsed.encrypted?.salt) { + throw new Error('Invalid template export file payload') + } + + const plaintext = await decrypt( + parsed.encrypted.ciphertext, + passphrase, + parsed.encrypted.iv, + parsed.encrypted.salt + ) + const pack = JSON.parse(plaintext) as Partial + const importedTemplates = Array.isArray(pack.templates) ? (pack.templates as TransactionTemplate[]) : [] + + if (mode === 'replace') { + await saveUserTransactionTemplates(passphrase, importedTemplates) + return { imported: importedTemplates.length } + } + + const existing = await loadUserTransactionTemplates(passphrase) + const byId = new Map() + existing.forEach((t) => byId.set(t.id, t)) + importedTemplates.forEach((t) => byId.set(t.id, normalizeTemplate(t))) + const merged = Array.from(byId.values()) + await saveUserTransactionTemplates(passphrase, merged) + return { imported: importedTemplates.length } +} + diff --git a/src/utils/accessibility.js b/src/utils/accessibility.js index 913c9a0..8f8b60a 100644 --- a/src/utils/accessibility.js +++ b/src/utils/accessibility.js @@ -1,4 +1,6 @@ // src/utils/accessibility.js +import { TRANSACTION_TEMPLATES } from "../lib/transactionTemplates.js"; +import { getCachedUserTransactionTemplates } from "../lib/transactionTemplateVault.ts"; let listeners = []; /** @@ -230,39 +232,28 @@ export const clearAllShortcuts = () => { /** * Transaction template storage */ -const TEMPLATE_STORAGE_KEY = "stellar_transaction_templates"; /** - * Save transaction template - * @param {object} template - Template data - * @returns {string} Template ID - */ -export const saveTransactionTemplate = (template) => { - const templates = getTransactionTemplates(); - const id = template.id || `template-${Date.now()}`; - - templates[id] = { - ...template, - id, - createdAt: template.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - localStorage.setItem(TEMPLATE_STORAGE_KEY, JSON.stringify(templates)); - return id; -}; - -/** - * Get all transaction templates - * @returns {object} Templates object + * Get all transaction templates (built-ins + any unlocked user templates). + * + * Note: user templates are stored encrypted in IndexedDB and are only + * available here after the user unlocks them in the UI (cached in-memory). + * + * @returns {object} Templates keyed by id */ export const getTransactionTemplates = () => { - try { - const stored = localStorage.getItem(TEMPLATE_STORAGE_KEY); - return stored ? JSON.parse(stored) : {}; - } catch { - return {}; - } + const builtIns = TRANSACTION_TEMPLATES.map((t) => ({ + ...t, + label: t.label || t.name, + })); + const user = getCachedUserTransactionTemplates(); + const all = [...user, ...builtIns]; + const byId = {}; + all.forEach((t) => { + if (!t?.id) return; + byId[t.id] = t; + }); + return byId; }; /** @@ -275,15 +266,7 @@ export const getTransactionTemplate = (id) => { return templates[id] || null; }; -/** - * Delete transaction template - * @param {string} id - Template ID - */ -export const deleteTransactionTemplate = (id) => { - const templates = getTransactionTemplates(); - delete templates[id]; - localStorage.setItem(TEMPLATE_STORAGE_KEY, JSON.stringify(templates)); -}; +// NOTE: CRUD operations live in src/lib/transactionTemplateVault.ts (encrypted). /** * Recent accounts storage