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 ? (
- 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'}
+
+ )}
- ))
+ );
+ })
)}