From c5b8d866a445eccfb6c7fc5f2403120bee70d8ec Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Sat, 22 Aug 2026 23:27:36 +0800 Subject: [PATCH] feat: add real-time credential validation feedback with zod schemas and inline error display (Closes #69) --- .../__tests__/credential-sharing.test.tsx | 222 +++++++++++- .../src/components/credential-sharing.tsx | 342 ++++++++++++++---- frontend/src/utils/credential-validation.ts | 34 ++ 3 files changed, 517 insertions(+), 81 deletions(-) create mode 100644 frontend/src/utils/credential-validation.ts diff --git a/frontend/__tests__/credential-sharing.test.tsx b/frontend/__tests__/credential-sharing.test.tsx index fdcc4c7c..f717c327 100644 --- a/frontend/__tests__/credential-sharing.test.tsx +++ b/frontend/__tests__/credential-sharing.test.tsx @@ -1,11 +1,19 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { CredentialSharing } from '../src/components/credential-sharing'; import { AccessibilityProvider } from '../src/contexts/AccessibilityContext'; +import { + credentialSharingSchema, + CREDENTIAL_OPTIONS, + DURATION_OPTIONS, +} from '../src/utils/credential-validation'; function renderWithProviders(ui: React.ReactElement) { return render({ui}); } +const VALID_ADDRESS = 'G' + 'A'.repeat(55); // 56 chars + describe('CredentialSharing', () => { const walletAddress = 'GABCDEF123456...'; @@ -46,4 +54,216 @@ describe('CredentialSharing', () => { renderWithProviders(); expect(screen.getByText('Shared Credentials')).toBeInTheDocument(); }); + + it('renders credential options in the select', () => { + renderWithProviders(); + const select = screen.getByLabelText('Select Vaccination Credential'); + expect(select).toBeInTheDocument(); + for (const option of CREDENTIAL_OPTIONS) { + expect(screen.getByText(option.label)).toBeInTheDocument(); + } + }); + + it('renders help text for each field', () => { + renderWithProviders(); + expect( + screen.getByText('Stellar wallet addresses start with G and are 56 characters long') + ).toBeInTheDocument(); + expect( + screen.getByText('Choose which vaccination proof you want to share') + ).toBeInTheDocument(); + expect( + screen.getByText('Choose how long the recipient can access your proof') + ).toBeInTheDocument(); + }); +}); + +describe('CredentialSharing validation', () => { + const walletAddress = 'GABCDEF123456...'; + + it('does not show errors for untouched empty fields initially', () => { + renderWithProviders(); + expect(screen.queryByText('Recipient wallet address is required')).not.toBeInTheDocument(); + expect(screen.queryByText('Please select a vaccination credential')).not.toBeInTheDocument(); + expect(screen.queryByText('Please select a proof duration')).not.toBeInTheDocument(); + }); + + it('shows required-field errors when submitting an empty form', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + const submitButton = screen.getByRole('button', { name: 'Share vaccination proof' }); + await user.click(submitButton); + + expect(screen.getByText('Recipient wallet address is required')).toBeInTheDocument(); + expect(screen.getByText('Please select a vaccination credential')).toBeInTheDocument(); + expect(screen.getByText('Please select a proof duration')).toBeInTheDocument(); + }); + + it('validates the recipient address in real-time as the user types', async () => { + renderWithProviders(); + + // Type length must be 56 chars — we use a shorter invalid string + const input = screen.getByLabelText('Recipient Wallet Address'); + + // Type an invalid address (wrong length) + await userEvent.type(input, 'GABCDEF1234567890'); + expect( + screen.getByText('Stellar wallet addresses start with G and are 56 characters long') + ).toBeInTheDocument(); + + // Clear and type a valid 56-char address + await userEvent.clear(input); + await userEvent.type(input, VALID_ADDRESS); + expect(document.getElementById('recipient-address-error')).not.toBeInTheDocument(); + expect(screen.getByLabelText('Recipient wallet address is valid')).toBeInTheDocument(); + }); + + it('highlights invalid fields with red border', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + // Submit empty form to trigger all errors + await user.click(screen.getByRole('button', { name: 'Share vaccination proof' })); + + const input = screen.getByLabelText('Recipient Wallet Address'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + + // The credential select should also be invalid + const credentialSelect = screen.getByLabelText('Select Vaccination Credential'); + expect(credentialSelect).toHaveAttribute('aria-invalid', 'true'); + }); + + it('shows success indicators when fields are valid', async () => { + renderWithProviders(); + + // Initially no success icons + expect( + screen.queryByLabelText('Recipient wallet address is valid') + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Vaccination credential is valid') + ).not.toBeInTheDocument(); + expect( + screen.queryByLabelText('Proof duration is valid') + ).not.toBeInTheDocument(); + + // Fill all fields with valid values + const input = screen.getByLabelText('Recipient Wallet Address'); + await userEvent.clear(input); + await userEvent.type(input, VALID_ADDRESS); + + const credentialSelect = screen.getByLabelText('Select Vaccination Credential'); + await userEvent.selectOptions(credentialSelect, CREDENTIAL_OPTIONS[0].value); + + const durationSelect = screen.getByLabelText('Proof Duration'); + await userEvent.selectOptions(durationSelect, '86400'); + + // Now all success indicators should appear + expect( + screen.getByLabelText('Recipient wallet address is valid') + ).toBeInTheDocument(); + expect( + screen.getByLabelText('Vaccination credential is valid') + ).toBeInTheDocument(); + expect( + screen.getByLabelText('Proof duration is valid') + ).toBeInTheDocument(); + }); + + it('clears errors when valid input is provided', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + // Trigger errors by submitting + await user.click(screen.getByRole('button', { name: 'Share vaccination proof' })); + expect(screen.getByText('Please select a vaccination credential')).toBeInTheDocument(); + + // Select a credential + const credentialSelect = screen.getByLabelText('Select Vaccination Credential'); + await user.selectOptions(credentialSelect, CREDENTIAL_OPTIONS[0].value); + + // Error should clear + expect( + screen.queryByText('Please select a vaccination credential') + ).not.toBeInTheDocument(); + }); + + it('shows error icon on invalid fields', async () => { + const user = userEvent.setup(); + renderWithProviders(); + + // Submit empty to trigger errors + await user.click(screen.getByRole('button', { name: 'Share vaccination proof' })); + + // AlertCircle icons should be visible (rendered with aria-hidden, so we check presence of the error text) + expect(screen.getByText('Recipient wallet address is required')).toBeInTheDocument(); + expect(screen.getByText('Please select a vaccination credential')).toBeInTheDocument(); + expect(screen.getByText('Please select a proof duration')).toBeInTheDocument(); + }); + + it('shows the correct error message for invalid stellar address', async () => { + renderWithProviders(); + + const input = screen.getByLabelText('Recipient Wallet Address'); + await userEvent.type(input, 'BADADDRESS'); + + expect( + screen.getByText('Stellar wallet addresses start with G and are 56 characters long') + ).toBeInTheDocument(); + }); }); + +describe('CredentialSharing zod schema', () => { + it('accepts a valid form payload', () => { + const result = credentialSharingSchema.safeParse({ + recipientAddress: VALID_ADDRESS, + credentialId: 'covid-19-vaccination', + duration: '86400', + }); + expect(result.success).toBe(true); + }); + + it('rejects an invalid recipient address', () => { + const result = credentialSharingSchema.safeParse({ + recipientAddress: 'BAD', + credentialId: 'covid-19-vaccination', + duration: '86400', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe( + 'Stellar wallet addresses start with G and are 56 characters long' + ); + } + }); + + it('rejects an empty credential id', () => { + const result = credentialSharingSchema.safeParse({ + recipientAddress: VALID_ADDRESS, + credentialId: '', + duration: '86400', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Please select a vaccination credential'); + } + }); + + it('rejects an empty duration', () => { + const result = credentialSharingSchema.safeParse({ + recipientAddress: VALID_ADDRESS, + credentialId: 'covid-19-vaccination', + duration: '', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Please select a proof duration'); + } + }); + + it('validates the generated test address is exactly 56 characters', () => { + expect(VALID_ADDRESS).toHaveLength(56); + expect(VALID_ADDRESS.startsWith('G')).toBe(true); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/credential-sharing.tsx b/frontend/src/components/credential-sharing.tsx index ee4b82cc..d98577ad 100644 --- a/frontend/src/components/credential-sharing.tsx +++ b/frontend/src/components/credential-sharing.tsx @@ -1,12 +1,28 @@ 'use client'; import { useState, useCallback, useRef } from 'react'; -import { Share2, Lock, Clock, X, Shield } from 'lucide-react'; +import { + Share2, + Lock, + Clock, + X, + Shield, + AlertCircle, + CheckCircle2, + HelpCircle, +} from 'lucide-react'; import { motion, AnimatePresence } from 'framer-motion'; +import { useForm, type FieldErrors } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { AnimatedProgress, SuccessOverlay, SuccessToast } from './animations'; import { useAccessibility } from '@/contexts/AccessibilityContext'; import { useCredentialOperation } from '@/hooks/useCredentialOperation'; -import { AlertCircle } from 'lucide-react'; +import { + credentialSharingSchema, + CREDENTIAL_OPTIONS, + DURATION_OPTIONS, + type CredentialSharingFormValues, +} from '@/utils/credential-validation'; interface CredentialSharingProps { walletAddress: string; @@ -29,53 +45,109 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { }); const { announceToScreenReader } = useAccessibility(); const shareButtonRef = useRef(null); - - const { execute, error, clearError, isPending: isSharing } = useCredentialOperation(); - const handleShare = useCallback(async () => { - if (isSharing) return; - clearError(); - announceToScreenReader('Generating zero-knowledge proof...'); - setShareProgress(0); + const { execute, error: operationError, clearError, isPending: isSharing } = useCredentialOperation(); - await execute(async () => { - return new Promise((resolve, reject) => { - // Simulate network failure randomly (e.g. 10% chance) for demonstration - if (Math.random() < 0.1) { - setTimeout(() => reject(new Error('network error')), 1000); - return; - } + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitted, touchedFields, dirtyFields }, + } = useForm({ + resolver: zodResolver(credentialSharingSchema), + mode: 'onChange', + reValidateMode: 'onChange', + defaultValues: { + recipientAddress: '', + credentialId: '', + duration: '', + }, + }); - const stages = [ - { progress: 15, delay: 400 }, - { progress: 35, delay: 800 }, - { progress: 60, delay: 1200 }, - { progress: 85, delay: 1600 }, - { progress: 100, delay: 2000 }, - ]; + const recipientAddress = watch('recipientAddress'); + const credentialId = watch('credentialId'); + const duration = watch('duration'); - stages.forEach(({ progress, delay }) => { - setTimeout(() => setShareProgress(progress), delay); - }); + // Show a field's error only once the user has interacted with it (typed, + // blurred, or attempted submit) so untouched forms stay quiet, while still + // validating on every keystroke for real-time feedback. + const isErrorVisible = useCallback( + (field: keyof CredentialSharingFormValues) => + Boolean(errors[field]) && (isSubmitted || dirtyFields[field] || touchedFields[field]), + [errors, isSubmitted, dirtyFields, touchedFields] + ); + + const recipientErrorVisible = isErrorVisible('recipientAddress'); + const credentialErrorVisible = isErrorVisible('credentialId'); + const durationErrorVisible = isErrorVisible('duration'); + + const handleShare = useCallback( + async (values: CredentialSharingFormValues) => { + if (isSharing) return; + clearError(); + announceToScreenReader('Generating zero-knowledge proof...'); + setShareProgress(0); + + await execute(async () => { + return new Promise((resolve, reject) => { + // Simulate network failure randomly (e.g. 10% chance) for demonstration + if (Math.random() < 0.1) { + setTimeout(() => reject(new Error('network error')), 1000); + return; + } - setTimeout(() => { - setShowSuccess(true); - announceToScreenReader('Proof generated successfully'); + const stages = [ + { progress: 15, delay: 400 }, + { progress: 35, delay: 800 }, + { progress: 60, delay: 1200 }, + { progress: 85, delay: 1600 }, + { progress: 100, delay: 2000 }, + ]; - const newShare: SharedCredential = { - id: crypto.randomUUID(), - vaccineType: 'COVID-19 Vaccination', - recipient: 'GABCDEF123456...', - expiresAt: new Date(Date.now() + 86400000).toISOString(), - }; - setSharedCredentials((prev) => [...prev, newShare]); - resolve(); - }, 2400); + stages.forEach(({ progress, delay }) => { + setTimeout(() => setShareProgress(progress), delay); + }); + + setTimeout(() => { + setShowSuccess(true); + announceToScreenReader('Proof generated successfully'); + + const selectedCredential = CREDENTIAL_OPTIONS.find( + (option) => option.value === values.credentialId + ); + + const newShare: SharedCredential = { + id: crypto.randomUUID(), + vaccineType: selectedCredential?.label ?? values.credentialId, + recipient: values.recipientAddress, + expiresAt: new Date(Date.now() + Number(values.duration) * 1000).toISOString(), + }; + setSharedCredentials((prev) => [...prev, newShare]); + resolve(); + }, 2400); + }); + }, { + context: 'ShareCredential', }); - }, { - context: 'ShareCredential', - }); - }, [isSharing, clearError, execute, announceToScreenReader]); + }, + [isSharing, clearError, execute, announceToScreenReader] + ); + + const handleInvalidSubmit = useCallback( + (validationErrors: FieldErrors) => { + clearError(); + const fieldLabels: Record = { + recipientAddress: 'a valid recipient wallet address', + credentialId: 'a vaccination credential', + duration: 'a proof duration', + }; + const missingLabels = (Object.keys(validationErrors) as (keyof CredentialSharingFormValues)[]) + .map((key) => fieldLabels[key]) + .join(', '); + announceToScreenReader(`Please provide ${missingLabels} to share your credential`); + }, + [clearError, announceToScreenReader] + ); const handleRevoke = useCallback( (id: string, vaccineType: string) => { @@ -111,56 +183,166 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) {

Share Vaccination Proof

{ - e.preventDefault(); - handleShare(); - }} + onSubmit={handleSubmit(handleShare, handleInvalidSubmit)} className="space-y-3 sm:space-y-4" + noValidate >
- -

