mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
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:
@@ -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/';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user