diff --git a/.betterer.results b/.betterer.results index 2dc9ff6cdc4..68f45cc918e 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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"], diff --git a/pkg/services/navtree/navtreeimpl/admin.go b/pkg/services/navtree/navtreeimpl/admin.go index 5817c1d9579..b50e171f8fd 100644 --- a/pkg/services/navtree/navtreeimpl/admin.go +++ b/pkg/services/navtree/navtreeimpl/admin.go @@ -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", diff --git a/public/app/features/auth-config/AuthConfigPage.tsx b/public/app/features/auth-config/AuthConfigPage.tsx index c72c0a363cb..1240ff69c37 100644 --- a/public/app/features/auth-config/AuthConfigPage.tsx +++ b/public/app/features/auth-config/AuthConfigPage.tsx @@ -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; 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 = ( - - documentation. - - ); - - const subTitle = Manage your auth settings and configure single sign-on. Find out more in our {docsLink}; - - 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 ( - + + Manage your auth settings and configure single sign-on. Find out more in our{' '} + + documentation + + . + + } + > -

Configured authentication

- {!!enabledProviders?.length && ( -
- {enabledProviders.map((provider) => ( + {!providerList.length ? ( + + ) : ( + + {providerList.map(({ provider, settings }) => ( onProviderCardClick(provider)} + configPath={settings.configPath} /> ))} -
- )} - {!enabledProviders?.length && firstAvailableProvider && !isEmpty(providerStatuses) && ( - - )} - {!!configuresProviders?.length && ( -
- {configuresProviders.map((provider) => ( - onProviderCardClick(provider)} - /> - ))} -
+ )}
); }; -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); diff --git a/public/app/features/auth-config/components/ConfigureAuthCTA.tsx b/public/app/features/auth-config/components/ConfigureAuthCTA.tsx index fca98251f28..c493aee5e51 100644 --- a/public/app/features/auth-config/components/ConfigureAuthCTA.tsx +++ b/public/app/features/auth-config/components/ConfigureAuthCTA.tsx @@ -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 = ({ - title, - buttonIcon, - buttonLink, - buttonTitle, - buttonDisabled, - description, - onClick, -}) => { +const ConfigureAuthCTA: React.FunctionComponent = () => { const styles = useStyles2(getStyles); - const footer = description ? {description} : ''; - const ctaElementClassName = !description ? styles.button : ''; - - const ctaElement = ( - onClick && onClick()} - > - {buttonTitle} - + return ( +
+ + + Configuration required + + + You have no authentication configuration created at the moment. + + + Refer to the documentation on how to configure authentication + +
); - - return ; }; 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'), + }), }; }; diff --git a/public/app/features/auth-config/components/ProviderCard.tsx b/public/app/features/auth-config/components/ProviderCard.tsx index bac117a10e6..1b3a57781c4 100644 --- a/public/app/features/auth-config/components/ProviderCard.tsx +++ b/public/app/features/auth-config/components/ProviderCard.tsx @@ -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 = { + 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 ( - onClick && onClick()}> -
- {authType} - {displayName} -
-
-
- {enabled ? : } -
- - Edit - - -
+ + {displayName} + {authType} + {isIconName(iconName) && ( + + + + )} + + + ); } - -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)}; - `, - }; -}; diff --git a/public/app/features/auth-config/state/actions.ts b/public/app/features/auth-config/state/actions.ts index 16b369523e6..21136da5a74 100644 --- a/public/app/features/auth-config/state/actions.ts +++ b/public/app/features/auth-config/state/actions.ts @@ -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> { 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> { }; } +export function loadProviders(): ThunkResult> { + 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 { return async (dispatch) => { const registeredProviders = getRegisteredAuthProviders(); @@ -33,8 +53,7 @@ export function loadProviderStatuses(): ThunkResult { 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)); }; diff --git a/public/app/features/auth-config/state/reducers.ts b/public/app/features/auth-config/state/reducers.ts index 8153760f1dd..0194ed538d0 100644 --- a/public/app/features/auth-config/state/reducers.ts +++ b/public/app/features/auth-config/state/reducers.ts @@ -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): AuthConfigState => { + return { ...state, providers: action.payload }; + }, }, }); @@ -50,6 +54,7 @@ export const { resetError, setWarning, resetWarning, + providersLoaded, } = authConfigSlice.actions; export const authConfigReducer = authConfigSlice.reducer; diff --git a/public/app/features/auth-config/types.ts b/public/app/features/auth-config/types.ts index 6dfb6ae5f7e..2a2791d7703 100644 --- a/public/app/features/auth-config/types.ts +++ b/public/app/features/auth-config/types.ts @@ -10,12 +10,25 @@ export interface AuthProviderInfo { export type GetStatusHook = () => Promise; +export type SSOProvider = { + provider: string; + settings: { + enabled: boolean; + name: string; + type: string; + + // Legacy fields + configPath?: string; + }; +}; + export interface AuthConfigState { settings: Settings; providerStatuses: Record; isLoading?: boolean; updateError?: SettingsError; warning?: SettingsError; + providers: SSOProvider[]; } export interface AuthProviderStatus { diff --git a/public/app/routes/routes.tsx b/public/app/routes/routes.tsx index ad175440b2e..c3af563468e 100644 --- a/public/app/routes/routes.tsx +++ b/public/app/routes/routes.tsx @@ -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') )