diff --git a/frontend/__tests__/credential-sharing.test.tsx b/frontend/__tests__/credential-sharing.test.tsx index fdcc4c7c..724cfd49 100644 --- a/frontend/__tests__/credential-sharing.test.tsx +++ b/frontend/__tests__/credential-sharing.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { CredentialSharing } from '../src/components/credential-sharing'; import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; @@ -6,6 +7,9 @@ function renderWithProviders(ui: React.ReactElement) { return render({ui}); } +// Checksum-valid Stellar ed25519 public key (version byte 0x30, CRC16-XModem, little-endian). +const VALID_RECIPIENT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; + describe('CredentialSharing', () => { const walletAddress = 'GABCDEF123456...'; @@ -17,7 +21,7 @@ describe('CredentialSharing', () => { it('renders share form elements', () => { renderWithProviders(); expect(screen.getByText('Recipient Wallet Address')).toBeInTheDocument(); - expect(screen.getByText('Select Vaccination Credential')).toBeInTheDocument(); + expect(screen.getByText('Select Credentials to Share')).toBeInTheDocument(); expect(screen.getByText('Proof Duration')).toBeInTheDocument(); }); @@ -46,4 +50,149 @@ describe('CredentialSharing', () => { renderWithProviders(); expect(screen.getByText('Shared Credentials')).toBeInTheDocument(); }); + + it('renders selectable credentials', () => { + renderWithProviders(); + expect(screen.getByRole('checkbox', { name: 'Share COVID-19 (Pfizer)' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Share Influenza 2025' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Share Hepatitis B' })).toBeInTheDocument(); + }); + + it('keeps share button disabled until recipient and credential are valid', () => { + renderWithProviders(); + const shareButton = screen.getAllByText('Share Vaccination Proof').find( + (el) => el.tagName === 'BUTTON' + ) as HTMLButtonElement; + expect(shareButton).toBeDisabled(); + }); + + it('shows validation error for an invalid recipient address', async () => { + const user = userEvent.setup(); + renderWithProviders(); + const recipientInput = screen.getByLabelText('Recipient Wallet Address'); + + await user.type(recipientInput, 'not-a-stellar-address'); + + expect( + screen.getByText('Enter a valid Stellar address (starts with G, 56 characters total)') + ).toBeInTheDocument(); + }); + + it('rejects a well-formed but checksum-invalid recipient address', async () => { + const user = userEvent.setup(); + renderWithProviders(); + const recipientInput = screen.getByLabelText('Recipient Wallet Address'); + + // 56 chars in the right alphabet, but the CRC16 checksum is wrong. + const badChecksum = 'G' + 'B'.repeat(55); + await user.type(recipientInput, badChecksum); + + expect( + screen.getByText('Enter a valid Stellar address (starts with G, 56 characters total)') + ).toBeInTheDocument(); + }); + + it('accepts a known checksum-valid Stellar public key', async () => { + const user = userEvent.setup(); + renderWithProviders(); + const recipientInput = screen.getByLabelText('Recipient Wallet Address'); + + await user.type(recipientInput, VALID_RECIPIENT); + + expect( + screen.queryByText('Enter a valid Stellar address (starts with G, 56 characters total)') + ).not.toBeInTheDocument(); + }); + + it('opens the confirmation dialog when the form is valid', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.type(screen.getByLabelText('Recipient Wallet Address'), VALID_RECIPIENT); + await user.click(screen.getByRole('checkbox', { name: 'Share COVID-19 (Pfizer)' })); + + const shareButton = screen.getAllByText('Share Vaccination Proof').find( + (el) => el.tagName === 'BUTTON' + ) as HTMLButtonElement; + await user.click(shareButton); + + expect(screen.getByRole('button', { name: 'Confirm Share' })).toBeInTheDocument(); + expect(screen.getByText(VALID_RECIPIENT)).toBeInTheDocument(); + }); + + describe('expiry transitions', () => { + beforeEach(() => { + jest.useFakeTimers(); + // Keep the simulated 10% network failure out of the test. + jest.spyOn(Math, 'random').mockReturnValue(0.5); + // jsdom does not ship crypto.randomUUID. + if (!global.crypto?.randomUUID) { + let idCounter = 0; + Object.defineProperty(global.crypto, 'randomUUID', { + value: () => `test-id-${++idCounter}`, + configurable: true, + }); + } + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + async function shareOne(user: ReturnType, credentialLabel: string) { + await user.type(screen.getByLabelText('Recipient Wallet Address'), VALID_RECIPIENT); + await user.click(screen.getByRole('checkbox', { name: `Share ${credentialLabel}` })); + + const shareButton = screen.getAllByText('Share Vaccination Proof').find( + (el) => el.tagName === 'BUTTON' + ) as HTMLButtonElement; + await user.click(shareButton); + await user.click(screen.getByRole('button', { name: 'Confirm Share' })); + + // Let the simulated proof generation finish, then reset the form so the + // next share can be created cleanly. + await act(async () => { + jest.advanceTimersByTime(3000); + }); + await user.clear(screen.getByLabelText('Recipient Wallet Address')); + await user.click(screen.getByRole('checkbox', { name: `Share ${credentialLabel}` })); + } + + it( + 'flips each share to expired at its own expiry time without user interaction', + async () => { + const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); + + renderWithProviders(); + + // First share: 1 hour. + await user.selectOptions(screen.getByLabelText('Proof Duration'), '3600'); + await shareOne(user, 'COVID-19 (Pfizer)'); + + // Second share: 1 day. + await user.selectOptions(screen.getByLabelText('Proof Duration'), '86400'); + await shareOne(user, 'Influenza 2025'); + + expect(screen.getAllByText('Active')).toHaveLength(2); + + // Advance past the first expiry only — the hourly share flips to + // expired while the daily one stays active. + await act(async () => { + jest.advanceTimersByTime(3600 * 1000 + 1); + }); + const badges = screen.getAllByText(/^(Active|Expired)$/); + expect(badges.filter((el) => el.textContent === 'Expired')).toHaveLength(1); + expect(badges.filter((el) => el.textContent === 'Active')).toHaveLength(1); + + // Advance past the second expiry — everything is expired now. + await act(async () => { + jest.advanceTimersByTime(86400 * 1000); + }); + expect(screen.queryByText('Active')).not.toBeInTheDocument(); + expect(screen.getAllByText('Expired')).toHaveLength(2); + }, + 15000 + ); + }); }); diff --git a/frontend/__tests__/stellar-address.test.ts b/frontend/__tests__/stellar-address.test.ts new file mode 100644 index 00000000..fe87aec2 --- /dev/null +++ b/frontend/__tests__/stellar-address.test.ts @@ -0,0 +1,32 @@ +import { isValidStellarAddress } from '@/utils/stellar-address'; + +describe('isValidStellarAddress', () => { + it('accepts a checksum-valid Stellar ed25519 public key', () => { + expect( + isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF') + ).toBe(true); + }); + + it('rejects a correctly formatted key with a bad checksum', () => { + // Same valid key with the last few chars altered to corrupt the checksum. + expect( + isValidStellarAddress('GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + ).toBe(false); + }); + + it('rejects non-Stellar strings', () => { + expect(isValidStellarAddress('not-an-address')).toBe(false); + expect(isValidStellarAddress('')).toBe(false); + }); + + it('rejects wrong lengths', () => { + expect(isValidStellarAddress('G' + 'A'.repeat(54))).toBe(false); + expect(isValidStellarAddress('G' + 'A'.repeat(56))).toBe(false); + }); + + it('rejects lowercase characters', () => { + expect( + isValidStellarAddress('gaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaawhf') + ).toBe(false); + }); +}); diff --git a/frontend/src/components/credential-sharing.tsx b/frontend/src/components/credential-sharing.tsx index ee4b82cc..e07e73dc 100644 --- a/frontend/src/components/credential-sharing.tsx +++ b/frontend/src/components/credential-sharing.tsx @@ -1,37 +1,114 @@ 'use client'; -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Share2, Lock, Clock, X, Shield } from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; import { AnimatedProgress, SuccessOverlay, SuccessToast } from './animations'; +import { ShareConfirmationModal } from './share-confirmation-modal'; import { useAccessibility } from '@/contexts/AccessibilityContext'; import { useCredentialOperation } from '@/hooks/useCredentialOperation'; +import { isValidStellarAddress } from '@/utils/stellar-address'; import { AlertCircle } from 'lucide-react'; interface CredentialSharingProps { walletAddress: string; } +interface SelectableCredential { + id: string; + vaccineType: string; +} + interface SharedCredential { id: string; vaccineType: string; recipient: string; expiresAt: string; + status: 'active' | 'revoked' | 'expired'; +} + +// Credentials available to share, mirroring the mock data used elsewhere in the +// vault/verification views until real credential records are wired up. +const AVAILABLE_CREDENTIALS: SelectableCredential[] = [ + { id: 'covid-pfizer', vaccineType: 'COVID-19 (Pfizer)' }, + { id: 'influenza-2025', vaccineType: 'Influenza 2025' }, + { id: 'hepatitis-b', vaccineType: 'Hepatitis B' }, +]; + +const DURATION_OPTIONS = [ + { value: '3600', label: '1 hour' }, + { value: '86400', label: '1 day' }, + { value: '604800', label: '1 week' }, + { value: '2592000', label: '1 month' }, +]; + +// Stellar public keys are 56 chars with a version byte and CRC16-XModem +// checksum; validate the checksum so a typo'd address can't receive a share. +function isValidRecipient(address: string): boolean { + return isValidStellarAddress(address); +} + +// A share is expired once its expiry time passes, regardless of the stored +// status. Derive the effective status so the list never shows stale "active". +function effectiveStatus( + share: { status: SharedCredential['status']; expiresAt: string }, + now: number +): SharedCredential['status'] { + if (share.status === 'revoked') return 'revoked'; + if (now >= new Date(share.expiresAt).getTime()) return 'expired'; + return 'active'; } export function CredentialSharing({ walletAddress }: CredentialSharingProps) { + const [recipient, setRecipient] = useState(''); + const [selectedIds, setSelectedIds] = useState([]); + const [durationSeconds, setDurationSeconds] = useState(86400); const [sharedCredentials, setSharedCredentials] = useState([]); const [shareProgress, setShareProgress] = useState(0); const [showSuccess, setShowSuccess] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [toast, setToast] = useState<{ show: boolean; title: string; description?: string }>({ show: false, title: '', }); const { announceToScreenReader } = useAccessibility(); const shareButtonRef = useRef(null); - + const statusRegionRef = useRef(null); + const { execute, error, clearError, isPending: isSharing } = useCredentialOperation(); + // Re-render active shares exactly when the nearest not-yet-expired one + // expires. Depending on `now` reschedules after every expiry transition. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const nextExpiry = sharedCredentials + .filter((s) => s.status !== 'revoked') + .map((s) => new Date(s.expiresAt).getTime()) + .filter((expiry) => expiry > now) + .sort((a, b) => a - b)[0]; + + if (nextExpiry === undefined) return; + + const timer = setTimeout(() => setNow(Date.now()), Math.max(0, nextExpiry - Date.now())); + return () => clearTimeout(timer); + }, [sharedCredentials, now]); + + const selectedCredentials = useMemo( + () => AVAILABLE_CREDENTIALS.filter((credential) => selectedIds.includes(credential.id)), + [selectedIds] + ); + + const formInvalid = !isValidRecipient(recipient) || selectedCredentials.length === 0; + + const toggleCredential = useCallback( + (id: string) => { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((existing) => existing !== id) : [...prev, id] + ); + }, + [] + ); + const handleShare = useCallback(async () => { if (isSharing) return; clearError(); @@ -62,24 +139,42 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { setShowSuccess(true); announceToScreenReader('Proof generated successfully'); - const newShare: SharedCredential = { + const expiresAt = new Date(Date.now() + durationSeconds * 1000).toISOString(); + const newShares: SharedCredential[] = selectedCredentials.map((credential) => ({ id: crypto.randomUUID(), - vaccineType: 'COVID-19 Vaccination', - recipient: 'GABCDEF123456...', - expiresAt: new Date(Date.now() + 86400000).toISOString(), - }; - setSharedCredentials((prev) => [...prev, newShare]); + vaccineType: credential.vaccineType, + recipient: recipient.trim(), + expiresAt, + status: 'active', + })); + setSharedCredentials((prev) => [...prev, ...newShares]); resolve(); }, 2400); }); }, { context: 'ShareCredential', }); - }, [isSharing, clearError, execute, announceToScreenReader]); + }, [isSharing, clearError, execute, announceToScreenReader, selectedCredentials, recipient, durationSeconds]); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + if (formInvalid) return; + setIsConfirmOpen(true); + }, + [formInvalid] + ); + + const handleConfirmShare = useCallback(() => { + setIsConfirmOpen(false); + void handleShare(); + }, [handleShare]); const handleRevoke = useCallback( (id: string, vaccineType: string) => { - setSharedCredentials((prev) => prev.filter((c) => c.id !== id)); + setSharedCredentials((prev) => + prev.map((share) => (share.id === id ? { ...share, status: 'revoked' } : share)) + ); setToast({ show: true, title: 'Access Revoked', @@ -91,15 +186,12 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { [announceToScreenReader] ); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent, action: () => void) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - action(); - } - }, - [] - ); + const handleKeyDown = useCallback((e: React.KeyboardEvent, action: () => void) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + action(); + } + }, []); return (
@@ -110,13 +202,7 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { {/* Share form */}

Share Vaccination Proof

-
{ - e.preventDefault(); - handleShare(); - }} - className="space-y-3 sm:space-y-4" - > +
-
- - -
+ +
+ + Select Credentials to Share + +
+ {AVAILABLE_CREDENTIALS.map((credential) => { + const checked = selectedIds.includes(credential.id); + return ( + + ); + })} +
+
+
@@ -200,16 +313,18 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) {
+ {/* Share confirmation dialog */} + setIsConfirmOpen(false)} + returnFocusTo={shareButtonRef.current} + postConfirmFocusRef={statusRegionRef} + /> + + {/* Status region: receives focus when the share button is disabled after confirmation. */} +
+ {/* Success overlay */} setShowSuccess(false)} /> diff --git a/frontend/src/components/share-confirmation-modal.tsx b/frontend/src/components/share-confirmation-modal.tsx new file mode 100644 index 00000000..22be8f22 --- /dev/null +++ b/frontend/src/components/share-confirmation-modal.tsx @@ -0,0 +1,210 @@ +'use client'; + +import { useEffect, useCallback, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Share2, Shield, Clock, CheckCircle } from 'lucide-react'; +import { useAccessibility } from '@/contexts/AccessibilityContext'; +import { getFocusableElements } from '@/utils/focusManagement'; + +interface ShareSummary { + recipient: string; + credentials: { id: string; vaccineType: string }[]; + durationSeconds: number; +} + +interface ShareConfirmationModalProps { + isOpen: boolean; + summary: ShareSummary | null; + onConfirm: () => void; + onCancel: () => void; + returnFocusTo?: HTMLElement | null; + postConfirmFocusRef?: React.RefObject; +} + +const DURATION_LABELS: Record = { + 3600: '1 hour', + 86400: '1 day', + 604800: '1 week', + 2592000: '1 month', +}; + +export function ShareConfirmationModal({ + isOpen, + summary, + onConfirm, + onCancel, + returnFocusTo, + postConfirmFocusRef, +}: ShareConfirmationModalProps) { + const { announceToScreenReader } = useAccessibility(); + const dialogRef = useRef(null); + const openerRef = useRef(null); + + useEffect(() => { + if (isOpen) { + announceToScreenReader('Share confirmation dialog opened'); + // Remember the element that opened the dialog so we can restore focus + // when it closes. Fall back to the active element if no explicit opener + // was provided. + openerRef.current = returnFocusTo ?? (document.activeElement as HTMLElement | null); + // Move focus into the dialog so keyboard/screen-reader users land inside. + const focusable = dialogRef.current ? getFocusableElements(dialogRef.current) : []; + if (focusable.length > 0) { + focusable[0].focus(); + } else { + dialogRef.current?.focus(); + } + } + }, [isOpen, announceToScreenReader, returnFocusTo]); + + const focusOpenerOrFallback = useCallback(() => { + const opener = openerRef.current; + const isDisabled = + opener instanceof HTMLButtonElement || opener instanceof HTMLInputElement + ? opener.disabled + : false; + if (opener && 'focus' in opener && !isDisabled) { + opener.focus(); + } else if (postConfirmFocusRef?.current) { + postConfirmFocusRef.current.focus(); + } + }, [postConfirmFocusRef]); + + const handleCancel = useCallback(() => { + const opener = openerRef.current; + if (opener && 'focus' in opener) { + opener.focus(); + } + onCancel(); + }, [onCancel]); + + const handleConfirm = useCallback(() => { + focusOpenerOrFallback(); + onConfirm(); + }, [onConfirm, focusOpenerOrFallback]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + handleCancel(); + return; + } + // Trap Tab and Shift+Tab within the dialog while it is open. + if (e.key === 'Tab' && dialogRef.current) { + const focusable = getFocusableElements(dialogRef.current); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }, + [handleCancel] + ); + + if (!summary) return null; + + return ( + + {isOpen && ( + + {/* Backdrop */} + + + {/* Modal content */} + + {/* Header */} +
+
+ +
+

+ Confirm Share +

+
+ + {/* Summary */} +
+
+
Recipient
+
{summary.recipient}
+
+
+
Credentials
+
    + {summary.credentials.map((credential) => ( +
  • + + {credential.vaccineType} +
  • + ))} +
+
+
+
Duration
+
+ + {DURATION_LABELS[summary.durationSeconds] ?? `${summary.durationSeconds} seconds`} +
+
+
+ + {/* Note */} +

+ The recipient will be able to view these credentials for the selected duration. You can revoke + access at any time from the shared list below. +

+ + {/* Action buttons */} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/utils/stellar-address.ts b/frontend/src/utils/stellar-address.ts new file mode 100644 index 00000000..6b899bd3 --- /dev/null +++ b/frontend/src/utils/stellar-address.ts @@ -0,0 +1,58 @@ +// Minimal Stellar StrKey validation. +// +// Stellar public keys are base32-encoded with a version byte and a CRC16-XModem +// checksum appended. The checksum is what makes an address that merely looks +// valid actually valid — catching typos that a plain character regex misses. + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + +function crc16Xmodem(bytes: number[]): number { + let crc = 0x0000; + for (const byte of bytes) { + crc ^= byte << 8; + for (let i = 0; i < 8; i++) { + crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc & 0xffff; +} + +function decodeBase32(encoded: string): number[] | null { + const bytes: number[] = []; + let buffer = 0; + let bits = 0; + + for (const char of encoded) { + const value = BASE32_ALPHABET.indexOf(char); + if (value === -1) return null; + buffer = (buffer << 5) | value; + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes.push((buffer >> bits) & 0xff); + } + } + + return bytes; +} + +/** + * Returns true when the string is a checksum-valid Stellar (G...) public key. + */ +export function isValidStellarAddress(address: string): boolean { + const trimmed = address.trim(); + if (!/^G[A-Z2-7]{55}$/.test(trimmed)) return false; + + const decoded = decodeBase32(trimmed); + if (!decoded || decoded.length !== 35) return false; + + const versionByte = decoded[0]; + if (versionByte !== 6 << 3) return false; // version byte for ed25519 public key + + const payload = decoded.slice(0, 33); + const checksum = decoded.slice(33); + const expected = crc16Xmodem(payload); + + // StrKey checksums are serialized little-endian. + return checksum[0] === (expected & 0xff) && checksum[1] === (expected >> 8); +}