Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion site/src/sphinx/setup-configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
9 changes: 9 additions & 0 deletions webapp/src/dogma/features/api/apiSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -592,6 +593,12 @@ export const apiSlice = createApi({
method: 'GET',
}),
}),
getMetadataProperties: builder.query<MetadataProperties, void>({
query: () => ({
url: `/api/v1/metadataProperties`,
method: 'GET',
}),
}),
isXdsWebEnabled: builder.query<boolean, void>({
query: () => ({
url: `/api/v1/xds/web`,
Expand Down Expand Up @@ -632,6 +639,8 @@ export const {
useGetXdsClientsQuery,
useGetXdsAppsQuery,
useGetXdsSnapshotQuery,
// Metadata properties
useGetMetadataPropertiesQuery,
// Project
useGetProjectsQuery,
useRestoreProjectMutation,
Expand Down
1 change: 1 addition & 0 deletions webapp/src/dogma/features/app-identity/AppIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface AppIdentity {
creation: UserAndTimestamp;
deactivation?: UserAndTimestamp;
deletion?: UserAndTimestamp;
properties?: Record<string, unknown>;
}

export interface Token extends AppIdentity {
Expand Down
10 changes: 10 additions & 0 deletions webapp/src/dogma/features/app-identity/DisplaySecretModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,20 @@ 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';

export const DisplaySecretModal = ({
isOpen,
onClose,
response,
schema,
}: {
isOpen: boolean;
onClose: () => void;
response: AppIdentityDto;
schema?: MetadataPropertiesSchema;
}) => {
const dispatch = useAppDispatch();
if (!response) return;
Expand Down Expand Up @@ -73,6 +76,13 @@ export const DisplaySecretModal = ({
<Td>{response.certificateId}</Td>
</Tr>
)}
{response.properties &&
Object.entries(response.properties).map(([key, value]) => (
<Tr key={key}>
<Td>{schema?.properties?.[key]?.title || key}</Td>
<Td>{String(value)}</Td>
</Tr>
))}
<Tr>
<Td>Level</Td>
<Td>{systemAdmin ? 'System Admin' : 'User'}</Td>
Expand Down
173 changes: 104 additions & 69 deletions webapp/src/dogma/features/app-identity/NewAppIdentity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])?$/;

Expand All @@ -39,7 +45,7 @@ type FormData = {
type: 'TOKEN' | 'CERTIFICATE';
certificateId?: string;
isSystemAdmin: boolean;
};
} & MetadataPropertiesFormData;

export const NewAppIdentity = () => {
const mtlsEnabled = useAppSelector((state) => state.serverConfig.mtlsEnabled);
Expand All @@ -60,19 +66,21 @@ export const NewAppIdentity = () => {
onToggle: onSecretModalToggle,
onClose: onSecretModalClose,
} = useDisclosure();
const methods = useForm<FormData>({
defaultValues: {
type: 'TOKEN',
},
});
const {
register,
handleSubmit,
reset,
watch,
control,
formState: { errors },
} = useForm<FormData>({
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);
Expand All @@ -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();
Expand Down Expand Up @@ -128,82 +140,105 @@ export const NewAppIdentity = () => {
</PopoverHeader>
<PopoverArrow />
<PopoverCloseButton />
<form onSubmit={handleSubmit(onSubmit)}>
<PopoverBody minWidth="md">
<FormControl mb={4}>
<FormLabel>Type</FormLabel>
<Controller
name="type"
control={control}
render={({ field: { onChange, value } }) => (
<RadioGroup value={value} onChange={onChange}>
<Stack direction="row" spacing={4}>
<Radio value="TOKEN">Token</Radio>
{mtlsEnabled && <Radio value="CERTIFICATE">Certificate</Radio>}
</Stack>
</RadioGroup>
)}
/>
</FormControl>

<FormControl isInvalid={errors.appId ? true : false} isRequired>
<FormLabel>Application ID</FormLabel>
<Input
type="text"
placeholder="my-app-id"
{...register('appId', { pattern: APP_ID_PATTERN })}
/>
<FormHelperText pl={1}>App ID used to access project repositories.</FormHelperText>
{errors.appId && (
<FormErrorMessage>The first/last character must be alphanumeric</FormErrorMessage>
)}
</FormControl>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<PopoverBody minWidth="md">
<FormControl mb={4}>
<FormLabel>Type</FormLabel>
<Controller
name="type"
control={control}
render={({ field: { onChange, value } }) => (
<RadioGroup value={value} onChange={onChange}>
<Stack direction="row" spacing={4}>
<Radio value="TOKEN">Token</Radio>
{mtlsEnabled && <Radio value="CERTIFICATE">Certificate</Radio>}
</Stack>
</RadioGroup>
)}
/>
</FormControl>

{selectedType === 'CERTIFICATE' && (
<FormControl mt={4} isInvalid={errors.certificateId ? true : false} isRequired>
<FormLabel>Certificate ID</FormLabel>
<FormControl isInvalid={errors.appId ? true : false} isRequired>
<FormLabel>Application ID</FormLabel>
<Input
type="text"
placeholder="certificate-id"
{...register('certificateId', {
required: selectedType === 'CERTIFICATE',
placeholder="my-app-id"
{...register('appId', {
required: 'Application ID is required',
pattern: {
value: APP_ID_PATTERN,
message: 'The first/last character must be alphanumeric',
},
})}
/>
<FormHelperText pl={1}>
Certificate identifier for mTLS authentication (e.g., CN or SPIFFE ID).
</FormHelperText>
{errors.certificateId && <FormErrorMessage>Certificate ID is required</FormErrorMessage>}
{errors.appId ? (
<FormErrorMessage>{errors.appId.message}</FormErrorMessage>
) : (
<FormHelperText pl={1}>App ID used to access project repositories.</FormHelperText>
)}
</FormControl>
)}

{user.roles.includes('LEVEL_SYSTEM_ADMIN') && (
<Flex mt={4}>
<Spacer />
<Checkbox colorScheme="teal" {...register('isSystemAdmin')}>
System Administrator-Level App Identity
</Checkbox>
</Flex>
)}
</PopoverBody>
<PopoverFooter border="0" display="flex" alignItems="center" justifyContent="space-between" pb={4}>
<Spacer />
<Button
type="submit"
colorScheme="teal"
variant="ghost"
isLoading={isLoading}
loadingText="Creating"
{selectedType === 'CERTIFICATE' && (
<FormControl mt={4} isInvalid={errors.certificateId ? true : false} isRequired>
<FormLabel>Certificate ID</FormLabel>
<Input
type="text"
placeholder="certificate-id"
{...register('certificateId', {
required: selectedType === 'CERTIFICATE',
})}
/>
{errors.certificateId ? (
<FormErrorMessage>Certificate ID is required</FormErrorMessage>
) : (
<FormHelperText pl={1}>
Certificate identifier for mTLS authentication (e.g., CN or SPIFFE ID).
</FormHelperText>
)}
</FormControl>
)}

{metadataProperties?.appIdentity && (
<MetadataPropertiesFields schema={metadataProperties.appIdentity} />
)}

{user.roles.includes('LEVEL_SYSTEM_ADMIN') && (
<Flex mt={4}>
<Spacer />
<Checkbox colorScheme="teal" {...register('isSystemAdmin')}>
System Administrator-Level App Identity
</Checkbox>
</Flex>
)}
</PopoverBody>
<PopoverFooter
border="0"
display="flex"
alignItems="center"
justifyContent="space-between"
pb={4}
>
Create
</Button>
</PopoverFooter>
</form>
<Spacer />
<Button
type="submit"
colorScheme="teal"
variant="ghost"
isLoading={isLoading}
loadingText="Creating"
>
Create
</Button>
</PopoverFooter>
</form>
</FormProvider>
</PopoverContent>
</Popover>
<DisplaySecretModal
isOpen={isSecretModalOpen}
onClose={onSecretModalClose}
response={appIdentityDetail}
schema={metadataProperties?.appIdentity}
/>
</>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, MetadataPropertySchema>;
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<string, string | boolean>;
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<string, unknown> | undefined {
if (!schema) {
return undefined;
}
if (!hasDeclaredProperties(schema)) {
const json = formData.propertiesJson?.trim();
return json ? JSON.parse(json) : undefined;
}
const result: Record<string, unknown> = {};
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;
}
Loading
Loading