From 80238ff70c38956c0a2f645915ed2cf4165746ef Mon Sep 17 00:00:00 2001 From: bbkenny Date: Mon, 24 Aug 2026 02:00:15 +0100 Subject: [PATCH 1/5] feat(sharing): build comprehensive credential sharing interface Rework the credential sharing view so it satisfies the full sharing flow: - recipient address input is controlled and validated against the Stellar public key format (G + 55 base32 chars) - multi-credential selection via checkboxes instead of an empty dropdown - duration is wired to state and drives the computed expiry - a confirmation dialog summarizes recipient/credentials/duration before the share is executed - shared entries now track status (active/revoked) and show a status badge; revoke marks the entry revoked instead of removing it Adds ShareConfirmationModal and expands the sharing tests to cover the new validation, selection, and confirmation behavior. Closes #63 --- .../__tests__/credential-sharing.test.tsx | 48 +++- .../src/components/credential-sharing.tsx | 242 +++++++++++++----- .../components/share-confirmation-modal.tsx | 147 +++++++++++ 3 files changed, 375 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/share-confirmation-modal.tsx diff --git a/frontend/__tests__/credential-sharing.test.tsx b/frontend/__tests__/credential-sharing.test.tsx index fdcc4c7c..8cdcc796 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 userEvent from '@testing-library/user-event'; import { CredentialSharing } from '../src/components/credential-sharing'; import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; @@ -6,6 +7,8 @@ function renderWithProviders(ui: React.ReactElement) { return render({ui}); } +const VALID_RECIPIENT = 'G' + 'A'.repeat(55); + describe('CredentialSharing', () => { const walletAddress = 'GABCDEF123456...'; @@ -17,7 +20,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 +49,47 @@ 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('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(); + }); }); diff --git a/frontend/src/components/credential-sharing.tsx b/frontend/src/components/credential-sharing.tsx index ee4b82cc..071fcac9 100644 --- a/frontend/src/components/credential-sharing.tsx +++ b/frontend/src/components/credential-sharing.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useMemo } 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 { AlertCircle } from 'lucide-react'; @@ -12,26 +13,72 @@ 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: "G" followed by 55 base32 (A-Z, 2-7) chars. +function isValidRecipient(address: string): boolean { + return /^G[A-Z2-7]{55}$/.test(address.trim()); } 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 { execute, error, clearError, isPending: isSharing } = useCredentialOperation(); + 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 +109,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 +156,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 +172,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 +283,18 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) {
+ {/* Share confirmation dialog */} + setIsConfirmOpen(false)} + /> + {/* 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..9155be57 --- /dev/null +++ b/frontend/src/components/share-confirmation-modal.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { useEffect, useCallback } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Share2, Shield, Clock, CheckCircle } from 'lucide-react'; +import { useAccessibility } from '@/contexts/AccessibilityContext'; + +interface ShareSummary { + recipient: string; + credentials: { id: string; vaccineType: string }[]; + durationSeconds: number; +} + +interface ShareConfirmationModalProps { + isOpen: boolean; + summary: ShareSummary | null; + onConfirm: () => void; + onCancel: () => void; +} + +const DURATION_LABELS: Record = { + 3600: '1 hour', + 86400: '1 day', + 604800: '1 week', + 2592000: '1 month', +}; + +export function ShareConfirmationModal({ + isOpen, + summary, + onConfirm, + onCancel, +}: ShareConfirmationModalProps) { + const { announceToScreenReader } = useAccessibility(); + + useEffect(() => { + if (isOpen) { + announceToScreenReader('Share confirmation dialog opened'); + } + }, [isOpen, announceToScreenReader]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + onCancel(); + } + }, + [onCancel] + ); + + 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 */} +
+ + +
+
+
+ )} +
+ ); +} From 92a84e2e2512d82bc37ea4930c7ef107f1f5c9ab Mon Sep 17 00:00:00 2001 From: bbkenny Date: Mon, 24 Aug 2026 02:27:18 +0100 Subject: [PATCH 2/5] fix(sharing): address CodeRabbit review - validate the Stellar public-key checksum (CRC16-XModem) instead of only matching the character grammar - derive the effective share status from expiresAt so expired shares no longer render as active or expose the revoke action - focus the confirmation dialog on open and trap Tab within it --- .../__tests__/credential-sharing.test.tsx | 16 +++++- .../src/components/credential-sharing.tsx | 31 +++++++--- .../components/share-confirmation-modal.tsx | 30 +++++++++- frontend/src/utils/stellar-address.ts | 57 +++++++++++++++++++ 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 frontend/src/utils/stellar-address.ts diff --git a/frontend/__tests__/credential-sharing.test.tsx b/frontend/__tests__/credential-sharing.test.tsx index 8cdcc796..939a2d1c 100644 --- a/frontend/__tests__/credential-sharing.test.tsx +++ b/frontend/__tests__/credential-sharing.test.tsx @@ -7,7 +7,7 @@ function renderWithProviders(ui: React.ReactElement) { return render({ui}); } -const VALID_RECIPIENT = 'G' + 'A'.repeat(55); +const VALID_RECIPIENT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABZKY'; describe('CredentialSharing', () => { const walletAddress = 'GABCDEF123456...'; @@ -77,6 +77,20 @@ describe('CredentialSharing', () => { ).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('opens the confirmation dialog when the form is valid', async () => { const user = userEvent.setup(); renderWithProviders(); diff --git a/frontend/src/components/credential-sharing.tsx b/frontend/src/components/credential-sharing.tsx index 071fcac9..f043db98 100644 --- a/frontend/src/components/credential-sharing.tsx +++ b/frontend/src/components/credential-sharing.tsx @@ -7,6 +7,7 @@ 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 { @@ -41,9 +42,18 @@ const DURATION_OPTIONS = [ { value: '2592000', label: '1 month' }, ]; -// Stellar public keys are 56 chars: "G" followed by 55 base32 (A-Z, 2-7) chars. +// 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 /^G[A-Z2-7]{55}$/.test(address.trim()); + 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 }): SharedCredential['status'] { + if (share.status === 'revoked') return 'revoked'; + if (Date.now() > new Date(share.expiresAt).getTime()) return 'expired'; + return 'active'; } export function CredentialSharing({ walletAddress }: CredentialSharingProps) { @@ -320,7 +330,9 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) {

