mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
SSO Config: Add generic OAuth (#79972)
* Setup route * Set up the page * Add orgs * Load settings * Make API call * Remove log * Add FormPrompt * Update types * Add tests * Fix tests * Cleanup * Load settings * Fix naming * Switch to PUT endpoint * Switch to CSS object * Setup fields * Render fields * Extend types * Dynamic provider page * Rename page * Filter out non-implemented providers * Fix types * Add teamIDs validation * Update tests * Fix URL * Update name * Send full data * Add password input * Update test * Expand default values * Fix test * Use SecretInput * Remove dev mode for the feature toggle * Convert fields * Remove fieldFormat utils * Update fields logic * Update tests * Update betterer * SSO: Add Generic OAuth page * SSO: Add Generic OAuth page * SSO: Make client secret not required * Update field name * Revert feature toggle to dev mode * Use provider endpoint * Fix form state check * Update tests * Fix URL redirect after form submit * Mock locationService * Separate Form component * Update fields * Add more fields * Add more fields * Fix spacing * Add UserMapping fields * Add rest of the fields * Add FieldRenderer * Update types * Update comment * Update feature toggle * Add checkbox * Do not submit form if there are errors * Fix revalidation * Redirect on success only * Fix redirect behavior * Add missing descriptions * Use inline checkbox * Add enabled field * Restore feature toggle * Remove source field from PUT request * Add URL to the fields * Add hidden prop to fields and sections * Add Delete button * Prettier * Add authStyle, still not working, description updates * Fix saving select values * Run prettier * Use defaultValue in Select field --------- Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com>
This commit is contained in:
parent
ec3207a943
commit
370fd5a5af
@ -12,7 +12,7 @@ export interface CheckboxProps extends Omit<HTMLProps<HTMLInputElement>, 'value'
|
||||
/** Label to display next to checkbox */
|
||||
label?: string;
|
||||
/** Description to display under the label */
|
||||
description?: string;
|
||||
description?: string | React.ReactElement;
|
||||
/** Current value of the checkbox */
|
||||
value?: boolean;
|
||||
/** htmlValue allows to specify the input "value" attribute */
|
||||
|
@ -75,8 +75,8 @@ export const AuthConfigPageUnconnected = ({
|
||||
) : (
|
||||
<Grid gap={3} minColumnWidth={34}>
|
||||
{providerList
|
||||
// Temporarily filter providers that don't have the UI implemented
|
||||
.filter(({ provider }) => !['grafana_com', 'generic_oauth'].includes(provider))
|
||||
// Temporarily filter out providers that don't have the UI implemented
|
||||
.filter(({ provider }) => !['grafana_com'].includes(provider))
|
||||
.map(({ provider, settings }) => (
|
||||
<ProviderCard
|
||||
key={provider}
|
||||
|
149
public/app/features/auth-config/FieldRenderer.tsx
Normal file
149
public/app/features/auth-config/FieldRenderer.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
|
||||
import { Checkbox, Field, Input, InputControl, SecretInput, Select, Switch, useTheme2 } from '@grafana/ui';
|
||||
|
||||
import { fieldMap } from './fields';
|
||||
import { SSOProviderDTO, SSOSettingsField } from './types';
|
||||
import { isSelectableValue } from './utils/guards';
|
||||
|
||||
interface FieldRendererProps
|
||||
extends Pick<UseFormReturn<SSOProviderDTO>, 'register' | 'control' | 'watch' | 'setValue' | 'unregister'> {
|
||||
field: SSOSettingsField;
|
||||
errors: UseFormReturn['formState']['errors'];
|
||||
secretConfigured: boolean;
|
||||
}
|
||||
|
||||
export const FieldRenderer = ({
|
||||
field,
|
||||
register,
|
||||
errors,
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
unregister,
|
||||
secretConfigured,
|
||||
}: FieldRendererProps) => {
|
||||
const [isSecretConfigured, setIsSecretConfigured] = useState(secretConfigured);
|
||||
const isDependantField = typeof field !== 'string';
|
||||
const name = isDependantField ? field.name : field;
|
||||
const parentValue = isDependantField ? watch(field.dependsOn) : null;
|
||||
const fieldData = fieldMap[name];
|
||||
const theme = useTheme2();
|
||||
// Unregister a field that depends on a toggle to clear its data
|
||||
useEffect(() => {
|
||||
if (isDependantField) {
|
||||
if (!parentValue) {
|
||||
unregister(name);
|
||||
}
|
||||
}
|
||||
}, [unregister, name, parentValue, isDependantField]);
|
||||
|
||||
if (!field) {
|
||||
console.log('missing field:', name);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Dependant field means the field depends on another field's value and shouldn't be rendered if the parent field is false
|
||||
if (isDependantField) {
|
||||
const parentValue = watch(field.dependsOn);
|
||||
if (!parentValue) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const fieldProps = {
|
||||
label: fieldData.label,
|
||||
required: !!fieldData.validation?.required,
|
||||
invalid: !!errors[name],
|
||||
error: fieldData.validation?.message,
|
||||
key: name,
|
||||
description: fieldData.description,
|
||||
defaultValue: fieldData.defaultValue,
|
||||
};
|
||||
|
||||
switch (fieldData.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Field {...fieldProps}>
|
||||
<Input
|
||||
{...register(name, { required: !!fieldData.validation?.required })}
|
||||
type={fieldData.type}
|
||||
id={name}
|
||||
autoComplete={'off'}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
case 'secret':
|
||||
return (
|
||||
<Field {...fieldProps} htmlFor={name}>
|
||||
<InputControl
|
||||
name={name}
|
||||
control={control}
|
||||
rules={fieldData.validation}
|
||||
render={({ field: { ref, value, ...field } }) => (
|
||||
<SecretInput
|
||||
{...field}
|
||||
autoComplete={'off'}
|
||||
id={name}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
isConfigured={isSecretConfigured}
|
||||
onReset={() => {
|
||||
setIsSecretConfigured(false);
|
||||
setValue(name, '');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
case 'select':
|
||||
const watchOptions = watch(name);
|
||||
let options = fieldData.options;
|
||||
|
||||
if (!fieldData.options?.length) {
|
||||
options = isSelectableValue(watchOptions) ? watchOptions : [{ label: '', value: '' }];
|
||||
}
|
||||
return (
|
||||
<Field {...fieldProps} htmlFor={name}>
|
||||
<InputControl
|
||||
rules={fieldData.validation}
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => {
|
||||
return (
|
||||
<Select
|
||||
{...fieldProps}
|
||||
placeholder={fieldData.placeholder}
|
||||
isMulti={fieldData.multi}
|
||||
invalid={invalid}
|
||||
inputId={name}
|
||||
options={options}
|
||||
allowCustomValue={!!fieldData.allowCustomValue}
|
||||
defaultValue={fieldData.defaultValue}
|
||||
onChange={onChange}
|
||||
onCreateOption={(v) => {
|
||||
const customValue = { value: v, label: v };
|
||||
onChange([...(options || []), customValue]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
case 'switch':
|
||||
return (
|
||||
<Field {...fieldProps}>
|
||||
<Switch {...register(name)} id={name} />
|
||||
</Field>
|
||||
);
|
||||
case 'checkbox':
|
||||
return (
|
||||
<Checkbox {...register(name)} id={name} {...fieldProps} className={css({ marginBottom: theme.spacing(2) })} />
|
||||
);
|
||||
default:
|
||||
console.error(`Unknown field type: ${fieldData.type}`);
|
||||
return null;
|
||||
}
|
||||
};
|
@ -34,6 +34,7 @@ jest.mock('app/core/components/FormPrompt/FormPrompt', () => ({
|
||||
}));
|
||||
|
||||
const testConfig: SSOProvider = {
|
||||
id: '300f9b7c-0488-40db-9763-a22ce8bf6b3e',
|
||||
provider: 'github',
|
||||
settings: {
|
||||
...emptySettings,
|
||||
|
@ -1,17 +1,17 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { AppEvents } from '@grafana/data';
|
||||
import { getAppEvents, getBackendSrv, isFetchError, locationService } from '@grafana/runtime';
|
||||
import { Button, Field, Input, InputControl, LinkButton, SecretInput, Select, Stack, Switch } from '@grafana/ui';
|
||||
import { Box, Button, CollapsableSection, ConfirmModal, Field, LinkButton, Stack, Switch } from '@grafana/ui';
|
||||
|
||||
import { FormPrompt } from '../../core/components/FormPrompt/FormPrompt';
|
||||
import { Page } from '../../core/components/Page/Page';
|
||||
|
||||
import { fieldMap, fields } from './fields';
|
||||
import { FieldData, SSOProvider, SSOProviderDTO } from './types';
|
||||
import { FieldRenderer } from './FieldRenderer';
|
||||
import { fields, sectionFields } from './fields';
|
||||
import { SSOProvider, SSOProviderDTO } from './types';
|
||||
import { dataToDTO, dtoToData } from './utils/data';
|
||||
import { isSelectableValue } from './utils/guards';
|
||||
|
||||
const appEvents = getAppEvents();
|
||||
|
||||
@ -29,19 +29,15 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
unregister,
|
||||
formState: { errors, dirtyFields, isSubmitted },
|
||||
} = useForm({ defaultValues: dataToDTO(config) });
|
||||
} = useForm({ defaultValues: dataToDTO(config), reValidateMode: 'onSubmit' });
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSecretConfigured, setIsSecretConfigured] = useState(!!config?.settings.clientSecret);
|
||||
const providerFields = fields[provider];
|
||||
const [submitError, setSubmitError] = useState(false);
|
||||
const dataSubmitted = isSubmitted && !submitError;
|
||||
|
||||
useEffect(() => {
|
||||
if (dataSubmitted) {
|
||||
locationService.push(`/admin/authentication`);
|
||||
}
|
||||
}, [dataSubmitted]);
|
||||
const sections = sectionFields[provider];
|
||||
const [resetConfig, setResetConfig] = useState(false);
|
||||
|
||||
const onSubmit = async (data: SSOProviderDTO) => {
|
||||
setIsSaving(true);
|
||||
@ -49,7 +45,8 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
|
||||
const requestData = dtoToData(data);
|
||||
try {
|
||||
await getBackendSrv().put(`/api/v1/sso-settings/${provider}`, {
|
||||
...config,
|
||||
id: config?.id,
|
||||
provider: config?.provider,
|
||||
settings: { ...config?.settings, ...requestData },
|
||||
});
|
||||
|
||||
@ -57,6 +54,11 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: ['Settings saved'],
|
||||
});
|
||||
reset(data);
|
||||
// Delay redirect so the form state can update
|
||||
setTimeout(() => {
|
||||
locationService.push(`/admin/authentication`);
|
||||
}, 300);
|
||||
} catch (error) {
|
||||
let message = '';
|
||||
if (isFetchError(error)) {
|
||||
@ -74,128 +76,134 @@ export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConf
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (name: keyof SSOProvider['settings'], fieldData: FieldData) => {
|
||||
switch (fieldData.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<Field
|
||||
label={fieldData.label}
|
||||
required={!!fieldData.validation?.required}
|
||||
invalid={!!errors[name]}
|
||||
error={fieldData.validation?.message}
|
||||
key={name}
|
||||
>
|
||||
<Input
|
||||
{...register(name, { required: !!fieldData.validation?.required })}
|
||||
type={fieldData.type}
|
||||
id={name}
|
||||
autoComplete={'off'}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
case 'secret':
|
||||
return (
|
||||
<Field
|
||||
label={fieldData.label}
|
||||
required={!!fieldData.validation?.required}
|
||||
invalid={!!errors[name]}
|
||||
error={fieldData.validation?.message}
|
||||
key={name}
|
||||
htmlFor={name}
|
||||
>
|
||||
<InputControl
|
||||
name={name}
|
||||
control={control}
|
||||
rules={fieldData.validation}
|
||||
render={({ field: { ref, value, ...field } }) => (
|
||||
<SecretInput
|
||||
{...field}
|
||||
autoComplete={'off'}
|
||||
id={name}
|
||||
value={typeof value === 'string' ? value : ''}
|
||||
isConfigured={isSecretConfigured}
|
||||
onReset={() => {
|
||||
setIsSecretConfigured(false);
|
||||
setValue(name, '');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
case 'select':
|
||||
const watchOptions = watch(name);
|
||||
const options = isSelectableValue(watchOptions) ? watchOptions : [{ label: '', value: '' }];
|
||||
return (
|
||||
<Field
|
||||
label={fieldData.label}
|
||||
htmlFor={name}
|
||||
key={name}
|
||||
invalid={!!errors[name]}
|
||||
error={fieldData.validation?.message}
|
||||
>
|
||||
<InputControl
|
||||
rules={fieldData.validation}
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field: { ref, onChange, ...fieldProps }, fieldState: { invalid } }) => {
|
||||
return (
|
||||
<Select
|
||||
{...fieldProps}
|
||||
placeholder={fieldData.placeholder}
|
||||
isMulti={fieldData.multi}
|
||||
invalid={invalid}
|
||||
inputId={name}
|
||||
options={options}
|
||||
allowCustomValue
|
||||
onChange={onChange}
|
||||
onCreateOption={(v) => {
|
||||
const customValue = { value: v, label: v };
|
||||
onChange([...options, customValue]);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unknown field type: ${fieldData.type}`);
|
||||
const onResetConfig = async () => {
|
||||
try {
|
||||
await getBackendSrv().delete(`/api/v1/sso-settings/${provider}`);
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertSuccess.name,
|
||||
payload: ['Settings reset to defaults'],
|
||||
});
|
||||
setTimeout(() => {
|
||||
locationService.push(`/admin/authentication`);
|
||||
});
|
||||
} catch (error) {
|
||||
let message = '';
|
||||
if (isFetchError(error)) {
|
||||
message = error.data.message;
|
||||
} else if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
appEvents.publish({
|
||||
type: AppEvents.alertError.name,
|
||||
payload: [message],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<Stack grow={1} direction={'column'}>
|
||||
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}>
|
||||
<>
|
||||
<FormPrompt
|
||||
// TODO Figure out why isDirty is not working
|
||||
confirmRedirect={!!Object.keys(dirtyFields).length && !dataSubmitted}
|
||||
onDiscard={() => {
|
||||
reset();
|
||||
}}
|
||||
/>
|
||||
<Field label="Enabled">
|
||||
<Switch {...register('enabled')} id="enabled" label={'Enabled'} />
|
||||
</Field>
|
||||
{providerFields.map((fieldName) => {
|
||||
const field = fieldMap[fieldName];
|
||||
return renderField(fieldName, field);
|
||||
})}
|
||||
<Stack gap={2}>
|
||||
<Field>
|
||||
<Button type={'submit'}>{isSaving ? 'Saving...' : 'Save'}</Button>
|
||||
</Field>
|
||||
<Field>
|
||||
<LinkButton href={'/admin/authentication'} variant={'secondary'}>
|
||||
Discard
|
||||
</LinkButton>
|
||||
</Field>
|
||||
<form onSubmit={handleSubmit(onSubmit)} style={{ maxWidth: '600px' }}>
|
||||
<>
|
||||
<FormPrompt
|
||||
confirmRedirect={!!Object.keys(dirtyFields).length && !dataSubmitted}
|
||||
onDiscard={() => {
|
||||
reset();
|
||||
}}
|
||||
/>
|
||||
<Field label="Enabled">
|
||||
<Switch {...register('enabled')} id="enabled" label={'Enabled'} />
|
||||
</Field>
|
||||
{sections ? (
|
||||
<Stack gap={2} direction={'column'}>
|
||||
{sections
|
||||
.filter((section) => !section.hidden)
|
||||
.map((section, index) => {
|
||||
return (
|
||||
<CollapsableSection label={section.name} isOpen={index === 0} key={section.name}>
|
||||
{section.fields
|
||||
.filter((field) => (typeof field !== 'string' ? !field.hidden : true))
|
||||
.map((field) => {
|
||||
return (
|
||||
<FieldRenderer
|
||||
key={typeof field === 'string' ? field : field.name}
|
||||
field={field}
|
||||
control={control}
|
||||
errors={errors}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
watch={watch}
|
||||
unregister={unregister}
|
||||
secretConfigured={!!config?.settings.clientSecret}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</CollapsableSection>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</>
|
||||
</form>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{providerFields.map((field) => {
|
||||
return (
|
||||
<FieldRenderer
|
||||
key={field}
|
||||
field={field}
|
||||
control={control}
|
||||
errors={errors}
|
||||
setValue={setValue}
|
||||
register={register}
|
||||
watch={watch}
|
||||
unregister={unregister}
|
||||
secretConfigured={!!config?.settings.clientSecret}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
<Box display={'flex'} gap={2} marginTop={6}>
|
||||
<Field>
|
||||
<Button type={'submit'}>{isSaving ? 'Saving...' : 'Save'}</Button>
|
||||
</Field>
|
||||
<Field>
|
||||
<LinkButton href={'/admin/authentication'} variant={'secondary'}>
|
||||
Discard
|
||||
</LinkButton>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button
|
||||
variant={'secondary'}
|
||||
onClick={(event) => {
|
||||
setResetConfig(true);
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</Field>
|
||||
</Box>
|
||||
</>
|
||||
</form>
|
||||
{resetConfig && (
|
||||
<ConfirmModal
|
||||
isOpen
|
||||
icon="trash-alt"
|
||||
title="Reset"
|
||||
body={
|
||||
<Stack direction={'column'} gap={3}>
|
||||
<span>Are you sure you want to reset this configuration?</span>
|
||||
<small>
|
||||
After resetting these settings Grafana will use the provider configuration from the system (config
|
||||
file/environment variables) if any.
|
||||
</small>
|
||||
</Stack>
|
||||
}
|
||||
confirmText="Reset"
|
||||
onDismiss={() => setResetConfig(false)}
|
||||
onConfirm={async () => {
|
||||
await onResetConfig();
|
||||
setResetConfig(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Page.Contents>
|
||||
);
|
||||
};
|
||||
|
@ -1,120 +0,0 @@
|
||||
import { FieldData, SSOProvider } from './types';
|
||||
import { isSelectableValue } from './utils/guards';
|
||||
|
||||
/** Map providers to their settings */
|
||||
export const fields: Record<SSOProvider['provider'], Array<keyof SSOProvider['settings']>> = {
|
||||
github: ['clientId', 'clientSecret', 'teamIds', 'allowedOrganizations'],
|
||||
google: ['clientId', 'clientSecret', 'allowedDomains'],
|
||||
gitlab: ['clientId', 'clientSecret', 'allowedOrganizations', 'teamIds'],
|
||||
azuread: ['clientId', 'clientSecret', 'authUrl', 'tokenUrl', 'scopes', 'allowedGroups', 'allowedDomains'],
|
||||
okta: [
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'authUrl',
|
||||
'tokenUrl',
|
||||
'apiUrl',
|
||||
'roleAttributePath',
|
||||
'allowedGroups',
|
||||
'allowedDomains',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* List all the fields that can be used in the form
|
||||
*/
|
||||
export const fieldMap: Record<string, FieldData> = {
|
||||
clientId: {
|
||||
label: 'Client Id',
|
||||
type: 'text',
|
||||
validation: {
|
||||
required: true,
|
||||
message: 'This field is required',
|
||||
},
|
||||
},
|
||||
clientSecret: {
|
||||
label: 'Client Secret',
|
||||
type: 'secret',
|
||||
},
|
||||
teamIds: {
|
||||
label: 'Team Ids',
|
||||
type: 'select',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
placeholder: 'Enter team IDs and press Enter to add',
|
||||
validation: {
|
||||
validate: (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return isNumeric(value);
|
||||
}
|
||||
if (isSelectableValue(value)) {
|
||||
return value.every((v) => v?.value && isNumeric(v.value));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
message: 'Team ID must be a number.',
|
||||
},
|
||||
},
|
||||
allowedOrganizations: {
|
||||
label: 'Allowed Organizations',
|
||||
type: 'select',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
placeholder: 'Enter organizations (my-team, myteam...) and press Enter to add',
|
||||
},
|
||||
allowedDomains: {
|
||||
label: 'Allowed Domains',
|
||||
type: 'select',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
},
|
||||
authUrl: {
|
||||
label: 'Auth Url',
|
||||
type: 'text',
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
tokenUrl: {
|
||||
label: 'Token Url',
|
||||
type: 'text',
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
scopes: {
|
||||
label: 'Scopes',
|
||||
type: 'select',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
},
|
||||
allowedGroups: {
|
||||
label: 'Allowed Groups',
|
||||
type: 'select',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
},
|
||||
apiUrl: {
|
||||
label: 'API Url',
|
||||
type: 'text',
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
roleAttributePath: {
|
||||
label: 'Role Attribute Path',
|
||||
type: 'text',
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Check if a string contains only numeric values
|
||||
function isNumeric(value: string) {
|
||||
return /^-?\d+$/.test(value);
|
||||
}
|
372
public/app/features/auth-config/fields.tsx
Normal file
372
public/app/features/auth-config/fields.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
import React from 'react';
|
||||
|
||||
import { TextLink } from '@grafana/ui';
|
||||
|
||||
import { FieldData, SSOProvider, SSOSettingsField } from './types';
|
||||
import { isSelectableValue } from './utils/guards';
|
||||
|
||||
/** Map providers to their settings */
|
||||
export const fields: Record<SSOProvider['provider'], Array<keyof SSOProvider['settings']>> = {
|
||||
github: ['clientId', 'clientSecret', 'teamIds', 'allowedOrganizations'],
|
||||
google: ['clientId', 'clientSecret', 'allowedDomains'],
|
||||
gitlab: ['clientId', 'clientSecret', 'allowedOrganizations', 'teamIds'],
|
||||
azuread: ['clientId', 'clientSecret', 'authUrl', 'tokenUrl', 'scopes', 'allowedGroups', 'allowedDomains'],
|
||||
okta: [
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'authUrl',
|
||||
'tokenUrl',
|
||||
'apiUrl',
|
||||
'roleAttributePath',
|
||||
'allowedGroups',
|
||||
'allowedDomains',
|
||||
],
|
||||
};
|
||||
|
||||
type Section = Record<
|
||||
SSOProvider['provider'],
|
||||
Array<{
|
||||
name: string;
|
||||
id: string;
|
||||
hidden?: boolean;
|
||||
fields: SSOSettingsField[];
|
||||
}>
|
||||
>;
|
||||
|
||||
export const sectionFields: Section = {
|
||||
generic_oauth: [
|
||||
{
|
||||
name: 'General settings',
|
||||
id: 'general',
|
||||
fields: [
|
||||
'name',
|
||||
'clientId',
|
||||
'clientSecret',
|
||||
'authStyle',
|
||||
'scopes',
|
||||
'authUrl',
|
||||
'tokenUrl',
|
||||
'apiUrl',
|
||||
'allowSignUp',
|
||||
'autoLogin',
|
||||
'signoutRedirectUrl',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'User mapping',
|
||||
id: 'user',
|
||||
fields: [
|
||||
'nameAttributePath',
|
||||
'loginAttributePath',
|
||||
'emailAttributeName',
|
||||
'emailAttributePath',
|
||||
'idTokenAttributeName',
|
||||
'roleAttributePath',
|
||||
'roleAttributeStrict',
|
||||
'allowAssignGrafanaAdmin',
|
||||
'skipOrgRoleSync',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Extra security measures',
|
||||
id: 'extra',
|
||||
fields: [
|
||||
'allowedOrganizations',
|
||||
'allowedDomains',
|
||||
'defineAllowedGroups',
|
||||
{ name: 'allowedGroups', dependsOn: 'defineAllowedGroups' },
|
||||
{ name: 'groupsAttributePath', dependsOn: 'defineAllowedGroups' },
|
||||
'defineAllowedTeamsIds',
|
||||
{ name: 'teamIds', dependsOn: 'defineAllowedTeamsIds' },
|
||||
{ name: 'teamsUrl', dependsOn: 'defineAllowedTeamsIds' },
|
||||
{ name: 'teamIdsAttributePath', dependsOn: 'defineAllowedTeamsIds' },
|
||||
'usePkce',
|
||||
'useRefreshToken',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'TLS',
|
||||
id: 'tls',
|
||||
fields: ['configureTLS', 'tlsSkipVerifyInsecure', 'tlsClientCert', 'tlsClientKey', 'tlsClientCa'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* List all the fields that can be used in the form
|
||||
*/
|
||||
export const fieldMap: Record<string, FieldData> = {
|
||||
clientId: {
|
||||
label: 'Client Id',
|
||||
type: 'text',
|
||||
description: 'The client ID of your OAuth2 app.',
|
||||
validation: {
|
||||
required: true,
|
||||
message: 'This field is required',
|
||||
},
|
||||
},
|
||||
clientSecret: {
|
||||
label: 'Client Secret',
|
||||
type: 'secret',
|
||||
description: 'The client secret of your OAuth2 app.',
|
||||
},
|
||||
teamIds: {
|
||||
label: 'Team Ids',
|
||||
type: 'select',
|
||||
description:
|
||||
'String list of team IDs. If set, the user must be a member of one of the given teams to log in. \n' +
|
||||
'If you configure team_ids, you must also configure teams_url and team_ids_attribute_path.',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
placeholder: 'Enter team IDs and press Enter to add',
|
||||
validation: {
|
||||
validate: (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return isNumeric(value);
|
||||
}
|
||||
if (isSelectableValue(value)) {
|
||||
return value.every((v) => v?.value && isNumeric(v.value));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
message: 'Team ID must be a number.',
|
||||
},
|
||||
},
|
||||
allowedOrganizations: {
|
||||
label: 'Allowed Organizations',
|
||||
type: 'select',
|
||||
description:
|
||||
'List of comma- or space-separated organizations. The user should be a member \n' +
|
||||
'of at least one organization to log in.',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
placeholder: 'Enter organizations (my-team, myteam...) and press Enter to add',
|
||||
},
|
||||
allowedDomains: {
|
||||
label: 'Allowed Domains',
|
||||
type: 'select',
|
||||
description:
|
||||
'List of comma- or space-separated domains. The user should belong to at least \n' + 'one domain to log in.',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
},
|
||||
authUrl: {
|
||||
label: 'Auth Url',
|
||||
type: 'text',
|
||||
description: 'The authorization endpoint of your OAuth2 provider.',
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
authStyle: {
|
||||
label: 'Auth Style',
|
||||
type: 'select',
|
||||
description: 'It determines how client_id and client_secret are sent to Oauth2 provider. Default is AutoDetect.',
|
||||
multi: false,
|
||||
options: [
|
||||
{ value: 'AutoDetect', label: 'AutoDetect' },
|
||||
{ value: 'InParams', label: 'InParams' },
|
||||
{ value: 'InHeader', label: 'InHeader' },
|
||||
],
|
||||
defaultValue: 'AutoDetect',
|
||||
},
|
||||
tokenUrl: {
|
||||
label: 'Token Url',
|
||||
type: 'text',
|
||||
description: 'The token endpoint of your OAuth2 provider.',
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
scopes: {
|
||||
label: 'Scopes',
|
||||
type: 'select',
|
||||
description: 'List of comma- or space-separated OAuth2 scopes.',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
},
|
||||
allowedGroups: {
|
||||
label: 'Allowed Groups',
|
||||
type: 'select',
|
||||
description:
|
||||
'List of comma- or space-separated groups. The user should be a member of \n' +
|
||||
'at least one group to log in. If you configure allowed_groups, you must also configure \n' +
|
||||
'groups_attribute_path.',
|
||||
multi: true,
|
||||
allowCustomValue: true,
|
||||
options: [],
|
||||
},
|
||||
apiUrl: {
|
||||
label: 'API Url',
|
||||
type: 'text',
|
||||
description: (
|
||||
<>
|
||||
The user information endpoint of your OAuth2 provider. Information returned by this endpoint must be compatible
|
||||
with{' '}
|
||||
<TextLink href={'https://connect2id.com/products/server/docs/api/userinfo'} external variant={'bodySmall'}>
|
||||
OpenID UserInfo
|
||||
</TextLink>
|
||||
.
|
||||
</>
|
||||
),
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
roleAttributePath: {
|
||||
label: 'Role Attribute Path',
|
||||
description: 'JMESPath expression to use for Grafana role lookup.',
|
||||
type: 'text',
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
name: {
|
||||
label: 'Display name',
|
||||
description: 'Helpful if you use more than one identity providers or SSO protocols.',
|
||||
type: 'text',
|
||||
},
|
||||
allowSignUp: {
|
||||
label: 'Allow sign up',
|
||||
description: 'If not enabled, only existing Grafana users can log in using OAuth.',
|
||||
type: 'switch',
|
||||
},
|
||||
autoLogin: {
|
||||
label: 'Auto login',
|
||||
description: 'Log in automatically, skipping the login screen.',
|
||||
type: 'switch',
|
||||
},
|
||||
signoutRedirectUrl: {
|
||||
label: 'Sign out redirect URL',
|
||||
description: 'The URL to redirect the user to after signing out from Grafana.',
|
||||
type: 'text',
|
||||
validation: {
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
emailAttributeName: {
|
||||
label: 'Email attribute name',
|
||||
description: 'Name of the key to use for user email lookup within the attributes map of OAuth2 ID token.',
|
||||
type: 'text',
|
||||
},
|
||||
emailAttributePath: {
|
||||
label: 'Email attribute path',
|
||||
description: 'JMESPath expression to use for user email lookup from the user information.',
|
||||
type: 'text',
|
||||
},
|
||||
nameAttributePath: {
|
||||
label: 'Name attribute path',
|
||||
description:
|
||||
'JMESPath expression to use for user name lookup from the user ID token. \n' +
|
||||
'This name will be used as the user’s display name.',
|
||||
type: 'text',
|
||||
},
|
||||
loginAttributePath: {
|
||||
label: 'Login attribute path',
|
||||
description: 'JMESPath expression to use for user login lookup from the user ID token.',
|
||||
type: 'text',
|
||||
},
|
||||
idTokenAttributeName: {
|
||||
label: 'ID token attribute name',
|
||||
description: 'The name of the key used to extract the ID token from the returned OAuth2 token.',
|
||||
type: 'text',
|
||||
},
|
||||
roleAttributeStrict: {
|
||||
label: 'Role attribute strict mode',
|
||||
description: 'If enabled, denies user login if the Grafana role cannot be extracted using Role attribute path.',
|
||||
type: 'switch',
|
||||
},
|
||||
allowAssignGrafanaAdmin: {
|
||||
label: 'Allow assign Grafana admin',
|
||||
description: 'If enabled, it will automatically sync the Grafana server administrator role.',
|
||||
type: 'switch',
|
||||
},
|
||||
skipOrgRoleSync: {
|
||||
label: 'Skip organization role sync',
|
||||
description: 'Prevent synchronizing users’ organization roles from your IdP.',
|
||||
type: 'switch',
|
||||
},
|
||||
defineAllowedGroups: {
|
||||
label: 'Define Allowed Groups',
|
||||
type: 'switch',
|
||||
},
|
||||
defineAllowedTeamsIds: {
|
||||
label: 'Define Allowed Teams Ids',
|
||||
type: 'switch',
|
||||
},
|
||||
usePkce: {
|
||||
label: 'Use Pkce',
|
||||
description: (
|
||||
<>
|
||||
If enabled, Grafana will use{' '}
|
||||
<TextLink external variant={'bodySmall'} href={'https://datatracker.ietf.org/doc/html/rfc7636'}>
|
||||
Proof Key for Code Exchange (PKCE)
|
||||
</TextLink>{' '}
|
||||
with the OAuth2 Authorization Code Grant.
|
||||
</>
|
||||
),
|
||||
type: 'checkbox',
|
||||
},
|
||||
useRefreshToken: {
|
||||
label: 'Use Refresh Token',
|
||||
description:
|
||||
'If enabled, Grafana will fetch a new access token using the refresh token provided by the OAuth2 provider.',
|
||||
type: 'checkbox',
|
||||
},
|
||||
configureTLS: {
|
||||
label: 'Configure TLS',
|
||||
type: 'switch',
|
||||
},
|
||||
tlsClientCa: {
|
||||
label: 'TLS Client CA',
|
||||
description: 'The path to the trusted certificate authority list.',
|
||||
type: 'text',
|
||||
},
|
||||
tlsClientCert: {
|
||||
label: 'TLS Client Cert',
|
||||
description: 'The path to the certificate',
|
||||
type: 'text',
|
||||
},
|
||||
tlsClientKey: {
|
||||
label: 'TLS Client Key',
|
||||
description: 'The path to the key',
|
||||
type: 'text',
|
||||
},
|
||||
tlsSkipVerifyInsecure: {
|
||||
label: 'TLS Skip Verify',
|
||||
description:
|
||||
'If enabled, the client accepts any certificate presented by the server and any host \n' +
|
||||
'name in that certificate. You should only use this for testing, because this mode leaves \n' +
|
||||
'SSL/TLS susceptible to man-in-the-middle attacks.',
|
||||
type: 'switch',
|
||||
},
|
||||
groupsAttributePath: {
|
||||
label: 'Groups attribute path',
|
||||
description:
|
||||
'JMESPath expression to use for user group lookup. If you configure allowed_groups, \n' +
|
||||
'you must also configure groups_attribute_path.',
|
||||
type: 'text',
|
||||
},
|
||||
teamsUrl: {
|
||||
label: 'Teams URL',
|
||||
description:
|
||||
'The URL used to query for team IDs. If not set, the default value is /teams. \n' +
|
||||
'If you configure teams_url, you must also configure team_ids_attribute_path.',
|
||||
type: 'text',
|
||||
},
|
||||
teamIdsAttributePath: {
|
||||
label: 'Team IDs attribute path',
|
||||
description:
|
||||
'The JMESPath expression to use for Grafana team ID lookup within the results returned by the teams_url endpoint.',
|
||||
type: 'text',
|
||||
},
|
||||
};
|
||||
|
||||
// Check if a string contains only numeric values
|
||||
function isNumeric(value: string) {
|
||||
return /^-?\d+$/.test(value);
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { Validate } from 'react-hook-form';
|
||||
|
||||
import { IconName, SelectableValue } from '@grafana/data';
|
||||
import { Settings } from 'app/types';
|
||||
|
||||
export interface AuthProviderInfo {
|
||||
id: string;
|
||||
type: string;
|
||||
@ -17,7 +17,6 @@ export type GetStatusHook = () => Promise<AuthProviderStatus>;
|
||||
export type SSOProviderSettingsBase = {
|
||||
allowAssignGrafanaAdmin?: boolean;
|
||||
allowSignUp?: boolean;
|
||||
|
||||
apiUrl?: string;
|
||||
authStyle?: string;
|
||||
authUrl?: string;
|
||||
@ -45,12 +44,20 @@ export type SSOProviderSettingsBase = {
|
||||
tlsSkipVerify?: boolean;
|
||||
tokenUrl?: string;
|
||||
type: string;
|
||||
usePKCE?: boolean;
|
||||
usePkce?: boolean;
|
||||
useRefreshToken?: boolean;
|
||||
nameAttributePath?: string;
|
||||
loginAttributePath?: string;
|
||||
idTokenAttributeName?: string;
|
||||
defineAllowedGroups?: boolean;
|
||||
defineAllowedTeamsIds?: boolean;
|
||||
configureTLS?: boolean;
|
||||
tlsSkipVerifyInsecure?: boolean;
|
||||
};
|
||||
|
||||
// SSO data received from the API and sent to it
|
||||
export type SSOProvider = {
|
||||
id: string;
|
||||
provider: string;
|
||||
settings: SSOProviderSettingsBase & {
|
||||
teamIds: string;
|
||||
@ -96,6 +103,7 @@ export interface SettingsError {
|
||||
export type FieldData = {
|
||||
label: string;
|
||||
type: string;
|
||||
description?: string | ReactElement;
|
||||
validation?: {
|
||||
required?: boolean;
|
||||
message?: string;
|
||||
@ -105,4 +113,9 @@ export type FieldData = {
|
||||
allowCustomValue?: boolean;
|
||||
options?: Array<SelectableValue<string>>;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
export type SSOSettingsField =
|
||||
| keyof SSOProvider['settings']
|
||||
| { name: keyof SSOProvider['settings']; dependsOn: keyof SSOProvider['settings']; hidden?: boolean };
|
||||
|
@ -40,7 +40,7 @@ export const emptySettings: SSOProviderDTO = {
|
||||
tlsSkipVerify: false,
|
||||
tokenUrl: '',
|
||||
type: '',
|
||||
usePKCE: false,
|
||||
usePkce: false,
|
||||
useRefreshToken: false,
|
||||
};
|
||||
|
||||
@ -79,9 +79,14 @@ export function dtoToData(dto: SSOProviderDTO) {
|
||||
|
||||
for (const field of arrayFields) {
|
||||
const value = dto[field];
|
||||
if (value && isSelectableValue(value)) {
|
||||
//@ts-expect-error
|
||||
settings[field] = valuesToString(value);
|
||||
if (value) {
|
||||
if (isSelectableValue(value)) {
|
||||
//@ts-expect-error
|
||||
settings[field] = valuesToString(value);
|
||||
} else if (isSelectableValue([value])) {
|
||||
//@ts-expect-error
|
||||
settings[field] = value.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
@ -89,6 +94,6 @@ export function dtoToData(dto: SSOProviderDTO) {
|
||||
|
||||
export function getArrayFields(obj: Record<string, FieldData>): Array<keyof SSOProviderDTO> {
|
||||
return Object.entries(obj)
|
||||
.filter(([_, value]) => value.type === 'select' && value.multi === true)
|
||||
.filter(([_, value]) => value.type === 'select')
|
||||
.map(([key]) => key as keyof SSOProviderDTO);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user