Skip to content

Commit 9e868b1

Browse files
committed
Render metadata property forms in the web UI
Motivation: The previous commit added declarable metadata property schemas to the server, but the web UI could not render input forms for them. Modifications: - Fetch the declared schemas via `GET /api/v1/metadataProperties` and render input fields for them in the project, repository and app identity creation popovers — text inputs with pattern validation, enum selects and checkboxes, using the schema `title`, `description` and `examples` as the label, help text and placeholder. - Fall back to a raw JSON textarea when a schema declares no top-level `properties` keyword. - Send the entered values as the `properties` of the creation requests, and show the stored values in the app identity secret modal. - Use a single inline validation path (`noValidate` + react-hook-form) in the creation forms so required and pattern errors render consistently. Result: - Together with the previous commit, closes #1346. - The creation dialogs prompt for the properties declared in `metadataProperties` and reject invalid values before submission. Nothing changes when the server declares nothing.
1 parent 59ae479 commit 9e868b1

12 files changed

Lines changed: 648 additions & 139 deletions

File tree

site/src/sphinx/setup-configuration.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -298,7 +298,8 @@ Core properties
298298
}
299299
300300
- the declared schemas are exposed via ``GET /api/v1/metadataProperties`` so that clients such as
301-
the web UI can render input forms for the declared properties.
301+
the web UI can render input forms for the declared properties. The web UI uses the standard
302+
``title`` and ``description`` keywords of each property as the form label and its help text.
302303

303304
.. _replication:
304305