No credentials shared yet

) : ( - sharedCredentials.map((share, index) => ( + sharedCredentials.map((share, index) => { + const status = effectiveStatus(share); + return (
diff --git a/frontend/src/components/share-confirmation-modal.tsx b/frontend/src/components/share-confirmation-modal.tsx index 9155be57..3bb2bbb8 100644 --- a/frontend/src/components/share-confirmation-modal.tsx +++ b/frontend/src/components/share-confirmation-modal.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useEffect, useCallback } from 'react'; +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; @@ -32,17 +33,42 @@ export function ShareConfirmationModal({ onCancel, }: ShareConfirmationModalProps) { const { announceToScreenReader } = useAccessibility(); + const dialogRef = useRef(null); useEffect(() => { if (isOpen) { announceToScreenReader('Share confirmation dialog opened'); + // 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]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Escape') { + e.preventDefault(); onCancel(); + 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(); + } } }, [onCancel] @@ -75,6 +101,8 @@ export function ShareConfirmationModal({ {/* Modal content */} = 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); + + return checksum[0] === (expected >> 8) && checksum[1] === (expected & 0xff); +} From fd4589e8a328599144bdfa1cd12e115abeb2e486 Mon Sep 17 00:00:00 2001 From: bbkenny Date: Mon, 24 Aug 2026 03:03:13 +0100 Subject: [PATCH 3/5] fix(sharing): address remaining CodeRabbit review comments - use little-endian checksum comparison in Stellar StrKey validation and add regression tests with a checksum-valid key - derive effective status with >= expiry comparison and schedule a re-render at the nearest non-revoked share expiry - capture the dialog opener, restore focus on cancel, and fall back to the status region when the opener is disabled after confirmation --- .../__tests__/credential-sharing.test.tsx | 15 +++++- frontend/__tests__/stellar-address.test.ts | 32 +++++++++++++ .../src/components/credential-sharing.tsx | 42 +++++++++++++++-- .../components/share-confirmation-modal.tsx | 47 ++++++++++++++++--- frontend/src/utils/stellar-address.ts | 3 +- 5 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 frontend/__tests__/stellar-address.test.ts diff --git a/frontend/__tests__/credential-sharing.test.tsx b/frontend/__tests__/credential-sharing.test.tsx index 939a2d1c..d19a4fa1 100644 --- a/frontend/__tests__/credential-sharing.test.tsx +++ b/frontend/__tests__/credential-sharing.test.tsx @@ -7,7 +7,8 @@ function renderWithProviders(ui: React.ReactElement) { return render({ui}); } -const VALID_RECIPIENT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABZKY'; +// Checksum-valid Stellar ed25519 public key (version byte 0x30, CRC16-XModem, little-endian). +const VALID_RECIPIENT = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF'; describe('CredentialSharing', () => { const walletAddress = 'GABCDEF123456...'; @@ -91,6 +92,18 @@ describe('CredentialSharing', () => { ).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(); 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 f043db98..5755199d 100644 --- a/frontend/src/components/credential-sharing.tsx +++ b/frontend/src/components/credential-sharing.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useRef, useMemo } 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'; @@ -50,9 +50,12 @@ function isValidRecipient(address: string): boolean { // 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 }): SharedCredential['status'] { +function effectiveStatus( + share: { status: SharedCredential['status']; expiresAt: string }, + now: number +): SharedCredential['status'] { if (share.status === 'revoked') return 'revoked'; - if (Date.now() > new Date(share.expiresAt).getTime()) return 'expired'; + if (now >= new Date(share.expiresAt).getTime()) return 'expired'; return 'active'; } @@ -70,9 +73,29 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { }); 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 non-revoked one expires. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const activeShares = sharedCredentials.filter((s) => s.status !== 'revoked'); + if (activeShares.length === 0) return; + + const nextExpiry = Math.min( + ...activeShares.map((s) => new Date(s.expiresAt).getTime()) + ); + const delay = nextExpiry - Date.now(); + if (delay <= 0) { + setNow(Date.now()); + return; + } + + const timer = setTimeout(() => setNow(Date.now()), delay); + return () => clearTimeout(timer); + }, [sharedCredentials]); + const selectedCredentials = useMemo( () => AVAILABLE_CREDENTIALS.filter((credential) => selectedIds.includes(credential.id)), [selectedIds] @@ -331,7 +354,7 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { ) : ( sharedCredentials.map((share, index) => { - const status = effectiveStatus(share); + const status = effectiveStatus(share, now); return ( setIsConfirmOpen(false)} + returnFocusTo={shareButtonRef.current} + postConfirmFocusRef={statusRegionRef} + /> + + {/* Status region: receives focus when the share button is disabled after confirmation. */} +
{/* Success overlay */} diff --git a/frontend/src/components/share-confirmation-modal.tsx b/frontend/src/components/share-confirmation-modal.tsx index 3bb2bbb8..22be8f22 100644 --- a/frontend/src/components/share-confirmation-modal.tsx +++ b/frontend/src/components/share-confirmation-modal.tsx @@ -17,6 +17,8 @@ interface ShareConfirmationModalProps { summary: ShareSummary | null; onConfirm: () => void; onCancel: () => void; + returnFocusTo?: HTMLElement | null; + postConfirmFocusRef?: React.RefObject; } const DURATION_LABELS: Record = { @@ -31,13 +33,20 @@ export function ShareConfirmationModal({ 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) { @@ -46,13 +55,39 @@ export function ShareConfirmationModal({ dialogRef.current?.focus(); } } - }, [isOpen, announceToScreenReader]); + }, [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(); - onCancel(); + handleCancel(); return; } // Trap Tab and Shift+Tab within the dialog while it is open. @@ -71,7 +106,7 @@ export function ShareConfirmationModal({ } } }, - [onCancel] + [handleCancel] ); if (!summary) return null; @@ -96,7 +131,7 @@ export function ShareConfirmationModal({ initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - onClick={onCancel} + onClick={handleCancel} /> {/* Modal content */} @@ -154,13 +189,13 @@ export function ShareConfirmationModal({ {/* Action buttons */}