diff --git a/package-lock.json b/package-lock.json index 29912d8..58db022 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,16 @@ { "name": "veritable-authority", - "version": "0.1.1", + "version": "0.1.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "veritable-authority", - "version": "0.1.1", + "version": "0.1.2", "license": "Apache-2.0", "dependencies": { "buffer": "^6.0.3", + "moment": "^2.29.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-json-view": "^1.21.3", @@ -16760,6 +16761,14 @@ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, + "node_modules/moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==", + "engines": { + "node": "*" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -40475,6 +40484,11 @@ } } }, + "moment": { + "version": "2.29.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz", + "integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==" + }, "normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/package.json b/package.json index 277aa93..d792c19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "veritable-authority", - "version": "0.1.1", + "version": "0.1.2", "description": "Front-end for Veritable authority", "author": "Digital Catapult (https://www.digicatapult.org.uk/)", "license": "Apache-2.0", @@ -14,6 +14,7 @@ }, "dependencies": { "buffer": "^6.0.3", + "moment": "^2.29.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-json-view": "^1.21.3", diff --git a/src/components/AgentAuthority/ContentWrap/ContentWrap.js b/src/components/AgentAuthority/ContentWrap/ContentWrap.js index 7b37cab..422f35e 100644 --- a/src/components/AgentAuthority/ContentWrap/ContentWrap.js +++ b/src/components/AgentAuthority/ContentWrap/ContentWrap.js @@ -6,9 +6,12 @@ import ColumnLeftWrap from '../ColumnLeft/ColumnLeftWrap' import ColumnRightWrap from '../ColumnRight/ColumnRightWrap' +import IssueLicense from '../IssueLicense' + export default function ContentWrap({ origin }) { return ( <> + diff --git a/src/components/AgentAuthority/IssueLicense/IssueLicense.js b/src/components/AgentAuthority/IssueLicense/IssueLicense.js new file mode 100644 index 0000000..f044a05 --- /dev/null +++ b/src/components/AgentAuthority/IssueLicense/IssueLicense.js @@ -0,0 +1,88 @@ +import { useEffect, useState } from 'react' +import { AUTHORITY_LABEL, LICENSE_SCHEMA_NAME } from '../../../utils/env.js' + +import useIssueLicense from '../../../interface/hooks/use-issue-license' +import useGetLoopedPresentProofRecords from '../../../interface/hooks/use-get-looped-present-proof-records' +import useGetCredDefinitionsCreated from '../../../interface/hooks/use-get-cred-definitions-created' +import usePostSchemas from '../../../interface/hooks/use-post-schemas' +import usePostCredentialDefinitions from '../../../interface/hooks/use-post-credential-definitions' + +import Error from '../../Common/Misc/Error' + +export default function IssueLicense({ origin }) { + const [licenseCredDefId, setLicenseCredDefId] = useState('') + const [, errorIssue, startIssueLicense] = useIssueLicense() + const [statusRecords, errorRecords, startGetRecordsHandler] = + useGetLoopedPresentProofRecords() + const [errorDefinitionsCreated, startFetchHandlerDefinitionsCreated] = + useGetCredDefinitionsCreated() + + const [errorPostSchema, startPostSchema] = usePostSchemas() + const [errorPostCredDefs, startPostCredDef] = usePostCredentialDefinitions() + + useEffect(() => { + const issueLicenses = (proposals) => { + proposals.forEach((proposal) => { + if (licenseCredDefId) { + startIssueLicense(origin, proposal, licenseCredDefId) + } + }) + } + + const intervalIdFetch = startGetRecordsHandler( + origin, + 'proposal-received', + issueLicenses + ) + if (statusRecords !== 'started') clearInterval(intervalIdFetch) + return function clear() { + return clearInterval(intervalIdFetch) + } + }, [ + origin, + statusRecords, + startGetRecordsHandler, + licenseCredDefId, + startIssueLicense, + ]) + + useEffect(() => { + startFetchHandlerDefinitionsCreated(origin, (credDefIds) => { + const licenseCredDefId = credDefIds.find((credDefId) => + credDefId.includes(LICENSE_SCHEMA_NAME) + ) + if (licenseCredDefId) { + setLicenseCredDefId(licenseCredDefId) + } else { + startPostSchema(origin, LICENSE_SCHEMA_NAME, (schemaId) => { + startPostCredDef( + origin, + schemaId, + AUTHORITY_LABEL, + setLicenseCredDefId + ) + }) + } + }) + }, [ + origin, + startFetchHandlerDefinitionsCreated, + startPostSchema, + startPostCredDef, + setLicenseCredDefId, + ]) + + return ( + <> + + + ) +} diff --git a/src/components/AgentAuthority/IssueLicense/index.js b/src/components/AgentAuthority/IssueLicense/index.js new file mode 100644 index 0000000..f8b8eff --- /dev/null +++ b/src/components/AgentAuthority/IssueLicense/index.js @@ -0,0 +1 @@ +export { default } from './IssueLicense' diff --git a/src/components/Common/Misc/Error/Error.js b/src/components/Common/Misc/Error/Error.js new file mode 100644 index 0000000..3a4fcc6 --- /dev/null +++ b/src/components/Common/Misc/Error/Error.js @@ -0,0 +1,23 @@ +export default function Error({ errors }) { + return ( + <> +
error != undefined) ? 'd-block' : 'd-none' + }`} + style={{ + position: 'fixed', + width: '10%', + height: '10%', + inset: '0px', + backgroundColor: 'rgba(0, 0, 0, 0.5)', + zIndex: 100, + }} + > +
+ {errors.toString()} +
+
+ + ) +} diff --git a/src/components/Common/Misc/Error/index.js b/src/components/Common/Misc/Error/index.js new file mode 100644 index 0000000..2ce4206 --- /dev/null +++ b/src/components/Common/Misc/Error/index.js @@ -0,0 +1 @@ +export { default } from './Error' diff --git a/src/interface/hooks/use-delete-record.js b/src/interface/hooks/use-delete-record.js new file mode 100644 index 0000000..1afb900 --- /dev/null +++ b/src/interface/hooks/use-delete-record.js @@ -0,0 +1,24 @@ +/** + * It returns a function that can be used to delete a connection. + */ +import { useCallback, useState } from 'react' +import del from '../api/helpers/del' + +export default function useDeleteRecord() { + const path = '/present-proof-2.0/records/' + const transformData = (retrievedData) => retrievedData + const [error, setError] = useState(null) + const [status, setStatus] = useState('idle') + const onStartFetch = useCallback((origin, presExId, setStoreData) => { + del( + origin, + path + presExId, + {}, + setStatus, + setError, + setStoreData, + transformData + ) + }, []) + return [status, error, onStartFetch] +} diff --git a/src/interface/hooks/use-get-cred-definitions-created.js b/src/interface/hooks/use-get-cred-definitions-created.js new file mode 100644 index 0000000..5f39325 --- /dev/null +++ b/src/interface/hooks/use-get-cred-definitions-created.js @@ -0,0 +1,19 @@ +/** + * This function returns a function that, will fetch the credential + * definitions created by the user + */ +import { useCallback, useState } from 'react' +import get from '../api/helpers/get' + +export default function useGetCredDefinitionsCreated() { + const path = '/credential-definitions/created' + const transformData = (retrievedData) => + retrievedData.credential_definition_ids + + const [error, setError] = useState(null) + + const onStartFetch = useCallback((fetchOrigin, setStoreData) => { + get(fetchOrigin, path, {}, () => {}, setError, setStoreData, transformData) + }, []) + return [error, onStartFetch] +} diff --git a/src/interface/hooks/use-get-looped-present-proof-records.js b/src/interface/hooks/use-get-looped-present-proof-records.js new file mode 100644 index 0000000..915b7eb --- /dev/null +++ b/src/interface/hooks/use-get-looped-present-proof-records.js @@ -0,0 +1,28 @@ +/** + * This function returns continuously the present proof records + */ +import { useCallback, useState } from 'react' +import getLooped from '../api/helpers/get-looped' +export default function useGetLoopedPresentProofRecords() { + const path = '/present-proof-2.0/records' + const transformData = (retData) => retData.results + const statusOptions = ['started', 'error', 'stopped'] + const [status, setStatus] = useState(statusOptions[0]) + const [error, setError] = useState(null) + const onStartFetch = useCallback((origin, state, setStoreData) => { + const params = state ? `state=${state}` : {} + const intervalId = setInterval(() => { + getLooped( + origin, + path, + params, + setStatus, + setError, + setStoreData, + transformData + ) + }, 1000) + return intervalId + }, []) + return [status, error, onStartFetch] +} diff --git a/src/interface/hooks/use-get-present-proof-records.js b/src/interface/hooks/use-get-present-proof-records.js new file mode 100644 index 0000000..0a9bac3 --- /dev/null +++ b/src/interface/hooks/use-get-present-proof-records.js @@ -0,0 +1,24 @@ +/** + * This function returns continuously the present proof records + */ +import { useCallback, useState } from 'react' +import get from '../api/helpers/get' +export default function useGetPresentProofRecord() { + const path = '/present-proof-2.0/records' + const transformData = (retData) => retData + const statusOptions = ['started', 'error', 'stopped'] + const [status, setStatus] = useState(statusOptions[0]) + const [error, setError] = useState(null) + const onStartFetch = useCallback((origin, presExId, setStoreData) => { + get( + origin, + `${path}/${presExId}`, + {}, + setStatus, + setError, + setStoreData, + transformData + ) + }, []) + return [status, error, onStartFetch] +} diff --git a/src/interface/hooks/use-issue-license.js b/src/interface/hooks/use-issue-license.js new file mode 100644 index 0000000..3a4b939 --- /dev/null +++ b/src/interface/hooks/use-issue-license.js @@ -0,0 +1,156 @@ +/** + * Polls for presentation proof proposals, then requests and verifies proofs. + * Finally issues a license and deletes the proof + */ +import { useState, useCallback } from 'react' +import moment from 'moment' + +import usePostPresentProofSendRequest from './use-post-present-proof-send-request' +import useGetPresentProofRecord from './use-get-present-proof-records' +import usePostIssueCredentialSendOffer from './use-post-issue-credential-send-offer' +import useDeleteRecord from './use-delete-record' + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +export default function useIssueLicense() { + const [inflight, setInflight] = useState(new Set()) + const [status, setStatus] = useState('') + const [timeoutError, setTimeoutError] = useState(null) + + const [statusRequest, errorRequest, postRequest] = + usePostPresentProofSendRequest() + const [statusDelete, errorDelete, deleteRecord] = useDeleteRecord() + const [statusRecords, errorRecords, getRecord] = useGetPresentProofRecord() + const [statusIssueCred, errorIssueCred, postCredOffer] = + usePostIssueCredentialSendOffer() + + const postRequestP = useCallback( + (origin, proposal) => + new Promise((resolve) => { + postRequest( + origin, + proposal.pres_proposal.comment, + proposal.connection_id, + proposal.by_format.pres_proposal.indy, + moment().format('YYYY-MM-DD'), + (data) => { + resolve(data) + } + ) + }), + [postRequest] + ) + + const deleteRecordP = useCallback( + (origin, presExId) => + new Promise((resolve) => { + deleteRecord(origin, presExId, () => { + resolve() + }) + }), + [deleteRecord] + ) + + const getRecordP = useCallback( + (origin, presExId) => + new Promise((resolve) => { + getRecord(origin, presExId, (data) => { + resolve(data) + }) + }), + [getRecord] + ) + + const postCredOfferP = useCallback( + (origin, verifiedRecord, licenseCredDefId) => + new Promise((resolve) => { + const attr = + verifiedRecord.by_format.pres.indy.requested_proof.revealed_attrs + const expiry = moment().add(2, 'years').format('YYYYMMDD') + + postCredOffer( + origin, + verifiedRecord.connection_id, + licenseCredDefId, + attr['0_id_uuid'].raw, + attr['0_type_uuid'].raw, + expiry, + verifiedRecord.pres_request.comment, + (data) => { + resolve(data) + } + ) + }), + [postCredOffer] + ) + + const waitForVerifiedRecord = useCallback( + async (origin, requestPresExId) => { + let i = 0 + const retryCount = 10 + for (; i < retryCount; i++) { + const record = await getRecordP(origin, requestPresExId) + if (record?.state === 'done') { + return record + } + await delay(3000) + } + if (i === retryCount) { + setTimeoutError('timeout') + return null + } + }, + [getRecordP] + ) + + const onStartFetch = useCallback( + async (origin, proposal, licenseCredDefId) => { + if (!inflight.has(proposal.pres_ex_id)) { + setInflight((prev) => new Set(prev.add(proposal.pres_ex_id))) + + const requestPresExId = await postRequestP(origin, proposal) + await deleteRecordP(origin, proposal.pres_ex_id) + const verified = await waitForVerifiedRecord(origin, requestPresExId) + if (!verified) return + const licenseId = await postCredOfferP( + origin, + verified, + licenseCredDefId + ) + await deleteRecordP(origin, verified.pres_ex_id) + + setInflight( + (prev) => new Set([...prev].filter((id) => id != proposal.pres_ex_id)) + ) + setStatus('done') + return licenseId + } + }, + [ + inflight, + postRequestP, + deleteRecordP, + waitForVerifiedRecord, + postCredOfferP, + ] + ) + + if ( + statusRequest === 'error' || + statusRecords === 'error' || + statusIssueCred === 'error' || + statusDelete === 'error' + ) { + setStatus('error') + } + + return [ + status, + errorRequest || + errorRecords || + errorIssueCred || + errorDelete || + timeoutError, + onStartFetch, + ] +} diff --git a/src/interface/hooks/use-post-credential-definitions.js b/src/interface/hooks/use-post-credential-definitions.js new file mode 100644 index 0000000..bfb71fc --- /dev/null +++ b/src/interface/hooks/use-post-credential-definitions.js @@ -0,0 +1,46 @@ +/** + * This function is used to create a credential definition + */ +import { useCallback, useState } from 'react' +import post from '../api/helpers/post' + +export default function usePostCredentialDefinitions() { + const path = '/credential-definitions' + const transformData = (retrievedData) => + retrievedData.credential_definition_id + const [error, setError] = useState(null) + const onStartFetch = useCallback( + (fetchOrigin, schemaId, persona, setStoreData) => { + const params = {} + const createBody = (schemaId, persona) => { + const supportRevocation = false + + const schemaDefName = schemaId.split(':')[2] + const schemaDefTagName = schemaDefName.replace(/\s+/g, '_') + const schemaDefTagPrefix = `${persona}.agent` + const credDefTag = `${schemaDefTagPrefix}.${schemaDefTagName}` + const did = schemaId.split(':')[0] + const definitionBody = { + schema_id: schemaId, + support_revocation: supportRevocation, + tag: credDefTag, + did: did, + } + return definitionBody + } + const body = createBody(schemaId, persona) + post( + fetchOrigin, + path, + params, + body, + () => {}, + setError, + setStoreData, + transformData + ) + }, + [] + ) + return [error, onStartFetch] +} diff --git a/src/interface/hooks/use-post-issue-credential-send-offer.js b/src/interface/hooks/use-post-issue-credential-send-offer.js new file mode 100644 index 0000000..a66e33f --- /dev/null +++ b/src/interface/hooks/use-post-issue-credential-send-offer.js @@ -0,0 +1,90 @@ +/** + * This function is used to send with POST a credential offer to the server + */ +import { useCallback, useState } from 'react' +import post from '../api/helpers/post' + +const convertToNameValueArr = (obj) => + Object.entries(obj).reduce((acc, [name, value]) => { + acc.push({ name, value }) + return acc + }, []) + +export default function usePostIssueCredentialSendOffer() { + const path = '/issue-credential-2.0/send-offer' + const transformData = (retrievedData) => retrievedData.cred_ex_id + const [error, setError] = useState(null) + const [status, setStatus] = useState('idle') + + const onStartFetch = useCallback( + ( + origin, + connectionId, + credDefId, + id, + type, + expiry, + testCertReferent, + setStoreData + ) => { + const createBody = ( + connectionId, + credDefId, + id, + type, + expiry, + testCertReferent + ) => { + const CRED_PREVIEW_TYPE = + 'https://didcomm.org/issue-credential/2.0/credential-preview' + + const getTimestamp = () => { + const timestamp = new Date() / 1000 + return timestamp.toFixed() + } + + const credAttrs = { + id: id, + type: type, + expiration_dateint: expiry, + timestamp: getTimestamp(), + test_cert_referent: testCertReferent, + } + + return { + connection_id: connectionId, + comment: `Offer on cred def id ${credDefId}`, + auto_remove: false, + credential_preview: { + '@type': CRED_PREVIEW_TYPE, + attributes: convertToNameValueArr(credAttrs), + }, + filter: { indy: { cred_def_id: credDefId } }, + trace: false, + } + } + + const params = {} + const body = createBody( + connectionId, + credDefId, + id, + type, + expiry, + testCertReferent + ) + post( + origin, + path, + params, + body, + setStatus, + setError, + setStoreData, + transformData + ) + }, + [] + ) + return [status, error, onStartFetch] +} diff --git a/src/interface/hooks/use-post-present-proof-send-request.js b/src/interface/hooks/use-post-present-proof-send-request.js new file mode 100644 index 0000000..f8cdcb7 --- /dev/null +++ b/src/interface/hooks/use-post-present-proof-send-request.js @@ -0,0 +1,72 @@ +/** + * This function is used to send with POST a proposal for a present proof + */ +import { useCallback, useState } from 'react' +import post from '../api/helpers/post' + +// dateStr should be in format YYYY-MM-DD +const dateStrToNum = (dateStr) => { + const [yearStr, monthStr, dayStr] = dateStr.split('-') + const year = parseInt(yearStr, 10), + month = parseInt(monthStr, 10), + day = parseInt(dayStr, 10) + return year * 100 * 100 + month * 100 + day +} + +export default function usePostPresentProofSendRequest() { + const path = '/present-proof-2.0/send-request' + + const [error, setError] = useState(null) + const [status, setStatus] = useState('idle') + + const createBody = (comment, connectionId, proposal, validity) => { + const predicates = [ + { + name: 'expiration_dateint', + p_type: '>=', + p_value: dateStrToNum(validity), + restrictions: [{ schema_name: 'drone schema' }], + }, + ] + + const proofProposalWebRequest = { + comment, + connection_id: connectionId, + presentation_request: { + indy: { + name: proposal.name, + version: proposal.version, + requested_attributes: proposal.requested_attributes, + requested_predicates: Object.fromEntries( + predicates.map((e) => [`0_${e.name}_GE_uuid`, e]) + ), + }, + }, + + trace: false, + } + return proofProposalWebRequest + } + + const onStartFetch = useCallback( + (origin, comment, connectionId, proposal, validity, setStoreData) => { + const params = {} + const body = createBody(comment, connectionId, proposal, validity) + + const transformData = (retrievedData) => retrievedData.pres_ex_id + + post( + origin, + path, + params, + body, + setStatus, + setError, + setStoreData, + transformData + ) + }, + [] + ) + return [status, error, onStartFetch] +} diff --git a/src/interface/hooks/use-post-schemas.js b/src/interface/hooks/use-post-schemas.js new file mode 100644 index 0000000..89bc060 --- /dev/null +++ b/src/interface/hooks/use-post-schemas.js @@ -0,0 +1,44 @@ +/** + * It creates a function that will post a schema to the server. + */ +import { useCallback, useState } from 'react' +import post from '../api/helpers/post' + +export default function usePostSchemas() { + const path = '/schemas' + const transformData = (retrievedData) => retrievedData.schema_id + const [error, setError] = useState(null) + const onStartFetch = useCallback((fetchOrigin, schemaName, setStoreData) => { + const createBody = (schemaName) => { + const version = () => { + const major = parseInt(Math.random() * 10 + 0) + const minor = parseInt(Math.random() * 100 + 0) + return `${major}.${minor}` + } + return { + schema_name: schemaName, + schema_version: version(), + attributes: [ + 'id', + 'type', + 'expiration_dateint', + 'timestamp', + 'test_cert_referent', + ], + } + } + + const body = createBody(schemaName) + post( + fetchOrigin, + path, + {}, + body, + () => {}, + setError, + setStoreData, + transformData + ) + }, []) + return [error, onStartFetch] +} diff --git a/src/utils/env.js b/src/utils/env.js index 695a41d..aa1a087 100644 --- a/src/utils/env.js +++ b/src/utils/env.js @@ -1,2 +1,6 @@ export const API_HOST = process.env.REACT_APP_API_HOST || 'localhost' export const API_PORT = process.env.REACT_APP_API_PORT || 8051 +export const AUTHORITY_LABEL = + process.env.REACT_APP_AUTHORITY_LABEL || 'authority' +export const LICENSE_SCHEMA_NAME = + process.env.REACT_APP_LICENSE_SCHEMA_NAME || 'AuthorityLicense'