SSO: Add GitHub auth configuration page (#78933)

* 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: 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

* Fix trailing slash
This commit is contained in:
Alex Khomenko 2023-12-21 15:26:42 +02:00 committed by GitHub
parent 05d1ce4026
commit fde8a00721
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 834 additions and 37 deletions

View File

@ -2350,6 +2350,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"]
],
"public/app/features/auth-config/utils/data.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/canvas/element.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import { Button } from '../Button';
import { Input } from '../Input/Input';
import { HorizontalGroup } from '../Layout/Layout';
import { Stack } from '../Layout/Stack/Stack';
export type Props = React.ComponentProps<typeof Input> & {
/** TRUE if the secret was already configured. (It is needed as often the backend doesn't send back the actual secret, only the information that it was configured) */
@ -15,13 +15,15 @@ export const CONFIGURED_TEXT = 'configured';
export const RESET_BUTTON_TEXT = 'Reset';
export const SecretInput = ({ isConfigured, onReset, ...props }: Props) => (
<HorizontalGroup>
<Stack>
{!isConfigured && <Input {...props} type="password" />}
{isConfigured && <Input {...props} type="text" disabled={true} value={CONFIGURED_TEXT} />}
{isConfigured && (
<Button onClick={onReset} variant="secondary">
{RESET_BUTTON_TEXT}
</Button>
<>
<Input {...props} type="text" disabled={true} value={CONFIGURED_TEXT} />
<Button onClick={onReset} variant="secondary">
{RESET_BUTTON_TEXT}
</Button>
</>
)}
</HorizontalGroup>
</Stack>
);

View File

@ -0,0 +1,115 @@
import { css } from '@emotion/css';
import history from 'history';
import React, { useEffect, useState } from 'react';
import { Prompt, Redirect } from 'react-router-dom';
import { Button, Modal } from '@grafana/ui';
export interface Props {
confirmRedirect?: boolean;
onDiscard: () => void;
/** Extra check to invoke when location changes.
* Could be useful in multistep forms where each step has a separate URL
*/
onLocationChange?: (location: history.Location) => void;
}
/**
* Component handling redirects when a form has unsaved changes.
* Page reloads are handled in useEffect via beforeunload event.
* URL navigation is handled by react-router's components since it does not trigger beforeunload event.
*/
export const FormPrompt = ({ confirmRedirect, onDiscard, onLocationChange }: Props) => {
const [modalIsOpen, setModalIsOpen] = useState(false);
const [blockedLocation, setBlockedLocation] = useState<history.Location | null>(null);
const [changesDiscarded, setChangesDiscarded] = useState(false);
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (confirmRedirect) {
e.preventDefault();
e.returnValue = '';
}
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
};
}, [confirmRedirect]);
// Returning 'false' from this function will prevent navigation to the next URL
const handleRedirect = (location: history.Location) => {
// Do not show the unsaved changes modal if only the URL params have changed
const currentPath = window.location.pathname;
const nextPath = location.pathname;
if (currentPath === nextPath) {
return true;
}
const locationChangeCheck = onLocationChange?.(location);
let blockRedirect = confirmRedirect && !changesDiscarded;
if (locationChangeCheck !== undefined) {
blockRedirect = blockRedirect && locationChangeCheck;
}
if (blockRedirect) {
setModalIsOpen(true);
setBlockedLocation(location);
return false;
}
if (locationChangeCheck) {
onDiscard();
}
return true;
};
const onBackToForm = () => {
setModalIsOpen(false);
setBlockedLocation(null);
};
const onDiscardChanges = () => {
setModalIsOpen(false);
setChangesDiscarded(true);
onDiscard();
};
return (
<>
<Prompt when={true} message={handleRedirect} />
{blockedLocation && changesDiscarded && <Redirect to={blockedLocation} />}
<UnsavedChangesModal isOpen={modalIsOpen} onDiscard={onDiscardChanges} onBackToForm={onBackToForm} />
</>
);
};
interface UnsavedChangesModalProps {
onDiscard: () => void;
onBackToForm: () => void;
isOpen: boolean;
}
const UnsavedChangesModal = ({ onDiscard, onBackToForm, isOpen }: UnsavedChangesModalProps) => {
return (
<Modal
isOpen={isOpen}
title="Leave page?"
onDismiss={onBackToForm}
icon="exclamation-triangle"
className={css({ width: '500px' })}
>
<h5>Changes that you made may not be saved.</h5>
<Modal.ButtonRow>
<Button variant="secondary" onClick={onBackToForm} fill="outline">
Continue editing
</Button>
<Button variant="destructive" onClick={onDiscard}>
Discard unsaved changes
</Button>
</Modal.ButtonRow>
</Modal>
);
};

