Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions common/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const API_PREFIX = '/api/v1';
export const CONFIGURATION_API_PREFIX = 'configuration';
export const API_ENDPOINT_AUTHINFO = API_PREFIX + '/auth/authinfo';
export const API_ENDPOINT_DASHBOARDSINFO = API_PREFIX + '/auth/dashboardsinfo';
export const API_ENDPOINT_DASHBOARD_SIGNIN_OPTIONS = API_ENDPOINT_DASHBOARDSINFO + '/signinoptions';
export const API_ENDPOINT_AUTHTYPE = API_PREFIX + '/auth/type';
export const LOGIN_PAGE_URI = '/app/' + APP_ID_LOGIN;
export const CUSTOM_ERROR_PAGE_URI = '/app/' + APP_ID_CUSTOMERROR;
Expand Down
28 changes: 28 additions & 0 deletions public/apps/configuration/panels/auth-view/auth-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,17 @@ import { SecurityPluginTopNavMenu } from '../../top-nav-menu';
import { AccessErrorComponent } from '../../access-error-component';
import { PageHeader } from '../../header/header-components';
import { ResourceType } from '../../../../../common';
import { getDashboardsSignInOptions } from '../../../../utils/dashboards-info-utils';
import { DashboardSignInOption } from '../../types';
import { SignInOptionsPanel } from './dashboard-signin-options';
import { LocalCluster } from '../../../../utils/datasource-utils';

