Ready
Network activity
0 requests
Connected - models run on-device
No recent network activity
0 groups0 total requests
Browse blocks
Privacy
Privacy-first tools that keep your data in the browser. Detect and hide personal information in text, and store notes in a passphrase-locked, encrypted vault with a verifiable history.
PII Redactor
Find and hide personal details like names, emails, and phone numbers in text.
Install this block
npx shadcn@latest add @localmode/ui/blocks/privacy/pii-redactorDetect and redact PII on-device with bert-base-NER, then demonstrate differential privacy.
Model loads on Scan (bert-base-NER ~110 MB).
Epsilon (ε) 1.0High privacy
More privacyLess privacy
'use client';/** * @file pii-redactor.tsx * @description PII Redactor block (`/blocks/privacy/pii-redactor`) — on-device NER PII detection + typed-placeholder redaction + a differential-privacy demonstration. */import { useRef, useState } from 'react';import { Copy, Download, FileText, Loader2, ScanLine, Check } from 'lucide-react';import { createPrivacyBudget, dpEmbeddingMiddleware, wrapEmbeddingModel, embed, redactPII, type PrivacyBudget,} from '@localmode/core';import { transformers } from '@localmode/transformers';import { useExtractEntities } from '@localmode/react';import { RedactedTextDisplay } from '@/components/redacted-text-display';import { EntityStatsBar } from '@/components/entity-stats-bar';import { EntityRelationshipGraph, type NodeTypeRegistry,} from '@/components/entity-relationship-graph';import { DifferentialPrivacyControls, DpAppliedBadge, type PrivacyBudgetState,} from '@/components/differential-privacy-controls';import { cn } from '@/lib/utils';interface DetectedEntity { text: string; type: string; start: number; end: number; score: number;}const CONFIDENCE_THRESHOLD = 0.5;function mapEntities( entities: { text: string; type: string; start: number; end: number; score: number }[],): DetectedEntity[] { return entities .map((e) => ({ text: e.text, type: e.type.replace(/^[BI]-/, ''), start: e.start, end: e.end, score: e.score, })) .filter((e) => e.score >= CONFIDENCE_THRESHOLD && e.end > e.start);}function redactText( text: string, entities: DetectedEntity[], includedTypes: ReadonlySet<string>,): string { const sorted = entities .filter((e) => includedTypes.has(e.type)) .sort((a, b) => b.start - a.start); let redacted = text; for (const entity of sorted) { redacted = redacted.slice(0, entity.start) + `[${entity.type}]` + redacted.slice(entity.end); } return redactPII(redacted, { emails: true, phones: true, ssn: true, creditCards: true });}function countByType(entities: DetectedEntity[]): Record<string, number> { const counts: Record<string, number> = {}; for (const e of entities) counts[e.type] = (counts[e.type] ?? 0) + 1; return counts;}const NODE_REGISTRY: NodeTypeRegistry = { PER: { color: 'var(--color-sky-500, #0ea5e9)' }, LOC: { color: 'var(--color-emerald-500, #10b981)' }, ORG: { color: 'var(--color-violet-500, #8b5cf6)' }, MISC: { color: 'var(--color-amber-500, #f59e0b)' },};function buildEntityGraph(entities: DetectedEntity[]) { const nodeMap = new Map<string, { id: string; label: string; type: string; weight: number }>(); for (const e of entities) { const id = `${e.type}:${e.text.toLowerCase()}`; const existing = nodeMap.get(id); if (existing) existing.weight += 1; else nodeMap.set(id, { id, label: e.text, type: e.type, weight: 1 }); } const ordered = [...entities].sort((a, b) => a.start - b.start); const edgeSet = new Set<string>(); const edges: { source: string; target: string }[] = []; for (let i = 0; i < ordered.length - 1; i++) { const a = `${ordered[i].type}:${ordered[i].text.toLowerCase()}`; const b = `${ordered[i + 1].type}:${ordered[i + 1].text.toLowerCase()}`; if (a === b) continue; const key = a < b ? `${a}|${b}` : `${b}|${a}`; if (edgeSet.has(key)) continue; edgeSet.add(key); edges.push({ source: a, target: b }); } return { nodes: [...nodeMap.values()], edges };}function embeddingSignature(vec: Float32Array | number[]): string { let sum = 0; for (const v of vec) sum += v; return sum.toFixed(6);}const NER_MODEL_ID = 'Xenova/bert-base-NER';const EMBEDDING_MODEL_ID = 'Xenova/all-MiniLM-L6-v2';const MIN_EPSILON = 0.5;const MAX_EPSILON = 10.0;const EPSILON_STEP = 0.5;const DEFAULT_EPSILON = 1.0;const MAX_BUDGET_EPSILON = 10.0;const SAMPLE_DOCUMENT = 'John Smith met with Dr. Sarah Johnson at the Microsoft headquarters in Seattle, Washington on March 15th. They discussed the upcoming partnership with Google and the European Union regulations. Contact John at john.smith@example.com or call +1-555-0123. His social security number is 123-45-6789.';let nerSingleton: ReturnType<typeof transformers.ner> | null = null;function getNERModel() { if (!nerSingleton) nerSingleton = transformers.ner(NER_MODEL_ID); return nerSingleton;}let embeddingModel: ReturnType<typeof transformers.embedding> | null = null;function getEmbeddingModel() { if (!embeddingModel) embeddingModel = transformers.embedding(EMBEDDING_MODEL_ID); return embeddingModel;}type Phase = 'idle' | 'scanning' | 'ready' | 'error';interface DpResult { epsilon: number; dims: number; plainSig: string; noisySig: string;}const ENTITY_LABELS: Record<string, string> = { PER: 'Person', LOC: 'Location', ORG: 'Organization', MISC: 'Misc',};export function PiiRedactorBlock() { const nerModel = useState(() => getNERModel())[0]; const { execute, cancel } = useExtractEntities({ model: nerModel }); const [input, setInput] = useState(''); const [phase, setPhase] = useState<Phase>('idle'); const [error, setError] = useState<string | null>(null); const [scannedText, setScannedText] = useState(''); const [entities, setEntities] = useState<DetectedEntity[]>([]); const [includedTypes, setIncludedTypes] = useState<ReadonlySet<string>>(() => new Set()); const [copied, setCopied] = useState(false); const [dpEnabled, setDpEnabled] = useState(false); const [epsilon, setEpsilon] = useState(DEFAULT_EPSILON); const [dpResult, setDpResult] = useState<DpResult | null>(null); const [budgetState, setBudgetState] = useState<PrivacyBudgetState | null>(null); const budgetRef = useRef<PrivacyBudget | null>(null); const hasResult = phase === 'ready' && scannedText.length > 0; const detectedTypes = Object.keys(countByType(entities)); const redacted = hasResult ? redactText(scannedText, entities, includedTypes) : ''; const graph = hasResult ? buildEntityGraph(entities) : { nodes: [], edges: [] }; const loadSample = () => setInput(SAMPLE_DOCUMENT); const readBudget = (budget: PrivacyBudget): PrivacyBudgetState => ({ consumed: budget.consumed(), maxEpsilon: MAX_BUDGET_EPSILON, }); const runDpDemo = async (redactedText: string) => { if (!budgetRef.current) { budgetRef.current = await createPrivacyBudget({ maxEpsilon: MAX_BUDGET_EPSILON, onExhausted: 'warn', persistKey: 'privacy-vault-dp-budget', }); } const budget = budgetRef.current; const baseModel = getEmbeddingModel(); const plain = await embed({ model: baseModel, value: redactedText }); const wrapped = wrapEmbeddingModel({ model: baseModel, middleware: dpEmbeddingMiddleware({ epsilon, mechanism: 'gaussian' }, budget), }); const noisy = await embed({ model: wrapped, value: redactedText }); setDpResult({ epsilon, dims: noisy.embedding.length, plainSig: embeddingSignature(plain.embedding), noisySig: embeddingSignature(noisy.embedding), }); setBudgetState(readBudget(budget)); }; const scan = async () => { if (!input.trim() || phase === 'scanning') return; setPhase('scanning'); setError(null); setDpResult(null); try { const data = await execute(input); if (!data) { setPhase((p) => (p === 'scanning' ? 'idle' : p)); return; } const mapped = mapEntities(data.entities); setScannedText(input); setEntities(mapped); setIncludedTypes(new Set(mapped.map((e) => e.type))); setPhase('ready'); if (dpEnabled) { const redactedText = redactText(input, mapped, new Set(mapped.map((e) => e.type))); await runDpDemo(redactedText); } } catch (err) { setError(err instanceof Error ? err.message : 'Scan failed'); setPhase('error'); } }; const toggleType = (type: string) => { setIncludedTypes((prev) => { const next = new Set(prev); if (next.has(type)) next.delete(type); else next.add(type); return next; }); }; const copyRedacted = async () => { try { await navigator.clipboard.writeText(redacted); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { } }; const exportClean = () => { const blob = new Blob([redacted], { type: 'text/plain' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'redacted.txt'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); }; const modelStatus = phase === 'scanning' ? 'loading' : phase === 'ready' ? 'ready' : phase; return ( <div className="flex flex-col gap-4 p-4"> <p className="text-xs text-muted-foreground"> Detect and redact PII on-device with bert-base-NER, then demonstrate differential privacy. </p> <div className="flex flex-col gap-2"> <div className="flex items-center justify-between gap-2"> <label htmlFor="redact-input" className="text-sm font-medium"> Document to scan </label> <button type="button" onClick={loadSample} 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <FileText className="size-3.5" aria-hidden="true" /> Load sample </button> </div> <textarea id="redact-input" value={input} disabled={phase === 'scanning'} onChange={(e) => setInput(e.target.value)} rows={5} placeholder="Paste text containing names, organizations, locations, emails, phone numbers, or SSNs…" className="w-full resize-y rounded-lg border border-input bg-background p-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50" /> <div className="flex flex-wrap items-center gap-2"> <button type="button" onClick={scan} disabled={!input.trim() || phase === 'scanning'} className="inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap 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" > {phase === 'scanning' ? ( <Loader2 className="size-4 animate-spin" aria-hidden="true" /> ) : ( <ScanLine className="size-4" aria-hidden="true" /> )} {phase === 'scanning' ? 'Scanning…' : 'Scan for PII'} </button> {phase === 'scanning' && ( <button type="button" onClick={cancel} className="rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > Cancel </button> )} <span role="status" data-status={modelStatus} className="basis-full text-xs text-muted-foreground sm:basis-auto" > {phase === 'idle' && 'Model loads on Scan (bert-base-NER ~110 MB).'} {phase === 'scanning' && 'Running on-device NER…'} {phase === 'ready' && `${entities.length} entities detected.`} {phase === 'error' && 'Scan failed.'} </span> </div> {error && ( <p role="alert" className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive" > {error} </p> )} </div> {(phase === 'scanning' || hasResult) && ( <> {hasResult && ( <> {} <ul aria-label="Detected PII entities" className="sr-only"> {entities.map((e, i) => ( <li key={i} data-entity-text={e.text} data-entity-type={e.type}> {e.text} ({e.type}) </li> ))} </ul> <EntityStatsBar entities={entities} /> {detectedTypes.length > 0 && ( <div className="flex flex-wrap items-center gap-2"> <span className="text-xs font-medium text-muted-foreground">Redact:</span> {detectedTypes.map((type) => { const on = includedTypes.has(type); return ( <button key={type} type="button" data-included={on} aria-pressed={on} onClick={() => toggleType(type)} className={cn( 'rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', on ? 'border-primary/40 bg-primary/10 text-primary' : 'border-border bg-background text-muted-foreground line-through', )} > {ENTITY_LABELS[type] ?? type} </button> ); })} </div> )} </> )} <div className="flex flex-col gap-2"> <div className="flex items-center justify-between gap-2"> <span className="text-sm font-medium">Redacted output</span> {hasResult && ( <div className="flex items-center gap-1.5"> <button type="button" onClick={copyRedacted} 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > {copied ? ( <Check className="size-3.5" aria-hidden="true" /> ) : ( <Copy className="size-3.5" aria-hidden="true" /> )} {copied ? 'Copied' : 'Copy'} </button> <button type="button" onClick={exportClean} 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" > <Download className="size-3.5" aria-hidden="true" /> Export Clean </button> </div> )} </div> <div role="group" aria-label="Redacted output"> <RedactedTextDisplay text={redacted} entities={[]} isScanning={phase === 'scanning'} emptyState="Scan a document to see the redacted output." /> </div> </div> {hasResult && graph.nodes.length > 0 && ( <div className="flex flex-col gap-1"> <span className="text-sm font-medium">Entity relationships</span> <EntityRelationshipGraph nodes={graph.nodes} edges={graph.edges} registry={NODE_REGISTRY} height={280} showExport={false} /> </div> )} </> )} {} <div className="flex flex-col gap-2"> <DifferentialPrivacyControls enabled={dpEnabled} onEnabledChange={setDpEnabled} epsilon={epsilon} onEpsilonChange={setEpsilon} minEpsilon={MIN_EPSILON} maxEpsilon={MAX_EPSILON} step={EPSILON_STEP} budget={budgetState ?? undefined} /> {budgetState && ( <span data-consumed={budgetState.consumed.toFixed(1)} data-max={budgetState.maxEpsilon.toFixed(1)} className="text-xs text-muted-foreground" > Privacy budget: {budgetState.consumed.toFixed(1)} / {budgetState.maxEpsilon.toFixed(1)} ε consumed </span> )} {dpResult && ( <div className="flex flex-col gap-1"> <DpAppliedBadge epsilon={dpResult.epsilon} dimensions={dpResult.dims} /> {} <span aria-label="Differential privacy embedding signatures" data-plain-sig={dpResult.plainSig} data-noisy-sig={dpResult.noisySig} data-dims={dpResult.dims} data-epsilon={dpResult.epsilon} className="font-mono text-[10px] text-muted-foreground" > baseline {dpResult.plainSig} · noised {dpResult.noisySig} </span> </div> )} {dpEnabled && !dpResult && ( <p className="text-xs text-muted-foreground"> Scan with DP enabled to noise the redacted text's embedding (all-MiniLM-L6-v2 ~23 MB). </p> )} </div> </div> );}Encrypted Vault
Keep notes in a passphrase-locked vault that encrypts everything before saving.
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> );}