@@ -3,6 +3,7 @@ import { storage, ProfileData } from '../../utils/storage';
33import { profileManager } from '../../utils/profile-manager' ;
44import { logger } from '../../utils/logger' ;
55import { validateProfile , VALIDATION_LIMITS } from '../../utils/validation' ;
6+ import { exportRecoveryFile , validatePassphrase } from '../../utils/recovery-file' ;
67import ProgressBar from './ProgressBar' ;
78import ImageCropper from './ImageCropper' ;
89
@@ -538,8 +539,121 @@ export function ProfileEditor() {
538539 < p className = "text-xs text-gray-400" >
539540 * Your profile will be saved as profile.json and a generated index.html on your homeserver
540541 </ p >
542+
543+ { /* Recovery File Export */ }
544+ < div className = "border-t border-gray-700 pt-4 mt-4" >
545+ < h3 className = "text-sm font-medium text-gray-300 mb-2" > Key Backup</ h3 >
546+ < p className = "text-xs text-gray-400 mb-3" >
547+ Export a recovery file to backup your keys. Store it securely - you'll need it if you lose access to your device.
548+ </ p >
549+ < button
550+ onClick = { ( ) => setShowRecoveryModal ( true ) }
551+ className = "w-full px-4 py-2 bg-yellow-600/20 hover:bg-yellow-600/30 text-yellow-400 border border-yellow-600/50 rounded-lg transition focus:outline-none focus:ring-2 focus:ring-yellow-500"
552+ aria-label = "Export recovery file"
553+ >
554+ 🔐 Export Recovery File
555+ </ button >
556+ </ div >
541557 </ div >
542558
559+ { /* Recovery File Modal */ }
560+ { showRecoveryModal && (
561+ < div className = "fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" >
562+ < div className = "bg-[#1F1F1F] border border-[#3F3F3F] rounded-lg p-6 max-w-md w-full" >
563+ < h3 className = "text-lg font-bold text-white mb-4" > Export Recovery File</ h3 >
564+ < p className = "text-sm text-gray-400 mb-4" >
565+ Enter a strong passphrase to encrypt your recovery file. You'll need this passphrase to restore your keys.
566+ </ p >
567+
568+ < div className = "space-y-3" >
569+ < div >
570+ < label className = "block text-sm font-medium text-gray-300 mb-1" >
571+ Passphrase
572+ </ label >
573+ < input
574+ type = "password"
575+ value = { recoveryPassphrase }
576+ onChange = { ( e ) => {
577+ setRecoveryPassphrase ( e . target . value ) ;
578+ setRecoveryError ( null ) ;
579+ } }
580+ className = "w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-yellow-500"
581+ placeholder = "Enter passphrase (min 8 characters)"
582+ />
583+ </ div >
584+
585+ < div >
586+ < label className = "block text-sm font-medium text-gray-300 mb-1" >
587+ Confirm Passphrase
588+ </ label >
589+ < input
590+ type = "password"
591+ value = { recoveryPassphraseConfirm }
592+ onChange = { ( e ) => {
593+ setRecoveryPassphraseConfirm ( e . target . value ) ;
594+ setRecoveryError ( null ) ;
595+ } }
596+ className = "w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-yellow-500"
597+ placeholder = "Confirm passphrase"
598+ />
599+ </ div >
600+
601+ { recoveryError && (
602+ < div className = "text-sm text-red-400" > { recoveryError } </ div >
603+ ) }
604+
605+ < div className = "flex gap-2 mt-4" >
606+ < button
607+ onClick = { async ( ) => {
608+ setRecoveryError ( null ) ;
609+
610+ // Validate passphrase
611+ const validation = validatePassphrase ( recoveryPassphrase ) ;
612+ if ( ! validation . isValid ) {
613+ setRecoveryError ( validation . error || 'Invalid passphrase' ) ;
614+ return ;
615+ }
616+
617+ if ( recoveryPassphrase !== recoveryPassphraseConfirm ) {
618+ setRecoveryError ( 'Passphrases do not match' ) ;
619+ return ;
620+ }
621+
622+ try {
623+ setIsExportingRecovery ( true ) ;
624+ await exportRecoveryFile ( recoveryPassphrase ) ;
625+ showMessage ( 'success' , 'Recovery file exported successfully' ) ;
626+ setShowRecoveryModal ( false ) ;
627+ setRecoveryPassphrase ( '' ) ;
628+ setRecoveryPassphraseConfirm ( '' ) ;
629+ } catch ( error ) {
630+ setRecoveryError ( ( error as Error ) . message || 'Failed to export recovery file' ) ;
631+ } finally {
632+ setIsExportingRecovery ( false ) ;
633+ }
634+ } }
635+ disabled = { isExportingRecovery || ! recoveryPassphrase || ! recoveryPassphraseConfirm }
636+ className = "flex-1 px-4 py-2 bg-yellow-600 hover:bg-yellow-700 disabled:bg-gray-600 text-white rounded-lg transition focus:outline-none focus:ring-2 focus:ring-yellow-500"
637+ >
638+ { isExportingRecovery ? 'Exporting...' : 'Export' }
639+ </ button >
640+ < button
641+ onClick = { ( ) => {
642+ setShowRecoveryModal ( false ) ;
643+ setRecoveryPassphrase ( '' ) ;
644+ setRecoveryPassphraseConfirm ( '' ) ;
645+ setRecoveryError ( null ) ;
646+ } }
647+ className = "px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition focus:outline-none focus:ring-2 focus:ring-gray-500"
648+ >
649+ Cancel
650+ </ button >
651+ </ div >
652+ </ div >
653+ </ div >
654+ </ div >
655+ ) }
656+
543657 { /* Image Cropper Modal */ }
544658 { showCropper && imageFileToCrop && (
545659 < ImageCropper
0 commit comments