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
+1
View File
@@ -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}
+5 -2
View File
@@ -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"
+30 -1
View File
@@ -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 {
+6
View File
@@ -526,5 +526,11 @@ var (
State: FeatureStateStable,
Owner: grafanaObservabilityLogsSquad,
},
{
Name: "authenticationConfigUI",
Description: "Enables authentication configuration UI",
State: FeatureStateAlpha,
Owner: grafanaAuthnzSquad,
},
}
)
+1
View File
@@ -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
1 Name State Owner requiresDevMode RequiresLicense RequiresRestart FrontendOnly
78 dataplaneFrontendFallback alpha @grafana/observability-metrics false false false true
79 useCachingService stable @grafana/grafana-operator-experience-squad false false true false
80 disableElasticsearchBackendQuerying stable @grafana/observability-logs false false false false
81 authenticationConfigUI alpha @grafana/grafana-authnz-team false false false false
+4
View File
@@ -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"
)
+1
View File
@@ -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"))
+20
View File
@@ -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),
)
}
+4 -1
View File
@@ -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()
+2
View File
@@ -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/';
+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);
}
+8
View File
@@ -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" />),
+3
View File
@@ -127,6 +127,9 @@ export enum AccessControlAction {
PluginsInstall = 'plugins:install',
PluginsWrite = 'plugins:write',
// Settings
SettingsRead = 'settings:read',
}
export interface Role {
+19
View File
@@ -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[];
}
+2
View File
@@ -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 };
+8
View File
@@ -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[]>;
}