FIx #7721 UI : Implement Authentication Mechanism on the bots details page (#7722)

* FIx #7721 UI : Implement Authentication Mechanism on the bots details page

* Addressing review comment
This commit is contained in:
Sachin Chaurasiya 2022-09-26 20:25:49 +05:30 committed by GitHub
parent cfdc50e18f
commit 448f2de3fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 969 additions and 332 deletions

View File

@ -15,7 +15,10 @@ import { AxiosResponse } from 'axios';
import { Operation } from 'fast-json-patch'; import { Operation } from 'fast-json-patch';
import { isNil, isUndefined } from 'lodash'; import { isNil, isUndefined } from 'lodash';
import { SearchIndex } from '../enums/search.enum'; import { SearchIndex } from '../enums/search.enum';
import { CreateUser } from '../generated/api/teams/createUser'; import {
AuthenticationMechanism,
CreateUser,
} from '../generated/api/teams/createUser';
import { JwtAuth } from '../generated/entity/teams/authN/jwtAuth'; import { JwtAuth } from '../generated/entity/teams/authN/jwtAuth';
import { User } from '../generated/entity/teams/user'; import { User } from '../generated/entity/teams/user';
import { EntityReference } from '../generated/type/entityReference'; import { EntityReference } from '../generated/type/entityReference';
@ -185,3 +188,11 @@ export const getGroupTypeTeams = async () => {
return response.data; return response.data;
}; };
export const getAuthMechanismForBotUser = async (botId: string) => {
const response = await APIClient.get<AuthenticationMechanism>(
`/users/auth-mechanism/${botId}`
);
return response.data;
};

View File

@ -0,0 +1,18 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License 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.
*/
.ant-space-authMechanism {
.ant-space-item:first-child {
width: 100%;
}
}

View File

@ -0,0 +1,293 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License 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 { Button, Divider, Input, Space, Typography } from 'antd';
import { capitalize } from 'lodash';
import React, { FC } from 'react';
import { AuthType } from '../../generated/api/teams/createUser';
import { AuthenticationMechanism } from '../../generated/entity/teams/user';
import { getTokenExpiry } from '../../utils/BotsUtils';
import SVGIcons from '../../utils/SvgUtils';
import CopyToClipboardButton from '../buttons/CopyToClipboardButton/CopyToClipboardButton';
import './AuthMechanism.less';
interface Props {
authenticationMechanism: AuthenticationMechanism;
hasPermission: boolean;
onEdit: () => void;
onTokenRevoke: () => void;
}
const AuthMechanism: FC<Props> = ({
authenticationMechanism,
hasPermission,
onEdit,
onTokenRevoke,
}: Props) => {
if (authenticationMechanism.authType === AuthType.Jwt) {
const JWTToken = authenticationMechanism.config?.JWTToken;
const JWTTokenExpiresAt =
authenticationMechanism.config?.JWTTokenExpiresAt ?? 0;
// get the token expiry date
const { tokenExpiryDate, isTokenExpired } =
getTokenExpiry(JWTTokenExpiresAt);
return (
<>
<Space className="w-full tw-justify-between">
<Typography.Text className="tw-text-base">
OpenMetadata JWT Token
</Typography.Text>
<Space>
{JWTToken ? (
<Button
danger
data-testid="revoke-button"
disabled={!hasPermission}
size="small"
type="default"
onClick={onTokenRevoke}>
Revoke token
</Button>
) : (
<Button
disabled={!hasPermission}
size="small"
type="primary"
onClick={onEdit}>
Generate New Token
</Button>
)}
</Space>
</Space>
<Divider style={{ margin: '8px 0px' }} />
<Typography.Paragraph>
Token you have generated that can be used to access the OpenMetadata
API.
</Typography.Paragraph>
{JWTToken ? (
<>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input.Password
contentEditable={false}
data-testid="token"
placeholder="Generate new token..."
value={JWTToken}
/>
<CopyToClipboardButton copyText={JWTToken} />
</Space>
<p
className="tw-text-grey-muted tw-mt-2 tw-italic"
data-testid="token-expiry">
{JWTTokenExpiresAt ? (
isTokenExpired ? (
`Expired on ${tokenExpiryDate}.`
) : (
`Expires on ${tokenExpiryDate}.`
)
) : (
<>
<SVGIcons alt="warning" icon="error" />
<span className="tw-ml-1 tw-align-middle">
This token has no expiration date.
</span>
</>
)}
</p>
</>
) : (
<div
className="tw-no-description tw-text-sm tw-mt-4"
data-testid="no-token">
No token available
</div>
)}
</>
);
}
if (authenticationMechanism.authType === AuthType.Sso) {
const authConfig = authenticationMechanism.config?.authConfig;
const ssoType = authenticationMechanism.config?.ssoServiceType;
return (
<>
<Space className="w-full tw-justify-between">
<Typography.Text>{`${capitalize(ssoType)} SSO`}</Typography.Text>
<Button
disabled={!hasPermission}
size="small"
type="primary"
onClick={onEdit}>
Edit
</Button>
</Space>
<Divider style={{ margin: '8px 0px' }} />
<Space className="w-full" direction="vertical">
{authConfig?.secretKey && (
<>
<Typography.Text>SecretKey</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input.Password
contentEditable={false}
data-testid="secretKey"
value={authConfig?.secretKey}
/>
<CopyToClipboardButton copyText={authConfig?.secretKey} />
</Space>
</>
)}
{authConfig?.privateKey && (
<>
<Typography.Text>PrivateKey</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input.Password
contentEditable={false}
data-testid="privateKey"
value={authConfig?.privateKey}
/>
<CopyToClipboardButton copyText={authConfig?.privateKey} />
</Space>
</>
)}
{authConfig?.clientSecret && (
<>
<Typography.Text>ClientSecret</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input.Password
contentEditable={false}
data-testid="clientSecret"
value={authConfig?.clientSecret}
/>
<CopyToClipboardButton copyText={authConfig?.clientSecret} />
</Space>
</>
)}
{authConfig?.audience && (
<>
<Typography.Text>Audience</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input
contentEditable={false}
data-testid="audience"
value={authConfig?.audience}
/>
<CopyToClipboardButton copyText={authConfig?.audience} />
</Space>
</>
)}
{authConfig?.clientId && (
<>
<Typography.Text>ClientId</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input
contentEditable={false}
data-testid="clientId"
value={authConfig?.clientId}
/>
<CopyToClipboardButton copyText={authConfig?.clientId} />
</Space>
</>
)}
{authConfig?.email && (
<>
<Typography.Text>Email</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input
contentEditable={false}
data-testid="email"
value={authConfig?.email}
/>
<CopyToClipboardButton copyText={authConfig?.email} />
</Space>
</>
)}
{authConfig?.orgURL && (
<>
<Typography.Text>OrgURL</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input
contentEditable={false}
data-testid="orgURL"
value={authConfig?.orgURL}
/>
<CopyToClipboardButton copyText={authConfig?.orgURL} />
</Space>
</>
)}
{authConfig?.scopes && (
<>
<Typography.Text>Scopes</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input
contentEditable={false}
data-testid="scopes"
value={authConfig?.scopes.join(',')}
/>
<CopyToClipboardButton
copyText={authConfig?.scopes.join(',')}
/>
</Space>
</>
)}
{authConfig?.domain && (
<>
<Typography.Text>Domain</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input
contentEditable={false}
data-testid="domain"
value={authConfig?.domain}
/>
<CopyToClipboardButton copyText={authConfig?.domain} />
</Space>
</>
)}
{authConfig?.authority && (
<>
<Typography.Text>Authority</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input
contentEditable={false}
data-testid="authority"
value={authConfig?.authority}
/>
<CopyToClipboardButton copyText={authConfig?.authority} />
</Space>
</>
)}
{authConfig?.tokenEndpoint && (
<>
<Typography.Text>TokenEndpoint</Typography.Text>
<Space className="w-full tw-justify-between ant-space-authMechanism">
<Input
contentEditable={false}
data-testid="tokenEndpoint"
value={authConfig?.tokenEndpoint}
/>
<CopyToClipboardButton copyText={authConfig?.tokenEndpoint} />
</Space>
</>
)}
</Space>
</>
);
}
return null;
};
export default AuthMechanism;

