Password Manager experiment built on ATProto Spaces
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337import React, { useState, useRef } from 'react';import { X, Upload, Download, FileJson, Check, Copy, AlertCircle, ArrowRight, ShieldAlert, KeyRound, FileText, CreditCard, User} from 'lucide-react';import { useVault } from '../context/VaultContext.js';import { parseBitwardenExport } from '@2password/core';
export const BitwardenModal: React.FC = () => { const { isBitwardenModalOpen, closeBitwardenModal, importBitwarden, exportBitwarden, activeItems } = useVault();
const [activeTab, setActiveTab] = useState<'import' | 'export'>('import');
const [jsonInput, setJsonInput] = useState(''); const [preview, setPreview] = useState<{ total: number; logins: number; notes: number; cards: number; identities: number; } | null>(null); const [parseError, setParseError] = useState<string | null>(null); const [importSuccess, setImportSuccess] = useState<number | null>(null); const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef<HTMLInputElement>(null);
const [exportJson, setExportJson] = useState<string | null>(null); const [copiedExport, setCopiedExport] = useState(false); const [isExporting, setIsExporting] = useState(false);
if (!isBitwardenModalOpen) return null;
const handleJsonChange = (text: string) => { setJsonInput(text); setParseError(null); setImportSuccess(null);
if (!text.trim()) { setPreview(null); return; }
try { const { items } = parseBitwardenExport(text); setPreview({ total: items.length, logins: items.filter((i) => i.type === 'login').length, notes: items.filter((i) => i.type === 'secureNote').length, cards: items.filter((i) => i.type === 'card').length, identities: items.filter((i) => i.type === 'identity').length }); } catch (err: any) { setPreview(null); setParseError(err.message || 'Invalid Bitwarden JSON format.'); } };
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return;
const reader = new FileReader(); reader.onload = (event) => { const content = event.target?.result as string; handleJsonChange(content); }; reader.readAsText(file); };
const handleExecuteImport = async () => { if (!jsonInput.trim()) return; setIsImporting(true); setParseError(null); try { const res = await importBitwarden(jsonInput); setImportSuccess(res.importedCount); setJsonInput(''); setPreview(null); } catch (err: any) { setParseError(err.message || 'Import failed.'); } finally { setIsImporting(false); } };
const handleGenerateExport = async () => { setIsExporting(true); try { const json = await exportBitwarden(); setExportJson(json); } catch (err: any) { alert(err.message || 'Failed to export vault.'); } finally { setIsExporting(false); } };
const handleDownloadFile = () => { if (!exportJson) return; const blob = new Blob([exportJson], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bitwarden_export_${new Date().toISOString().slice(0, 10)}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); };
const handleCopyExport = async () => { if (!exportJson) return; try { await navigator.clipboard.writeText(exportJson); setCopiedExport(true); setTimeout(() => setCopiedExport(false), 2000); } catch { // ignore } };
return ( <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-in fade-in duration-150"> <div className="w-full max-w-xl rounded-2xl bg-slate-900 border border-slate-700/80 shadow-2xl overflow-hidden animate-in zoom-in-95 duration-150 flex flex-col max-h-[90vh]"> <div className="px-6 py-4 border-b border-slate-800 flex items-center justify-between shrink-0"> <div className="flex items-center gap-2.5"> <div className="p-2 rounded-xl bg-teal-500/10 text-teal-400 border border-teal-500/20"> <FileJson className="w-4 h-4" /> </div> <div> <h3 className="text-sm font-bold text-white">Bitwarden Vault Sync</h3> <p className="text-[11px] text-slate-400">Import & Export Standard JSON</p> </div> </div> <button type="button" onClick={closeBitwardenModal} className="p-1 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-slate-800 cursor-pointer" > <X className="w-4 h-4" /> </button> </div>
<div className="px-6 pt-4 pb-2 shrink-0"> <div className="flex rounded-xl bg-slate-950 p-1 border border-slate-800"> <button type="button" onClick={() => setActiveTab('import')} className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer flex items-center justify-center gap-1.5 ${ activeTab === 'import' ? 'bg-slate-800 text-teal-300 shadow-xs' : 'text-slate-400 hover:text-slate-200' }`} > <Upload className="w-3.5 h-3.5" /> Import Bitwarden </button> <button type="button" onClick={() => { setActiveTab('export'); if (!exportJson) handleGenerateExport(); }} className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer flex items-center justify-center gap-1.5 ${ activeTab === 'export' ? 'bg-slate-800 text-teal-300 shadow-xs' : 'text-slate-400 hover:text-slate-200' }`} > <Download className="w-3.5 h-3.5" /> Export Vault </button> </div> </div>
<div className="p-6 overflow-y-auto space-y-4 flex-1"> {activeTab === 'import' ? ( <div className="space-y-4"> <div className="p-3 rounded-xl bg-slate-950 border border-slate-800 text-xs text-slate-400 leading-relaxed"> Export your Bitwarden vault as <span className="font-semibold text-slate-200">unencrypted .json</span> format. Upload the file or paste the contents below. </div>
{importSuccess !== null && ( <div className="p-3.5 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-300 text-xs flex items-center gap-2.5"> <Check className="w-4 h-4 text-emerald-400 shrink-0" /> <span> Successfully imported <strong className="text-white">{importSuccess}</strong> credentials into your encrypted vault! </span> </div> )}
{parseError && ( <div className="p-3.5 rounded-xl bg-rose-500/10 border border-rose-500/30 text-rose-300 text-xs flex items-center gap-2.5"> <AlertCircle className="w-4 h-4 text-rose-400 shrink-0" /> <span>{parseError}</span> </div> )}
<div onClick={() => fileInputRef.current?.click()} className="p-6 rounded-2xl border-2 border-dashed border-slate-700 hover:border-teal-400/60 bg-slate-950/40 hover:bg-slate-950/80 cursor-pointer transition-all text-center group" > <input ref={fileInputRef} type="file" accept=".json" onChange={handleFileUpload} className="hidden" /> <Upload className="w-8 h-8 mx-auto text-slate-500 group-hover:text-teal-400 transition-colors mb-2" /> <p className="text-xs font-semibold text-slate-300 group-hover:text-white"> Choose or drag & drop a Bitwarden .json export file </p> <p className="text-[11px] text-slate-500 mt-1">Supports unencrypted JSON exports</p> </div>
<div className="space-y-1.5"> <label className="block text-xs font-medium text-slate-400"> Or paste JSON directly: </label> <textarea rows={5} value={jsonInput} onChange={(e) => handleJsonChange(e.target.value)} placeholder='{"encrypted": false, "items": [...]}' className="w-full p-3 bg-slate-950 border border-slate-800 rounded-xl text-xs font-mono text-slate-300 focus:outline-none focus:border-teal-500" /> </div>
{preview && ( <div className="p-4 rounded-xl bg-teal-950/20 border border-teal-500/30 space-y-3"> <div className="flex items-center justify-between"> <span className="text-xs font-bold text-teal-300 uppercase tracking-wider"> Detected Items to Import ({preview.total}) </span> </div> <div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs"> <div className="p-2 rounded-lg bg-slate-950 border border-slate-800 flex items-center gap-2"> <KeyRound className="w-3.5 h-3.5 text-sky-400" /> <span className="text-slate-300">{preview.logins} Logins</span> </div> <div className="p-2 rounded-lg bg-slate-950 border border-slate-800 flex items-center gap-2"> <FileText className="w-3.5 h-3.5 text-amber-400" /> <span className="text-slate-300">{preview.notes} Notes</span> </div> <div className="p-2 rounded-lg bg-slate-950 border border-slate-800 flex items-center gap-2"> <CreditCard className="w-3.5 h-3.5 text-emerald-400" /> <span className="text-slate-300">{preview.cards} Cards</span> </div> <div className="p-2 rounded-lg bg-slate-950 border border-slate-800 flex items-center gap-2"> <User className="w-3.5 h-3.5 text-indigo-400" /> <span className="text-slate-300">{preview.identities} Identities</span> </div> </div>
<button type="button" onClick={handleExecuteImport} disabled={isImporting || preview.total === 0} className="w-full py-2.5 rounded-xl bg-teal-500 hover:bg-teal-400 text-slate-950 font-bold text-xs cursor-pointer shadow-md shadow-teal-500/20 transition-all flex items-center justify-center gap-2 disabled:opacity-50" > {isImporting ? ( 'Importing & Encrypting...' ) : ( <> <span>Confirm & Import {preview.total} Items</span> <ArrowRight className="w-4 h-4" /> </> )} </button> </div> )} </div> ) : ( <div className="space-y-4"> <div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start gap-2.5"> <ShieldAlert className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" /> <div className="leading-relaxed"> <strong>Security Note:</strong> Bitwarden JSON export will contain decrypted passwords and notes. Ensure your device is secure before downloading or copying. </div> </div>
{exportJson && ( <div className="space-y-3"> <div className="flex items-center justify-between text-xs text-slate-400"> <span>Export preview ({activeItems.length} items decrypted):</span> <div className="flex items-center gap-2"> <button type="button" onClick={handleCopyExport} className="flex items-center gap-1 text-cyan-400 hover:text-cyan-300 cursor-pointer font-medium" > {copiedExport ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} <span>{copiedExport ? 'Copied!' : 'Copy JSON'}</span> </button> </div> </div>
<pre className="p-3 bg-slate-950 border border-slate-800 rounded-xl text-[11px] font-mono text-slate-300 max-h-56 overflow-y-auto select-all"> {exportJson} </pre>
<button type="button" onClick={handleDownloadFile} className="w-full py-2.5 rounded-xl bg-teal-500 hover:bg-teal-400 text-slate-950 font-bold text-xs cursor-pointer shadow-md shadow-teal-500/20 transition-all flex items-center justify-center gap-2" > <Download className="w-4 h-4 stroke-[2.5]" /> <span>Download JSON File</span> </button> </div> )} </div> )} </div> </div> </div> );};