Skip to content

Commit

Permalink
Merge pull request #434 from wwWallet/oid4vci/batch-issuance
Browse files Browse the repository at this point in the history
OID4VCI: Batch issuance
  • Loading branch information
kkmanos authored Nov 25, 2024
2 parents b727db8 + af24c31 commit aebba0b
Show file tree
Hide file tree
Showing 21 changed files with 313 additions and 140 deletions.
10 changes: 7 additions & 3 deletions src/components/Credentials/CredentialImage.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useState, useEffect, useContext } from "react";
import StatusRibbon from '../../components/Credentials/StatusRibbon';
import ExpiredRibbon from './ExpiredRibbon';
import UsagesRibbon from "./UsagesRibbon";
import ContainerContext from '../../context/ContainerContext';

const CredentialImage = ({ credential, className, onClick, showRibbon = true }) => {
const CredentialImage = ({ credential, className, onClick, showRibbon = true, vcEntityInstances = null }) => {
const [parsedCredential, setParsedCredential] = useState(null);
const container = useContext(ContainerContext);

Expand All @@ -24,7 +25,10 @@ const CredentialImage = ({ credential, className, onClick, showRibbon = true })
<img src={parsedCredential.credentialImage.credentialImageURL} alt={"Credential"} className={className} onClick={onClick} />
)}
{parsedCredential && showRibbon &&
<StatusRibbon parsedCredential={parsedCredential} />
<ExpiredRibbon parsedCredential={parsedCredential} />
}
{vcEntityInstances && showRibbon &&
<UsagesRibbon vcEntityInstances={vcEntityInstances} />
}
</>
);
Expand Down
55 changes: 43 additions & 12 deletions src/components/Credentials/CredentialLayout.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, { useState, useEffect, useContext } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { FaArrowLeft, FaArrowRight, FaExclamationTriangle } from "react-icons/fa";
import { PiCardsBold } from "react-icons/pi";

// Hooks
import useScreenType from '../../hooks/useScreenType';
Expand Down Expand Up @@ -31,13 +32,18 @@ const CredentialLayout = ({ children, title = null }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isExpired, setIsExpired] = useState(null);

const [zeroSigCount, setZeroSigCount] = useState(null)
const [sigTotal, setSigTotal] = useState(null);

useEffect(() => {
const getData = async () => {
const response = await api.get('/storage/vc');
const vcEntity = response.data.vc_list
.filter((vcEntity) => vcEntity.credentialIdentifier === credentialId)[0];
const vcEntityInstances = response.data.vc_list
.filter((vcEntity) => vcEntity.credentialIdentifier === credentialId);
setZeroSigCount(vcEntityInstances.filter(instance => instance.sigCount === 0).length || 0);
setSigTotal(vcEntityInstances.length);
if (!vcEntity) {
throw new Error("Credential not found");
}
Expand All @@ -60,6 +66,22 @@ const CredentialLayout = ({ children, title = null }) => {
});
}, [vcEntity, container]);

const UsageStats = ({ zeroSigCount, sigTotal }) => {
if (zeroSigCount === null || sigTotal === null) return null;

const usageClass = zeroSigCount === 0 ? 'text-orange-600 dark:text-orange-500' : 'text-green-600 dark:text-green-500';

return (
<div className={`flex items-center text-gray-800 dark:text-white ${screenType === 'mobile' ? 'text-sm' : 'text-md'}`}>
<PiCardsBold size={18} className=' mr-1' />
<p className=' font-base'>
<span className={`${usageClass} font-semibold`}>{zeroSigCount}</span>
<span>/{sigTotal}</span> {t('pageCredentials.details.availableUsages')}
</p>
</div>
);
};

