/ frontend / src / components / emergency / OfflineSigningTool.tsx
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  }