SAML: Configuration UI (#64054)

* Add initial authentication config page skeleton

* Add initial SAML config page WIP

* Add few more pages

* Add connect to IdP page

* Assertion mappings page stub and url params

* Able to save settings

* Some tweaks for authentication page

* Tweak behaviour

* Tweak provider name

* Move SAML config pages to enterprise

* minor refactor

* Able to reset settings

* Configure key and cert from UI

* Refactor WIP

* Tweak styles

* Optional save button

* Some tweaks for the page

* Don't show info popup when save settings

* Improve key/cert validation

* Fetch provider status and display on auth page

* Add settings list to the auth page

* Show call to action card if no auth configured

* clean up

* Show authentication page only if SAML available

* Add access control for SSO config page

* Add feature toggle for auth config UI

* Add code owners for auth config page

* Auth config UI disabled by default

* Fix feature toggle check

* Apply suggestions from review

* Refactor: use forms for steps

* Clean up

* Improve authentication page loading

* Fix CTA link

* Minor tweaks

* Fix page route

* Fix formatting

* Fix generated code formatting
This commit is contained in:
Alexander Zobnin
2023-04-13 16:07:43 +02:00
committed by GitHub
parent 7d1b61e033
commit 7476219b0c
28 changed files with 647 additions and 5 deletions
@@ -0,0 +1,155 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import React, { useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { StoreState } from 'app/types';
import ConfigureAuthCTA from './components/ConfigureAuthCTA';
import { ProviderCard } from './components/ProviderCard';
import { loadSettings } from './state/actions';
import { filterAuthSettings, getProviderUrl } from './utils';
import { getRegisteredAuthProviders } from '.';
interface OwnProps {}
export type Props = OwnProps & ConnectedProps<typeof connector>;
function mapStateToProps(state: StoreState) {
const { settings, isLoading, providerStatuses } = state.authConfig;
return {
settings,
isLoading,
providerStatuses,
};
}
const mapDispatchToProps = {
loadSettings,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export const AuthConfigPageUnconnected = ({
settings,
providerStatuses,
isLoading,
loadSettings,
}: Props): JSX.Element => {
const styles = useStyles2(getStyles);
useEffect(() => {
loadSettings();
}, [loadSettings]);
const authProviders = getRegisteredAuthProviders();
const enabledProviders = authProviders.filter((p) => providerStatuses[p.id]?.enabled);
const configuresProviders = authProviders.filter(
(p) => providerStatuses[p.id]?.configured && !providerStatuses[p.id]?.enabled
);
const availableProviders = authProviders.filter(
(p) => !providerStatuses[p.id]?.enabled && !providerStatuses[p.id]?.configured
);
const authSettings = filterAuthSettings(settings);
const firstAvailableProvider = availableProviders?.length ? availableProviders[0] : null;
return (
<Page navId="authentication">
<Page.Contents isLoading={isLoading}>
<h3 className={styles.sectionHeader}>Configured authentication</h3>
{!!enabledProviders?.length && (
<div className={styles.cardsContainer}>
{enabledProviders.map((provider) => (
<ProviderCard
key={provider.id}
providerId={provider.id}
displayName={provider.displayName}
authType={provider.type}
enabled={providerStatuses[provider.id]?.enabled}
configPath={provider.configPath}
/>
))}
</div>
)}
{!enabledProviders?.length && firstAvailableProvider && !isEmpty(providerStatuses) && (
<ConfigureAuthCTA
title={`You have no ${firstAvailableProvider.type} configuration created at the moment`}
buttonIcon="plus-circle"
buttonLink={getProviderUrl(firstAvailableProvider)}
buttonTitle={`Configure ${firstAvailableProvider.type}`}
description={`Important: if you have ${firstAvailableProvider.type} configuration enabled via the .ini file Grafana is using it.
Configuring ${firstAvailableProvider.type} via UI will take precedence over any configuration in the .ini file.
No changes will be written into .ini file.`}
/>
)}
{!!configuresProviders?.length && (
<div className={styles.cardsContainer}>
{configuresProviders.map((provider) => (
<ProviderCard
key={provider.id}
providerId={provider.id}
displayName={provider.displayName}
authType={provider.protocol}
enabled={providerStatuses[provider.id]?.enabled}
configPath={provider.configPath}
/>
))}
</div>
)}
<div className={styles.settingsSection}>
<h3>Settings</h3>
{authSettings && (
<table className="filter-table">
<tbody>
{Object.entries(authSettings).map(([sectionName, sectionSettings], i) => (
<React.Fragment key={`section-${i}`}>
<tr>
<td className="admin-settings-section">{sectionName}</td>
<td />
</tr>
{Object.entries(sectionSettings).map(([settingName, settingValue], j) => (
<tr key={`property-${j}`}>
<td className={styles.settingName}>{settingName}</td>
<td className={styles.settingName}>{settingValue}</td>
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
)}
</div>
</Page.Contents>
</Page>
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
cardsContainer: css`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(288px, 1fr));
gap: ${theme.spacing(3)};
margin-bottom: ${theme.spacing(3)};
margin-top: ${theme.spacing(2)};
`,
sectionHeader: css`
margin-bottom: ${theme.spacing(3)};
`,
settingsSection: css`
margin-top: ${theme.spacing(4)};
`,
settingName: css`
padding-left: 25px;
`,
settingValue: css`
white-space: break-spaces;
`,
};
};
export default connector(AuthConfigPageUnconnected);
@@ -0,0 +1,45 @@
import React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { Alert } from '@grafana/ui';
import { StoreState } from 'app/types';
import { resetError, resetWarning } from './state/reducers';
function mapStateToProps(state: StoreState) {
return {
error: state.authConfig.updateError,
warning: state.authConfig.warning,
};
}
const mapDispatchToProps = {
resetError,
resetWarning,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type Props = ConnectedProps<typeof connector>;
export const ErrorContainerUnconnected = ({ error, warning, resetError, resetWarning }: Props): JSX.Element => {
return (
<div>
{error && (
<Alert title={error.message} onRemove={() => resetError()}>
{error.errors?.map((e, i) => (
<div key={i}>{e}</div>
))}
</Alert>
)}
{warning && (
<Alert title={warning.message} onRemove={() => resetWarning()} severity="warning">
{warning.errors?.map((e, i) => (
<div key={i}>{e}</div>
))}
</Alert>
)}
</div>
);
};
export default connector(ErrorContainerUnconnected);
@@ -0,0 +1,56 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { CallToActionCard, IconName, LinkButton, useStyles2 } from '@grafana/ui';
export interface Props {
title: string;
buttonIcon: IconName;
buttonLink?: string;
buttonTitle: string;
buttonDisabled?: boolean;
description?: string;
}
const ConfigureAuthCTA: React.FunctionComponent<Props> = ({
title,
buttonIcon,
buttonLink,
buttonTitle,
buttonDisabled,
description,
}) => {
const styles = useStyles2(getStyles);
const footer = description ? <span key="proTipFooter">{description}</span> : '';
const ctaElementClassName = !description ? styles.button : '';
const ctaElement = (
<LinkButton
size="lg"
href={buttonLink}
icon={buttonIcon}
className={ctaElementClassName}
data-testid={selectors.components.CallToActionCard.buttonV2(buttonTitle)}
disabled={buttonDisabled}
>
{buttonTitle}
</LinkButton>
);
return <CallToActionCard className={styles.cta} message={title} footer={footer} callToActionElement={ctaElement} />;
};
const getStyles = (theme: GrafanaTheme2) => {
return {
cta: css`
text-align: center;
`,
button: css`
margin-bottom: ${theme.spacing(2.5)};
`,
};
};
export default ConfigureAuthCTA;
@@ -0,0 +1,61 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Card, useStyles2 } from '@grafana/ui';
import { BASE_PATH } from '../constants';
export const LOGO_SIZE = '48px';
type Props = {
providerId: string;
displayName: string;
enabled: boolean;
configPath?: string;
authType?: string;
badges?: JSX.Element[];
};
export function ProviderCard({ providerId, displayName, enabled, configPath, authType, badges }: Props) {
const styles = useStyles2(getStyles);
configPath = BASE_PATH + (configPath || providerId);
return (
<Card href={configPath} className={styles.container}>
<Card.Heading className={styles.name}>{displayName}</Card.Heading>
<div className={styles.footer}>
{authType && <Badge text={authType} color="blue" icon="info-circle" />}
{enabled ? <Badge text="Enabled" color="green" icon="check" /> : <Badge text="Not enabled" color="red" />}
</div>
</Card>
);
}
export const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
min-height: ${theme.spacing(16)};
display: flex;
flex-direction: column;
justify-content: space-between;
padding: ${theme.spacing(2)};
`,
header: css`
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: ${theme.spacing(2)};
`,
footer: css`
display: flex;
justify-content: space-between;
`,
name: css`
align-self: flex-start;
font-size: ${theme.typography.h4.fontSize};
color: ${theme.colors.text.primary};
margin: 0;
`,
};
};
@@ -0,0 +1 @@
export const BASE_PATH = 'admin/authentication/';
+49
View File
@@ -0,0 +1,49 @@
import { AuthProviderStatus, Settings, SettingsSection } from 'app/types';
import { AuthProviderInfo, GetStatusHook } from './types';
export * from './types';
const registeredAuthProviders: AuthProviderInfo[] = [];
const authProvidersConfigHooks: Record<string, GetStatusHook> = {};
export function registerAuthProvider(provider: AuthProviderInfo, getConfigHook?: GetStatusHook) {
if (!registeredAuthProviders.find((p) => p.id === provider.id)) {
registeredAuthProviders.push(provider);
if (getConfigHook) {
authProvidersConfigHooks[provider.id] = getConfigHook;
}
}
}
export function getRegisteredAuthProviders(): AuthProviderInfo[] {
return registeredAuthProviders;
}
export function getAuthProviderInfo(provider: string) {
return registeredAuthProviders.find((p) => p.id === provider);
}
export function getAuthProviders(cfg: Settings): SettingsSection[] {
const providers: SettingsSection[] = [];
for (const [section, sectionConfig] of Object.entries(cfg)) {
const provider = registeredAuthProviders.find((provider) => `auth.${provider.id}` === section);
if (provider) {
const providerData = {
...sectionConfig,
providerId: provider.id,
displayName: sectionConfig.name || provider.displayName,
};
providers.push(providerData);
}
}
return providers;
}
export async function getAuthProviderStatus(providerId: string): Promise<AuthProviderStatus> {
if (authProvidersConfigHooks[providerId]) {
const getStatusHook = authProvidersConfigHooks[providerId];
return getStatusHook();
}
return { configured: false, enabled: false };
}
@@ -0,0 +1,79 @@
import { lastValueFrom } from 'rxjs';
import { getBackendSrv, isFetchError } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import {
AccessControlAction,
Settings,
ThunkResult,
SettingsError,
UpdateSettingsQuery,
AuthProviderStatus,
} from 'app/types';
import { getAuthProviderStatus, getRegisteredAuthProviders } from '..';
import { loadingBegin, loadingEnd, providerStatusesLoaded, resetError, setError, settingsUpdated } from './reducers';
export function loadSettings(): ThunkResult<Promise<Settings>> {
return async (dispatch) => {
if (contextSrv.hasPermission(AccessControlAction.SettingsRead)) {
dispatch(loadingBegin());
const result = await getBackendSrv().get('/api/admin/settings');
dispatch(settingsUpdated(result));
await dispatch(loadProviderStatuses());
dispatch(loadingEnd());
return result;
}
};
}
export function loadProviderStatuses(): ThunkResult<void> {
return async (dispatch) => {
const registeredProviders = getRegisteredAuthProviders();
const providerStatuses: Record<string, AuthProviderStatus> = {};
const getStatusPromises: Array<Promise<AuthProviderStatus>> = [];
for (const provider of registeredProviders) {
getStatusPromises.push(getAuthProviderStatus(provider.id));
}
const statuses = await Promise.all(getStatusPromises);
for (let i = 0; i < registeredProviders.length; i++) {
const provider = registeredProviders[i];
const status = statuses[i];
providerStatuses[provider.id] = status;
}
dispatch(providerStatusesLoaded(providerStatuses));
};
}
export function saveSettings(data: UpdateSettingsQuery): ThunkResult<Promise<boolean>> {
return async (dispatch) => {
if (contextSrv.hasPermission(AccessControlAction.SettingsRead)) {
try {
await lastValueFrom(
getBackendSrv().fetch({
url: '/api/admin/settings',
method: 'PUT',
data,
showSuccessAlert: false,
showErrorAlert: false,
})
);
dispatch(resetError());
return true;
} catch (error) {
console.log(error);
if (isFetchError(error)) {
error.isHandled = true;
const updateErr: SettingsError = {
message: error.data?.message,
errors: error.data?.errors,
};
dispatch(setError(updateErr));
return false;
}
}
}
return false;
};
}
@@ -0,0 +1,57 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AuthConfigState, AuthProviderStatus, Settings, SettingsError } from 'app/types';
export const initialState: AuthConfigState = {
settings: {},
providerStatuses: {},
isLoading: false,
};
const authConfigSlice = createSlice({
name: 'authConfig',
initialState,
reducers: {
settingsUpdated: (state, action: PayloadAction<Settings>): AuthConfigState => {
return { ...state, settings: action.payload };
},
providerStatusesLoaded: (state, action: PayloadAction<{ [key: string]: AuthProviderStatus }>): AuthConfigState => {
return { ...state, providerStatuses: action.payload };
},
loadingBegin: (state: AuthConfigState) => {
return { ...state, isLoading: true };
},
loadingEnd: (state: AuthConfigState) => {
return { ...state, isLoading: false };
},
setError: (state, action: PayloadAction<SettingsError>): AuthConfigState => {
return { ...state, updateError: action.payload };
},
resetError: (state): AuthConfigState => {
return { ...state, updateError: undefined };
},
setWarning: (state, action: PayloadAction<SettingsError>): AuthConfigState => {
return { ...state, warning: action.payload };
},
resetWarning: (state): AuthConfigState => {
return { ...state, warning: undefined };
},
},
});
export const {
settingsUpdated,
providerStatusesLoaded,
loadingBegin,
loadingEnd,
setError,
resetError,
setWarning,
resetWarning,
} = authConfigSlice.actions;
export const authConfigReducer = authConfigSlice.reducer;
export default {
authConfig: authConfigReducer,
};
+11
View File
@@ -0,0 +1,11 @@
import { AuthProviderStatus } from 'app/types';
export interface AuthProviderInfo {
id: string;
type: string;
protocol: string;
displayName: string;
configPath?: string;
}
export type GetStatusHook = () => Promise<AuthProviderStatus>;
+15
View File
@@ -0,0 +1,15 @@
import { Settings } from 'app/types';
import { BASE_PATH } from './constants';
import { AuthProviderInfo } from './types';
export function filterAuthSettings(settings: Settings) {
const authSettings: Settings = Object.fromEntries(
Object.entries(settings).filter(([sectionName]) => sectionName === 'auth')
);
return authSettings;
}
export function getProviderUrl(provider: AuthProviderInfo) {
return BASE_PATH + (provider.configPath || provider.id);
}