diff --git a/.gitignore b/.gitignore index 3272185e5..783971160 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,4 @@ yarn-error.log* resources/android resources/ios -services/credential-server/data/brans.json \ No newline at end of file +services/credential-server/data/* \ No newline at end of file diff --git a/services/credential-server-ui/src/App.tsx b/services/credential-server-ui/src/App.tsx index 20b9fea5f..64c8a4f96 100644 --- a/services/credential-server-ui/src/App.tsx +++ b/services/credential-server-ui/src/App.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect } from "react"; import "./App.css"; import { Routes, Route } from "react-router-dom"; import NavBar from "./components/NavBar"; @@ -10,6 +10,7 @@ import { ConnectionPage } from "./pages/ConnectionPage"; import { CredentialPage } from "./pages/CredentialPage"; import { RequestCredential } from "./pages/RequestCredential"; import { RevocationPage } from "./pages/RevocationPage"; +import { CreateSchemaPage } from "./pages/CreateSchemaPage"; export const MENU_ITEMS = [ { @@ -36,6 +37,12 @@ export const MENU_ITEMS = [ path: "/revocation", component: , }, + { + key: "create-schema", + label: "Create Schema", + path: "/create-schema", + component: , + }, ]; function App() { diff --git a/services/credential-server-ui/src/components/CredentialForm.tsx b/services/credential-server-ui/src/components/CredentialForm.tsx new file mode 100644 index 000000000..46992d1db --- /dev/null +++ b/services/credential-server-ui/src/components/CredentialForm.tsx @@ -0,0 +1,323 @@ +import React, { useEffect, useState } from "react"; +import { useForm, Controller } from "react-hook-form"; +import axios from "axios"; +import { + Alert, + Autocomplete, + Box, + Button, + FormControl, + Grid, + InputLabel, + MenuItem, + Select, + TextField, +} from "@mui/material"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { config } from "../config"; +import { Contact } from "../types"; +import { CredentialType, UUID_REGEX } from "../constants"; +import { + IAttributeObj, + IAttributes, + SchemaShortDetails, +} from "../constants/type"; + +interface CredentialFormProps { + onSubmit: (values: any) => Promise; + submitButtonText: string; + successMessage: string; + apiPath: string; +} + +const CredentialForm: React.FC = ({ + onSubmit, + submitButtonText, + successMessage, + apiPath, +}) => { + const { + control, + register, + handleSubmit, + setValue, + watch, + formState: { errors }, + } = useForm(); + + const [contacts, setContacts] = useState([]); + const [attributes, setAttributes] = useState([]); + const [isSuccess, setIsSuccess] = useState(false); + const [schemaList, setSchemaList] = useState([]); + const [defaultCredentialType, setDefaultCredentialType] = + useState(""); + const [selectedSchemaId, setSelectedSchemaId] = useState(null); + const [selectedContact, setSelectedContact] = useState(null); + + useEffect(() => { + handleGetContacts(); + handleGetSchemaList(); + }, []); + + useEffect(() => { + const type = watch("credential_type") as CredentialType; + if (!type) return; + + handleGetSchemaDetails(type); + }, [watch("credential_type")]); + + const handleGetContacts = async () => { + try { + setContacts( + (await axios.get(`${config.endpoint}${config.path.contacts}`)).data.data + ); + } catch (e) { + console.log(e); + } + }; + + const handleGetSchemaList = async () => { + try { + const response = await axios.get( + `${config.endpoint}${config.path.schemaList}` + ); + const schemas: SchemaShortDetails[] = response.data; + setSchemaList(schemas); + if (schemas.length > 0) { + setDefaultCredentialType(schemas[0].title); + setValue("credential_type", schemas[0].title); + setSelectedSchemaId(schemas[0].$id); + } else { + setDefaultCredentialType(""); + } + } catch (error) { + console.log(`Error fetching schema list: ${error}`); + } + }; + + const handleGetSchemaDetails = async (schemaTitle: string) => { + const schema = schemaList.find((s) => s.title === schemaTitle); + if (!schema) return; + + try { + const response = await axios.get( + `${config.endpoint}${config.path.schemaCustomFields}`, + { + params: { id: schema.$id }, + } + ); + const { customizableKeys } = response.data; + + const newAttributes = Object.keys(customizableKeys).map((key: string) => { + const value = customizableKeys[key]; + return { + key, + label: `${value.name} - ${value.description}`, + type: value.type || "string", // Default to 'string' if type is not provided + defaultValue: value.default || "", // Add default value if provided + }; + }); + + newAttributes.forEach((att: IAttributeObj, index: number) => { + setValue(`attributes.${index}.key`, att.key); + setValue(`attributes.${index}.label`, att.label); + setValue(`attributes.${index}.value`, att.defaultValue); + }); + + setAttributes(newAttributes); + setSelectedSchemaId(schema.$id); + } catch (error) { + console.log(`Error fetching schema details: ${error}`); + } + }; + + const handleFormSubmit = async (values: any) => { + setIsSuccess(false); + let objAttributes = {}; + let attribute: IAttributes = {}; + + values.attributes.forEach((att: IAttributeObj) => { + if (att.key && att.value) attribute[att.key] = att.value; + }); + + if (Object.keys(attribute).length) { + objAttributes = { + attribute, + }; + } + + const data = { + schemaSaid: selectedSchemaId, + aid: values.selectedContact, + ...objAttributes, + }; + + try { + await onSubmit(data); + setIsSuccess(true); + } catch (error) { + console.log(error); + } + }; + + return ( + +
+ + + + ( + + UUID_REGEX.test(option.alias) + ? option.id + : `${option.alias} (${option.id})` + } + options={contacts || []} + value={selectedContact} + onChange={(_event, data) => { + field.onChange(data?.id || null); + setSelectedContact(data); + }} + renderInput={(params) => ( + + )} + /> + )} + /> + + + + + + + + Credential Type + ( + + )} + /> + + + + {attributes.map((att, index) => ( + + { + switch (att.type) { + case "integer": + return ( + + field.onChange(Number(e.target.value)) + } + /> + ); + case "string": + default: + return ( + + ); + } + }} + /> + + ))} + + + + {errors.selectedContact && ( + + Please, select a contact from the list of connections + + )} + {errors.credential_type && ( + Please, select a credential type + )} + {isSuccess && {successMessage}} + + + + +
+
+ ); +}; + +export { CredentialForm }; diff --git a/services/credential-server-ui/src/components/NavBar.tsx b/services/credential-server-ui/src/components/NavBar.tsx index 3c8dd2920..061986c4d 100644 --- a/services/credential-server-ui/src/components/NavBar.tsx +++ b/services/credential-server-ui/src/components/NavBar.tsx @@ -19,6 +19,8 @@ const NavBar: React.FC = () => { const [anchorElNav, setAnchorElNav] = React.useState( null ); + const [anchorElCredentials, setAnchorElCredentials] = + React.useState(null); const handleOpenNavMenu = (event: React.MouseEvent) => { setAnchorElNav(event.currentTarget); @@ -28,6 +30,14 @@ const NavBar: React.FC = () => { setAnchorElNav(null); }; + const handleOpenCredentialsMenu = (event: React.MouseEvent) => { + setAnchorElCredentials(event.currentTarget); + }; + + const handleCloseCredentialsMenu = () => { + setAnchorElCredentials(null); + }; + return ( @@ -103,7 +113,14 @@ const NavBar: React.FC = () => { - {MENU_ITEMS.map((item) => ( + {MENU_ITEMS.filter( + (item) => + ![ + "issue-credential", + "request-credential", + "revocation", + ].includes(item.key) + ).map((item) => ( ))} + + + {MENU_ITEMS.filter((item) => + [ + "issue-credential", + "request-credential", + "revocation", + ].includes(item.key) + ).map((item) => ( + + {item.label} + + ))} + diff --git a/services/credential-server-ui/src/components/Toast.tsx b/services/credential-server-ui/src/components/Toast.tsx new file mode 100644 index 000000000..dd2f0e0b7 --- /dev/null +++ b/services/credential-server-ui/src/components/Toast.tsx @@ -0,0 +1,40 @@ +import React, { useEffect } from "react"; +import Alert from "@mui/material/Alert"; +import AlertTitle from "@mui/material/AlertTitle"; +import { Box } from "@mui/material"; + +interface ToastProps { + message: string; + severity: "success" | "error" | "info" | "warning"; + visible: boolean; + onClose: () => void; +} + +const Toast: React.FC = ({ + message, + severity, + visible, + onClose, +}) => { + useEffect(() => { + if (visible) { + const timer = setTimeout(onClose, 3000); + return () => clearTimeout(timer); + } + }, [visible, onClose]); + + if (!visible) return null; + + return ( + + + + {severity.charAt(0).toUpperCase() + severity.slice(1)} + + {message} + + + ); +}; + +export default Toast; diff --git a/services/credential-server-ui/src/components/createSchema/CreateSchemaField.tsx b/services/credential-server-ui/src/components/createSchema/CreateSchemaField.tsx new file mode 100644 index 000000000..8d0b25876 --- /dev/null +++ b/services/credential-server-ui/src/components/createSchema/CreateSchemaField.tsx @@ -0,0 +1,251 @@ +import React from "react"; +import { + TextField, + IconButton, + Select, + MenuItem, + FormControlLabel, + Checkbox, +} from "@mui/material"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { SchemaField } from "../../constants/type"; + +interface FieldSectionProps { + section: string; + fields: SchemaField[]; + handleFieldChange: ( + section: string, + index: number, + field: "name" | "description" | "type" | "default" | "fields", + value: any + ) => void; + handleCustomizableChange: ( + section: string, + index: number, + value: boolean + ) => void; + handleAddField: (section: string) => void; + removeField: (section: string, index: number) => void; +} + +const CreateSchemaField: React.FC = ({ + section, + fields, + handleFieldChange, + handleCustomizableChange, + handleAddField, + removeField, +}) => { + const onFieldChange = ( + section: string, + index: number, + field: "name" | "description" | "type" | "default" | "fields", + value: any + ) => { + if (field === "type") { + handleCustomizableChange(section, index, false); + handleFieldChange(section, index, "default", ""); + } + handleFieldChange(section, index, field, value); + }; + + const onCustomizableChange = ( + section: string, + index: number, + value: boolean + ) => { + if (!value) { + handleFieldChange(section, index, "default", ""); + } + handleCustomizableChange(section, index, value); + }; + + const handleAddNestedField = (parentIndex: number) => { + const newField: SchemaField = { + name: "", + description: "", + type: "string", + customizable: false, + fields: [], + }; + const updatedFields = [...fields]; + if (!updatedFields[parentIndex].fields) { + updatedFields[parentIndex].fields = []; + } + updatedFields[parentIndex].fields!.push(newField); + handleFieldChange( + section, + parentIndex, + "fields", + updatedFields[parentIndex].fields + ); + }; + + const handleRemoveField = (section: string, index: number) => { + removeField(section, index); + }; + + return ( +
+ {fields.map((field, index) => ( +
+
+
+ + onFieldChange(section, index, "name", e.target.value) + } + /> + +
+
+ {field.type !== "object" && ( + + onCustomizableChange(section, index, e.target.checked) + } + /> + } + label="Customizable" + /> + )} + {field.type === "object" && ( + handleAddNestedField(index)} + > + + + )} + handleRemoveField(section, index)} + style={{ marginLeft: "10px" }} + > + + +
+
+
+ + onFieldChange(section, index, "description", e.target.value) + } + /> +
+ {field.customizable && field.type !== "object" && ( +
+ + onFieldChange( + section, + index, + "default", + field.type === "integer" + ? parseInt(e.target.value) + : e.target.value + ) + } + /> +
+ )} + {field.fields && field.fields.length > 0 && ( +
+ { + const updatedFields = [...fields]; + updatedFields[index].fields![nestedIndex][nestedField] = + nestedValue; + handleFieldChange( + section, + index, + "fields", + updatedFields[index].fields + ); + }} + handleCustomizableChange={( + nestedSection, + nestedIndex, + nestedValue + ) => { + const updatedFields = [...fields]; + updatedFields[index].fields![nestedIndex].customizable = + nestedValue; + handleFieldChange( + section, + index, + "fields", + updatedFields[index].fields + ); + }} + handleAddField={handleAddField} + removeField={(nestedSection, nestedIndex) => { + const updatedFields = [...fields]; + updatedFields[index].fields = updatedFields[ + index + ].fields!.filter((_, i) => i !== nestedIndex); + handleFieldChange( + section, + index, + "fields", + updatedFields[index].fields + ); + }} + /> +
+ )} +
+ ))} +
+ ); +}; + +export default CreateSchemaField; diff --git a/services/credential-server-ui/src/components/credentialForms/prescriptionForm.tsx b/services/credential-server-ui/src/components/credentialForms/prescriptionForm.tsx deleted file mode 100644 index 297083cfb..000000000 --- a/services/credential-server-ui/src/components/credentialForms/prescriptionForm.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import React, { useState } from "react"; -import { - Alert, - Button, - FormControl, - Grid, - InputLabel, - MenuItem, - Select, - TextField, -} from "@mui/material"; -import { DatePicker } from "@mui/x-date-pickers"; -import { Dayjs } from "dayjs"; - -interface PrescriptionFormProps { - onCustomCredentialChange?: (newJson: string) => void; -} - -const PrescriptionForm: React.FC = ({ - onCustomCredentialChange, -}) => { - const jsonFilePath = "credentials-json/prescription-credential.json"; - const [type, setType] = useState(""); - const [name, setName] = useState(""); - const [expirationDate, setExpirationDate] = React.useState( - null - ); - - const [isSuccessfulValidationVisible, setIsSuccessfulValidationVisible] = - useState(false); - const [isUnsuccessfulValidationVisible, setIsUnsuccessfulValidationVisible] = - useState(false); - - const isInformationCorrect = () => { - if (type === "" || name === "" || expirationDate === null) { - return false; - } - return true; - }; - - const generateJson = async () => { - if (!isInformationCorrect()) { - setIsSuccessfulValidationVisible(false); - setIsUnsuccessfulValidationVisible(true); - return; - } - - const prescriptionCredential = await fetch(jsonFilePath).then((response) => - response.json() - ); - prescriptionCredential.credentialSubject.prescription.type = type; - prescriptionCredential.credentialSubject.prescription.name = name; - prescriptionCredential.expirationDate = expirationDate?.toISOString(); - onCustomCredentialChange?.(prescriptionCredential); - - setIsSuccessfulValidationVisible(true); - setIsUnsuccessfulValidationVisible(false); - }; - - return ( - - - - Class of medicine - - - - - - setName(e.target.value)} - /> - - - - setExpirationDate(newExpirationDate)} - /> - - - - - - - - - {isSuccessfulValidationVisible && ( - Successfully validated - )} - - {isUnsuccessfulValidationVisible && ( - - It was not possible to validate the information - - )} - - - - ); -}; - -export default PrescriptionForm; diff --git a/services/credential-server-ui/src/components/credentialForms/relationshipCredential.tsx b/services/credential-server-ui/src/components/credentialForms/relationshipCredential.tsx deleted file mode 100644 index 2ccac240b..000000000 --- a/services/credential-server-ui/src/components/credentialForms/relationshipCredential.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React, { useState } from "react"; -import { Alert, Button, Grid, TextField } from "@mui/material"; - -interface RelationshipFormProps { - onCustomCredentialChange?: (newJson: string) => void; -} - -const RelationshipForm: React.FC = ({ - onCustomCredentialChange, -}) => { - const jsonFilePath = "credentials-json/relationship-credential.json"; - const [namePartner1, setNamePartner1] = useState(""); - const [namePartner2, setNamePartner2] = useState(""); - - const [isSuccessfulValidationVisible, setIsSuccessfulValidationVisible] = - useState(false); - const [isUnsuccessfulValidationVisible, setIsUnsuccessfulValidationVisible] = - useState(false); - - const isInformationCorrect = () => { - if (namePartner1 === "" || namePartner2 === "") { - return false; - } - return true; - }; - - const generateJson = async () => { - if (!isInformationCorrect()) { - setIsSuccessfulValidationVisible(false); - setIsUnsuccessfulValidationVisible(true); - return; - } - - const prescriptionCredential = await fetch(jsonFilePath).then((response) => - response.json() - ); - prescriptionCredential.credentialSubject[0].name = namePartner1; - prescriptionCredential.credentialSubject[1].name = namePartner2; - onCustomCredentialChange?.(prescriptionCredential); - - setIsSuccessfulValidationVisible(true); - setIsUnsuccessfulValidationVisible(false); - }; - - return ( - - - setNamePartner1(e.target.value)} - /> - - - setNamePartner2(e.target.value)} - /> - - - - - - - - - {isSuccessfulValidationVisible && ( - Successfully validated - )} - - {isUnsuccessfulValidationVisible && ( - - It was not possible to validate the information - - )} - - - - ); -}; - -export default RelationshipForm; diff --git a/services/credential-server-ui/src/config.ts b/services/credential-server-ui/src/config.ts index 63f1baf73..90bbe18e1 100644 --- a/services/credential-server-ui/src/config.ts +++ b/services/credential-server-ui/src/config.ts @@ -36,6 +36,10 @@ const config = { resolveOobi: "/resolveOobi", requestDisclosure: "/requestDisclosure", revokeCredential: "/revokeCredential", + saveSchema: "/saveSchema", + schemaList: "/schemaList", + schemaCustomFields: "/schemaCustomFields", + deleteSchema: "/deleteSchema", }, }; diff --git a/services/credential-server-ui/src/constants/index.ts b/services/credential-server-ui/src/constants/index.ts index 9b1b78c5a..bc6d94c18 100644 --- a/services/credential-server-ui/src/constants/index.ts +++ b/services/credential-server-ui/src/constants/index.ts @@ -3,11 +3,6 @@ export enum CredentialType { GLEIF = "Qualified vLEI Issuer", LE = "Legal Entity", } -export const SCHEMA_SAID = { - [CredentialType.RARE_EVO]: "EJxnJdxkHbRw2wVFNe4IUOPLt8fEtg9Sr3WyTjlgKoIb", - [CredentialType.GLEIF]: "EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao", - [CredentialType.LE]: "ENPXp1vQzRF6JwIuS-mp2U8Uf1MoADoP_GqQ62VsDZWY", -}; export const Attributes = { [CredentialType.RARE_EVO]: [ diff --git a/services/credential-server-ui/src/constants/type.ts b/services/credential-server-ui/src/constants/type.ts index 688a25f6c..b0c8b3943 100644 --- a/services/credential-server-ui/src/constants/type.ts +++ b/services/credential-server-ui/src/constants/type.ts @@ -3,7 +3,23 @@ export interface IAttributes { } export interface IAttributeObj { + defaultValue: any; + type: any; key: string; label: string; value?: string; } + +export interface SchemaShortDetails { + $id: string; + title: string; +} + +export interface SchemaField { + name: string; + description: string; + type: string; + customizable: boolean; + default?: string | number; + fields?: SchemaField[]; +} diff --git a/services/credential-server-ui/src/pages/ConnectionPage.tsx b/services/credential-server-ui/src/pages/ConnectionPage.tsx index 985cc166c..2df21ef11 100644 --- a/services/credential-server-ui/src/pages/ConnectionPage.tsx +++ b/services/credential-server-ui/src/pages/ConnectionPage.tsx @@ -8,12 +8,18 @@ import { Contact } from "../types"; import RefreshIcon from "@mui/icons-material/Refresh"; import { DeleteOutline } from "@mui/icons-material"; import { UUID_REGEX } from "../constants"; +import Toast from "../components/Toast"; const ConnectionPage: React.FC = () => { const [allContacts, setAllContacts] = useState([]); const [contacts, setContacts] = useState([]); const [connectionsFilter, setConnectionsFilter] = useState(""); const [step, setStep] = useState(0); + const [toastVisible, setToastVisible] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + const [toastSeverity, setToastSeverity] = useState< + "success" | "error" | "info" | "warning" + >("error"); useEffect(() => { handleGetContacts(); @@ -30,22 +36,40 @@ const ConnectionPage: React.FC = () => { }, [connectionsFilter]); const handleGetContacts = async () => { - const contactsList = ( - await axios.get(`${config.endpoint}${config.path.contacts}`) - ).data.data; - setContacts(contactsList); - setAllContacts(contactsList); + try { + const contactsList = ( + await axios.get(`${config.endpoint}${config.path.contacts}`) + ).data.data; + setContacts(contactsList); + setAllContacts(contactsList); + } catch (error) { + console.error("Error fetching contacts:", error); + setToastMessage("It was not possible to connect to the backend server"); + setToastSeverity("error"); + setToastVisible(true); + } }; const handleDeleteContact = async (id: string) => { - await axios.delete( - `${config.endpoint}${config.path.deleteContact}?id=${id}` - ); - await handleGetContacts(); + try { + await axios.delete( + `${config.endpoint}${config.path.deleteContact}?id=${id}` + ); + handleGetContacts(); + } catch (error) { + console.error("Error deleting contact:", error); + setToastMessage("It was not possible to delete the contact"); + setToastSeverity("error"); + setToastVisible(true); + } + }; + + const handleCloseToast = () => { + setToastVisible(false); }; return ( - <> +
{ container xs={10} sx={{ my: { md: 1 } }} + key={contact.id} > { ))} - + +
); }; diff --git a/services/credential-server-ui/src/pages/CreateSchemaPage.tsx b/services/credential-server-ui/src/pages/CreateSchemaPage.tsx new file mode 100644 index 000000000..6af60b817 --- /dev/null +++ b/services/credential-server-ui/src/pages/CreateSchemaPage.tsx @@ -0,0 +1,621 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { + Button, + TextField, + Divider, + Accordion, + AccordionSummary, + AccordionDetails, + Tooltip, + IconButton, + Paper, + Input, + Alert, + Typography, + Box, + Grid, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; +import AddIcon from "@mui/icons-material/Add"; +import RefreshIcon from "@mui/icons-material/Refresh"; +import { config } from "../config"; +import { + GENERATE_SCHEMA_BLUEPRINT, + ATTRIBUTES_BLOCK, + EDGES_BLOCK, + RULES_BLOCK, +} from "../utils/schemaBlueprint"; +import CreateSchemaField from "../components/createSchema/CreateSchemaField"; +import { SchemaField, SchemaShortDetails } from "../constants/type"; +import { DeleteOutline } from "@mui/icons-material"; +import Toast from "../components/Toast"; + +const CreateSchemaPage = () => { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [credentialType, setCredentialType] = useState(""); + const [edges, setEdges] = useState([]); + const [rules, setRules] = useState([]); + const [attributes, setAttributes] = useState([]); + const [isSuccess, setIsSuccess] = useState(false); + const [error, setError] = useState(null); + const [generatedSchema, setGeneratedSchema] = useState(null); + const [allSchemas, setAllSchemas] = useState([]); + const [schemas, setSchemas] = useState([]); + const [schemasFilter, setSchemasFilter] = useState(""); + + const [toastVisible, setToastVisible] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + const [toastSeverity, setToastSeverity] = useState< + "success" | "error" | "info" | "warning" + >("error"); + + useEffect(() => { + handleGetSchemas(); + }, []); + + useEffect(() => { + if (allSchemas) { + const regex = new RegExp(schemasFilter, "gi"); + setSchemas( + allSchemas.filter( + (schema: { $id: string; title: string }) => + regex.test(schema.$id) || regex.test(schema.title) + ) + ); + } + }, [schemasFilter, allSchemas]); + + const handleGenerateSchema = async (event: React.FormEvent) => { + event.preventDefault(); + setIsSuccess(false); + setError(null); + + if (!title || !description || !credentialType) { + setError("Title, Description, and Type fields must be filled."); + setToastMessage("Title, Description, and Type fields must be filled."); + setToastSeverity("error"); + setToastVisible(true); + return; + } + + const allFieldNames = [...attributes, ...edges, ...rules].map( + (field) => field.name + ); + const uniqueFieldNames = new Set(allFieldNames); + + if (allFieldNames.length !== uniqueFieldNames.size) { + setError("All extra field names must be unique."); + setToastMessage("All extra field names must be unique."); + setToastSeverity("error"); + setToastVisible(true); + return; + } + + const schema: any = JSON.parse(JSON.stringify(GENERATE_SCHEMA_BLUEPRINT)); + + schema.title = title; + schema.description = description; + schema.credentialType = credentialType; + + const addFieldsToSchema = ( + fields: SchemaField[], + schemaProperties: any, + requiredFields: string[] + ) => { + fields.forEach((field) => { + if (field.name && field.description) { + const fieldSchema: any = { + description: field.description, + type: field.type, + }; + + if (field.customizable) { + fieldSchema.customizable = field.customizable; + if ( + field.default !== undefined && + field.default !== "" && + !Number.isNaN(field.default) + ) { + fieldSchema.default = field.default; + } + } + + if (field.type === "object" && field.fields) { + fieldSchema.properties = {}; + fieldSchema.required = []; + addFieldsToSchema( + field.fields, + fieldSchema.properties, + fieldSchema.required + ); + } + + schemaProperties[field.name] = fieldSchema; + requiredFields.push(field.name); + } + }); + }; + + // Process attributes + if (attributes.length > 0) { + const attributesBlock = JSON.parse( + JSON.stringify(ATTRIBUTES_BLOCK.oneOf[1]) + ); + addFieldsToSchema( + attributes, + attributesBlock.properties, + attributesBlock.required + ); + schema.properties.a = { + oneOf: [ + { description: "Attributes block SAID", type: "string" }, + attributesBlock, + ], + }; + schema.required.push("a"); + } + + // Process edges + if (edges.length > 0) { + const edgesBlock = JSON.parse(JSON.stringify(EDGES_BLOCK.oneOf[1])); + addFieldsToSchema(edges, edgesBlock.properties, edgesBlock.required); + schema.properties.e = { + oneOf: [ + { description: "Edges block SAID", type: "string" }, + edgesBlock, + ], + }; + schema.required.push("e"); + } + + // Process rules + if (rules.length > 0) { + const rulesBlock = JSON.parse(JSON.stringify(RULES_BLOCK.oneOf[1])); + addFieldsToSchema(rules, rulesBlock.properties, rulesBlock.required); + schema.properties.r = { + oneOf: [ + { description: "Rules block SAID", type: "string" }, + rulesBlock, + ], + }; + schema.required.push("r"); + } + + setGeneratedSchema(schema); + }; + + const saveSchema = async () => { + try { + const response = await axios.post( + `${config.endpoint}${config.path.saveSchema}`, + generatedSchema + ); + console.log("Schema saved successfully:", response.data); + setIsSuccess(true); + setToastMessage("Schema saved successfully"); + setToastSeverity("success"); + setToastVisible(true); + } catch (error) { + console.error("Error saving schema:", error); + setError("Error saving schema"); + setToastMessage("Error saving schema"); + setToastSeverity("error"); + setToastVisible(true); + } + }; + + const addField = (section: string) => { + const newField: SchemaField = { + name: "", + description: "", + type: "string", + customizable: false, + fields: [], + }; + + if (section === "edges") { + setEdges((prevEdges) => [...prevEdges, newField]); + } else if (section === "rules") { + setRules((prevRules) => [...prevRules, newField]); + } else if (section === "attributes") { + setAttributes((prevAttributes) => [...prevAttributes, newField]); + } + }; + + const handleFieldChange = ( + section: string, + index: number, + field: "name" | "description" | "type" | "default" | "fields", + value: any + ) => { + const updateFields = (fields: SchemaField[]) => { + const newFields = [...fields]; + if (field === "fields") { + newFields[index].fields = value as SchemaField[]; + } else { + newFields[index][field] = value as any; + } + return newFields; + }; + + if (section === "edges") { + setEdges(updateFields(edges)); + } else if (section === "rules") { + setRules(updateFields(rules)); + } else if (section === "attributes") { + setAttributes(updateFields(attributes)); + } + }; + + const handleCustomizableChange = ( + section: string, + index: number, + value: boolean + ) => { + const updateFields = (fields: SchemaField[]) => { + const newFields = [...fields]; + newFields[index].customizable = value; + return newFields; + }; + + if (section === "edges") { + setEdges(updateFields(edges)); + } else if (section === "rules") { + setRules(updateFields(rules)); + } else if (section === "attributes") { + setAttributes(updateFields(attributes)); + } + }; + + const removeField = (section: string, index: number) => { + const updateFields = (fields: SchemaField[]) => + fields.filter((_, i) => i !== index); + + if (section === "edges") { + setEdges(updateFields(edges)); + } else if (section === "rules") { + setRules(updateFields(rules)); + } else if (section === "attributes") { + setAttributes(updateFields(attributes)); + } + }; + + const handleGetSchemas = async () => { + try { + const schemasList = ( + await axios.get(`${config.endpoint}${config.path.schemaList}`) + ).data; + setSchemas(schemasList); + setAllSchemas(schemasList); + } catch (error) { + console.error("Error fetching schemas:", error); + setToastMessage("Error fetching schemas"); + setToastSeverity("error"); + setToastVisible(true); + } + }; + + const handleDeleteSchema = async (id: string) => { + try { + await axios.delete( + `${config.endpoint}${config.path.deleteSchema}?id=${id}` + ); + await handleGetSchemas(); + } catch (error) { + console.error("Error deleting schema:", error); + setToastMessage("Error deleting schema"); + setToastSeverity("error"); + setToastVisible(true); + } + }; + + const handleCloseToast = () => { + setToastVisible(false); + }; + + return ( + <> + + Generate Schema + + +
+ + + setTitle(e.target.value)} + /> + + + setDescription(e.target.value)} + /> + + + setCredentialType(e.target.value)} + /> + + + + + + + }> + Extra Fields + + +
+
+

Attributes

+ + + + + +
+ addField("attributes")} + color="primary" + > + + +
+ +
+
+

Edges

+ + + + + +
+ addField("edges")} + color="primary" + > + + +
+ +
+
+

Rules

+ + + + + +
+ addField("rules")} + color="primary" + > + + +
+ +
+
+
+ + + {error && ( + + {error} + + )} + + + +
+
+ {generatedSchema && ( + + + Create Schema +
+                {JSON.stringify(generatedSchema, null, 2)}
+              
+ + {isSuccess && ( + + Schema saved successfully + + )} + + +
+
+ )} +
+ + + + Manage schemas + + + + + + + setSchemasFilter(event.target.value)} + placeholder="Search for schemas" + /> +

+
+
+ + {schemas && + schemas.map((schema, index) => ( + + + {schema.title} ({schema.$id}) + + + + + + ))} + +
+ + + ); +}; + +export { CreateSchemaPage }; diff --git a/services/credential-server-ui/src/pages/CredentialPage.tsx b/services/credential-server-ui/src/pages/CredentialPage.tsx index eed2c3bce..d7c862629 100644 --- a/services/credential-server-ui/src/pages/CredentialPage.tsx +++ b/services/credential-server-ui/src/pages/CredentialPage.tsx @@ -1,107 +1,19 @@ -import React, { useEffect, useState } from "react"; -import RefreshIcon from "@mui/icons-material/Refresh"; -import { config } from "../config"; -import { - Alert, - Box, - Button, - FormControl, - Grid, - InputLabel, - MenuItem, - Select, - TextField, - Typography, - Autocomplete, -} from "@mui/material"; +import React from "react"; +import { CredentialForm } from "../components/CredentialForm"; import axios from "axios"; -import { Contact } from "../types"; -import { - Attributes, - CredentialType, - SCHEMA_SAID, - UUID_REGEX, - credentialTypes, -} from "../constants"; -import { Controller, useForm } from "react-hook-form"; -import { IAttributeObj, IAttributes } from "../constants/type"; +import { config } from "../config"; +import { Typography } from "@mui/material"; const CredentialPage: React.FC = () => { - const { - control, - register, - handleSubmit, - setValue, - watch, - formState: { errors }, - } = useForm(); - - const [contacts, setContacts] = useState([]); - const [attributes, setAttributes] = useState([]); - const [isIssueCredentialSuccess, setIsIssueCredentialSuccess] = - useState(false); - useEffect(() => { - handleGetContacts(); - }, []); - - useEffect(() => { - const type = watch("credential_type") as CredentialType; - if (!type) return; - - const newAttributes = Attributes[type]; - newAttributes.forEach((att, index) => { - setValue(`attributes.${index}.key`, att.key); - setValue(`attributes.${index}.label`, att.label); - }); - - setAttributes(newAttributes); - }, [watch("credential_type")]); - - const handleGetContacts = async () => { - try { - setContacts( - (await axios.get(`${config.endpoint}${config.path.contacts}`)).data.data - ); - } catch (e) { - console.log(e); - } - }; - - const handleRequestCredential = async (values: any) => { - setIsIssueCredentialSuccess(false); - const schemaSaid = SCHEMA_SAID[values.credential_type as CredentialType]; - let objAttributes = {}; - let attribute: IAttributes = {}; - - values.attributes.forEach((att: IAttributeObj) => { - if (att.key && att.value) attribute[att.key] = att.value; - }); - - if (Object.keys(attribute).length) { - objAttributes = { - attribute, - }; - } - - const data = { - schemaSaid: schemaSaid, - aid: values.selectedContact, - ...objAttributes, - }; - - axios - .post(`${config.endpoint}${config.path.issueAcdcCredential}`, data) - .then((response) => { - console.log(response); - if (response.status === 200) { - setIsIssueCredentialSuccess(true); - } - }) - .catch((error) => console.log(error)); + const handleIssueCredential = async (data: any) => { + await axios.post( + `${config.endpoint}${config.path.issueAcdcCredential}`, + data + ); }; return ( - <> +
{ > Issue Credential -
-
- - - - ( - - UUID_REGEX.test(option.alias) - ? option.id - : `${option.alias} (${option.id})` - } - options={contacts || []} - renderInput={(params) => ( - - )} - onChange={(_event, data) => - field.onChange(data?.id || null) - } - /> - )} - /> - - - - - - - - Credential Type - ( - - )} - /> - - - - {attributes.map((att, index) => ( - <> - - ( - - - ( - - )} - /> - - - ))} - - - - {errors.selectedContact && ( - - Please, select a contact from the list of connections - - )} - {errors.credential_type && ( - - Please, select a credential type - - )} - {isIssueCredentialSuccess && ( - - Issue credential successfully - - )} - - - - -
-
- + +
); }; diff --git a/services/credential-server-ui/src/pages/RequestCredential.tsx b/services/credential-server-ui/src/pages/RequestCredential.tsx index de28d7959..0a0481306 100644 --- a/services/credential-server-ui/src/pages/RequestCredential.tsx +++ b/services/credential-server-ui/src/pages/RequestCredential.tsx @@ -1,110 +1,19 @@ -import { - Alert, - Autocomplete, - Box, - Button, - FormControl, - FormLabel, - Grid, - InputLabel, - MenuItem, - Select, - Stack, - TextField, - Typography, -} from "@mui/material"; -import React, { useEffect, useState } from "react"; -import { Controller, useForm } from "react-hook-form"; -import RefreshIcon from "@mui/icons-material/Refresh"; +import React from "react"; +import { CredentialForm } from "../components/CredentialForm"; import axios from "axios"; import { config } from "../config"; -import { Contact } from "../types"; -import { - Attributes, - CredentialType, - SCHEMA_SAID, - UUID_REGEX, - credentialTypes, -} from "../constants"; -import { IAttributeObj, IAttributes } from "../constants/type"; - -function RequestCredential() { - const { - control, - register, - handleSubmit, - setValue, - watch, - formState: { errors }, - } = useForm(); - const [contacts, setContacts] = useState([]); - - const [isRequestCredentialSuccess, setIsRequestCredentialSuccess] = - useState(false); - const [attributes, setAttributes] = useState([]); - - useEffect(() => { - const type = watch("credential_type") as CredentialType; - if (!type) return; - - const newAttributes = Attributes[type]; - - if (!newAttributes) return; - newAttributes.forEach((att, index) => { - setValue(`attributes.${index}.key`, att.key); - setValue(`attributes.${index}.label`, att.label); - }); - - setAttributes(newAttributes); - }, [watch("credential_type")]); - - useEffect(() => { - handleGetContacts(); - }, []); - - const handleGetContacts = async () => { - try { - setContacts( - (await axios.get(`${config.endpoint}${config.path.contacts}`)).data.data - ); - } catch (e) { - console.log(e); - } +import { Typography } from "@mui/material"; + +const RequestCredential: React.FC = () => { + const handleRequestCredential = async (data: any) => { + await axios.post( + `${config.endpoint}${config.path.requestDisclosure}`, + data + ); }; - const handleRequestCredential = async (values: any) => { - const schemaSaid = SCHEMA_SAID[values.credential_type as CredentialType]; - let objAttributes = {}; - let attributes: IAttributes = {}; - - values.attributes.forEach((att: IAttributeObj) => { - if (att.key && att.value) attributes[att.key] = att.value; - }); - - if (Object.keys(attributes).length) { - objAttributes = { - attributes, - }; - } - - const data = { - schemaSaid: schemaSaid, - aid: values.selectedContact, - ...objAttributes, - }; - - axios - .post(`${config.endpoint}${config.path.requestDisclosure}`, data) - .then((response) => { - console.log(response); - if (response.status === 200) { - setIsRequestCredentialSuccess(true); - } - }) - .catch((error) => console.log(error)); - }; return ( - +
Request Credential -
- - - - ( - - UUID_REGEX.test(option.alias) - ? option.id - : `${option.alias} (${option.id})` - } - options={contacts || []} - renderInput={(params) => ( - - )} - onChange={(_event, data) => - field.onChange(data?.id || null) - } - /> - )} - /> - - - - - - - - Credential Type - ( - - )} - /> - - - - Attributes - {attributes.map((att, index) => ( - - - ( - - )} - /> - - - ( - - )} - /> - - - ))} - - - - {errors.selectedContact && ( - - Please, select a contact from the list of connections - - )} - {errors.credential_type && ( - Please, select a credential type - )} - {isRequestCredentialSuccess && ( - - Request credential successfully sent - - )} - - - - -
- + +
); -} +}; export { RequestCredential }; diff --git a/services/credential-server-ui/src/pages/RevocationPage.tsx b/services/credential-server-ui/src/pages/RevocationPage.tsx index fdf194610..a908259e8 100644 --- a/services/credential-server-ui/src/pages/RevocationPage.tsx +++ b/services/credential-server-ui/src/pages/RevocationPage.tsx @@ -18,6 +18,7 @@ import { Controller, useForm } from "react-hook-form"; import { config } from "../config"; import { UUID_REGEX } from "../constants"; import axios from "axios"; +import Toast from "../components/Toast"; const RevocationPage: React.FC = () => { const { @@ -33,6 +34,12 @@ const RevocationPage: React.FC = () => { const [isRevokeCredentialSuccess, setIsRevokeCredentialSuccess] = useState(false); + const [toastVisible, setToastVisible] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + const [toastSeverity, setToastSeverity] = useState< + "success" | "error" | "info" | "warning" + >("error"); + useEffect(() => { handleGetContacts(); }, []); @@ -42,24 +49,33 @@ const RevocationPage: React.FC = () => { }, [selectedContact]); const handleGetContacts = async () => { - setContacts( - (await axios.get(`${config.endpoint}${config.path.contacts}`)).data.data - ); + try { + const response = await axios.get( + `${config.endpoint}${config.path.contacts}` + ); + setContacts(response.data.data); + } catch (error) { + console.error("Error fetching contacts:", error); + setToastMessage("Error fetching contacts"); + setToastSeverity("error"); + setToastVisible(true); + } }; const handleGetContactCredentials = async (contactId?: string) => { if (!contactId) { return setCredentials([]); } - const credentialsData = ( - await axios.get(`${config.endpoint}${config.path.contactCredentials}`, { - params: { contactId }, - }) - ).data.data; - if (credentialsData.length) { - setCredentials(credentialsData); - } else { - setCredentials([]); + try { + const response = await axios.get( + `${config.endpoint}${config.path.credentials}?contactId=${contactId}` + ); + setCredentials(response.data.data); + } catch (error) { + console.error("Error fetching credentials:", error); + setToastMessage("Error fetching credentials"); + setToastSeverity("error"); + setToastVisible(true); } }; @@ -78,6 +94,10 @@ const RevocationPage: React.FC = () => { await handleGetContactCredentials(values.selectedContact); }; + const handleCloseToast = () => { + setToastVisible(false); + }; + return ( <> { + ); }; diff --git a/services/credential-server-ui/src/utils/schemaBlueprint.ts b/services/credential-server-ui/src/utils/schemaBlueprint.ts new file mode 100644 index 000000000..e9d9f70d8 --- /dev/null +++ b/services/credential-server-ui/src/utils/schemaBlueprint.ts @@ -0,0 +1,80 @@ +const ATTRIBUTES_BLOCK = { + oneOf: [ + { description: "Attributes block SAID", type: "string" }, + { + $id: "string", + description: "Attributes block", + type: "object", + properties: { + d: { description: "Attributes block SAID", type: "string" }, + i: { description: "Issuer AID", type: "string" }, + dt: { + description: "Issuance date time", + type: "string", + format: "date-time", + }, + }, + additionalProperties: false, + required: ["i", "dt"], + }, + ], +}; + +const EDGES_BLOCK = { + oneOf: [ + { description: "Edges block SAID", type: "string" }, + { + $id: "string", + description: "Edges block", + type: "object", + properties: { + d: { description: "Edges block SAID", type: "string" }, + }, + additionalProperties: false, + required: ["d"], + }, + ], +}; + +const RULES_BLOCK = { + oneOf: [ + { description: "Rules block SAID", type: "string" }, + { + $id: "string", + description: "Rules block", + type: "object", + properties: { + d: { description: "Rules block SAID", type: "string" }, + }, + additionalProperties: false, + required: ["d"], + }, + ], +}; + +const GENERATE_SCHEMA_BLUEPRINT = { + $id: "string", + $schema: "http://json-schema.org/draft-07/schema#", + title: "string", + description: "string", + type: "object", + credentialType: "string", + version: "1.0.0", + properties: { + v: { description: "Version", type: "string" }, + d: { description: "Credential SAID", type: "string" }, + u: { description: "One time use nonce", type: "string" }, + i: { description: "Issuer AID", type: "string" }, + ri: { description: "Credential status registry", type: "string" }, + s: { description: "Schema SAID", type: "string" }, + }, + additionalProperties: false, + required: ["i", "ri", "s", "d"], +}; + +export { + GENERATE_SCHEMA_BLUEPRINT, + ATTRIBUTES_BLOCK, + EDGES_BLOCK, + RULES_BLOCK, +}; diff --git a/services/credential-server/package-lock.json b/services/credential-server/package-lock.json index 59f9d9fa4..e4b6048a5 100644 --- a/services/credential-server/package-lock.json +++ b/services/credential-server/package-lock.json @@ -13,6 +13,7 @@ "body-parser": "^1.20.2", "cors": "^2.8.5", "express": "^5.0.0-beta.3", + "lmdb": "^3.1.3", "net": "^1.0.2", "node-cache": "^5.1.2", "qrcode-terminal": "^0.12.0", @@ -171,6 +172,150 @@ "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", "dev": true }, + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.1.3.tgz", + "integrity": "sha512-VV667lP23gIsQkb80rnQwAHjj6F1uZp30qTnvLSlep3pOomzXcQBMFp4ZmJLeGJnnPy54JjNsYBFyg9X95wCPw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-darwin-x64": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.1.3.tgz", + "integrity": "sha512-kuhKKJxGCQr9gBtUd7cVBBn6OtwQg7vIiD5gHEZb+jWLJulg6N4uPSLTab8W9tvpb3ryRTAejMt7F89/2MoRrQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.1.3.tgz", + "integrity": "sha512-R0CkYoJPHUfxPe2LaAqMGwTf5+1eXchUMNISO8OKEvKkS/zg2emIYTOb29v1k8WGSmdJkgQneBav/W3h5NorzA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-arm64": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.1.3.tgz", + "integrity": "sha512-XnSHGKsJ1Fr5LBjyDkG7JnVJlduhg7AhV1J6YQujStsKnehuiidsNW0InEJrAO4QMHqquwnCfLvU9PPJfpFVYw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-linux-x64": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.1.3.tgz", + "integrity": "sha512-epvFL9/Tem00evtuq05kqWbRppJ4G/D8wa6LnQmOu779VmbrY6+M3v3h4fnt2QqMQt3+J6Cg/gZACDlDcH+eUw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@lmdb/lmdb-win32-x64": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.1.3.tgz", + "integrity": "sha512-S6P96biJyrt/CUYSP0v4OH1U9ITzHhHCh1kn7hHOscS3S1+T/D74sCJKQ9xb/Raos2NJHqtZ8EyQVEVjOzmqbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@noble/hashes": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.3.tgz", @@ -656,6 +801,14 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -1237,6 +1390,30 @@ "libsodium-sumo": "^0.7.13" } }, + "node_modules/lmdb": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.1.3.tgz", + "integrity": "sha512-WQcfSoOTw+XfBXQN4dRN1Ke5vv+NgmOWs4wH8oh4jYFmPZIaVPiQVF+0nGdjQDLGDcrMMyr2C34cG6WZPes6xQ==", + "hasInstallScript": true, + "dependencies": { + "msgpackr": "^1.10.2", + "node-addon-api": "^6.1.0", + "node-gyp-build-optional-packages": "5.2.2", + "ordered-binary": "^1.4.1", + "weak-lru-cache": "^1.2.2" + }, + "bin": { + "download-lmdb-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@lmdb/lmdb-darwin-arm64": "3.1.3", + "@lmdb/lmdb-darwin-x64": "3.1.3", + "@lmdb/lmdb-linux-arm": "3.1.3", + "@lmdb/lmdb-linux-arm64": "3.1.3", + "@lmdb/lmdb-linux-x64": "3.1.3", + "@lmdb/lmdb-win32-x64": "3.1.3" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -1339,6 +1516,35 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/msgpackr": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.0.tgz", + "integrity": "sha512-I8qXuuALqJe5laEBYoFykChhSXLikZmUhccjGsPuSJ/7uPip2TJ7lwdIQwWSAi0jGZDXv4WOP8Qg65QZRuXxXw==", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1352,6 +1558,11 @@ "resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz", "integrity": "sha512-kbhcj2SVVR4caaVnGLJKmlk2+f+oLkjqdKeQlmUtz6nGzOpbcobwVIeSURNgraV/v3tlmGIX82OcPCl0K6RbHQ==" }, + "node_modules/node-addon-api": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", + "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==" + }, "node_modules/node-cache": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", @@ -1363,6 +1574,19 @@ "node": ">= 8.0.0" } }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, "node_modules/nodemon": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.0.1.tgz", @@ -1463,6 +1687,11 @@ "node": ">= 0.8" } }, + "node_modules/ordered-binary": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.5.2.tgz", + "integrity": "sha512-JTo+4+4Fw7FreyAvlSLjb1BBVaxEQAacmjD3jjuyPZclpbEghTvQZbXBb2qPd2LeIMxiHwXBZUcpmG2Gl/mDEA==" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2132,6 +2361,11 @@ "node": ">= 0.8" } }, + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==" + }, "node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", diff --git a/services/credential-server/package.json b/services/credential-server/package.json index e4eb4aba4..d07412c49 100644 --- a/services/credential-server/package.json +++ b/services/credential-server/package.json @@ -19,6 +19,7 @@ "body-parser": "^1.20.2", "cors": "^2.8.5", "express": "^5.0.0-beta.3", + "lmdb": "^3.1.3", "net": "^1.0.2", "node-cache": "^5.1.2", "qrcode-terminal": "^0.12.0", diff --git a/services/credential-server/src/agent.ts b/services/credential-server/src/agent.ts index fe9a20eca..12df575f2 100644 --- a/services/credential-server/src/agent.ts +++ b/services/credential-server/src/agent.ts @@ -3,16 +3,16 @@ import { config } from "./config"; import { SignifyApi } from "./modules/signify/signifyApi"; import { NotificationRoute } from "./modules/signify/signifyApi.type"; import { readFile, writeFile } from "fs/promises"; -import { existsSync, mkdir, mkdirSync } from "fs"; +import { existsSync, mkdirSync } from "fs"; import path from "path"; +import { SCHEMAS_KEY } from "./types/schema.type"; +import lmdb from "./utils/lmdb"; class Agent { static readonly ISSUER_AID_NAME = "issuer"; static readonly HOLDER_AID_NAME = "holder"; static readonly QVI_SCHEMA_SAID = "EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao"; - static readonly RARE_EVO_DEMO_SCHEMA_SAID = - "EJxnJdxkHbRw2wVFNe4IUOPLt8fEtg9Sr3WyTjlgKoIb"; static readonly LE_SCHEMA_SAID = "ENPXp1vQzRF6JwIuS-mp2U8Uf1MoADoP_GqQ62VsDZWY"; @@ -264,6 +264,20 @@ class Agent { ); await this.signifyApi.deleteNotification(grantNotification.i); } + + async saveSchema(schema: any, label?: string): Promise { + const { saidifiedSchema, customizableKeys } = + await this.signifyApi.saidifySchema(schema, label); + let schemas = await lmdb.get(SCHEMAS_KEY); + if (schemas === undefined) { + schemas = {}; + } + schemas[saidifiedSchema.$id] = { + schema: saidifiedSchema, + customizableKeys, + }; + await lmdb.put(SCHEMAS_KEY, schemas); + } } export { Agent }; diff --git a/services/credential-server/src/apis/credential.api.ts b/services/credential-server/src/apis/credential.api.ts index 290578eb4..1872750b5 100644 --- a/services/credential-server/src/apis/credential.api.ts +++ b/services/credential-server/src/apis/credential.api.ts @@ -2,9 +2,10 @@ import { NextFunction, Request, Response } from "express"; import { Agent } from "../agent"; import { ResponseData } from "../types/response.type"; import { httpResponse } from "../utils/response.util"; -import { ACDC_SCHEMAS } from "../utils/schemas"; import { log } from "../log"; import { SignifyApi } from "../modules/signify"; +import lmdb from "../utils/lmdb"; +import { SCHEMAS_KEY } from "../types/schema.type"; async function issueAcdcCredential( req: Request, @@ -12,7 +13,7 @@ async function issueAcdcCredential( next: NextFunction ): Promise { const { schemaSaid, aid, attribute } = req.body; - if (!ACDC_SCHEMAS[schemaSaid]) { + if (!(await lmdb.get(SCHEMAS_KEY))[schemaSaid]) { const response: ResponseData = { statusCode: 409, success: false, diff --git a/services/credential-server/src/apis/schema.api.ts b/services/credential-server/src/apis/schema.api.ts index 7057eaacb..0be78ec00 100644 --- a/services/credential-server/src/apis/schema.api.ts +++ b/services/credential-server/src/apis/schema.api.ts @@ -1,13 +1,98 @@ import { Request, Response } from "express"; -import { ACDC_SCHEMAS } from "../utils/schemas"; +import { ResponseData } from "../types/response.type"; +import { httpResponse } from "../utils/response.util"; +import { Agent } from "../agent"; +import lmdb from "../utils/lmdb"; +import { SCHEMAS_KEY, SchemaShortDetails } from "../types/schema.type"; async function schemaApi(req: Request, res: Response) { const { id } = req.params; - const data = ACDC_SCHEMAS[id]; - if (!data) { + const schemas = await lmdb.get(SCHEMAS_KEY); + if (!schemas) { + return res.status(404).send("No schemas found"); + } + + const schema = schemas[id]; + if (!schema) { return res.status(404).send("Schema for given SAID not found"); } + + const data = schema.schema; + if (!data) { + return res.status(404).send("Schema data not found"); + } + return res.send(data); } -export { schemaApi }; +function schemaList(req: Request, res: Response) { + const schemas = lmdb.get(SCHEMAS_KEY); + if (!schemas) { + return res.status(404).send("No schemas found"); + } + + const schemaDetailsList: Array = []; + + Object.entries(schemas).forEach(([id, schema]) => { + const typedSchema = (schema as any).schema; + schemaDetailsList.push({ + $id: typedSchema.$id, + title: typedSchema.title, + }); + }); + + return res.send(schemaDetailsList); +} + +async function schemaCustomFields(req: Request, res: Response) { + const { id } = req.query; + const schemas = await lmdb.get(SCHEMAS_KEY); + if (!schemas) { + return res.status(404).send("No schemas found"); + } + + const data = schemas[id as string]; + if (!data) { + return res.status(404).send("Schema for given SAID not found"); + } + + return res.send({ customizableKeys: data.customizableKeys }); +} + +async function saveSchema(req: Request, res: Response) { + try { + await Agent.agent.saveSchema(req.body, "$id"); + const response: ResponseData = { + statusCode: 200, + success: true, + data: "Schema succesfully generated", + }; + httpResponse(res, response); + } catch (error: any) { + console.error("Error during sadify operation:", error); + + const response: ResponseData = { + statusCode: 500, + success: false, + data: + error.message || + "An unknown error occurred while processing the schema.", + }; + httpResponse(res, response); + } +} + +async function deleteSchema(req: Request, res: Response) { + const { id } = req.query; + const schemas = await lmdb.get(SCHEMAS_KEY); + if (!schemas) { + return res.status(404).send("No schemas found"); + } + + delete schemas[id as string]; + await lmdb.put(SCHEMAS_KEY, schemas); + + return res.send("Schema deleted successfully"); +} + +export { schemaApi, schemaList, schemaCustomFields, saveSchema, deleteSchema }; diff --git a/services/credential-server/src/config.ts b/services/credential-server/src/config.ts index 29fb5628b..876016969 100644 --- a/services/credential-server/src/config.ts +++ b/services/credential-server/src/config.ts @@ -25,6 +25,10 @@ const config = { requestDisclosure: "/requestDisclosure", revokeCredential: "/revokeCredential", deleteContact: "/deleteContact", + saveSchema: "/saveSchema", + schemaList: "/schemaList", + schemaCustomFields: "/schemaCustomFields", + deleteSchema: "/deleteSchema", }, }; diff --git a/services/credential-server/src/modules/signify/signifyApi.ts b/services/credential-server/src/modules/signify/signifyApi.ts index 5aeaced29..0802c4c43 100644 --- a/services/credential-server/src/modules/signify/signifyApi.ts +++ b/services/credential-server/src/modules/signify/signifyApi.ts @@ -10,6 +10,8 @@ import { waitAndGetDoneOp } from "./utils"; import { config } from "../../config"; import { v4 as uuidv4 } from "uuid"; import { Agent } from "../../agent"; +import lmdb from "../../utils/lmdb"; +import { SCHEMAS_KEY } from "../../types/schema.type"; export class SignifyApi { static readonly DEFAULT_ROLE = "agent"; @@ -105,28 +107,49 @@ export class SignifyApi { registryId: string, schemaId: string, recipient: string, - attribute: { [key: string]: string } + attribute: { [key: string]: any } ) { await this.resolveOobi(`${config.oobiEndpoint}/oobi/${schemaId}`); let vcdata = {}; - if ( - schemaId === Agent.RARE_EVO_DEMO_SCHEMA_SAID || - schemaId === Agent.QVI_SCHEMA_SAID - ) { + const schema = await lmdb.get(SCHEMAS_KEY)[schemaId]; + if (schema) { vcdata = attribute; } else { throw new Error(SignifyApi.UNKNOW_SCHEMA_ID + schemaId); } - const result = await this.signifyClient.credentials().issue(issuerName, { + let credentialData = { ri: registryId, s: schemaId, a: { i: recipient, - ...vcdata, }, - }); + }; + + for (const [key, value] of Object.entries(vcdata)) { + const keys = key.split("."); + let current = credentialData; + for (let i = 0; i < keys.length; i++) { + if (i === keys.length - 1) { + current[keys[i]] = value; + } else { + current = current[keys[i]] = current[keys[i]] || {}; + } + } + } + + if (credentialData["e"]) { + credentialData["e"]["d"] = ""; + } + if (credentialData["r"]) { + credentialData["r"]["d"] = ""; + } + + const result = await this.signifyClient + .credentials() + .issue(issuerName, credentialData); + await waitAndGetDoneOp( this.signifyClient, result.op, @@ -415,4 +438,42 @@ export class SignifyApi { } return op; } + + async saidifySchema(schema: any, label?: string): Promise { + const customizableKeys: { [key: string]: any } = {}; + const removeCustomizables = (obj: any, path: string[] = []): any => { + if (typeof obj !== "object" || obj === null) return obj; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + const currentPath = [...path, key]; + if (obj[key] && obj[key].customizable === true) { + let keyPath = currentPath.slice(1).join("."); + keyPath = keyPath.replace(/oneOf\.\d+\.properties\./g, ""); + keyPath = keyPath.replace(/\.properties\./g, "."); + customizableKeys[keyPath] = { ...obj[key] }; + customizableKeys[keyPath].name = key; + delete obj[key].customizable; + } else { + obj[key] = removeCustomizables(obj[key], currentPath); + } + } + } + return obj; + }; + schema = removeCustomizables(schema); + const saidifyDeepest = (obj: any): any => { + if (typeof obj !== "object" || obj === null) return obj; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + obj[key] = saidifyDeepest(obj[key]); + } + } + if (obj.hasOwnProperty(label)) { + return Saider.saidify(obj, undefined, undefined, label)[1]; + } + return obj; + }; + const saidifiedSchema = saidifyDeepest(schema); + return { saidifiedSchema, customizableKeys }; + } } diff --git a/services/credential-server/src/routes.ts b/services/credential-server/src/routes.ts index 495fbc17e..7d9a4e6e8 100644 --- a/services/credential-server/src/routes.ts +++ b/services/credential-server/src/routes.ts @@ -9,7 +9,13 @@ import { contactCredentials, } from "./apis/credential.api"; import { createShortenUrl, getFullUrl } from "./apis/shorten.api"; -import { schemaApi } from "./apis/schema.api"; +import { + saveSchema, + schemaApi, + schemaCustomFields, + schemaList, + deleteSchema, +} from "./apis/schema.api"; import { contactList, deleteContact } from "./apis/contact.api"; import { resolveOobi } from "./apis/oobi.api"; @@ -26,5 +32,9 @@ router.get(config.path.contactCredentials, contactCredentials); router.post(config.path.requestDisclosure, requestDisclosure); router.post(config.path.revokeCredential, revokeCredential); router.delete(config.path.deleteContact, deleteContact); +router.post(config.path.saveSchema, saveSchema); +router.get(config.path.schemaList, schemaList); +router.get(config.path.schemaCustomFields, schemaCustomFields); +router.delete(config.path.deleteSchema, deleteSchema); export default router; diff --git a/services/credential-server/src/server.ts b/services/credential-server/src/server.ts index b71459ec8..3edc573b7 100644 --- a/services/credential-server/src/server.ts +++ b/services/credential-server/src/server.ts @@ -5,6 +5,7 @@ import { config } from "./config"; import { Agent } from "./agent"; import router from "./routes"; import { log } from "./log"; +import { initilizeDB } from "./utils/initializeDB"; async function startServer() { const app = express(); @@ -18,7 +19,9 @@ async function startServer() { error: err.message, }); }); + await Agent.agent.start(); + await initilizeDB(); app.listen(config.port, async () => { await Agent.agent.initKeri(); log(`Listening on port ${config.port}`); diff --git a/services/credential-server/src/types/schema.type.ts b/services/credential-server/src/types/schema.type.ts new file mode 100644 index 000000000..ac5d54987 --- /dev/null +++ b/services/credential-server/src/types/schema.type.ts @@ -0,0 +1,8 @@ +interface SchemaShortDetails { + $id: string; + title: string; +} + +const SCHEMAS_KEY = "schemas"; + +export { SCHEMAS_KEY, SchemaShortDetails }; diff --git a/services/credential-server/src/utils/initializeDB.ts b/services/credential-server/src/utils/initializeDB.ts new file mode 100644 index 000000000..551f6365b --- /dev/null +++ b/services/credential-server/src/utils/initializeDB.ts @@ -0,0 +1,21 @@ +import { Agent } from "../agent"; +import { ACDC_SCHEMAS } from "./schemas"; + +async function initilizeDB() { + try { + for (const schemaKey of Object.keys(ACDC_SCHEMAS)) { + try { + await Agent.agent.saveSchema( + ACDC_SCHEMAS[schemaKey as keyof typeof ACDC_SCHEMAS], + "$id" + ); + } catch (error) { + console.error(`Error saving schema ${schemaKey}:`, error); + } + } + } catch (error) { + console.error("Error saving schemas:", error); + } +} + +export { initilizeDB }; diff --git a/services/credential-server/src/utils/lmdb.ts b/services/credential-server/src/utils/lmdb.ts new file mode 100644 index 000000000..1118875a2 --- /dev/null +++ b/services/credential-server/src/utils/lmdb.ts @@ -0,0 +1,39 @@ +import { open, RootDatabase } from "lmdb"; + +class Lmdb { + private static instance: Lmdb; + private db: RootDatabase; + + private constructor() { + this.db = open({ + path: "./data/lmdb", + }); + } + + public static getInstance(): Lmdb { + if (!Lmdb.instance) { + Lmdb.instance = new Lmdb(); + } + return Lmdb.instance; + } + + public async put(key: string, value: any): Promise { + try { + await this.db.put(key, value); + } catch (error) { + console.error("Error putting data in LMDB:", error); + throw error; + } + } + + public get(key: string): any { + try { + return this.db.get(key); + } catch (error) { + console.error("Error getting data from LMDB:", error); + throw error; + } + } +} + +export default Lmdb.getInstance(); diff --git a/services/credential-server/src/utils/schemas.ts b/services/credential-server/src/utils/schemas.ts index 4e175a0d5..734f48c83 100644 --- a/services/credential-server/src/utils/schemas.ts +++ b/services/credential-server/src/utils/schemas.ts @@ -34,6 +34,7 @@ const ACDC_SCHEMAS = { description: "LEI of the requesting Legal Entity", type: "string", format: "ISO 17442", + customizable: true, }, gracePeriod: { description: "Allocated grace period", @@ -150,6 +151,7 @@ const ACDC_SCHEMAS = { attendeeName: { description: "The name of the attendee", type: "string", + customizable: true, }, }, additionalProperties: false, @@ -223,6 +225,7 @@ const ACDC_SCHEMAS = { description: "LE Issuer AID", type: "string", format: "ISO 17442", + customizable: true, }, }, additionalProperties: false,