export function AuthView(props: AppDependencies) {
const [authentication, setAuthentication] = React.useState([]);
const [authorization, setAuthorization] = React.useState([]);
const [dashboardSignInOptions, setDashboardSignInOptions] = React.useState<
DashboardSignInOption[]
>([]);
const [loading, setLoading] = React.useState(false);
const { dataSource, setDataSource } = useContext(DataSourceContext)!;
const [errorFlag, setErrorFlag] = React.useState(false);
Expand All @@ -45,6 +52,15 @@ export function AuthView(props: AppDependencies) {

setAuthentication(config.authc);
setAuthorization(config.authz);
if (dataSource.id === LocalCluster.id) {
try {
setDashboardSignInOptions(await getDashboardsSignInOptions(props.coreStart.http));
} catch (error) {
setDashboardSignInOptions([]);
}
} else {
setDashboardSignInOptions([]);
}
setErrorFlag(false);
setAccessErrorFlag(false);
} catch (e) {
Expand Down Expand Up @@ -139,6 +155,18 @@ export function AuthView(props: AppDependencies) {
<>
{/* @ts-ignore */}
<AuthenticationSequencePanel authc={authentication} loading={loading} />
{dataSource.id === LocalCluster.id && (
<>
<EuiSpacer size="m" />
{/* @ts-ignore */}
<SignInOptionsPanel
authc={authentication}
signInEnabledOptions={dashboardSignInOptions}
http={props.coreStart.http}
isAnonymousAuthEnabled={Boolean(props.config.auth.anonymous_auth_enabled)}
/>
</>
)}
<EuiSpacer size="m" />
{/* @ts-ignore */}
<AuthorizationPanel authz={authorization} loading={loading} config={props.config} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
/*
* Copyright OpenSearch Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

import {
EuiBasicTableColumn,
EuiFlexGroup,
EuiGlobalToastList,
EuiHealth,
EuiHorizontalRule,
EuiInMemoryTable,
EuiPageContentHeader,
EuiPageContentHeaderSection,
EuiPanel,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { get, keys } from 'lodash';
import { HttpStart } from 'opensearch-dashboards/public';
import React from 'react';
import { updateDashboardSignInOptions } from '../../../../utils/dashboards-info-utils';
import { DashboardOption, DashboardSignInOption } from '../../types';
import { createErrorToast, createSuccessToast, useToastState } from '../../utils/toast-utils';
import { SignInOptionsModal } from './signin-options-modal';

interface SignInOptionsPanelProps {
authc: [];
signInEnabledOptions: DashboardSignInOption[];
http: HttpStart;
isAnonymousAuthEnabled: boolean;
}

const OPTION_LABELS: Record<DashboardSignInOption, string> = {
[DashboardSignInOption.BASIC]: 'Basic authentication',
[DashboardSignInOption.OPEN_ID]: 'OpenID Connect',
[DashboardSignInOption.SAML]: 'SAML',
[DashboardSignInOption.ANONYMOUS]: 'Anonymous',
};

export const columns: Array<EuiBasicTableColumn<DashboardOption>> = [
{
field: 'displayName',
name: 'Name',
dataType: 'string',
sortable: true,
},
{
field: 'status',
name: 'Status',
render: (enabled: DashboardOption['status']) => (
<EuiHealth color={enabled ? 'success' : 'subdued'}>
{enabled ? 'Enabled' : 'Disabled'}
</EuiHealth>
),
},
];

function getDashboardOptions(
authc: [],
enabledOptions: DashboardSignInOption[],
isAnonymousAuthEnabled: boolean
) {
const options = keys(authc)
.map((domain) => get(authc, [domain, 'http_authenticator', 'type']))
.filter((option): option is string => Boolean(option))
.map((option) => {
switch (option.toLowerCase()) {
case 'basic':
case DashboardSignInOption.BASIC:
return DashboardSignInOption.BASIC;
case DashboardSignInOption.OPEN_ID:
return DashboardSignInOption.OPEN_ID;
case DashboardSignInOption.SAML:
return DashboardSignInOption.SAML;
default:
return undefined;
}
})
.filter((option): option is DashboardSignInOption => Boolean(option))
.filter((option): option is DashboardSignInOption =>
[
DashboardSignInOption.BASIC,
DashboardSignInOption.OPEN_ID,
DashboardSignInOption.SAML,
].includes(option as DashboardSignInOption)
)
.filter((option, index, arr) => arr.indexOf(option) === index)
.map((option) => ({
name: option,
displayName: OPTION_LABELS[option],
status: enabledOptions.includes(option),
}));

if (isAnonymousAuthEnabled) {
options.push({
name: DashboardSignInOption.ANONYMOUS,
displayName: OPTION_LABELS[DashboardSignInOption.ANONYMOUS],
status: enabledOptions.includes(DashboardSignInOption.ANONYMOUS),
});
}

return options.sort((a, b) => a.displayName.localeCompare(b.displayName));
}

export function SignInOptionsPanel(props: SignInOptionsPanelProps) {
const [toasts, addToast, removeToast] = useToastState();
const [dashboardOptions, setDashboardOptions] = React.useState<DashboardOption[]>(() =>
getDashboardOptions(props.authc, props.signInEnabledOptions, props.isAnonymousAuthEnabled)
);

React.useEffect(() => {
setDashboardOptions(
getDashboardOptions(props.authc, props.signInEnabledOptions, props.isAnonymousAuthEnabled)
);
}, [props.authc, props.signInEnabledOptions, props.isAnonymousAuthEnabled]);

const handleUpdate = async (selectedOptions: DashboardOption[]) => {
const selectedNames = selectedOptions.map((option) => option.name);

try {
await updateDashboardSignInOptions(props.http, selectedNames);
setDashboardOptions((currentOptions) =>
currentOptions.map((option) => ({
...option,
status: selectedNames.includes(option.name),
}))
);
addToast(
createSuccessToast(
'dashboard-signin-options-success',
'Dashboards sign-in options updated',
'Changes applied.'
)
);
} catch (error) {
addToast(
createErrorToast(
'dashboard-signin-options-error',
'Dashboards sign-in options not updated',
error instanceof Error ? error.message : 'Error updating values.'
)
);
}
};

return (
<EuiPanel>
<EuiPageContentHeader>
<EuiPageContentHeaderSection>
<EuiTitle size="s">
<h3>Dashboards sign-in options</h3>
</EuiTitle>
<EuiText size="xs" color="subdued">
<p>
Choose which configured authentication methods appear on the Dashboards login page.
</p>
</EuiText>
</EuiPageContentHeaderSection>
<EuiPageContentHeaderSection>
<EuiFlexGroup responsive={false}>
<SignInOptionsModal dashboardOptions={dashboardOptions} handleUpdate={handleUpdate} />
</EuiFlexGroup>
</EuiPageContentHeaderSection>
</EuiPageContentHeader>
<EuiHorizontalRule margin="m" />
<EuiInMemoryTable
tableLayout="auto"
columns={columns}
items={dashboardOptions}
itemId="name"
pagination={false}
sorting={{ sort: { field: 'displayName', direction: 'asc' } }}
/>
<EuiGlobalToastList toasts={toasts} toastLifeTimeMs={3000} dismissToast={removeToast} />
</EuiPanel>
);
}
124 changes: 124 additions & 0 deletions public/apps/configuration/panels/auth-view/signin-options-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
* Copyright OpenSearch Contributors
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/

import {
EuiButton,
EuiCallOut,
EuiInMemoryTable,
EuiModal,
EuiModalBody,
EuiModalFooter,
EuiModalHeader,
EuiModalHeaderTitle,
EuiSpacer,
} from '@elastic/eui';
import React from 'react';
import { DashboardOption } from '../../types';
import { columns } from './dashboard-signin-options';

interface SignInOptionsModalProps {
dashboardOptions: DashboardOption[];
handleUpdate: (selectedOptions: DashboardOption[]) => Promise<void>;
}

function haveSameSelection(left: DashboardOption[], right: DashboardOption[]) {
if (left.length !== right.length) {
return false;
}

return left.every((option) =>
right.some((selectedOption) => selectedOption.name === option.name)
);
}

export function SignInOptionsModal(props: SignInOptionsModalProps) {
const selectedOptions = React.useMemo(
() => props.dashboardOptions.filter((option) => option.status),
[props.dashboardOptions]
);
const [isModalVisible, setIsModalVisible] = React.useState(false);
const [newSignInOptions, setNewSignInOptions] = React.useState<DashboardOption[]>(
selectedOptions
);

React.useEffect(() => {
if (!isModalVisible) {
setNewSignInOptions(selectedOptions);
}
}, [isModalVisible, selectedOptions]);

const disableUpdate = haveSameSelection(
newSignInOptions,
props.dashboardOptions.filter((option) => option.status)
);

return (
<>
<EuiButton
data-test-subj="editDashboardSigninOptions"
onClick={() => setIsModalVisible(true)}
>
Edit
</EuiButton>
{isModalVisible && (
<EuiModal onClose={() => setIsModalVisible(false)}>
<EuiModalHeader>
<EuiModalHeaderTitle>Dashboards sign-in options</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
Select which configured authentication methods appear on the Dashboards login page.
<EuiSpacer />
{newSignInOptions.length === 0 && (
<>
<EuiCallOut
color="warning"
iconType="alert"
title="Select at least one sign-in option."
/>
<EuiSpacer />
</>
)}
<EuiInMemoryTable
tableLayout="auto"
columns={columns.slice(0, 1)}
items={props.dashboardOptions}
itemId="name"
pagination={false}
selection={{
onSelectionChange: setNewSignInOptions,
initialSelected: selectedOptions,
}}
sorting={{ sort: { field: 'displayName', direction: 'asc' } }}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiButton onClick={() => setIsModalVisible(false)}>Cancel</EuiButton>
<EuiButton
fill
data-test-subj="updateDashboardSigninOptions"
disabled={disableUpdate || newSignInOptions.length === 0}
onClick={async () => {
await props.handleUpdate(newSignInOptions);
setIsModalVisible(false);
}}
>
Update
</EuiButton>
</EuiModalFooter>
</EuiModal>
)}
</>
);
}
Loading
Loading