Skip to content

Commit d067fbb

Browse files
committed
Add frontend component to edit plugin configuration.
1 parent 88e4976 commit d067fbb

File tree

3 files changed

+261
-0
lines changed

3 files changed

+261
-0
lines changed
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* Form component for configuring App Group Lifecycle Plugins.
3+
* Used in CreateUpdate dialogs (edit mode).
4+
*/
5+
6+
import * as React from 'react';
7+
import Box from '@mui/material/Box';
8+
import CircularProgress from '@mui/material/CircularProgress';
9+
import FormControl from '@mui/material/FormControl';
10+
import FormHelperText from '@mui/material/FormHelperText';
11+
import MenuItem from '@mui/material/MenuItem';
12+
import Select, {SelectChangeEvent} from '@mui/material/Select';
13+
import TextField from '@mui/material/TextField';
14+
import Typography from '@mui/material/Typography';
15+
import Checkbox from '@mui/material/Checkbox';
16+
import FormControlLabel from '@mui/material/FormControlLabel';
17+
import {useFormContext, Controller} from 'react-hook-form';
18+
19+
import {
20+
useGetAppGroupLifecyclePlugins,
21+
useGetAppGroupLifecyclePluginAppConfigProperties,
22+
useGetAppGroupLifecyclePluginGroupConfigProperties,
23+
PluginConfigProperty,
24+
} from '../api/apiComponents';
25+
26+
type PluginConfiguration = {
27+
[propertyId: string]: any;
28+
};
29+
30+
interface AppGroupLifecyclePluginConfigurationFormProps {
31+
/**
32+
* Entity type at which the plugin is configured ('app' or 'group')
33+
*/
34+
entityType: 'app' | 'group';
35+
36+
/**
37+
* Currently selected plugin ID (if any)
38+
*/
39+
selectedPluginId?: string | null;
40+
41+
/**
42+
* Current configuration values
43+
*/
44+
currentConfig?: PluginConfiguration;
45+
46+
/**
47+
* Callback when plugin selection changes (app level only)
48+
*/
49+
onPluginChange?: (pluginId: string | null) => void;
50+
}
51+
52+
/**
53+
* Renders a single configuration field based on its schema
54+
*/
55+
function ConfigField({property, value, fieldName}: {property: PluginConfigProperty; value: any; fieldName: string}) {
56+
const {register, control} = useFormContext();
57+
58+
switch (property.type) {
59+
case 'boolean':
60+
return (
61+
<FormControl fullWidth sx={{mb: 2}}>
62+
<Controller
63+
name={fieldName}
64+
control={control}
65+
defaultValue={value ?? property.default_value ?? false}
66+
render={({field}) => (
67+
<FormControlLabel control={<Checkbox {...field} checked={field.value} />} label={property.display_name} />
68+
)}
69+
/>
70+
{property.help_text && <FormHelperText>{property.help_text}</FormHelperText>}
71+
</FormControl>
72+
);
73+
74+
case 'number':
75+
return (
76+
<TextField
77+
fullWidth
78+
label={property.display_name}
79+
type="number"
80+
helperText={property.help_text}
81+
required={property.required}
82+
defaultValue={value ?? property.default_value}
83+
{...register(fieldName, {
84+
required: property.required,
85+
valueAsNumber: true,
86+
})}
87+
sx={{mb: 2}}
88+
/>
89+
);
90+
91+
case 'text':
92+
default:
93+
return (
94+
<TextField
95+
fullWidth
96+
label={property.display_name}
97+
helperText={property.help_text}
98+
required={property.required}
99+
defaultValue={value ?? property.default_value ?? ''}
100+
{...register(fieldName, {
101+
required: property.required,
102+
})}
103+
sx={{mb: 2}}
104+
/>
105+
);
106+
}
107+
}
108+
109+
export default function AppGroupLifecyclePluginConfigurationForm({
110+
entityType,
111+
selectedPluginId,
112+
currentConfig = {},
113+
onPluginChange,
114+
}: AppGroupLifecyclePluginConfigurationFormProps) {
115+
const {data: plugins, isLoading: pluginsLoading} = useGetAppGroupLifecyclePlugins();
116+
117+
const useConfigPropertiesHook =
118+
entityType === 'app'
119+
? useGetAppGroupLifecyclePluginAppConfigProperties
120+
: useGetAppGroupLifecyclePluginGroupConfigProperties;
121+
122+
const {data: configProperties, isLoading: configLoading} = useConfigPropertiesHook(
123+
{pathParams: {pluginId: selectedPluginId || ''}},
124+
{enabled: !!selectedPluginId},
125+
);
126+
127+
const selectedPlugin = React.useMemo(() => {
128+
if (!plugins || !selectedPluginId) return null;
129+
return plugins.find((p) => p.id === selectedPluginId) || null;
130+
}, [plugins, selectedPluginId]);
131+
132+
const handlePluginSelectionChange = (event: SelectChangeEvent<string>) => {
133+
const newPluginId = event.target.value;
134+
onPluginChange?.(newPluginId || null);
135+
};
136+
137+
if (pluginsLoading) {
138+
return (
139+
<Box sx={{display: 'flex', justifyContent: 'center', p: 2}}>
140+
<CircularProgress size={24} />
141+
</Box>
142+
);
143+
}
144+
145+
if (!plugins || plugins.length === 0) {
146+
return null;
147+
}
148+
149+
return (
150+
<Box sx={{mt: 3}}>
151+
<Typography variant="h6" gutterBottom>
152+
Configure {entityType === 'app' ? 'an' : 'the'} App Group Lifecycle Plugin
153+
</Typography>
154+
155+
{/* Plugin Selection (app-level only) */}
156+
{entityType === 'app' && (
157+
<FormControl fullWidth sx={{mb: 3}}>
158+
<Select value={selectedPluginId || ''} onChange={handlePluginSelectionChange} displayEmpty>
159+
<MenuItem value="">
160+
<em>None</em>
161+
</MenuItem>
162+
{plugins.map((plugin) => (
163+
<MenuItem key={plugin.id} value={plugin.id}>
164+
{plugin.display_name}
165+
</MenuItem>
166+
))}
167+
</Select>
168+
{selectedPlugin && <FormHelperText>{selectedPlugin.description}</FormHelperText>}
169+
</FormControl>
170+
)}
171+
172+
{/* Display Selected Plugin (group-level only) */}
173+
{entityType === 'group' && selectedPlugin && (
174+
<>
175+
<Typography variant="subtitle1" gutterBottom>
176+
Selected Plugin
177+
</Typography>
178+
<Box sx={{pl: 2}}>
179+
<Typography variant="body2" gutterBottom>
180+
<strong>{selectedPlugin.display_name}</strong>: {selectedPlugin.description}
181+
</Typography>
182+
</Box>
183+
</>
184+
)}
185+
186+
{/* Plugin Configuration */}
187+
{selectedPluginId && (
188+
<>
189+
{configLoading ? (
190+
<Box sx={{display: 'flex', justifyContent: 'center', p: 2}}>
191+
<CircularProgress size={24} />
192+
</Box>
193+
) : configProperties && Object.keys(configProperties).length > 0 ? (
194+
<>
195+
<Typography variant="subtitle1" gutterBottom>
196+
Configuration
197+
</Typography>
198+
<Box sx={{pl: 2}}>
199+
{Object.entries(configProperties).map(([propertyId, property]) => {
200+
const fieldName = `plugin_data.${selectedPluginId}.configuration.${propertyId}`;
201+
return (
202+
<ConfigField
203+
key={propertyId}
204+
property={property}
205+
value={currentConfig[propertyId]}
206+
fieldName={fieldName}
207+
/>
208+
);
209+
})}
210+
</Box>
211+
</>
212+
) : null}
213+
</>
214+
)}
215+
</Box>
216+
);
217+
}

