story-research-zapwall/components/UnlockAccountModal.tsx

265 lines
7.7 KiB
TypeScript

import { useState, useRef, useEffect } from 'react'
import { nostrAuthService } from '@/lib/nostrAuth'
import { getWordSuggestions } from '@/lib/keyManagementBIP39'
interface UnlockAccountModalProps {
onSuccess: () => void
onClose: () => void
}
function WordInputWithAutocomplete({
index,
value,
onChange,
onFocus,
onBlur,
}: {
index: number
value: string
onChange: (value: string) => void
onFocus: () => void
onBlur: () => void
}) {
const [suggestions, setSuggestions] = useState<string[]>([])
const [showSuggestions, setShowSuggestions] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const suggestionsRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (value.length > 0) {
const newSuggestions = getWordSuggestions(value, 5)
setSuggestions(newSuggestions)
setShowSuggestions(newSuggestions.length > 0)
setSelectedIndex(-1)
} else {
setSuggestions([])
setShowSuggestions(false)
}
}, [value])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value.trim().toLowerCase()
onChange(newValue)
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
} else if (e.key === 'Enter' && selectedIndex >= 0 && suggestions[selectedIndex]) {
e.preventDefault()
onChange(suggestions[selectedIndex] ?? '')
setShowSuggestions(false)
inputRef.current?.blur()
} else if (e.key === 'Escape') {
setShowSuggestions(false)
inputRef.current?.blur()
}
}
const handleSuggestionClick = (suggestion: string) => {
onChange(suggestion)
setShowSuggestions(false)
inputRef.current?.blur()
}
return (
<div className="relative">
<label htmlFor={`word-${index}`} className="block text-sm font-medium text-gray-700 mb-2">
Mot {index + 1}
</label>
<input
ref={inputRef}
id={`word-${index}`}
type="text"
value={value}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={onFocus}
onBlur={() => {
// Delay to allow click on suggestion
setTimeout(() => {
setShowSuggestions(false)
onBlur()
}, 200)
}}
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-lg text-center"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
/>
{showSuggestions && suggestions.length > 0 && (
<div
ref={suggestionsRef}
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-lg shadow-lg max-h-40 overflow-y-auto"
>
{suggestions.map((suggestion, idx) => (
<button
key={suggestion}
type="button"
onClick={() => handleSuggestionClick(suggestion)}
className={`w-full text-left px-3 py-2 hover:bg-gray-100 ${
idx === selectedIndex ? 'bg-gray-100' : ''
}`}
>
{suggestion}
</button>
))}
</div>
)}
</div>
)
}
function WordInputs({
words,
onWordChange,
}: {
words: string[]
onWordChange: (index: number, value: string) => void
}) {
const [, setFocusedIndex] = useState<number | null>(null)
return (
<div className="grid grid-cols-2 gap-4">
{words.map((word, index) => (
<WordInputWithAutocomplete
key={index}
index={index}
value={word}
onChange={(value) => onWordChange(index, value)}
onFocus={() => setFocusedIndex(index)}
onBlur={() => setFocusedIndex(null)}
/>
))}
</div>
)
}
function useUnlockAccount(words: string[], setWords: (words: string[]) => void, setError: (error: string | null) => void) {
const handleWordChange = (index: number, value: string) => {
const newWords = [...words]
newWords[index] = value.trim().toLowerCase()
setWords(newWords)
setError(null)
}
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText()
const pastedWords = text.trim().split(/\s+/).slice(0, 4)
if (pastedWords.length === 4) {
setWords(pastedWords.map((w) => w.toLowerCase()))
setError(null)
}
} catch (_e) {
// Ignore clipboard errors
}
}
return { handleWordChange, handlePaste }
}
function UnlockAccountButtons({
loading,
words,
onUnlock,
onClose,
}: {
loading: boolean
words: string[]
onUnlock: () => void
onClose: () => void
}) {
return (
<div className="flex gap-4">
<button
onClick={onClose}
className="flex-1 py-2 px-4 bg-gray-200 hover:bg-gray-300 rounded-lg font-medium transition-colors"
>
Annuler
</button>
<button
onClick={() => {
void onUnlock()
}}
disabled={loading || words.some((word) => !word)}
className="flex-1 py-2 px-4 bg-neon-cyan/20 hover:bg-neon-cyan/30 text-neon-cyan rounded-lg font-medium transition-all border border-neon-cyan/50 hover:shadow-glow-cyan disabled:opacity-50"
>
{loading ? 'Déverrouillage...' : 'Déverrouiller'}
</button>
</div>
)
}
function UnlockAccountForm({
words,
handleWordChange,
handlePaste,
}: {
words: string[]
handleWordChange: (index: number, value: string) => void
handlePaste: () => void
}) {
return (
<div className="mb-4">
<WordInputs words={words} onWordChange={handleWordChange} />
<button
onClick={() => {
void handlePaste()
}}
className="mt-2 text-sm text-gray-600 hover:text-gray-800 underline"
>
Coller depuis le presse-papiers
</button>
</div>
)
}
export function UnlockAccountModal({ onSuccess, onClose }: UnlockAccountModalProps) {
const [words, setWords] = useState(['', '', '', ''])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const { handleWordChange, handlePaste } = useUnlockAccount(words, setWords, setError)
const handleUnlock = async () => {
if (words.some((word) => !word)) {
setError('Veuillez remplir tous les mots-clés')
return
}
setLoading(true)
setError(null)
try {
await nostrAuthService.unlockAccount(words)
onSuccess()
onClose()
} catch (e) {
setError(e instanceof Error ? e.message : 'Échec du déverrouillage. Vérifiez vos mots-clés.')
} finally {
setLoading(false)
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4">Déverrouiller votre compte</h2>
<p className="text-gray-600 mb-6">
Entrez vos 4 mots-clés de récupération (dictionnaire BIP39) pour déverrouiller votre compte.
Ces mots déchiffrent la clé de chiffrement (KEK) stockée dans l&apos;API Credentials, qui déchiffre ensuite votre clé privée.
</p>
<UnlockAccountForm words={words} handleWordChange={handleWordChange} handlePaste={handlePaste} />
{error && <p className="text-sm text-red-600 mb-4">{error}</p>}
<UnlockAccountButtons loading={loading} words={words} onUnlock={handleUnlock} onClose={onClose} />
</div>
</div>
)
}