SSO: Display provider list (#78472)

* Load providers

* Display providers

* Rename

* Remove redundant styles

* Update Grid import

* Return data in camelCase from the OAuth fb strategy

* Update cards and remove empty state

* Add comment

* Add feature toggle

* Update betterer

* Add empty state

* Fix configPath

* Update betterer

* Revert backend changes

* Remove newline

* Enable auth routes

---------

Co-authored-by: Mihaly Gyongyosi <mgyongyosi@users.noreply.github.com>
pull/78642/head^2
Alex Khomenko 1 year ago committed by GitHub
parent 5c451cbb7d
commit 1141dd62ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 21
      .betterer.results
  2. 2
      pkg/services/navtree/navtreeimpl/admin.go
  3. 158
      public/app/features/auth-config/AuthConfigPage.tsx
  4. 69
      public/app/features/auth-config/components/ConfigureAuthCTA.tsx
  5. 102
      public/app/features/auth-config/components/ProviderCard.tsx
  6. 29
      public/app/features/auth-config/state/actions.ts
  7. 7
      public/app/features/auth-config/state/reducers.ts
  8. 13
      public/app/features/auth-config/types.ts
  9. 2
      public/app/routes/routes.tsx

@ -2571,27 +2571,6 @@ 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/AuthConfigPage.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"]
],
"public/app/features/auth-config/components/ConfigureAuthCTA.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"]
],
"public/app/features/auth-config/components/ProviderCard.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"]
],
"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"],

