From 40766c16b38e8b7c154ab37d8f54e205936cbb8f Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Sat, 22 Aug 2026 19:01:10 +0800 Subject: [PATCH] feat: add credential sharing history display with status filter (Closes #61) - Add sharedAt timestamp and status field to SharedCredential - Revoke marks as 'revoked' instead of removing the entry - Add status filter buttons (All / Active / Revoked / Expired) - Auto-detect expired credentials based on expiresAt - Show status badge on each credential entry - Update tests for filter UI and share flow --- .../__tests__/credential-sharing.test.tsx | 40 ++++++- .../src/components/credential-sharing.tsx | 112 +++++++++++++++--- 2 files changed, 130 insertions(+), 22 deletions(-) diff --git a/frontend/__tests__/credential-sharing.test.tsx b/frontend/__tests__/credential-sharing.test.tsx index fdcc4c7c..f507b672 100644 --- a/frontend/__tests__/credential-sharing.test.tsx +++ b/frontend/__tests__/credential-sharing.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'; import { CredentialSharing } from '../src/components/credential-sharing'; import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; @@ -42,8 +42,42 @@ describe('CredentialSharing', () => { expect(screen.getByText('1 month')).toBeInTheDocument(); }); - it('renders shared credentials section heading', () => { + it('renders shared credentials section heading and status filters', () => { renderWithProviders(); expect(screen.getByText('Shared Credentials')).toBeInTheDocument(); + expect(screen.getByText('All')).toBeInTheDocument(); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.getByText('Revoked')).toBeInTheDocument(); + expect(screen.getByText('Expired')).toBeInTheDocument(); }); -}); + + it('shares a credential and displays it with active status', async () => { + jest.useFakeTimers(); + renderWithProviders(); + + // Make the simulated network call deterministic (avoid the 10% random error) + const randomSpy = jest.spyOn(Math, 'random').mockReturnValue(0.5); + + const shareButton = screen + .getAllByText('Share Vaccination Proof') + .find((el) => el.tagName === 'BUTTON') as HTMLElement; + + // Wait for the share flow to complete inside act + await act(async () => { + fireEvent.click(shareButton); + // Advance all setTimeout (stages + 2400ms final resolution) + jest.runAllTimers(); + }); + + // Flush remaining microtasks + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByText(/COVID-19 Vaccination/)).toBeInTheDocument(); + expect(screen.getByLabelText(/Status: Active/)).toBeInTheDocument(); + + randomSpy.mockRestore(); + jest.useRealTimers(); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/credential-sharing.tsx b/frontend/src/components/credential-sharing.tsx index ee4b82cc..79dac62c 100644 --- a/frontend/src/components/credential-sharing.tsx +++ b/frontend/src/components/credential-sharing.tsx @@ -17,6 +17,8 @@ interface SharedCredential { vaccineType: string; recipient: string; expiresAt: string; + sharedAt: string; + status: 'active' | 'revoked' | 'expired'; } export function CredentialSharing({ walletAddress }: CredentialSharingProps) { @@ -67,6 +69,8 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { vaccineType: 'COVID-19 Vaccination', recipient: 'GABCDEF123456...', expiresAt: new Date(Date.now() + 86400000).toISOString(), + sharedAt: new Date().toISOString(), + status: 'active', }; setSharedCredentials((prev) => [...prev, newShare]); resolve(); @@ -79,7 +83,9 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { const handleRevoke = useCallback( (id: string, vaccineType: string) => { - setSharedCredentials((prev) => prev.filter((c) => c.id !== id)); + setSharedCredentials((prev) => + prev.map((c) => (c.id === id ? { ...c, status: 'revoked' as const } : c)) + ); setToast({ show: true, title: 'Access Revoked', @@ -91,6 +97,19 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { [announceToScreenReader] ); + // Derive effective status: an entry is expired once its expiry has passed, + // unless it was explicitly revoked first. + const resolveStatus = useCallback((credential: SharedCredential): 'active' | 'revoked' | 'expired' => { + if (credential.status === 'revoked') return 'revoked'; + if (new Date(credential.expiresAt).getTime() <= Date.now()) return 'expired'; + return 'active'; + }, []); + + const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'revoked' | 'expired'>('all'); + const filteredCredentials = sharedCredentials.filter( + (c) => statusFilter === 'all' || resolveStatus(c) === statusFilter + ); + const handleKeyDown = useCallback( (e: React.KeyboardEvent, action: () => void) => { if (e.key === 'Enter' || e.key === ' ') { @@ -219,10 +238,36 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { {/* Shared credentials list */}
-

Shared Credentials

+
+

Shared Credentials

+
+ {( + [ + { value: 'all', label: 'All' }, + { value: 'active', label: 'Active' }, + { value: 'revoked', label: 'Revoked' }, + { value: 'expired', label: 'Expired' }, + ] as const + ).map(({ value, label }) => ( + + ))} +
+
- {sharedCredentials.length === 0 ? ( + {filteredCredentials.length === 0 ? ( ) : ( - sharedCredentials.map((share, index) => ( + filteredCredentials.map((share, index) => { + const status = resolveStatus(share); + const statusLabel = status === 'expired' ? 'Expired' : status === 'revoked' ? 'Revoked' : 'Active'; + return (
- handleRevoke(share.id, share.vaccineType)} - onKeyDown={(e) => - handleKeyDown(e, () => handleRevoke(share.id, share.vaccineType)) - } - whileHover={{ scale: 1.1 }} - whileTap={{ scale: 0.9 }} - aria-label={`Revoke access for ${share.vaccineType}`} - > - + {status === 'active' ? ( + handleRevoke(share.id, share.vaccineType)} + onKeyDown={(e) => + handleKeyDown(e, () => handleRevoke(share.id, share.vaccineType)) + } + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + aria-label={`Revoke access for ${share.vaccineType}`} + > + + ) : ( + + {statusLabel === 'Expired' ? 'Expired' : 'Revoked'} + + )} - )) + ); + }) )}