webapp/src/dogma/features/api/apiSlice.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { ProjectMetadataDto } from 'dogma/features/project/ProjectMetadataDto';
2424
import { FileContentDto } from 'dogma/features/file/FileContentDto';
2525
import { RevisionDto } from 'dogma/features/history/RevisionDto';
2626
import { AppIdentityDto } from 'dogma/features/app-identity/AppIdentity';
27+
import { MetadataProperties } from 'dogma/features/metadata-properties/MetadataProperties';
2728
import { FetchBaseQueryError } from '@reduxjs/toolkit/query';
2829
import { DeleteUserOrAppIdentityRepositoryRoleDto } from 'dogma/features/repo/settings/DeleteUserOrAppIdentityRepositoryRoleDto';
2930
import { AddUserOrAppIdentityRepositoryRoleDto } from 'dogma/features/repo/settings/AddUserOrAppIdentityRepositoryRoleDto';
@@ -592,6 +593,12 @@ export const apiSlice = createApi({
592593
method: 'GET',
593594
}),
594595
}),
596+
getMetadataProperties: builder.query<MetadataProperties, void>({
597+
query: () => ({
598+
url: `/api/v1/metadataProperties`,
599+
method: 'GET',
600+
}),
601+
}),
595602
isXdsWebEnabled: builder.query<boolean, void>({
596603
query: () => ({
597604
url: `/api/v1/xds/web`,
@@ -632,6 +639,8 @@ export const {
632639
useGetXdsClientsQuery,
633640
useGetXdsAppsQuery,
634641
useGetXdsSnapshotQuery,
642+
// Metadata properties
643+
useGetMetadataPropertiesQuery,
635644
// Project
636645
useGetProjectsQuery,
637646
useRestoreProjectMutation,

webapp/src/dogma/features/app-identity/AppIdentity.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface AppIdentity {
1010
creation: UserAndTimestamp;
1111
deactivation?: UserAndTimestamp;
1212
deletion?: UserAndTimestamp;
13+
properties?: Record<string, unknown>;
1314
}
1415

1516
export interface Token extends AppIdentity {

webapp/src/dogma/features/app-identity/DisplaySecretModal.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,20 @@ import {
1919
import { DateWithTooltip } from 'dogma/common/components/DateWithTooltip';
2020
import { newNotification } from 'dogma/features/notification/notificationSlice';
2121
import { AppIdentityDto, isToken, isCertificate } from 'dogma/features/app-identity/AppIdentity';
22+
import { MetadataPropertiesSchema } from 'dogma/features/metadata-properties/MetadataProperties';
2223
import { useAppDispatch } from 'dogma/hooks';
2324
import { MdContentCopy } from 'react-icons/md';
2425

2526
export const DisplaySecretModal = ({
2627
isOpen,
2728
onClose,
2829
response,
30+
schema,
2931
}: {
3032
isOpen: boolean;
3133
onClose: () => void;
3234
response: AppIdentityDto;
35+
schema?: MetadataPropertiesSchema;
3336
}) => {
3437
const dispatch = useAppDispatch();
3538
if (!response) return;
@@ -73,6 +76,13 @@ export const DisplaySecretModal = ({
7376
<Td>{response.certificateId}</Td>
7477
</Tr>
7578
)}
79+
{response.properties &&
80+
Object.entries(response.properties).map(([key, value]) => (
81+
<Tr key={key}>
82+
<Td>{schema?.properties?.[key]?.title || key}</Td>
83+
<Td>{String(value)}</Td>
84+
</Tr>
85+
))}
7686
<Tr>
7787
<Td>Level</Td>
7888
<Td>{systemAdmin ? 'System Admin' : 'User'}</Td>

webapp/src/dogma/features/app-identity/NewAppIdentity.tsx

Lines changed: 104 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,15 @@ import { useAddNewAppIdentityMutation } from 'dogma/features/api/apiSlice';
2828
import { newNotification } from 'dogma/features/notification/notificationSlice';
2929
import ErrorMessageParser from 'dogma/features/services/ErrorMessageParser';
3030
import { useState } from 'react';
31-
import { Controller, useForm } from 'react-hook-form';
31+
import { Controller, FormProvider, useForm } from 'react-hook-form';
3232
import { IoMdArrowDropdown } from 'react-icons/io';
3333
import { useAppDispatch, useAppSelector } from 'dogma/hooks';
34+
import { useGetMetadataPropertiesQuery } from 'dogma/features/api/apiSlice';
35+
import {
36+
MetadataPropertiesFormData,
37+
toMetadataProperties,
38+
} from 'dogma/features/metadata-properties/MetadataProperties';
39+
import { MetadataPropertiesFields } from 'dogma/features/metadata-properties/MetadataPropertiesFields';
3440

3541
const APP_ID_PATTERN = /^[0-9A-Za-z](?:[-+_0-9A-Za-z\.]*[0-9A-Za-z])?$/;
3642

@@ -39,7 +45,7 @@ type FormData = {
3945
type: 'TOKEN' | 'CERTIFICATE';
4046
certificateId?: string;
4147
isSystemAdmin: boolean;
42-
};
48+
} & MetadataPropertiesFormData;
4349

4450
export const NewAppIdentity = () => {
4551
const mtlsEnabled = useAppSelector((state) => state.serverConfig.mtlsEnabled);
@@ -60,19 +66,21 @@ export const NewAppIdentity = () => {
6066
onToggle: onSecretModalToggle,
6167
onClose: onSecretModalClose,
6268
} = useDisclosure();
69+
const methods = useForm<FormData>({
70+
defaultValues: {
71+
type: 'TOKEN',
72+
},
73+
});
6374
const {
6475
register,
6576
handleSubmit,
6677
reset,
6778
watch,
6879
control,
6980
formState: { errors },
70-
} = useForm<FormData>({
71-
defaultValues: {
72-
type: 'TOKEN',
73-
},
74-
});
81+
} = methods;
7582
const [addNewAppIdentity, { isLoading }] = useAddNewAppIdentityMutation();
83+
const { data: metadataProperties } = useGetMetadataPropertiesQuery();
7684
const [appIdentityDetail, setAppIdentityDetail] = useState(null);
7785
const dispatch = useAppDispatch();
7886
const { user } = useAppSelector((state) => state.auth);
@@ -92,6 +100,10 @@ export const NewAppIdentity = () => {
92100
if (formData.type === 'CERTIFICATE' && formData.certificateId) {
93101
params.set('certificateId', formData.certificateId);
94102
}
103+
const properties = toMetadataProperties(metadataProperties?.appIdentity, formData);
104+
if (properties) {
105+
params.set('properties', JSON.stringify(properties));
106+
}
95107
const data = params.toString();
96108
try {
97109
const response = await addNewAppIdentity({ data }).unwrap();
@@ -128,82 +140,105 @@ export const NewAppIdentity = () => {
128140
</PopoverHeader>
129141
<PopoverArrow />
130142
<PopoverCloseButton />
131-
<form onSubmit={handleSubmit(onSubmit)}>
132-
<PopoverBody minWidth="md">
133-
<FormControl mb={4}>
134-
<FormLabel>Type</FormLabel>
135-
<Controller
136-
name="type"
137-
control={control}
138-
render={({ field: { onChange, value } }) => (
139-
<RadioGroup value={value} onChange={onChange}>
140-
<Stack direction="row" spacing={4}>
141-
<Radio value="TOKEN">Token</Radio>
142-
{mtlsEnabled && <Radio value="CERTIFICATE">Certificate</Radio>}
143-
</Stack>
144-
</RadioGroup>
145-
)}
146-
/>
147-
</FormControl>
148-
149-
<FormControl isInvalid={errors.appId ? true : false} isRequired>
150-
<FormLabel>Application ID</FormLabel>
151-
<Input
152-
type="text"
153-
placeholder="my-app-id"
154-
{...register('appId', { pattern: APP_ID_PATTERN })}
155-
/>
156-
<FormHelperText pl={1}>App ID used to access project repositories.</FormHelperText>
157-
{errors.appId && (
158-
<FormErrorMessage>The first/last character must be alphanumeric</FormErrorMessage>
159-
)}
160-
</FormControl>
143+
<FormProvider {...methods}>
144+
<form onSubmit={handleSubmit(onSubmit)} noValidate>
145+
<PopoverBody minWidth="md">
146+
<FormControl mb={4}>
147+
<FormLabel>Type</FormLabel>
148+
<Controller
149+
name="type"
150+
control={control}
151+
render={({ field: { onChange, value } }) => (
152+
<RadioGroup value={value} onChange={onChange}>
153+
<Stack direction="row" spacing={4}>
154+
<Radio value="TOKEN">Token</Radio>
155+
{mtlsEnabled && <Radio value="CERTIFICATE">Certificate</Radio>}
156+
</Stack>
157+
</RadioGroup>
158+
)}
159+
/>
160+
</FormControl>
161161

162-
{selectedType === 'CERTIFICATE' && (
163-
<FormControl mt={4} isInvalid={errors.certificateId ? true : false} isRequired>
164-
<FormLabel>Certificate ID</FormLabel>
162+
<FormControl isInvalid={errors.appId ? true : false} isRequired>
163+
<FormLabel>Application ID</FormLabel>
165164
<Input
166165
type="text"
167-
placeholder="certificate-id"
168-
{...register('certificateId', {
169-
required: selectedType === 'CERTIFICATE',
166+
placeholder="my-app-id"
167+
{...register('appId', {
168+
required: 'Application ID is required',
169+
pattern: {
170+
value: APP_ID_PATTERN,
171+
message: 'The first/last character must be alphanumeric',
172+
},
170173
})}
171174
/>
172-
<FormHelperText pl={1}>
173-
Certificate identifier for mTLS authentication (e.g., CN or SPIFFE ID).
174-
</FormHelperText>
175-
{errors.certificateId && <FormErrorMessage>Certificate ID is required</FormErrorMessage>}
175+
{errors.appId ? (
176+
<FormErrorMessage>{errors.appId.message}</FormErrorMessage>
177+
) : (
178+
<FormHelperText pl={1}>App ID used to access project repositories.</FormHelperText>
179+
)}
176180
</FormControl>
177-
)}
178181

179-
{user.roles.includes('LEVEL_SYSTEM_ADMIN') && (
180-
<Flex mt={4}>
181-
<Spacer />
182-
<Checkbox colorScheme="teal" {...register('isSystemAdmin')}>
183-
System Administrator-Level App Identity
184-
</Checkbox>
185-
</Flex>
186-
)}
187-
</PopoverBody>
188-
<PopoverFooter border="0" display="flex" alignItems="center" justifyContent="space-between" pb={4}>
189-
<Spacer />
190-
<Button
191-
type="submit"
192-
colorScheme="teal"
193-
variant="ghost"
194-
isLoading={isLoading}
195-
loadingText="Creating"
182+
{selectedType === 'CERTIFICATE' && (
183+
<FormControl mt={4} isInvalid={errors.certificateId ? true : false} isRequired>
184+
<FormLabel>Certificate ID</FormLabel>
185+
<Input
186+
type="text"
187+
placeholder="certificate-id"
188+
{...register('certificateId', {
189+
required: selectedType === 'CERTIFICATE',
190+
})}
191+
/>
192+
{errors.certificateId ? (
193+
<FormErrorMessage>Certificate ID is required</FormErrorMessage>
194+
) : (
195+
<FormHelperText pl={1}>
196+
Certificate identifier for mTLS authentication (e.g., CN or SPIFFE ID).
197+
</FormHelperText>
198+
)}
199+
</FormControl>
200+
)}
201+
202+
{metadataProperties?.appIdentity && (
203+
<MetadataPropertiesFields schema={metadataProperties.appIdentity} />
204+
)}
205+
206+
{user.roles.includes('LEVEL_SYSTEM_ADMIN') && (
207+
<Flex mt={4}>
208+
<Spacer />
209+
<Checkbox colorScheme="teal" {...register('isSystemAdmin')}>
210+
System Administrator-Level App Identity
211+
</Checkbox>
212+
</Flex>
213+
)}
214+
</PopoverBody>
215+
<PopoverFooter
216+
border="0"
217+
display="flex"
218+
alignItems="center"
219+
justifyContent="space-between"
220+
pb={4}
196221
>
197-
Create
198-
</Button>
199-
</PopoverFooter>
200-
</form>
222+
<Spacer />
223+
<Button
224+
type="submit"
225+
colorScheme="teal"
226+
variant="ghost"
227+
isLoading={isLoading}
228+
loadingText="Creating"
229+
>
230+
Create
231+
</Button>
232+
</PopoverFooter>
233+
</form>
234+
</FormProvider>
201235
</PopoverContent>
202236
</Popover>
203237
<DisplaySecretModal
204238
isOpen={isSecretModalOpen}
205239
onClose={onSecretModalClose}
206240
response={appIdentityDetail}
241+
schema={metadataProperties?.appIdentity}
207242
/>
208243
</>
209244
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
export interface MetadataPropertySchema {
2+
type?: string;
3+
pattern?: string;
4+
enum?: (string | number)[];
5+
title?: string;
6+
description?: string;
7+
examples?: unknown[];
8+
}
9+
10+
export interface MetadataPropertiesSchema {
11+
properties?: Record<string, MetadataPropertySchema>;
12+
required?: string[];
13+
}
14+
15+
// The response of `GET /api/v1/metadataProperties`. Each field is the JSON Schema that the
16+
// `properties` of the corresponding resource must conform to at creation time.
17+
export interface MetadataProperties {
18+
project?: MetadataPropertiesSchema;
19+
repo?: MetadataPropertiesSchema;
20+
appIdentity?: MetadataPropertiesSchema;
21+
}
22+
23+
// The form fields managed by `MetadataPropertiesFields`.
24+
export type MetadataPropertiesFormData = {
25+
properties?: Record<string, string | boolean>;
26+
propertiesJson?: string;
27+
};
28+
29+
export function hasDeclaredProperties(schema: MetadataPropertiesSchema | undefined): boolean {
30+
return !!schema && !!schema.properties && Object.keys(schema.properties).length > 0;
31+
}
32+
33+
export function validateJsonObject(value?: string): string | true {
34+
if (!value || value.trim() === '') {
35+
return true;
36+
}
37+
try {
38+
const parsed = JSON.parse(value);
39+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
40+
return 'Must be a JSON object';
41+
}
42+
return true;
43+
} catch {
44+
return 'Invalid JSON';
45+
}
46+
}
47+
48+
// Builds the `properties` object of a creation request from the form values, converting each value
49+
// to the type declared in the schema. Returns undefined if there is nothing to send.
50+
export function toMetadataProperties(
51+
schema: MetadataPropertiesSchema | undefined,
52+
formData: MetadataPropertiesFormData,
53+
): Record<string, unknown> | undefined {
54+
if (!schema) {
55+
return undefined;
56+
}
57+
if (!hasDeclaredProperties(schema)) {
58+
const json = formData.propertiesJson?.trim();
59+
return json ? JSON.parse(json) : undefined;
60+
}
61+
const result: Record<string, unknown> = {};
62+
for (const [name, property] of Object.entries(schema.properties)) {
63+
const value = formData.properties?.[name];
64+
if (value === undefined || value === '') {
65+
continue;
66+
}
67+
switch (property.type) {
68+
case 'boolean':
69+
result[name] = value === true || value === 'true';
70+
break;
71+
case 'integer':
72+
case 'number':
73+
result[name] = Number(value);
74+
break;
75+
default:
76+
result[name] = value;
77+
}
78+
}
79+
return Object.keys(result).length > 0 ? result : undefined;
80+
}

0 commit comments

Comments
 (0)