@ -80,7 +80,7 @@ func (s *ServiceImpl) getAdminNode(c *contextmodel.ReqContext) (*navtree.NavLink
})
}
if authConfigUIAvailable && hasAccess(evalAuthenticationSettings()) {
if (authConfigUIAvailable && hasAccess(evalAuthenticationSettings())) || s.features.IsEnabled(ctx, featuremgmt.FlagSsoSettingsApi) {
configNodes = append(configNodes, &navtree.NavLink{
Text: "Authentication",
Id: "authentication",

@ -1,31 +1,27 @@
import { css } from '@emotion/css';
import { isEmpty } from 'lodash';
import React, { useEffect } from 'react';
import React, { JSX, useEffect } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { GrafanaTheme2 } from '@grafana/data';
import { reportInteraction } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { Grid, TextLink } 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 { AuthProviderInfo } from './types';
import { getProviderUrl } from './utils';
import { getRegisteredAuthProviders } from '.';
import { getRegisteredAuthProviders } from './index';
interface OwnProps {}
export type Props = OwnProps & ConnectedProps<typeof connector>;
function mapStateToProps(state: StoreState) {
const { isLoading, providerStatuses } = state.authConfig;
const { isLoading, providerStatuses, providers } = state.authConfig;
return {
isLoading,
providerStatuses,
providers,
};
}
@ -35,125 +31,65 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
export const AuthConfigPageUnconnected = ({ providerStatuses, isLoading, loadSettings }: Props): JSX.Element => {
const styles = useStyles2(getStyles);
export const AuthConfigPageUnconnected = ({
providerStatuses,
isLoading,
loadSettings,
providers,
}: Props): JSX.Element => {
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 && !providerStatuses[p.id]?.hide
);
const firstAvailableProvider = availableProviders?.length ? availableProviders[0] : null;
{
/* TODO: make generic for the provider of the configuration or make the documentation point to a collection of all our providers */
}
const docsLink = (
<a
className="external-link"
href="https://grafana.com/docs/grafana/next/setup-grafana/configure-security/configure-authentication/saml-ui/"
target="_blank"
rel="noopener noreferrer"
>
documentation.
</a>
);
const subTitle = <span>Manage your auth settings and configure single sign-on. Find out more in our {docsLink}</span>;
const onCTAClick = () => {
reportInteraction('authentication_ui_created', { provider: firstAvailableProvider?.type });
};
const onProviderCardClick = (provider: AuthProviderInfo) => {
reportInteraction('authentication_ui_provider_clicked', { provider: provider.type });
const availableProviders = authProviders.filter((p) => !providerStatuses[p.id]?.hide);
const onProviderCardClick = (providerType: string) => {
reportInteraction('authentication_ui_provider_clicked', { provider: providerType });
};
const providerList = availableProviders.length
? [
...availableProviders.map((p) => ({
provider: p.id,
settings: { ...providerStatuses[p.id], configPath: p.configPath, type: p.type },
})),
...providers,
]
: providers;
return (
<Page navId="authentication" subTitle={subTitle}>
<Page
navId="authentication"
subTitle={
<>
Manage your auth settings and configure single sign-on. Find out more in our{' '}
<TextLink href="https://grafana.com/docs/grafana/next/setup-grafana/configure-security/configure-authentication">
documentation
</TextLink>
.
</>
}
>
<Page.Contents isLoading={isLoading}>
<h3 className={styles.sectionHeader}>Configured authentication</h3>
{!!enabledProviders?.length && (
<div className={styles.cardsContainer}>
{enabledProviders.map((provider) => (
{!providerList.length ? (
<ConfigureAuthCTA />
) : (
<Grid gap={3} minColumnWidth={34}>
{providerList.map(({ provider, settings }) => (
<ProviderCard
key={provider.id}
providerId={provider.id}
displayName={providerStatuses[provider.id]?.name || provider.displayName}
authType={provider.protocol}
enabled={providerStatuses[provider.id]?.enabled}
configPath={provider.configPath}
key={provider}
authType={settings.type || 'OAuth'}
providerId={provider}
displayName={provider}
enabled={settings.enabled}
onClick={() => onProviderCardClick(provider)}
configPath={settings.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}`}
onClick={onCTAClick}
/>
)}
{!!configuresProviders?.length && (
<div className={styles.cardsContainer}>
{configuresProviders.map((provider) => (
<ProviderCard
key={provider.id}
providerId={provider.id}
displayName={providerStatuses[provider.id]?.name || provider.displayName}
authType={provider.protocol}
enabled={providerStatuses[provider.id]?.enabled}
configPath={provider.configPath}
onClick={() => onProviderCardClick(provider)}
/>
))}
</div>
</Grid>
)}
</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;
`,
doclink: css`
padding-bottom: 5px;
padding-top: -5px;
font-size: ${theme.typography.bodySmall.fontSize};
a {
color: ${theme.colors.info.name}; // use theme link color or any other color
text-decoration: underline; // underline or none, as you prefer
}
`,
settingValue: css`
white-space: break-spaces;
`,
};
};
export default connector(AuthConfigPageUnconnected);

@ -2,57 +2,40 @@ 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';
import { Icon, Stack, Text, TextLink, useStyles2 } from '@grafana/ui';
export interface Props {
title: string;
buttonIcon: IconName;
buttonLink?: string;
buttonTitle: string;
buttonDisabled?: boolean;
description?: string;
onClick?: () => void;
}
export interface Props {}
const ConfigureAuthCTA: React.FunctionComponent<Props> = ({
title,
buttonIcon,
buttonLink,
buttonTitle,
buttonDisabled,
description,
onClick,
}) => {
const ConfigureAuthCTA: React.FunctionComponent<Props> = () => {
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}
onClick={() => onClick && onClick()}
>
{buttonTitle}
</LinkButton>
return (
<div className={styles.container}>
<Stack gap={1} alignItems={'center'}>
<Icon name={'cog'} />
<Text>Configuration required</Text>
</Stack>
<Text variant={'bodySmall'} color={'secondary'}>
You have no authentication configuration created at the moment.
</Text>
<TextLink href={'https://grafana.com/docs/grafana/latest/auth/overview/'} external>
Refer to the documentation on how to configure authentication
</TextLink>
</div>
);
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)};
`,
container: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
backgroundColor: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
padding: theme.spacing(3),
width: 'max-content',
margin: theme.spacing(3, 'auto'),
}),
};
};

@ -1,12 +1,9 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Badge, Card, useStyles2, Icon } from '@grafana/ui';
import { IconName, isIconName } from '@grafana/data';
import { Badge, Card, Icon } from '@grafana/ui';
import { BASE_PATH } from '../constants';
export const LOGO_SIZE = '48px';
import { getProviderUrl } from '../utils';
type Props = {
providerId: string;
@ -14,79 +11,36 @@ type Props = {
enabled: boolean;
configPath?: string;
authType?: string;
badges?: JSX.Element[];
onClick?: () => void;
};
export function ProviderCard({ providerId, displayName, enabled, configPath, authType, badges, onClick }: Props) {
const styles = useStyles2(getStyles);
configPath = BASE_PATH + (configPath || providerId);
// TODO Remove when this is available from API
const UIMap: Record<string, [IconName, string]> = {
github: ['github', 'GitHub'],
gitlab: ['gitlab', 'GitLab'],
google: ['google', 'Google'],
generic_oauth: ['lock', 'Generic OAuth'],
grafana_com: ['grafana', 'Grafana.com'],
azuread: ['microsoft', 'Azure AD'],
okta: ['okta', 'Okta'],
};
export function ProviderCard({ providerId, enabled, configPath, authType, onClick }: Props) {
//@ts-expect-error
const url = getProviderUrl({ configPath, id: providerId });
const [iconName, displayName] = UIMap[providerId] || ['lock', providerId.toUpperCase()];
return (
<Card href={configPath} className={styles.container} onClick={() => onClick && onClick()}>
<div className={styles.header}>
<span className={styles.smallText}>{authType}</span>
<span className={styles.name}>{displayName}</span>
</div>
<div className={styles.footer}>
<div className={styles.badgeContainer}>
{enabled ? <Badge text="Enabled" color="green" icon="check" /> : <Badge text="Not enabled" color="blue" />}
</div>
<span className={styles.edit}>
Edit
<Icon color="blue" name={'arrow-right'} size="sm" />
</span>
</div>
<Card href={url} onClick={onClick}>
<Card.Heading>{displayName}</Card.Heading>
<Card.Meta>{authType}</Card.Meta>
{isIconName(iconName) && (
<Card.Figure>
<Icon name={iconName} size={'xxxl'} />
</Card.Figure>
)}
<Card.Actions>
<Badge text={enabled ? 'Enabled' : 'Not enabled'} color={enabled ? 'green' : 'blue'} />
</Card.Actions>
</Card>
);
}
export const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
min-height: ${theme.spacing(18)};
display: flex;
flex-direction: column;
justify-content: space-around;
border-radius: ${theme.spacing(0.5)};
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
`,
header: css`
margin-top: -${theme.spacing(2)};
display: flex;
flex-direction: column;
justify-content: start;
align-items: flex-start;
margin-bottom: ${theme.spacing(2)};
`,
footer: css`
margin-top: ${theme.spacing(2)};
display: flex;
justify-content: space-between;
align-items: center;
`,
name: css`
align-self: flex-start;
font-size: ${theme.typography.h4.fontSize};
color: ${theme.colors.text.primary};
margin: 0;
margin-top: ${theme.spacing(-1)};
`,
smallText: css`
font-size: ${theme.typography.bodySmall.fontSize};
color: ${theme.colors.text.secondary};
padding: ${theme.spacing(1)} 0; // Add some padding
max-width: 90%; // Add a max-width to prevent text from stretching too wide
`,
badgeContainer: css`
display: flex;
gap: ${theme.spacing(1)};
`,
edit: css`
display: flex;
align-items: center;
color: ${theme.colors.text.link};
gap: ${theme.spacing(0.5)};
`,
};
};

@ -1,18 +1,27 @@
import { lastValueFrom } from 'rxjs';
import { getBackendSrv, isFetchError } from '@grafana/runtime';
import { config, getBackendSrv, isFetchError } from '@grafana/runtime';
import { contextSrv } from 'app/core/core';
import { AccessControlAction, Settings, ThunkResult, UpdateSettingsQuery } from 'app/types';
import { getAuthProviderStatus, getRegisteredAuthProviders } from '..';
import { getAuthProviderStatus, getRegisteredAuthProviders, SSOProvider } from '..';
import { AuthProviderStatus, SettingsError } from '../types';
import { loadingBegin, loadingEnd, providerStatusesLoaded, resetError, setError, settingsUpdated } from './reducers';
import {
loadingBegin,
loadingEnd,
providersLoaded,
providerStatusesLoaded,
resetError,
setError,
settingsUpdated,
} from './reducers';
export function loadSettings(): ThunkResult<Promise<Settings>> {
return async (dispatch) => {
if (contextSrv.hasPermission(AccessControlAction.SettingsRead)) {
dispatch(loadingBegin());
dispatch(loadProviders());
const result = await getBackendSrv().get('/api/admin/settings');
dispatch(settingsUpdated(result));
await dispatch(loadProviderStatuses());
@ -22,6 +31,17 @@ export function loadSettings(): ThunkResult<Promise<Settings>> {
};
}
export function loadProviders(): ThunkResult<Promise<SSOProvider[]>> {
return async (dispatch) => {
if (!config.featureToggles.ssoSettingsApi) {
return [];
}
const result = await getBackendSrv().get('/api/v1/sso-settings');
dispatch(providersLoaded(result));
return result;
};
}
export function loadProviderStatuses(): ThunkResult<void> {
return async (dispatch) => {
const registeredProviders = getRegisteredAuthProviders();
@ -33,8 +53,7 @@ export function loadProviderStatuses(): ThunkResult<void> {
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;
providerStatuses[provider.id] = statuses[i];
}
dispatch(providerStatusesLoaded(providerStatuses));
};

@ -2,12 +2,13 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Settings } from 'app/types';
import { SettingsError, AuthProviderStatus, AuthConfigState } from '../types';
import { SettingsError, AuthProviderStatus, AuthConfigState, SSOProvider } from '../types';
export const initialState: AuthConfigState = {
settings: {},
providerStatuses: {},
isLoading: false,
providers: [],
};
const authConfigSlice = createSlice({
@ -38,6 +39,9 @@ const authConfigSlice = createSlice({
resetWarning: (state): AuthConfigState => {
return { ...state, warning: undefined };
},
providersLoaded: (state, action: PayloadAction<SSOProvider[]>): AuthConfigState => {
return { ...state, providers: action.payload };
},
},
});
@ -50,6 +54,7 @@ export const {
resetError,
setWarning,
resetWarning,
providersLoaded,
} = authConfigSlice.actions;
export const authConfigReducer = authConfigSlice.reducer;

@ -10,12 +10,25 @@ export interface AuthProviderInfo {
export type GetStatusHook = () => Promise<AuthProviderStatus>;
export type SSOProvider = {
provider: string;
settings: {
enabled: boolean;
name: string;
type: string;
// Legacy fields
configPath?: string;
};
};
export interface AuthConfigState {
settings: Settings;
providerStatuses: Record<string, AuthProviderStatus>;
isLoading?: boolean;
updateError?: SettingsError;
warning?: SettingsError;
providers: SSOProvider[];
}
export interface AuthProviderStatus {

@ -269,7 +269,7 @@ export function getAppRoutes(): RouteDescriptor[] {
path: '/admin/authentication',
roles: () => contextSrv.evaluatePermission([AccessControlAction.SettingsWrite]),
component:
config.licenseInfo.enabledFeatures?.saml || config.ldapEnabled
config.licenseInfo.enabledFeatures?.saml || config.ldapEnabled || config.featureToggles.ssoSettingsApi
? SafeDynamicImport(
() => import(/* webpackChunkName: "AdminAuthentication" */ 'app/features/auth-config/AuthConfigPage')
)

Loading…
Cancel
Save