src/pages/apps/CreateUpdate.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import {App, AppTagMap, OktaUser, Tag} from '../../api/apiSchemas';
3030
import {isAccessAdmin, isAppOwnerGroupOwner, ACCESS_APP_RESERVED_NAME} from '../../authorization';
3131
import accessConfig from '../../config/accessConfig';
32+
import AppGroupLifecyclePluginConfigurationForm from '../../components/AppGroupLifecyclePluginConfigurationForm';
3233

3334
interface AppButtonProps {
3435
setOpen(open: boolean): any;
@@ -70,6 +71,11 @@ function AppDialog(props: AppDialogProps) {
7071
const [requestError, setRequestError] = React.useState('');
7172
const [submitting, setSubmitting] = React.useState(false);
7273

74+
const [selectedAppGroupLifecyclePluginId, setSelectedAppGroupLifecyclePluginId] = React.useState<string | null>(
75+
(props.app as any)?.app_group_lifecycle_plugin || null,
76+
);
77+
const isAllowedToConfigureAppGroupLifecyclePlugin = isAccessAdmin(props.currentUser);
78+
7379
const complete = (
7480
completedApp: App | undefined,
7581
error: CreateAppError | PutAppByIdError | null,
@@ -115,6 +121,10 @@ function AppDialog(props: AppDialogProps) {
115121
app.tags_to_add = selectedTags.map((tag: Tag) => tag.id);
116122
}
117123

124+
if (isAllowedToConfigureAppGroupLifecyclePlugin) {
125+
(app as any).app_group_lifecycle_plugin = selectedAppGroupLifecyclePluginId || null;
126+
}
127+
118128
if (props.app == null) {
119129
createApp.mutate({body: app});
120130
} else {
@@ -206,6 +216,18 @@ function AppDialog(props: AppDialogProps) {
206216
renderInput={(params) => <TextField {...params} label="Tags" placeholder="Tags" />}
207217
/>
208218
</FormControl>
219+
{isAllowedToConfigureAppGroupLifecyclePlugin && (
220+
<AppGroupLifecyclePluginConfigurationForm
221+
entityType="app"
222+
selectedPluginId={selectedAppGroupLifecyclePluginId}
223+
currentConfig={
224+
selectedAppGroupLifecyclePluginId && (props.app as any)?.plugin_data
225+
? (props.app as any).plugin_data[selectedAppGroupLifecyclePluginId]?.configuration || {}
226+
: {}
227+
}
228+
onPluginChange={setSelectedAppGroupLifecyclePluginId}
229+
/>
230+
)}
209231
</DialogContent>
210232
<DialogActions>
211233
<Button onClick={() => props.setOpen(false)}>Cancel</Button>

src/pages/groups/CreateUpdate.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import {PolymorphicGroup, AppGroup, App, OktaUser, Tag, OktaGroupTagMap} from '../../api/apiSchemas';
3333
import {canManageGroup, isAccessAdmin, isAppOwnerGroupOwner} from '../../authorization';
3434
import accessConfig from '../../config/accessConfig';
35+
import AppGroupLifecyclePluginConfigurationForm from '../../components/AppGroupLifecyclePluginConfigurationForm';
3536

3637
interface GroupButtonProps {
3738
defaultGroupType: 'okta_group' | 'app_group' | 'role_group';
@@ -98,6 +99,16 @@ function GroupDialog(props: GroupDialogProps) {
9899
const [requestError, setRequestError] = React.useState('');
99100
const [submitting, setSubmitting] = React.useState(false);
100101

102+
const appGroupLifecyclePluginId = React.useMemo(() => {
103+
if (groupType !== 'app_group') return null;
104+
const app = props.app ?? (props.group as AppGroup)?.app;
105+
return (app as any)?.app_group_lifecycle_plugin || null;
106+
}, [groupType, props.app, props.group]);
107+
108+
const isAllowedToConfigureAppGroupLifecyclePlugin =
109+
isAccessAdmin(props.currentUser) ||
110+
isAppOwnerGroupOwner(props.currentUser, props.app?.id ?? (props.group as AppGroup)?.app?.id ?? '');
111+
101112
const complete = (
102113
completedGroup: PolymorphicGroup | undefined,
103114
error: CreateGroupError | PutGroupByIdError | null,
@@ -321,6 +332,17 @@ function GroupDialog(props: GroupDialogProps) {
321332
renderInput={(params) => <TextField {...params} label="Tags" placeholder="Tags" />}
322333
/>
323334
</FormControl>
335+
{appGroupLifecyclePluginId && isAllowedToConfigureAppGroupLifecyclePlugin && (
336+
<AppGroupLifecyclePluginConfigurationForm
337+
entityType="group"
338+
selectedPluginId={appGroupLifecyclePluginId}
339+
currentConfig={
340+
appGroupLifecyclePluginId && (props.group as any)?.plugin_data
341+
? (props.group as any).plugin_data[appGroupLifecyclePluginId]?.configuration || {}
342+
: {}
343+
}
344+
/>
345+
)}
324346
</DialogContent>
325347
<DialogActions>
326348
<Button onClick={() => props.setOpen(false)}>Cancel</Button>

0 commit comments

Comments
 (0)