/**
* Restore Modal - Lit Component
*
* Framework-agnostic restore modal for wallet data import.
* Uses lukso-modal from @lukso/web-components for consistent UI.
*/
import { safeCustomElement } from '@lukso/core/utils'
import type {
BackupFile,
PasskeyAuthProvider,
WalletData,
} from '@lukso/passkey-auth'
import { readBackupFile } from '@lukso/passkey-auth'
import { html, nothing } from 'lit'
import { property, state } from 'lit/decorators.js'
import { CoreLitElement } from './styles'
// Import lukso web components
import '@lukso/web-components/dist/components/lukso-modal'
import '@lukso/web-components/dist/components/lukso-button'
import '@lukso/web-components/dist/components/lukso-input'
enum Step {
UPLOAD = 1,
PASSWORD = 2,
PREVIEW = 3,
SUCCESS = 4,
}
@safeCustomElement('restore-modal')
export class RestoreModal extends CoreLitElement {
// Public properties
@property({ type: Boolean, reflect: true }) isOpen = false
@property({ type: String }) theme: 'light' | 'dark' | 'auto' = 'auto'
@property({ type: Object }) keyGenerator: PasskeyAuthProvider | null = null
// Private state
@state() private currentStep: Step = Step.UPLOAD
@state() private selectedFile: File | null = null
@state() private backupData: BackupFile | null = null
@state() private fileError: string | null = null
@state() private isDragging = false
@state() private password = ''
@state() private passwordErrors: string[] = []
@state() private isRestoring = false
@state() private validationWarnings: string[] = []
@state() private controllers: Array<{
address: string
status: 'new' | 'existing'
}> = []
@state() private restoredAddresses: string[] = []
validationInfo: string[] = []
walletData: WalletData | null = null
/**
* Public method for parent to set preview result
* Called after parent handles the 'preview' event
*/
public setPreviewResult(options: {
controllers: Array<{ address: string; status: 'new' | 'existing' }>
warnings?: string[]
}) {
this.controllers = options.controllers
this.validationWarnings = options.warnings || []
}
/**
* Public method for parent to set validation result
* Called after parent handles the 'validate' event
*/
public setValidationResult(
walletData: WalletData | null,
options?: {
error?: string
warnings?: string[]
info?: string[]
}
) {
this.isRestoring = false
if (options?.error) {
this.passwordErrors = [options.error]
this.validationWarnings = []
this.validationInfo = []
} else if (walletData) {
this.walletData = walletData
this.validationWarnings = options?.warnings || []
this.validationInfo = options?.info || []
this.currentStep = Step.SUCCESS
}
}
render() {
return html`
${this.getStepTitle()}
${this.renderStep()}
`
}
private renderStep() {
switch (this.currentStep) {
case Step.UPLOAD:
return this.renderUploadStep()
case Step.PASSWORD:
return this.renderPasswordStep()
case Step.PREVIEW:
return this.renderPreviewStep()
case Step.SUCCESS:
return this.renderSuccessStep()
default:
return nothing
}
}
private renderUploadStep() {
return html`
Select your backup file to restore your wallet data. Your backup file stays on your device and is never uploaded.
Drag and drop your backup file here
or
{
e.stopPropagation()
this.triggerFileInput()
}}
>
Choose File
${
this.selectedFile
? html`
${this.selectedFile.name}
`
: nothing
}
${
this.fileError
? html`
${this.fileError}
`
: nothing
}
Cancel
`
}
private renderPasswordStep() {
const canProceed = this.password && this.password.length > 0
return html`
This backup is encrypted. Enter the password to decrypt it.
{
this.password = e.detail.value
}}
@keyup=${(e: KeyboardEvent) => e.key === 'Enter' && canProceed && this.previewRestore()}
is-full-width
autofocus
>
${this.passwordErrors.map(
(error) => html`
${error}
`
)}
`
}
private renderPreviewStep() {
return html`
${
this.controllers.length > 0
? `Found ${this.controllers.length} controller${this.controllers.length === 1 ? '' : 's'} in backup.`
: 'No controllers found in backup.'
}
${
this.controllers.length > 0
? html`
Controllers:
${this.controllers.map(
(controller) => html`
${this.truncateAddress(controller.address)}
${controller.status === 'new' ? 'NEW' : 'EXISTS'}
${controller.address}
`
)}
`
: nothing
}
${
this.validationWarnings.length > 0
? html`
${this.validationWarnings.map(
(warning) => html`
⚠️
${warning}
`
)}
`
: nothing
}
Cancel
Back
Confirm Restore
`
}
private renderSuccessStep() {
return html`
✅
Wallet Restored Successfully!
${
this.restoredAddresses.length > 0
? `Imported ${this.restoredAddresses.length} address${this.restoredAddresses.length > 1 ? 'es' : ''} to your wallet.`
: 'Your wallet has been restored successfully.'
}
${
this.restoredAddresses.length > 0
? html`
Restored Addresses:
${this.restoredAddresses.map(
(address: string) => html`
✓
${this.truncateAddress(address)}
`
)}
`
: ''
}
OK
`
}
private getStepTitle(): string {
switch (this.currentStep) {
case Step.UPLOAD:
return 'Restore Wallet'
case Step.PASSWORD:
return 'Enter Password'
case Step.PREVIEW:
return 'Review Changes'
case Step.SUCCESS:
return 'Restore Complete'
default:
return 'Restore Wallet'
}
}
private triggerFileInput() {
const input = this.shadowRoot?.querySelector(
'input[type="file"]'
) as HTMLInputElement
if (input) {
input.click()
}
}
private async handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) {
await this.handleFile(file)
}
}
private handleDragOver(e: DragEvent) {
e.preventDefault()
this.isDragging = true
}
private handleDragLeave() {
this.isDragging = false
}
private async handleDrop(e: DragEvent) {
e.preventDefault()
this.isDragging = false
const file = e.dataTransfer?.files?.[0]
if (file) {
await this.handleFile(file)
}
}
private async handleFile(file: File) {
this.fileError = null
if (!file.name.endsWith('.json')) {
this.fileError = 'Please select a valid JSON backup file'
return
}
try {
// Use passkey-auth readBackupFile function
const backup = await readBackupFile(file)
this.selectedFile = file
this.backupData = backup
// Check if encrypted (handle both current and legacy formats)
const isEncrypted = backup.encryptedSecrets // Legacy: always encrypted if encryptedSecrets exists
? true
: backup.secrets?.encrypted // Current: check encrypted flag
if (isEncrypted) {
this.currentStep = Step.PASSWORD
} else {
// Unencrypted backup - preview directly
await this.previewRestore()
}
} catch (error) {
this.fileError =
error instanceof Error ? error.message : 'Failed to read backup file'
console.error('Failed to read backup file:', error)
}
}
private async previewRestore() {
if (!this.backupData) return
this.isRestoring = true
this.passwordErrors = []
try {
// First, we need to decrypt the backup if it's encrypted
let decryptedBackup = this.backupData
if (
this.backupData.encryptedSecrets ||
this.backupData.secrets?.encrypted
) {
// PREVIEW ONLY: Use verify to check password and get preview data (don't actually import yet)
if (!this.keyGenerator) {
throw new Error('KeyGenerator not available')
}
// Use verifyBackup for preview - this validates password and returns public data only
const verifiedBackup = await this.keyGenerator.verifyBackup(
this.backupData,
this.password
)
// Use the verified backup data (which has filtered public data)
decryptedBackup = verifiedBackup
} else {
// Unencrypted - use data directly for preview (no import needed)
decryptedBackup = this.backupData
}
// Extract all controller addresses from the backup
const backupControllers = new Set()
// Get addresses from the secrets (the actual private keys we're restoring)
const secretsData = (decryptedBackup.secrets?.data as any[]) || []
for (const secret of secretsData) {
if (secret.address) {
backupControllers.add(secret.address.toLowerCase())
}
}
// Also get controllers from account metadata (V2 format with networks)
for (const account of decryptedBackup.accounts || []) {
// V2 format: accounts have networks array with controllers
if (account.networks) {
for (const network of account.networks) {
for (const controller of network.controllers || []) {
if (controller.address) {
backupControllers.add(controller.address.toLowerCase())
}
}
}
}
}
// Also get controllers from LSP23CrossChainDeployment if present
const lsp23Deployments =
(decryptedBackup as any).LSP23CrossChainDeployment || []
for (const deployment of lsp23Deployments) {
for (const controller of deployment.initialControllers || []) {
if (controller.address) {
backupControllers.add(controller.address.toLowerCase())
}
}
}
console.log(
'🔍 [RestoreModal] Found controller addresses:',
Array.from(backupControllers)
)
// Extract addresses from the decrypted backup for preview
for (const secret of secretsData) {
if (secret.address) {
backupControllers.add(secret.address.toLowerCase())
}
}
// For now, mark all addresses as 'new' - could enhance this later
this.controllers = Array.from(backupControllers).map((address) => ({
address,
status: 'new' as const,
}))
this.currentStep = Step.PREVIEW
} catch (error) {
console.error('Failed to preview restore:', error)
const errorMessage =
error instanceof Error ? error.message : 'Failed to preview restore'
// Check for password-specific errors
if (errorMessage.toLowerCase().includes('password')) {
this.passwordErrors = ['Incorrect password. Please try again.']
} else {
// For non-password errors, set as file error to show in upload step
this.fileError = errorMessage
this.currentStep = Step.UPLOAD
// Clear the file selection so user can try again
this.selectedFile = null
this.backupData = null
// Clear the file input
const input = this.shadowRoot?.querySelector(
'input[type="file"]'
) as HTMLInputElement
if (input) {
input.value = ''
}
}
} finally {
this.isRestoring = false
}
}
private async confirmRestore() {
if (!this.backupData || !this.keyGenerator) {
console.error(
'❌ [RestoreModal] Missing backup data or keyGenerator for restore'
)
this.dispatchEvent(
new CustomEvent('error', {
detail: { error: 'Missing backup data or keyGenerator' },
bubbles: true,
composed: true,
})
)
return
}
try {
this.isRestoring = true
console.log('🔄 [RestoreModal] Starting secure restoration process...')
// ✅ SECURE: Use keyGenerator.restoreFromBackup internally
const result = await this.keyGenerator.restoreFromBackup(
this.backupData,
this.password
)
if (result.success) {
console.log('✅ [RestoreModal] Restore successful:', result.addresses)
// Store restored addresses for display on success screen
this.restoredAddresses = result.addresses
// Emit success event (only public addresses, no sensitive data)
this.dispatchEvent(
new CustomEvent('success', {
detail: { addresses: result.addresses },
bubbles: true,
composed: true,
})
)
// Move to success step
this.currentStep = Step.SUCCESS
} else {
console.error('❌ [RestoreModal] Restore failed')
this.dispatchEvent(
new CustomEvent('error', {
detail: {
error:
'Restore failed. Please check the backup file and password.',
},
bubbles: true,
composed: true,
})
)
}
} catch (error) {
console.error('❌ [RestoreModal] Restore error:', error)
const errorMessage =
error instanceof Error ? error.message : 'Unknown error'
this.dispatchEvent(
new CustomEvent('error', {
detail: { error: errorMessage },
bubbles: true,
composed: true,
})
)
} finally {
this.isRestoring = false
}
}
private goBack() {
if (this.currentStep === Step.PASSWORD) {
this.currentStep = Step.UPLOAD
this.password = ''
this.passwordErrors = []
}
}
private goBackFromPreview() {
// Go back to password step (or upload if unencrypted)
const isEncrypted = this.backupData?.encryptedSecrets
? true
: this.backupData?.secrets?.encrypted
if (isEncrypted) {
this.currentStep = Step.PASSWORD
} else {
this.currentStep = Step.UPLOAD
}
// Clear preview data
this.controllers = []
this.validationWarnings = []
}
private close() {
this.isOpen = false
this.dispatchEvent(
new CustomEvent('close', { bubbles: true, composed: true })
)
// Reset state after animation
setTimeout(() => {
this.currentStep = Step.UPLOAD
this.selectedFile = null
this.backupData = null
this.fileError = null
this.isDragging = false
this.password = ''
this.passwordErrors = []
this.isRestoring = false
this.walletData = null
this.restoredAddresses = []
this.controllers = []
this.validationWarnings = []
}, 300)
}
private truncateAddress(address: string): string {
if (!address || address.length < 10) return address
return `${address.slice(0, 6)}...${address.slice(-4)}`
}
}
declare global {
interface HTMLElementTagNameMap {
'restore-modal': RestoreModal
}
}