265 lines
7.7 KiB
TypeScript
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'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>
|
|
)
|
|
}
|