OfflineSigningTool.tsx
1 import { useState } from 'react' 2 import { useMutation, useQueryClient } from '@tanstack/react-query' 3 import type { Chain } from '../../types/vote' 4 import type { 5 EmergencyAction, 6 OfflineSigningPayload, 7 SignedOfflinePayload, 8 } from '../../types/emergency' 9 import * as emergencyService from '../../services/emergency' 10 11 interface OfflineSigningToolProps { 12 action: EmergencyAction 13 chain: Chain 14 onClose?: () => void 15 } 16 17 export default function OfflineSigningTool({ 18 action, 19 chain, 20 onClose, 21 }: OfflineSigningToolProps) { 22 const queryClient = useQueryClient() 23 const [step, setStep] = useState<'generate' | 'import'>('generate') 24 const [payload, setPayload] = useState<OfflineSigningPayload | null>(null) 25 const [signedOutput, setSignedOutput] = useState('') 26 const [error, setError] = useState<string | null>(null) 27 28 // Generate payload for offline signing 29 const handleGeneratePayload = () => { 30 const generated = emergencyService.generateOfflinePayload(action) 31 setPayload(generated) 32 } 33 34 // Submit offline signature 35 const submitMutation = useMutation({ 36 mutationFn: (signedPayload: SignedOfflinePayload) => 37 emergencyService.submitOfflineSignature(signedPayload, chain), 38 onSuccess: () => { 39 queryClient.invalidateQueries({ queryKey: ['pendingEmergencyActions', chain] }) 40 onClose?.() 41 }, 42 onError: (err) => { 43 setError(err instanceof Error ? err.message : 'Failed to submit signature') 44 }, 45 }) 46 47 const handleImportSignature = () => { 48 setError(null) 49 const parsed = emergencyService.parseCLIOutput(signedOutput) 50 51 if (!parsed) { 52 setError('Invalid signature format. Please paste the complete CLI output.') 53 return 54 } 55 56 submitMutation.mutate(parsed) 57 } 58 59 const cliCommand = payload ? emergencyService.generateCLICommand(payload) : '' 60 61 return ( 62 <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"> 63 <div className="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"> 64 {/* Header */} 65 <div className="p-4 border-b flex items-center justify-between"> 66 <div> 67 <h3 className="font-semibold">Offline Signing Tool</h3> 68 <p className="text-sm text-gray-600"> 69 Sign emergency action from an air-gapped device 70 </p> 71 </div> 72 <button 73 onClick={onClose} 74 className="p-2 hover:bg-gray-100 rounded-lg" 75 > 76 <CloseIcon className="h-5 w-5" /> 77 </button> 78 </div> 79 80 {/* Tabs */} 81 <div className="border-b"> 82 <div className="flex"> 83 <button 84 onClick={() => setStep('generate')} 85 className={`px-4 py-2 text-sm font-medium border-b-2 ${ 86 step === 'generate' 87 ? 'border-red-500 text-red-600' 88 : 'border-transparent text-gray-500 hover:text-gray-700' 89 }`} 90 > 91 1. Generate Payload 92 </button> 93 <button 94 onClick={() => setStep('import')} 95 className={`px-4 py-2 text-sm font-medium border-b-2 ${ 96 step === 'import' 97 ? 'border-red-500 text-red-600' 98 : 'border-transparent text-gray-500 hover:text-gray-700' 99 }`} 100 > 101 2. Import Signature 102 </button> 103 </div> 104 </div> 105 106 {/* Content */} 107 <div className="p-4"> 108 {step === 'generate' && ( 109 <div className="space-y-4"> 110 {/* Action Summary */} 111 <div className="bg-red-50 border border-red-200 rounded-lg p-4"> 112 <div className="text-sm text-red-800"> 113 <strong>Action:</strong>{' '} 114 {emergencyService.formatActionType(action.actionType).label} 115 </div> 116 <div className="text-sm text-red-800 mt-1"> 117 <strong>Reason:</strong> {action.reason} 118 </div> 119 <div className="text-sm text-red-800 mt-1"> 120 <strong>Chain:</strong> {chain.toUpperCase()} 121 </div> 122 </div> 123 124 {!payload ? ( 125 <button 126 onClick={handleGeneratePayload} 127 className="w-full py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700" 128 > 129 Generate Signing Payload 130 </button> 131 ) : ( 132 <> 133 {/* Instructions */} 134 <div className="bg-gray-50 rounded-lg p-4 text-sm"> 135 <h4 className="font-medium mb-2">Instructions:</h4> 136 <ol className="list-decimal list-inside space-y-2 text-gray-600"> 137 <li>Copy the command below to your air-gapped device</li> 138 <li>Run it with your DEQ keypair</li> 139 <li> 140 Copy the signed output back and paste it in the Import tab 141 </li> 142 </ol> 143 </div> 144 145 {/* CLI Command */} 146 <div> 147 <label className="block text-sm font-medium text-gray-700 mb-1"> 148 CLI Command 149 </label> 150 <div className="relative"> 151 <pre className="bg-gray-900 text-green-400 p-4 rounded-lg text-xs overflow-x-auto"> 152 {cliCommand} 153 </pre> 154 <button 155 onClick={() => navigator.clipboard.writeText(cliCommand)} 156 className="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-white rounded text-xs hover:bg-gray-600" 157 > 158 Copy 159 </button> 160 </div> 161 </div> 162 163 {/* Human-Readable Message */} 164 <div> 165 <label className="block text-sm font-medium text-gray-700 mb-1"> 166 Message Being Signed 167 </label> 168 <pre className="bg-gray-100 p-4 rounded-lg text-xs overflow-x-auto whitespace-pre-wrap"> 169 {payload.message} 170 </pre> 171 </div> 172 173 {/* QR Code placeholder */} 174 <div className="bg-gray-50 border border-dashed border-gray-300 rounded-lg p-6 text-center"> 175 <QRIcon className="h-8 w-8 mx-auto text-gray-400" /> 176 <p className="text-sm text-gray-500 mt-2"> 177 QR code generation available in production 178 </p> 179 </div> 180 181 <button 182 onClick={() => setStep('import')} 183 className="w-full py-2 border border-gray-300 rounded-lg text-sm hover:bg-gray-50" 184 > 185 Continue to Import Signature 186 </button> 187 </> 188 )} 189 </div> 190 )} 191 192 {step === 'import' && ( 193 <div className="space-y-4"> 194 <div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-sm text-blue-800"> 195 Paste the signed output from your air-gapped device below. 196 </div> 197 198 <div> 199 <label className="block text-sm font-medium text-gray-700 mb-1"> 200 Signed Output (JSON) 201 </label> 202 <textarea 203 value={signedOutput} 204 onChange={(e) => setSignedOutput(e.target.value)} 205 rows={8} 206 placeholder={`{ 207 "payload": { ... }, 208 "signature": "...", 209 "publicKey": "..." 210 }`} 211 className="w-full px-3 py-2 border rounded-lg font-mono text-sm" 212 /> 213 </div> 214 215 {error && ( 216 <div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700"> 217 {error} 218 </div> 219 )} 220 221 <button 222 onClick={handleImportSignature} 223 disabled={!signedOutput || submitMutation.isPending} 224 className="w-full py-3 bg-green-600 text-white rounded-lg font-medium disabled:opacity-50 hover:bg-green-700" 225 > 226 {submitMutation.isPending ? 'Submitting...' : 'Submit Signature'} 227 </button> 228 </div> 229 )} 230 </div> 231 232 {/* Footer */} 233 <div className="p-4 bg-gray-50 border-t text-xs text-gray-500"> 234 <p> 235 <strong>Security Note:</strong> Never enter your private key in this 236 interface. Only paste the signed output from the CLI tool. 237 </p> 238 </div> 239 </div> 240 </div> 241 ) 242 } 243 244 /** 245 * Button to trigger offline signing flow 246 */ 247 export function OfflineSignButton({ 248 action, 249 chain, 250 }: { 251 action: EmergencyAction 252 chain: Chain 253 }) { 254 const [showTool, setShowTool] = useState(false) 255 256 return ( 257 <> 258 <button 259 onClick={() => setShowTool(true)} 260 className="px-3 py-1.5 border border-gray-300 rounded text-sm hover:bg-gray-50" 261 > 262 Offline Sign 263 </button> 264 265 {showTool && ( 266 <OfflineSigningTool 267 action={action} 268 chain={chain} 269 onClose={() => setShowTool(false)} 270 /> 271 )} 272 </> 273 ) 274 } 275 276 function CloseIcon({ className }: { className?: string }) { 277 return ( 278 <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"> 279 <path 280 strokeLinecap="round" 281 strokeLinejoin="round" 282 strokeWidth={2} 283 d="M6 18L18 6M6 6l12 12" 284 /> 285 </svg> 286 ) 287 } 288 289 function QRIcon({ className }: { className?: string }) { 290 return ( 291 <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor"> 292 <path 293 strokeLinecap="round" 294 strokeLinejoin="round" 295 strokeWidth={2} 296 d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" 297 /> 298 </svg> 299 ) 300 }