- Enter the Stellar wallet address of the recipient -

+
+ + {recipientErrorVisible ? ( +
+ {recipientErrorVisible ? ( + + + ) : ( +

+

+ )}
- +
+ + {credentialErrorVisible ? ( +
+ {credentialErrorVisible ? ( + + + ) : ( +

+ Choose which vaccination proof you want to share +

+ )}
- +
+ + {durationErrorVisible ? ( +
+ {durationErrorVisible ? ( + + + ) : ( +

+ Choose how long the recipient can access your proof +

+ )}
{/* Progress indicator during sharing */} @@ -181,9 +363,9 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { )} - {/* Error message */} + {/* Operation error message */} - {error && ( + {operationError && ( -

{error}

+

{operationError}

)}
@@ -289,4 +471,4 @@ export function CredentialSharing({ walletAddress }: CredentialSharingProps) { />
); -} +} \ No newline at end of file diff --git a/frontend/src/utils/credential-validation.ts b/frontend/src/utils/credential-validation.ts new file mode 100644 index 00000000..d1d83684 --- /dev/null +++ b/frontend/src/utils/credential-validation.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +/** + * Stellar public keys are 56 characters long and always start with 'G'. + * The remaining 55 characters are uppercase letters and digits. + */ +export const STELLAR_ADDRESS_REGEX = /^G[A-Z0-9]{55}$/; + +export const CREDENTIAL_OPTIONS = [ + { value: 'covid-19-vaccination', label: 'COVID-19 Vaccination' }, + { value: 'mmr-vaccination', label: 'MMR Vaccination' }, + { value: 'influenza-vaccination', label: 'Influenza Vaccination' }, +] as const; + +export const DURATION_OPTIONS = [ + { value: '3600', label: '1 hour' }, + { value: '86400', label: '1 day' }, + { value: '604800', label: '1 week' }, + { value: '2592000', label: '1 month' }, +] as const; + +export const credentialSharingSchema = z.object({ + recipientAddress: z + .string() + .min(1, 'Recipient wallet address is required') + .regex( + STELLAR_ADDRESS_REGEX, + 'Stellar wallet addresses start with G and are 56 characters long' + ), + credentialId: z.string().min(1, 'Please select a vaccination credential'), + duration: z.string().min(1, 'Please select a proof duration'), +}); + +export type CredentialSharingFormValues = z.infer; \ No newline at end of file