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 ? (
- No credentials shared yet
+
+ {sharedCredentials.length === 0
+ ? 'No credentials shared yet'
+ : `No ${statusFilter} credentials`}
+
) : (
- sharedCredentials.map((share, index) => (
+ filteredCredentials.map((share, index) => {
+ const status = resolveStatus(share);
+ const statusLabel = status === 'expired' ? 'Expired' : status === 'revoked' ? 'Revoked' : 'Active';
+ return (
-
{share.vaccineType}
+
+
{share.vaccineType}
+
+ {statusLabel}
+
+
Shared with: {share.recipient}
Expires: {new Date(share.expiresAt).toLocaleString()}
+
Shared: {new Date(share.sharedAt).toLocaleString()}
-
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'}
+
+ )}
- ))
+ );
+ })
)}