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:
@@ -345,6 +345,7 @@ lerna.json @grafana/frontend-ops
|
||||
/public/app/core/components/Layers/ @grafana/dataviz-squad
|
||||
/public/app/features/all.ts @grafana/grafana-frontend-platform
|
||||
/public/app/features/admin/ @grafana/grafana-authnz-team
|
||||
/public/app/features/auth-config/ @grafana/grafana-authnz-team
|
||||
/public/app/features/annotations/ @grafana/grafana-frontend-platform
|
||||
/public/app/features/api-keys/ @grafana/grafana-frontend-platform
|
||||
/public/app/features/canvas/ @grafana/dataviz-squad
|
||||
|
||||
@@ -71,6 +71,7 @@ Alpha features might be changed or removed without prior notice.
|
||||
| `storage` | Configurable storage for dashboards, datasources, and resources |
|
||||
| `exploreMixedDatasource` | Enable mixed datasource in Explore |
|
||||
| `newTraceView` | Shows the new trace view design |
|
||||
| `correlations` | Correlations page |
|
||||
| `datasourceQueryMultiStatus` | Introduce HTTP 207 Multi Status for api/ds/query |
|
||||
| `traceToMetrics` | Enable trace to metrics links |
|
||||
| `prometheusWideSeries` | Enable wide series responses in the Prometheus datasource |
|
||||
@@ -109,6 +110,7 @@ Alpha features might be changed or removed without prior notice.
|
||||
| `unifiedRequestLog` | Writes error logs to the request logger |
|
||||
| `pyroscopeFlameGraph` | Changes flame graph to pyroscope one |
|
||||
| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different |
|
||||
| `authenticationConfigUI` | Enables authentication configuration UI |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
||||
@@ -97,4 +97,5 @@ export interface FeatureToggles {
|
||||
dataplaneFrontendFallback?: boolean;
|
||||
useCachingService?: boolean;
|
||||
disableElasticsearchBackendQuerying?: boolean;
|
||||
authenticationConfigUI?: boolean;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function ClipboardButton({
|
||||
variant={showCopySuccess ? 'success' : variant}
|
||||
aria-label={showCopySuccess ? 'Copied' : undefined}
|
||||
{...buttonProps}
|
||||
className={cx(styles.button, showCopySuccess && styles.successButton)}
|
||||
className={cx(styles.button, showCopySuccess && styles.successButton, buttonProps.className)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -309,7 +309,8 @@ const (
|
||||
ActionServerStatsRead = "server.stats:read"
|
||||
|
||||
// Settings actions
|
||||
ActionSettingsRead = "settings:read"
|
||||
ActionSettingsRead = "settings:read"
|
||||
ActionSettingsWrite = "settings:write"
|
||||
|
||||
// Datasources actions
|
||||
ActionDatasourcesExplore = "datasources:explore"
|
||||
@@ -324,7 +325,9 @@ const (
|
||||
ScopeUsersAll = "users:*"
|
||||
|
||||
// Settings scope
|
||||
ScopeSettingsAll = "settings:*"
|
||||
ScopeSettingsAll = "settings:*"
|
||||
ScopeSettingsAuth = "settings:auth:*"
|
||||
ScopeSettingsSAML = "settings:auth.saml:*"
|
||||
|
||||
// Team related actions
|
||||
ActionTeamsCreate = "teams:create"
|
||||
|
||||
@@ -171,6 +171,31 @@ var (
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
authenticationConfigWriterRole = RoleDTO{
|
||||
Name: "fixed:authentication.config:writer",
|
||||
DisplayName: "Authentication config writer",
|
||||
Description: "Read and update authentication configuration and access configuration UI.",
|
||||
Group: "Settings",
|
||||
Permissions: []Permission{
|
||||
{
|
||||
Action: ActionSettingsRead,
|
||||
Scope: ScopeSettingsAuth,
|
||||
},
|
||||
{
|
||||
Action: ActionSettingsRead,
|
||||
Scope: ScopeSettingsSAML,
|
||||
},
|
||||
{
|
||||
Action: ActionSettingsWrite,
|
||||
Scope: ScopeSettingsAuth,
|
||||
},
|
||||
{
|
||||
Action: ActionSettingsWrite,
|
||||
Scope: ScopeSettingsSAML,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Declare OSS roles to the accesscontrol service
|
||||
@@ -207,9 +232,13 @@ func DeclareFixedRoles(service Service) error {
|
||||
Role: usersWriterRole,
|
||||
Grants: []string{RoleGrafanaAdmin},
|
||||
}
|
||||
authenticationConfigWriter := RoleRegistration{
|
||||
Role: authenticationConfigWriterRole,
|
||||
Grants: []string{RoleGrafanaAdmin},
|
||||
}
|
||||
|
||||
return service.DeclareFixedRoles(ldapReader, ldapWriter, orgUsersReader, orgUsersWriter,
|
||||
settingsReader, statsReader, usersReader, usersWriter)
|
||||
settingsReader, statsReader, usersReader, usersWriter, authenticationConfigWriter)
|
||||
}
|
||||
|
||||
func ConcatPermissions(permissions ...[]Permission) []Permission {
|
||||
|
||||
@@ -526,5 +526,11 @@ var (
|
||||
State: FeatureStateStable,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "authenticationConfigUI",
|
||||
Description: "Enables authentication configuration UI",
|
||||
State: FeatureStateAlpha,
|
||||
Owner: grafanaAuthnzSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -78,3 +78,4 @@ externalServiceAuth,alpha,@grafana/grafana-authnz-team,true,false,false,false
|
||||
dataplaneFrontendFallback,alpha,@grafana/observability-metrics,false,false,false,true
|
||||
useCachingService,stable,@grafana/grafana-operator-experience-squad,false,false,true,false
|
||||
disableElasticsearchBackendQuerying,stable,@grafana/observability-logs,false,false,false,false
|
||||
authenticationConfigUI,alpha,@grafana/grafana-authnz-team,false,false,false,false
|
||||
|
||||
|
@@ -322,4 +322,8 @@ const (
|
||||
// FlagDisableElasticsearchBackendQuerying
|
||||
// Disable the processing of queries and responses in the Elasticsearch data source through backend
|
||||
FlagDisableElasticsearchBackendQuerying = "disableElasticsearchBackendQuerying"
|
||||
|
||||
// FlagAuthenticationConfigUI
|
||||
// Enables authentication configuration UI
|
||||
FlagAuthenticationConfigUI = "authenticationConfigUI"
|
||||
)
|
||||
|
||||
@@ -192,6 +192,7 @@ func ApplyAdminIA(root *NavTreeRoot) {
|
||||
orgSettings.Text = "Default preferences"
|
||||
adminNodeLinks = append(adminNodeLinks, orgSettings)
|
||||
}
|
||||
adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("authentication"))
|
||||
adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("server-settings"))
|
||||
adminNodeLinks = AppendIfNotNil(adminNodeLinks, root.FindById("global-orgs"))
|
||||
|
||||
|
||||
@@ -136,6 +136,17 @@ func (s *ServiceImpl) getServerAdminNode(c *contextmodel.ReqContext) *navtree.Na
|
||||
}
|
||||
}
|
||||
|
||||
authConfigUIAvailable := s.license.FeatureEnabled("saml") && s.features.IsEnabled(featuremgmt.FlagAuthenticationConfigUI)
|
||||
if authConfigUIAvailable && hasAccess(ac.ReqGrafanaAdmin, evalAuthenticationSettings()) {
|
||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||
Text: "Authentication",
|
||||
Id: "authentication",
|
||||
SubTitle: "Manage your auth settings and configure single sign-on",
|
||||
Icon: "signin",
|
||||
Url: s.cfg.AppSubURL + "/admin/authentication",
|
||||
})
|
||||
}
|
||||
|
||||
if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) {
|
||||
adminNavLinks = append(adminNavLinks, &navtree.NavLink{
|
||||
Text: "Organizations", SubTitle: "Isolated instances of Grafana running on the same server", Id: "global-orgs", Url: s.cfg.AppSubURL + "/admin/orgs", Icon: "building",
|
||||
@@ -189,3 +200,12 @@ func enableServiceAccount(s *ServiceImpl, c *contextmodel.ReqContext) bool {
|
||||
hasAccess := ac.HasAccess(s.accessControl, c)
|
||||
return hasAccess(ac.ReqOrgAdmin, serviceaccounts.AccessEvaluator)
|
||||
}
|
||||
|
||||
func evalAuthenticationSettings() ac.Evaluator {
|
||||
return ac.EvalAll(
|
||||
ac.EvalPermission(ac.ActionSettingsWrite, ac.ScopeSettingsAuth),
|
||||
ac.EvalPermission(ac.ActionSettingsWrite, ac.ScopeSettingsSAML),
|
||||
ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsAuth),
|
||||
ac.EvalPermission(ac.ActionSettingsRead, ac.ScopeSettingsSAML),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/licensing"
|
||||
"github.com/grafana/grafana/pkg/services/navtree"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginsettings"
|
||||
@@ -35,6 +36,7 @@ type ServiceImpl struct {
|
||||
accesscontrolService ac.Service
|
||||
kvStore kvstore.KVStore
|
||||
apiKeyService apikey.Service
|
||||
license licensing.Licensing
|
||||
|
||||
// Navigation
|
||||
navigationAppConfig map[string]NavigationAppConfig
|
||||
@@ -48,7 +50,7 @@ type NavigationAppConfig struct {
|
||||
Icon string
|
||||
}
|
||||
|
||||
func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore plugins.Store, pluginSettings pluginsettings.Service, starService star.Service, features *featuremgmt.FeatureManager, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service) navtree.Service {
|
||||
func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStore plugins.Store, pluginSettings pluginsettings.Service, starService star.Service, features *featuremgmt.FeatureManager, dashboardService dashboards.DashboardService, accesscontrolService ac.Service, kvStore kvstore.KVStore, apiKeyService apikey.Service, license licensing.Licensing) navtree.Service {
|
||||
service := &ServiceImpl{
|
||||
cfg: cfg,
|
||||
log: log.New("navtree service"),
|
||||
@@ -61,6 +63,7 @@ func ProvideService(cfg *setting.Cfg, accessControl ac.AccessControl, pluginStor
|
||||
accesscontrolService: accesscontrolService,
|
||||
kvStore: kvStore,
|
||||
apiKeyService: apiKeyService,
|
||||
license: license,
|
||||
}
|
||||
|
||||
service.readNavigationSettings()
|
||||
|
||||
@@ -4,6 +4,7 @@ import sharedReducers from 'app/core/reducers';
|
||||
import ldapReducers from 'app/features/admin/state/reducers';
|
||||
import alertingReducers from 'app/features/alerting/state/reducers';
|
||||
import apiKeysReducers from 'app/features/api-keys/state/reducers';
|
||||
import authConfigReducers from 'app/features/auth-config/state/reducers';
|
||||
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
|
||||
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers';
|
||||
@@ -46,6 +47,7 @@ const rootReducers = {
|
||||
...panelsReducers,
|
||||
...templatingReducers,
|
||||
...supportBundlesReducer,
|
||||
...authConfigReducers,
|
||||
plugins: pluginsReducer,
|
||||
[alertingApi.reducerPath]: alertingApi.reducer,
|
||||
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -313,6 +313,14 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
component: SafeDynamicImport(() => import(/* webpackChunkName: "TeamPages" */ 'app/features/teams/TeamPages')),
|
||||
},
|
||||
// ADMIN
|
||||
{
|
||||
path: '/admin/authentication',
|
||||
component: config.featureToggles.authenticationConfigUI
|
||||
? SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "AdminAuthentication" */ 'app/features/auth-config/AuthConfigPage')
|
||||
)
|
||||
: () => <Redirect to="/admin" />,
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: () => (config.featureToggles.topnav ? <NavLandingPage navId="cfg" /> : <Redirect to="/admin/users" />),
|
||||
|
||||
@@ -127,6 +127,9 @@ export enum AccessControlAction {
|
||||
|
||||
PluginsInstall = 'plugins:install',
|
||||
PluginsWrite = 'plugins:write',
|
||||
|
||||
// Settings
|
||||
SettingsRead = 'settings:read',
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Settings } from 'app/types';
|
||||
|
||||
export interface AuthConfigState {
|
||||
settings: Settings;
|
||||
providerStatuses: Record<string, AuthProviderStatus>;
|
||||
isLoading?: boolean;
|
||||
updateError?: SettingsError;
|
||||
warning?: SettingsError;
|
||||
}
|
||||
|
||||
export interface AuthProviderStatus {
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsError {
|
||||
message: string;
|
||||
errors: string[];
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export * from './appEvent';
|
||||
export * from './query';
|
||||
export * from './accessControl';
|
||||
export * from './supportBundles';
|
||||
export * from './configAuth';
|
||||
export * from './settings';
|
||||
|
||||
import * as CoreEvents from './events';
|
||||
export { CoreEvents };
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type Settings = { [key: string]: SettingsSection };
|
||||
|
||||
export type SettingsSection = Record<string, string>;
|
||||
|
||||
export interface UpdateSettingsQuery {
|
||||
updates?: Settings;
|
||||
removals?: Record<string, string[]>;
|
||||
}
|
||||
Reference in New Issue
Block a user