Ready
Network activity
0 requests
Connected - models run on-device
No recent network activity
0 groups0 total requests
Browse blocks
Encrypted Vault
Keep private notes and documents in a vault locked by a passphrase you choose. Everything is encrypted before it is saved, decrypted only when you view it, stays locked after a reload, and keeps a tamper-evident history you can verify and export. Everything runs in your browser using built-in encryption, and nothing downloads.
Install this block
npx shadcn@latest add @localmode/ui/blocks/privacy/encrypted-vaultA passphrase-locked, end-to-end encrypted item store with a hash-chained audit log (Web Crypto only).
Encrypted vaultNo vault
Audit log(0)
- No audit entries yet.
'use client';/** * @file encrypted-vault.tsx * @description Encrypted Vault block (`/blocks/privacy/encrypted-vault`) — passphrase-locked AES-GCM item store with a tamper-evident hash-chained audit log. */import { useEffect, useRef, useState } from 'react';import { FileUp, Lock, Plus, ShieldCheck, Download, ListChecks } from 'lucide-react';import { createAuditLog, exportAuditLog, type AuditLog,} from '@localmode/core';import { useEncryptedVault, useAuditLog } from '@localmode/react';import { PassphraseGate } from '@/components/passphrase-gate';import { VaultItemCard } from '@/components/vault-item-card';import { LockStatusBadge } from '@/components/lock-status-badge';import { cn } from '@/lib/utils';import type { StrengthColor } from '@/components/password-strength-bar';const MIN_PASSPHRASE_LENGTH = 8;const VAULT_NAME = 'privacy-vault';interface PassphraseStrength { value: number; label?: string; color: StrengthColor;}function estimateStrength(passphrase: string): PassphraseStrength { const classes = (/[a-z]/.test(passphrase) ? 1 : 0) + (/[A-Z]/.test(passphrase) ? 1 : 0) + (/[0-9]/.test(passphrase) ? 1 : 0) + (/[^a-zA-Z0-9]/.test(passphrase) ? 1 : 0); const lengthScore = Math.min(passphrase.length, 16) / 16; const value = Math.round(lengthScore * 60 + (classes / 4) * 40); return { value, label: passphrase.length === 0 ? undefined : value < 40 ? 'Weak' : value < 70 ? 'Good' : 'Strong', color: value < 40 ? 'error' : value < 70 ? 'warning' : 'success', };}function formatTimestamp(ms: number): string { return new Date(ms).toLocaleString();}interface VaultData { title: string; content: string; kind: 'note' | 'document';}export function EncryptedVaultBlock() { const vault = useEncryptedVault<VaultData>({ name: VAULT_NAME }); const { status, items, error, isBusy, unlock, lock, createItem, deleteItem } = vault; const [auditLog, setAuditLog] = useState<AuditLog | null>(null); const audit = useAuditLog(auditLog); useEffect(() => { let live = true; let created: AuditLog | null = null; createAuditLog({ name: VAULT_NAME }) .then((log) => { if (live) { created = log; setAuditLog(log); } else { void log.close(); } }) .catch(() => {}); return () => { live = false; void created?.close(); }; }, []); const logChainRef = useRef<Promise<unknown>>(Promise.resolve()); const logEvent = (kind: string, payload: unknown): Promise<unknown> => { logChainRef.current = logChainRef.current .then(() => (auditLog ? audit.append(kind, payload) : undefined)) .catch(() => {}); return logChainRef.current; }; const [strength, setStrength] = useState<PassphraseStrength>({ value: 0, color: 'error' }); const [gateError, setGateError] = useState<string | undefined>(undefined); const [revealedId, setRevealedId] = useState<string | null>(null); const [title, setTitle] = useState(''); const [content, setContent] = useState(''); const [pendingDelete, setPendingDelete] = useState<{ id: string; title: string } | null>(null); const fileRef = useRef<HTMLInputElement>(null); const mode: 'create' | 'unlock' = status === 'uninitialized' ? 'create' : 'unlock'; const lockStatus = status === 'unlocked' ? 'unlocked' : status === 'locked' ? 'locked' : 'no-vault'; const handleSubmitPassphrase = async (passphrase: string) => { setGateError(undefined); const wasUninitialized = status === 'uninitialized'; const ok = await unlock(passphrase); if (ok) { if (wasUninitialized) logEvent('vault.created', { name: VAULT_NAME }); logEvent('vault.unlocked', { name: VAULT_NAME }); } else { setGateError('Incorrect passphrase. Please try again.'); logEvent('vault.unlock_failed', { name: VAULT_NAME }); } }; const handleLock = () => { lock(); setRevealedId(null); logEvent('vault.locked', { name: VAULT_NAME }); }; const handleReveal = (id: string, itemTitle: string) => { setRevealedId(id); logEvent('item.viewed', { itemId: id, title: itemTitle }); }; const handleAddNote = async (e: React.FormEvent) => { e.preventDefault(); if (!title.trim() || !content.trim()) return; const item = await createItem({ title: title.trim(), content: content.trim(), kind: 'note' }); if (item) { logEvent('item.added', { itemId: item.id, title: item.data.title }); setTitle(''); setContent(''); } }; const handleImport = async (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; const text = await file.text(); const item = await createItem({ title: file.name, content: text, kind: 'document' }); if (item) logEvent('item.added', { itemId: item.id, title: item.data.title }); if (fileRef.current) fileRef.current.value = ''; }; const handleDelete = async (id: string, itemTitle: string) => { const ok = await deleteItem(id); if (ok) { if (revealedId === id) setRevealedId(null); logEvent('item.deleted', { itemId: id, title: itemTitle }); } setPendingDelete(null); }; const verifyChainResult = async () => { await audit.verify(); }; const exportAudit = async () => { if (!auditLog) return; const chunks: string[] = []; for await (const line of exportAuditLog(auditLog)) chunks.push(line); const blob = new Blob(chunks, { type: 'application/x-ndjson' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'privacy-vault-audit.jsonl'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }; const wrongPassphrase = error?.name === 'VaultPassphraseError' ? 'Incorrect passphrase. Please try again.' : gateError; return ( <div className="flex flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> A passphrase-locked, end-to-end encrypted item store with a hash-chained audit log (Web Crypto only). </p> <div className="flex items-center justify-between gap-2"> <span className="text-sm font-medium">Encrypted vault</span> <LockStatusBadge status={lockStatus} /> </div> {status !== 'unlocked' ? ( <PassphraseGate className="mx-auto" mode={mode} isBusy={isBusy} minLength={MIN_PASSPHRASE_LENGTH} strength={strength} error={wrongPassphrase} onPassphraseChange={(v) => setStrength(estimateStrength(v))} onSubmit={handleSubmitPassphrase} title={mode === 'create' ? 'Create your vault' : 'Unlock your vault'} description={ mode === 'create' ? 'Choose a passphrase. It derives the encryption key (in memory only) - it is never stored.' : 'Enter your passphrase to decrypt your items.' } /> ) : ( <> {} <form onSubmit={handleAddNote} className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4"> <label htmlFor="vault-note-title" className="sr-only"> Note title </label> <input id="vault-note-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" /> <label htmlFor="vault-note-content" className="sr-only"> Secret content </label> <textarea id="vault-note-content" value={content} onChange={(e) => setContent(e.target.value)} rows={3} placeholder="Secret content: encrypted with AES-GCM before it is stored" className="w-full resize-y rounded-md border border-input bg-background p-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50" /> <div className="flex items-center gap-2"> <button type="submit" disabled={!title.trim() || !content.trim() || isBusy} className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background" > <Plus className="size-4" aria-hidden="true" /> Add note </button> <button type="button" onClick={() => fileRef.current?.click()} className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <FileUp className="size-4" aria-hidden="true" /> Import .txt </button> <label htmlFor="vault-import-file" className="sr-only"> Import a text document </label> <input ref={fileRef} id="vault-import-file" type="file" accept=".txt,text/plain" onChange={handleImport} className="hidden" /> <button type="button" onClick={handleLock} className="ml-auto inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Lock className="size-4" aria-hidden="true" /> Lock </button> </div> </form> {} <div className="flex flex-col gap-3"> {items.length === 0 ? ( <p className="rounded-lg border border-dashed border-border px-4 py-8 text-center text-sm text-muted-foreground"> No items yet. Add an encrypted note or import a text document. </p> ) : ( items.map((item) => ( <div key={item.id} role="group" aria-label={item.data.title} data-item-id={item.id}> <VaultItemCard title={item.data.title} kind={item.data.kind} createdAt={formatTimestamp(item.createdAt)} locked={false} revealed={revealedId === item.id} content={revealedId === item.id ? item.data.content : undefined} onReveal={() => handleReveal(item.id, item.data.title)} onHide={() => setRevealedId(null)} onDelete={() => setPendingDelete({ id: item.id, title: item.data.title })} /> {pendingDelete?.id === item.id && ( <div role="alertdialog" aria-label={`Delete ${item.data.title}`} className="mt-2 flex flex-col gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3" > <p className="text-sm text-foreground"> Permanently delete <span className="font-medium">{item.data.title}</span>? The encrypted contents are destroyed and cannot be recovered. </p> <div className="flex items-center gap-2"> <button type="button" onClick={() => void handleDelete(item.id, item.data.title)} className="inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2 focus-visible:ring-offset-background" > Delete permanently </button> <button type="button" autoFocus onClick={() => setPendingDelete(null)} className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Cancel </button> </div> </div> )} </div> )) )} </div> </> )} {} <div className="flex flex-col gap-2 rounded-xl border border-border bg-card p-4"> <div className="flex flex-wrap items-center justify-between gap-2"> <h2 className="flex items-center gap-1.5 whitespace-nowrap text-sm font-medium"> <ListChecks className="size-4 text-muted-foreground" aria-hidden="true" /> Audit log <span className="text-muted-foreground">({audit.entries.length})</span> </h2> <div className="flex items-center gap-1.5"> <button type="button" onClick={verifyChainResult} disabled={audit.entries.length === 0} className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <ShieldCheck className="size-3.5" aria-hidden="true" /> Verify chain </button> <button type="button" onClick={exportAudit} disabled={audit.entries.length === 0} className="inline-flex items-center gap-1.5 rounded-md border border-border bg-background px-2.5 py-1 text-xs font-medium transition-colors hover:bg-accent disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Download className="size-3.5" aria-hidden="true" /> Export JSONL </button> </div> </div> {audit.lastVerification && ( <p role="status" data-ok={audit.lastVerification.ok} className={cn( 'text-xs font-medium', audit.lastVerification.ok ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400', )} > {audit.lastVerification.ok ? `Chain valid: ${audit.lastVerification.entriesChecked} entries verified.` : `Chain broken at entry ${audit.lastVerification.brokenAt} (${audit.lastVerification.reason}).`} </p> )} <ol aria-label="Audit log entries" className="flex flex-col gap-1 text-xs"> {audit.entries.length === 0 ? ( <li className="text-muted-foreground">No audit entries yet.</li> ) : ( audit.entries.map((entry) => ( <li key={entry.id} data-audit-kind={entry.kind} className="flex items-center gap-2 font-mono" > <span className="text-muted-foreground">{formatTimestamp(entry.timestamp)}</span> <span className="font-medium">{entry.kind}</span> </li> )) )} </ol> </div> </div> );}