return (
<div className=" sm:px-6">
{screenType !== 'mobile' ? (
Expand All @@ -79,28 +101,37 @@ const CredentialLayout = ({ children, title = null }) => {
<button onClick={() => navigate(-1)} className="mr-2 mb-2" aria-label="Go back to the previous page">
<FaArrowLeft size={20} className="text-2xl text-primary dark:text-white" />
</button>
{title &&<H1 heading={title} hr={false} />}
{title && <H1 heading={title} hr={false} />}
</div>
)}
<PageDescription description={t('pageCredentials.details.description')} />

<div className="flex flex-wrap mt-0 lg:mt-5">
{/* Block 1: credential */}
<div className='flex flex-row w-full md:w-1/2'>
<div className='flex flex-row items-center gap-5 mt-2 mb-4 px-2'>
<div className='flex flex-row w-full lg:w-1/2'>
<div className={`flex flex-row items-center gap-5 mt-2 mb-4 px-2`}>
{vcEntity && (
// Open the modal when the credential is clicked
<button className="relative rounded-xl xm:rounded-lg w-4/5 xm:w-4/12 overflow-hidden transition-shadow shadow-md hover:shadow-lg cursor-pointer w-full"
onClick={() => setShowFullscreenImgPopup(true)}
aria-label={`${credentialFiendlyName}`}
title={t('pageCredentials.credentialFullScreenTitle', { friendlyName: credentialFiendlyName })}
>
<CredentialImage credential={vcEntity.credential} className={"w-full object-cover"} showRibbon={screenType !== 'mobile'} />
</button>
<div className='flex flex-col gap-4 w-4/5 xm:w-4/12'>
<button className="relative rounded-xl xm:rounded-lg w-full overflow-hidden transition-shadow shadow-md hover:shadow-lg cursor-pointer w-full"
onClick={() => setShowFullscreenImgPopup(true)}
aria-label={`${credentialFiendlyName}`}
title={t('pageCredentials.credentialFullScreenTitle', { friendlyName: credentialFiendlyName })}
>
<CredentialImage vcEntity={vcEntity} credential={vcEntity.credential} className={"w-full object-cover"} showRibbon={screenType !== 'mobile'} />
</button>
{screenType !== 'mobile' && zeroSigCount !== null && sigTotal &&
<UsageStats zeroSigCount={zeroSigCount} sigTotal={sigTotal} />
}
</div>
)}

<div>
{screenType === 'mobile' && (
<p className='text-xl font-bold text-primary dark:text-white'>{credentialFiendlyName}</p>
<div className='flex flex-start flex-col gap-1'>
<p className='text-xl font-bold text-primary dark:text-white'>{credentialFiendlyName}</p>
<UsageStats zeroSigCount={zeroSigCount} sigTotal={sigTotal} />
</div>
)}
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
// StatusRibbon.js
// ExpiredRibbon.js
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckExpired } from '../../functions/CheckExpired';

const StatusRibbon = ({ parsedCredential }) => {
const ExpiredRibbon = ({ parsedCredential }) => {
const { t } = useTranslation();

return (
<>
{parsedCredential && CheckExpired(parsedCredential.expiry_date) &&
<div className={`absolute bottom-0 right-0 text-white text-xs py-1 px-3 rounded-tl-lg border-t-2 border-l-2 border-gray-200 dark:border-gray-800 ${CheckExpired(parsedCredential.expiry_date) && 'bg-red-600'}`}>
{t('statusRibbon.expired')}
<div className={`absolute bottom-0 right-0 text-white text-xs py-1 px-3 rounded-tl-lg rounded-br-2xl border-t border-l border-white ${CheckExpired(parsedCredential.expirationDate) ? 'bg-red-600' : 'bg-green-500'}`}>
{t('expiredRibbon.expired')}
</div>
}
</>
);
};

export default StatusRibbon;
export default ExpiredRibbon;
19 changes: 19 additions & 0 deletions src/components/Credentials/UsagesRibbon.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// UsagesRibbon.js
import React from 'react';
import { PiCardsBold } from "react-icons/pi";

const UsagesRibbon = ({ vcEntityInstances }) => {
const zeroSigCount = vcEntityInstances?.filter(instance => instance.sigCount === 0).length || 0;

return (
<>
{vcEntityInstances &&
<div className={`z-50 absolute top-[-5px] font-semibold right-[-5px] text-white text-xs py-1 px-3 flex gap-1 items-center rounded-lg border-2 border-gray-100 dark:border-gray-800 ${zeroSigCount === 0 ? 'bg-orange-500' : 'bg-green-500'}`}>
<PiCardsBold size={18} /> {zeroSigCount}
</div>
}
</>
);
};

export default UsagesRibbon;
2 changes: 1 addition & 1 deletion src/components/History/HistoryDetailContent.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const HistoryDetailContent = ({ historyItem }) => {
aria-label={credential.friendlyName}
title={t('pageCredentials.credentialFullScreenTitle', { friendlyName: credential.friendlyName })}
>
<CredentialImage credential={credential} className="w-full h-full rounded-xl" />
<CredentialImage credential={credential} showRibbon={false} className="w-full h-full rounded-xl" />
</div>
);

Expand Down
9 changes: 6 additions & 3 deletions src/components/Popups/SelectCredentialsPopup.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import SessionContext from '../../context/SessionContext';
import ContainerContext from '../../context/ContainerContext';
import useScreenType from '../../hooks/useScreenType';
import Slider from '../Shared/Slider';
import CredentialsContext from '../../context/CredentialsContext';

const formatTitle = (title) => {
if (title) {
Expand Down Expand Up @@ -60,6 +61,7 @@ const StepBar = ({ totalSteps, currentStep, stepTitles }) => {
function SelectCredentialsPopup({ isOpen, setIsOpen, setSelectionMap, conformantCredentialsMap, verifierDomainName }) {
const { api } = useContext(SessionContext);
const [vcEntities, setVcEntities] = useState([]);
const { vcEntityList, vcEntityListInstances } = useContext(CredentialsContext);
const navigate = useNavigate();
const { t } = useTranslation();
const keys = useMemo(() => Object.keys(conformantCredentialsMap), [conformantCredentialsMap]);
Expand All @@ -82,9 +84,8 @@ function SelectCredentialsPopup({ isOpen, setIsOpen, setSelectionMap, conformant
}

try {
const response = await api.get('/storage/vc');
const vcEntities = await Promise.all(
response.data.vc_list.map(async vcEntity => {
vcEntityList.map(async vcEntity => {
return container.credentialParserRegistry.parse(vcEntity.credential).then((c) => {
if ('error' in c) {
return;
Expand Down Expand Up @@ -158,16 +159,18 @@ function SelectCredentialsPopup({ isOpen, setIsOpen, setSelectionMap, conformant
const renderSlideContent = (vcEntity) => (
<button
key={vcEntity.id}
className="relative rounded-xl overflow-hidden transition-shadow shadow-md hover:shadow-xl cursor-pointer"
className="relative rounded-xl transition-shadow shadow-md hover:shadow-xl cursor-pointer"
tabIndex={currentSlide !== vcEntities.indexOf(vcEntity) + 1 ? -1 : 0}
onClick={() => handleClick(vcEntity.credentialIdentifier)}
aria-label={`${vcEntity.friendlyName}`}
title={t('selectCredentialPopup.credentialSelectTitle', { friendlyName: vcEntity.friendlyName })}
>
<CredentialImage
vcEntityInstances={vcEntityListInstances.filter((vc) => vc.credentialIdentifier === vcEntity.credentialIdentifier)}
key={vcEntity.credentialIdentifier}
credential={vcEntity.credential}
className="w-full object-cover rounded-xl"
showRibbon={currentSlide === vcEntities.indexOf(vcEntity) + 1}
/>

<div className={`absolute inset-0 rounded-xl transition-opacity bg-white/50 ${selectedCredential === vcEntity.credentialIdentifier ? 'opacity-0' : 'opacity-50'}`} />
Expand Down
2 changes: 1 addition & 1 deletion src/components/Shared/Slider.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const Slider = ({ items, renderSlideContent, onSlideChange, initialSlide = 1 })
{items.map((item, index) => (
<SwiperSlide
key={item.id || index}
className={`rounded-xl ${Math.abs(currentSlide - (index + 1)) > 1 && 'invisible pointer-events-none'}`}
className={`rounded-xl ${Math.abs(currentSlide - (index + 1)) > 1 && 'invisible pointer-events-none'} ${currentSlide == (index + 1) && 'overflow-visible-force'} `}
>
{renderSlideContent(item, index)}
</SwiperSlide>
Expand Down
20 changes: 15 additions & 5 deletions src/context/ContainerContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import defaultCredentialImage from "../assets/images/cred.png";
import renderSvgTemplate from "../components/Credentials/RenderSvgTemplate";
import renderCustomSvgTemplate from "../components/Credentials/RenderCustomSvgTemplate";
import StatusContext from "./StatusContext";
import { CredentialBatchHelper } from "../lib/services/CredentialBatchHelper";

export type ContainerContextValue = {
httpProxy: IHttpProxy,
Expand Down Expand Up @@ -90,6 +91,14 @@ export const ContainerContextProvider = ({ children }) => {

cont.register<IOpenID4VCIClientStateRepository>('OpenID4VCIClientStateRepository', OpenID4VCIClientStateRepository, userData.settings.openidRefreshTokenMaxAgeInSeconds);
cont.register<IOpenID4VCIHelper>('OpenID4VCIHelper', OpenID4VCIHelper, cont.resolve<IHttpProxy>('HttpProxy'));

cont.register<CredentialBatchHelper>('CredentialBatchHelper', CredentialBatchHelper,
async function updateCredential(storableCredential: StorableCredential) {
await api.post('/storage/vc/update', {
credential: storableCredential
});
}
)
const credentialParserRegistry = cont.resolve<ICredentialParserRegistry>('CredentialParserRegistry');

credentialParserRegistry.addParser({
Expand Down Expand Up @@ -192,6 +201,7 @@ export const ContainerContextProvider = ({ children }) => {
cont.resolve<IOpenID4VPRelyingPartyStateRepository>('OpenID4VPRelyingPartyStateRepository'),
cont.resolve<IHttpProxy>('HttpProxy'),
cont.resolve<ICredentialParserRegistry>('CredentialParserRegistry'),
cont.resolve<CredentialBatchHelper>('CredentialBatchHelper'),
async function getAllStoredVerifiableCredentials() {
const fetchAllCredentials = await api.get('/storage/vc');
return { verifiableCredentials: fetchAllCredentials.data.vc_list };
Expand All @@ -216,15 +226,15 @@ export const ContainerContextProvider = ({ children }) => {
cont.register<OpenID4VCIClientFactory>('OpenID4VCIClientFactory', OpenID4VCIClientFactory,
cont.resolve<IHttpProxy>('HttpProxy'),
cont.resolve<IOpenID4VCIClientStateRepository>('OpenID4VCIClientStateRepository'),
async (cNonce: string, audience: string, clientId: string): Promise<{ jws: string }> => {
const [{ proof_jwts: [proof_jwt] }, newPrivateData, keystoreCommit] = await keystore.generateOpenid4vciProofs([{ nonce: cNonce, audience, issuer: clientId }]);
async (requests: { nonce: string, audience: string, issuer: string }[]): Promise<{ proof_jwts: string[] }> => {
const [{ proof_jwts }, newPrivateData, keystoreCommit] = await keystore.generateOpenid4vciProofs(requests);
await api.updatePrivateData(newPrivateData);
await keystoreCommit();
return { jws: proof_jwt };
return { proof_jwts };
},
async function storeCredential(c: StorableCredential) {
async function storeCredentials(cList: StorableCredential[]) {
await api.post('/storage/vc', {
...c
credentials: cList
});
},
);
Expand Down
27 changes: 17 additions & 10 deletions src/context/CredentialsContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,32 @@ const CredentialsContext = createContext();
export const CredentialsProvider = ({ children }) => {
const { api } = useContext(SessionContext);
const [vcEntityList, setVcEntityList] = useState([]);
const [vcEntityListInstances, setVcEntityListInstances] = useState([]);
const [latestCredentials, setLatestCredentials] = useState(new Set());
const [currentSlide, setCurrentSlide] = useState(1);

const fetchVcData = useCallback(async () => {
const response = await api.get('/storage/vc');
const fetchedVcList = response.data.vc_list;

const vcEntityList = await Promise.all(fetchedVcList.map(async vcEntity => {
const vcEntityList = (await Promise.all(fetchedVcList.map(async (vcEntity) => {
return { ...vcEntity };
}));
}))).filter((vcEntity) => vcEntity.instanceId == 0); // show only the first instance

vcEntityList.sort(reverse(compareBy(vc => vc.id)));

return vcEntityList;
return { vcEntityList, fetchedVcList };
}, [api]);

const updateVcListAndLatestCredentials = (vcEntityList) => {
const updateVcListAndLatestCredentials = (vcEntityList, fetchedVcList) => {
setLatestCredentials(new Set(vcEntityList.filter(vc => vc.id === vcEntityList[0].id).map(vc => vc.id)));

setTimeout(() => {
setLatestCredentials(new Set());
}, 2000);

setVcEntityList(vcEntityList);
setVcEntityListInstances(fetchedVcList);
};

const pollForCredentials = useCallback(() => {
Expand All @@ -48,13 +50,13 @@ export const CredentialsProvider = ({ children }) => {
const previousVcList = await getItem("vc", userId);
const previousSize = previousVcList.vc_list.length;

const vcEntityList = await fetchVcData();
const { vcEntityList, fetchedVcList } = await fetchVcData();

if (previousSize < vcEntityList.length) {
console.log('Found new credentials, stopping polling');
isPolling = false;
clearInterval(intervalId);
updateVcListAndLatestCredentials(vcEntityList);
updateVcListAndLatestCredentials(vcEntityList, fetchedVcList);
}

if (attempts >= 5) {
Expand All @@ -69,9 +71,13 @@ export const CredentialsProvider = ({ children }) => {
try {
const userId = api.getSession().uuid;
const previousVcList = await getItem("vc", userId);
const previousSize = previousVcList?.vc_list.length;
const vcEntityList = await fetchVcData();
const uniqueIdentifiers = new Set(previousVcList?.vc_list.map(vc => vc.credentialIdentifier));
const previousSize = uniqueIdentifiers.size;

const { vcEntityList, fetchedVcList } = await fetchVcData();

setVcEntityList(vcEntityList);
setVcEntityListInstances(fetchedVcList);

const newCredentialsFound = previousSize < vcEntityList.length;
if (shouldPoll && !newCredentialsFound) {
Expand All @@ -81,9 +87,10 @@ export const CredentialsProvider = ({ children }) => {
} else if (newCredentialsFound) {
window.history.replaceState({}, '', `/`);
console.log("Found new credentials, no need to poll");
updateVcListAndLatestCredentials(vcEntityList);
updateVcListAndLatestCredentials(vcEntityList, fetchedVcList);
} else {
setVcEntityList(vcEntityList);
setVcEntityListInstances(fetchedVcList);
}
} catch (error) {
console.error('Failed to fetch data', error);
Expand All @@ -101,7 +108,7 @@ export const CredentialsProvider = ({ children }) => {
}, [getData]);

return (
<CredentialsContext.Provider value={{ vcEntityList, latestCredentials, getData, currentSlide, setCurrentSlide }}>
<CredentialsContext.Provider value={{ vcEntityList, vcEntityListInstances, latestCredentials, getData, currentSlide, setCurrentSlide }}>
{children}
</CredentialsContext.Provider>
);
Expand Down
4 changes: 4 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,7 @@ input:-webkit-autofill {
.ReactModal__Content:focus {
outline: none;
}

.overflow-visible-force {
overflow: visible !important;
}
3 changes: 3 additions & 0 deletions src/lib/schemas/OpenidCredentialIssuerMetadataSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ export const OpenidCredentialIssuerMetadataSchema = z.object({
name: z.string(),
locale: z.string(),
})).optional(),
batch_credential_issuance: z.object({
batch_size: z.number(),
}).optional(),
credential_configurations_supported: z.record(CredentialConfigurationSupportedSchema)
})

Expand Down
Loading

0 comments on commit aebba0b

Please sign in to comment.