View File

@ -0,0 +1,542 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License 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 { Button, Form, Input, Select, Space } from 'antd';
import React, { FC, useEffect, useState } from 'react';
import { useAuthContext } from '../../authentication/auth-provider/AuthProvider';
import { SsoServiceType } from '../../generated/entity/teams/authN/ssoAuth';
import {
AuthenticationMechanism,
AuthType,
JWTTokenExpiry,
} from '../../generated/entity/teams/user';
import { Auth0SSOClientConfig } from '../../generated/security/client/auth0SSOClientConfig';
import { AzureSSOClientConfig } from '../../generated/security/client/azureSSOClientConfig';
import { CustomOidcSSOClientConfig } from '../../generated/security/client/customOidcSSOClientConfig';
import { GoogleSSOClientConfig } from '../../generated/security/client/googleSSOClientConfig';
import { OktaSSOClientConfig } from '../../generated/security/client/oktaSSOClientConfig';
import {
getAuthMechanismTypeOptions,
getJWTTokenExpiryOptions,
} from '../../utils/BotsUtils';
import { SSOClientConfig } from '../CreateUser/CreateUser.interface';
import Loader from '../Loader/Loader';
const { Option } = Select;
interface Props {
isUpdating: boolean;
authenticationMechanism: AuthenticationMechanism;
onSave: (updatedAuthMechanism: AuthenticationMechanism) => void;
onCancel: () => void;
}
const AuthMechanismForm: FC<Props> = ({
isUpdating,
onSave,
onCancel,
authenticationMechanism,
}) => {
const { authConfig } = useAuthContext();
const [authMechanism, setAuthMechanism] = useState<AuthType>(
authenticationMechanism.authType ?? AuthType.Jwt
);
const [tokenExpiry, setTokenExpiry] = useState<JWTTokenExpiry>(
authenticationMechanism.config?.JWTTokenExpiry ?? JWTTokenExpiry.OneHour
);
const [ssoClientConfig, setSSOClientConfig] = useState<SSOClientConfig>(
(authenticationMechanism.config?.authConfig as SSOClientConfig) ??
({} as SSOClientConfig)
);
useEffect(() => {
const authType = authenticationMechanism.authType;
const authConfig = authenticationMechanism.config?.authConfig;
const JWTTokenExpiryValue = authenticationMechanism.config?.JWTTokenExpiry;
setAuthMechanism(authType ?? AuthType.Jwt);
setSSOClientConfig(
(authConfig as SSOClientConfig) ?? ({} as SSOClientConfig)
);
setTokenExpiry(JWTTokenExpiryValue ?? JWTTokenExpiry.OneHour);
}, [authenticationMechanism]);
/**
* Handle on change event
* @param event
*/
const handleOnChange = (
event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const value = event.target.value;
const eleName = event.target.name;
switch (eleName) {
case 'secretKey':
case 'audience':
case 'clientId':
case 'domain':
case 'clientSecret':
case 'authority':
case 'privateKey':
case 'orgURL':
case 'tokenEndpoint':
setSSOClientConfig((previous) => ({
...previous,
[eleName]: value,
}));
break;
case 'scopes':
setSSOClientConfig((previous) => ({
...previous,
scopes: value ? value.split(',') : [],
}));
break;
case 'oktaEmail':
setSSOClientConfig((previous) => ({
...previous,
email: value,
}));
break;
default:
break;
}
};
const handleSave = () => {
const updatedAuthMechanism: AuthenticationMechanism = {
authType: authMechanism,
config:
authMechanism === AuthType.Jwt
? {
JWTTokenExpiry: tokenExpiry,
}
: {
ssoServiceType: authConfig?.provider as SsoServiceType,
authConfig: {
...ssoClientConfig,
},
},
};
onSave(updatedAuthMechanism);
};
const getSSOConfig = () => {
switch (authConfig?.provider) {
case SsoServiceType.Google: {
const googleConfig = ssoClientConfig as GoogleSSOClientConfig;
return (
<>
<Form.Item
label="SecretKey"
name="secretKey"
rules={[
{
required: true,
message: 'SecretKey is required',
},
]}>
<Input.Password
data-testid="secretKey"
name="secretKey"
placeholder="secretKey"
value={googleConfig.secretKey}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item label="Audience" name="audience">
<Input
data-testid="audience"
name="audience"
placeholder="audience"
value={googleConfig.audience}
onChange={handleOnChange}
/>
</Form.Item>
</>
);
}
case SsoServiceType.Auth0: {
const auth0Config = ssoClientConfig as Auth0SSOClientConfig;
return (
<>
<Form.Item
label="SecretKey"
name="secretKey"
rules={[
{
required: true,
message: 'SecretKey is required',
},
]}>
<Input.Password
data-testid="secretKey"
name="secretKey"
placeholder="secretKey"
value={auth0Config.secretKey}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="ClientId"
name="clientId"
rules={[
{
required: true,
message: 'ClientId is required',
},
]}>
<Input
data-testid="clientId"
name="clientId"
placeholder="clientId"
value={auth0Config.clientId}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="Domain"
name="domain"
rules={[
{
required: true,
message: 'Domain is required',
},
]}>
<Input
data-testid="domain"
name="domain"
placeholder="domain"
value={auth0Config.domain}
onChange={handleOnChange}
/>
</Form.Item>
</>
);
}
case SsoServiceType.Azure: {
const azureConfig = ssoClientConfig as AzureSSOClientConfig;
return (
<>
<Form.Item
label="ClientSecret"
name="clientSecret"
rules={[
{
required: true,
message: 'ClientSecret is required',
},
]}>
<Input.Password
data-testid="clientSecret"
name="clientSecret"
placeholder="clientSecret"
value={azureConfig.clientSecret}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="ClientId"
name="clientId"
rules={[
{
required: true,
message: 'ClientId is required',
},
]}>
<Input
data-testid="clientId"
name="clientId"
placeholder="clientId"
value={azureConfig.clientId}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="Authority"
name="authority"
rules={[
{
required: true,
message: 'Authority is required',
},
]}>
<Input
data-testid="authority"
name="authority"
placeholder="authority"
value={azureConfig.authority}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="Scopes"
name="scopes"
rules={[
{
required: true,
message: 'Scopes is required',
},
]}>
<Input
data-testid="scopes"
name="scopes"
placeholder="Scopes value comma separated"
value={azureConfig.scopes.join(',')}
onChange={handleOnChange}
/>
</Form.Item>
</>
);
}
case SsoServiceType.Okta: {
const oktaConfig = ssoClientConfig as OktaSSOClientConfig;
return (
<>
<Form.Item
label="PrivateKey"
name="privateKey"
rules={[
{
required: true,
message: 'PrivateKey is required',
},
]}>
<Input.Password
data-testid="privateKey"
name="privateKey"
placeholder="privateKey"
value={oktaConfig.privateKey}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="ClientId"
name="clientId"
rules={[
{
required: true,
message: 'ClientId is required',
},
]}>
<Input
data-testid="clientId"
name="clientId"
placeholder="clientId"
value={oktaConfig.clientId}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="OrgURL"
name="orgURL"
rules={[
{
required: true,
message: 'OrgURL is required',
},
]}>
<Input
data-testid="orgURL"
name="orgURL"
placeholder="orgURL"
value={oktaConfig.orgURL}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="Email"
name="oktaEmail"
rules={[
{
required: true,
type: 'email',
message: 'Service account Email is required',
},
]}>
<Input
data-testid="oktaEmail"
name="oktaEmail"
placeholder="Okta Service account Email"
value={oktaConfig.email}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item label="Scopes" name="scopes">
<Input
data-testid="scopes"
name="scopes"
placeholder="Scopes value comma separated"
value={oktaConfig.scopes?.join('')}
onChange={handleOnChange}
/>
</Form.Item>
</>
);
}
case SsoServiceType.CustomOidc: {
const customOidcConfig = ssoClientConfig as CustomOidcSSOClientConfig;
return (
<>
<Form.Item
label="SecretKey"
name="secretKey"
rules={[
{
required: true,
message: 'SecretKey is required',
},
]}>
<Input.Password
data-testid="secretKey"
name="secretKey"
placeholder="secretKey"
value={customOidcConfig.secretKey}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="ClientId"
name="clientId"
rules={[
{
required: true,
message: 'ClientId is required',
},
]}>
<Input
data-testid="clientId"
name="clientId"
placeholder="clientId"
value={customOidcConfig.clientId}
onChange={handleOnChange}
/>
</Form.Item>
<Form.Item
label="TokenEndpoint"
name="tokenEndpoint"
rules={[
{
required: true,
message: 'TokenEndpoint is required',
},
]}>
<Input
data-testid="tokenEndpoint"
name="tokenEndpoint"
placeholder="tokenEndpoint"
value={customOidcConfig.tokenEndpoint}
onChange={handleOnChange}
/>
</Form.Item>
</>
);
}
default:
return null;
}
};
return (
<Form
id="update-auth-mechanism-form"
layout="vertical"
onFinish={handleSave}>
<Form.Item
label="Auth Mechanism"
name="auth-mechanism"
rules={[
{
required: true,
validator: () => {
if (!authMechanism) {
return Promise.reject('Auth Mechanism is required');
}
return Promise.resolve();
},
},
]}>
<Select
className="w-full"
data-testid="auth-mechanism"
defaultValue={authMechanism}
placeholder="Select Auth Mechanism"
onChange={(value) => setAuthMechanism(value)}>
{getAuthMechanismTypeOptions(authConfig).map((option) => (
<Option key={option.value}>{option.label}</Option>
))}
</Select>
</Form.Item>
{authMechanism === AuthType.Jwt && (
<Form.Item
label="Token Expiration"
name="token-expiration"
rules={[
{
required: true,
validator: () => {
if (!tokenExpiry) {
return Promise.reject('Token Expiration is required');
}
return Promise.resolve();
},
},
]}>
<Select
className="w-full"
data-testid="token-expiry"
defaultValue={tokenExpiry}
placeholder="Select Token Expiration"
onChange={(value) => setTokenExpiry(value)}>
{getJWTTokenExpiryOptions().map((option) => (
<Option key={option.value}>{option.label}</Option>
))}
</Select>
</Form.Item>
)}
{authMechanism === AuthType.Sso && <>{getSSOConfig()}</>}
<Space className="w-full tw-justify-end" size={4}>
<Button data-testid="cancel-edit" type="link" onClick={onCancel}>
Cancel
</Button>
<Button
data-testid="save-edit"
form="update-auth-mechanism-form"
htmlType="submit"
type="primary">
{isUpdating ? <Loader size="small" /> : 'Save'}
</Button>
</Space>
</Form>
);
};
export default AuthMechanismForm;

