1- import { useState , useEffect , useRef , useCallback , FormEvent , DragEvent } from "react" ;
2- import {
3- ExclamationCircleIcon ,
4- ArrowUpTrayIcon ,
5- CheckCircleIcon ,
6- } from "@heroicons/react/24/outline" ;
1+ import { useState , useEffect , FormEvent } from "react" ;
2+ import { ExclamationCircleIcon } from "@heroicons/react/24/outline" ;
73import { useVaultStore , DuplicateKeyError } from "@/stores/vaultStore" ;
84import { validatePrivateKey , getFingerprint } from "@/utils/ssh-keys" ;
95import Drawer from "@/components/common/Drawer" ;
10- import { LABEL , INPUT , INPUT_MONO } from "@/utils/styles" ;
6+ import KeyFileInput from "@/components/common/KeyFileInput" ;
7+ import { LABEL , INPUT } from "@/utils/styles" ;
118import type { VaultKeyEntry } from "@/types/vault" ;
129
1310interface Props {
@@ -29,29 +26,23 @@ export default function KeyDrawer({ open, editKey, onClose }: Props) {
2926 const [ passphraseError , setPassphraseError ] = useState < string | null > ( null ) ;
3027 const [ submitting , setSubmitting ] = useState ( false ) ;
3128 const [ error , setError ] = useState < string | null > ( null ) ;
32- const [ inputMode , setInputMode ] = useState < "file" | "text" > ( "file" ) ;
33- const [ dragging , setDragging ] = useState ( false ) ;
34- const fileInputRef = useRef < HTMLInputElement > ( null ) ;
3529
3630 useEffect ( ( ) => {
3731 if ( ! open ) return ;
3832 if ( editKey ) {
3933 setName ( editKey . name ) ;
4034 setKeyData ( editKey . data ) ;
4135 setEncrypted ( editKey . hasPassphrase ) ;
42- setInputMode ( "text" ) ;
4336 } else {
4437 setName ( "" ) ;
4538 setKeyData ( "" ) ;
4639 setEncrypted ( false ) ;
47- setInputMode ( "file" ) ;
4840 }
4941 setNameError ( null ) ;
5042 setPassphrase ( "" ) ;
5143 setKeyError ( null ) ;
5244 setPassphraseError ( null ) ;
5345 setError ( null ) ;
54- setDragging ( false ) ;
5546 } , [ open , editKey ] ) ;
5647
5748 const handleNameChange = ( value : string ) => {
@@ -81,46 +72,11 @@ export default function KeyDrawer({ open, editKey, onClose }: Props) {
8172 setEncrypted ( result . encrypted ) ;
8273 } ;
8374
84- const processFile = ( file : File ) => {
85- if ( file . size > 512 * 1024 ) return ;
86- const reader = new FileReader ( ) ;
87- reader . onload = ( ) => {
88- const text = reader . result as string ;
89- handleKeyChange ( text ) ;
90- if ( ! name ) {
91- const base = file . name . replace ( / \. [ ^ . ] + $ / , "" ) ;
92- setName ( base ) ;
93- }
94- } ;
95- reader . readAsText ( file ) ;
96- } ;
97-
98- const handleDrop = ( e : DragEvent ) => {
99- e . preventDefault ( ) ;
100- setDragging ( false ) ;
101- const file = e . dataTransfer ?. files ?. [ 0 ] ;
102- if ( file ) processFile ( file ) ;
75+ const handleFileName = ( fileName : string ) => {
76+ if ( ! name ) setName ( fileName ) ;
10377 } ;
10478
105- const handleKeyChangeRef = useRef ( handleKeyChange ) ;
106- handleKeyChangeRef . current = handleKeyChange ;
107-
108- const handlePaste = useCallback ( ( e : Event ) => {
109- const ce = e as ClipboardEvent ;
110- const text = ce . clipboardData ?. getData ( "text" ) ;
111- if ( ! text ) return ;
112- const result = validatePrivateKey ( text . trim ( ) ) ;
113- if ( result . valid ) {
114- e . preventDefault ( ) ;
115- handleKeyChangeRef . current ( text ) ;
116- }
117- } , [ ] ) ;
118-
119- useEffect ( ( ) => {
120- if ( ! open || isEdit ) return ;
121- document . addEventListener ( "paste" , handlePaste ) ;
122- return ( ) => document . removeEventListener ( "paste" , handlePaste ) ;
123- } , [ open , isEdit , handlePaste ] ) ;
79+ const validateForPaste = ( text : string ) => validatePrivateKey ( text . trim ( ) ) . valid ;
12480
12581 const handlePassphraseChange = ( value : string ) => {
12682 setPassphrase ( value ) ;
@@ -259,114 +215,22 @@ export default function KeyDrawer({ open, editKey, onClose }: Props) {
259215 ) }
260216 </ div >
261217
262- { /* Private key data — file or text input */ }
263- < div >
264- < div className = "flex items-center justify-between mb-1.5" >
265- < label htmlFor = "key-data" className = { LABEL + " !mb-0" } >
266- Private Key
267- </ label >
268- { ! isEdit && (
269- < div className = "flex gap-1" >
270- < button
271- type = "button"
272- onClick = { ( ) => setInputMode ( "file" ) }
273- className = { `px-2 py-0.5 rounded text-2xs font-medium transition-all ${ inputMode === "file" ? "bg-primary/10 text-primary" : "text-text-muted hover:text-text-secondary" } ` }
274- >
275- File
276- </ button >
277- < button
278- type = "button"
279- onClick = { ( ) => setInputMode ( "text" ) }
280- className = { `px-2 py-0.5 rounded text-2xs font-medium transition-all ${ inputMode === "text" ? "bg-primary/10 text-primary" : "text-text-muted hover:text-text-secondary" } ` }
281- >
282- Text
283- </ button >
284- </ div >
285- ) }
286- </ div >
287-
288- { inputMode === "file" && ! isEdit ? (
289- < div
290- onDragOver = { ( e ) => {
291- e . preventDefault ( ) ;
292- setDragging ( true ) ;
293- } }
294- onDragLeave = { ( ) => setDragging ( false ) }
295- onDrop = { handleDrop }
296- onClick = { ( ) => fileInputRef . current ?. click ( ) }
297- className = { `flex flex-col items-center justify-center gap-2 px-4 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-all ${
298- dragging
299- ? "border-primary bg-primary/5"
300- : keyData
301- ? "border-accent-green/30 bg-accent-green/5"
302- : `border-border hover:border-primary/30 ${ keyError ? "border-accent-red/30" : "" } `
303- } `}
304- >
305- < input
306- ref = { fileInputRef }
307- type = "file"
308- accept = ".pem,.key,.txt"
309- className = "hidden"
310- onChange = { ( e ) => {
311- const file = e . target . files ?. [ 0 ] ;
312- if ( file ) processFile ( file ) ;
313- e . target . value = "" ;
314- } }
315- />
316- { keyData ? (
317- < >
318- < CheckCircleIcon className = "w-5 h-5 text-accent-green" />
319- < span className = "text-xs text-accent-green font-medium" >
320- Key loaded { encrypted && "(encrypted)" }
321- </ span >
322- < button
323- type = "button"
324- onClick = { ( e ) => {
325- e . stopPropagation ( ) ;
326- handleKeyChange ( "" ) ;
327- } }
328- className = "text-2xs text-text-muted hover:text-text-primary transition-colors"
329- >
330- Clear
331- </ button >
332- </ >
333- ) : (
334- < >
335- < ArrowUpTrayIcon className = "w-5 h-5 text-text-muted" />
336- < span className = "text-xs text-text-secondary" >
337- Drop private key file, paste, or browse
338- </ span >
339- </ >
340- ) }
341- </ div >
342- ) : (
343- < textarea
344- id = "key-data"
345- value = { keyData }
346- onChange = { ( e ) => handleKeyChange ( e . target . value ) }
347- placeholder = { "-----BEGIN OPENSSH PRIVATE KEY-----\n..." }
348- rows = { 8 }
349- aria-invalid = { ! ! keyError }
350- aria-describedby = { keyError ? "key-data-error" : undefined }
351- className = { `${ INPUT_MONO } resize-none` }
352- />
353- ) }
354-
355- { ! isEdit && (
356- < p className = "mt-1 text-2xs text-text-muted" >
357- RSA, DSA, ECDSA, ED25519 — PEM and OpenSSH formats.
358- </ p >
359- ) }
360- { keyError && (
361- < p
362- id = "key-data-error"
363- className = "text-2xs text-accent-red mt-1.5 flex items-center gap-1"
364- >
365- < ExclamationCircleIcon className = "w-3.5 h-3.5 shrink-0" />
366- { keyError }
367- </ p >
368- ) }
369- </ div >
218+ < KeyFileInput
219+ label = "Private Key"
220+ id = "key-data"
221+ value = { keyData }
222+ onChange = { handleKeyChange }
223+ validate = { validateForPaste }
224+ onFileName = { handleFileName }
225+ disabled = { isEdit }
226+ error = { keyError }
227+ accept = ".pem,.key,.txt"
228+ placeholder = { "-----BEGIN OPENSSH PRIVATE KEY-----\n..." }
229+ rows = { 8 }
230+ hint = "RSA, DSA, ECDSA, ED25519 — PEM and OpenSSH formats."
231+ loadedLabel = { `Key loaded${ encrypted ? " (encrypted)" : "" } ` }
232+ emptyLabel = "Drop private key file, paste, or browse"
233+ />
370234
371235 { encrypted && (
372236 < div >
0 commit comments