From 8f36a541177c2134eea9bfc47bc8ad273d0e8c68 Mon Sep 17 00:00:00 2001 From: Naumov Alexey Date: Mon, 7 Jun 2021 15:17:13 +0300 Subject: [PATCH 01/10] feat(core-connections): CB-1037 add permissions management for connection --- .../core-blocks/src/FormControls/Filter.tsx | 161 +++++++++++++++++ .../src/Table/TableColumnValue.tsx | 4 +- .../src/Tree/TreeNode/TreeNodeFilter.tsx | 119 ------------- webapp/packages/core-blocks/src/index.ts | 2 +- .../core-connections/public/icons/role.svg | 18 ++ .../core-connections/public/icons/user.svg | 9 + .../ConnectionAccess/ConnectionAccess.tsx | 167 ++++++++++-------- .../ConnectionAccessGrantedList.tsx | 142 +++++++++++++++ .../ConnectionAccess/ConnectionAccessList.tsx | 137 ++++++++++++++ .../ConnectionAccessTabService.ts | 11 +- .../ConnectionAccessTableHeader.tsx | 60 +++++++ .../ConnectionAccessTableInnerHeader.tsx | 28 +++ .../ConnectionAccessTableItem.tsx | 49 +++++ .../IConnectionAccessTabState.ts | 7 +- .../ConnectionAccess/getFilteredSubjects.ts | 29 +++ .../useConnectionAccessState.ts | 48 ++--- .../core-connections/src/locales/en.ts | 12 ++ .../core-connections/src/locales/ru.ts | 12 ++ .../authentication/users/getRolesList.gql | 2 +- webapp/packages/core-sdk/src/sdk.ts | 15 +- 20 files changed, 802 insertions(+), 230 deletions(-) create mode 100644 webapp/packages/core-blocks/src/FormControls/Filter.tsx delete mode 100644 webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeFilter.tsx create mode 100644 webapp/packages/core-connections/public/icons/role.svg create mode 100644 webapp/packages/core-connections/public/icons/user.svg create mode 100644 webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx create mode 100644 webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx create mode 100644 webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx create mode 100644 webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableInnerHeader.tsx create mode 100644 webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx create mode 100644 webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/getFilteredSubjects.ts diff --git a/webapp/packages/core-blocks/src/FormControls/Filter.tsx b/webapp/packages/core-blocks/src/FormControls/Filter.tsx new file mode 100644 index 0000000000..ba7b8166b3 --- /dev/null +++ b/webapp/packages/core-blocks/src/FormControls/Filter.tsx @@ -0,0 +1,161 @@ +/* + * 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 { observable } from 'mobx'; +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; + } +`; + +export interface IFilterState { + filterValue: string; +} + +interface Props { + state?: IFilterState; + onFilter?: (filter: string) => void; + toggleMode?: boolean; + onToggle?: (status: boolean) => void; + placeholder?: string; + disabled?: boolean; + className?: string; +} + +export const Filter: React.FC = observer(function Filter({ + state, + onFilter, + toggleMode, + onToggle, + placeholder, + disabled, + className, +}) { + const [inputRef] = useFocus({}); + const [toggled, setToggled] = useState(!toggleMode); + const [filterState] = useState(() => state || observable({ filterValue: '' })); + + const filter = useCallback((value: string) => { + filterState.filterValue = value; + if (onFilter) { + onFilter(value); + } + }, [onFilter, filterState]); + + const toggle = useCallback(() => { + setToggled(prev => { + if (prev) { + filter(''); + } + if (onToggle) { + onToggle(!prev); + } + return !prev; + }); + }, [onToggle, filter]); + + const onKeyDown = useCallback((event: React.KeyboardEvent) => { + if (event.key === 'Enter' || event.keyCode === 13) { + event.stopPropagation(); + } + }, []); + + const handleClick = (event: React.MouseEvent) => { + event.stopPropagation(); + }; + + const preventPropagation = (event: React.MouseEvent) => { + event.stopPropagation(); + event.preventDefault(); + }; + + useEffect(() => { + if (toggled && toggleMode) { + inputRef.current?.focus(); + } + }, [toggled, toggleMode, inputRef]); + + useEffect(() => () => { filter(''); }, []); + + return styled(useStyles(styles, toggleMode && toggleModeButtonStyle))( + + filter(String(value).trim())} + {...use({ toggled })} + /> + + + ); +}); diff --git a/webapp/packages/core-blocks/src/Table/TableColumnValue.tsx b/webapp/packages/core-blocks/src/Table/TableColumnValue.tsx index 78ec1d8413..c109fe83b4 100644 --- a/webapp/packages/core-blocks/src/Table/TableColumnValue.tsx +++ b/webapp/packages/core-blocks/src/Table/TableColumnValue.tsx @@ -23,7 +23,7 @@ type Props = React.PropsWithChildren<{ expand?: boolean; onClick?: () => void; onDoubleClick?: () => void; -}>; +}> & React.DetailedHTMLProps, 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 && {children}} {!flex && children} diff --git a/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeFilter.tsx b/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeFilter.tsx deleted file mode 100644 index 22c59b75bb..0000000000 --- a/webapp/packages/core-blocks/src/Tree/TreeNode/TreeNodeFilter.tsx +++ /dev/null @@ -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 = observer(function TreeNodeFilter({ - disabled, - className, -}) { - const context = useContext(TreeNodeContext); - const [inputRef] = useFocus({}); - 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) => { - if (event.key === 'Enter' || event.keyCode === 13) { - event.stopPropagation(); - } - }, []); - - const handleClick = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - - const preventPropagation = (event: React.MouseEvent) => { - event.stopPropagation(); - event.preventDefault(); - }; - - useEffect(() => { - if (filterEnabled) { - inputRef.current?.focus(); - } - }, [filterEnabled]); - - useEffect(() => () => { context.filter(''); }, []); - - return styled(useStyles(styles))( - - context.filter(String(value).trim())} - /> - - - ); -}); diff --git a/webapp/packages/core-blocks/src/index.ts b/webapp/packages/core-blocks/src/index.ts index b5a56b51bf..516fc6e87c 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -27,7 +27,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'; @@ -40,6 +39,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'; diff --git a/webapp/packages/core-connections/public/icons/role.svg b/webapp/packages/core-connections/public/icons/role.svg new file mode 100644 index 0000000000..fcfc156fc4 --- /dev/null +++ b/webapp/packages/core-connections/public/icons/role.svg @@ -0,0 +1,18 @@ + + + + group + Created with Sketch. + + + + + + + + + + + + + \ No newline at end of file diff --git a/webapp/packages/core-connections/public/icons/user.svg b/webapp/packages/core-connections/public/icons/user.svg new file mode 100644 index 0000000000..3554ad1309 --- /dev/null +++ b/webapp/packages/core-connections/public/icons/user.svg @@ -0,0 +1,9 @@ + + + + user + Created with Sketch. + + + + \ No newline at end of file diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx index c004157b38..33c9f0ca8b 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx @@ -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 { 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: string; + icon: string; +} export const ConnectionAccess: TabContainerPanelComponent = 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,67 @@ export const ConnectionAccess: TabContainerPanelComponent onLoad: resource => resource.loadAll(), }); + const grantedUsers = useMemo(() => computed(() => users.resource.values + .filter(user => state.grantedSubjects.includes(user.userId)) + ), [state.grantedSubjects, users.resource.values]); + + const grantedRoles = useMemo(() => computed(() => roles.resource.values + .filter(role => state.grantedSubjects.includes(role.roleId)) + ), [state.grantedSubjects, roles.resource.values]); + 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; + const infoItem: IInfoItem = { + text: translate('connections_connection_access_save_reminder'), + icon: '/icons/info_icon.svg', + }; + + if (cloud) { + infoItem.text = translate('connections_connection_access_cloud_placeholder'); + } if (!selected) { return null; } - if (users.resource.values.length === 0 && roles.resource.values.length) { - return styled(style)( - - - {translate('connections_administration_connection_access_empty')} - - - ); - } - return styled(style)( - - - - - - {translate('connections_connection_name')} - - - - {roles.resource.values.map(role => ( - - - - - {role.roleName} - - - ))} - {users.resource.values.map(user => ( - - - - - {user.userId} - - - ))} - -
- -
-
+ + {() => styled(style)( + + {!users.resource.values.length && !roles.resource.values.length ? ( + + {translate('connections_administration_connection_access_empty')} + + ) : ( + <> + {(unsaved || cloud) && ( + + + {infoItem.text} + + )} + + {state.editing && ( + + )} + + )} + + )} + ); }); diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx new file mode 100644 index 0000000000..fe71d2559b --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx @@ -0,0 +1,142 @@ +/* + * 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, + IFilterState, + 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 } 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; + } + Table { + flex: 1; + } + ` +); + +interface Props { + grantedUsers: AdminUserInfoFragment[]; + grantedRoles: AdminRoleInfo[]; + disabled: boolean; + onRevoke: (subjectIds: string[]) => void; + onEdit: () => void; +} + +export const ConnectionAccessGrantedList: React.FC = 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>(() => observable(new Map())); + const [filterState] = useState(() => observable({ filterValue: '' })); + const subjectsSelected = useMemo(() => computed(() => Array.from(selectedSubjects.values()).some(Boolean)), []); + + const revoke = useCallback(() => { + const subjectsToRevoke = []; + for (const [subject, value] of selectedSubjects) { + if (value) { + subjectsToRevoke.push(subject); + } + } + + props.onRevoke(subjectsToRevoke); + selectedSubjects.clear(); + }, []); + + const roles = useMemo(() => computed(() => getFilteredRoles( + grantedRoles, filterState.filterValue + )), [filterState.filterValue, grantedRoles]); + + const users = useMemo(() => computed(() => getFilteredUsers( + grantedUsers, filterState.filterValue + )), [filterState.filterValue, grantedUsers]); + + let tableInfoText: string = translate('connections_connection_access_admin_info'); + if (!roles.get().length && !users.get().length) { + if (filterState.filterValue) { + tableInfoText = translate('connections_connection_access_filter_no_result'); + } else { + tableInfoText = translate('connections_connection_access_empty_table_placeholder'); + } + } + + return styled(style)( + + + + + + + + + + + {tableInfoText} + + + {roles.get().map(role => ( + + ))} + {users.get().map(user => ( + + ))} + +
+
+ ); +}); diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx new file mode 100644 index 0000000000..14a859ea9b --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx @@ -0,0 +1,137 @@ +/* + * 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, + IFilterState, + 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 } 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; + } + Table { + flex: 1; + } + ` +); + +interface Props { + userList: AdminUserInfoFragment[]; + roleList: AdminRoleInfo[]; + grantedSubjects: string[]; + onGrant: (subjectIds: string[]) => void; + disabled: boolean; +} + +export const ConnectionAccessList: React.FC = 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>(() => observable(new Map())); + const [filterState] = useState(() => observable({ filterValue: '' })); + const subjectsSelected = useMemo(() => computed( + () => Array.from(selectedSubjects.values()).some(Boolean) + ), [selectedSubjects]); + + const grant = useCallback(() => { + const subjectsToGrant = []; + + for (const [subject, value] of selectedSubjects) { + if (value) { + subjectsToGrant.push(subject); + } + } + + props.onGrant(subjectsToGrant); + selectedSubjects.clear(); + }, []); + + const roles = useMemo(() => computed(() => getFilteredRoles( + roleList, filterState.filterValue + )), [filterState.filterValue, roleList]); + + const users = useMemo(() => computed(() => getFilteredUsers( + userList, filterState.filterValue + )), [filterState.filterValue, userList]); + + return styled(style)( + + + + + + + + {!roles.get().length && !users.get().length && filterState.filterValue && ( + + + {translate('connections_connection_access_filter_no_result')} + + + )} + {roles.get().map(role => ( + + ))} + {users.get().map(user => ( + + ))} + +
+
+ ); +}); diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTabService.ts b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTabService.ts index 644a8379de..38d9e0b6a5 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTabService.ts +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTabService.ts @@ -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 { 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 { + private async isChanged(connectionId: string, next: string[]): Promise { 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)); } } diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx new file mode 100644 index 0000000000..17ed70ed1f --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx @@ -0,0 +1,60 @@ +/* + * 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, IFilterState } from '@cloudbeaver/core-blocks'; +import { useTranslate } from '@cloudbeaver/core-localization'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +interface Props { + filter: 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; + grid-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; + grid-gap: 16px; + border-bottom: 1px solid; + } + ` +); + +export const ConnectionAccessTableHeader: React.FC = observer(function ConnectionAccessTableHeader({ filter, disabled, className, children }) { + const translate = useTranslate(); + return styled(useStyles(styles))( +
+ + + {children} + +
+ ); +}); diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableInnerHeader.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableInnerHeader.tsx new file mode 100644 index 0000000000..70cf2d720e --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableInnerHeader.tsx @@ -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 = observer(function ConnectionAccessTableInnerHeader({ className }) { + const translate = useTranslate(); + return ( + + + + {translate('connections_connection_access_user_or_role_name')} + {translate('connections_connection_description')} + + ); +}); diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx new file mode 100644 index 0000000000..d5b4acf84b --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx @@ -0,0 +1,49 @@ +/* + * 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'; + +interface Props { + id: any; + name: string; + icon: string; + disabled: boolean; + iconTooltip?: string; + description?: string; + className?: string; +} + +const style = css` + StaticImage { + display: flex; + width: 24px; + } +`; + +export const ConnectionAccessTableItem: React.FC = observer(function ConnectionAccessTableItem({ + id, name, description, icon, iconTooltip, disabled, className, +}) { + return styled(style)( + + + + + + {name} + {description} + + ); +}); diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/IConnectionAccessTabState.ts b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/IConnectionAccessTabState.ts index a671c3114f..560ee1e62b 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/IConnectionAccessTabState.ts +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/IConnectionAccessTabState.ts @@ -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; - grantedSubjects: AdminConnectionGrantInfo[]; + grantedSubjects: string[]; + initialGrantedSubjects: string[]; + editing: boolean; } diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/getFilteredSubjects.ts b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/getFilteredSubjects.ts new file mode 100644 index 0000000000..b3679f8b7c --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/getFilteredSubjects.ts @@ -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 ?? '')); +} diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts index db11fb7f19..349b1be3ea 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts @@ -9,37 +9,40 @@ 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; } -export function useConnectionAccessState(formState: IConnectionFormState): IConnectionAccessState { +export function useConnectionAccessState(connection: DatabaseConnectionFragment | undefined): IConnectionAccessState { const connectionsResource = useService(ConnectionsResource); const notificationService = useService(NotificationService); const state = useTabState(); - 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 => { + const newGrantedSubjects = []; + for (const subjectId of state.grantedSubjects) { + if (!subjectIds.includes(subjectId)) { + newGrantedSubjects.push(subjectId); + } + } + state.grantedSubjects = newGrantedSubjects; + }; + + const grant = (subjectIds: string[]): void => { + state.grantedSubjects.push(...subjectIds); }; const load = async () => { @@ -50,13 +53,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 +66,5 @@ export function useConnectionAccessState(formState: IConnectionFormState): IConn state.loading = false; }; - return useObjectRef({ state, select, load }); + return useObjectRef({ state, revoke, grant, edit, load }); } diff --git a/webapp/packages/core-connections/src/locales/en.ts b/webapp/packages/core-connections/src/locales/en.ts index 1617093513..c3f7725919 100644 --- a/webapp/packages/core-connections/src/locales/en.ts +++ b/webapp/packages/core-connections/src/locales/en.ts @@ -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 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'], diff --git a/webapp/packages/core-connections/src/locales/ru.ts b/webapp/packages/core-connections/src/locales/ru.ts index e2515d3826..42b57a1a46 100644 --- a/webapp/packages/core-connections/src/locales/ru.ts +++ b/webapp/packages/core-connections/src/locales/ru.ts @@ -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', 'Хост'], diff --git a/webapp/packages/core-sdk/src/queries/authentication/users/getRolesList.gql b/webapp/packages/core-sdk/src/queries/authentication/users/getRolesList.gql index b7a323567a..2d49828a8b 100644 --- a/webapp/packages/core-sdk/src/queries/authentication/users/getRolesList.gql +++ b/webapp/packages/core-sdk/src/queries/authentication/users/getRolesList.gql @@ -2,7 +2,7 @@ query getRolesList($roleId: ID) { roles: listRoles(roleId: $roleId) { roleId roleName - + description #rolePermissions } } \ No newline at end of file diff --git a/webapp/packages/core-sdk/src/sdk.ts b/webapp/packages/core-sdk/src/sdk.ts index 2d84efa68b..515256df4d 100644 --- a/webapp/packages/core-sdk/src/sdk.ts +++ b/webapp/packages/core-sdk/src/sdk.ts @@ -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; + description?: Maybe; } export interface QueryCreateUserArgs { @@ -260,6 +263,12 @@ export interface QueryUpdateConnectionConfigurationArgs { config: ConnectionConfig; } +export interface QueryUpdateRoleArgs { + roleId: Scalars['ID']; + roleName?: Maybe; + description?: Maybe; +} + export interface QueryUserConnectionsArgs { id?: Maybe; } @@ -893,6 +902,7 @@ export interface AdminUserInfo { export interface AdminRoleInfo { roleId: Scalars['ID']; roleName?: Maybe; + description?: Maybe; rolePermissions: Array>; } @@ -1042,7 +1052,7 @@ export type GetRolesListQueryVariables = Exact<{ roleId?: Maybe; }>; -export interface GetRolesListQuery { roles: Array>> } +export interface GetRolesListQuery { roles: Array>> } export type GetUserGrantedConnectionsQueryVariables = Exact<{ userId?: Maybe; @@ -1445,7 +1455,7 @@ export interface GetSqlExecuteTaskResultsMutation { & { resultSet?: Maybe<( Pick & { columns?: Maybe + Pick & { supportedOperations: Array> } )>>>; } )>; } @@ -1923,6 +1933,7 @@ export const GetRolesListDocument = ` roles: listRoles(roleId: $roleId) { roleId roleName + description } } `; From 7aafe48906ebfd0a37495b66548751325c507deb Mon Sep 17 00:00:00 2001 From: Naumov Alexey Date: Mon, 7 Jun 2021 15:29:47 +0300 Subject: [PATCH 02/10] feat(core-connections): CB-1037 remove unnecessary optional chaining --- .../ConnectionAccess/ConnectionAccessGrantedList.tsx | 4 +++- .../ConnectionForm/ConnectionAccess/getFilteredSubjects.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx index fe71d2559b..f3003e9499 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx @@ -69,7 +69,9 @@ export const ConnectionAccessGrantedList: React.FC = observer(function Co const translate = useTranslate(); const [selectedSubjects] = useState>(() => observable(new Map())); const [filterState] = useState(() => observable({ filterValue: '' })); - const subjectsSelected = useMemo(() => computed(() => Array.from(selectedSubjects.values()).some(Boolean)), []); + const subjectsSelected = useMemo(() => computed( + () => Array.from(selectedSubjects.values()).some(Boolean) + ), [selectedSubjects]); const revoke = useCallback(() => { const subjectsToRevoke = []; diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/getFilteredSubjects.ts b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/getFilteredSubjects.ts index b3679f8b7c..5fd9d160bc 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/getFilteredSubjects.ts +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/getFilteredSubjects.ts @@ -14,7 +14,7 @@ import type { AdminRoleInfo, AdminUserInfoFragment } from '@cloudbeaver/core-sdk */ export function getFilteredUsers(users: AdminUserInfoFragment[], filter: string): AdminUserInfoFragment[] { return users - .filter(user => user.userId?.toLowerCase().includes(filter.toLowerCase())) + .filter(user => user.userId.toLowerCase().includes(filter.toLowerCase())) .sort((a, b) => (a.userId).localeCompare(b.userId)); } From 4775840451361da998f73c1b410cddad1f57e4fc Mon Sep 17 00:00:00 2001 From: serge-rider Date: Mon, 7 Jun 2021 22:45:53 +0300 Subject: [PATCH 03/10] CB-907 Content value edit support --- .../service/sql/WebSQLProcessor.java | 1 + .../cloudbeaver/service/sql/WebSQLUtils.java | 53 ++++++++++++++----- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java index c64cd90374..64d27eb6ff 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLProcessor.java @@ -385,6 +385,7 @@ public class WebSQLProcessor { for (int i = 0; i < updateAttributes.length; i++) { DBDAttributeBinding updateAttribute = updateAttributes[i]; Object cellRawValue = updateValues.get(String.valueOf(updateAttribute.getOrdinalPosition())); + cellRawValue = WebSQLUtils.makePlainCellValue(cellRawValue); Object realCellValue = updateAttribute.getValueHandler().getValueFromObject(session, updateAttribute, cellRawValue, false, true); rowValues[i] = realCellValue; finalRow[updateAttribute.getOrdinalPosition()] = WebSQLUtils.makeWebCellValue(webSession, null, realCellValue, dataFormat); diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java index 517f693c76..9b3fbcfecf 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/service/sql/WebSQLUtils.java @@ -46,6 +46,16 @@ public class WebSQLUtils { public static final int BINARY_PREVIEW_LENGTH = 255; public static final int BINARY_MAX_LENGTH = 1 * 1024 * 1024; + public static final String VALUE_TYPE_ATTR = "$type"; + + public static final String VALUE_TYPE_COLLECTION = "collection"; + public static final String VALUE_TYPE_MAP = "map"; + public static final String VALUE_TYPE_DOCUMENT = "document"; + public static final String VALUE_TYPE_CONTENT = "content"; + public static final String VALUE_TYPE_GEOMETRY = "geometry"; + public static final String ATTR_TEXT = "text"; + public static final String ATTR_BINARY = "binary"; + public static Object makeWebCellValue(WebSession session, DBSTypedObject type, Object cellValue, WebDataFormat dataFormat) throws DBCException { if (cellValue instanceof Date) { return CBConstants.ISO_DATE_FORMAT.format(cellValue); @@ -77,7 +87,7 @@ public class WebSQLUtils { items[i] = makeWebCellValue(session, collection.getComponentType(), collection.getItem(i), dataFormat); } - Map map = createMapOfType("collection"); + Map map = createMapOfType(VALUE_TYPE_COLLECTION); map.put("value", items); return map; } else if (value instanceof DBDComposite) { @@ -87,7 +97,7 @@ public class WebSQLUtils { struct.put(attr.getName(), makeWebCellValue(session, attr, composite.getAttributeValue(attr), dataFormat)); } - Map map = createMapOfType("map"); + Map map = createMapOfType(VALUE_TYPE_MAP); map.put("value", struct); return map; } @@ -97,7 +107,7 @@ public class WebSQLUtils { @NotNull private static Map createMapOfType(String type) { Map map = new LinkedHashMap<>(); - map.put("$type", type); + map.put(VALUE_TYPE_ATTR, type); return map; } @@ -111,7 +121,7 @@ public class WebSQLUtils { throw new DBCException("Error serializing document", e); } - Map map = createMapOfType("document"); + Map map = createMapOfType(VALUE_TYPE_DOCUMENT); map.put("id", CommonUtils.toString(document.getDocumentId())); map.put("contentType", document.getDocumentContentType()); map.put("properties", Collections.emptyMap()); @@ -121,27 +131,27 @@ public class WebSQLUtils { private static Object serializeContentValue(WebSession session, DBDContent value) throws DBCException { - Map map = createMapOfType("content"); + Map map = createMapOfType(VALUE_TYPE_CONTENT); if (ContentUtils.isTextContent(value)) { String stringValue = ContentUtils.getContentStringValue(session.getProgressMonitor(), value); - map.put("text", stringValue); + map.put(ATTR_TEXT, stringValue); } else { - map.put("binary", true); + map.put(ATTR_BINARY, true); byte[] binaryValue = ContentUtils.getContentBinaryValue(session.getProgressMonitor(), value); if (binaryValue != null) { byte[] previewValue = binaryValue; if (previewValue.length > BINARY_PREVIEW_LENGTH) { previewValue = Arrays.copyOf(previewValue, BINARY_PREVIEW_LENGTH); } - map.put("text", GeneralUtils.convertToString(binaryValue, 0, binaryValue.length)); + map.put(ATTR_TEXT, GeneralUtils.convertToString(binaryValue, 0, binaryValue.length)); byte[] inlineValue = binaryValue; if (inlineValue.length > BINARY_MAX_LENGTH) { inlineValue = Arrays.copyOf(inlineValue, BINARY_PREVIEW_LENGTH); } - map.put("binary", Base64.encode(inlineValue)); + map.put(ATTR_BINARY, Base64.encode(inlineValue)); } else { - map.put("text", null); + map.put(ATTR_TEXT, null); } } map.put("contentType", value.getContentType()); @@ -150,9 +160,9 @@ public class WebSQLUtils { } private static Object serializeGeometryValue(DBGeometry value) { - Map map = createMapOfType("geometry"); + Map map = createMapOfType(VALUE_TYPE_GEOMETRY); map.put("srid", value.getSRID()); - map.put("text", value.toString()); + map.put(ATTR_TEXT, value.toString()); map.put("properties", value.getProperties()); DBGeometry xValue = GisTransformUtils.transformToSRID(value, GisConstants.SRID_4326); @@ -162,4 +172,23 @@ public class WebSQLUtils { return map; } + public static Object makePlainCellValue(Object value) throws DBCException { + if (value instanceof Map) { + Map map = (Map) value; + Object typeAttr = map.get(VALUE_TYPE_ATTR); + if (typeAttr instanceof String) { + switch ((String)typeAttr) { + case VALUE_TYPE_CONTENT: + if (map.get(ATTR_BINARY) != null) { + throw new DBCException("Binary content edit is not supported yet"); + } + value = map.get(ATTR_TEXT); + break; + default: + throw new DBCException("Type '" + typeAttr + "' edit is not supported yet"); + } + } + } + return value; + } } From 1dcf8f23c3083453cdf153db17b53cc1bdf40377 Mon Sep 17 00:00:00 2001 From: serge-rider Date: Tue, 8 Jun 2021 00:04:06 +0300 Subject: [PATCH 04/10] dbeaver/dbeaver#12284 PostgreSQL drivers update (42.2.20) --- server/drivers/postgresql/pom.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/drivers/postgresql/pom.xml b/server/drivers/postgresql/pom.xml index 9768530a95..c6e1b9c82b 100644 --- a/server/drivers/postgresql/pom.xml +++ b/server/drivers/postgresql/pom.xml @@ -18,12 +18,12 @@ org.postgresql postgresql - 42.2.5 + 42.2.20 net.postgis postgis-jdbc - 2.2.1 + 2.5.0 From cbc07558cd26795dbea803072ad2a6ce1fe9b085 Mon Sep 17 00:00:00 2001 From: Wroud Date: Tue, 8 Jun 2021 20:33:55 +0300 Subject: [PATCH 05/10] fix: CB-907 numeric cell editing --- .../src/DataGrid/CellEditor/CellEditor.tsx | 17 ++--------------- .../src/DataGrid/CellRenderer/CellRenderer.tsx | 2 +- .../DataGridContextMenuFilterService.ts | 4 ++-- .../src/DataGrid/Formatters/CellFormatter.tsx | 2 +- .../src/DataGrid/Formatters/IndexFormatter.tsx | 6 +----- .../src/DataGrid/RowRenderer/RowRenderer.tsx | 4 +--- .../src/DataGrid/useGridSelectedCellsCopy.ts | 2 +- .../Actions/IDatabaseDataFormatAction.ts | 2 +- .../Actions/ResultSet/ResultSetFormatAction.ts | 18 +++++++++++++----- .../DatabaseDataModel/DatabaseDataEditor.ts | 5 ++++- .../TextValue/TextValuePresentation.tsx | 11 +++++------ 11 files changed, 32 insertions(+), 41 deletions(-) diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellEditor/CellEditor.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellEditor/CellEditor.tsx index 6263aedf7d..bd6665f0fe 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellEditor/CellEditor.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellEditor/CellEditor.tsx @@ -87,22 +87,9 @@ export const CellEditor = observer, 'rowIdx' | 'row' } }); - const value = formatter?.get(row[column.key]); - const numericCell = typeof tableDataContext?.getCellValue(rowIdx, column.key) === 'number'; + const value = formatter?.getText(row[column.key]) ?? ''; - const handleSave = () => { - if (numericCell) { - const editor = dataGridContext.model.source.getEditor(dataGridContext.resultIndex); - let value = editor.getCell(rowIdx, Number(column.key)); - - if (typeof value === 'string') { - value = value.replace(',', '.'); - } - - editor.setCell(rowIdx, Number(column.key), Number(value)); - } - onClose(false); - }; + const handleSave = () => onClose(false); const handleReject = () => { dataGridContext.model.source.getEditor(dataGridContext.resultIndex) .revertCell(rowIdx, Number(column.key)); diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx index 211d5b547d..062519da18 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/CellRenderer/CellRenderer.tsx @@ -40,7 +40,7 @@ export const CellRenderer: React.FC> = observer(function classes.push('rdg-cell-custom-editing'); } - if (editor?.isCellEdited(rowIdx, Number(column.key))) { + if (!tableDataContext?.isIndexColumn(column.key) && editor?.isCellEdited(rowIdx, Number(column.key))) { classes.push('rdg-cell-custom-edited'); } diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridContextMenu/DataGridContextMenuFilter/DataGridContextMenuFilterService.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridContextMenu/DataGridContextMenuFilter/DataGridContextMenuFilterService.ts index 0570be8f92..580ed07e60 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridContextMenu/DataGridContextMenuFilter/DataGridContextMenuFilterService.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/DataGridContextMenu/DataGridContextMenuFilter/DataGridContextMenuFilterService.ts @@ -90,7 +90,7 @@ export class DataGridContextMenuFilterService { }, titleGetter() { const val = typeof value === 'function' ? value() : value; - const stringifyValue = format.toString(val); + const stringifyValue = format.toDisplayString(val); const wrappedValue = wrapOperationArgument(operation.id, stringifyValue); const clippeddValue = replaceMiddle(wrappedValue, ' ... ', 8, 30); return `${columnLabel} ${operation.expression} ${clippeddValue}`; @@ -244,7 +244,7 @@ export class DataGridContextMenuFilterService { icon: '/icons/filter_custom.png', onClick: async () => { const isNull = format.isNull(cellValue); - const stringifyCellValue = format.toString(cellValue); + const stringifyCellValue = format.toDisplayString(cellValue); const customValue = await this.commonDialogService.open( FilterCustomValueDialog, { diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/Formatters/CellFormatter.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/Formatters/CellFormatter.tsx index f0eda14236..e0d3d865ba 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/Formatters/CellFormatter.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/Formatters/CellFormatter.tsx @@ -36,7 +36,7 @@ export const CellFormatter: React.FC = observer(function CellFor const rawValue = formatter?.get(row[column.key]) ?? row[column.key]; const classes = getClasses(rawValue); const [menuVisible, setMenuVisible] = useState(false); - const value = formatter?.toString(rawValue) ?? String(rawValue); + const value = formatter?.toDisplayString(rawValue) ?? String(rawValue); const handleClose = useCallback(() => { editingContext?.closeEditor({ idx: column.idx, rowIdx }); diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/Formatters/IndexFormatter.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/Formatters/IndexFormatter.tsx index a18e430c1a..be9f007789 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/Formatters/IndexFormatter.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/Formatters/IndexFormatter.tsx @@ -9,9 +9,5 @@ import type { FormatterProps } from 'react-data-grid'; export const IndexFormatter: React.FC = function IndexFormatter({ rowIdx }) { - return ( - - {rowIdx + 1} - - ); + return
{rowIdx + 1}
; }; diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/RowRenderer/RowRenderer.tsx b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/RowRenderer/RowRenderer.tsx index c716b82b29..bd74d07455 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/RowRenderer/RowRenderer.tsx +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/RowRenderer/RowRenderer.tsx @@ -12,7 +12,5 @@ import { Row } from 'react-data-grid'; import { CellRenderer } from '../CellRenderer/CellRenderer'; export const RowRenderer: React.FC> = function RowRenderer(props) { - return ( - - ); + return ; }; diff --git a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridSelectedCellsCopy.ts b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridSelectedCellsCopy.ts index 02cb63fefa..03c3018c37 100644 --- a/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridSelectedCellsCopy.ts +++ b/webapp/packages/plugin-data-spreadsheet-new/src/DataGrid/useGridSelectedCellsCopy.ts @@ -48,7 +48,7 @@ function copyGridSelectedDataToClipboard( if (colIndexes.includes(columnIdx)) { const cell = editor.getCell(rowIdx, columnIdx); - const cellValue = format.get(cell); + const cellValue = format.getText(cell); rowCellsValues.push(cellValue ?? ''); } else { rowCellsValues.push(''); diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataFormatAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataFormatAction.ts index 2012fffee8..cbb84f1bc4 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataFormatAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataFormatAction.ts @@ -15,5 +15,5 @@ export interface IDatabaseDataFormatAction any; getText: (value: any) => string | null; isNull: (value: any) => boolean; - toString: (value: any) => string; + toDisplayString: (value: any) => string; } diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction.ts index a4f7c8507e..5f5e941a9a 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetFormatAction.ts @@ -16,6 +16,8 @@ import type { IResultSetElementKey } from './IResultSetElementKey'; import { isResultSetContentValue } from './isResultSetContentValue'; import { ResultSetDataAction } from './ResultSetDataAction'; +export type IResultSetValue = string | number | Record | null> | null; + @databaseDataAction() export class ResultSetFormatAction extends DatabaseDataAction implements IDatabaseDataFormatAction { @@ -45,11 +47,11 @@ export class ResultSetFormatAction extends DatabaseDataAction 1000) { return value.split('').map(v => (v.charCodeAt(0) < 32 ? ' ' : v)).join(''); diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataEditor.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataEditor.ts index d6294f6557..25a3b6146e 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataEditor.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/DatabaseDataEditor.ts @@ -292,8 +292,11 @@ export class DatabaseDataEditor implements } private compareCellValue(valueA: any, valueB: any) { + valueA = valueA ?? ''; + valueB = valueB ?? ''; + if (typeof valueA === 'number' || typeof valueB === 'number') { - return Number(valueA) === Number(valueB); + return String(valueA) === String(valueB); } if (isResultSetContentValue(valueA) && isResultSetContentValue(valueB)) { diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx index 7ec4d96db9..dee91cafda 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -89,14 +89,14 @@ export const TextValuePresentation: TabContainerPanelComponent | undefined; let readonly = true; @@ -106,11 +106,11 @@ export const TextValuePresentation: TabContainerPanelComponent Date: Tue, 8 Jun 2021 20:45:38 +0300 Subject: [PATCH 06/10] fix(plugin-data-viewer): CB-907 code line wrap --- .../ValuePanelPresentation/TextValue/TextValuePresentation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx index dee91cafda..6f897a3f3e 100644 --- a/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx +++ b/webapp/packages/plugin-data-viewer/src/ValuePanelPresentation/TextValue/TextValuePresentation.tsx @@ -166,7 +166,7 @@ export const TextValuePresentation: TabContainerPanelComponent handleChange(value)} /> From eb268c0755783e75002b31446a0334605eaadb1c Mon Sep 17 00:00:00 2001 From: Wroud Date: Wed, 9 Jun 2021 09:33:05 +0300 Subject: [PATCH 07/10] refactor(plugin-data-viewer): CB-1028 rename --- .../DatabaseDataModel/Actions/IDatabaseDataSelectAction.ts | 5 ++--- .../Actions/ResultSet/ResultSetSelectAction.ts | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataSelectAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataSelectAction.ts index 28df2377c9..125a8e0e56 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataSelectAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/IDatabaseDataSelectAction.ts @@ -11,7 +11,7 @@ import type { IExecutor } from '@cloudbeaver/core-executor'; import type { IDatabaseDataAction } from '../IDatabaseDataAction'; import type { IDatabaseDataResult } from '../IDatabaseDataResult'; -export type DatabaseDataEditorActionsData = { +export type DatabaseDataSelectActionsData = { type: 'select'; resultId: string; key: TKey; @@ -23,12 +23,11 @@ export type DatabaseDataEditorActionsData = { export interface IDatabaseDataSelectAction extends IDatabaseDataAction { - readonly actions: IExecutor>; + readonly actions: IExecutor>; isSelected: () => boolean; isElementSelected: (key: TKey) => boolean; getFocusedElement: () => TKey | null; getSelectedElements: () => TKey[]; - getRowSelection: (row: number) => number[]; set: (key: TKey, selected: boolean) => void; focus: (key: TKey | null) => void; clear: () => void; diff --git a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction.ts b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction.ts index 5c9e9add12..3793d06d46 100644 --- a/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction.ts +++ b/webapp/packages/plugin-data-viewer/src/DatabaseDataModel/Actions/ResultSet/ResultSetSelectAction.ts @@ -15,7 +15,7 @@ import { DatabaseDataAction } from '../../DatabaseDataAction'; import type { IDatabaseDataSource } from '../../IDatabaseDataSource'; import type { IDatabaseResultSet } from '../../IDatabaseResultSet'; import { databaseDataAction } from '../DatabaseDataActionDecorator'; -import type { DatabaseDataEditorActionsData, IDatabaseDataSelectAction } from '../IDatabaseDataSelectAction'; +import type { DatabaseDataSelectActionsData, IDatabaseDataSelectAction } from '../IDatabaseDataSelectAction'; import type { IResultSetElementKey } from './IResultSetElementKey'; @databaseDataAction() @@ -23,7 +23,7 @@ export class ResultSetSelectAction extends DatabaseDataAction { static dataFormat = ResultDataFormat.Resultset; - readonly actions: IExecutor>; + readonly actions: IExecutor>; readonly selectedElements: Map; private focusedElement: IResultSetElementKey | null; From a04866c487d4d7c8b6bd0fb7c37fb68d60eb1786 Mon Sep 17 00:00:00 2001 From: Naumov Alexey Date: Wed, 9 Jun 2021 13:31:47 +0300 Subject: [PATCH 08/10] refactor(core-connections): CB-1037 change Filter state managment --- .../core-blocks/src/FormControls/Filter.tsx | 80 ++++++++++++------- .../ConnectionAccess/ConnectionAccess.tsx | 29 ++++--- .../ConnectionAccessGrantedList.tsx | 37 +++------ .../ConnectionAccess/ConnectionAccessList.tsx | 30 +++---- .../ConnectionAccessTableHeader.tsx | 19 +++-- .../ConnectionAccessTableItem.tsx | 6 +- .../useConnectionAccessState.ts | 8 +- .../core-connections/src/locales/en.ts | 4 +- .../core-connections/src/locales/ru.ts | 10 +-- 9 files changed, 116 insertions(+), 107 deletions(-) diff --git a/webapp/packages/core-blocks/src/FormControls/Filter.tsx b/webapp/packages/core-blocks/src/FormControls/Filter.tsx index ba7b8166b3..9051db9313 100644 --- a/webapp/packages/core-blocks/src/FormControls/Filter.tsx +++ b/webapp/packages/core-blocks/src/FormControls/Filter.tsx @@ -6,7 +6,6 @@ * you may not use this file except in compliance with the License. */ -import { observable } from 'mobx'; import { observer } from 'mobx-react-lite'; import { useCallback, useEffect, useState } from 'react'; import styled, { css, use } from 'reshadow'; @@ -64,13 +63,7 @@ const innerInputStyle = css` } `; -export interface IFilterState { - filterValue: string; -} - -interface Props { - state?: IFilterState; - onFilter?: (filter: string) => void; +interface BaseProps { toggleMode?: boolean; onToggle?: (status: boolean) => void; placeholder?: string; @@ -78,8 +71,24 @@ interface Props { className?: string; } -export const Filter: React.FC = observer(function Filter({ +type ControlledProps = BaseProps & { + name?: string; + value?: string; + state?: never; + onFilter?: (value: string, name?: string) => void; +}; + +type ObjectsProps = BaseProps & { + name: TKey; + state: TState; + onFilter?: (value: TState[TKey], name: TKey) => void; + value?: never; +}; + +export const Filter: React.FC> = observer(function Filter({ state, + name, + value: valueControlled, onFilter, toggleMode, onToggle, @@ -89,26 +98,34 @@ export const Filter: React.FC = observer(function Filter({ }) { const [inputRef] = useFocus({}); const [toggled, setToggled] = useState(!toggleMode); - const [filterState] = useState(() => state || observable({ filterValue: '' })); - const filter = useCallback((value: string) => { - filterState.filterValue = value; - if (onFilter) { - onFilter(value); + const filter = useCallback((value: string | number, name?: string) => { + value = String(value).trim(); + + if (state && name) { + state[name] = value; } - }, [onFilter, filterState]); + + if (onFilter) { + onFilter(value, name); + } + }, [onFilter, state]); const toggle = useCallback(() => { - setToggled(prev => { - if (prev) { - filter(''); - } - if (onToggle) { - onToggle(!prev); - } - return !prev; - }); - }, [onToggle, filter]); + if (!toggleMode) { + return; + } + + if (toggled) { + filter(''); + } + + setToggled(!toggled); + + if (onToggle) { + onToggle(!toggled); + } + }, [toggleMode, toggled, onToggle, filter]); const onKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 'Enter' || event.keyCode === 13) { @@ -131,7 +148,11 @@ export const Filter: React.FC = observer(function Filter({ } }, [toggled, toggleMode, inputRef]); - useEffect(() => () => { filter(''); }, []); + let value: any = valueControlled; + + if (state && name !== undefined && name in state) { + value = state[name]; + } return styled(useStyles(styles, toggleMode && toggleModeButtonStyle))( = observer(function Filter({ style={innerInputStyle} placeholder={placeholder} disabled={disabled} - value={filterState.filterValue} + name={name} + value={value} onKeyDown={onKeyDown} - onChange={value => filter(String(value).trim())} + onChange={filter} {...use({ toggled })} /> diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx index 33c9f0ca8b..74ef5c6569 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx @@ -23,7 +23,7 @@ import { Group, IconOrImage, } from '@cloudbeaver/core-blocks'; -import { useTranslate } from '@cloudbeaver/core-localization'; +import { TLocalizationToken, useTranslate } from '@cloudbeaver/core-localization'; import { useStyles } from '@cloudbeaver/core-theming'; import { isCloudConnection } from '../../Administration/ConnectionsResource'; @@ -57,7 +57,7 @@ const styles = css` `; interface IInfoItem { - text: string; + text: TLocalizationToken; icon: string; } @@ -81,23 +81,30 @@ export const ConnectionAccess: TabContainerPanelComponent const grantedUsers = useMemo(() => computed(() => users.resource.values .filter(user => state.grantedSubjects.includes(user.userId)) - ), [state.grantedSubjects, users.resource.values]); + ), [state.grantedSubjects, users.resource]); const grantedRoles = useMemo(() => computed(() => roles.resource.values .filter(role => state.grantedSubjects.includes(role.roleId)) - ), [state.grantedSubjects, roles.resource.values]); + ), [state.grantedSubjects, roles.resource]); const { selected } = useTab(tabId, load); const loading = users.isLoading() || roles.isLoading() || state.loading; const cloud = formState.info ? isCloudConnection(formState.info) : false; const disabled = loading || !state.loaded || formState.disabled || cloud; - const infoItem: IInfoItem = { - text: translate('connections_connection_access_save_reminder'), - icon: '/icons/info_icon.svg', - }; + let infoItem: IInfoItem | null = null; + + if (unsaved) { + infoItem = { + text: 'connections_connection_access_save_reminder', + icon: '/icons/info_icon.svg', + }; + } if (cloud) { - infoItem.text = translate('connections_connection_access_cloud_placeholder'); + infoItem = { + text: 'connections_connection_access_cloud_placeholder', + icon: '/icons/info_icon.svg', + }; } if (!selected) { @@ -114,10 +121,10 @@ export const ConnectionAccess: TabContainerPanelComponent ) : ( <> - {(unsaved || cloud) && ( + {infoItem && ( - {infoItem.text} + {translate(infoItem.text)} )} = observer(function Co const translate = useTranslate(); const [selectedSubjects] = useState>(() => observable(new Map())); const [filterState] = useState(() => observable({ filterValue: '' })); - const subjectsSelected = useMemo(() => computed( - () => Array.from(selectedSubjects.values()).some(Boolean) + const selectedList = useMemo(() => computed( + () => Array.from(selectedSubjects.entries()).filter(([key, value]) => value).map(([key]) => key) ), [selectedSubjects]); const revoke = useCallback(() => { - const subjectsToRevoke = []; - for (const [subject, value] of selectedSubjects) { - if (value) { - subjectsToRevoke.push(subject); - } - } - - props.onRevoke(subjectsToRevoke); + props.onRevoke(selectedList.get()); selectedSubjects.clear(); }, []); const roles = useMemo(() => computed(() => getFilteredRoles( grantedRoles, filterState.filterValue - )), [filterState.filterValue, grantedRoles]); + )), [filterState, grantedRoles]); const users = useMemo(() => computed(() => getFilteredUsers( grantedUsers, filterState.filterValue - )), [filterState.filterValue, grantedUsers]); + )), [filterState, grantedUsers]); - let tableInfoText: string = translate('connections_connection_access_admin_info'); + let tableInfoText: TLocalizationToken = 'connections_connection_access_admin_info'; if (!roles.get().length && !users.get().length) { if (filterState.filterValue) { - tableInfoText = translate('connections_connection_access_filter_no_result'); + tableInfoText = 'connections_connection_access_filter_no_result'; } else { - tableInfoText = translate('connections_connection_access_empty_table_placeholder'); + tableInfoText = 'connections_connection_access_empty_table_placeholder'; } } return styled(style)( - - + + @@ -113,7 +102,7 @@ export const ConnectionAccessGrantedList: React.FC = observer(function Co - {tableInfoText} + {translate(tableInfoText)} {roles.get().map(role => ( diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx index 14a859ea9b..ab968b738b 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx @@ -19,14 +19,13 @@ import { BASE_CONTAINERS_STYLES, Group, Button, - IFilterState, 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 } from './ConnectionAccessTableHeader/ConnectionAccessTableHeader'; +import { ConnectionAccessTableHeader, IFilterState } from './ConnectionAccessTableHeader/ConnectionAccessTableHeader'; import { ConnectionAccessTableInnerHeader } from './ConnectionAccessTableHeader/ConnectionAccessTableInnerHeader'; import { ConnectionAccessTableItem } from './ConnectionAccessTableitem'; import { getFilteredRoles, getFilteredUsers } from './getFilteredSubjects'; @@ -43,9 +42,6 @@ const styles = composes( position: relative; overflow: auto !important; } - Table { - flex: 1; - } ` ); @@ -69,35 +65,27 @@ export const ConnectionAccessList: React.FC = observer(function Connectio const translate = useTranslate(); const [selectedSubjects] = useState>(() => observable(new Map())); const [filterState] = useState(() => observable({ filterValue: '' })); - const subjectsSelected = useMemo(() => computed( - () => Array.from(selectedSubjects.values()).some(Boolean) + const selectedList = useMemo(() => computed( + () => Array.from(selectedSubjects.entries()).filter(([key, value]) => value).map(([key]) => key) ), [selectedSubjects]); const grant = useCallback(() => { - const subjectsToGrant = []; - - for (const [subject, value] of selectedSubjects) { - if (value) { - subjectsToGrant.push(subject); - } - } - - props.onGrant(subjectsToGrant); + props.onGrant(selectedList.get()); selectedSubjects.clear(); }, []); const roles = useMemo(() => computed(() => getFilteredRoles( roleList, filterState.filterValue - )), [filterState.filterValue, roleList]); + )), [filterState, roleList]); const users = useMemo(() => computed(() => getFilteredUsers( userList, filterState.filterValue - )), [filterState.filterValue, userList]); + )), [filterState, userList]); return styled(style)( - - + +
@@ -116,7 +104,7 @@ export const ConnectionAccessList: React.FC = observer(function Connectio name={role.roleName || ''} description={role.description} icon='/icons/role.svg' - iconTooltip={translate('connections_connection_access_role_tooltip')} + iconTooltip='connections_connection_access_role_tooltip' disabled={disabled || grantedSubjects.includes(role.roleId)} /> ))} diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx index 17ed70ed1f..8a962672f4 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx @@ -9,12 +9,16 @@ import { observer } from 'mobx-react-lite'; import styled, { css } from 'reshadow'; -import { Filter, IFilterState } from '@cloudbeaver/core-blocks'; +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 { - filter: IFilterState; + filterState: IFilterState; disabled: boolean; className?: string; } @@ -29,7 +33,6 @@ const styles = composes( buttons { display: flex; gap: 16px; - grid-gap: 16px; } header { overflow: hidden; @@ -41,17 +44,21 @@ const styles = composes( justify-content: space-between; padding: 16px; gap: 16px; - grid-gap: 16px; border-bottom: 1px solid; } ` ); -export const ConnectionAccessTableHeader: React.FC = observer(function ConnectionAccessTableHeader({ filter, disabled, className, children }) { +export const ConnectionAccessTableHeader: React.FC = observer(function ConnectionAccessTableHeader({ filterState, disabled, className, children }) { const translate = useTranslate(); return styled(useStyles(styles))(
- + {children} diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx index d5b4acf84b..09b6b7a2a3 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx @@ -10,13 +10,14 @@ 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?: string; + iconTooltip?: TLocalizationToken; description?: string; className?: string; } @@ -31,6 +32,7 @@ const style = css` export const ConnectionAccessTableItem: React.FC = observer(function ConnectionAccessTableItem({ id, name, description, icon, iconTooltip, disabled, className, }) { + const translate = useTranslate(); return styled(style)( = observer(function Conn - + {name} {description} diff --git a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts index 349b1be3ea..3c26bc010b 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts @@ -32,13 +32,7 @@ export function useConnectionAccessState(connection: DatabaseConnectionFragment }; const revoke = (subjectIds: string[]): void => { - const newGrantedSubjects = []; - for (const subjectId of state.grantedSubjects) { - if (!subjectIds.includes(subjectId)) { - newGrantedSubjects.push(subjectId); - } - } - state.grantedSubjects = newGrantedSubjects; + state.grantedSubjects = state.grantedSubjects.filter(subject => !subjectIds.includes(subject)); }; const grant = (subjectIds: string[]): void => { diff --git a/webapp/packages/core-connections/src/locales/en.ts b/webapp/packages/core-connections/src/locales/en.ts index c3f7725919..0055a1e907 100644 --- a/webapp/packages/core-connections/src/locales/en.ts +++ b/webapp/packages/core-connections/src/locales/en.ts @@ -32,13 +32,13 @@ export default [ ['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_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 console.'], + ['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.'], diff --git a/webapp/packages/core-connections/src/locales/ru.ts b/webapp/packages/core-connections/src/locales/ru.ts index 42b57a1a46..86b79822c6 100644 --- a/webapp/packages/core-connections/src/locales/ru.ts +++ b/webapp/packages/core-connections/src/locales/ru.ts @@ -29,14 +29,14 @@ export default [ ['connections_connection_address', 'Адрес'], ['connections_connection_name', 'Название подключения'], ['connections_connection_access_user_or_role_name', 'Имя пользователя или роли'], - ['connections_connection_access_save_reminder', 'У вас есть несохраненные изменения. Нажмите кнопку сохранить, чтобы применить изменения в настройках доступа.'], + ['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_revoke', 'Запретить'], + ['connections_connection_access_grant', 'Разрешить'], ['connections_connection_access_edit', 'Изменить'], - ['connections_connection_access_cloud_placeholder', 'Облачные подключения видны всем пользователям. Вы можете настроить доступ в AWS консоли.'], - ['connections_connection_access_empty_table_placeholder', 'Вы еще ничего не добавили. Нажмите кнопку редактировать, чтобы добавить роль или пользователя.'], + ['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', 'Администраторы видят все подключения.'], From f9fea1efee9a33a24d9c14bf1c2e7399938b49a1 Mon Sep 17 00:00:00 2001 From: Serge Rider Date: Wed, 9 Jun 2021 16:57:47 +0300 Subject: [PATCH 09/10] CB-1043 User password change API --- .../schema/service.auth.graphqls | 2 ++ .../io/cloudbeaver/service/auth/DBWServiceAuth.java | 2 ++ .../service/auth/WebServiceBindingAuth.java | 5 +++++ .../service/auth/impl/WebServiceAuthImpl.java | 12 ++++++++++++ 4 files changed, 21 insertions(+) diff --git a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls index 500393eb80..a992b3c211 100644 --- a/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls +++ b/server/bundles/io.cloudbeaver.service.auth/schema/service.auth.graphqls @@ -78,4 +78,6 @@ extend type Query { authProviders: [AuthProviderInfo!]! + authChangeLocalPassword(oldPassword: String!, newPassword: String!): Boolean! + } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java index f2131c58f4..d59f914648 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/DBWServiceAuth.java @@ -48,4 +48,6 @@ public interface DBWServiceAuth extends DBWService { @WebAction(requirePermissions = {}) WebAuthProviderInfo[] getAuthProviders(); + @WebAction() + boolean changeLocalPassword(@NotNull WebSession webSession, @NotNull String oldPassword, @NotNull String newPassword) throws DBWebException; } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java index 4a98cd5dd7..f9bfc67b81 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/WebServiceBindingAuth.java @@ -47,6 +47,11 @@ public class WebServiceBindingAuth extends WebServiceBindingBase }) .dataFetcher("activeUser", env -> getService(env).activeUser(getWebSession(env, false))) .dataFetcher("authProviders", env -> getService(env).getAuthProviders()) + .dataFetcher("authChangeLocalPassword", env -> getService(env).changeLocalPassword( + getWebSession(env), + env.getArgument("oldPassword"), + env.getArgument("newPassword") + )) ; } diff --git a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java index bf453ddca6..5ee85dee6f 100644 --- a/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java +++ b/server/bundles/io.cloudbeaver.service.auth/src/io/cloudbeaver/service/auth/impl/WebServiceAuthImpl.java @@ -248,4 +248,16 @@ public class WebServiceAuthImpl implements DBWServiceAuth { .toArray(WebAuthProviderInfo[]::new); } + @Override + public boolean changeLocalPassword(@NotNull WebSession webSession, @NotNull String oldPassword, @NotNull String newPassword) throws DBWebException { + if (webSession.getUser() == null) { + throw new DBWebException("User must be logged in"); + } + try { + return LocalAuthProvider.changeUserPassword(webSession, oldPassword, newPassword); + } catch (DBException e) { + throw new DBWebException("Error changing user password", e); + } + } + } From a5fd4fb6d07933d72fdf75387414f45ca1e99829 Mon Sep 17 00:00:00 2001 From: Serge Rider Date: Wed, 9 Jun 2021 17:47:52 +0300 Subject: [PATCH 10/10] CB-1043 User password change function --- .../provider/local/LocalAuthProvider.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java index fd2cfdc986..688f559eb9 100644 --- a/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java +++ b/server/bundles/io.cloudbeaver.server/src/io/cloudbeaver/auth/provider/local/LocalAuthProvider.java @@ -77,4 +77,30 @@ public class LocalAuthProvider implements DBWAuthProvider { return SecurityUtils.makeDigest(password); } + public static boolean changeUserPassword(@NotNull WebSession webSession, @NotNull String oldPassword, @NotNull String newPassword) throws DBException { + String userName = webSession.getUser().getUserId(); + + WebAuthProviderDescriptor authProvider = WebServiceRegistry.getInstance().getAuthProvider(PROVIDER_ID); + Map storedCredentials = CBApplication.getInstance().getSecurityController().getUserCredentials(userName, authProvider); + if (storedCredentials == null) { + throw new DBException("Invalid user name or password"); + } + String storedPasswordHash = CommonUtils.toString(storedCredentials.get(CRED_PASSWORD), null); + if (CommonUtils.isEmpty(storedPasswordHash)) { + throw new DBException("User has no password (login restricted)"); + } + + if (CommonUtils.isEmpty(oldPassword)) { + throw new DBException("No user password provided"); + } + String clientPasswordHash = WebAuthProviderPropertyEncryption.hash.encrypt(userName, oldPassword); + if (!storedPasswordHash.equals(clientPasswordHash)) { + throw new DBException("Invalid user name or password"); + } + + storedCredentials.put(CRED_PASSWORD, clientPasswordHash); + CBApplication.getInstance().getSecurityController().setUserCredentials(userName, authProvider, storedCredentials); + return true; + } + }