View File

@ -12,9 +12,8 @@
*/ */
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Card, Input, Select } from 'antd'; import { Card } from 'antd';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import classNames from 'classnames';
import React, { import React, {
FC, FC,
Fragment, Fragment,
@ -23,40 +22,34 @@ import React, {
useMemo, useMemo,
useState, useState,
} from 'react'; } from 'react';
import { useAuthContext } from '../../authentication/auth-provider/AuthProvider'; import {
import { getUserToken, updateUser } from '../../axiosAPIs/userAPI'; getAuthMechanismForBotUser,
updateUser,
} from '../../axiosAPIs/userAPI';
import { import {
GlobalSettingOptions, GlobalSettingOptions,
GlobalSettingsMenuCategory, GlobalSettingsMenuCategory,
} from '../../constants/globalSettings.constants'; } from '../../constants/globalSettings.constants';
import { import {
AuthenticationMechanism,
AuthType, AuthType,
JWTTokenExpiry,
SsoServiceType,
User, User,
} from '../../generated/entity/teams/user'; } from '../../generated/entity/teams/user';
import { EntityReference } from '../../generated/type/entityReference'; import { EntityReference } from '../../generated/type/entityReference';
import { import { getEntityName } from '../../utils/CommonUtils';
getAuthMechanismTypeOptions,
getJWTTokenExpiryOptions,
getTokenExpiryDate,
getTokenExpiryText,
} from '../../utils/BotsUtils';
import { getEntityName, requiredField } from '../../utils/CommonUtils';
import { getSettingPath } from '../../utils/RouterUtils'; import { getSettingPath } from '../../utils/RouterUtils';
import SVGIcons, { Icons } from '../../utils/SvgUtils'; import SVGIcons, { Icons } from '../../utils/SvgUtils';
import { showErrorToast } from '../../utils/ToastUtils'; import { showErrorToast } from '../../utils/ToastUtils';
import { Button } from '../buttons/Button/Button'; import { Button } from '../buttons/Button/Button';
import CopyToClipboardButton from '../buttons/CopyToClipboardButton/CopyToClipboardButton';
import Description from '../common/description/Description'; import Description from '../common/description/Description';
import TitleBreadcrumb from '../common/title-breadcrumb/title-breadcrumb.component'; import TitleBreadcrumb from '../common/title-breadcrumb/title-breadcrumb.component';
import PageLayout, { leftPanelAntCardStyle } from '../containers/PageLayout'; import PageLayout, { leftPanelAntCardStyle } from '../containers/PageLayout';
import { Field } from '../Field/Field';
import Loader from '../Loader/Loader';
import ConfirmationModal from '../Modals/ConfirmationModal/ConfirmationModal'; import ConfirmationModal from '../Modals/ConfirmationModal/ConfirmationModal';
import { OperationPermission } from '../PermissionProvider/PermissionProvider.interface'; import { OperationPermission } from '../PermissionProvider/PermissionProvider.interface';
import { UserDetails } from '../Users/Users.interface'; import { UserDetails } from '../Users/Users.interface';
const { Option } = Select; import AuthMechanism from './AuthMechanism';
import AuthMechanismForm from './AuthMechanismForm';
interface BotsDetailProp extends HTMLAttributes<HTMLDivElement> { interface BotsDetailProp extends HTMLAttributes<HTMLDivElement> {
botsData: User; botsData: User;
botPermission: OperationPermission; botPermission: OperationPermission;
@ -64,32 +57,25 @@ interface BotsDetailProp extends HTMLAttributes<HTMLDivElement> {
revokeTokenHandler: () => void; revokeTokenHandler: () => void;
} }
interface Option {
value: string;
label: string;
}
const BotDetails: FC<BotsDetailProp> = ({ const BotDetails: FC<BotsDetailProp> = ({
botsData, botsData,
updateBotsDetails, updateBotsDetails,
revokeTokenHandler, revokeTokenHandler,
botPermission, botPermission,
}) => { }) => {
const { authConfig } = useAuthContext();
const [displayName, setDisplayName] = useState(botsData.displayName); const [displayName, setDisplayName] = useState(botsData.displayName);
const [isDisplayNameEdit, setIsDisplayNameEdit] = useState(false); const [isDisplayNameEdit, setIsDisplayNameEdit] = useState(false);
const [isDescriptionEdit, setIsDescriptionEdit] = useState(false); const [isDescriptionEdit, setIsDescriptionEdit] = useState(false);
const [botsToken, setBotsToken] = useState<string>('');
const [botsTokenExpiry, setBotsTokenExpiry] = useState<number>();
const [isRevokingToken, setIsRevokingToken] = useState<boolean>(false); const [isRevokingToken, setIsRevokingToken] = useState<boolean>(false);
const [generateToken, setGenerateToken] = useState<boolean>(false);
const [authMechanism, setAuthMechanism] = useState<AuthType>(AuthType.Jwt);
const [tokenExpiry, setTokenExpiry] = useState<JWTTokenExpiry>(
JWTTokenExpiry.OneHour
);
const [isUpdating, setIsUpdating] = useState<boolean>(false); const [isUpdating, setIsUpdating] = useState<boolean>(false);
const [authenticationMechanism, setAuthenticationMechanism] =
useState<AuthenticationMechanism>();
const [isAuthMechanismEdit, setIsAuthMechanismEdit] =
useState<boolean>(false);
const editAllPermission = useMemo( const editAllPermission = useMemo(
() => botPermission.EditAll, () => botPermission.EditAll,
[botPermission] [botPermission]
@ -104,20 +90,18 @@ const BotDetails: FC<BotsDetailProp> = ({
[botPermission] [botPermission]
); );
const fetchBotsToken = () => { const fetchAuthMechanismForBot = async () => {
getUserToken(botsData.id) try {
.then((res) => { const response = await getAuthMechanismForBotUser(botsData.id);
const { JWTToken, JWTTokenExpiresAt } = res; setAuthenticationMechanism(response);
setBotsToken(JWTToken); } catch (error) {
setBotsTokenExpiry(JWTTokenExpiresAt); showErrorToast(error as AxiosError);
setTokenExpiry(res.JWTTokenExpiry ?? JWTTokenExpiry.OneHour); }
})
.catch((err: AxiosError) => {
showErrorToast(err);
});
}; };
const handleBotTokenDetailUpdate = async () => { const handleAuthMechanismUpdate = async (
updatedAuthMechanism: AuthenticationMechanism
) => {
setIsUpdating(true); setIsUpdating(true);
try { try {
const { const {
@ -145,30 +129,30 @@ const BotDetails: FC<BotsDetailProp> = ({
roles, roles,
authenticationMechanism: { authenticationMechanism: {
...botsData.authenticationMechanism, ...botsData.authenticationMechanism,
authType: authMechanism, authType: updatedAuthMechanism.authType,
config: config:
authMechanism === AuthType.Jwt updatedAuthMechanism.authType === AuthType.Jwt
? { ? {
JWTTokenExpiry: tokenExpiry, JWTTokenExpiry: updatedAuthMechanism.config?.JWTTokenExpiry,
} }
: { : {
ssoServiceType: SsoServiceType.Google, ssoServiceType: updatedAuthMechanism.config?.ssoServiceType,
authConfig: {}, authConfig: updatedAuthMechanism.config?.authConfig,
}, },
}, },
} as User); } as User);
if (response.data) { if (response.data) {
fetchBotsToken(); fetchAuthMechanismForBot();
} }
} catch (error) { } catch (error) {
showErrorToast(error as AxiosError); showErrorToast(error as AxiosError);
} finally { } finally {
setIsUpdating(false); setIsUpdating(false);
setGenerateToken(false); setIsAuthMechanismEdit(false);
} }
}; };
const handleTokenGeneration = () => setGenerateToken(true); const handleAuthMechanismEdit = () => setIsAuthMechanismEdit(true);
const onDisplayNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { const onDisplayNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setDisplayName(e.target.value); setDisplayName(e.target.value);
@ -297,207 +281,29 @@ const BotDetails: FC<BotsDetailProp> = ({
); );
}; };
const fetchRightPanel = () => { const rightPanel = (
return ( <Card
<Card className="ant-card-feed"
className="ant-card-feed" style={{
style={{ ...leftPanelAntCardStyle,
...leftPanelAntCardStyle, marginTop: '16px',
marginTop: '16px', }}>
}}> <div data-testid="right-panel">
<div data-testid="right-panel"> <div className="tw-flex tw-flex-col">
<div className="tw-flex tw-flex-col"> <h6 className="tw-mb-2 tw-text-lg">Token Security</h6>
<h6 className="tw-mb-2 tw-text-lg">Token Security</h6> <p className="tw-mb-2">
<p className="tw-mb-2"> Anyone who has your JWT Token will be able to send REST API requests
Anyone who has your JWT Token will be able to send REST API to the OpenMetadata Server. Do not expose the JWT Token in your
requests to the OpenMetadata Server. Do not expose the JWT Token application code. Do not share it on GitHub or anywhere else online.
in your application code. Do not share it on GitHub or anywhere </p>
else online.
</p>
</div>
</div> </div>
</Card>
);
};
const getCopyComponent = () => {
if (botsToken) {
return <CopyToClipboardButton copyText={botsToken} />;
} else {
return null;
}
};
const getBotsTokenExpiryDate = () => {
if (botsTokenExpiry) {
// get the current date timestamp
const currentTimeStamp = Date.now();
const isTokenExpired = currentTimeStamp >= botsTokenExpiry;
// get the token expiry date
const tokenExpiryDate = getTokenExpiryDate(botsTokenExpiry);
return (
<p
className="tw-text-grey-muted tw-mt-2 tw-italic"
data-testid="token-expiry">
{isTokenExpired
? `Expired on ${tokenExpiryDate}`
: `Expires on ${tokenExpiryDate}`}
.
</p>
);
} else {
return (
<p
className="tw-text-grey-muted tw-mt-2 tw-italic"
data-testid="token-expiry">
<SVGIcons alt="warning" icon="error" />
<span className="tw-ml-1 tw-align-middle">
This token has no expiration date.
</span>
</p>
);
}
};
const centerLayout = () => {
if (generateToken) {
return (
<div className="tw-mt-4" data-testid="generate-token-form">
<Field>
<label
className="tw-block tw-form-label tw-mb-0"
htmlFor="auth-mechanism">
{requiredField('Auth Mechanism')}
</label>
<Select
className="w-full"
data-testid="auth-mechanism"
defaultValue={authMechanism}
placeholder="Select Auth Mechanism"
onChange={(value) => setAuthMechanism(value)}>
{getAuthMechanismTypeOptions(authConfig).map((option) => (
<Option key={option.value}>{option.label}</Option>
))}
</Select>
</Field>
<Field>
<label
className="tw-block tw-form-label tw-mb-0"
htmlFor="token-expiry">
{requiredField('Token Expiration')}
</label>
<Select
className="w-full"
data-testid="token-expiry"
defaultValue={tokenExpiry}
placeholder="Select Token Expiration"
onChange={(value) => setTokenExpiry(value)}>
{getJWTTokenExpiryOptions().map((option) => (
<Option key={option.value}>{option.label}</Option>
))}
</Select>
<p className="tw-mt-2">{getTokenExpiryText(tokenExpiry)}</p>
</Field>
<div className="tw-flex tw-justify-end">
<Button
className={classNames('tw-mr-2')}
data-testid="discard-button"
size="regular"
theme="primary"
variant="text"
onClick={() => setGenerateToken(false)}>
Cancel
</Button>
<Button
data-testid="generate-button"
size="regular"
theme="primary"
type="submit"
variant="contained"
onClick={handleBotTokenDetailUpdate}>
{isUpdating ? <Loader size="small" /> : 'Save'}
</Button>
</div>
</div>
);
} else {
if (botsToken) {
return (
<Fragment>
<div className="tw-flex tw-justify-between tw-items-center tw-mt-4">
<Input.Password
contentEditable={false}
data-testid="token"
placeholder="Generate new token..."
value={botsToken}
/>
{getCopyComponent()}
</div>
{getBotsTokenExpiryDate()}
</Fragment>
);
} else {
return (
<div
className="tw-no-description tw-text-sm tw-mt-4"
data-testid="no-token">
No token available
</div>
);
}
}
};
const getCenterLayout = () => {
return (
<div
className="tw-w-full tw-bg-white tw-shadow tw-rounded tw-p-4 tw-mt-4"
data-testid="center-panel">
<div className="tw-flex tw-justify-between tw-items-center">
<h6 className="tw-mb-2 tw-self-center">
{generateToken ? 'Generate JWT token' : 'JWT Token'}
</h6>
{!generateToken && editAllPermission ? (
<>
{botsToken ? (
<Button
className="tw-px-2 tw-py-0.5 tw-font-medium tw-ml-2 tw-rounded-md tw-border-error hover:tw-border-error tw-text-error hover:tw-text-error focus:tw-outline-none"
data-testid="revoke-button"
size="custom"
variant="outlined"
onClick={() => setIsRevokingToken(true)}>
Revoke token
</Button>
) : (
<Button
data-testid="generate-token"
size="small"
theme="primary"
variant="outlined"
onClick={() => handleTokenGeneration()}>
Generate new token
</Button>
)}
</>
) : null}
</div>
<hr className="tw-mt-2" />
<p className="tw-mt-4">
Token you have generated that can be used to access the OpenMetadata
API.
</p>
{centerLayout()}
</div> </div>
); </Card>
}; );
useEffect(() => { useEffect(() => {
if (botsData.id) { if (botsData.id) {
fetchBotsToken(); fetchAuthMechanismForBot();
} }
}, [botsData]); }, [botsData]);
@ -520,8 +326,32 @@ const BotDetails: FC<BotsDetailProp> = ({
/> />
} }
leftPanel={fetchLeftPanel()} leftPanel={fetchLeftPanel()}
rightPanel={fetchRightPanel()}> rightPanel={rightPanel}>
{getCenterLayout()} {authenticationMechanism && (
<Card
data-testid="center-panel"
style={{
...leftPanelAntCardStyle,
marginTop: '16px',
}}>
{isAuthMechanismEdit ? (
<AuthMechanismForm
authenticationMechanism={authenticationMechanism}
isUpdating={isUpdating}
onCancel={() => setIsAuthMechanismEdit(false)}
onSave={handleAuthMechanismUpdate}
/>
) : (
<AuthMechanism
authenticationMechanism={authenticationMechanism}
hasPermission={editAllPermission}
onEdit={handleAuthMechanismEdit}
onTokenRevoke={() => setIsRevokingToken(true)}
/>
)}
</Card>
)}
{isRevokingToken ? ( {isRevokingToken ? (
<ConfirmationModal <ConfirmationModal
bodyText="Are you sure you want to revoke access for JWT token?" bodyText="Are you sure you want to revoke access for JWT token?"
@ -532,7 +362,7 @@ const BotDetails: FC<BotsDetailProp> = ({
onConfirm={() => { onConfirm={() => {
revokeTokenHandler(); revokeTokenHandler();
setIsRevokingToken(false); setIsRevokingToken(false);
handleTokenGeneration(); handleAuthMechanismEdit();
}} }}
/> />
) : null} ) : null}

View File

@ -11,29 +11,15 @@
* limitations under the License. * limitations under the License.
*/ */
import { import { findByTestId, fireEvent, render } from '@testing-library/react';
findByTestId,
fireEvent,
queryByTestId,
render,
} from '@testing-library/react';
import React from 'react'; import React from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { getUserToken } from '../../axiosAPIs/userAPI';
import { OperationPermission } from '../PermissionProvider/PermissionProvider.interface'; import { OperationPermission } from '../PermissionProvider/PermissionProvider.interface';
import BotDetails from './BotDetails.component'; import BotDetails from './BotDetails.component';
const revokeTokenHandler = jest.fn(); const revokeTokenHandler = jest.fn();
const updateBotsDetails = jest.fn(); const updateBotsDetails = jest.fn();
const mockToken = {
JWTToken:
// eslint-disable-next-line max-len
'eyJraWQiOiJHYjM4OWEtOWY3Ni1nZGpzLWE5MmotMDI0MmJrOTQzNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzYWNoaW5jaGF1cmFzaXlhY2hvdGV5ODciLCJpc0JvdCI6dHJ1ZSwiaXNzIjoib3Blbi1tZXRhZGF0YS5vcmciLCJleHAiOjE2NTMzMDM5ODcsImlhdCI6MTY1MjY5OTE4NywiZW1haWwiOiJzYWNoaW5jaGF1cmFzaXlhY2hvdGV5ODdAZ21haWwuY29tIn0.qwcyGU_geL9GsZ58lw5H46eP7OY9GNq3gBS5l3DhvOGTjtqWzFBUdtYwg3KdP0ejXHSMW5DD2I-1jbCZI8tuSRZ0kdN7gt0xEhU3o7pweAcDb38mbPB3sgvNTGqrdX9Ya6ICVVDH3v7jVxJuJcykDxfVYFy6fyrwbrW3RxuyacV9xMUIyrD8EyDuAhth4wpwGnj5NqikQFRdqQYEWZlyafskMad4ghMy2eoFjrSc5vv7KN0bkp1SHGjxr_TAd3Oc9lIMWKquUZthGXQnnj5XKxGl1PJnXqK7l3U25DcCobbc5KxOI2_TUxfFNIfxduoHiWsAUBSqshvh7O7nCqiZqw',
JWTTokenExpiry: '7',
JWTTokenExpiresAt: 1653303987652,
};
const botsData = { const botsData = {
id: 'ea09aed1-0251-4a75-b92a-b65641610c53', id: 'ea09aed1-0251-4a75-b92a-b65641610c53',
name: 'sachinchaurasiyachotey87', name: 'sachinchaurasiyachotey87',
@ -46,23 +32,20 @@ const botsData = {
href: 'http://localhost:8585/api/v1/users/ea09aed1-0251-4a75-b92a-b65641610c53', href: 'http://localhost:8585/api/v1/users/ea09aed1-0251-4a75-b92a-b65641610c53',
isBot: true, isBot: true,
isAdmin: false, isAdmin: false,
changeDescription: {
fieldsAdded: [
{
name: 'authenticationMechanism',
newValue: {
config: mockToken,
authType: 'JWT',
},
},
],
fieldsUpdated: [],
fieldsDeleted: [],
previousVersion: 0.1,
},
deleted: false, deleted: false,
}; };
const mockAuthMechanism = {
config: {
JWTToken:
// eslint-disable-next-line max-len
'eyJraWQiOiJHYjM4OWEtOWY3Ni1nZGpzLWE5MmotMDI0MmJrOTQzNTYiLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJzYWNoaW5jaGF1cmFzaXlhY2hvdGV5ODciLCJpc0JvdCI6dHJ1ZSwiaXNzIjoib3Blbi1tZXRhZGF0YS5vcmciLCJleHAiOjE2NjY3OTE5NjAsImlhdCI6MTY2NDE5OTk2MCwiZW1haWwiOiJzYWNoaW5jaGF1cmFzaXlhY2hvdGV5ODdAZ21haWwuY29tIn0.e5y5hh61EksbcWlLet_GpE84raDYvMho6OXAOLe5MCKrimHYj1roqoY54PFlJDSdrPWJOOeAFsTOxlqnMB_FGhOIufNW9yJwlkIOspWCusNJisLpv8_oYw9ZbrB5ATKyDz9MLTaZRZptx3JirA7s6tV-DJZId-mNzQejW2kiecYZeLZ-ipHqQeVxfzryfxUqcBUGTv-_de0uxlPdklqBuwt24bCy29qVIGxUweFDhrstmdRx_ZyQdrRvmeMHifUB6FCB1OBbII8mKYvF2P0CWF_SsxVLlRHUeOsxKeAeUk1MAA1mHm4UYdMD9OAuFMTZ10gpiELebVWiKrFYYjdICA',
JWTTokenExpiry: '30',
JWTTokenExpiresAt: 1666791960664,
},
authType: 'JWT',
};
const mockProp = { const mockProp = {
botsData, botsData,
botPermission: { botPermission: {
@ -85,9 +68,9 @@ jest.mock('../../utils/PermissionsUtils', () => ({
jest.mock('../../axiosAPIs/userAPI', () => { jest.mock('../../axiosAPIs/userAPI', () => {
return { return {
updateUser: jest.fn().mockImplementation(() => Promise.resolve(botsData)), updateUser: jest.fn().mockImplementation(() => Promise.resolve(botsData)),
getUserToken: jest getAuthMechanismForBotUser: jest
.fn() .fn()
.mockImplementation(() => Promise.resolve(mockToken)), .mockImplementation(() => Promise.resolve(mockAuthMechanism)),
}; };
}); });
@ -125,54 +108,6 @@ describe('Test BotsDetail Component', () => {
expect(tokenExpiry).toBeInTheDocument(); expect(tokenExpiry).toBeInTheDocument();
}); });
it('Should render no token placeholder if token is not present', async () => {
(getUserToken as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({
...mockToken,
JWTToken: '',
JWTTokenExpiresAt: '',
})
);
const { container } = render(<BotDetails {...mockProp} />, {
wrapper: MemoryRouter,
});
const tokenElement = queryByTestId(container, 'token');
const tokenExpiry = queryByTestId(container, 'token-expiry');
expect(tokenElement).not.toBeInTheDocument();
expect(tokenExpiry).not.toBeInTheDocument();
const noToken = await findByTestId(container, 'no-token');
expect(noToken).toBeInTheDocument();
});
it('Should render generate token form if generate token button is clicked', async () => {
(getUserToken as jest.Mock).mockImplementationOnce(() =>
Promise.resolve({ ...mockToken, JWTToken: '', JWTTokenExpiresAt: '' })
);
const { container } = render(<BotDetails {...mockProp} />, {
wrapper: MemoryRouter,
});
const generateToken = await findByTestId(container, 'generate-token');
expect(generateToken).toHaveTextContent('Generate new token');
fireEvent.click(generateToken);
const tokenForm = await findByTestId(container, 'generate-token-form');
expect(tokenForm).toBeInTheDocument();
const generateButton = await findByTestId(tokenForm, 'generate-button');
const discardButton = await findByTestId(tokenForm, 'discard-button');
expect(generateButton).toBeInTheDocument();
expect(discardButton).toBeInTheDocument();
});
it('Test Revoke token flow', async () => { it('Test Revoke token flow', async () => {
const { container } = render(<BotDetails {...mockProp} />, { const { container } = render(<BotDetails {...mockProp} />, {
wrapper: MemoryRouter, wrapper: MemoryRouter,

View File

@ -100,10 +100,18 @@ export const getTokenExpiryText = (expiry: string) => {
/** /**
* *
* @param expiry expiry timestamp * @param expiry expiry timestamp
* @returns date like "Fri 23rd September, 2022,02:26 PM." * @returns TokenExpiry
*/ */
export const getTokenExpiryDate = (expiry: number) => { export const getTokenExpiry = (expiry: number) => {
return moment(expiry).format('ddd Do MMMM, YYYY,hh:mm A'); // get the current date timestamp
const currentTimeStamp = Date.now();
const isTokenExpired = currentTimeStamp >= expiry;
return {
tokenExpiryDate: moment(expiry).format('ddd Do MMMM, YYYY,hh:mm A'),
isTokenExpired,
};
}; };
export const DEFAULT_GOOGLE_SSO_CLIENT_CONFIG = { export const DEFAULT_GOOGLE_SSO_CLIENT_CONFIG = {