View File

@ -74,17 +74,20 @@ export const AuthConfigPageUnconnected = ({
<ConfigureAuthCTA />
) : (
<Grid gap={3} minColumnWidth={34}>
{providerList.map(({ provider, settings }) => (
<ProviderCard
key={provider}
authType={settings.type || 'OAuth'}
providerId={provider}
displayName={provider}
enabled={settings.enabled}
onClick={() => onProviderCardClick(provider)}
configPath={settings.configPath}
/>
))}
{providerList
// Temporarily filter providers that don't have the UI implemented
.filter(({ provider }) => !['grafana_com', 'generic_oauth'].includes(provider))
.map(({ provider, settings }) => (
<ProviderCard
key={provider}
authType={settings.type || 'OAuth'}
providerId={provider}
enabled={settings.enabled}
onClick={() => onProviderCardClick(provider)}
//@ts-expect-error Remove legacy types
configPath={settings.configPath}
/>
))}
</Grid>
)}
</Page.Contents>

View File

@ -0,0 +1,116 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { JSX } from 'react';
import { ProviderConfigForm } from './ProviderConfigForm';
import { SSOProvider } from './types';
import { emptySettings } from './utils/data';
const putMock = jest.fn(() => Promise.resolve({}));
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({
put: putMock,
}),
config: {
panels: {
test: {
id: 'test',
name: 'test',
},
},
},
getAppEvents: () => ({
publish: jest.fn(),
}),
isFetchError: () => true,
locationService: {
push: jest.fn(),
},
}));
// Mock the FormPrompt component as it requires Router setup to work
jest.mock('app/core/components/FormPrompt/FormPrompt', () => ({
FormPrompt: () => <></>,
}));
const testConfig: SSOProvider = {
provider: 'github',
settings: {
...emptySettings,
name: 'GitHub',
type: 'OAuth',
clientId: '12345',
clientSecret: 'abcde',
enabled: true,
teamIds: '',
allowedOrganizations: '',
allowedDomains: '',
allowedGroups: '',
scopes: '',
},
};
const emptyConfig = {
...testConfig,
settings: { ...testConfig.settings, clientId: '', clientSecret: '' },
};
function setup(jsx: JSX.Element) {
return {
user: userEvent.setup(),
...render(jsx),
};
}
describe('ProviderConfigForm', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders all fields correctly', async () => {
setup(<ProviderConfigForm config={testConfig} provider={testConfig.provider} />);
expect(screen.getByRole('checkbox', { name: /Enabled/i })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: /Client ID/i })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /Team IDs/i })).toBeInTheDocument();
expect(screen.getByRole('combobox', { name: /Allowed organizations/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Save/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Discard/i })).toBeInTheDocument();
});
it('should save correct data on form submit', async () => {
const { user } = setup(<ProviderConfigForm config={emptyConfig} provider={emptyConfig.provider} />);
await user.type(screen.getByRole('textbox', { name: /Client ID/i }), 'test-client-id');
await user.type(screen.getByLabelText(/Client secret/i), 'test-client-secret');
// Type a team name and press enter to select it
await user.type(screen.getByRole('combobox', { name: /Team IDs/i }), '12324{enter}');
// Add two orgs
await user.type(screen.getByRole('combobox', { name: /Allowed organizations/i }), 'test-org1{enter}');
await user.type(screen.getByRole('combobox', { name: /Allowed organizations/i }), 'test-org2{enter}');
await user.click(screen.getByRole('button', { name: /Save/i }));
await waitFor(() => {
expect(putMock).toHaveBeenCalledWith('/api/v1/sso-settings/github', {
...testConfig,
settings: {
...testConfig.settings,
allowedOrganizations: 'test-org1,test-org2',
clientId: 'test-client-id',
clientSecret: 'test-client-secret',
teamIds: '12324',
enabled: true,
allowedDomains: '',
allowedGroups: '',
scopes: '',
},
});
});
});
it('should validate required fields', async () => {
const { user } = setup(<ProviderConfigForm config={emptyConfig} provider={emptyConfig.provider} />);
await user.click(screen.getByRole('button', { name: /Save/i }));
// Should show an alert for empty client ID
expect(await screen.findAllByRole('alert')).toHaveLength(1);
});
});

