@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.
@@ -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