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; + } + } 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; + } } 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); + } + } + } 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 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..9051db9313 --- /dev/null +++ b/webapp/packages/core-blocks/src/FormControls/Filter.tsx @@ -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 = 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, + placeholder, + disabled, + className, +}) { + const [inputRef] = useFocus({}); + 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) => { + 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]); + + let value: any = valueControlled; + + if (state && name !== undefined && name in state) { + value = state[name]; + } + + return styled(useStyles(styles, toggleMode && toggleModeButtonStyle))( + + + + + ); +}); 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 237374a9a2..2fbf33197e 100644 --- a/webapp/packages/core-blocks/src/index.ts +++ b/webapp/packages/core-blocks/src/index.ts @@ -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'; 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..74ef5c6569 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 { 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 = 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 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)( - - - {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')} + + ) : ( + <> + {infoItem && ( + + + {translate(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..e88d4bfbb6 --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessGrantedList.tsx @@ -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 = 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 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)( + + + + + + + + + + + {translate(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..ab968b738b --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessList.tsx @@ -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 = 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 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)( + + + + + + + + {!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..8a962672f4 --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableHeader/ConnectionAccessTableHeader.tsx @@ -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 = 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/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..09b6b7a2a3 --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/ConnectionAccessTableItem.tsx @@ -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 = observer(function ConnectionAccessTableItem({ + id, name, description, icon, iconTooltip, disabled, className, +}) { + const translate = useTranslate(); + 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..5fd9d160bc --- /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..3c26bc010b 100644 --- a/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts +++ b/webapp/packages/core-connections/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts @@ -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; } -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 => { + 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 }); } diff --git a/webapp/packages/core-connections/src/locales/en.ts b/webapp/packages/core-connections/src/locales/en.ts index 1617093513..0055a1e907 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 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'], diff --git a/webapp/packages/core-connections/src/locales/ru.ts b/webapp/packages/core-connections/src/locales/ru.ts index e2515d3826..86b79822c6 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 } } `; 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/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/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/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; 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..6f897a3f3e 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 handleChange(value)} /> @@ -175,7 +175,6 @@ export const TextValuePresentation: TabContainerPanelComponent