diff --git a/site/src/sphinx/setup-configuration.rst b/site/src/sphinx/setup-configuration.rst index 0f0478b5e..95bda0c84 100644 --- a/site/src/sphinx/setup-configuration.rst +++ b/site/src/sphinx/setup-configuration.rst @@ -298,7 +298,8 @@ Core properties } - the declared schemas are exposed via ``GET /api/v1/metadataProperties`` so that clients such as - the web UI can render input forms for the declared properties. + the web UI can render input forms for the declared properties. The web UI uses the standard + ``title`` and ``description`` keywords of each property as the form label and its help text. .. _replication: diff --git a/webapp/src/dogma/features/api/apiSlice.ts b/webapp/src/dogma/features/api/apiSlice.ts index e5d65d02c..8d875d37d 100644 --- a/webapp/src/dogma/features/api/apiSlice.ts +++ b/webapp/src/dogma/features/api/apiSlice.ts @@ -24,6 +24,7 @@ import { ProjectMetadataDto } from 'dogma/features/project/ProjectMetadataDto'; import { FileContentDto } from 'dogma/features/file/FileContentDto'; import { RevisionDto } from 'dogma/features/history/RevisionDto'; import { AppIdentityDto } from 'dogma/features/app-identity/AppIdentity'; +import { MetadataProperties } from 'dogma/features/metadata-properties/MetadataProperties'; import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; import { DeleteUserOrAppIdentityRepositoryRoleDto } from 'dogma/features/repo/settings/DeleteUserOrAppIdentityRepositoryRoleDto'; import { AddUserOrAppIdentityRepositoryRoleDto } from 'dogma/features/repo/settings/AddUserOrAppIdentityRepositoryRoleDto'; @@ -592,6 +593,12 @@ export const apiSlice = createApi({ method: 'GET', }), }), + getMetadataProperties: builder.query({ + query: () => ({ + url: `/api/v1/metadataProperties`, + method: 'GET', + }), + }), isXdsWebEnabled: builder.query({ query: () => ({ url: `/api/v1/xds/web`, @@ -632,6 +639,8 @@ export const { useGetXdsClientsQuery, useGetXdsAppsQuery, useGetXdsSnapshotQuery, + // Metadata properties + useGetMetadataPropertiesQuery, // Project useGetProjectsQuery, useRestoreProjectMutation, diff --git a/webapp/src/dogma/features/app-identity/AppIdentity.ts b/webapp/src/dogma/features/app-identity/AppIdentity.ts index fe9b0db02..d16dd34e5 100644 --- a/webapp/src/dogma/features/app-identity/AppIdentity.ts +++ b/webapp/src/dogma/features/app-identity/AppIdentity.ts @@ -10,6 +10,7 @@ export interface AppIdentity { creation: UserAndTimestamp; deactivation?: UserAndTimestamp; deletion?: UserAndTimestamp; + properties?: Record; } export interface Token extends AppIdentity { diff --git a/webapp/src/dogma/features/app-identity/DisplaySecretModal.tsx b/webapp/src/dogma/features/app-identity/DisplaySecretModal.tsx index 81f93a0c6..f1738c82e 100644 --- a/webapp/src/dogma/features/app-identity/DisplaySecretModal.tsx +++ b/webapp/src/dogma/features/app-identity/DisplaySecretModal.tsx @@ -19,6 +19,7 @@ import { import { DateWithTooltip } from 'dogma/common/components/DateWithTooltip'; import { newNotification } from 'dogma/features/notification/notificationSlice'; import { AppIdentityDto, isToken, isCertificate } from 'dogma/features/app-identity/AppIdentity'; +import { MetadataPropertiesSchema } from 'dogma/features/metadata-properties/MetadataProperties'; import { useAppDispatch } from 'dogma/hooks'; import { MdContentCopy } from 'react-icons/md'; @@ -26,10 +27,12 @@ export const DisplaySecretModal = ({ isOpen, onClose, response, + schema, }: { isOpen: boolean; onClose: () => void; response: AppIdentityDto; + schema?: MetadataPropertiesSchema; }) => { const dispatch = useAppDispatch(); if (!response) return; @@ -73,6 +76,13 @@ export const DisplaySecretModal = ({ {response.certificateId} )} + {response.properties && + Object.entries(response.properties).map(([key, value]) => ( + + {schema?.properties?.[key]?.title || key} + {String(value)} + + ))} Level {systemAdmin ? 'System Admin' : 'User'} diff --git a/webapp/src/dogma/features/app-identity/NewAppIdentity.tsx b/webapp/src/dogma/features/app-identity/NewAppIdentity.tsx index 5c2c9e0aa..0da0d78ec 100644 --- a/webapp/src/dogma/features/app-identity/NewAppIdentity.tsx +++ b/webapp/src/dogma/features/app-identity/NewAppIdentity.tsx @@ -28,9 +28,15 @@ import { useAddNewAppIdentityMutation } from 'dogma/features/api/apiSlice'; import { newNotification } from 'dogma/features/notification/notificationSlice'; import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser'; import { useState } from 'react'; -import { Controller, useForm } from 'react-hook-form'; +import { Controller, FormProvider, useForm } from 'react-hook-form'; import { IoMdArrowDropdown } from 'react-icons/io'; import { useAppDispatch, useAppSelector } from 'dogma/hooks'; +import { useGetMetadataPropertiesQuery } from 'dogma/features/api/apiSlice'; +import { + MetadataPropertiesFormData, + toMetadataProperties, +} from 'dogma/features/metadata-properties/MetadataProperties'; +import { MetadataPropertiesFields } from 'dogma/features/metadata-properties/MetadataPropertiesFields'; const APP_ID_PATTERN = /^[0-9A-Za-z](?:[-+_0-9A-Za-z\.]*[0-9A-Za-z])?$/; @@ -39,7 +45,7 @@ type FormData = { type: 'TOKEN' | 'CERTIFICATE'; certificateId?: string; isSystemAdmin: boolean; -}; +} & MetadataPropertiesFormData; export const NewAppIdentity = () => { const mtlsEnabled = useAppSelector((state) => state.serverConfig.mtlsEnabled); @@ -60,6 +66,11 @@ export const NewAppIdentity = () => { onToggle: onSecretModalToggle, onClose: onSecretModalClose, } = useDisclosure(); + const methods = useForm({ + defaultValues: { + type: 'TOKEN', + }, + }); const { register, handleSubmit, @@ -67,12 +78,9 @@ export const NewAppIdentity = () => { watch, control, formState: { errors }, - } = useForm({ - defaultValues: { - type: 'TOKEN', - }, - }); + } = methods; const [addNewAppIdentity, { isLoading }] = useAddNewAppIdentityMutation(); + const { data: metadataProperties } = useGetMetadataPropertiesQuery(); const [appIdentityDetail, setAppIdentityDetail] = useState(null); const dispatch = useAppDispatch(); const { user } = useAppSelector((state) => state.auth); @@ -92,6 +100,10 @@ export const NewAppIdentity = () => { if (formData.type === 'CERTIFICATE' && formData.certificateId) { params.set('certificateId', formData.certificateId); } + const properties = toMetadataProperties(metadataProperties?.appIdentity, formData); + if (properties) { + params.set('properties', JSON.stringify(properties)); + } const data = params.toString(); try { const response = await addNewAppIdentity({ data }).unwrap(); @@ -128,82 +140,105 @@ export const NewAppIdentity = () => { -
- - - Type - ( - - - Token - {mtlsEnabled && Certificate} - - - )} - /> - - - - Application ID - - App ID used to access project repositories. - {errors.appId && ( - The first/last character must be alphanumeric - )} - + + + + + Type + ( + + + Token + {mtlsEnabled && Certificate} + + + )} + /> + - {selectedType === 'CERTIFICATE' && ( - - Certificate ID + + Application ID - - Certificate identifier for mTLS authentication (e.g., CN or SPIFFE ID). - - {errors.certificateId && Certificate ID is required} + {errors.appId ? ( + {errors.appId.message} + ) : ( + App ID used to access project repositories. + )} - )} - {user.roles.includes('LEVEL_SYSTEM_ADMIN') && ( - - - - System Administrator-Level App Identity - - - )} - - - - - - + + + + + ); diff --git a/webapp/src/dogma/features/metadata-properties/MetadataProperties.ts b/webapp/src/dogma/features/metadata-properties/MetadataProperties.ts new file mode 100644 index 000000000..7eb07bf93 --- /dev/null +++ b/webapp/src/dogma/features/metadata-properties/MetadataProperties.ts @@ -0,0 +1,80 @@ +export interface MetadataPropertySchema { + type?: string; + pattern?: string; + enum?: (string | number)[]; + title?: string; + description?: string; + examples?: unknown[]; +} + +export interface MetadataPropertiesSchema { + properties?: Record; + required?: string[]; +} + +// The response of `GET /api/v1/metadataProperties`. Each field is the JSON Schema that the +// `properties` of the corresponding resource must conform to at creation time. +export interface MetadataProperties { + project?: MetadataPropertiesSchema; + repo?: MetadataPropertiesSchema; + appIdentity?: MetadataPropertiesSchema; +} + +// The form fields managed by `MetadataPropertiesFields`. +export type MetadataPropertiesFormData = { + properties?: Record; + propertiesJson?: string; +}; + +export function hasDeclaredProperties(schema: MetadataPropertiesSchema | undefined): boolean { + return !!schema && !!schema.properties && Object.keys(schema.properties).length > 0; +} + +export function validateJsonObject(value?: string): string | true { + if (!value || value.trim() === '') { + return true; + } + try { + const parsed = JSON.parse(value); + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return 'Must be a JSON object'; + } + return true; + } catch { + return 'Invalid JSON'; + } +} + +// Builds the `properties` object of a creation request from the form values, converting each value +// to the type declared in the schema. Returns undefined if there is nothing to send. +export function toMetadataProperties( + schema: MetadataPropertiesSchema | undefined, + formData: MetadataPropertiesFormData, +): Record | undefined { + if (!schema) { + return undefined; + } + if (!hasDeclaredProperties(schema)) { + const json = formData.propertiesJson?.trim(); + return json ? JSON.parse(json) : undefined; + } + const result: Record = {}; + for (const [name, property] of Object.entries(schema.properties)) { + const value = formData.properties?.[name]; + if (value === undefined || value === '') { + continue; + } + switch (property.type) { + case 'boolean': + result[name] = value === true || value === 'true'; + break; + case 'integer': + case 'number': + result[name] = Number(value); + break; + default: + result[name] = value; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} diff --git a/webapp/src/dogma/features/metadata-properties/MetadataPropertiesFields.tsx b/webapp/src/dogma/features/metadata-properties/MetadataPropertiesFields.tsx new file mode 100644 index 000000000..a4af36602 --- /dev/null +++ b/webapp/src/dogma/features/metadata-properties/MetadataPropertiesFields.tsx @@ -0,0 +1,141 @@ +import { + Checkbox, + Code, + FormControl, + FormErrorMessage, + FormHelperText, + FormLabel, + Input, + Select, + Textarea, + useColorModeValue, +} from '@chakra-ui/react'; +import { FieldError, useFormContext } from 'react-hook-form'; +import { + hasDeclaredProperties, + MetadataPropertiesFormData, + MetadataPropertiesSchema, + MetadataPropertySchema, + validateJsonObject, +} from 'dogma/features/metadata-properties/MetadataProperties'; + +const PATTERN_MISMATCH = 'PATTERN_MISMATCH'; + +// Renders input fields for the metadata properties declared in the JSON Schema of a resource type. +// Must be rendered inside a react-hook-form `FormProvider`. +export const MetadataPropertiesFields = ({ schema }: { schema: MetadataPropertiesSchema }) => { + const { + register, + formState: { errors }, + } = useFormContext(); + + if (!hasDeclaredProperties(schema)) { + // The schema declares its shape without a top-level `properties` keyword; fall back to raw JSON. + const error = errors.propertiesJson; + return ( + + Properties (JSON) +