@sip-protocol/react 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -14
- package/dist/index.d.mts +1224 -6
- package/dist/index.d.ts +1224 -6
- package/dist/index.js +5783 -10
- package/dist/index.mjs +5777 -9
- package/package.json +9 -8
- package/src/components/ethereum/index.ts +55 -0
- package/src/components/ethereum/privacy-toggle.tsx +822 -0
- package/src/components/ethereum/stealth-address-display.tsx +1050 -0
- package/src/components/ethereum/transaction-history.tsx +1187 -0
- package/src/components/ethereum/transaction-tracker.tsx +302 -0
- package/src/components/ethereum/viewing-key-manager.tsx +228 -0
- package/src/components/index.ts +107 -0
- package/src/components/privacy-toggle.tsx +548 -0
- package/src/components/stealth-address-display.tsx +770 -0
- package/src/components/transaction-history.tsx +651 -0
- package/src/components/transaction-tracker.tsx +1079 -0
- package/src/components/viewing-key-manager.tsx +1576 -0
- package/src/hooks/index.ts +61 -0
- package/src/hooks/use-privacy-advisor.ts +371 -0
- package/src/hooks/use-private-swap.ts +5 -5
- package/src/hooks/use-proof-composition.ts +654 -0
- package/src/hooks/use-scan-payments.ts +504 -0
- package/src/hooks/use-stealth-address.ts +23 -7
- package/src/hooks/use-stealth-transfer.ts +284 -0
- package/src/hooks/use-transaction-history.ts +435 -0
- package/src/index.ts +75 -0
|
@@ -0,0 +1,1576 @@
|
|
|
1
|
+
import React, { useState, useCallback, useMemo, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Viewing key status
|
|
5
|
+
*/
|
|
6
|
+
export type ViewingKeyStatus = 'active' | 'revoked' | 'expired' | 'pending'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Key export format
|
|
10
|
+
*/
|
|
11
|
+
export type KeyExportFormat = 'encrypted_file' | 'qr_code' | 'plaintext'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Key import source
|
|
15
|
+
*/
|
|
16
|
+
export type KeyImportSource = 'file' | 'qr_code' | 'text'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Viewing key usage entry
|
|
20
|
+
*/
|
|
21
|
+
export interface ViewingKeyUsage {
|
|
22
|
+
timestamp: number
|
|
23
|
+
action: 'created' | 'shared' | 'used' | 'revoked' | 'exported' | 'imported'
|
|
24
|
+
details?: string
|
|
25
|
+
recipient?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Viewing key data
|
|
30
|
+
*/
|
|
31
|
+
export interface ViewingKey {
|
|
32
|
+
id: string
|
|
33
|
+
publicKey: string
|
|
34
|
+
privateKey?: string
|
|
35
|
+
label?: string
|
|
36
|
+
status: ViewingKeyStatus
|
|
37
|
+
createdAt: number
|
|
38
|
+
expiresAt?: number
|
|
39
|
+
usageHistory: ViewingKeyUsage[]
|
|
40
|
+
sharedWith?: string[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* ViewingKeyManager component props
|
|
45
|
+
*/
|
|
46
|
+
export interface ViewingKeyManagerProps {
|
|
47
|
+
/** List of viewing keys */
|
|
48
|
+
keys: ViewingKey[]
|
|
49
|
+
/** Callback to generate a new key */
|
|
50
|
+
onGenerateKey?: (label?: string) => Promise<ViewingKey>
|
|
51
|
+
/** Callback to export a key */
|
|
52
|
+
onExportKey?: (keyId: string, format: KeyExportFormat, password?: string) => Promise<string | Blob>
|
|
53
|
+
/** Callback to import a key */
|
|
54
|
+
onImportKey?: (source: KeyImportSource, data: string | File) => Promise<ViewingKey>
|
|
55
|
+
/** Callback to share a key */
|
|
56
|
+
onShareKey?: (keyId: string, recipient: string) => Promise<void>
|
|
57
|
+
/** Callback to revoke a key */
|
|
58
|
+
onRevokeKey?: (keyId: string) => Promise<void>
|
|
59
|
+
/** Callback when backup is acknowledged */
|
|
60
|
+
onBackupAcknowledged?: (keyId: string) => void
|
|
61
|
+
/** Whether to show backup reminder */
|
|
62
|
+
showBackupReminder?: boolean
|
|
63
|
+
/** Custom class name */
|
|
64
|
+
className?: string
|
|
65
|
+
/** Size variant */
|
|
66
|
+
size?: 'sm' | 'md' | 'lg'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Wizard step type
|
|
71
|
+
*/
|
|
72
|
+
type WizardStep = 'idle' | 'generate' | 'export' | 'import' | 'share' | 'revoke'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* CSS styles for the component
|
|
76
|
+
*/
|
|
77
|
+
const styles = `
|
|
78
|
+
.sip-vk-manager {
|
|
79
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
80
|
+
background: #ffffff;
|
|
81
|
+
border: 1px solid #e5e7eb;
|
|
82
|
+
border-radius: 12px;
|
|
83
|
+
overflow: hidden;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.sip-vk-manager[data-size="sm"] {
|
|
87
|
+
border-radius: 8px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.sip-vk-manager[data-size="lg"] {
|
|
91
|
+
border-radius: 16px;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Header */
|
|
95
|
+
.sip-vk-header {
|
|
96
|
+
display: flex;
|
|
97
|
+
align-items: center;
|
|
98
|
+
justify-content: space-between;
|
|
99
|
+
padding: 16px 20px;
|
|
100
|
+
background: linear-gradient(135deg, #312e81 0%, #4c1d95 100%);
|
|
101
|
+
color: white;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.sip-vk-manager[data-size="sm"] .sip-vk-header {
|
|
105
|
+
padding: 12px 16px;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.sip-vk-manager[data-size="lg"] .sip-vk-header {
|
|
109
|
+
padding: 20px 24px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.sip-vk-header-title {
|
|
113
|
+
display: flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: 10px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.sip-vk-header-title svg {
|
|
119
|
+
width: 24px;
|
|
120
|
+
height: 24px;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.sip-vk-header-title h2 {
|
|
124
|
+
font-size: 16px;
|
|
125
|
+
font-weight: 600;
|
|
126
|
+
margin: 0;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.sip-vk-manager[data-size="sm"] .sip-vk-header-title h2 {
|
|
130
|
+
font-size: 14px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.sip-vk-manager[data-size="lg"] .sip-vk-header-title h2 {
|
|
134
|
+
font-size: 18px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.sip-vk-header-actions {
|
|
138
|
+
display: flex;
|
|
139
|
+
gap: 8px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Buttons */
|
|
143
|
+
.sip-vk-btn {
|
|
144
|
+
display: inline-flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
justify-content: center;
|
|
147
|
+
gap: 6px;
|
|
148
|
+
padding: 8px 16px;
|
|
149
|
+
border: none;
|
|
150
|
+
border-radius: 6px;
|
|
151
|
+
font-size: 13px;
|
|
152
|
+
font-weight: 500;
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
transition: all 0.2s;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.sip-vk-btn svg {
|
|
158
|
+
width: 16px;
|
|
159
|
+
height: 16px;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
.sip-vk-btn-primary {
|
|
163
|
+
background: rgba(255, 255, 255, 0.2);
|
|
164
|
+
color: white;
|
|
165
|
+
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.sip-vk-btn-primary:hover {
|
|
169
|
+
background: rgba(255, 255, 255, 0.3);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.sip-vk-btn-secondary {
|
|
173
|
+
background: #f3f4f6;
|
|
174
|
+
color: #374151;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.sip-vk-btn-secondary:hover {
|
|
178
|
+
background: #e5e7eb;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.sip-vk-btn-danger {
|
|
182
|
+
background: #fee2e2;
|
|
183
|
+
color: #dc2626;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.sip-vk-btn-danger:hover {
|
|
187
|
+
background: #fecaca;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.sip-vk-btn-success {
|
|
191
|
+
background: #d1fae5;
|
|
192
|
+
color: #059669;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.sip-vk-btn-success:hover {
|
|
196
|
+
background: #a7f3d0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.sip-vk-btn:disabled {
|
|
200
|
+
opacity: 0.5;
|
|
201
|
+
cursor: not-allowed;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* Key List */
|
|
205
|
+
.sip-vk-list {
|
|
206
|
+
padding: 0;
|
|
207
|
+
margin: 0;
|
|
208
|
+
list-style: none;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.sip-vk-empty {
|
|
212
|
+
padding: 40px 20px;
|
|
213
|
+
text-align: center;
|
|
214
|
+
color: #6b7280;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.sip-vk-empty svg {
|
|
218
|
+
width: 48px;
|
|
219
|
+
height: 48px;
|
|
220
|
+
margin-bottom: 12px;
|
|
221
|
+
opacity: 0.5;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.sip-vk-empty p {
|
|
225
|
+
margin: 0 0 16px;
|
|
226
|
+
font-size: 14px;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* Key Item */
|
|
230
|
+
.sip-vk-item {
|
|
231
|
+
display: flex;
|
|
232
|
+
align-items: center;
|
|
233
|
+
justify-content: space-between;
|
|
234
|
+
padding: 16px 20px;
|
|
235
|
+
border-bottom: 1px solid #e5e7eb;
|
|
236
|
+
transition: background 0.2s;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.sip-vk-item:last-child {
|
|
240
|
+
border-bottom: none;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.sip-vk-item:hover {
|
|
244
|
+
background: #f9fafb;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
.sip-vk-manager[data-size="sm"] .sip-vk-item {
|
|
248
|
+
padding: 12px 16px;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.sip-vk-manager[data-size="lg"] .sip-vk-item {
|
|
252
|
+
padding: 20px 24px;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.sip-vk-item-info {
|
|
256
|
+
display: flex;
|
|
257
|
+
align-items: center;
|
|
258
|
+
gap: 12px;
|
|
259
|
+
flex: 1;
|
|
260
|
+
min-width: 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
.sip-vk-item-icon {
|
|
264
|
+
display: flex;
|
|
265
|
+
align-items: center;
|
|
266
|
+
justify-content: center;
|
|
267
|
+
width: 40px;
|
|
268
|
+
height: 40px;
|
|
269
|
+
border-radius: 10px;
|
|
270
|
+
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
271
|
+
color: white;
|
|
272
|
+
flex-shrink: 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.sip-vk-item-icon svg {
|
|
276
|
+
width: 20px;
|
|
277
|
+
height: 20px;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.sip-vk-item-details {
|
|
281
|
+
flex: 1;
|
|
282
|
+
min-width: 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.sip-vk-item-label {
|
|
286
|
+
font-size: 14px;
|
|
287
|
+
font-weight: 600;
|
|
288
|
+
color: #111827;
|
|
289
|
+
margin-bottom: 2px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.sip-vk-item-key {
|
|
293
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
294
|
+
font-size: 12px;
|
|
295
|
+
color: #6b7280;
|
|
296
|
+
overflow: hidden;
|
|
297
|
+
text-overflow: ellipsis;
|
|
298
|
+
white-space: nowrap;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.sip-vk-item-meta {
|
|
302
|
+
display: flex;
|
|
303
|
+
align-items: center;
|
|
304
|
+
gap: 12px;
|
|
305
|
+
margin-top: 6px;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.sip-vk-item-badge {
|
|
309
|
+
display: inline-flex;
|
|
310
|
+
align-items: center;
|
|
311
|
+
gap: 4px;
|
|
312
|
+
padding: 2px 8px;
|
|
313
|
+
font-size: 10px;
|
|
314
|
+
font-weight: 600;
|
|
315
|
+
text-transform: uppercase;
|
|
316
|
+
letter-spacing: 0.05em;
|
|
317
|
+
border-radius: 4px;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.sip-vk-item-badge[data-status="active"] {
|
|
321
|
+
background: #d1fae5;
|
|
322
|
+
color: #059669;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.sip-vk-item-badge[data-status="revoked"] {
|
|
326
|
+
background: #fee2e2;
|
|
327
|
+
color: #dc2626;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.sip-vk-item-badge[data-status="expired"] {
|
|
331
|
+
background: #fef3c7;
|
|
332
|
+
color: #d97706;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
.sip-vk-item-badge[data-status="pending"] {
|
|
336
|
+
background: #dbeafe;
|
|
337
|
+
color: #2563eb;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
.sip-vk-item-date {
|
|
341
|
+
font-size: 11px;
|
|
342
|
+
color: #9ca3af;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.sip-vk-item-actions {
|
|
346
|
+
display: flex;
|
|
347
|
+
gap: 4px;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.sip-vk-item-action {
|
|
351
|
+
display: flex;
|
|
352
|
+
align-items: center;
|
|
353
|
+
justify-content: center;
|
|
354
|
+
width: 32px;
|
|
355
|
+
height: 32px;
|
|
356
|
+
padding: 0;
|
|
357
|
+
border: none;
|
|
358
|
+
border-radius: 6px;
|
|
359
|
+
background: transparent;
|
|
360
|
+
color: #6b7280;
|
|
361
|
+
cursor: pointer;
|
|
362
|
+
transition: all 0.2s;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.sip-vk-item-action:hover {
|
|
366
|
+
background: #f3f4f6;
|
|
367
|
+
color: #111827;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.sip-vk-item-action:disabled {
|
|
371
|
+
opacity: 0.3;
|
|
372
|
+
cursor: not-allowed;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
.sip-vk-item-action svg {
|
|
376
|
+
width: 18px;
|
|
377
|
+
height: 18px;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/* Modal/Wizard */
|
|
381
|
+
.sip-vk-modal {
|
|
382
|
+
position: fixed;
|
|
383
|
+
top: 0;
|
|
384
|
+
left: 0;
|
|
385
|
+
right: 0;
|
|
386
|
+
bottom: 0;
|
|
387
|
+
background: rgba(0, 0, 0, 0.5);
|
|
388
|
+
display: flex;
|
|
389
|
+
align-items: center;
|
|
390
|
+
justify-content: center;
|
|
391
|
+
z-index: 1000;
|
|
392
|
+
padding: 20px;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.sip-vk-modal-content {
|
|
396
|
+
background: white;
|
|
397
|
+
border-radius: 16px;
|
|
398
|
+
width: 100%;
|
|
399
|
+
max-width: 480px;
|
|
400
|
+
max-height: 90vh;
|
|
401
|
+
overflow: auto;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
.sip-vk-modal-header {
|
|
405
|
+
display: flex;
|
|
406
|
+
align-items: center;
|
|
407
|
+
justify-content: space-between;
|
|
408
|
+
padding: 20px 24px;
|
|
409
|
+
border-bottom: 1px solid #e5e7eb;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.sip-vk-modal-title {
|
|
413
|
+
font-size: 18px;
|
|
414
|
+
font-weight: 600;
|
|
415
|
+
color: #111827;
|
|
416
|
+
margin: 0;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.sip-vk-modal-close {
|
|
420
|
+
display: flex;
|
|
421
|
+
align-items: center;
|
|
422
|
+
justify-content: center;
|
|
423
|
+
width: 32px;
|
|
424
|
+
height: 32px;
|
|
425
|
+
padding: 0;
|
|
426
|
+
border: none;
|
|
427
|
+
border-radius: 8px;
|
|
428
|
+
background: transparent;
|
|
429
|
+
color: #6b7280;
|
|
430
|
+
cursor: pointer;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
.sip-vk-modal-close:hover {
|
|
434
|
+
background: #f3f4f6;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.sip-vk-modal-close svg {
|
|
438
|
+
width: 20px;
|
|
439
|
+
height: 20px;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.sip-vk-modal-body {
|
|
443
|
+
padding: 24px;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.sip-vk-modal-footer {
|
|
447
|
+
display: flex;
|
|
448
|
+
justify-content: flex-end;
|
|
449
|
+
gap: 12px;
|
|
450
|
+
padding: 16px 24px;
|
|
451
|
+
border-top: 1px solid #e5e7eb;
|
|
452
|
+
background: #f9fafb;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/* Form elements */
|
|
456
|
+
.sip-vk-form-group {
|
|
457
|
+
margin-bottom: 20px;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
.sip-vk-form-group:last-child {
|
|
461
|
+
margin-bottom: 0;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
.sip-vk-label {
|
|
465
|
+
display: block;
|
|
466
|
+
font-size: 13px;
|
|
467
|
+
font-weight: 600;
|
|
468
|
+
color: #374151;
|
|
469
|
+
margin-bottom: 6px;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.sip-vk-input {
|
|
473
|
+
width: 100%;
|
|
474
|
+
padding: 10px 14px;
|
|
475
|
+
border: 1px solid #d1d5db;
|
|
476
|
+
border-radius: 8px;
|
|
477
|
+
font-size: 14px;
|
|
478
|
+
color: #111827;
|
|
479
|
+
transition: border-color 0.2s, box-shadow 0.2s;
|
|
480
|
+
box-sizing: border-box;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.sip-vk-input:focus {
|
|
484
|
+
outline: none;
|
|
485
|
+
border-color: #6366f1;
|
|
486
|
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
.sip-vk-textarea {
|
|
490
|
+
width: 100%;
|
|
491
|
+
padding: 10px 14px;
|
|
492
|
+
border: 1px solid #d1d5db;
|
|
493
|
+
border-radius: 8px;
|
|
494
|
+
font-size: 13px;
|
|
495
|
+
font-family: 'SF Mono', Monaco, monospace;
|
|
496
|
+
color: #111827;
|
|
497
|
+
resize: vertical;
|
|
498
|
+
min-height: 100px;
|
|
499
|
+
box-sizing: border-box;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.sip-vk-textarea:focus {
|
|
503
|
+
outline: none;
|
|
504
|
+
border-color: #6366f1;
|
|
505
|
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.sip-vk-select {
|
|
509
|
+
width: 100%;
|
|
510
|
+
padding: 10px 14px;
|
|
511
|
+
border: 1px solid #d1d5db;
|
|
512
|
+
border-radius: 8px;
|
|
513
|
+
font-size: 14px;
|
|
514
|
+
color: #111827;
|
|
515
|
+
background: white;
|
|
516
|
+
cursor: pointer;
|
|
517
|
+
box-sizing: border-box;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.sip-vk-select:focus {
|
|
521
|
+
outline: none;
|
|
522
|
+
border-color: #6366f1;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/* Warning box */
|
|
526
|
+
.sip-vk-warning {
|
|
527
|
+
display: flex;
|
|
528
|
+
align-items: flex-start;
|
|
529
|
+
gap: 12px;
|
|
530
|
+
padding: 14px 16px;
|
|
531
|
+
background: #fef3c7;
|
|
532
|
+
border: 1px solid #fcd34d;
|
|
533
|
+
border-radius: 8px;
|
|
534
|
+
margin-bottom: 20px;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.sip-vk-warning svg {
|
|
538
|
+
width: 20px;
|
|
539
|
+
height: 20px;
|
|
540
|
+
color: #d97706;
|
|
541
|
+
flex-shrink: 0;
|
|
542
|
+
margin-top: 1px;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.sip-vk-warning-text {
|
|
546
|
+
font-size: 13px;
|
|
547
|
+
color: #92400e;
|
|
548
|
+
line-height: 1.5;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.sip-vk-warning-text strong {
|
|
552
|
+
font-weight: 600;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/* Danger box */
|
|
556
|
+
.sip-vk-danger {
|
|
557
|
+
display: flex;
|
|
558
|
+
align-items: flex-start;
|
|
559
|
+
gap: 12px;
|
|
560
|
+
padding: 14px 16px;
|
|
561
|
+
background: #fee2e2;
|
|
562
|
+
border: 1px solid #fca5a5;
|
|
563
|
+
border-radius: 8px;
|
|
564
|
+
margin-bottom: 20px;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
.sip-vk-danger svg {
|
|
568
|
+
width: 20px;
|
|
569
|
+
height: 20px;
|
|
570
|
+
color: #dc2626;
|
|
571
|
+
flex-shrink: 0;
|
|
572
|
+
margin-top: 1px;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
.sip-vk-danger-text {
|
|
576
|
+
font-size: 13px;
|
|
577
|
+
color: #991b1b;
|
|
578
|
+
line-height: 1.5;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/* Backup reminder */
|
|
582
|
+
.sip-vk-backup-reminder {
|
|
583
|
+
display: flex;
|
|
584
|
+
align-items: center;
|
|
585
|
+
justify-content: space-between;
|
|
586
|
+
padding: 12px 20px;
|
|
587
|
+
background: #fef3c7;
|
|
588
|
+
border-bottom: 1px solid #fcd34d;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
.sip-vk-backup-reminder-text {
|
|
592
|
+
display: flex;
|
|
593
|
+
align-items: center;
|
|
594
|
+
gap: 10px;
|
|
595
|
+
font-size: 13px;
|
|
596
|
+
color: #92400e;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.sip-vk-backup-reminder-text svg {
|
|
600
|
+
width: 18px;
|
|
601
|
+
height: 18px;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/* History */
|
|
605
|
+
.sip-vk-history {
|
|
606
|
+
margin-top: 16px;
|
|
607
|
+
padding-top: 16px;
|
|
608
|
+
border-top: 1px solid #e5e7eb;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
.sip-vk-history-title {
|
|
612
|
+
font-size: 12px;
|
|
613
|
+
font-weight: 600;
|
|
614
|
+
color: #6b7280;
|
|
615
|
+
text-transform: uppercase;
|
|
616
|
+
letter-spacing: 0.05em;
|
|
617
|
+
margin-bottom: 12px;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.sip-vk-history-list {
|
|
621
|
+
display: flex;
|
|
622
|
+
flex-direction: column;
|
|
623
|
+
gap: 8px;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.sip-vk-history-item {
|
|
627
|
+
display: flex;
|
|
628
|
+
align-items: center;
|
|
629
|
+
gap: 10px;
|
|
630
|
+
font-size: 12px;
|
|
631
|
+
color: #6b7280;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
.sip-vk-history-item-icon {
|
|
635
|
+
width: 6px;
|
|
636
|
+
height: 6px;
|
|
637
|
+
border-radius: 50%;
|
|
638
|
+
background: #d1d5db;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
.sip-vk-history-item[data-action="created"] .sip-vk-history-item-icon {
|
|
642
|
+
background: #10b981;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
.sip-vk-history-item[data-action="shared"] .sip-vk-history-item-icon {
|
|
646
|
+
background: #3b82f6;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.sip-vk-history-item[data-action="revoked"] .sip-vk-history-item-icon {
|
|
650
|
+
background: #ef4444;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/* QR Code display */
|
|
654
|
+
.sip-vk-qr-display {
|
|
655
|
+
display: flex;
|
|
656
|
+
flex-direction: column;
|
|
657
|
+
align-items: center;
|
|
658
|
+
padding: 20px;
|
|
659
|
+
background: #f9fafb;
|
|
660
|
+
border-radius: 12px;
|
|
661
|
+
margin-bottom: 16px;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
.sip-vk-qr-placeholder {
|
|
665
|
+
width: 200px;
|
|
666
|
+
height: 200px;
|
|
667
|
+
display: flex;
|
|
668
|
+
align-items: center;
|
|
669
|
+
justify-content: center;
|
|
670
|
+
background: white;
|
|
671
|
+
border: 1px solid #e5e7eb;
|
|
672
|
+
border-radius: 8px;
|
|
673
|
+
margin-bottom: 12px;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
.sip-vk-qr-placeholder svg {
|
|
677
|
+
width: 64px;
|
|
678
|
+
height: 64px;
|
|
679
|
+
color: #d1d5db;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/* File upload */
|
|
683
|
+
.sip-vk-file-upload {
|
|
684
|
+
display: flex;
|
|
685
|
+
flex-direction: column;
|
|
686
|
+
align-items: center;
|
|
687
|
+
padding: 32px 20px;
|
|
688
|
+
border: 2px dashed #d1d5db;
|
|
689
|
+
border-radius: 12px;
|
|
690
|
+
cursor: pointer;
|
|
691
|
+
transition: all 0.2s;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
.sip-vk-file-upload:hover {
|
|
695
|
+
border-color: #6366f1;
|
|
696
|
+
background: #f5f3ff;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
.sip-vk-file-upload svg {
|
|
700
|
+
width: 40px;
|
|
701
|
+
height: 40px;
|
|
702
|
+
color: #9ca3af;
|
|
703
|
+
margin-bottom: 12px;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
.sip-vk-file-upload-text {
|
|
707
|
+
font-size: 14px;
|
|
708
|
+
color: #6b7280;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
.sip-vk-file-upload-hint {
|
|
712
|
+
font-size: 12px;
|
|
713
|
+
color: #9ca3af;
|
|
714
|
+
margin-top: 4px;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/* Dark mode */
|
|
718
|
+
@media (prefers-color-scheme: dark) {
|
|
719
|
+
.sip-vk-manager {
|
|
720
|
+
background: #1f2937;
|
|
721
|
+
border-color: #374151;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.sip-vk-item:hover {
|
|
725
|
+
background: #111827;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.sip-vk-item-label {
|
|
729
|
+
color: #f9fafb;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
.sip-vk-item-key {
|
|
733
|
+
color: #9ca3af;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
.sip-vk-modal-content {
|
|
737
|
+
background: #1f2937;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
.sip-vk-modal-header {
|
|
741
|
+
border-color: #374151;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
.sip-vk-modal-title {
|
|
745
|
+
color: #f9fafb;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.sip-vk-modal-footer {
|
|
749
|
+
background: #111827;
|
|
750
|
+
border-color: #374151;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
.sip-vk-input,
|
|
754
|
+
.sip-vk-textarea,
|
|
755
|
+
.sip-vk-select {
|
|
756
|
+
background: #374151;
|
|
757
|
+
border-color: #4b5563;
|
|
758
|
+
color: #f9fafb;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
.sip-vk-label {
|
|
762
|
+
color: #d1d5db;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
.sip-vk-btn-secondary {
|
|
766
|
+
background: #374151;
|
|
767
|
+
color: #e5e7eb;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
.sip-vk-btn-secondary:hover {
|
|
771
|
+
background: #4b5563;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
.sip-vk-item-action:hover {
|
|
775
|
+
background: #374151;
|
|
776
|
+
color: #f9fafb;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
.sip-vk-empty {
|
|
780
|
+
color: #9ca3af;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
`
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Icons
|
|
787
|
+
*/
|
|
788
|
+
const KeyIcon = () => (
|
|
789
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
790
|
+
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
|
791
|
+
</svg>
|
|
792
|
+
)
|
|
793
|
+
|
|
794
|
+
const PlusIcon = () => (
|
|
795
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
796
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
797
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
798
|
+
</svg>
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
const DownloadIcon = () => (
|
|
802
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
803
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
804
|
+
<polyline points="7 10 12 15 17 10" />
|
|
805
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
806
|
+
</svg>
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
const UploadIcon = () => (
|
|
810
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
811
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
812
|
+
<polyline points="17 8 12 3 7 8" />
|
|
813
|
+
<line x1="12" y1="3" x2="12" y2="15" />
|
|
814
|
+
</svg>
|
|
815
|
+
)
|
|
816
|
+
|
|
817
|
+
const ShareIcon = () => (
|
|
818
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
819
|
+
<circle cx="18" cy="5" r="3" />
|
|
820
|
+
<circle cx="6" cy="12" r="3" />
|
|
821
|
+
<circle cx="18" cy="19" r="3" />
|
|
822
|
+
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" />
|
|
823
|
+
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" />
|
|
824
|
+
</svg>
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
const TrashIcon = () => (
|
|
828
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
829
|
+
<polyline points="3 6 5 6 21 6" />
|
|
830
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
831
|
+
</svg>
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
const XIcon = () => (
|
|
835
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
836
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
837
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
838
|
+
</svg>
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
const AlertIcon = () => (
|
|
842
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
843
|
+
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
844
|
+
<line x1="12" y1="9" x2="12" y2="13" />
|
|
845
|
+
<line x1="12" y1="17" x2="12.01" y2="17" />
|
|
846
|
+
</svg>
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
const QrCodeIcon = () => (
|
|
850
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
851
|
+
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
852
|
+
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
853
|
+
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
854
|
+
<rect x="14" y="14" width="3" height="3" />
|
|
855
|
+
<rect x="18" y="14" width="3" height="3" />
|
|
856
|
+
<rect x="14" y="18" width="3" height="3" />
|
|
857
|
+
<rect x="18" y="18" width="3" height="3" />
|
|
858
|
+
</svg>
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
const FileIcon = () => (
|
|
862
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
863
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
864
|
+
<polyline points="14 2 14 8 20 8" />
|
|
865
|
+
</svg>
|
|
866
|
+
)
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Format date for display
|
|
870
|
+
*/
|
|
871
|
+
function formatDate(timestamp: number): string {
|
|
872
|
+
return new Date(timestamp).toLocaleDateString(undefined, {
|
|
873
|
+
year: 'numeric',
|
|
874
|
+
month: 'short',
|
|
875
|
+
day: 'numeric',
|
|
876
|
+
})
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* Truncate key for display
|
|
881
|
+
*/
|
|
882
|
+
function truncateKey(key: string, chars = 8): string {
|
|
883
|
+
if (key.length <= chars * 2 + 3) return key
|
|
884
|
+
return `${key.slice(0, chars)}...${key.slice(-chars)}`
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* ViewingKeyManager - Component for managing NEAR viewing keys
|
|
889
|
+
*
|
|
890
|
+
* @example Basic usage
|
|
891
|
+
* ```tsx
|
|
892
|
+
* import { ViewingKeyManager } from '@sip-protocol/react'
|
|
893
|
+
*
|
|
894
|
+
* function KeyManagement() {
|
|
895
|
+
* const [keys, setKeys] = useState<ViewingKey[]>([])
|
|
896
|
+
*
|
|
897
|
+
* return (
|
|
898
|
+
* <ViewingKeyManager
|
|
899
|
+
* keys={keys}
|
|
900
|
+
* onGenerateKey={async (label) => {
|
|
901
|
+
* const newKey = await generateViewingKey(label)
|
|
902
|
+
* setKeys([...keys, newKey])
|
|
903
|
+
* return newKey
|
|
904
|
+
* }}
|
|
905
|
+
* />
|
|
906
|
+
* )
|
|
907
|
+
* }
|
|
908
|
+
* ```
|
|
909
|
+
*/
|
|
910
|
+
export function ViewingKeyManager({
|
|
911
|
+
keys,
|
|
912
|
+
onGenerateKey,
|
|
913
|
+
onExportKey,
|
|
914
|
+
onImportKey,
|
|
915
|
+
onShareKey,
|
|
916
|
+
onRevokeKey,
|
|
917
|
+
onBackupAcknowledged: _onBackupAcknowledged,
|
|
918
|
+
showBackupReminder = true,
|
|
919
|
+
className = '',
|
|
920
|
+
size = 'md',
|
|
921
|
+
}: ViewingKeyManagerProps) {
|
|
922
|
+
const [wizardStep, setWizardStep] = useState<WizardStep>('idle')
|
|
923
|
+
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null)
|
|
924
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
925
|
+
const [error, setError] = useState<string | null>(null)
|
|
926
|
+
|
|
927
|
+
// Form states
|
|
928
|
+
const [generateLabel, setGenerateLabel] = useState('')
|
|
929
|
+
const [exportFormat, setExportFormat] = useState<KeyExportFormat>('encrypted_file')
|
|
930
|
+
const [exportPassword, setExportPassword] = useState('')
|
|
931
|
+
const [importSource, setImportSource] = useState<KeyImportSource>('file')
|
|
932
|
+
const [importData, setImportData] = useState('')
|
|
933
|
+
const [shareRecipient, setShareRecipient] = useState('')
|
|
934
|
+
|
|
935
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
936
|
+
|
|
937
|
+
// Get selected key
|
|
938
|
+
const selectedKey = useMemo(
|
|
939
|
+
() => keys.find((k) => k.id === selectedKeyId),
|
|
940
|
+
[keys, selectedKeyId]
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
// Keys that need backup
|
|
944
|
+
const keysNeedingBackup = useMemo(
|
|
945
|
+
() => keys.filter((k) => k.status === 'active' && k.usageHistory.every((u) => u.action !== 'exported')),
|
|
946
|
+
[keys]
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
// Reset form
|
|
950
|
+
const resetForm = useCallback(() => {
|
|
951
|
+
setWizardStep('idle')
|
|
952
|
+
setSelectedKeyId(null)
|
|
953
|
+
setError(null)
|
|
954
|
+
setGenerateLabel('')
|
|
955
|
+
setExportFormat('encrypted_file')
|
|
956
|
+
setExportPassword('')
|
|
957
|
+
setImportSource('file')
|
|
958
|
+
setImportData('')
|
|
959
|
+
setShareRecipient('')
|
|
960
|
+
}, [])
|
|
961
|
+
|
|
962
|
+
// Handle generate key
|
|
963
|
+
const handleGenerate = useCallback(async () => {
|
|
964
|
+
if (!onGenerateKey) return
|
|
965
|
+
|
|
966
|
+
setIsLoading(true)
|
|
967
|
+
setError(null)
|
|
968
|
+
|
|
969
|
+
try {
|
|
970
|
+
await onGenerateKey(generateLabel || undefined)
|
|
971
|
+
resetForm()
|
|
972
|
+
} catch (err) {
|
|
973
|
+
setError(err instanceof Error ? err.message : 'Failed to generate key')
|
|
974
|
+
} finally {
|
|
975
|
+
setIsLoading(false)
|
|
976
|
+
}
|
|
977
|
+
}, [onGenerateKey, generateLabel, resetForm])
|
|
978
|
+
|
|
979
|
+
// Handle export key
|
|
980
|
+
const handleExport = useCallback(async () => {
|
|
981
|
+
if (!onExportKey || !selectedKeyId) return
|
|
982
|
+
|
|
983
|
+
setIsLoading(true)
|
|
984
|
+
setError(null)
|
|
985
|
+
|
|
986
|
+
try {
|
|
987
|
+
const result = await onExportKey(selectedKeyId, exportFormat, exportPassword || undefined)
|
|
988
|
+
|
|
989
|
+
// Handle download if it's a blob
|
|
990
|
+
if (result instanceof Blob) {
|
|
991
|
+
const url = URL.createObjectURL(result)
|
|
992
|
+
const a = document.createElement('a')
|
|
993
|
+
a.href = url
|
|
994
|
+
a.download = `viewing-key-${selectedKeyId.slice(0, 8)}.enc`
|
|
995
|
+
document.body.appendChild(a)
|
|
996
|
+
a.click()
|
|
997
|
+
document.body.removeChild(a)
|
|
998
|
+
URL.revokeObjectURL(url)
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
resetForm()
|
|
1002
|
+
} catch (err) {
|
|
1003
|
+
setError(err instanceof Error ? err.message : 'Failed to export key')
|
|
1004
|
+
} finally {
|
|
1005
|
+
setIsLoading(false)
|
|
1006
|
+
}
|
|
1007
|
+
}, [onExportKey, selectedKeyId, exportFormat, exportPassword, resetForm])
|
|
1008
|
+
|
|
1009
|
+
// Handle import key
|
|
1010
|
+
const handleImport = useCallback(async () => {
|
|
1011
|
+
if (!onImportKey || !importData) return
|
|
1012
|
+
|
|
1013
|
+
setIsLoading(true)
|
|
1014
|
+
setError(null)
|
|
1015
|
+
|
|
1016
|
+
try {
|
|
1017
|
+
await onImportKey(importSource, importData)
|
|
1018
|
+
resetForm()
|
|
1019
|
+
} catch (err) {
|
|
1020
|
+
setError(err instanceof Error ? err.message : 'Failed to import key')
|
|
1021
|
+
} finally {
|
|
1022
|
+
setIsLoading(false)
|
|
1023
|
+
}
|
|
1024
|
+
}, [onImportKey, importSource, importData, resetForm])
|
|
1025
|
+
|
|
1026
|
+
// Handle share key
|
|
1027
|
+
const handleShare = useCallback(async () => {
|
|
1028
|
+
if (!onShareKey || !selectedKeyId || !shareRecipient) return
|
|
1029
|
+
|
|
1030
|
+
setIsLoading(true)
|
|
1031
|
+
setError(null)
|
|
1032
|
+
|
|
1033
|
+
try {
|
|
1034
|
+
await onShareKey(selectedKeyId, shareRecipient)
|
|
1035
|
+
resetForm()
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
setError(err instanceof Error ? err.message : 'Failed to share key')
|
|
1038
|
+
} finally {
|
|
1039
|
+
setIsLoading(false)
|
|
1040
|
+
}
|
|
1041
|
+
}, [onShareKey, selectedKeyId, shareRecipient, resetForm])
|
|
1042
|
+
|
|
1043
|
+
// Handle revoke key
|
|
1044
|
+
const handleRevoke = useCallback(async () => {
|
|
1045
|
+
if (!onRevokeKey || !selectedKeyId) return
|
|
1046
|
+
|
|
1047
|
+
setIsLoading(true)
|
|
1048
|
+
setError(null)
|
|
1049
|
+
|
|
1050
|
+
try {
|
|
1051
|
+
await onRevokeKey(selectedKeyId)
|
|
1052
|
+
resetForm()
|
|
1053
|
+
} catch (err) {
|
|
1054
|
+
setError(err instanceof Error ? err.message : 'Failed to revoke key')
|
|
1055
|
+
} finally {
|
|
1056
|
+
setIsLoading(false)
|
|
1057
|
+
}
|
|
1058
|
+
}, [onRevokeKey, selectedKeyId, resetForm])
|
|
1059
|
+
|
|
1060
|
+
// Handle file selection
|
|
1061
|
+
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
|
1062
|
+
const file = event.target.files?.[0]
|
|
1063
|
+
if (file) {
|
|
1064
|
+
const reader = new FileReader()
|
|
1065
|
+
reader.onload = (e) => {
|
|
1066
|
+
setImportData(e.target?.result as string)
|
|
1067
|
+
}
|
|
1068
|
+
reader.readAsText(file)
|
|
1069
|
+
}
|
|
1070
|
+
}, [])
|
|
1071
|
+
|
|
1072
|
+
// Open action modal
|
|
1073
|
+
const openAction = useCallback((step: WizardStep, keyId?: string) => {
|
|
1074
|
+
setWizardStep(step)
|
|
1075
|
+
if (keyId) setSelectedKeyId(keyId)
|
|
1076
|
+
setError(null)
|
|
1077
|
+
}, [])
|
|
1078
|
+
|
|
1079
|
+
return (
|
|
1080
|
+
<>
|
|
1081
|
+
<style>{styles}</style>
|
|
1082
|
+
|
|
1083
|
+
<div className={`sip-vk-manager ${className}`} data-size={size}>
|
|
1084
|
+
{/* Backup reminder */}
|
|
1085
|
+
{showBackupReminder && keysNeedingBackup.length > 0 && (
|
|
1086
|
+
<div className="sip-vk-backup-reminder" data-testid="backup-reminder">
|
|
1087
|
+
<span className="sip-vk-backup-reminder-text">
|
|
1088
|
+
<AlertIcon />
|
|
1089
|
+
{keysNeedingBackup.length} key(s) not backed up
|
|
1090
|
+
</span>
|
|
1091
|
+
<button
|
|
1092
|
+
type="button"
|
|
1093
|
+
className="sip-vk-btn sip-vk-btn-secondary"
|
|
1094
|
+
onClick={() => {
|
|
1095
|
+
if (keysNeedingBackup[0]) {
|
|
1096
|
+
openAction('export', keysNeedingBackup[0].id)
|
|
1097
|
+
}
|
|
1098
|
+
}}
|
|
1099
|
+
>
|
|
1100
|
+
Backup Now
|
|
1101
|
+
</button>
|
|
1102
|
+
</div>
|
|
1103
|
+
)}
|
|
1104
|
+
|
|
1105
|
+
{/* Header */}
|
|
1106
|
+
<div className="sip-vk-header">
|
|
1107
|
+
<div className="sip-vk-header-title">
|
|
1108
|
+
<KeyIcon />
|
|
1109
|
+
<h2>Viewing Keys</h2>
|
|
1110
|
+
</div>
|
|
1111
|
+
<div className="sip-vk-header-actions">
|
|
1112
|
+
{onImportKey && (
|
|
1113
|
+
<button
|
|
1114
|
+
type="button"
|
|
1115
|
+
className="sip-vk-btn sip-vk-btn-primary"
|
|
1116
|
+
onClick={() => openAction('import')}
|
|
1117
|
+
aria-label="Import key"
|
|
1118
|
+
>
|
|
1119
|
+
<UploadIcon />
|
|
1120
|
+
Import
|
|
1121
|
+
</button>
|
|
1122
|
+
)}
|
|
1123
|
+
{onGenerateKey && (
|
|
1124
|
+
<button
|
|
1125
|
+
type="button"
|
|
1126
|
+
className="sip-vk-btn sip-vk-btn-primary"
|
|
1127
|
+
onClick={() => openAction('generate')}
|
|
1128
|
+
aria-label="Generate new key"
|
|
1129
|
+
>
|
|
1130
|
+
<PlusIcon />
|
|
1131
|
+
Generate
|
|
1132
|
+
</button>
|
|
1133
|
+
)}
|
|
1134
|
+
</div>
|
|
1135
|
+
</div>
|
|
1136
|
+
|
|
1137
|
+
{/* Key list */}
|
|
1138
|
+
{keys.length === 0 ? (
|
|
1139
|
+
<div className="sip-vk-empty" data-testid="empty-state">
|
|
1140
|
+
<KeyIcon />
|
|
1141
|
+
<p>No viewing keys yet</p>
|
|
1142
|
+
{onGenerateKey && (
|
|
1143
|
+
<button
|
|
1144
|
+
type="button"
|
|
1145
|
+
className="sip-vk-btn sip-vk-btn-secondary"
|
|
1146
|
+
onClick={() => openAction('generate')}
|
|
1147
|
+
>
|
|
1148
|
+
Generate Your First Key
|
|
1149
|
+
</button>
|
|
1150
|
+
)}
|
|
1151
|
+
</div>
|
|
1152
|
+
) : (
|
|
1153
|
+
<ul className="sip-vk-list" data-testid="key-list">
|
|
1154
|
+
{keys.map((key) => (
|
|
1155
|
+
<li key={key.id} className="sip-vk-item" data-testid={`key-item-${key.id}`}>
|
|
1156
|
+
<div className="sip-vk-item-info">
|
|
1157
|
+
<div className="sip-vk-item-icon">
|
|
1158
|
+
<KeyIcon />
|
|
1159
|
+
</div>
|
|
1160
|
+
<div className="sip-vk-item-details">
|
|
1161
|
+
<div className="sip-vk-item-label">{key.label || 'Viewing Key'}</div>
|
|
1162
|
+
<div className="sip-vk-item-key">{truncateKey(key.publicKey)}</div>
|
|
1163
|
+
<div className="sip-vk-item-meta">
|
|
1164
|
+
<span className="sip-vk-item-badge" data-status={key.status}>
|
|
1165
|
+
{key.status}
|
|
1166
|
+
</span>
|
|
1167
|
+
<span className="sip-vk-item-date">Created {formatDate(key.createdAt)}</span>
|
|
1168
|
+
</div>
|
|
1169
|
+
</div>
|
|
1170
|
+
</div>
|
|
1171
|
+
<div className="sip-vk-item-actions">
|
|
1172
|
+
{onExportKey && key.status === 'active' && (
|
|
1173
|
+
<button
|
|
1174
|
+
type="button"
|
|
1175
|
+
className="sip-vk-item-action"
|
|
1176
|
+
onClick={() => openAction('export', key.id)}
|
|
1177
|
+
title="Export key"
|
|
1178
|
+
aria-label="Export key"
|
|
1179
|
+
>
|
|
1180
|
+
<DownloadIcon />
|
|
1181
|
+
</button>
|
|
1182
|
+
)}
|
|
1183
|
+
{onShareKey && key.status === 'active' && (
|
|
1184
|
+
<button
|
|
1185
|
+
type="button"
|
|
1186
|
+
className="sip-vk-item-action"
|
|
1187
|
+
onClick={() => openAction('share', key.id)}
|
|
1188
|
+
title="Share key"
|
|
1189
|
+
aria-label="Share key"
|
|
1190
|
+
>
|
|
1191
|
+
<ShareIcon />
|
|
1192
|
+
</button>
|
|
1193
|
+
)}
|
|
1194
|
+
{onRevokeKey && key.status === 'active' && (
|
|
1195
|
+
<button
|
|
1196
|
+
type="button"
|
|
1197
|
+
className="sip-vk-item-action"
|
|
1198
|
+
onClick={() => openAction('revoke', key.id)}
|
|
1199
|
+
title="Revoke key"
|
|
1200
|
+
aria-label="Revoke key"
|
|
1201
|
+
>
|
|
1202
|
+
<TrashIcon />
|
|
1203
|
+
</button>
|
|
1204
|
+
)}
|
|
1205
|
+
</div>
|
|
1206
|
+
</li>
|
|
1207
|
+
))}
|
|
1208
|
+
</ul>
|
|
1209
|
+
)}
|
|
1210
|
+
|
|
1211
|
+
{/* Generate Modal */}
|
|
1212
|
+
{wizardStep === 'generate' && (
|
|
1213
|
+
<div className="sip-vk-modal" role="dialog" aria-modal="true" data-testid="generate-modal">
|
|
1214
|
+
<div className="sip-vk-modal-content">
|
|
1215
|
+
<div className="sip-vk-modal-header">
|
|
1216
|
+
<h3 className="sip-vk-modal-title">Generate Viewing Key</h3>
|
|
1217
|
+
<button type="button" className="sip-vk-modal-close" onClick={resetForm}>
|
|
1218
|
+
<XIcon />
|
|
1219
|
+
</button>
|
|
1220
|
+
</div>
|
|
1221
|
+
<div className="sip-vk-modal-body">
|
|
1222
|
+
<div className="sip-vk-warning">
|
|
1223
|
+
<AlertIcon />
|
|
1224
|
+
<div className="sip-vk-warning-text">
|
|
1225
|
+
<strong>Important:</strong> A viewing key allows anyone who has it to see your
|
|
1226
|
+
transaction details. Only share with trusted parties for compliance purposes.
|
|
1227
|
+
</div>
|
|
1228
|
+
</div>
|
|
1229
|
+
<div className="sip-vk-form-group">
|
|
1230
|
+
<label className="sip-vk-label" htmlFor="generate-label">
|
|
1231
|
+
Key Label (optional)
|
|
1232
|
+
</label>
|
|
1233
|
+
<input
|
|
1234
|
+
id="generate-label"
|
|
1235
|
+
type="text"
|
|
1236
|
+
className="sip-vk-input"
|
|
1237
|
+
placeholder="e.g., Auditor Key, Tax Advisor"
|
|
1238
|
+
value={generateLabel}
|
|
1239
|
+
onChange={(e) => setGenerateLabel(e.target.value)}
|
|
1240
|
+
/>
|
|
1241
|
+
</div>
|
|
1242
|
+
{error && <div className="sip-vk-danger"><AlertIcon /><div className="sip-vk-danger-text">{error}</div></div>}
|
|
1243
|
+
</div>
|
|
1244
|
+
<div className="sip-vk-modal-footer">
|
|
1245
|
+
<button type="button" className="sip-vk-btn sip-vk-btn-secondary" onClick={resetForm}>
|
|
1246
|
+
Cancel
|
|
1247
|
+
</button>
|
|
1248
|
+
<button
|
|
1249
|
+
type="button"
|
|
1250
|
+
className="sip-vk-btn sip-vk-btn-success"
|
|
1251
|
+
onClick={handleGenerate}
|
|
1252
|
+
disabled={isLoading}
|
|
1253
|
+
>
|
|
1254
|
+
{isLoading ? 'Generating...' : 'Generate Key'}
|
|
1255
|
+
</button>
|
|
1256
|
+
</div>
|
|
1257
|
+
</div>
|
|
1258
|
+
</div>
|
|
1259
|
+
)}
|
|
1260
|
+
|
|
1261
|
+
{/* Export Modal */}
|
|
1262
|
+
{wizardStep === 'export' && selectedKey && (
|
|
1263
|
+
<div className="sip-vk-modal" role="dialog" aria-modal="true" data-testid="export-modal">
|
|
1264
|
+
<div className="sip-vk-modal-content">
|
|
1265
|
+
<div className="sip-vk-modal-header">
|
|
1266
|
+
<h3 className="sip-vk-modal-title">Export Viewing Key</h3>
|
|
1267
|
+
<button type="button" className="sip-vk-modal-close" onClick={resetForm}>
|
|
1268
|
+
<XIcon />
|
|
1269
|
+
</button>
|
|
1270
|
+
</div>
|
|
1271
|
+
<div className="sip-vk-modal-body">
|
|
1272
|
+
<div className="sip-vk-form-group">
|
|
1273
|
+
<label className="sip-vk-label" htmlFor="export-format">
|
|
1274
|
+
Export Format
|
|
1275
|
+
</label>
|
|
1276
|
+
<select
|
|
1277
|
+
id="export-format"
|
|
1278
|
+
className="sip-vk-select"
|
|
1279
|
+
value={exportFormat}
|
|
1280
|
+
onChange={(e) => setExportFormat(e.target.value as KeyExportFormat)}
|
|
1281
|
+
>
|
|
1282
|
+
<option value="encrypted_file">Encrypted File (Recommended)</option>
|
|
1283
|
+
<option value="qr_code">QR Code</option>
|
|
1284
|
+
<option value="plaintext">Plain Text (Not Recommended)</option>
|
|
1285
|
+
</select>
|
|
1286
|
+
</div>
|
|
1287
|
+
{exportFormat === 'encrypted_file' && (
|
|
1288
|
+
<div className="sip-vk-form-group">
|
|
1289
|
+
<label className="sip-vk-label" htmlFor="export-password">
|
|
1290
|
+
Encryption Password
|
|
1291
|
+
</label>
|
|
1292
|
+
<input
|
|
1293
|
+
id="export-password"
|
|
1294
|
+
type="password"
|
|
1295
|
+
className="sip-vk-input"
|
|
1296
|
+
placeholder="Enter a strong password"
|
|
1297
|
+
value={exportPassword}
|
|
1298
|
+
onChange={(e) => setExportPassword(e.target.value)}
|
|
1299
|
+
/>
|
|
1300
|
+
</div>
|
|
1301
|
+
)}
|
|
1302
|
+
{exportFormat === 'qr_code' && (
|
|
1303
|
+
<div className="sip-vk-qr-display">
|
|
1304
|
+
<div className="sip-vk-qr-placeholder" data-testid="qr-placeholder">
|
|
1305
|
+
<QrCodeIcon />
|
|
1306
|
+
</div>
|
|
1307
|
+
<span style={{ fontSize: '12px', color: '#6b7280' }}>
|
|
1308
|
+
Scan to import key
|
|
1309
|
+
</span>
|
|
1310
|
+
</div>
|
|
1311
|
+
)}
|
|
1312
|
+
{exportFormat === 'plaintext' && (
|
|
1313
|
+
<div className="sip-vk-danger">
|
|
1314
|
+
<AlertIcon />
|
|
1315
|
+
<div className="sip-vk-danger-text">
|
|
1316
|
+
<strong>Warning:</strong> Exporting as plain text is not secure. The key will
|
|
1317
|
+
be visible to anyone with access to your clipboard or screen.
|
|
1318
|
+
</div>
|
|
1319
|
+
</div>
|
|
1320
|
+
)}
|
|
1321
|
+
{error && <div className="sip-vk-danger"><AlertIcon /><div className="sip-vk-danger-text">{error}</div></div>}
|
|
1322
|
+
</div>
|
|
1323
|
+
<div className="sip-vk-modal-footer">
|
|
1324
|
+
<button type="button" className="sip-vk-btn sip-vk-btn-secondary" onClick={resetForm}>
|
|
1325
|
+
Cancel
|
|
1326
|
+
</button>
|
|
1327
|
+
<button
|
|
1328
|
+
type="button"
|
|
1329
|
+
className="sip-vk-btn sip-vk-btn-success"
|
|
1330
|
+
onClick={handleExport}
|
|
1331
|
+
disabled={isLoading || (exportFormat === 'encrypted_file' && !exportPassword)}
|
|
1332
|
+
>
|
|
1333
|
+
{isLoading ? 'Exporting...' : 'Export'}
|
|
1334
|
+
</button>
|
|
1335
|
+
</div>
|
|
1336
|
+
</div>
|
|
1337
|
+
</div>
|
|
1338
|
+
)}
|
|
1339
|
+
|
|
1340
|
+
{/* Import Modal */}
|
|
1341
|
+
{wizardStep === 'import' && (
|
|
1342
|
+
<div className="sip-vk-modal" role="dialog" aria-modal="true" data-testid="import-modal">
|
|
1343
|
+
<div className="sip-vk-modal-content">
|
|
1344
|
+
<div className="sip-vk-modal-header">
|
|
1345
|
+
<h3 className="sip-vk-modal-title">Import Viewing Key</h3>
|
|
1346
|
+
<button type="button" className="sip-vk-modal-close" onClick={resetForm}>
|
|
1347
|
+
<XIcon />
|
|
1348
|
+
</button>
|
|
1349
|
+
</div>
|
|
1350
|
+
<div className="sip-vk-modal-body">
|
|
1351
|
+
<div className="sip-vk-form-group">
|
|
1352
|
+
<label className="sip-vk-label" htmlFor="import-source">
|
|
1353
|
+
Import From
|
|
1354
|
+
</label>
|
|
1355
|
+
<select
|
|
1356
|
+
id="import-source"
|
|
1357
|
+
className="sip-vk-select"
|
|
1358
|
+
value={importSource}
|
|
1359
|
+
onChange={(e) => {
|
|
1360
|
+
setImportSource(e.target.value as KeyImportSource)
|
|
1361
|
+
setImportData('')
|
|
1362
|
+
}}
|
|
1363
|
+
>
|
|
1364
|
+
<option value="file">Encrypted File</option>
|
|
1365
|
+
<option value="qr_code">QR Code</option>
|
|
1366
|
+
<option value="text">Paste Text</option>
|
|
1367
|
+
</select>
|
|
1368
|
+
</div>
|
|
1369
|
+
{importSource === 'file' && (
|
|
1370
|
+
<div className="sip-vk-form-group">
|
|
1371
|
+
<input
|
|
1372
|
+
ref={fileInputRef}
|
|
1373
|
+
type="file"
|
|
1374
|
+
accept=".enc,.json,.txt"
|
|
1375
|
+
onChange={handleFileSelect}
|
|
1376
|
+
style={{ display: 'none' }}
|
|
1377
|
+
/>
|
|
1378
|
+
<div
|
|
1379
|
+
className="sip-vk-file-upload"
|
|
1380
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1381
|
+
data-testid="file-upload"
|
|
1382
|
+
>
|
|
1383
|
+
<FileIcon />
|
|
1384
|
+
<span className="sip-vk-file-upload-text">
|
|
1385
|
+
{importData ? 'File loaded' : 'Click to select file'}
|
|
1386
|
+
</span>
|
|
1387
|
+
<span className="sip-vk-file-upload-hint">.enc, .json, or .txt</span>
|
|
1388
|
+
</div>
|
|
1389
|
+
</div>
|
|
1390
|
+
)}
|
|
1391
|
+
{importSource === 'qr_code' && (
|
|
1392
|
+
<div className="sip-vk-qr-display">
|
|
1393
|
+
<div className="sip-vk-qr-placeholder" data-testid="qr-scanner">
|
|
1394
|
+
<QrCodeIcon />
|
|
1395
|
+
</div>
|
|
1396
|
+
<span style={{ fontSize: '12px', color: '#6b7280' }}>
|
|
1397
|
+
Position QR code in camera view
|
|
1398
|
+
</span>
|
|
1399
|
+
</div>
|
|
1400
|
+
)}
|
|
1401
|
+
{importSource === 'text' && (
|
|
1402
|
+
<div className="sip-vk-form-group">
|
|
1403
|
+
<label className="sip-vk-label" htmlFor="import-data">
|
|
1404
|
+
Key Data
|
|
1405
|
+
</label>
|
|
1406
|
+
<textarea
|
|
1407
|
+
id="import-data"
|
|
1408
|
+
className="sip-vk-textarea"
|
|
1409
|
+
placeholder="Paste your viewing key here..."
|
|
1410
|
+
value={importData}
|
|
1411
|
+
onChange={(e) => setImportData(e.target.value)}
|
|
1412
|
+
/>
|
|
1413
|
+
</div>
|
|
1414
|
+
)}
|
|
1415
|
+
{error && <div className="sip-vk-danger"><AlertIcon /><div className="sip-vk-danger-text">{error}</div></div>}
|
|
1416
|
+
</div>
|
|
1417
|
+
<div className="sip-vk-modal-footer">
|
|
1418
|
+
<button type="button" className="sip-vk-btn sip-vk-btn-secondary" onClick={resetForm}>
|
|
1419
|
+
Cancel
|
|
1420
|
+
</button>
|
|
1421
|
+
<button
|
|
1422
|
+
type="button"
|
|
1423
|
+
className="sip-vk-btn sip-vk-btn-success"
|
|
1424
|
+
onClick={handleImport}
|
|
1425
|
+
disabled={isLoading || !importData}
|
|
1426
|
+
>
|
|
1427
|
+
{isLoading ? 'Importing...' : 'Import'}
|
|
1428
|
+
</button>
|
|
1429
|
+
</div>
|
|
1430
|
+
</div>
|
|
1431
|
+
</div>
|
|
1432
|
+
)}
|
|
1433
|
+
|
|
1434
|
+
{/* Share Modal */}
|
|
1435
|
+
{wizardStep === 'share' && selectedKey && (
|
|
1436
|
+
<div className="sip-vk-modal" role="dialog" aria-modal="true" data-testid="share-modal">
|
|
1437
|
+
<div className="sip-vk-modal-content">
|
|
1438
|
+
<div className="sip-vk-modal-header">
|
|
1439
|
+
<h3 className="sip-vk-modal-title">Share Viewing Key</h3>
|
|
1440
|
+
<button type="button" className="sip-vk-modal-close" onClick={resetForm}>
|
|
1441
|
+
<XIcon />
|
|
1442
|
+
</button>
|
|
1443
|
+
</div>
|
|
1444
|
+
<div className="sip-vk-modal-body">
|
|
1445
|
+
<div className="sip-vk-warning">
|
|
1446
|
+
<AlertIcon />
|
|
1447
|
+
<div className="sip-vk-warning-text">
|
|
1448
|
+
<strong>Warning:</strong> Sharing this viewing key will allow the recipient to
|
|
1449
|
+
see all transactions associated with this key. Only share with trusted parties.
|
|
1450
|
+
</div>
|
|
1451
|
+
</div>
|
|
1452
|
+
<div className="sip-vk-form-group">
|
|
1453
|
+
<label className="sip-vk-label" htmlFor="share-recipient">
|
|
1454
|
+
Recipient Address or Email
|
|
1455
|
+
</label>
|
|
1456
|
+
<input
|
|
1457
|
+
id="share-recipient"
|
|
1458
|
+
type="text"
|
|
1459
|
+
className="sip-vk-input"
|
|
1460
|
+
placeholder="e.g., auditor.near or auditor@company.com"
|
|
1461
|
+
value={shareRecipient}
|
|
1462
|
+
onChange={(e) => setShareRecipient(e.target.value)}
|
|
1463
|
+
/>
|
|
1464
|
+
</div>
|
|
1465
|
+
{error && <div className="sip-vk-danger"><AlertIcon /><div className="sip-vk-danger-text">{error}</div></div>}
|
|
1466
|
+
</div>
|
|
1467
|
+
<div className="sip-vk-modal-footer">
|
|
1468
|
+
<button type="button" className="sip-vk-btn sip-vk-btn-secondary" onClick={resetForm}>
|
|
1469
|
+
Cancel
|
|
1470
|
+
</button>
|
|
1471
|
+
<button
|
|
1472
|
+
type="button"
|
|
1473
|
+
className="sip-vk-btn sip-vk-btn-success"
|
|
1474
|
+
onClick={handleShare}
|
|
1475
|
+
disabled={isLoading || !shareRecipient}
|
|
1476
|
+
>
|
|
1477
|
+
{isLoading ? 'Sharing...' : 'Share Key'}
|
|
1478
|
+
</button>
|
|
1479
|
+
</div>
|
|
1480
|
+
</div>
|
|
1481
|
+
</div>
|
|
1482
|
+
)}
|
|
1483
|
+
|
|
1484
|
+
{/* Revoke Modal */}
|
|
1485
|
+
{wizardStep === 'revoke' && selectedKey && (
|
|
1486
|
+
<div className="sip-vk-modal" role="dialog" aria-modal="true" data-testid="revoke-modal">
|
|
1487
|
+
<div className="sip-vk-modal-content">
|
|
1488
|
+
<div className="sip-vk-modal-header">
|
|
1489
|
+
<h3 className="sip-vk-modal-title">Revoke Viewing Key</h3>
|
|
1490
|
+
<button type="button" className="sip-vk-modal-close" onClick={resetForm}>
|
|
1491
|
+
<XIcon />
|
|
1492
|
+
</button>
|
|
1493
|
+
</div>
|
|
1494
|
+
<div className="sip-vk-modal-body">
|
|
1495
|
+
<div className="sip-vk-danger">
|
|
1496
|
+
<AlertIcon />
|
|
1497
|
+
<div className="sip-vk-danger-text">
|
|
1498
|
+
<strong>This action cannot be undone.</strong> Revoking this key will
|
|
1499
|
+
immediately prevent anyone with this key from viewing your transactions.
|
|
1500
|
+
{selectedKey.sharedWith && selectedKey.sharedWith.length > 0 && (
|
|
1501
|
+
<> This key has been shared with {selectedKey.sharedWith.length} recipient(s).</>
|
|
1502
|
+
)}
|
|
1503
|
+
</div>
|
|
1504
|
+
</div>
|
|
1505
|
+
<p style={{ fontSize: '14px', color: '#374151', marginBottom: 0 }}>
|
|
1506
|
+
Are you sure you want to revoke the key "{selectedKey.label || 'Viewing Key'}"?
|
|
1507
|
+
</p>
|
|
1508
|
+
{error && <div className="sip-vk-danger" style={{ marginTop: 16 }}><AlertIcon /><div className="sip-vk-danger-text">{error}</div></div>}
|
|
1509
|
+
</div>
|
|
1510
|
+
<div className="sip-vk-modal-footer">
|
|
1511
|
+
<button type="button" className="sip-vk-btn sip-vk-btn-secondary" onClick={resetForm}>
|
|
1512
|
+
Cancel
|
|
1513
|
+
</button>
|
|
1514
|
+
<button
|
|
1515
|
+
type="button"
|
|
1516
|
+
className="sip-vk-btn sip-vk-btn-danger"
|
|
1517
|
+
onClick={handleRevoke}
|
|
1518
|
+
disabled={isLoading}
|
|
1519
|
+
>
|
|
1520
|
+
{isLoading ? 'Revoking...' : 'Revoke Key'}
|
|
1521
|
+
</button>
|
|
1522
|
+
</div>
|
|
1523
|
+
</div>
|
|
1524
|
+
</div>
|
|
1525
|
+
)}
|
|
1526
|
+
</div>
|
|
1527
|
+
</>
|
|
1528
|
+
)
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Hook to manage viewing keys
|
|
1533
|
+
*/
|
|
1534
|
+
export function useViewingKeyManager(initialKeys: ViewingKey[] = []) {
|
|
1535
|
+
const [keys, setKeys] = useState<ViewingKey[]>(initialKeys)
|
|
1536
|
+
|
|
1537
|
+
const addKey = useCallback((key: ViewingKey) => {
|
|
1538
|
+
setKeys((prev) => [...prev, key])
|
|
1539
|
+
}, [])
|
|
1540
|
+
|
|
1541
|
+
const removeKey = useCallback((keyId: string) => {
|
|
1542
|
+
setKeys((prev) => prev.filter((k) => k.id !== keyId))
|
|
1543
|
+
}, [])
|
|
1544
|
+
|
|
1545
|
+
const updateKey = useCallback((keyId: string, updates: Partial<ViewingKey>) => {
|
|
1546
|
+
setKeys((prev) =>
|
|
1547
|
+
prev.map((k) => (k.id === keyId ? { ...k, ...updates } : k))
|
|
1548
|
+
)
|
|
1549
|
+
}, [])
|
|
1550
|
+
|
|
1551
|
+
const revokeKey = useCallback((keyId: string) => {
|
|
1552
|
+
updateKey(keyId, {
|
|
1553
|
+
status: 'revoked',
|
|
1554
|
+
usageHistory: [
|
|
1555
|
+
...(keys.find((k) => k.id === keyId)?.usageHistory || []),
|
|
1556
|
+
{ timestamp: Date.now(), action: 'revoked' },
|
|
1557
|
+
],
|
|
1558
|
+
})
|
|
1559
|
+
}, [keys, updateKey])
|
|
1560
|
+
|
|
1561
|
+
const activeKeys = useMemo(() => keys.filter((k) => k.status === 'active'), [keys])
|
|
1562
|
+
const revokedKeys = useMemo(() => keys.filter((k) => k.status === 'revoked'), [keys])
|
|
1563
|
+
|
|
1564
|
+
return {
|
|
1565
|
+
keys,
|
|
1566
|
+
setKeys,
|
|
1567
|
+
addKey,
|
|
1568
|
+
removeKey,
|
|
1569
|
+
updateKey,
|
|
1570
|
+
revokeKey,
|
|
1571
|
+
activeKeys,
|
|
1572
|
+
revokedKeys,
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
export default ViewingKeyManager
|