mirror of
https://github.com/grafana/grafana.git
synced 2024-11-22 08:56:43 -06:00
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:
parent
05d1ce4026
commit
fde8a00721
@ -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"],
|
||||
|
@ -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>
|
||||
);
|
||||
|
115
public/app/core/components/FormPrompt/FormPrompt.tsx
Normal file
115
public/app/core/components/FormPrompt/FormPrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
116
public/app/features/auth-config/ProviderConfigForm.test.tsx
Normal file
116
public/app/features/auth-config/ProviderConfigForm.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
201
public/app/features/auth-config/ProviderConfigForm.tsx
Normal file
201
public/app/features/auth-config/ProviderConfigForm.tsx
Normal 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>
|
||||
);
|
||||
};
|
72
public/app/features/auth-config/ProviderConfigPage.tsx
Normal file
72
public/app/features/auth-config/ProviderConfigPage.tsx
Normal 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);
|
@ -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;
|
||||
|
116
public/app/features/auth-config/fields.ts
Normal file
116
public/app/features/auth-config/fields.ts
Normal 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);
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
@ -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;
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
94
public/app/features/auth-config/utils/data.ts
Normal file
94
public/app/features/auth-config/utils/data.ts
Normal 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);
|
||||
}
|
5
public/app/features/auth-config/utils/guards.ts
Normal file
5
public/app/features/auth-config/utils/guards.ts
Normal 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);
|
||||
}
|
6
public/app/features/auth-config/utils/url.ts
Normal file
6
public/app/features/auth-config/utils/url.ts
Normal 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}`);
|
||||
}
|
@ -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(
|
||||
|
Loading…
Reference in New Issue
Block a user