diff --git a/src/app/api/apiRoutes.ts b/src/app/api/apiRoutes.ts index 663f20a55..d7e5df079 100644 --- a/src/app/api/apiRoutes.ts +++ b/src/app/api/apiRoutes.ts @@ -119,6 +119,10 @@ const apiRoutes = { deactivateCertificate: '/deactivate', importCertificate: '/import', }, + Oid4vcIssuer: { + root: 'oid4vc', + createIssuer: '/oid4vc/issuers', + }, } export default apiRoutes diff --git a/src/app/api/oid4vc.ts b/src/app/api/oid4vc.ts new file mode 100644 index 000000000..63dcebdde --- /dev/null +++ b/src/app/api/oid4vc.ts @@ -0,0 +1,27 @@ +import { AxiosResponse } from 'axios' +import { ICreateIssuerPayload } from '../oid4vc/interface/interface' +import apiRoutes from './apiRoutes' +import { axiosPost } from '@/services/apiRequests' +import { getHeaderConfigs } from '@/config/GetHeaderConfigs' + +export const createIssuer = async ( + orgId: string, + payload: ICreateIssuerPayload, +): Promise => { + const url = `/orgs/${orgId}${apiRoutes.Oid4vcIssuer.createIssuer}` + + const config = getHeaderConfigs() + + const axiosPayload = { + url, + payload, + config, + } + + try { + return await axiosPost(axiosPayload) + } catch (error) { + const err = error as Error + return err?.message + } +} diff --git a/src/app/oid4vc/create-issuer/page.tsx b/src/app/oid4vc/create-issuer/page.tsx new file mode 100644 index 000000000..910800fc6 --- /dev/null +++ b/src/app/oid4vc/create-issuer/page.tsx @@ -0,0 +1,11 @@ +import CreateIssuer from '@/features/oid4vc/components/CreateIssuer' +import PageContainer from '@/components/layout/page-container' +import React from 'react' + +const page = (): React.JSX.Element => ( + + + +) + +export default page diff --git a/src/app/oid4vc/interface/interface.ts b/src/app/oid4vc/interface/interface.ts new file mode 100644 index 000000000..dcc9b53b6 --- /dev/null +++ b/src/app/oid4vc/interface/interface.ts @@ -0,0 +1,26 @@ +export interface ICreateIssuerPayload { + issuerId: string + display: Display[] +} + +export interface Display { + locale: string + name: string + description: string + logo: Logo +} + +export interface Logo { + uri: string + alt_text: string +} + +export interface IDisplayItem { + locale: string + name: string + description: string + logo: { + uri: string + alt_text: string + } +} diff --git a/src/features/components/SessionManager.tsx b/src/features/components/SessionManager.tsx index e7fb7260c..96e648370 100644 --- a/src/features/components/SessionManager.tsx +++ b/src/features/components/SessionManager.tsx @@ -31,6 +31,7 @@ const preventRedirectOnPaths = [ '/agent-config', '/credentials', '/verification', + '/oid4vc', ] const excludeRouteForSessionCheck = [ '/verify-email-success', diff --git a/src/features/oid4vc/components/CreateIssuer.tsx b/src/features/oid4vc/components/CreateIssuer.tsx new file mode 100644 index 000000000..85742feda --- /dev/null +++ b/src/features/oid4vc/components/CreateIssuer.tsx @@ -0,0 +1,400 @@ +'use client' + +import { ChevronDown, ChevronRight, Plus } from 'lucide-react' +import { JSX, useEffect, useState } from 'react' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +import { AlertComponent } from '@/components/AlertComponent' +import { AxiosResponse } from 'axios' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { IDisplayItem } from '@/app/oid4vc/interface/interface' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import Loader from '@/components/Loader' +import { Textarea } from '@/components/ui/textarea' +import { apiStatusCodes } from '@/config/CommonConstant' +import { createIssuer } from '@/app/api/oid4vc' +import { useAppSelector } from '@/lib/hooks' + +interface LocaleEntry { + name: string + description: string + locale: string +} + +export default function CreateIssuer(): JSX.Element { + const [name, setName] = useState('') + const [description, setDescription] = useState('') + const [logoLink, setLogoLink] = useState('') + const [advancedOpen, setAdvancedOpen] = useState(false) + const [locales, setLocales] = useState([]) + const [errors, setErrors] = useState>({}) + const [touched, setTouched] = useState>({}) + + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const orgId = useAppSelector((state) => state.organization.orgId) + const orgName = useAppSelector((state) => state.organization.orgInfo?.name) + const localesSelection: Record = { + Fr: 'fr', + De: 'de', + Es: 'es', + } + + useEffect(() => { + setTimeout(() => { + setSuccess(null) + setError(null) + }, 3000) + }, [success, error]) + + const addLocale = (): void => + setLocales((prev) => [...prev, { name: '', description: '', locale: 'fr' }]) + + const updateLocale = ( + index: number, + field: keyof LocaleEntry, + value: string, + ): void => + setLocales((prev) => + prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)), + ) + + const removeLocale = (index: number): void => + setLocales((prev) => prev.filter((_, i) => i !== index)) + + const validate = (fieldName?: string): boolean => { + const newErrors = { ...errors } + + const check = (key: string, condition: boolean, message: string): void => { + if (condition) { + newErrors[key] = message + } else { + delete newErrors[key] + } + } + + if (!fieldName || fieldName === 'name') { + check('name', !name.trim(), 'Name is required') + } + + if (!fieldName || fieldName === 'description') { + check('description', !description.trim(), 'Description is required') + } + + if (!fieldName || fieldName === 'logo') { + check('logo', !logoLink.trim(), 'Logo link is required') + + const urlPattern = /^(https?:\/\/)[^\s$.?#].[^\s]*$/i + check( + 'serverUrl', + logoLink !== '' && !urlPattern.test(logoLink), + 'Please enter a valid URL', + ) + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + } + + const generateIssuerKey = (orgName: string, issuerName: string): string => { + const getShortCode = (str: string): string => { + const words = str.trim().split(/\s+/) + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toLowerCase() + } + return str.substring(0, 2).toLowerCase() + } + + const orgPart = getShortCode(orgName) + const issuerPart = getShortCode(issuerName) + + const suffix = Math.random().toString(36).substring(2, 6) + + return `${orgPart}-${issuerPart}-${suffix}` + } + + const handleSubmit = async (): Promise => { + if (validate()) { + try { + setLoading(true) + + const logo = { + uri: logoLink, + // eslint-disable-next-line camelcase + alt_text: 'Issuer logo', + } + const formattedLocales = locales.map((value) => ({ ...value, logo })) + let display = [ + { + locale: 'en', + name, + description, + logo, + }, + ] + if (formattedLocales.length > 0) { + display = [...display, ...formattedLocales] + } + const seen: Record = {} + const uniqueData = display.reduce((acc, current) => { + if (!seen[current.locale]) { + seen[current.locale] = true + acc.push(current) + } + return acc + }, []) + const payload = { + issuerId: `${generateIssuerKey(orgName || '', name)}-jwt-issuer`, + display: uniqueData, + } + const res = await createIssuer(orgId, payload) + const { data } = res as AxiosResponse + if (data && data.statusCode === apiStatusCodes.API_STATUS_CREATED) { + setSuccess('Issuer created successfully') + } + } catch (error) { + setError('Failed to create Issuer') + console.error('Failed to submit form', error) + } finally { + setLoading(false) + } + } + } + + return ( +
+ {(Boolean(error) || Boolean(success)) && ( + { + setError(null) + setSuccess(null) + }} + /> + )} + +

Create Issuer

+

Create new issuer for OID4VC

+
+ {/* Name & Logo row */} +
+
+ + { + setName(e.target.value) + if (errors.name) { + setErrors({ ...errors, name: '' }) + } // Clear error on type + }} + onBlur={() => { + setTouched({ ...touched, name: true }) + validate('name') + }} + placeholder="Enter issuer name" + /> + {touched.name && errors.name && ( +

+ {errors.name} +

+ )} +
+ +
+ +
+
+ {logoLink ? ( + Logo preview + ) : ( + 'Logo' + )} +
+
+ {/*

or link

*/} + { + setLogoLink(e.target.value) + if (errors.name) { + setErrors({ ...errors, logo: '' }) + } + }} + onBlur={() => { + setTouched({ ...touched, logo: true }) + validate('logo') + }} + /> + {touched.logo && errors.logo && ( +

+ {errors.logo} +

+ )} +
+
+
+
+ + {/* Description */} +
+ +