Merge pull request #384 from dbeaver/feat/permission-management

feat(core-connections): CB-1037 add permissions management for connec…
This commit is contained in:
Alexey
2021-06-09 15:42:00 +03:00
committed by GitHub
20 changed files with 814 additions and 231 deletions
@@ -0,0 +1,183 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { useCallback, useEffect, useState } from 'react';
import styled, { css, use } from 'reshadow';
import { composes, useStyles } from '@cloudbeaver/core-theming';
import { IconButton } from '../IconButton';
import { useFocus } from '../useFocus';
import { InputFieldNew } from './InputFieldNew';
const styles = css`
filter-container {
position: relative;
min-width: 24px;
min-height: 24px;
}
InputFieldNew {
display: none;
width: 300px;
&[|toggled] {
display: block;
}
}
IconButton {
position: absolute;
right: 2px;
top: 2px;
margin: 0;
width: 24px;
height: 24px;
border-radius: 2px;
cursor: auto;
&[|toggled] {
border-radius: unset;
}
}
`;
const toggleModeButtonStyle = composes(
css`
IconButton {
composes: theme-background-primary theme-text-on-primary from global;
}
`,
css`
IconButton {
cursor: pointer;
}
`);
const innerInputStyle = css`
input {
height: 28px;
padding-right: 24px !important;
}
`;
interface BaseProps {
toggleMode?: boolean;
onToggle?: (status: boolean) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
type ControlledProps = BaseProps & {
name?: string;
value?: string;
state?: never;
onFilter?: (value: string, name?: string) => void;
};
type ObjectsProps<TKey extends keyof TState, TState> = BaseProps & {
name: TKey;
state: TState;
onFilter?: (value: TState[TKey], name: TKey) => void;
value?: never;
};
export const Filter: React.FC<ControlledProps | ObjectsProps<any, any>> = observer(function Filter({
state,
name,
value: valueControlled,
onFilter,
toggleMode,
onToggle,
placeholder,
disabled,
className,
}) {
const [inputRef] = useFocus<HTMLInputElement>({});
const [toggled, setToggled] = useState(!toggleMode);
const filter = useCallback((value: string | number, name?: string) => {
value = String(value).trim();
if (state && name) {
state[name] = value;
}
if (onFilter) {
onFilter(value, name);
}
}, [onFilter, state]);
const toggle = useCallback(() => {
if (!toggleMode) {
return;
}
if (toggled) {
filter('');
}
setToggled(!toggled);
if (onToggle) {
onToggle(!toggled);
}
}, [toggleMode, toggled, onToggle, filter]);
const onKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' || event.keyCode === 13) {
event.stopPropagation();
}
}, []);
const handleClick = (event: React.MouseEvent<HTMLInputElement>) => {
event.stopPropagation();
};
const preventPropagation = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
};
useEffect(() => {
if (toggled && toggleMode) {
inputRef.current?.focus();
}
}, [toggled, toggleMode, inputRef]);
let value: any = valueControlled;
if (state && name !== undefined && name in state) {
value = state[name];
}
return styled(useStyles(styles, toggleMode && toggleModeButtonStyle))(
<filter-container
as='div'
className={className}
onClick={handleClick}
onDoubleClick={preventPropagation}
>
<InputFieldNew
ref={inputRef}
style={innerInputStyle}
placeholder={placeholder}
disabled={disabled}
name={name}
value={value}
onKeyDown={onKeyDown}
onChange={filter}
{...use({ toggled })}
/>
<IconButton
name='search'
disabled={disabled}
onClick={toggle}
{...use({ toggled })}
/>
</filter-container>
);
});
@@ -23,7 +23,7 @@ type Props = React.PropsWithChildren<{
expand?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
}>;
}> & React.DetailedHTMLProps<React.TdHTMLAttributes<HTMLTableDataCellElement>, HTMLTableDataCellElement>;
export const TableColumnValue = observer(function TableColumnValue({
align,
@@ -34,6 +34,7 @@ export const TableColumnValue = observer(function TableColumnValue({
className,
onClick,
onDoubleClick,
...rest
}: Props) {
const styles = useStyles();
const tableContext = useContext(TableContext);
@@ -72,6 +73,7 @@ export const TableColumnValue = observer(function TableColumnValue({
{...use({ centerContent })}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
{...rest}
>
{flex && <td-flex as='div' className={className}>{children}</td-flex>}
{!flex && children}
@@ -1,119 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { useCallback, useContext, useEffect, useState } from 'react';
import styled, { css, use } from 'reshadow';
import { composes, useStyles } from '@cloudbeaver/core-theming';
import { InputFieldNew } from '../../FormControls/InputFieldNew';
import { IconButton } from '../../IconButton';
import { useFocus } from '../../useFocus';
import { TreeNodeContext } from './TreeNodeContext';
const styles = composes(
css`
IconButton {
composes: theme-background-primary theme-text-on-primary from global;
}
`,
css`
InputFieldNew {
display: none;
width: 300px;
&[|filterEnabled] {
display: block;
}
}
IconButton {
position: absolute;
right: 2px;
margin: 0;
width: 24px;
height: 24px;
cursor: pointer;
border-radius: 2px;
&[|filterEnabled] {
border-radius: unset;
}
}
`);
const innerInputStyle = css`
input {
height: 28px;
padding-right: 24px !important;
}
`;
interface Props {
disabled?: boolean;
className?: string;
}
export const TreeNodeFilter: React.FC<Props> = observer(function TreeNodeFilter({
disabled,
className,
}) {
const context = useContext(TreeNodeContext);
const [inputRef] = useFocus<HTMLInputElement>({});
const [filterEnabled, setFilterEnabled] = useState(false);
if (!context) {
throw new Error('Context not provided');
}
const onFilterEnabledChange = useCallback(() => {
setFilterEnabled(prev => {
if (prev) {
context.filter('');
}
return !prev;
});
}, [context]);
const onKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' || event.keyCode === 13) {
event.stopPropagation();
}
}, []);
const handleClick = (event: React.MouseEvent<HTMLInputElement>) => {
event.stopPropagation();
};
const preventPropagation = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
};
useEffect(() => {
if (filterEnabled) {
inputRef.current?.focus();
}
}, [filterEnabled]);
useEffect(() => () => { context.filter(''); }, []);
return styled(useStyles(styles))(
<filter-container className={className} onClick={handleClick} onDoubleClick={preventPropagation}>
<InputFieldNew
ref={inputRef}
style={innerInputStyle}
disabled={disabled}
value={context.filterValue}
{...use({ filterEnabled })}
onKeyDown={onKeyDown}
onChange={value => context.filter(String(value).trim())}
/>
<IconButton name='search' disabled={disabled} onClick={onFilterEnabledChange} {...use({ filterEnabled })} />
</filter-container>
);
});
+1 -1
View File
@@ -83,7 +83,6 @@ export * from './Tree/TreeNode/TreeNodeIcon';
export * from './Tree/TreeNode/TreeNodeName';
export * from './Tree/TreeNode/TreeNodeNested';
export * from './Tree/TreeNode/TreeNodeSelect';
export * from './Tree/TreeNode/TreeNodeFilter';
export * from './Tree/TreeNode/TreeNodeStyles';
export * from './Button';
export * from './ToolsPanel/ToolsAction';
@@ -96,6 +95,7 @@ export * from './FormControls/Checkboxes/Switch';
export * from './FormControls/Checkboxes/SwitchNew';
export * from './FormControls/Checkboxes/useCheckboxState';
export * from './FormControls/FormBox';
export * from './FormControls/Filter';
export * from './Containers/BASE_CONTAINERS_STYLES';
export * from './Containers/Container';
export * from './Containers/Group';
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53 (72520) - https://sketchapp.com -->
<title>group</title>
<desc>Created with Sketch.</desc>
<defs>
<polygon id="path-1" points="0 22.208 0 0.0003 29.0417 0.0003 29.0417 22.208"></polygon>
</defs>
<g id="group" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Page-1" transform="translate(1.000000, 4.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1"></use>
</mask>
<g id="Clip-2"></g>
<path d="M18.7917,10.25 C21.6217,10.25 23.9167,7.955 23.9167,5.125 C23.9167,2.295 21.6217,0 18.7917,0 C18.2447,0 17.7157,0.085 17.2197,0.239 C18.1937,1.623 18.7917,3.314 18.7917,5.125 C18.7917,6.936 18.1937,8.61 17.2197,9.994 C17.7157,10.165 18.2447,10.25 18.7917,10.25 M10.2497,10.25 C13.0807,10.25 15.3747,7.955 15.3747,5.125 C15.3747,2.295 13.0807,0 10.2497,0 C7.4197,0 5.1247,2.295 5.1247,5.125 C5.1247,7.955 7.4197,10.25 10.2497,10.25 M21.5597,13.94 C22.9767,15.17 23.9167,16.776 23.9167,18.792 L23.9167,22.208 L29.0417,22.208 L29.0417,18.792 C29.0417,16.161 24.9927,14.521 21.5597,13.94 M10.2497,13.667 C6.8337,13.667 -0.0003,15.375 -0.0003,18.792 L-0.0003,22.208 L20.4997,22.208 L20.4997,18.792 C20.4997,15.375 13.6667,13.667 10.2497,13.667" id="Fill-1" fill="#338ECC" mask="url(#mask-2)"></path>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 53 (72520) - https://sketchapp.com -->
<title>user</title>
<desc>Created with Sketch.</desc>
<g id="user" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<path d="M15.8,5.2779 C17.801,5.2779 19.422,6.8999 19.422,8.8999 C19.422,10.9009 17.801,12.5229 15.8,12.5229 L15.8,12.5229 C13.799,12.5229 12.178,10.9009 12.178,8.8999 L12.178,8.8999 C12.178,6.8999 13.799,5.2779 15.8,5.2779 Z M15.8,20.8029 C20.923,20.8029 26.322,23.3209 26.322,24.4249 L26.322,26.3229 L5.278,26.3229 L5.278,24.4249 C5.278,23.3209 10.625,20.8029 15.8,20.8029 L15.8,20.8029 Z M15.8,2.0009 C11.989,2.0009 8.9,5.0899 8.9,8.8999 C8.9,12.7109 11.989,15.7999 15.8,15.7999 C19.611,15.7999 22.7,12.7109 22.7,8.8999 C22.7,5.0899 19.611,2.0009 15.8,2.0009 L15.8,2.0009 Z M15.8,17.5249 C11.194,17.5249 2,19.8199 2,24.4249 L2,29.5999 L29.6,29.5999 L29.6,24.4249 C29.6,19.8199 20.406,17.5249 15.8,17.5249 L15.8,17.5249 Z" id="Page-1" fill="#338ECC"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -6,18 +6,13 @@
* you may not use this file except in compliance with the License.
*/
import { computed } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useMemo } from 'react';
import styled, { css } from 'reshadow';
import { RolesResource, UsersResource } from '@cloudbeaver/core-authentication';
import {
Table,
TableHeader,
TableColumnHeader,
TableBody,
TableItem,
TableColumnValue,
TableItemSelect,
TextPlaceholder,
Loader,
useTab,
@@ -25,43 +20,56 @@ import {
useMapResource,
BASE_CONTAINERS_STYLES,
ColoredContainer,
Group
Group,
IconOrImage,
} from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { TLocalizationToken, useTranslate } from '@cloudbeaver/core-localization';
import { useStyles } from '@cloudbeaver/core-theming';
import { isCloudConnection } from '../../Administration/ConnectionsResource';
import type { IConnectionFormProps } from '../IConnectionFormProps';
import { ConnectionAccessGrantedList } from './ConnectionAccessGrantedList';
import { ConnectionAccessList } from './ConnectionAccessList';
import { useConnectionAccessState } from './useConnectionAccessState';
const styles = composes(
css`
Table {
composes: theme-background-surface theme-text-on-surface from global;
}
`,
css`
ColoredContainer {
flex: 1;
overflow: auto;
}
Group {
max-height: 100%;
position: relative;
overflow: auto !important;
}
Table {
flex: 1;
}
`
);
const styles = css`
ColoredContainer {
flex: 1;
overflow: auto;
}
Group {
max-height: 100%;
position: relative;
overflow: auto !important;
}
info-item {
display: flex;
align-items: center;
}
IconOrImage {
width: 24px;
height: 24px;
margin-right: 16px;
}
Loader {
z-index: 2;
}
`;
interface IInfoItem {
text: TLocalizationToken;
icon: string;
}
export const ConnectionAccess: TabContainerPanelComponent<IConnectionFormProps> = observer(function ConnectionAccess({
tabId,
state: formState,
}) {
const { state, load, select } = useConnectionAccessState(formState);
const { state, edit, grant, revoke, load } = useConnectionAccessState(formState.info);
const style = useStyles(styles, BASE_CONTAINERS_STYLES);
const translate = useTranslate();
const unsaved = state.initialGrantedSubjects.length !== state.grantedSubjects.length
|| state.initialGrantedSubjects.some(subject => !state.grantedSubjects.includes(subject));
const users = useMapResource(UsersResource, null, {
onLoad: resource => resource.loadAll(),
@@ -71,56 +79,74 @@ export const ConnectionAccess: TabContainerPanelComponent<IConnectionFormProps>
onLoad: resource => resource.loadAll(),
});
const grantedUsers = useMemo(() => computed(() => users.resource.values
.filter(user => state.grantedSubjects.includes(user.userId))
), [state.grantedSubjects, users.resource]);
const grantedRoles = useMemo(() => computed(() => roles.resource.values
.filter(role => state.grantedSubjects.includes(role.roleId))
), [state.grantedSubjects, roles.resource]);
const { selected } = useTab(tabId, load);
const loading = users.isLoading() || roles.isLoading() || state.loading;
const disabled = loading || !state.loaded || formState.disabled;
const cloud = formState.info ? isCloudConnection(formState.info) : false;
const disabled = loading || !state.loaded || formState.disabled || cloud;
let infoItem: IInfoItem | null = null;
if (unsaved) {
infoItem = {
text: 'connections_connection_access_save_reminder',
icon: '/icons/info_icon.svg',
};
}
if (cloud) {
infoItem = {
text: 'connections_connection_access_cloud_placeholder',
icon: '/icons/info_icon.svg',
};
}
if (!selected) {
return null;
}
if (users.resource.values.length === 0 && roles.resource.values.length) {
return styled(style)(
<ColoredContainer parent>
<Group keepSize large>
<TextPlaceholder>{translate('connections_administration_connection_access_empty')}</TextPlaceholder>
</Group>
</ColoredContainer>
);
}
return styled(style)(
<ColoredContainer parent>
<Group box keepSize large>
<Table selectedItems={state.selectedSubjects} onSelect={select}>
<TableHeader>
<TableColumnHeader min />
<TableColumnHeader>{translate('connections_connection_name')}</TableColumnHeader>
<TableColumnHeader />
</TableHeader>
<TableBody>
{roles.resource.values.map(role => (
<TableItem key={role.roleId} item={role.roleId} selectDisabled={disabled}>
<TableColumnValue centerContent flex>
<TableItemSelect disabled={disabled} />
</TableColumnValue>
<TableColumnValue>{role.roleName}</TableColumnValue>
<TableColumnValue />
</TableItem>
))}
{users.resource.values.map(user => (
<TableItem key={user.userId} item={user.userId} selectDisabled={disabled}>
<TableColumnValue centerContent flex>
<TableItemSelect disabled={disabled} />
</TableColumnValue>
<TableColumnValue>{user.userId}</TableColumnValue>
<TableColumnValue />
</TableItem>
))}
</TableBody>
</Table>
<Loader key="overlay" loading={loading} overlay />
</Group>
</ColoredContainer>
<Loader state={[users, roles, state]}>
{() => styled(style)(
<ColoredContainer parent gap>
{!users.resource.values.length && !roles.resource.values.length ? (
<Group keepSize large>
<TextPlaceholder>{translate('connections_administration_connection_access_empty')}</TextPlaceholder>
</Group>
) : (
<>
{infoItem && (
<info-item>
<IconOrImage icon={infoItem.icon} />
{translate(infoItem.text)}
</info-item>
)}
<ConnectionAccessGrantedList
grantedUsers={grantedUsers.get()}
grantedRoles={grantedRoles.get()}
disabled={disabled}
onEdit={edit}
onRevoke={revoke}
/>
{state.editing && (
<ConnectionAccessList
userList={users.resource.values}
roleList={roles.resource.values}
grantedSubjects={state.grantedSubjects}
disabled={disabled}
onGrant={grant}
/>
)}
</>
)}
</ColoredContainer>
)}
</Loader>
);
});
@@ -0,0 +1,133 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { computed, observable } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useCallback, useMemo, useState } from 'react';
import styled, { css } from 'reshadow';
import {
Table,
TableBody,
TableItem,
TableColumnValue,
BASE_CONTAINERS_STYLES,
Group,
Button,
useObjectRef
} from '@cloudbeaver/core-blocks';
import { TLocalizationToken, useTranslate } from '@cloudbeaver/core-localization';
import type { AdminRoleInfo, AdminUserInfoFragment } from '@cloudbeaver/core-sdk';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { ConnectionAccessTableHeader, IFilterState } from './ConnectionAccessTableHeader/ConnectionAccessTableHeader';
import { ConnectionAccessTableInnerHeader } from './ConnectionAccessTableHeader/ConnectionAccessTableInnerHeader';
import { ConnectionAccessTableItem } from './ConnectionAccessTableitem';
import { getFilteredRoles, getFilteredUsers } from './getFilteredSubjects';
const styles = composes(
css`
Table {
composes: theme-background-surface theme-text-on-surface from global;
}
`,
css`
Group {
height: 100%;
position: relative;
overflow: auto !important;
}
`
);
interface Props {
grantedUsers: AdminUserInfoFragment[];
grantedRoles: AdminRoleInfo[];
disabled: boolean;
onRevoke: (subjectIds: string[]) => void;
onEdit: () => void;
}
export const ConnectionAccessGrantedList: React.FC<Props> = observer(function ConnectionAccessGrantedList({
grantedUsers,
grantedRoles,
disabled,
onRevoke,
onEdit,
}) {
const props = useObjectRef({ onRevoke, onEdit });
const style = useStyles(styles, BASE_CONTAINERS_STYLES);
const translate = useTranslate();
const [selectedSubjects] = useState<Map<any, boolean>>(() => observable(new Map()));
const [filterState] = useState<IFilterState>(() => observable({ filterValue: '' }));
const selectedList = useMemo(() => computed(
() => Array.from(selectedSubjects.entries()).filter(([key, value]) => value).map(([key]) => key)
), [selectedSubjects]);
const revoke = useCallback(() => {
props.onRevoke(selectedList.get());
selectedSubjects.clear();
}, []);
const roles = useMemo(() => computed(() => getFilteredRoles(
grantedRoles, filterState.filterValue
)), [filterState, grantedRoles]);
const users = useMemo(() => computed(() => getFilteredUsers(
grantedUsers, filterState.filterValue
)), [filterState, grantedUsers]);
let tableInfoText: TLocalizationToken = 'connections_connection_access_admin_info';
if (!roles.get().length && !users.get().length) {
if (filterState.filterValue) {
tableInfoText = 'connections_connection_access_filter_no_result';
} else {
tableInfoText = 'connections_connection_access_empty_table_placeholder';
}
}
return styled(style)(
<Group box medium>
<ConnectionAccessTableHeader filterState={filterState} disabled={disabled}>
<Button disabled={disabled || !selectedList.get().length} mod={['outlined']} onClick={revoke}>{translate('connections_connection_access_revoke')}</Button>
<Button disabled={disabled} mod={['raised']} onClick={props.onEdit}>{translate('connections_connection_access_edit')}</Button>
</ConnectionAccessTableHeader>
<Table selectedItems={selectedSubjects}>
<ConnectionAccessTableInnerHeader />
<TableBody>
<TableItem item='tableInfo'>
<TableColumnValue colSpan={5}>
{translate(tableInfoText)}
</TableColumnValue>
</TableItem>
{roles.get().map(role => (
<ConnectionAccessTableItem
key={role.roleId}
id={role.roleId}
name={role.roleName || ''}
description={role.description}
icon='/icons/role.svg'
iconTooltip={translate('connections_connection_access_role_tooltip')}
disabled={disabled}
/>
))}
{users.get().map(user => (
<ConnectionAccessTableItem
key={user.userId}
id={user.userId}
name={user.userId}
icon='/icons/user.svg'
iconTooltip={translate('connections_connection_access_user_tooltip')}
disabled={disabled}
/>
))}
</TableBody>
</Table>
</Group>
);
});
@@ -0,0 +1,125 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { computed, observable } from 'mobx';
import { observer } from 'mobx-react-lite';
import { useCallback, useMemo, useState } from 'react';
import styled, { css } from 'reshadow';
import {
Table,
TableBody,
TableItem,
TableColumnValue,
BASE_CONTAINERS_STYLES,
Group,
Button,
useObjectRef
} from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
import type { AdminRoleInfo, AdminUserInfoFragment } from '@cloudbeaver/core-sdk';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { ConnectionAccessTableHeader, IFilterState } from './ConnectionAccessTableHeader/ConnectionAccessTableHeader';
import { ConnectionAccessTableInnerHeader } from './ConnectionAccessTableHeader/ConnectionAccessTableInnerHeader';
import { ConnectionAccessTableItem } from './ConnectionAccessTableitem';
import { getFilteredRoles, getFilteredUsers } from './getFilteredSubjects';
const styles = composes(
css`
Table {
composes: theme-background-surface theme-text-on-surface from global;
}
`,
css`
Group {
height: 100%;
position: relative;
overflow: auto !important;
}
`
);
interface Props {
userList: AdminUserInfoFragment[];
roleList: AdminRoleInfo[];
grantedSubjects: string[];
onGrant: (subjectIds: string[]) => void;
disabled: boolean;
}
export const ConnectionAccessList: React.FC<Props> = observer(function ConnectionAccessList({
userList,
roleList,
grantedSubjects,
onGrant,
disabled,
}) {
const props = useObjectRef({ onGrant });
const style = useStyles(styles, BASE_CONTAINERS_STYLES);
const translate = useTranslate();
const [selectedSubjects] = useState<Map<any, boolean>>(() => observable(new Map()));
const [filterState] = useState<IFilterState>(() => observable({ filterValue: '' }));
const selectedList = useMemo(() => computed(
() => Array.from(selectedSubjects.entries()).filter(([key, value]) => value).map(([key]) => key)
), [selectedSubjects]);
const grant = useCallback(() => {
props.onGrant(selectedList.get());
selectedSubjects.clear();
}, []);
const roles = useMemo(() => computed(() => getFilteredRoles(
roleList, filterState.filterValue
)), [filterState, roleList]);
const users = useMemo(() => computed(() => getFilteredUsers(
userList, filterState.filterValue
)), [filterState, userList]);
return styled(style)(
<Group box medium>
<ConnectionAccessTableHeader filterState={filterState} disabled={disabled}>
<Button disabled={disabled || !selectedList.get().length} mod={['raised']} onClick={grant}>{translate('connections_connection_access_grant')}</Button>
</ConnectionAccessTableHeader>
<Table selectedItems={selectedSubjects}>
<ConnectionAccessTableInnerHeader />
<TableBody>
{!roles.get().length && !users.get().length && filterState.filterValue && (
<TableItem item='tableInfo'>
<TableColumnValue colSpan={5}>
{translate('connections_connection_access_filter_no_result')}
</TableColumnValue>
</TableItem>
)}
{roles.get().map(role => (
<ConnectionAccessTableItem
key={role.roleId}
id={role.roleId}
name={role.roleName || ''}
description={role.description}
icon='/icons/role.svg'
iconTooltip='connections_connection_access_role_tooltip'
disabled={disabled || grantedSubjects.includes(role.roleId)}
/>
))}
{users.get().map(user => (
<ConnectionAccessTableItem
key={user.userId}
id={user.userId}
name={user.userId}
icon='/icons/user.svg'
iconTooltip={translate('connections_connection_access_user_tooltip')}
disabled={disabled || grantedSubjects.includes(user.userId)}
/>
))}
</TableBody>
</Table>
</Group>
);
});
@@ -9,7 +9,6 @@
import { AdministrationScreenService } from '@cloudbeaver/core-administration';
import { Bootstrap, injectable } from '@cloudbeaver/core-di';
import type { IExecutionContextProvider } from '@cloudbeaver/core-executor';
import type { AdminConnectionGrantInfo } from '@cloudbeaver/core-sdk';
import type { MetadataValueGetter } from '@cloudbeaver/core-utils';
import { ConnectionsResource } from '../../Administration/ConnectionsResource';
@@ -57,10 +56,11 @@ export class ConnectionAccessTabService extends Bootstrap {
private stateGetter(context: IConnectionFormProps): MetadataValueGetter<string, IConnectionAccessTabState> {
return () => ({
selectedSubjects: new Map(),
loading: false,
loaded: false,
editing: false,
grantedSubjects: [],
initialGrantedSubjects: [],
});
}
@@ -93,8 +93,9 @@ export class ConnectionAccessTabService extends Bootstrap {
if (changed) {
await this.connectionsResource.setAccessSubjects(
config.connectionId,
state.grantedSubjects.map(subject => subject.subjectId)
state.grantedSubjects
);
state.initialGrantedSubjects = state.grantedSubjects.slice();
}
}
@@ -125,12 +126,12 @@ export class ConnectionAccessTabService extends Bootstrap {
}
}
private async isChanged(connectionId: string, next: AdminConnectionGrantInfo[]): Promise<boolean> {
private async isChanged(connectionId: string, next: string[]): Promise<boolean> {
const current = await this.connectionsResource.loadAccessSubjects(connectionId);
if (current.length !== next.length) {
return true;
}
return current.some(value => !next.some(subject => subject.subjectId === value.subjectId));
return current.some(value => !next.some(subjectId => subjectId === value.subjectId));
}
}
@@ -0,0 +1,67 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import styled, { css } from 'reshadow';
import { Filter } from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
import { composes, useStyles } from '@cloudbeaver/core-theming';
export interface IFilterState {
filterValue: string;
}
interface Props {
filterState: IFilterState;
disabled: boolean;
className?: string;
}
const styles = composes(
css`
header {
composes: theme-border-color-background theme-background-surface theme-text-on-surface from global;
}
`,
css`
buttons {
display: flex;
gap: 16px;
}
header {
overflow: hidden;
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
gap: 16px;
border-bottom: 1px solid;
}
`
);
export const ConnectionAccessTableHeader: React.FC<Props> = observer(function ConnectionAccessTableHeader({ filterState, disabled, className, children }) {
const translate = useTranslate();
return styled(useStyles(styles))(
<header className={className}>
<Filter
disabled={disabled}
placeholder={translate('connections_connection_access_filter_placeholder')}
name='filterValue'
state={filterState}
/>
<buttons>
{children}
</buttons>
</header>
);
});
@@ -0,0 +1,28 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import { TableColumnHeader, TableHeader } from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
interface Props {
className?: string;
}
export const ConnectionAccessTableInnerHeader: React.FC<Props> = observer(function ConnectionAccessTableInnerHeader({ className }) {
const translate = useTranslate();
return (
<TableHeader className={className}>
<TableColumnHeader min />
<TableColumnHeader min />
<TableColumnHeader>{translate('connections_connection_access_user_or_role_name')}</TableColumnHeader>
<TableColumnHeader>{translate('connections_connection_description')}</TableColumnHeader>
</TableHeader>
);
});
@@ -0,0 +1,51 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { observer } from 'mobx-react-lite';
import styled, { css } from 'reshadow';
import { StaticImage, TableColumnValue, TableItem, TableItemSelect } from '@cloudbeaver/core-blocks';
import { TLocalizationToken, useTranslate } from '@cloudbeaver/core-localization';
interface Props {
id: any;
name: string;
icon: string;
disabled: boolean;
iconTooltip?: TLocalizationToken;
description?: string;
className?: string;
}
const style = css`
StaticImage {
display: flex;
width: 24px;
}
`;
export const ConnectionAccessTableItem: React.FC<Props> = observer(function ConnectionAccessTableItem({
id, name, description, icon, iconTooltip, disabled, className,
}) {
const translate = useTranslate();
return styled(style)(
<TableItem
item={id}
disabled={disabled}
selectDisabled={disabled}
className={className}
>
<TableColumnValue centerContent flex>
<TableItemSelect disabled={disabled} />
</TableColumnValue>
<TableColumnValue><StaticImage icon={icon} title={translate(iconTooltip)} /></TableColumnValue>
<TableColumnValue>{name}</TableColumnValue>
<TableColumnValue>{description}</TableColumnValue>
</TableItem>
);
});
@@ -6,11 +6,10 @@
* you may not use this file except in compliance with the License.
*/
import type { AdminConnectionGrantInfo } from '@cloudbeaver/core-sdk';
export interface IConnectionAccessTabState {
loading: boolean;
loaded: boolean;
selectedSubjects: Map<string, boolean>;
grantedSubjects: AdminConnectionGrantInfo[];
grantedSubjects: string[];
initialGrantedSubjects: string[];
editing: boolean;
}
@@ -0,0 +1,29 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2021 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import type { AdminRoleInfo, AdminUserInfoFragment } from '@cloudbeaver/core-sdk';
/**
* @param {AdminUserInfoFragment[]} users
* @param {string} filter
*/
export function getFilteredUsers(users: AdminUserInfoFragment[], filter: string): AdminUserInfoFragment[] {
return users
.filter(user => user.userId.toLowerCase().includes(filter.toLowerCase()))
.sort((a, b) => (a.userId).localeCompare(b.userId));
}
/**
* @param {AdminRoleInfo[]} roles
* @param {string} filter
*/
export function getFilteredRoles(roles: AdminRoleInfo[], filter: string): AdminRoleInfo[] {
return roles
.filter(role => role.roleName?.toLowerCase().includes(filter.toLowerCase()) && role.roleId !== 'admin')
.sort((a, b) => (a.roleName ?? '').localeCompare(b.roleName ?? ''));
}
@@ -9,37 +9,34 @@
import { useObjectRef, useTabState } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { AdminSubjectType } from '@cloudbeaver/core-sdk';
import type { DatabaseConnectionFragment } from '@cloudbeaver/core-sdk';
import { ConnectionsResource } from '../../Administration/ConnectionsResource';
import type { IConnectionFormState } from '../IConnectionFormProps';
import type { IConnectionAccessTabState } from './IConnectionAccessTabState';
interface IConnectionAccessState {
state: IConnectionAccessTabState;
select: (subjectId: string, value: boolean) => void;
revoke: (subjectIds: string[]) => void;
grant: (subjectIds: string[]) => void;
edit: () => void;
load: () => Promise<void>;
}
export function useConnectionAccessState(formState: IConnectionFormState): IConnectionAccessState {
export function useConnectionAccessState(connection: DatabaseConnectionFragment | undefined): IConnectionAccessState {
const connectionsResource = useService(ConnectionsResource);
const notificationService = useService(NotificationService);
const state = useTabState<IConnectionAccessTabState>();
const select = (subjectId: string, value: boolean): void => {
if (!value) {
const index = state.grantedSubjects.findIndex(subject => subject.subjectId === subjectId);
if (index > -1) {
state.grantedSubjects.splice(index, 1);
}
return;
}
const edit = () => {
state.editing = !state.editing;
};
state.grantedSubjects.push({
connectionId: '',
subjectId,
subjectType: AdminSubjectType.User,
});
const revoke = (subjectIds: string[]): void => {
state.grantedSubjects = state.grantedSubjects.filter(subject => !subjectIds.includes(subject));
};
const grant = (subjectIds: string[]): void => {
state.grantedSubjects.push(...subjectIds);
};
const load = async () => {
@@ -50,13 +47,12 @@ export function useConnectionAccessState(formState: IConnectionFormState): IConn
try {
state.loading = true;
if (formState.info) {
state.grantedSubjects = await connectionsResource.loadAccessSubjects(formState.info.id);
if (connection) {
const grantedSubjects = await connectionsResource.loadAccessSubjects(connection.id);
state.grantedSubjects = grantedSubjects.map(subject => subject.subjectId);
state.initialGrantedSubjects = state.grantedSubjects.slice();
}
for (const subject of state.grantedSubjects) {
state.selectedSubjects.set(subject.subjectId, true);
}
state.loaded = true;
} catch (exception) {
notificationService.logException(exception, 'connections_connection_edit_access_load_failed');
@@ -64,5 +60,5 @@ export function useConnectionAccessState(formState: IConnectionFormState): IConn
state.loading = false;
};
return useObjectRef({ state, select, load });
return useObjectRef({ state, revoke, grant, edit, load });
}
@@ -31,6 +31,18 @@ export default [
['connections_connection_edit_search_hosts', 'Host names'],
['connections_connection_address', 'Address'],
['connections_connection_name', 'Connection name'],
['connections_connection_access_user_or_role_name', 'User or Role name'],
['connections_connection_access_save_reminder', 'You have unsaved changes. Click Save to save all permission changes.'],
['connections_connection_access_user_tooltip', 'User'],
['connections_connection_access_role_tooltip', 'Role'],
['connections_connection_access_revoke', 'Revoke'],
['connections_connection_access_grant', 'Grant'],
['connections_connection_access_edit', 'Edit'],
['connections_connection_access_cloud_placeholder', 'Cloud connections are visible to all users. Access management can be configured in "AWS Management Console".'],
['connections_connection_access_empty_table_placeholder', 'There are no items yet. Press edit button to grant role or user.'],
['connections_connection_access_filter_placeholder', 'Search for user or role name'],
['connections_connection_access_filter_no_result', 'No results have been found.'],
['connections_connection_access_admin_info', 'Administators see all connections.'],
['connections_connection_description', 'Description'],
['connections_connection_driver', 'Driver'],
['connections_connection_host', 'Host'],
@@ -28,6 +28,18 @@ export default [
['connections_connection_edit_search_hosts', 'Названия хостов'],
['connections_connection_address', 'Адрес'],
['connections_connection_name', 'Название подключения'],
['connections_connection_access_user_or_role_name', 'Имя пользователя или роли'],
['connections_connection_access_save_reminder', 'У вас есть несохраненные изменения. Нажмите кнопку Сохранить, чтобы применить изменения в настройках доступа.'],
['connections_connection_access_user_tooltip', 'Пользователь'],
['connections_connection_access_role_tooltip', 'Роль'],
['connections_connection_access_revoke', 'Запретить'],
['connections_connection_access_grant', 'Разрешить'],
['connections_connection_access_edit', 'Изменить'],
['connections_connection_access_cloud_placeholder', 'Облачные подключения видны всем пользователям. Вы можете настроить доступ в "Консоль Управления AWS".'],
['connections_connection_access_empty_table_placeholder', 'Вы еще ничего не добавили. Нажмите кнопку Изменить, чтобы добавить роль или пользователя.'],
['connections_connection_access_filter_placeholder', 'Поиск по имени пользователя или роли'],
['connections_connection_access_filter_no_result', 'По вашему запросу ничего не найдено.'],
['connections_connection_access_admin_info', 'Администраторы видят все подключения.'],
['connections_connection_description', 'Описание'],
['connections_connection_driver', 'Драйвер'],
['connections_connection_host', 'Хост'],
@@ -2,7 +2,7 @@ query getRolesList($roleId: ID) {
roles: listRoles(roleId: $roleId) {
roleId
roleName
description
#rolePermissions
}
}
+13 -2
View File
@@ -68,6 +68,7 @@ export interface Query {
sqlSupportedOperations: DataTypeLogicalOperation[];
templateConnections: ConnectionInfo[];
updateConnectionConfiguration: ConnectionInfo;
updateRole: AdminRoleInfo;
userConnections: ConnectionInfo[];
}
@@ -108,6 +109,8 @@ export interface QueryCreateConnectionConfigurationArgs {
export interface QueryCreateRoleArgs {
roleId: Scalars['ID'];
roleName?: Maybe<Scalars['String']>;
description?: Maybe<Scalars['String']>;
}
export interface QueryCreateUserArgs {
@@ -260,6 +263,12 @@ export interface QueryUpdateConnectionConfigurationArgs {
config: ConnectionConfig;
}
export interface QueryUpdateRoleArgs {
roleId: Scalars['ID'];
roleName?: Maybe<Scalars['String']>;
description?: Maybe<Scalars['String']>;
}
export interface QueryUserConnectionsArgs {
id?: Maybe<Scalars['ID']>;
}
@@ -893,6 +902,7 @@ export interface AdminUserInfo {
export interface AdminRoleInfo {
roleId: Scalars['ID'];
roleName?: Maybe<Scalars['String']>;
description?: Maybe<Scalars['String']>;
rolePermissions: Array<Maybe<Scalars['ID']>>;
}
@@ -1042,7 +1052,7 @@ export type GetRolesListQueryVariables = Exact<{
roleId?: Maybe<Scalars['ID']>;
}>;
export interface GetRolesListQuery { roles: Array<Maybe<Pick<AdminRoleInfo, 'roleId' | 'roleName'>>> }
export interface GetRolesListQuery { roles: Array<Maybe<Pick<AdminRoleInfo, 'roleId' | 'roleName' | 'description'>>> }
export type GetUserGrantedConnectionsQueryVariables = Exact<{
userId?: Maybe<Scalars['ID']>;
@@ -1445,7 +1455,7 @@ export interface GetSqlExecuteTaskResultsMutation {
& { resultSet?: Maybe<(
Pick<SqlResultSet, 'id' | 'rows' | 'hasMoreData'>
& { columns?: Maybe<Array<Maybe<(
Pick<SqlResultColumn, 'dataKind' | 'entityName' | 'fullTypeName' | 'icon' | 'label' | 'maxLength' | 'name' | 'position' | 'precision' | 'readOnly' | 'scale' | 'typeName'>
Pick<SqlResultColumn, 'dataKind' | 'entityName' | 'fullTypeName' | 'icon' | 'label' | 'maxLength' | 'name' | 'position' | 'precision' | 'readOnly' | 'readOnlyStatus' | 'scale' | 'typeName'>
& { supportedOperations: Array<Pick<DataTypeLogicalOperation, 'id' | 'expression' | 'argumentCount'>> }
)>>>; }
)>; }
@@ -1923,6 +1933,7 @@ export const GetRolesListDocument = `
roles: listRoles(roleId: $roleId) {
roleId
roleName
description
}
}
`;