diff --git a/.betterer.results b/.betterer.results index 15f0b6cd595..b042d926eca 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/packages/grafana-ui/src/components/SecretInput/SecretInput.tsx b/packages/grafana-ui/src/components/SecretInput/SecretInput.tsx index b996cf70236..da7dc303264 100644 --- a/packages/grafana-ui/src/components/SecretInput/SecretInput.tsx +++ b/packages/grafana-ui/src/components/SecretInput/SecretInput.tsx @@ -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 & { /** 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) => ( - + {!isConfigured && } - {isConfigured && } {isConfigured && ( - + <> + + + )} - + ); diff --git a/public/app/core/components/FormPrompt/FormPrompt.tsx b/public/app/core/components/FormPrompt/FormPrompt.tsx new file mode 100644 index 00000000000..4339b7b89b0 --- /dev/null +++ b/public/app/core/components/FormPrompt/FormPrompt.tsx @@ -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(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 ( + <> + + {blockedLocation && changesDiscarded && } + + + ); +}; + +interface UnsavedChangesModalProps { + onDiscard: () => void; + onBackToForm: () => void; + isOpen: boolean; +} + +const UnsavedChangesModal = ({ onDiscard, onBackToForm, isOpen }: UnsavedChangesModalProps) => { + return ( + +
Changes that you made may not be saved.
+ + + + +
+ ); +}; diff --git a/public/app/features/auth-config/AuthConfigPage.tsx b/public/app/features/auth-config/AuthProvidersListPage.tsx similarity index 78% rename from public/app/features/auth-config/AuthConfigPage.tsx rename to public/app/features/auth-config/AuthProvidersListPage.tsx index 1240ff69c37..53d31756529 100644 --- a/public/app/features/auth-config/AuthConfigPage.tsx +++ b/public/app/features/auth-config/AuthProvidersListPage.tsx @@ -74,17 +74,20 @@ export const AuthConfigPageUnconnected = ({ ) : ( - {providerList.map(({ provider, settings }) => ( - 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 }) => ( + onProviderCardClick(provider)} + //@ts-expect-error Remove legacy types + configPath={settings.configPath} + /> + ))} )} diff --git a/public/app/features/auth-config/ProviderConfigForm.test.tsx b/public/app/features/auth-config/ProviderConfigForm.test.tsx new file mode 100644 index 00000000000..a458748451d --- /dev/null +++ b/public/app/features/auth-config/ProviderConfigForm.test.tsx @@ -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(); + 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(); + 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(); + await user.click(screen.getByRole('button', { name: /Save/i })); + + // Should show an alert for empty client ID + expect(await screen.findAllByRole('alert')).toHaveLength(1); + }); +}); diff --git a/public/app/features/auth-config/ProviderConfigForm.tsx b/public/app/features/auth-config/ProviderConfigForm.tsx new file mode 100644 index 00000000000..c4fc4264415 --- /dev/null +++ b/public/app/features/auth-config/ProviderConfigForm.tsx @@ -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 ( + + + + ); + case 'secret': + return ( + + ( + { + setIsSecretConfigured(false); + setValue(name, ''); + }} + /> + )} + /> + + ); + case 'select': + const watchOptions = watch(name); + const options = isSelectableValue(watchOptions) ? watchOptions : [{ label: '', value: '' }]; + return ( + + { + return ( +