View File

@ -0,0 +1,201 @@
import React, { useEffect, 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 { 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 { dataToDTO, dtoToData } from './utils/data';
import { isSelectableValue } from './utils/guards';
const appEvents = getAppEvents();
interface ProviderConfigProps {
config?: SSOProvider;
isLoading?: boolean;
provider: string;
}
export const ProviderConfigForm = ({ config, provider, isLoading }: ProviderConfigProps) => {
const {
register,
handleSubmit,
control,
reset,
watch,
setValue,
formState: { errors, dirtyFields, isSubmitted },
} = useForm({ defaultValues: dataToDTO(config) });
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 onSubmit = async (data: SSOProviderDTO) => {
setIsSaving(true);
setSubmitError(false);
const requestData = dtoToData(data);
try {
await getBackendSrv().put(`/api/v1/sso-settings/${provider}`, {
...config,
settings: { ...config?.settings, ...requestData },
});
appEvents.publish({
type: AppEvents.alertSuccess.name,
payload: ['Settings saved'],
});
} 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],
});
setSubmitError(true);
} finally {
setIsSaving(false);
}
};
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}`);
}
};
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>
</Stack>
</>
</form>
</Stack>
</Page.Contents>
);
};

View File

@ -0,0 +1,72 @@
import React, { useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { NavModelItem } from '@grafana/data';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { StoreState } from '../../types';
import { ProviderConfigForm } from './ProviderConfigForm';
import { loadProviders } from './state/actions';
import { SSOProvider } from './types';
const getPageNav = (config?: SSOProvider): NavModelItem => {
if (!config) {
return {
text: 'Authentication',
subTitle: 'Configure authentication providers',
icon: 'shield',
id: 'authentication',
};
}
return {
text: config.settings.name || '',
subTitle: `To configure ${config.settings.name} OAuth2 you must register your application with ${config.settings.name}. ${config.settings.name} will generate a Client ID and Client Secret for you to use.`,
icon: config.settings.icon || 'shield',
id: config.provider,
};
};
interface RouteProps extends GrafanaRouteComponentProps<{ provider: string }> {}
function mapStateToProps(state: StoreState, props: RouteProps) {
const { isLoading, providers } = state.authConfig;
const { provider } = props.match.params;
const config = providers.find((config) => config.provider === provider);
return {
config,
isLoading,
provider,
};
}
const mapDispatchToProps = {
loadProviders,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = ConnectedProps<typeof connector>;
/**
* Separate the Page logic from the Content logic for easier testing.
*/
export const ProviderConfigPage = ({ config, loadProviders, isLoading, provider }: Props) => {
const pageNav = getPageNav(config);
useEffect(() => {
loadProviders(provider);
}, [loadProviders, provider]);
if (!config) {
return null;
}
return (
<Page navId="authentication" pageNav={pageNav}>
<ProviderConfigForm config={config} isLoading={isLoading} provider={provider} />
</Page>
);
};
export default connector(ProviderConfigPage);

View File

@ -3,11 +3,10 @@ import React from 'react';
import { IconName, isIconName } from '@grafana/data';
import { Badge, Card, Icon } from '@grafana/ui';
import { getProviderUrl } from '../utils';
import { getProviderUrl } from '../utils/url';
type Props = {
providerId: string;
displayName: string;
enabled: boolean;
configPath?: string;
authType?: string;

View File

@ -0,0 +1,116 @@
import { FieldData, SSOProvider } from './types';
/** 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);
}
return value.every((v) => v?.value && isNumeric(v.value));
},
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);
}

View File

@ -31,13 +31,13 @@ export function loadSettings(): ThunkResult<Promise<Settings>> {
};
}
export function loadProviders(): ThunkResult<Promise<SSOProvider[]>> {
export function loadProviders(provider = ''): ThunkResult<Promise<SSOProvider[]>> {
return async (dispatch) => {
if (!config.featureToggles.ssoSettingsApi) {
return [];
}
const result = await getBackendSrv().get('/api/v1/sso-settings');
dispatch(providersLoaded(result));
const result = await getBackendSrv().get(`/api/v1/sso-settings${provider ? `/${provider}` : ''}`);
dispatch(providersLoaded(provider ? [result] : result));
return result;
};
}

View File

@ -1,3 +1,4 @@
import { IconName, SelectableValue } from '@grafana/data';
import { Settings } from 'app/types';
export interface AuthProviderInfo {
@ -10,18 +11,63 @@ export interface AuthProviderInfo {
export type GetStatusHook = () => Promise<AuthProviderStatus>;
// Settings types common to the provider settings data when working with the API and forms
export type SSOProviderSettingsBase = {
allowAssignGrafanaAdmin?: boolean;
allowSignUp?: boolean;
apiUrl?: string;
authStyle?: string;
authUrl?: string;
autoLogin?: boolean;
clientId: string;
clientSecret: string;
emailAttributeName?: string;
emailAttributePath?: string;
emptyScopes?: boolean;
enabled: boolean;
extra?: Record<string, unknown>;
groupsAttributePath?: string;
hostedDomain?: string;
icon?: IconName;
name?: string;
roleAttributePath?: string;
roleAttributeStrict?: boolean;
signoutRedirectUrl?: string;
skipOrgRoleSync?: boolean;
teamIdsAttributePath?: string;
teamsUrl?: string;
tlsClientCa?: string;
tlsClientCert?: string;
tlsClientKey?: string;
tlsSkipVerify?: boolean;
tokenUrl?: string;
type: string;
usePKCE?: boolean;
useRefreshToken?: boolean;
};
// SSO data received from the API and sent to it
export type SSOProvider = {
provider: string;
settings: {
enabled: boolean;
name: string;
type: string;
// Legacy fields
configPath?: string;
settings: SSOProviderSettingsBase & {
teamIds: string;
allowedOrganizations: string;
allowedDomains?: string;
allowedGroups?: string;
scopes?: string;
};
};
// SSO data format for storing in the forms
export type SSOProviderDTO = Partial<SSOProviderSettingsBase> & {
teamIds: Array<SelectableValue<string>>;
allowedOrganizations: Array<SelectableValue<string>>;
allowedDomains?: Array<SelectableValue<string>>;
allowedGroups?: Array<SelectableValue<string>>;
scopes?: Array<SelectableValue<string>>;
};
export interface AuthConfigState {
settings: Settings;
providerStatuses: Record<string, AuthProviderStatus>;
@ -43,3 +89,18 @@ export interface SettingsError {
message: string;
errors: string[];
}
// Data structure used to render form fields
export type FieldData = {
label: string;
type: string;
validation?: {
required?: boolean;
message?: string;
validate?: (value: string | Array<SelectableValue<string>>) => boolean | string | Promise<boolean | string>;
};
multi?: boolean;
allowCustomValue?: boolean;
options?: Array<SelectableValue<string>>;
placeholder?: string;
};

View File

@ -1,6 +0,0 @@
import { BASE_PATH } from './constants';
import { AuthProviderInfo } from './types';
export function getProviderUrl(provider: AuthProviderInfo) {
return BASE_PATH + (provider.configPath || provider.id);
}

View File

@ -0,0 +1,94 @@
import { SelectableValue } from '@grafana/data';
import { fieldMap } from '../fields';
import { FieldData, SSOProvider, SSOProviderDTO } from '../types';
import { isSelectableValue } from './guards';
export const emptySettings: SSOProviderDTO = {
allowAssignGrafanaAdmin: false,
allowSignUp: false,
allowedDomains: [],
allowedGroups: [],
allowedOrganizations: [],
apiUrl: '',
authStyle: '',
authUrl: '',
autoLogin: false,
clientId: '',
clientSecret: '',
emailAttributeName: '',
emailAttributePath: '',
emptyScopes: false,
enabled: false,
extra: {},
groupsAttributePath: '',
hostedDomain: '',
icon: 'shield',
name: '',
roleAttributePath: '',
roleAttributeStrict: false,
scopes: [],
signoutRedirectUrl: '',
skipOrgRoleSync: false,
teamIds: [],
teamIdsAttributePath: '',
teamsUrl: '',
tlsClientCa: '',
tlsClientCert: '',
tlsClientKey: '',
tlsSkipVerify: false,
tokenUrl: '',
type: '',
usePKCE: false,
useRefreshToken: false,
};
const strToValue = (val: string | string[]): SelectableValue[] => {
if (!val?.length) {
return [];
}
if (Array.isArray(val)) {
return val.map((v) => ({ label: v, value: v }));
}
return val.split(/[\s,]/).map((s) => ({ label: s, value: s }));
};
export function dataToDTO(data?: SSOProvider): SSOProviderDTO {
if (!data) {
return emptySettings;
}
const arrayFields = getArrayFields(fieldMap);
const settings = { ...data.settings };
for (const field of arrayFields) {
//@ts-expect-error
settings[field] = strToValue(settings[field]);
}
//@ts-expect-error
return settings;
}
const valuesToString = (values: Array<SelectableValue<string>>) => {
return values.map(({ value }) => value).join(',');
};
// Convert the DTO to the data format used by the API
export function dtoToData(dto: SSOProviderDTO) {
const arrayFields = getArrayFields(fieldMap);
const settings = { ...dto };
for (const field of arrayFields) {
const value = dto[field];
if (value && isSelectableValue(value)) {
//@ts-expect-error
settings[field] = valuesToString(value);
}
}
return settings;
}
export function getArrayFields(obj: Record<string, FieldData>): Array<keyof SSOProviderDTO> {
return Object.entries(obj)
.filter(([_, value]) => value.type === 'select' && value.multi === true)
.map(([key]) => key as keyof SSOProviderDTO);
}

View File

@ -0,0 +1,5 @@
import { SelectableValue } from '@grafana/data';
export function isSelectableValue(value: unknown): value is SelectableValue[] {
return Array.isArray(value) && value.every((v) => typeof v === 'object' && v !== null && 'value' in v);
}

View File

@ -0,0 +1,6 @@
import { BASE_PATH } from '../constants';
import { AuthProviderInfo } from '../types';
export function getProviderUrl(provider: AuthProviderInfo) {
return BASE_PATH + (provider.configPath || `advanced/${provider.id}`);
}

View File

@ -282,10 +282,20 @@ export function getAppRoutes(): RouteDescriptor[] {
component:
config.licenseInfo.enabledFeatures?.saml || config.ldapEnabled || config.featureToggles.ssoSettingsApi
? SafeDynamicImport(
() => import(/* webpackChunkName: "AdminAuthentication" */ 'app/features/auth-config/AuthConfigPage')
() =>
import(/* webpackChunkName: "AdminAuthentication" */ '../features/auth-config/AuthProvidersListPage')
)
: () => <Redirect to="/admin" />,
},
{
path: '/admin/authentication/advanced/:provider',
roles: () => contextSrv.evaluatePermission([AccessControlAction.SettingsWrite]),
component: config.featureToggles.ssoSettingsApi
? SafeDynamicImport(
() => import(/* webpackChunkName: "AdminAuthentication" */ '../features/auth-config/ProviderConfigPage')
)
: () => <Redirect to="/admin" />,
},
{
path: '/admin/settings',
component: SafeDynamicImport(