Merge pull request #140 from dbeaver/feat-access-managment

feat(plugin-authentication): rework administration page
This commit is contained in:
Serge Rider
2020-08-12 17:12:03 +03:00
committed by GitHub
31 changed files with 635 additions and 587 deletions
@@ -78,10 +78,10 @@ export const TableItem = observer(function TableItem({
>
{children}
</tr>
{isExpanded && (
{isExpanded && ExpandElement && (
<tr {...use({ noHover: true })}>
<td colSpan={Children.count(children)} {...use({ expandArea: true })}>
{ExpandElement && <ExpandElement item={item} />}
<ExpandElement item={item} />
</td>
</tr>
)}
@@ -146,19 +146,19 @@ export const ConnectionEdit = observer(function ConnectionEdit({
<fill as="div" />
<Button
type="button"
disabled={controller.isSaving}
disabled={controller.isDisabled}
mod={['outlined']}
onClick={handleCancel}
>
{translate('connections_connection_edit_cancel')}
{translate('ui_processing_cancel')}
</Button>
<Button
type="button"
disabled={controller.isSaving}
disabled={controller.isDisabled}
mod={['unelevated']}
onClick={controller.onSaveConnection}
>
{translate(controller.isNew ? 'connections_connection_edit_add' : 'connections_connection_edit_save')}
{translate(controller.isNew ? 'ui_processing_create' : 'ui_processing_save')}
</Button>
</TabList>
{controller.isLoading
@@ -6,7 +6,7 @@
* you may not use this file except in compliance with the License.
*/
import { observable, action } from 'mobx';
import { observable, action, computed } from 'mobx';
import {
injectable, IInitializableController, IDestructibleController, Bootstrap
@@ -47,6 +47,10 @@ implements IInitializableController, IDestructibleController {
credentials: {},
};
@computed get isDisabled() {
return this.isLoading || this.isSaving;
}
get isNew() {
return this.connectionsResource.isNew(this.connectionId);
}
@@ -180,13 +184,12 @@ implements IInitializableController, IDestructibleController {
await this.connectionsResource.load(this.connectionId);
this.connectionInfo = this.connectionsResource.get(this.connectionId)!;
await this.loadDriver(this.connectionInfo.driverId);
} catch (exception) {
this.notificationService.logException(exception, `Can't load ConnectionInfo ${this.connectionId}`);
} finally {
this.isLoading = false;
}
await this.loadDriver(this.connectionInfo.driverId);
}
private async loadDriver(driverId: string) {
@@ -42,7 +42,7 @@ export const ConnectionForm = observer(function ConnectionForm({
checkboxLabel={translate('connections_connection_template')}
checked={controller.config.template}
onChange={value => controller.onChange('template', value)}
disabled={!controller.isNew || controller.isSaving}
disabled={!controller.isNew || controller.isDisabled}
mod='surface'
/>
</group>
@@ -65,7 +65,7 @@ export const ConnectionForm = observer(function ConnectionForm({
name="name"
value={controller.config.name}
onChange={value => controller.onChange('name', value)}
disabled={controller.isSaving}
disabled={controller.isDisabled}
mod='surface'
>
{translate('connections_connection_name')}
@@ -77,7 +77,7 @@ export const ConnectionForm = observer(function ConnectionForm({
rows={3}
value={controller.config.description}
onChange={value => controller.onChange('description', value)}
disabled={controller.isSaving}
disabled={controller.isDisabled}
mod='surface'
>
{translate('connections_connection_description')}
@@ -92,7 +92,7 @@ export const ConnectionForm = observer(function ConnectionForm({
value={'custom'}
onClick={() => controller.onChangeType(ConnectionType.Attributes)}
checked={controller.connectionType === ConnectionType.Attributes}
disabled={controller.isSaving}
disabled={controller.isDisabled}
mod={['primary']}
>
{translate('customConnection_connectionType_custom')}
@@ -103,7 +103,7 @@ export const ConnectionForm = observer(function ConnectionForm({
value={'url'}
onClick={() => controller.onChangeType(ConnectionType.URL)}
checked={controller.connectionType === ConnectionType.URL}
disabled={controller.isSaving}
disabled={controller.isDisabled}
mod={['primary']}
>
{translate('customConnection_connectionType_url')}
@@ -118,7 +118,7 @@ export const ConnectionForm = observer(function ConnectionForm({
name="url"
value={controller.config.url}
onChange={value => controller.onChange('url', value)}
disabled={controller.isSaving}
disabled={controller.isDisabled}
mod='surface'
>
{translate('customConnection_url_JDBC')}
@@ -135,7 +135,7 @@ export const ConnectionForm = observer(function ConnectionForm({
autofillToken={`section-${controller.driver?.id || ''} section-auth`}
properties={controller.authModel.properties}
credentials={controller.config.credentials}
processing={controller.isSaving}
processing={controller.isDisabled}
/>
</>
)}
@@ -20,6 +20,7 @@ export interface IFormController {
config: ConnectionConfig;
connectionType: ConnectionType;
isSaving: boolean;
isDisabled: boolean;
onChangeType(type: ConnectionType): void;
onSelectDriver(driver: DBDriver): void;
onChange<T extends keyof ConnectionConfig>(property: T, value: ConnectionConfig[T]): void;
@@ -15,25 +15,40 @@ import {
ResourceKey,
isResourceKeyList,
} from '@cloudbeaver/core-sdk';
import { uuid } from '@cloudbeaver/core-utils';
import { uuid, MetadataMap } from '@cloudbeaver/core-utils';
const NEW_CONNECTION_SYMBOL = Symbol('new-connection');
type ConnectionNew = ConnectionInfo & { [NEW_CONNECTION_SYMBOL]: boolean }
@injectable()
export class ConnectionsResource extends CachedMapResource<string, ConnectionInfo> {
constructor(
private graphQLService: GraphQLService,
) {
private metadata: MetadataMap<string, boolean>;
constructor(private graphQLService: GraphQLService) {
super(new Map());
this.metadata = new MetadataMap(() => false);
}
has(id: string) {
if (this.metadata.has(id)) {
return this.metadata.get(id);
}
return this.data.has(id);
}
isNew(id: string) {
return id.startsWith('new-');
if (!this.has(id)) {
return false;
}
return NEW_CONNECTION_SYMBOL in this.get(id)!;
}
addNew() {
const connectionInfo = {
id: `new-${uuid()}`,
name: 'New connection',
} as ConnectionInfo;
[NEW_CONNECTION_SYMBOL]: true,
} as ConnectionNew;
this.data.set(connectionInfo.id, connectionInfo);
this.markUpdated(connectionInfo.id);
@@ -52,7 +67,7 @@ export class ConnectionsResource extends CachedMapResource<string, ConnectionInf
if (id) {
this.data.delete(id);
}
this.data.set(connection.id, connection as ConnectionInfo);
this.set(connection.id, connection as ConnectionInfo);
return this.get(connection.id)!;
}
@@ -91,6 +106,10 @@ export class ConnectionsResource extends CachedMapResource<string, ConnectionInf
}
this.markUpdated(key);
// TODO: getConnections must accept connectionId, so we can update some connection or all connections,
// here we should check is it's was a full update
this.metadata.set('all', true);
return this.data;
}
@@ -1,8 +1,5 @@
export default [
['connections_administration_item', 'Connection Management'],
['connections_connection_edit_save', 'Save'],
['connections_connection_edit_add', 'Add'],
['connections_connection_edit_cancel', 'Cancel'],
['connections_connection_edit_authentication', 'Authentication'],
['connections_connection_name', 'Name'],
['connections_connection_description', 'Description'],
@@ -1,8 +1,5 @@
export default [
['connections_administration_item', 'Управление подключениями'],
['connections_connection_edit_save', 'Сохранить'],
['connections_connection_edit_add', 'Добавить'],
['connections_connection_edit_cancel', 'Отменить'],
['connections_connection_edit_authentication', 'Авторизация'],
['connections_connection_name', 'Название'],
['connections_connection_description', 'Описапние'],
@@ -6,6 +6,8 @@ export const defaultENLocale = [
['ui_processing_canceling', 'Canceling...'],
['ui_processing_retry', 'Retry'],
['ui_processing_ok', 'Ok'],
['ui_processing_create', 'Create'],
['ui_processing_save', 'Save'],
['ui_error_message', 'Error:'],
['ui_errors_details', 'Details'],
['ui_search', 'Search...'],
@@ -6,6 +6,8 @@ export const defaultRULocale = [
['ui_processing_canceling', 'Отмена...'],
['ui_processing_retry', 'Повторить'],
['ui_processing_ok', 'Принять'],
['ui_processing_create', 'Создать'],
['ui_processing_save', 'Сохранить'],
['ui_error_message', 'Ошибка:'],
['ui_errors_details', 'Информация'],
['ui_search', 'Поиск...'],
@@ -71,9 +71,9 @@ export abstract class CachedMapResource<TKey, TValue> extends CachedResource<
isLoaded(key: ResourceKey<TKey>): boolean {
if (isResourceKeyList(key)) {
return key.list.every(key => this.data.has(key));
return key.list.every(key => this.has(key));
}
return this.data.has(key);
return this.has(key);
}
isDataLoading(key: ResourceKey<TKey>): boolean {
@@ -142,6 +142,10 @@ export abstract class CachedMapResource<TKey, TValue> extends CachedResource<
await this.loadData(key);
return this.get(key) as Array<TValue>| TValue;
}
has(key: TKey): boolean {
return this.data.has(key);
}
}
export function isResourceKeyList<T>(data: any): data is ResourceKeyList<T> {
@@ -0,0 +1,3 @@
query revokeUserRole($userId: ID!, $roleId: ID!) {
revokeUserRole(userId: $userId, roleId: $roleId)
}
+15
View File
@@ -846,6 +846,13 @@ export type GrantUserRoleQueryVariables = Exact<{
export type GrantUserRoleQuery = Pick<Query, 'grantUserRole'>;
export type RevokeUserRoleQueryVariables = Exact<{
userId: Scalars['ID'];
roleId: Scalars['ID'];
}>;
export type RevokeUserRoleQuery = Pick<Query, 'revokeUserRole'>;
export type SetUserCredentialsQueryVariables = Exact<{
userId: Scalars['ID'];
providerId: Scalars['ID'];
@@ -1330,6 +1337,11 @@ export const GrantUserRoleDocument = `
grantUserRole(userId: $userId, roleId: $roleId)
}
`;
export const RevokeUserRoleDocument = `
query revokeUserRole($userId: ID!, $roleId: ID!) {
revokeUserRole(userId: $userId, roleId: $roleId)
}
`;
export const SetUserCredentialsDocument = `
query setUserCredentials($userId: ID!, $providerId: ID!, $credentials: Object!) {
setUserCredentials(userId: $userId, providerId: $providerId, credentials: $credentials)
@@ -2067,6 +2079,9 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper =
grantUserRole(variables: GrantUserRoleQueryVariables): Promise<GrantUserRoleQuery> {
return withWrapper(() => client.request<GrantUserRoleQuery>(GrantUserRoleDocument, variables));
},
revokeUserRole(variables: RevokeUserRoleQueryVariables): Promise<RevokeUserRoleQuery> {
return withWrapper(() => client.request<RevokeUserRoleQuery>(RevokeUserRoleDocument, variables));
},
setUserCredentials(variables: SetUserCredentialsQueryVariables): Promise<SetUserCredentialsQuery> {
return withWrapper(() => client.request<SetUserCredentialsQuery>(SetUserCredentialsDocument, variables));
},
@@ -39,6 +39,14 @@ export class MetadataMap<TKey, TValue> {
return this.length;
}
has(key: TKey): boolean {
return this.data.has(key);
}
set(key: TKey, value: TValue) {
this.data.set(key, value);
}
get(key: TKey): TValue {
if (this.data.has(key)) {
return this.data.get(key)!;
@@ -9,40 +9,52 @@
import { injectable } from '@cloudbeaver/core-di';
import {
GraphQLService,
CachedDataResource,
AdminRoleInfo
AdminRoleInfo,
CachedMapResource,
ResourceKey
} from '@cloudbeaver/core-sdk';
import { MetadataMap } from '@cloudbeaver/core-utils';
@injectable()
export class RolesResource extends CachedDataResource<AdminRoleInfo[], string | undefined> {
constructor(
private graphQLService: GraphQLService,
) {
super([]);
export class RolesResource extends CachedMapResource<string, AdminRoleInfo> {
private metadata: MetadataMap<string, boolean>;
constructor(private graphQLService: GraphQLService) {
super(new Map());
this.metadata = new MetadataMap(() => false);
}
isLoaded(roleId?: string) {
return roleId
? this.data.some(role => role.roleId === roleId)
: !!this.data.length;
has(id: string) {
if (this.metadata.has(id)) {
return this.metadata.get(id);
}
return this.data.has(id);
}
protected async loader(roleId?: string): Promise<AdminRoleInfo[]> {
async loadAll() {
await this.load('all');
return this.data;
}
async refreshAll() {
await this.refresh('all');
return this.data;
}
protected async loader(key: ResourceKey<string>): Promise<Map<string, AdminRoleInfo>> {
const roleId = key === 'all' ? undefined : key as string;
const { roles } = await this.graphQLService.gql.getRolesList({ roleId });
if (!roleId) {
this.markUpdated(roleId);
return roles as AdminRoleInfo[];
if (key === 'all') {
this.data.clear();
this.metadata.set('all', true);
}
const index = this.data.findIndex(role => role.roleId === roleId);
if (index !== -1) {
this.data.splice(index, 1, ...roles as AdminRoleInfo[]);
} else {
this.data.push(...roles as AdminRoleInfo[]);
for (const role of roles) {
this.set(role.roleId, role as AdminRoleInfo);
}
this.markUpdated(roleId);
this.markUpdated(key);
return this.data;
}
@@ -1,103 +0,0 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 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';
import { useCallback } from 'react';
import styled, { css } from 'reshadow';
import {
InputField, SubmittingForm, ErrorMessage, Button
} from '@cloudbeaver/core-blocks';
import { useController } from '@cloudbeaver/core-di';
import { useTranslate } from '@cloudbeaver/core-localization';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { CreateUserController } from './CreateUserController';
const styles = composes(
css`
`,
css`
SubmittingForm {
display: flex;
flex: 1;
}
create-form {
flex: 1;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
group {
box-sizing: border-box;
display: flex;
margin: 0 12px;
}
controls {
display: flex;
align-items: center;
justify-content: flex-end;
}
item-title {
composes: theme-typography--headline5 from global;
padding: 16px;
}
`
);
export const CreateUser = observer(function CreateUser() {
const controller = useController(CreateUserController);
const translate = useTranslate();
const handleChange = useCallback(
(value: string) => controller.login = value,
[]
);
return styled(useStyles(styles))(
<>
<item-title as='div'>
User creation
</item-title>
<SubmittingForm>
<create-form as='div'>
<group as="div">
<InputField
type='text'
name='login'
value={controller.login}
onChange={handleChange}
disabled={controller.isCreating}
mod='surface'
>
{translate('User name')}
</InputField>
</group>
<controls as="div">
<Button
type="button"
mod={['unelevated']}
onClick={controller.create}
disabled={controller.isCreating}
>
{translate('Create')}
</Button>
</controls>
</create-form>
</SubmittingForm>
{controller.error.responseMessage && (
<ErrorMessage
text={controller.error.responseMessage}
hasDetails={controller.error.hasDetails}
onShowDetails={controller.showDetails}
/>
)}
</>
);
});
@@ -1,57 +0,0 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 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 { AdministrationScreenService } from '@cloudbeaver/core-administration';
import { injectable } from '@cloudbeaver/core-di';
import { CommonDialogService } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { GQLErrorCatcher } from '@cloudbeaver/core-sdk';
import { UsersManagerService } from '../../UsersManagerService';
@injectable()
export class CreateUserController {
@observable isCreating = false;
@observable login = '';
readonly error = new GQLErrorCatcher();
constructor(
private notificationService: NotificationService,
private administrationScreenService: AdministrationScreenService,
private usersManagerService: UsersManagerService,
private commonDialogService: CommonDialogService,
) { }
create = async () => {
if (this.isCreating) {
return;
}
this.isCreating = true;
try {
await this.usersManagerService.create(this.login);
this.administrationScreenService.navigateToItem('users');
} catch (exception) {
if (!this.error.catch(exception)) {
this.notificationService.logException(exception, 'User create failed');
}
} finally {
this.isCreating = false;
}
}
showDetails = () => {
if (this.error.exception) {
this.commonDialogService.open(ErrorDetailsDialog, this.error.exception);
}
}
}
@@ -1,161 +0,0 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 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';
import { useCallback } from 'react';
import styled, { css } from 'reshadow';
import {
SubmittingForm, ErrorMessage, InputField, Checkbox, useFocus
} from '@cloudbeaver/core-blocks';
import { useController } from '@cloudbeaver/core-di';
import { CommonDialogWrapper, DialogComponentProps } from '@cloudbeaver/core-dialogs';
import { useTranslate } from '@cloudbeaver/core-localization';
import { composes, useStyles } from '@cloudbeaver/core-theming';
import { CreateUserDialogController } from './CreateUserDialogController';
import { CreateUserDialogFooter } from './CreateUserDialogFooter';
const styles = composes(
css`
Tab {
composes: theme-ripple theme-background-secondary theme-text-on-secondary from global;
}
ErrorMessage {
composes: theme-background-secondary from global;
}
`,
css`
CommonDialogWrapper {
min-height: 400px;
min-width: 600px;
}
SubmittingForm {
overflow: auto;
margin-top: 20px;
margin-bottom: 20px;
}
SubmittingForm, create-form {
flex: 1;
display: flex;
flex-direction: column;
}
create-form {
flex-direction: column;
padding: 18px 24px;
}
ErrorMessage {
position: sticky;
bottom: 0;
padding: 8px 24px;
}
`
);
export const CreateUserDialog = observer(function CreateUserDialog({
rejectDialog,
}: DialogComponentProps<null, null>) {
const [focusedRef] = useFocus({ focusFirstChild: true });
const controller = useController(CreateUserDialogController, rejectDialog);
const translate = useTranslate();
const handleLoginChange = useCallback(
(value: string) => controller.credentials.login = value,
[]
);
const handlePasswordChange = useCallback(
(value: string) => controller.credentials.password = value,
[]
);
const handlePasswordRepeatChange = useCallback(
(value: string) => controller.credentials.passwordRepeat = value,
[]
);
const handleRoleChange = useCallback(
(roleId: string, value: boolean) => controller.credentials.roles.set(roleId, value),
[]
);
return styled(useStyles(styles))(
<CommonDialogWrapper
title={translate('authentication_create_user_dialog_title')}
noBodyPadding
footer={(
<CreateUserDialogFooter
isFormFilled={controller.isFormFilled}
isCreating={controller.isCreating}
onCancel={rejectDialog}
onCreate={controller.create}
/>
)}
onReject={rejectDialog}
>
<SubmittingForm onSubmit={controller.create} autoComplete="disabled" ref={focusedRef as React.RefObject<HTMLFormElement>}>
<create-form as='div'>
<group as="div">
<InputField
type='text'
name='login'
value={controller.credentials.login}
onChange={handleLoginChange}
disabled={controller.isCreating}
mod='surface'
>
{translate('authentication_user_name')}
</InputField>
</group>
<group as="div">
<InputField
type='password'
name='password'
value={controller.credentials.password}
onChange={handlePasswordChange}
disabled={controller.isCreating}
mod='surface'
>
{translate('authentication_user_password')}
</InputField>
</group>
<group as="div">
<InputField
type='password'
name='password_repeat'
value={controller.credentials.passwordRepeat}
onChange={handlePasswordRepeatChange}
disabled={controller.isCreating}
mod='surface'
>
{translate('authentication_user_password_repeat')}
</InputField>
</group>
{controller.roles.map((role, i) => (
<group as="div" key={role.roleId}>
<Checkbox
value={role.roleId}
name='role'
checkboxLabel={role.roleName || role.roleId}
onChange={checked => handleRoleChange(role.roleId, checked)}
checked={controller.credentials.roles.get(role.roleId)}
disabled={controller.isCreating}
mod='surface'
>
{i === 0 && translate('authentication_user_role')}
</Checkbox>
</group>
))}
</create-form>
</SubmittingForm>
{controller.error.responseMessage && (
<ErrorMessage
text={controller.error.responseMessage}
hasDetails={controller.error.hasDetails}
onShowDetails={controller.showDetails}
/>
)}
</CommonDialogWrapper>
);
});
@@ -1,68 +0,0 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 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';
import styled, { css } from 'reshadow';
import { Button } from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
const styles = css`
controls {
display: flex;
height: 100%;
flex: 1;
align-items: center;
margin: auto;
justify-content: flex-end;
}
fill {
flex: 1;
}
`;
export type Props = {
isFormFilled: boolean;
isCreating: boolean;
onCancel(): void;
onCreate(): void;
}
export const CreateUserDialogFooter = observer(
function CreateUserDialogFooter({
isFormFilled,
isCreating,
onCancel,
onCreate,
}: Props) {
const translate = useTranslate();
return styled(styles)(
<controls as="div">
<Button
type="button"
mod={['outlined']}
onClick={onCancel}
disabled={isCreating}
>
{translate('ui_processing_cancel')}
</Button>
<fill as="div"/>
<Button
type="button"
mod={['unelevated']}
onClick={onCreate}
disabled={!isFormFilled || isCreating}
>
{translate('authentication_create_user_dialog_create')}
</Button>
</controls>
);
}
);
@@ -57,7 +57,11 @@ export const UsersAdministration = observer(function UsersAdministration() {
<IconButton name="reload" viewBox="0 0 28 28" onClick={controller.update} />
</AdministrationTools>
<content as='div'>
<UsersTable users={controller.users} selectedItems={controller.selectedItems} />
<UsersTable
users={controller.users}
selectedItems={controller.selectedItems}
expandedItems={controller.expandedItems}
/>
{controller.isLoading && <Loader overlay/>}
</content>
</>
@@ -14,34 +14,45 @@ import { NotificationService } from '@cloudbeaver/core-events';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { GQLErrorCatcher } from '@cloudbeaver/core-sdk';
import { UsersManagerService } from '../UsersManagerService';
import { CreateUserDialog } from './CreateUserDialog/CreateUserDialog';
import { UsersResource } from '../UsersResource';
@injectable()
export class UsersAdministrationController {
@observable isDeleting = false;
readonly selectedItems = observable<string, boolean>(new Map())
readonly expandedItems = observable<string, boolean>(new Map())
readonly error = new GQLErrorCatcher();
get users() {
return this.usersManagerService.users.data;
return Array.from(this.usersResource.data.values())
.sort((a, b) => {
if (this.usersResource.isNew(a.userId) === this.usersResource.isNew(b.userId)) {
return 0;
}
if (this.usersResource.isNew(a.userId)) {
return -1;
}
return 1;
});
}
get isLoading() {
return this.usersManagerService.users.isLoading();
return this.usersResource.isLoading();
}
constructor(
private notificationService: NotificationService,
private usersManagerService: UsersManagerService,
private usersResource: UsersResource,
private commonDialogService: CommonDialogService,
) { }
create = () => {
this.commonDialogService.open(CreateUserDialog, null);
const user = this.usersResource.addNew();
this.expandedItems.set(user.userId, true);
}
update = async () => {
try {
await this.usersManagerService.users.refresh(undefined);
await this.usersResource.refreshAll();
} catch (exception) {
if (!this.error.catch(exception)) {
this.notificationService.logException(exception, 'Users update failed');
@@ -75,10 +86,10 @@ export class UsersAdministrationController {
}
for (const userId of deletionList) {
await this.usersManagerService.delete(userId);
await this.usersResource.delete(userId);
}
this.selectedItems.clear();
await this.usersManagerService.users.refresh(undefined);
await this.usersResource.loadAll();
} catch (exception) {
if (!this.error.catch(exception)) {
this.notificationService.logException(exception, 'User delete failed');
@@ -10,8 +10,7 @@ import { AdministrationItemService, AdministrationScreenService } from '@cloudbe
import { injectable, Bootstrap } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { UsersManagerService } from '../UsersManagerService';
import { CreateUser } from './CreateUser/CreateUser';
import { UsersResource } from '../UsersResource';
import { UsersAdministration } from './UsersAdministration';
import { UsersDrawerItem } from './UsersDrawerItem';
@@ -21,7 +20,7 @@ export class UsersAdministrationService extends Bootstrap {
private administrationItemService: AdministrationItemService,
private administrationScreenService: AdministrationScreenService,
private notificationService: NotificationService,
private usersManagerService: UsersManagerService,
private usersResource: UsersResource,
) {
super();
}
@@ -31,10 +30,6 @@ export class UsersAdministrationService extends Bootstrap {
name: 'users',
order: 1,
sub: [
{
name: 'create',
getComponent: () => CreateUser,
},
{
name: 'edit',
getComponent: () => UsersAdministration,
@@ -52,17 +47,13 @@ export class UsersAdministrationService extends Bootstrap {
this.administrationScreenService.navigateToItem('users');
}
navToCreate() {
this.administrationScreenService.navigateToItemSub('users', 'create');
}
navToEdit(userId: string) {
this.administrationScreenService.navigateToItemSub('users', 'edit', userId);
}
private async loadUsers() {
try {
await this.usersManagerService.users.load(undefined);
await this.usersResource.loadAll();
} catch (exception) {
this.notificationService.logException(exception, 'Error occurred while loading users');
}
@@ -7,22 +7,45 @@
*/
import { observer } from 'mobx-react';
import styled from 'reshadow';
import styled, { css, use } from 'reshadow';
import {
TableItem, TableColumnValue, TableItemSelect
TableItem, TableColumnValue, TableItemSelect, TableItemExpand
} from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { AdminUserInfo } from '@cloudbeaver/core-sdk';
import { useStyles } from '@cloudbeaver/core-theming';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { UsersResource } from '../../UsersResource';
import { UserEdit } from './UserEdit';
const styles = composes(
css`
TableColumnValue {
composes: theme-border-color-color-positive from global;
}
`,
css`
[|new] {
border-left: solid 3px;
}
`
);
type Props = {
user: AdminUserInfo;
}
export const User = observer(function User({ user }: Props) {
return styled(useStyles())(
<TableItem item={user.userId}>
<TableColumnValue centerContent><TableItemSelect /></TableColumnValue>
const usersResource = useService(UsersResource);
const isNew = usersResource.isNew(user.userId);
return styled(useStyles(styles))(
<TableItem item={user.userId} expandElement={UserEdit}>
<TableColumnValue centerContent flex {...use({ new: isNew })}>
<TableItemSelect />
<TableItemExpand />
</TableColumnValue>
<TableColumnValue>{user.userId}</TableColumnValue>
<TableColumnValue>{user.grantedRoles.join(', ')}</TableColumnValue>
<TableColumnValue></TableColumnValue>
@@ -0,0 +1,266 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 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';
import { useContext, useCallback } from 'react';
import styled, { css, use } from 'reshadow';
import {
TabsState, TabList, Tab,
TabTitle, Loader, SubmittingForm, TabPanel,
ErrorMessage, Button, TableItemContext,
TableContext,
InputField,
Checkbox,
useFocus,
InputGroup
} from '@cloudbeaver/core-blocks';
import { useService, useController } from '@cloudbeaver/core-di';
import { useTranslate } from '@cloudbeaver/core-localization';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { UsersResource } from '../../UsersResource';
import { UserEditController } from './UserEditController';
const styles = composes(
css`
Tab {
composes: theme-ripple theme-background-secondary theme-text-on-secondary from global;
}
TabList {
composes: theme-border-color-background from global;
}
ErrorMessage {
composes: theme-background-secondary from global;
}
SubmittingForm {
composes: theme-background-secondary from global;
}
`,
css`
custom-connection {
display: flex;
flex-direction: column;
box-sizing: border-box;
}
CommonDialogWrapper {
max-height: 500px;
min-height: 500px;
}
SubmittingForm, BaseTabPanel {
flex: 1;
display: flex;
flex-direction: column;
}
TabList {
align-items: center;
box-sizing: border-box;
display: inline-flex;
width: 100%;
padding-left: 24px;
outline: none;
border-bottom: solid 1px;
}
Tab {
composes: theme-typography--body2 from global;
text-transform: uppercase;
font-weight: normal;
&:global([aria-selected=true]) {
font-weight: normal !important;
}
}
ErrorMessage {
position: sticky;
bottom: 0;
padding: 8px 24px;
}
fill {
flex: 1;
}
connection-form[|new] {
border-left: solid 3px;
}
SubmittingForm, Loader {
min-height: 320px;
max-height: 500px;
}
IconButton {
height: 32px;
width: 32px;
margin-right: 16px;
}
Button:not(:first-child) {
margin-right: 24px;
}
layout-grid {
flex: 1;
width: 100%;
}
`
);
type Props = {
item: string;
}
export const UserEdit = observer(function UserEdit({
item,
}: Props) {
const tableContext = useContext(TableContext);
const context = useContext(TableItemContext);
const translate = useTranslate();
const controller = useController(UserEditController, item);
const usersResource = useService(UsersResource);
const [focusedRef] = useFocus({ focusFirstChild: true });
const handleCancel = useCallback(() => {
tableContext?.setItemExpand(context?.item, false);
if (controller.isNew) {
usersResource.delete(item);
}
}, [tableContext, context]);
const handleLoginChange = useCallback(
(value: string) => controller.credentials.login = value,
[]
);
const handlePasswordChange = useCallback(
(value: string) => controller.credentials.password = value,
[]
);
const handlePasswordRepeatChange = useCallback(
(value: string) => controller.credentials.passwordRepeat = value,
[]
);
const handleRoleChange = useCallback(
(roleId: string, value: boolean) => controller.credentials.roles.set(roleId, value),
[]
);
return styled(useStyles(styles))(
<TabsState selectedId='info'>
<TabList>
<Tab tabId='info' >
<TabTitle>{translate('authentication_administration_user_info')}</TabTitle>
</Tab>
<Tab tabId='connections_access' >
<TabTitle>{translate('authentication_administration_user_connections_access')}</TabTitle>
</Tab>
<fill as="div" />
<Button
type="button"
disabled={controller.isCreating}
mod={['outlined']}
onClick={handleCancel}
>
{translate('ui_processing_cancel')}
</Button>
<Button
type="button"
disabled={controller.isCreating}
mod={['unelevated']}
onClick={controller.save}
>
{translate(controller.isNew ? 'ui_processing_create' : 'ui_processing_save')}
</Button>
</TabList>
{controller.isLoading
? <Loader />
: (
<SubmittingForm onSubmit={controller.save} ref={focusedRef as React.RefObject<HTMLFormElement>}>
<layout-grid as="div">
<layout-grid-inner as="div">
<layout-grid-cell as='div' {...use({ 'span-tablet': 12, 'span-desktop': 5 })}>
<group as="div">
<InputGroup>{translate('authentication_user_credentials')}</InputGroup>
</group>
<group as="div">
<InputField
type='text'
name='login'
value={controller.credentials.login}
onChange={handleLoginChange}
disabled={!controller.isNew || controller.isCreating}
mod='surface'
>
{translate('authentication_user_name')}
</InputField>
</group>
<group as="div">
<InputField
type='password'
name='password'
value={controller.credentials.password}
onChange={handlePasswordChange}
disabled={controller.isCreating}
mod='surface'
>
{translate('authentication_user_password')}
</InputField>
</group>
<group as="div">
<InputField
type='password'
name='password_repeat'
value={controller.credentials.passwordRepeat}
onChange={handlePasswordRepeatChange}
disabled={controller.isCreating}
mod='surface'
>
{translate('authentication_user_password_repeat')}
</InputField>
</group>
</layout-grid-cell>
<layout-grid-cell as='div' {...use({ 'span-tablet': 12, 'span-desktop': 5 })}>
<group as="div">
<InputGroup>{translate('authentication_user_role')}</InputGroup>
</group>
{controller.roles.map((role, i) => (
<group as="div" key={role.roleId}>
<Checkbox
value={role.roleId}
name='role'
checkboxLabel={role.roleName || role.roleId}
onChange={checked => handleRoleChange(role.roleId, checked)}
checked={controller.credentials.roles.get(role.roleId)}
disabled={controller.isCreating}
mod='surface'
/>
</group>
))}
</layout-grid-cell>
</layout-grid-inner>
</layout-grid>
</SubmittingForm>
)}
{controller.error.responseMessage && (
<ErrorMessage
text={controller.error.responseMessage}
hasDetails={controller.error.hasDetails}
onShowDetails={controller.showDetails}
/>
)}
</TabsState>
);
});
@@ -12,14 +12,15 @@ import { injectable, IInitializableController, IDestructibleController } from '@
import { CommonDialogService } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
import { ErrorDetailsDialog } from '@cloudbeaver/core-notifications';
import { GQLErrorCatcher } from '@cloudbeaver/core-sdk';
import { GQLErrorCatcher, AdminUserInfo } from '@cloudbeaver/core-sdk';
import { RolesManagerService } from '../../RolesManagerService';
import { UsersManagerService } from '../../UsersManagerService';
import { UsersResource } from '../../UsersResource';
@injectable()
export class CreateUserDialogController implements IInitializableController, IDestructibleController {
export class UserEditController implements IInitializableController, IDestructibleController {
@observable isCreating = false;
@observable isLoading = true;
@observable credentials = {
login: '',
password: '',
@@ -27,6 +28,10 @@ export class CreateUserDialogController implements IInitializableController, IDe
roles: new Map<string, boolean>(),
};
get isNew() {
return this.usersResource.isNew(this.userId);
}
@computed get isFormFilled() {
const rolesState = Array.from(this.credentials.roles.values())
.filter(Boolean);
@@ -38,21 +43,26 @@ export class CreateUserDialogController implements IInitializableController, IDe
}
@computed get roles() {
return this.rolesManagerService.roles.data;
return Array.from(this.rolesManagerService.roles.data.values());
}
@computed get user(): AdminUserInfo {
return this.usersResource.get(this.userId)!;
}
readonly error = new GQLErrorCatcher();
private isDistructed = false;
private close!: () => void;
private userId!: string;
constructor(
private notificationService: NotificationService,
private usersManagerService: UsersManagerService,
private commonDialogService: CommonDialogService,
private rolesManagerService: RolesManagerService,
private usersResource: UsersResource,
) { }
init(onClose: () => void) {
this.close = onClose;
init(id: string) {
this.userId = id;
this.loadRoles();
}
@@ -60,10 +70,16 @@ export class CreateUserDialogController implements IInitializableController, IDe
this.isDistructed = true;
}
create = async () => {
save = async () => {
if (this.isCreating) {
return;
}
if (!this.credentials.password && this.isNew) {
this.notificationService.logError({ title: 'authentication_user_password_not_set' });
return;
}
if (this.credentials.password !== this.credentials.passwordRepeat) {
this.notificationService.logError({ title: 'authentication_user_passwords_not_match' });
return;
@@ -72,17 +88,25 @@ export class CreateUserDialogController implements IInitializableController, IDe
this.isCreating = true;
let isUserCreated = false;
try {
const user = await this.usersManagerService.create(this.credentials.login, false);
isUserCreated = !!user;
await this.usersManagerService.updateCredentials(user.userId, { password: this.credentials.password });
if (this.isNew) {
await this.usersResource.create(this.credentials.login, this.userId);
}
isUserCreated = !!this.user;
if (this.credentials.password) {
await this.usersResource.updateCredentials(this.user.userId, { password: this.credentials.password });
}
for (const [roleId, checked] of this.credentials.roles) {
if (checked) {
await this.usersManagerService.grantRole(user.userId, roleId);
if (!this.user.grantedRoles.includes(roleId)) {
await this.usersResource.grantRole(this.user.userId, roleId);
}
} else if (!this.isNew) {
await this.usersResource.revokeRole(this.user.userId, roleId);
}
}
await this.usersManagerService.users.refresh(user.userId);
await this.usersResource.refresh(this.user.userId);
this.notificationService.logInfo({ title: 'authentication_user_user_created' });
this.close();
} catch (exception) {
if (isUserCreated) {
await this.deleteUser(this.credentials.login);
@@ -103,7 +127,7 @@ export class CreateUserDialogController implements IInitializableController, IDe
private async deleteUser(userId: string) {
try {
await this.usersManagerService.delete(userId);
await this.usersResource.delete(userId);
} catch (exception) {
if (!this.error.catch(exception) || this.isDistructed) {
this.notificationService.logException(exception, 'Error deleting partially created user');
@@ -113,9 +137,23 @@ export class CreateUserDialogController implements IInitializableController, IDe
private async loadRoles() {
try {
await this.rolesManagerService.roles.load(undefined);
await this.rolesManagerService.roles.loadAll();
await this.loadUser();
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load roles');
} finally {
this.isLoading = false;
}
}
private async loadUser() {
try {
await this.usersResource.load(this.userId);
this.credentials.login = this.user.userId;
this.credentials.roles = new Map(this.user.grantedRoles.map(roleId => ([roleId, true])));
} catch (exception) {
this.notificationService.logException(exception, 'Can\'t load user');
}
}
}
@@ -14,28 +14,26 @@ import {
} from '@cloudbeaver/core-blocks';
import { useTranslate } from '@cloudbeaver/core-localization';
import { AdminUserInfo } from '@cloudbeaver/core-sdk';
import { useStyles, composes } from '@cloudbeaver/core-theming';
import { useStyles } from '@cloudbeaver/core-theming';
import { User } from './User';
const styles = composes(
css``,
css`
TableColumnHeader {
border-top: solid 1px;
}
`
);
const styles = css`
TableColumnHeader {
border-top: solid 1px;
}
`;
type Props = {
users: AdminUserInfo[];
selectedItems: Map<string, boolean>;
expandedItems: Map<string, boolean>;
}
export const UsersTable = observer(function UsersTable({ users, selectedItems }: Props) {
export const UsersTable = observer(function UsersTable({ users, selectedItems, expandedItems }: Props) {
const translate = useTranslate();
return styled(useStyles(styles))(
<Table selectedItems={selectedItems}>
<Table selectedItems={selectedItems} expandedItems={expandedItems}>
<TableHeader>
<TableColumnHeader min/>
<TableColumnHeader>{translate('authentication_user_name')}</TableColumnHeader>
@@ -1,64 +0,0 @@
/*
* cloudbeaver - Cloud Database Manager
* Copyright (C) 2020 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 { injectable } from '@cloudbeaver/core-di';
import { GraphQLService, AdminUserInfo } from '@cloudbeaver/core-sdk';
import { AuthInfoService } from '../AuthInfoService';
import { AuthProviderService } from '../AuthProviderService';
import { UsersResource } from './UsersResource';
@injectable()
export class UsersManagerService {
constructor(
private graphQLService: GraphQLService,
private authProviderService: AuthProviderService,
private authInfoService: AuthInfoService,
readonly users: UsersResource
) {
}
async create(userId: string, update?: boolean): Promise<AdminUserInfo> {
const { user } = await this.graphQLService.gql.createUser({ userId });
if (update) {
// TODO: maybe better to do refresh
this.users.data.push(user as AdminUserInfo);
}
return user as AdminUserInfo;
}
async grantRole(userId: string, roleId: string, update?: boolean) {
await this.graphQLService.gql.grantUserRole({ userId, roleId });
if (update) {
await this.users.refresh(userId);
}
}
async delete(userId: string, update?: boolean) {
if (this.authInfoService.userInfo?.userId === userId) {
throw new Error('You can\'t delete current logged user');
}
await this.graphQLService.gql.deleteUser({ userId });
if (update) {
await this.users.refresh(userId);
}
}
async updateCredentials(userId: string, credentials: Record<string, any>) {
const provider = 'local';
const processedCredentials = await this.authProviderService.processCredentials(provider, credentials);
await this.graphQLService.gql.setUserCredentials({
providerId: provider,
userId,
credentials: processedCredentials,
});
}
}
@@ -9,41 +9,145 @@
import { injectable } from '@cloudbeaver/core-di';
import {
GraphQLService,
CachedDataResource,
AdminUserInfo
CachedMapResource,
AdminUserInfo,
ResourceKey,
isResourceKeyList
} from '@cloudbeaver/core-sdk';
import { MetadataMap, uuid } from '@cloudbeaver/core-utils';
import { AuthInfoService } from '../AuthInfoService';
import { AuthProviderService } from '../AuthProviderService';
const NEW_USER_SYMBOL = Symbol('new-user');
@injectable()
export class UsersResource extends CachedDataResource<AdminUserInfo[], string | undefined> {
export class UsersResource extends CachedMapResource<string, AdminUserInfo> {
private metadata: MetadataMap<string, boolean>;
constructor(
private graphQLService: GraphQLService,
private authProviderService: AuthProviderService,
private authInfoService: AuthInfoService,
) {
super([]);
super(new Map());
this.metadata = new MetadataMap(() => false);
}
isLoaded(userId?: string) {
return userId
? this.data.some(role => role.userId === userId)
: !!this.data.length;
isNew(id: string): boolean {
if (!this.has(id)) {
return false;
}
return NEW_USER_SYMBOL in this.get(id)!;
}
protected async loader(userId?: string): Promise<AdminUserInfo[]> {
has(id: string) {
if (this.metadata.has(id)) {
return this.metadata.get(id);
}
return this.data.has(id);
}
addNew() {
const user = {
userId: `new-${uuid()}`,
grantedRoles: [],
configurationParameters: {},
metaParameters: {},
[NEW_USER_SYMBOL]: true,
} as AdminUserInfo;
this.data.set(user.userId, user);
this.markUpdated(user.userId);
return user;
}
async create(userId: string, id?: string): Promise<AdminUserInfo> {
const { user } = await this.graphQLService.gql.createUser({ userId });
if (id) {
this.data.delete(id);
}
this.set(user.userId, user as AdminUserInfo);
return this.get(user.userId)!;
}
async grantRole(userId: string, roleId: string) {
await this.graphQLService.gql.grantUserRole({ userId, roleId });
await this.refresh(userId);
}
async revokeRole(userId: string, roleId: string) {
await this.graphQLService.gql.revokeUserRole({ userId, roleId });
await this.refresh(userId);
}
async updateCredentials(userId: string, credentials: Record<string, any>) {
const provider = 'local';
const processedCredentials = await this.authProviderService.processCredentials(provider, credentials);
await this.graphQLService.gql.setUserCredentials({
providerId: provider,
userId,
credentials: processedCredentials,
});
}
async delete(key: ResourceKey<string>) {
if (isResourceKeyList(key)) {
for (let i = 0; i < key.list.length; i++) {
if (this.isActiveUser(key.list[i])) {
throw new Error('You can\'t delete current logged user');
}
this.data.delete(key.list[i]);
if (!this.isNew(key.list[i])) {
await this.graphQLService.gql.deleteUser({ userId: key.list[i] });
}
}
} else {
if (this.isActiveUser(key)) {
throw new Error('You can\'t delete current logged user');
}
this.data.delete(key);
if (!this.isNew(key)) {
await this.graphQLService.gql.deleteUser({ userId: key });
}
}
this.markUpdated(key);
this.itemDeleteSubject.next(key);
}
async loadAll() {
await this.load('all');
return this.data;
}
async refreshAll() {
await this.refresh('all');
return this.data;
}
protected async loader(key: ResourceKey<string>): Promise<Map<string, AdminUserInfo>> {
const userId = key === 'all' ? undefined : key as string;
const { users } = await this.graphQLService.gql.getUsersList({ userId });
if (!userId) {
this.markUpdated(userId);
return users as AdminUserInfo[];
if (key === 'all') {
this.data.clear();
this.metadata.set('all', true);
}
const index = this.data.findIndex(user => user.userId === userId);
if (index !== -1) {
this.data.splice(index, 1, ...users as AdminUserInfo[]);
} else {
this.data.push(...users as AdminUserInfo[]);
for (const user of users) {
this.set(user.userId, user as AdminUserInfo);
}
this.markUpdated(userId);
this.markUpdated(key);
return this.data;
}
private isActiveUser(userId: string) {
return this.authInfoService.userInfo?.userId === userId;
}
}
@@ -2,14 +2,16 @@ export default [
['authentication_login_dialog_title', 'Authentication'],
['authentication_login', 'Login'],
['authentication_logout', 'Logout'],
['authentication_administration_user_info', 'Info'],
['authentication_administration_user_connections_access', 'Connections Access'],
['authentication_administration_item', 'Users'],
['authentication_administration_confirm_user_deletion', 'Confirm user deletion'],
['authentication_create_user_dialog_title', 'Create new user'],
['authentication_create_user_dialog_create', 'Create'],
['authentication_user_name', 'User name'],
['authentication_user_credentials', 'Credentials'],
['authentication_user_password', 'User password'],
['authentication_user_password_repeat', 'Repeat password'],
['authentication_user_role', 'User role'],
['authentication_user_password_not_set', 'Password is required'],
['authentication_user_passwords_not_match', "Passwords don't match"],
['authentication_user_user_created', 'User was created successfully'],
];
@@ -2,13 +2,16 @@ export default [
['authentication_login_dialog_title', 'Авторизация'],
['authentication_login', 'Войти'],
['authentication_logout', 'Выйти'],
['authentication_administration_user_info', 'Общее'],
['authentication_administration_user_connections_access', 'Доступ к базам'],
['authentication_administration_item', 'Пользователи'],
['authentication_create_user_dialog_title', 'Создание нового пользователя'],
['authentication_create_user_dialog_create', 'Создать'],
['authentication_administration_confirm_user_deletion', 'Подтвердите удаление'],
['authentication_user_name', 'Имя'],
['authentication_user_credentials', 'Учетные данные'],
['authentication_user_password', 'Пароль'],
['authentication_user_password_repeat', 'Повторите пароль'],
['authentication_user_role', 'Роль'],
['authentication_user_password_not_set', 'Пароль не задан'],
['authentication_user_passwords_not_match', 'Пароли не совпадают'],
['authentication_user_user_created', 'Пользователь создан успешно'],
];
@@ -11,7 +11,6 @@ import { IServiceInjector, PluginManifest } from '@cloudbeaver/core-di';
import { RolesManagerService } from './Administration/RolesManagerService';
import { RolesResource } from './Administration/RolesResource';
import { UsersAdministrationService } from './Administration/Users/UsersAdministrationService';
import { UsersManagerService } from './Administration/UsersManagerService';
import { UsersResource } from './Administration/UsersResource';
import { AuthenticationLocaleService } from './AuthenticationLocaleService';
import { AuthenticationService } from './AuthenticationService';
@@ -35,7 +34,6 @@ export const manifest: PluginManifest = {
AuthDialogService,
AuthMenuService,
UsersAdministrationService,
UsersManagerService,
RolesManagerService,
AuthenticationLocaleService,
RolesResource,