CB-3268 feat: admin users pagination (#1821)

* CB-3268 feat: admin users pagination

* CB-3268 fix: code style

* CB-3268 feat: filter users by enabled state

* CB-3268 feat: limit users loading

* CB-3268 fix: use limit/offset pagination

* CB-3268 fix pagination api

* CB-3268 fix users query

* CB-3268 fix: users refresh

---------

Co-authored-by: Aleksandr Skoblikov <aleksandr.skoblikov@dbeaver.com>
Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com>
This commit is contained in:
Alexey
2023-07-27 15:27:14 +03:00
committed by GitHub
co-authored by Aleksandr Skoblikov Daria Marutkina
parent e33eb9fd60
commit 3cdcb07646
39 changed files with 693 additions and 232 deletions
@@ -0,0 +1,28 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2023 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.cloudbeaver;
import org.jkiss.dbeaver.model.DBPPage;
import org.jkiss.dbeaver.model.data.json.JSONUtils;
import java.util.Map;
public class WebPage extends DBPPage {
public WebPage(Map<String, Object> params) {
super(JSONUtils.getInteger(params, "offset"), JSONUtils.getInteger(params, "limit"));
}
}
@@ -3,6 +3,11 @@ scalar Object
# Date/Time
scalar DateTime
input PageInput {
limit: Int
offset: Int
}
type Query
type Mutation
@@ -18,7 +18,6 @@ package io.cloudbeaver.model.user;
import io.cloudbeaver.WebServiceUtils;
import io.cloudbeaver.auth.provisioning.SMProvisioner;
import io.cloudbeaver.model.app.WebApplication;
import io.cloudbeaver.registry.WebAuthProviderConfiguration;
import io.cloudbeaver.registry.WebAuthProviderDescriptor;
import io.cloudbeaver.server.CBApplication;
@@ -114,11 +114,16 @@ input ServerConfigInput {
sessionExpireTime: Int
}
extend type Query {
input AdminUserFilterInput {
userIdMask: String
enabledState: Boolean
}
extend type Query {
#### Users and teams
listUsers(userId: ID): [AdminUserInfo!]!
adminUserInfo(userId: ID!): AdminUserInfo!
listUsers(page: PageInput!, filter: AdminUserFilterInput!): [AdminUserInfo!]!
listTeams(teamId: ID): [AdminTeamInfo!]!
listPermissions: [AdminPermissionInfo!]!
listAuthRoles: [String!]!
@@ -0,0 +1,47 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2023 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.cloudbeaver.service.admin;
import org.jkiss.code.NotNull;
import org.jkiss.dbeaver.model.DBPPage;
import org.jkiss.dbeaver.model.data.json.JSONUtils;
import org.jkiss.dbeaver.model.security.user.SMUserFilter;
import java.util.Map;
public class AdminUserInfoFilter {
@NotNull
private final SMUserFilter filter;
public AdminUserInfoFilter(@NotNull Map<String, Object> params, @NotNull Map<String, Object> pageParams) {
DBPPage page = new DBPPage(
JSONUtils.getInteger(pageParams, "offset", 0),
JSONUtils.getInteger(pageParams, "limit", Integer.MAX_VALUE)
);
Boolean enabledState = params.containsKey("enabledState") ? JSONUtils.getBoolean(params, "enabledState") : null;
this.filter = new SMUserFilter(
JSONUtils.getString(params, "userIdMask"),
enabledState,
page
);
}
public SMUserFilter getFilter() {
return filter;
}
}
@@ -31,7 +31,6 @@ import org.jkiss.dbeaver.model.security.SMDataSourceGrant;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Web service API
@@ -40,7 +39,12 @@ public interface DBWServiceAdmin extends DBWService {
@WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN)
@NotNull
List<AdminUserInfo> listUsers(@NotNull WebSession webSession, @Nullable String userName) throws DBWebException;
AdminUserInfo getUserById(@NotNull WebSession webSession, @NotNull String userId) throws DBWebException;
@WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN)
@NotNull
List<AdminUserInfo> listUsers(@NotNull WebSession webSession, AdminUserInfoFilter filter)
throws DBWebException;
@WebAction(requirePermissions = DBWConstants.PERMISSION_ADMIN)
@NotNull
@@ -31,7 +31,8 @@ import org.jkiss.utils.CommonUtils;
/**
* Web service implementation
*/
public class WebServiceBindingAdmin extends WebServiceBindingBase<DBWServiceAdmin> implements DBWServiceBindingServlet<CBApplication> {
public class WebServiceBindingAdmin extends WebServiceBindingBase<DBWServiceAdmin>
implements DBWServiceBindingServlet<CBApplication> {
private static final String SCHEMA_FILE_NAME = "schema/service.admin.graphqls";
@@ -42,8 +43,17 @@ public class WebServiceBindingAdmin extends WebServiceBindingBase<DBWServiceAdmi
@Override
public void bindWiring(DBWBindingContext model) throws DBWebException {
model.getQueryType()
.dataFetcher("listUsers",
env -> getService(env).listUsers(getWebSession(env), env.getArgument("userId")))
.dataFetcher(
"adminUserInfo",
env -> getService(env).getUserById(getWebSession(env), env.getArgument("userId"))
)
.dataFetcher(
"listUsers",
env -> getService(env).listUsers(
getWebSession(env),
new AdminUserInfoFilter(env.getArgument("filter"), env.getArgument("page"))
)
)
.dataFetcher("listTeams",
env -> getService(env).listTeams(getWebSession(env), env.getArgument("teamId")))
.dataFetcher("listPermissions",
@@ -22,7 +22,6 @@ import io.cloudbeaver.WebProjectImpl;
import io.cloudbeaver.WebServiceUtils;
import io.cloudbeaver.auth.provider.local.LocalAuthProvider;
import io.cloudbeaver.model.WebPropertyInfo;
import io.cloudbeaver.model.app.BaseWebApplication;
import io.cloudbeaver.model.session.WebAuthInfo;
import io.cloudbeaver.model.session.WebSession;
import io.cloudbeaver.model.user.WebUser;
@@ -66,16 +65,26 @@ public class WebServiceAdmin implements DBWServiceAdmin {
.flatMap(service -> service.getPermissions().stream())
.collect(Collectors.toMap(WebPermissionDescriptor::getId, Function.identity()));
@NotNull
@Override
public AdminUserInfo getUserById(@NotNull WebSession webSession, @NotNull String userId) throws DBWebException {
try {
SMUser smUser = webSession.getAdminSecurityController().getUserById(userId);
return new AdminUserInfo(webSession, new WebUser(smUser));
} catch (Exception e) {
throw new DBWebException("Error getting user - " + userId, e);
}
}
@NotNull
@Override
public List<AdminUserInfo> listUsers(@NotNull WebSession webSession, String userName) throws DBWebException {
public List<AdminUserInfo> listUsers(@NotNull WebSession webSession, @NotNull AdminUserInfoFilter adminFilter) throws DBWebException {
try {
List<AdminUserInfo> webUsers = new ArrayList<>();
for (SMUser smUser : webSession.getAdminSecurityController().findUsers(userName)) {
webUsers.add(new AdminUserInfo(webSession, new WebUser(smUser)));
List<AdminUserInfo> users = new ArrayList<>();
for (SMUser smUser : webSession.getAdminSecurityController().findUsers(adminFilter.getFilter())) {
users.add(new AdminUserInfo(webSession, new WebUser(smUser)));
}
return webUsers;
return users;
} catch (Exception e) {
throw new DBWebException("Error reading users", e);
}
@@ -37,6 +37,7 @@ import org.jkiss.code.NotNull;
import org.jkiss.code.Nullable;
import org.jkiss.dbeaver.DBException;
import org.jkiss.dbeaver.Log;
import org.jkiss.dbeaver.model.DBPPage;
import org.jkiss.dbeaver.model.auth.*;
import org.jkiss.dbeaver.model.exec.DBCException;
import org.jkiss.dbeaver.model.impl.jdbc.JDBCUtils;
@@ -49,6 +50,7 @@ import org.jkiss.dbeaver.model.security.exception.SMAccessTokenExpiredException;
import org.jkiss.dbeaver.model.security.exception.SMException;
import org.jkiss.dbeaver.model.security.exception.SMRefreshTokenExpiredException;
import org.jkiss.dbeaver.model.security.user.*;
import org.jkiss.dbeaver.model.sql.SQLUtils;
import org.jkiss.utils.ArrayUtils;
import org.jkiss.utils.CommonUtils;
import org.jkiss.utils.SecurityUtils;
@@ -303,19 +305,45 @@ public class CBEmbeddedSecurityController implements SMAdminController, SMAuthen
return getUserById(getUserIdOrThrow());
}
@Override
public int countUsers(@NotNull SMUserFilter filter) throws DBCException {
try (Connection dbCon = database.openConnection()) {
try (PreparedStatement dbStat = dbCon.prepareStatement(database.normalizeTableNames(
"SELECT COUNT(*) FROM {table_prefix}CB_USER" + buildUsersFilter(filter)))) {
setUsersFilterValues(dbStat, filter, 1);
try (ResultSet dbResult = dbStat.executeQuery()) {
if (dbResult.next()) {
return dbResult.getInt(1);
} else {
return 0;
}
}
}
} catch (SQLException e) {
throw new DBCException("Error while counting users", e);
}
}
@NotNull
@Override
public SMUser[] findUsers(String userNameMask) throws DBCException {
return findUsers(new SMUserFilter(userNameMask, null, new DBPPage(0, Integer.MAX_VALUE)));
}
@NotNull
@Override
public SMUser[] findUsers(@NotNull SMUserFilter filter)
throws DBCException {
try (Connection dbCon = database.openConnection()) {
Map<String, SMUser> result = new LinkedHashMap<>();
// Read users
try (PreparedStatement dbStat = dbCon.prepareStatement(
database.normalizeTableNames("SELECT USER_ID,IS_ACTIVE,DEFAULT_AUTH_ROLE FROM {table_prefix}CB_USER" +
(CommonUtils.isEmpty(userNameMask) ? "\nORDER BY USER_ID" : " WHERE USER_ID=?")))
) {
if (!CommonUtils.isEmpty(userNameMask)) {
dbStat.setString(1, userNameMask);
}
database.normalizeTableNames("SELECT USER_ID,IS_ACTIVE,DEFAULT_AUTH_ROLE FROM {table_prefix}CB_USER"
+ buildUsersFilter(filter) + "\nORDER BY USER_ID LIMIT ? OFFSET ?"))) {
int parameterIndex = setUsersFilterValues(dbStat, filter, 1);
dbStat.setInt(parameterIndex++, filter.getPage().getLimit());
dbStat.setInt(parameterIndex++, filter.getPage().getOffset());
try (ResultSet dbResult = dbStat.executeQuery()) {
while (dbResult.next()) {
String userId = dbResult.getString(1);
@@ -325,14 +353,22 @@ public class CBEmbeddedSecurityController implements SMAdminController, SMAuthen
}
}
}
readSubjectsMetas(dbCon, SMSubjectType.user, userNameMask, result);
if (result.isEmpty()) {
return new SMUser[0];
}
readSubjectsMetas(dbCon, SMSubjectType.user, filter.getUserIdMask(), result);
StringBuilder teamsSql = new StringBuilder()
.append("SELECT USER_ID,TEAM_ID FROM {table_prefix}CB_USER_TEAM")
.append("\n")
.append("WHERE USER_ID IN (")
.append(SQLUtils.generateParamList(result.size()))
.append(")");
// Read teams
try (PreparedStatement dbStat = dbCon.prepareStatement(
database.normalizeTableNames("SELECT USER_ID,TEAM_ID FROM {table_prefix}CB_USER_TEAM" +
(CommonUtils.isEmpty(userNameMask) ? "" : " WHERE USER_ID=?")))
) {
if (!CommonUtils.isEmpty(userNameMask)) {
dbStat.setString(1, userNameMask);
try (PreparedStatement dbStat = dbCon.prepareStatement(database.normalizeTableNames(teamsSql.toString()))) {
int parameterIndex = 1;
for (String userId : result.keySet()) {
dbStat.setString(parameterIndex++, userId);
}
try (ResultSet dbResult = dbStat.executeQuery()) {
while (dbResult.next()) {
@@ -352,6 +388,33 @@ public class CBEmbeddedSecurityController implements SMAdminController, SMAuthen
}
}
private String buildUsersFilter(SMUserFilter filter) {
StringBuilder where = new StringBuilder();
List<String> whereParts = new ArrayList<>();
if (!CommonUtils.isEmpty(filter.getUserIdMask())) {
whereParts.add("USER_ID LIKE ?");
}
if (filter.getEnabledState() != null) {
whereParts.add("IS_ACTIVE=?");
}
if (whereParts.size() > 0) {
where.append(whereParts.stream().collect(Collectors.joining(" AND ", " WHERE ", "")));
}
return where.toString();
}
private int setUsersFilterValues(PreparedStatement dbStat, SMUserFilter filter, int parameterIndex)
throws SQLException {
if (!CommonUtils.isEmpty(filter.getUserIdMask())) {
dbStat.setString(parameterIndex++, "%" + filter.getUserIdMask() + "%");
}
if (filter.getEnabledState() != null) {
dbStat.setString(parameterIndex++, filter.getEnabledState() ? CHAR_BOOL_TRUE : CHAR_BOOL_FALSE);
}
return parameterIndex;
}
private void cleanupSubjectMeta(Connection dbCon, String subjectId) throws SQLException {
// Delete old metas
try (PreparedStatement dbStat = dbCon.prepareStatement(
@@ -379,17 +442,18 @@ public class CBEmbeddedSecurityController implements SMAdminController, SMAuthen
}
}
private void readSubjectsMetas(Connection dbCon, SMSubjectType subjectType, String nameMask, Map<String, ? extends SMSubject> result) throws SQLException {
private void readSubjectsMetas(Connection dbCon, SMSubjectType subjectType, String userIdMask,
Map<String, ? extends SMSubject> result) throws SQLException {
// Read metas
try (PreparedStatement dbStat = dbCon.prepareStatement(
database.normalizeTableNames("SELECT m.SUBJECT_ID,m.META_ID,m.META_VALUE FROM {table_prefix}CB_AUTH_SUBJECT s, " +
"{table_prefix}CB_SUBJECT_META m\n" +
"WHERE s.SUBJECT_TYPE=? AND s.SUBJECT_ID=m.SUBJECT_ID" +
(CommonUtils.isEmpty(nameMask) ? "" : " AND s.SUBJECT_ID=?")))
(CommonUtils.isEmpty(userIdMask) ? "" : " AND s.SUBJECT_ID LIKE ?")))
) {
dbStat.setString(1, subjectType.getCode());
if (!CommonUtils.isEmpty(nameMask)) {
dbStat.setString(2, nameMask);
if (!CommonUtils.isEmpty(userIdMask)) {
dbStat.setString(2, "%" + userIdMask + "%");
}
try (ResultSet dbResult = dbStat.executeQuery()) {
while (dbResult.next()) {
@@ -5,8 +5,6 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { runInAction } from 'mobx';
import { injectable } from '@cloudbeaver/core-di';
import { ServerConfigResource, SessionPermissionsResource } from '@cloudbeaver/core-root';
import {
@@ -14,13 +12,17 @@ import {
AdminUserInfo,
AdminUserInfoFragment,
CachedMapAllKey,
CachedMapPageKey,
CachedMapResource,
GetUsersListQueryVariables,
GraphQLService,
ICachedMapPageOptions,
isResourceAlias,
isResourceKeyAlias,
ResourceKey,
ResourceKeyFlat,
resourceKeyList,
resourceKeyListAlias,
resourceKeyListAliasFactory,
ResourceKeySimple,
ResourceKeyUtils,
} from '@cloudbeaver/core-sdk';
@@ -36,7 +38,20 @@ const NEW_USER_SYMBOL = Symbol('new-user');
export type AdminUser = AdminUserInfoFragment;
type AdminUserNew = AdminUser & { [NEW_USER_SYMBOL]: boolean };
type UserResourceIncludes = Omit<GetUsersListQueryVariables, 'userId'>;
type UserResourceIncludes = Omit<GetUsersListQueryVariables, 'userId' | 'page' | 'filter'>;
interface IUserResourceSearchPageOptions extends ICachedMapPageOptions {
userId?: string;
enabledState?: boolean;
}
export const UsersResourceSearchUser = resourceKeyListAliasFactory<
any,
[offset: number, limit: number, userId?: string, enabledState?: boolean],
Readonly<IUserResourceSearchPageOptions>
>('@users-resource/page', (offset: number, limit: number, userId?: string, enabledState?: boolean) => ({ offset, limit, userId, enabledState }));
export const UsersResourceNewUsers = resourceKeyListAlias('@users-resource/new-users');
interface UserCreateOptions {
userId: string;
@@ -60,13 +75,19 @@ export class UsersResource extends CachedMapResource<string, AdminUser, UserReso
super();
sessionPermissionsResource.require(this, EAdminPermission.admin).outdateResource(this);
}
this.addAlias(UsersResourceSearchUser, key => {
const pageInfo = this.getPageInfo(key as ResourceKeyFlat<string>);
isNew(id: string): boolean {
if (!this.has(id)) {
return true;
}
return NEW_USER_SYMBOL in this.get(id)!;
return resourceKeyList(pageInfo?.edges || []);
});
this.addAlias(UsersResourceNewUsers, () => {
const orderedKeys = this.entries
.filter(k => isNewUser(k[1]))
.sort((a, b) => compareUsers(a[1], b[1]))
.map(([key]) => key);
return resourceKeyList(orderedKeys);
});
}
getEmptyUser(): AdminUserInfo {
@@ -130,6 +151,12 @@ export class UsersResource extends CachedMapResource<string, AdminUser, UserReso
return this.get(user.userId)!;
}
cleanNewFlags(): void {
for (const user of this.data.values()) {
(user as AdminUserNew)[NEW_USER_SYMBOL] = false;
}
}
async grantTeam(userId: string, teamId: string, skipUpdate?: boolean): Promise<void> {
await this.graphQLService.sdk.grantUserTeam({ userId, teamId });
@@ -211,7 +238,14 @@ export class UsersResource extends CachedMapResource<string, AdminUser, UserReso
}
protected async loader(originalKey: ResourceKey<string>, includes?: string[]): Promise<Map<string, AdminUser>> {
const search = this.isAlias(originalKey, UsersResourceSearchUser);
const page = this.isAlias(originalKey, CachedMapPageKey);
const all = this.isAlias(originalKey, CachedMapAllKey);
if (all) {
throw new Error('Loading all users is prohibited');
}
const usersList: AdminUser[] = [];
await ResourceKeyUtils.forEachAsync(originalKey, async key => {
@@ -221,21 +255,41 @@ export class UsersResource extends CachedMapResource<string, AdminUser, UserReso
userId = key;
}
const { users } = await this.graphQLService.sdk.getUsersList({
userId,
...this.getDefaultIncludes(),
...this.getIncludesMap(userId, includes),
});
if (userId !== undefined) {
const { user } = await this.graphQLService.sdk.getAdminUserInfo({
userId,
...this.getDefaultIncludes(),
...this.getIncludesMap(userId, includes),
});
usersList.push(...users);
usersList.push(user);
} else {
const { users } = await this.graphQLService.sdk.getUsersList({
page: {
offset: page || search ? originalKey.options.offset : 100,
limit: page || search ? originalKey.options.limit : 0,
},
filter: {
userIdMask: search ? originalKey.options.userId : undefined,
enabledState: search ? originalKey.options.enabledState : undefined,
},
...this.getDefaultIncludes(),
...this.getIncludesMap(userId, includes),
});
usersList.push(...users);
if (page || search) {
this.setPageInfo(originalKey, {
edges: users.map(user => user.userId),
hasNextPage: users.length === originalKey.options.limit,
});
}
}
});
const key = resourceKeyList(usersList.map(user => user.userId));
if (all) {
this.replace(key, usersList);
} else {
this.set(key, usersList);
}
this.set(key, usersList);
return this.data;
}
@@ -247,6 +301,11 @@ export class UsersResource extends CachedMapResource<string, AdminUser, UserReso
};
}
protected dataSet(key: string, value: AdminUserInfoFragment): void {
const oldValue = this.data.get(key);
super.dataSet(key, { ...oldValue, ...value });
}
protected validateKey(key: string): boolean {
return typeof key === 'string';
}
@@ -255,3 +314,11 @@ export class UsersResource extends CachedMapResource<string, AdminUser, UserReso
export function isLocalUser(user: AdminUser): boolean {
return user.origins.some(origin => origin.type === AUTH_PROVIDER_LOCAL_ID);
}
export function isNewUser(user: AdminUser): boolean {
return NEW_USER_SYMBOL in user;
}
export function compareUsers(a: AdminUser, b: AdminUser): number {
return a.userId.localeCompare(b.userId);
}
@@ -0,0 +1,112 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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 { action, computed, observable } from 'mobx';
import { type IServiceConstructor, useService } from '@cloudbeaver/core-di';
import {
CachedMapPageKey,
CachedMapResource,
CachedMapResourceListGetter,
CachedMapResourceValue,
ICachedMapPageOptions,
ResourceKeyListAlias,
} from '@cloudbeaver/core-sdk';
import { isArraysEqual } from '@cloudbeaver/core-utils';
import { useObservableRef } from '../useObservableRef';
interface IOptions {
pageSize?: number;
getKey?: (offset: number, limit: number) => ResourceKeyListAlias<any, Readonly<ICachedMapPageOptions>>;
dependencies?: any[];
}
interface IPagination<TResource> {
offset: number;
key: ResourceKeyListAlias<any, Readonly<ICachedMapPageOptions>>;
data: CachedMapResourceListGetter<CachedMapResourceValue<TResource>, []>;
hasNextPage: boolean;
loaded: ResourceKeyListAlias<any, any>[];
refresh: () => void;
loadMore: () => void;
}
interface IPaginationPrivate<TResource> extends IPagination<TResource> {
pageSize: number;
resource: CachedMapResource<any, any, any, any>;
dependencies: any[];
reset(): void;
}
const DEFAULT_PAGE_SIZE = 100;
export function usePagination<TResource extends CachedMapResource<any, any, any, any>>(
ctor: IServiceConstructor<TResource>,
options?: IOptions,
): IPagination<TResource> {
const pageSize = options?.pageSize || DEFAULT_PAGE_SIZE;
const resource = useService(ctor);
const pagination = useObservableRef<IPaginationPrivate<TResource>>(
() => {
const key = options?.getKey?.(0, pageSize) || CachedMapPageKey(0, pageSize);
const dependencies = options?.dependencies || [];
return {
offset: 0,
key,
dependencies,
get hasNextPage(): boolean {
return this.resource.getPageInfo(this.key)?.hasNextPage || false;
},
get data(): CachedMapResourceListGetter<CachedMapResourceValue<TResource>, []> {
return this.loaded.map(key => this.resource.get(key)).flat();
},
loaded: [key],
loadMore() {
const pageInfo = this.resource.getPageInfo(this.key);
if (pageInfo?.hasNextPage) {
this.offset = this.key.options.offset + this.pageSize;
this.key = options?.getKey?.(this.offset, this.pageSize) || CachedMapPageKey(this.offset, this.pageSize);
this.loaded.push(this.key);
}
},
refresh() {
for (const key of this.loaded) {
this.resource.markOutdated(key);
}
this.reset();
},
reset() {
this.offset = 0;
this.key = options?.getKey?.(this.offset, this.pageSize) || CachedMapPageKey(this.offset, this.pageSize);
this.loaded = [this.key];
},
};
},
{
offset: observable.ref,
key: observable.ref,
loaded: observable.shallow,
hasNextPage: computed,
loadMore: action.bound,
reset: action.bound,
refresh: action.bound,
},
{ pageSize, resource },
);
if (!isArraysEqual(pagination.dependencies, options?.dependencies || [])) {
pagination.dependencies = options?.dependencies || [];
pagination.reset();
}
return pagination;
}
@@ -32,7 +32,7 @@ import { getComputed } from '../getComputed';
import { useObjectRef } from '../useObjectRef';
import { useObservableRef } from '../useObservableRef';
interface KeyWithIncludes<TKey, TIncludes> {
export interface ResourceKeyWithIncludes<TKey, TIncludes> {
readonly key: TKey | null;
readonly includes: TIncludes;
}
@@ -66,8 +66,8 @@ interface IMapResourceState<TResource> extends ILoadableState {
loaded: boolean;
resource: TResource;
isOutdated: () => boolean;
load: () => void;
reload: () => void;
load: () => Promise<void>;
reload: () => Promise<void>;
}
interface IMapResourceListResult<TResource, TIncludes> extends IMapResourceState<TResource> {
@@ -115,7 +115,7 @@ export function useResource<
>(
component: { name: string },
ctor: IServiceConstructor<TResource> | TResource,
keyObj: TResource extends any ? TKeyArg | null | KeyWithIncludes<TKeyArg, TIncludes> : never,
keyObj: TResource extends any ? TKeyArg | null | ResourceKeyWithIncludes<TKeyArg, TIncludes> : never,
actions?: TResource extends any ? IActions<TResource, TKeyArg, TIncludes> : never,
): TResult<TResource, TKeyArg, TIncludes>;
@@ -126,7 +126,7 @@ export function useResource<
>(
component: { name: string },
ctor: IServiceConstructor<TResource> | TResource,
keyObj: TResource extends any ? TKeyArg | null | KeyWithIncludes<TKeyArg, TIncludes> : never,
keyObj: TResource extends any ? TKeyArg | null | ResourceKeyWithIncludes<TKeyArg, TIncludes> : never,
actions?: TResource extends any ? IActions<TResource, TKeyArg, TIncludes> : never,
): IMapResourceResult<TResource, TIncludes> | IMapResourceListResult<TResource, TIncludes> | IDataResourceResult<TResource, TIncludes> {
// eslint-disable-next-line react-hooks/rules-of-hooks
@@ -462,6 +462,6 @@ export function useResource<
return result;
}
function isKeyWithIncludes<TKey, TIncludes>(obj: any): obj is KeyWithIncludes<TKey, TIncludes> {
function isKeyWithIncludes<TKey, TIncludes>(obj: any): obj is ResourceKeyWithIncludes<TKey, TIncludes> {
return obj && typeof obj === 'object' && 'includes' in obj && 'key' in obj;
}
+1
View File
@@ -179,6 +179,7 @@ export * from './useExecutor';
export * from './useFn';
export * from './useFocus';
export * from './useFormValidator';
export * from './ResourcesHooks/usePagination';
export * from './ResourcesHooks/useResource';
export * from './useMouse';
export * from './useObjectRef';
@@ -7,6 +7,7 @@ export default [
['ui_stepper_back', 'Back'],
['ui_stepper_next', 'Next'],
['ui_stepper_finish', 'Finish'],
['ui_load_more', 'Load more'],
['ui_processing_loading', 'Loading...'],
['ui_processing_cancel', 'Cancel'],
['ui_processing_canceling', 'Cancelling...'],
@@ -7,6 +7,7 @@ export default [
['ui_stepper_back', 'Indietro'],
['ui_stepper_next', 'Avanti'],
['ui_stepper_finish', 'Termina'],
['ui_load_more', 'Load more'],
['ui_processing_loading', 'Caricamento...'],
['ui_processing_cancel', 'Annulla'],
['ui_processing_canceling', 'Annullamento...'],
@@ -5,6 +5,7 @@ export default [
['ui_light_theme', 'Светлая'],
['ui_dark_theme', 'Темная'],
['ui_stepper_back', 'Назад'],
['ui_load_more', 'Загрузить ещё'],
['ui_processing_loading', 'Загрузка...'],
['ui_processing_cancel', 'Отменить'],
['ui_processing_canceling', 'Отмена...'],
@@ -7,6 +7,7 @@ export default [
['ui_stepper_back', '返回'],
['ui_stepper_next', '下一步'],
['ui_stepper_finish', '完成'],
['ui_load_more', 'Load more'],
['ui_processing_loading', '加载中...'],
['ui_processing_cancel', '取消'],
['ui_processing_canceling', '取消中...'],
@@ -12,10 +12,10 @@ import { ILoadableState, isArraysEqual, isContainsException } from '@cloudbeaver
import { CachedResource, CachedResourceKey, ICachedResourceMetadata } from './CachedResource';
import type { CachedResourceIncludeArgs, CachedResourceValueIncludes } from './CachedResourceIncludes';
import type { ResourceKey, ResourceKeySimple } from './ResourceKey';
import type { ResourceKey, ResourceKeyFlat, ResourceKeySimple } from './ResourceKey';
import type { ResourceKeyAlias } from './ResourceKeyAlias';
import { isResourceKeyList, resourceKeyList, ResourceKeyList } from './ResourceKeyList';
import { ResourceKeyListAlias, resourceKeyListAlias } from './ResourceKeyListAlias';
import { ResourceKeyListAlias, resourceKeyListAlias, resourceKeyListAliasFactory } from './ResourceKeyListAlias';
import { ResourceKeyUtils } from './ResourceKeyUtils';
export type CachedMapResourceKey<TResource> = CachedResourceKey<TResource>;
@@ -30,8 +30,25 @@ export type CachedMapResourceLoader<TRealKey, TKey, TValue, TIncludes> = TRealKe
? Array<CachedResourceValueIncludes<TValue, TIncludes>>
: CachedResourceValueIncludes<TValue, TIncludes>;
export interface ICachedMapPageOptions {
offset: number;
limit: number;
}
export const CachedMapAllKey = resourceKeyListAlias('@cached-map-resource/all');
export const CachedMapEmptyKey = resourceKeyListAlias('@cached-map-resource/empty');
export const CachedMapPageKey = resourceKeyListAliasFactory<any, [offset: number, limit: number], Readonly<ICachedMapPageOptions>>(
'@cached-map-resource/page',
(offset: number, limit: number) => ({ offset, limit }),
);
interface IPageInfo<TKey> {
totalCount?: number;
edges?: TKey[];
hasNextPage?: boolean;
}
type ICachedMapResourceMetadata<TKey> = ICachedResourceMetadata & IPageInfo<TKey>;
/**
* CachedMapResource is a resource that stores data in a Map.
@@ -40,7 +57,7 @@ export abstract class CachedMapResource<
TKey,
TValue,
TContext extends Record<string, any> = Record<string, never>,
TMetadata extends ICachedResourceMetadata = ICachedResourceMetadata,
TMetadata extends ICachedMapResourceMetadata<TKey> = ICachedMapResourceMetadata<TKey>,
> extends CachedResource<Map<TKey, TValue>, TValue, TKey, CachedResourceIncludeArgs<TValue, TContext>, TMetadata> {
readonly onItemUpdate: ISyncExecutor<ResourceKeySimple<TKey>>;
readonly onItemDelete: ISyncExecutor<ResourceKeySimple<TKey>>;
@@ -63,6 +80,11 @@ export abstract class CachedMapResource<
this.onItemDelete = new SyncExecutor<ResourceKeySimple<TKey>>(null);
this.addAlias(CachedMapAllKey, () => resourceKeyList(this.keys));
this.addAlias(CachedMapPageKey, key => {
const pageInfo = this.getPageInfo(key as ResourceKeyFlat<TKey>);
return resourceKeyList(pageInfo?.edges || []);
});
makeObservable<this, 'dataSet' | 'dataDelete'>(this, {
set: action,
@@ -121,6 +143,14 @@ export abstract class CachedMapResource<
return ResourceKeyUtils.map(key, key => this.dataGet(this.getKeyRef(key)));
}
getPageInfo(key: ResourceKeyFlat<TKey>): IPageInfo<TKey> | undefined {
if (!this.hasMetadata(key)) {
return undefined;
}
return this.getMetadata(key);
}
set(key: TKey | ResourceKeyAlias<TKey, any>, value: TValue): void;
set(key: ResourceKeyList<TKey> | ResourceKeyListAlias<TKey, any>, value: TValue[]): void;
set(key: ResourceKey<TKey>, value: TValue | TValue[]): void;
@@ -225,6 +255,14 @@ export abstract class CachedMapResource<
return super.getKeyRef(key);
}
protected setPageInfo(key: ResourceKeyFlat<TKey>, pageInfo: IPageInfo<TKey>): void {
const metadata = this.getMetadata(key);
metadata.totalCount = pageInfo.totalCount;
metadata.edges = pageInfo.edges;
metadata.hasNextPage = pageInfo.hasNextPage;
}
/**
* Use it instead of this.data.has
* This method can be override
@@ -100,6 +100,7 @@ export abstract class CachedResource<
/** Need to infer value type */
private readonly typescriptHack: TValue;
private captureAliasGetterExecution: boolean;
constructor(defaultKey: ResourceKey<TKey>, private readonly defaultValue: () => TData, defaultIncludes: TInclude = [] as any) {
super();
@@ -114,6 +115,7 @@ export abstract class CachedResource<
this.defaultIncludes = defaultIncludes;
this.paramAliases = [];
this.outdateWaitList = [];
this.captureAliasGetterExecution = false;
this.metadata = new MetadataMap((key, metadata) => observable(this.getDefaultMetadata(key, metadata) as TMetadata, undefined, { deep: false }));
this.scheduler = new TaskScheduler(this.isIntersect);
this.data = defaultValue();
@@ -824,7 +826,7 @@ export abstract class CachedResource<
if (isResourceAlias(param)) {
for (const alias of this.paramAliases) {
if (alias.id === param.id) {
param = alias.getAlias(param);
param = this.captureGetAlias(alias, param);
deep++;
break;
}
@@ -848,6 +850,10 @@ export abstract class CachedResource<
protected transformToAlias(
key: ResourceKeyAlias<TKey, any> | ResourceKeyListAlias<TKey, any>,
): ResourceKeyAlias<TKey, any> | ResourceKeyListAlias<TKey, any> {
if (this.captureAliasGetterExecution) {
return key;
}
let deep = 0;
// eslint-disable-next-line no-labels
transform: if (deep < 10) {
@@ -862,7 +868,7 @@ export abstract class CachedResource<
for (const alias of this.paramAliases) {
if (alias.id === key.id) {
const data = alias.getAlias(key);
const data = this.captureGetAlias(alias, key);
if (isResourceAlias(data)) {
key = data;
@@ -880,6 +886,15 @@ export abstract class CachedResource<
return key;
}
protected captureGetAlias(alias: IParamAlias<TKey>, param: ResourceAlias<TKey, any>): ResourceKey<TKey> {
try {
this.captureAliasGetterExecution = true;
return alias.getAlias(param);
} finally {
this.captureAliasGetterExecution = false;
}
}
/**
* Check if key is valid. Can be overridden to provide custom validation.
* When key is alias checks that alias is registered.
@@ -8,7 +8,8 @@
import { isObjectsEqual } from '@cloudbeaver/core-utils';
export type ResourceAliasOptionsKey = string | number;
export type ResourceAliasOptionsValue = ResourceAliasOptionsKey | Array<ResourceAliasOptionsKey>;
export type ResourceAliasOptionsValueTypes = string | number | boolean | null | undefined;
export type ResourceAliasOptionsValue = ResourceAliasOptionsValueTypes | Array<ResourceAliasOptionsValueTypes>;
export type ResourceAliasOptions = Readonly<Record<ResourceAliasOptionsKey, ResourceAliasOptionsValue>> | undefined;
export abstract class ResourceAlias<TKey, TOptions extends ResourceAliasOptions> {
@@ -0,0 +1,5 @@
query getAdminUserInfo($userId: ID!, $includeMetaParameters: Boolean!, $customIncludeOriginDetails: Boolean!) {
user: adminUserInfo(userId: $userId) {
...AdminUserInfo
}
}
@@ -1,5 +1,5 @@
query getUsersList($userId: ID, $includeMetaParameters: Boolean!, $customIncludeOriginDetails: Boolean!) {
users: listUsers(userId: $userId) {
query getUsersList($page: PageInput!, $filter: AdminUserFilterInput!, $includeMetaParameters: Boolean!, $customIncludeOriginDetails: Boolean!) {
users: listUsers(page: $page, filter: $filter) {
...AdminUserInfo
}
}
}
}
+72 -4
View File
@@ -82,6 +82,11 @@ export interface AdminTeamInfo {
teamPermissions: Array<Scalars['ID']>;
}
export interface AdminUserFilterInput {
enabledState?: InputMaybe<Scalars['Boolean']>;
userIdMask?: InputMaybe<Scalars['String']>;
}
export interface AdminUserInfo {
authRole?: Maybe<Scalars['String']>;
configurationParameters: Scalars['Object'];
@@ -933,6 +938,11 @@ export enum ObjectPropertyLength {
Tiny = 'TINY',
}
export interface PageInput {
limit?: InputMaybe<Scalars['Int']>;
offset?: InputMaybe<Scalars['Int']>;
}
export interface ProductInfo {
buildTime: Scalars['String'];
description?: Maybe<Scalars['String']>;
@@ -959,6 +969,7 @@ export interface ProjectInfo {
export interface Query {
activeUser?: Maybe<UserInfo>;
adminUserInfo: AdminUserInfo;
authChangeLocalPassword: Scalars['Boolean'];
authLogin: AuthInfo;
authLogout?: Maybe<Scalars['Boolean']>;
@@ -1043,6 +1054,10 @@ export interface Query {
userConnections: Array<ConnectionInfo>;
}
export interface QueryAdminUserInfoArgs {
userId: Scalars['ID'];
}
export interface QueryAuthChangeLocalPasswordArgs {
newPassword: Scalars['String'];
oldPassword: Scalars['String'];
@@ -1167,7 +1182,8 @@ export interface QueryListTeamsArgs {
}
export interface QueryListUsersArgs {
userId?: InputMaybe<Scalars['ID']>;
filter: AdminUserFilterInput;
page: PageInput;
}
export interface QueryMetadataGetNodeDdlArgs {
@@ -2124,6 +2140,42 @@ export type EnableUserQueryVariables = Exact<{
export type EnableUserQuery = { enableUser?: boolean };
export type GetAdminUserInfoQueryVariables = Exact<{
userId: Scalars['ID'];
includeMetaParameters: Scalars['Boolean'];
customIncludeOriginDetails: Scalars['Boolean'];
}>;
export type GetAdminUserInfoQuery = {
user: {
userId: string;
grantedTeams: Array<string>;
linkedAuthProviders: Array<string>;
metaParameters?: any;
enabled: boolean;
authRole?: string;
origins: Array<{
type: string;
subType?: string;
displayName: string;
icon?: string;
details?: Array<{
id?: string;
displayName?: string;
description?: string;
category?: string;
dataType?: string;
defaultValue?: any;
validValues?: Array<any>;
value?: any;
length: ObjectPropertyLength;
features: Array<string>;
order: number;
}>;
}>;
};
};
export type GetUserGrantedConnectionsQueryVariables = Exact<{
userId: Scalars['ID'];
}>;
@@ -2133,7 +2185,8 @@ export type GetUserGrantedConnectionsQuery = {
};
export type GetUsersListQueryVariables = Exact<{
userId?: InputMaybe<Scalars['ID']>;
page: PageInput;
filter: AdminUserFilterInput;
includeMetaParameters: Scalars['Boolean'];
customIncludeOriginDetails: Scalars['Boolean'];
}>;
@@ -5518,6 +5571,13 @@ export const EnableUserDocument = `
enableUser(userId: $userId, enabled: $enabled)
}
`;
export const GetAdminUserInfoDocument = `
query getAdminUserInfo($userId: ID!, $includeMetaParameters: Boolean!, $customIncludeOriginDetails: Boolean!) {
user: adminUserInfo(userId: $userId) {
...AdminUserInfo
}
}
${AdminUserInfoFragmentDoc}`;
export const GetUserGrantedConnectionsDocument = `
query getUserGrantedConnections($userId: ID!) {
grantedConnections: getSubjectConnectionAccess(subjectId: $userId) {
@@ -5529,8 +5589,8 @@ export const GetUserGrantedConnectionsDocument = `
}
`;
export const GetUsersListDocument = `
query getUsersList($userId: ID, $includeMetaParameters: Boolean!, $customIncludeOriginDetails: Boolean!) {
users: listUsers(userId: $userId) {
query getUsersList($page: PageInput!, $filter: AdminUserFilterInput!, $includeMetaParameters: Boolean!, $customIncludeOriginDetails: Boolean!) {
users: listUsers(page: $page, filter: $filter) {
...AdminUserInfo
}
}
@@ -6884,6 +6944,14 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper =
'query',
);
},
getAdminUserInfo(variables: GetAdminUserInfoQueryVariables, requestHeaders?: GraphQLClientRequestHeaders): Promise<GetAdminUserInfoQuery> {
return withWrapper(
wrappedRequestHeaders =>
client.request<GetAdminUserInfoQuery>(GetAdminUserInfoDocument, variables, { ...requestHeaders, ...wrappedRequestHeaders }),
'getAdminUserInfo',
'query',
);
},
getUserGrantedConnections(
variables: GetUserGrantedConnectionsQueryVariables,
requestHeaders?: GraphQLClientRequestHeaders,
@@ -7,7 +7,7 @@
*/
import { computed, makeObservable, observable } from 'mobx';
import { AdminUser, AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, UsersResource } from '@cloudbeaver/core-authentication';
import { AdminUser, AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, compareUsers, UsersResource } from '@cloudbeaver/core-authentication';
import { IInitializableController, injectable } from '@cloudbeaver/core-di';
import { CommonDialogService, ConfirmationDialogDelete, DialogueStateResult } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
@@ -23,15 +23,7 @@ export class MetaParametersController implements IInitializableController {
readonly error = new GQLErrorCatcher();
get users(): AdminUser[] {
return Array.from(this.usersResource.data.values()).sort((a, b) => {
if (this.usersResource.isNew(a.userId) === this.usersResource.isNew(b.userId)) {
return a.userId.localeCompare(b.userId);
}
if (this.usersResource.isNew(a.userId)) {
return -1;
}
return 1;
});
return Array.from(this.usersResource.data.values()).sort(compareUsers);
}
get isProvidersLoading(): boolean {
@@ -8,7 +8,7 @@
import { observer } from 'mobx-react-lite';
import styled, { css } from 'reshadow';
import { AdminUser, UsersResource } from '@cloudbeaver/core-authentication';
import { AdminUser, UsersResource, UsersResourceSearchUser } from '@cloudbeaver/core-authentication';
import {
ColoredContainer,
Container,
@@ -22,7 +22,6 @@ import {
useStyles,
useTranslate,
} from '@cloudbeaver/core-blocks';
import { CachedMapAllKey } from '@cloudbeaver/core-sdk';
import { TabContainerPanelComponent, useTab } from '@cloudbeaver/core-ui';
import type { ITeamFormProps } from '../ITeamFormProps';
@@ -53,7 +52,7 @@ export const GrantedUsers: TabContainerPanelComponent<ITeamFormProps> = observer
const state = useGrantedUsers(formState.config, formState.mode);
const { selected } = useTab(tabId);
const users = useResource(GrantedUsers, UsersResource, CachedMapAllKey, { active: selected });
const users = useResource(GrantedUsers, UsersResource, UsersResourceSearchUser(0, 1000), { active: selected });
const grantedUsers = getComputed(() =>
users.data.filter<AdminUser>((user): user is AdminUser => !!user && state.state.grantedUsers.includes(user.userId)),
@@ -5,7 +5,7 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { computed, makeObservable, observable } from 'mobx';
import { computed, makeObservable, observable, toJS } from 'mobx';
import { AdminUser, AuthRolesResource, compareTeams, isLocalUser, TeamInfo, TeamsResource, UsersResource } from '@cloudbeaver/core-authentication';
import {
@@ -359,7 +359,7 @@ export class UserFormController implements IInitializableController, IDestructib
private async loadUser() {
try {
this.credentials.metaParameters = this.user.metaParameters;
this.credentials.metaParameters = observable(toJS(this.user.metaParameters ?? {}));
this.credentials.login = this.user.userId;
this.credentials.teams = new Map(this.user.grantedTeams.map(teamId => [teamId, true]));
this.credentials.authRole = this.user.authRole;
@@ -8,7 +8,7 @@
import React from 'react';
import { AdministrationItemService } from '@cloudbeaver/core-administration';
import { AdminUser, TeamsResource } from '@cloudbeaver/core-authentication';
import { AdminUser, TeamsResource, UsersResource } from '@cloudbeaver/core-authentication';
import { PlaceholderContainer } from '@cloudbeaver/core-blocks';
import { Bootstrap, injectable } from '@cloudbeaver/core-di';
@@ -44,6 +44,7 @@ export class UsersAdministrationService extends Bootstrap {
private readonly createUserService: CreateUserService,
private readonly teamsResource: TeamsResource,
private readonly createTeamService: CreateTeamService,
private readonly usersResource: UsersResource,
) {
super();
}
@@ -63,7 +64,7 @@ export class UsersAdministrationService extends Bootstrap {
{
name: EUsersAdministrationSub.Teams,
onActivate: this.loadTeams.bind(this),
onDeActivate: (configurationWizard, outside) => {
onDeActivate: (param, configurationWizard, outside) => {
if (outside) {
this.teamsResource.cleanNewFlags();
}
@@ -79,10 +80,14 @@ export class UsersAdministrationService extends Bootstrap {
load(): void | Promise<void> {}
private async cancelCreate(param: string | null) {
private async cancelCreate(param: string | null, configurationWizard: boolean, outside: boolean) {
if (param === 'create') {
this.createUserService.close();
}
if (outside) {
this.usersResource.cleanNewFlags();
}
}
private async loadTeams(param: string | null) {
@@ -5,22 +5,17 @@
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
import { action, computed, observable } from 'mobx';
import { action, observable } from 'mobx';
import type { AdminUser } from '@cloudbeaver/core-authentication';
import { useObservableRef } from '@cloudbeaver/core-blocks';
import type { TLocalizationToken } from '@cloudbeaver/core-localization';
import { isArraysEqual } from '@cloudbeaver/core-utils';
export enum EUserStatus {
ENABLED = 'ENABLED',
DISABLED = 'DISABLED',
ALL = 'ALL',
}
type EnabledStatusType = 'true' | 'false' | 'all';
interface IStatus {
label: TLocalizationToken;
value: EUserStatus;
value: EnabledStatusType;
}
export const USER_ROLE_ALL = 'ALL';
@@ -28,38 +23,42 @@ export const USER_ROLE_ALL = 'ALL';
export const USER_STATUSES: IStatus[] = [
{
label: 'authentication_administration_users_filters_status_enabled',
value: EUserStatus.ENABLED,
value: 'true',
},
{
label: 'authentication_administration_users_filters_status_disabled',
value: EUserStatus.DISABLED,
value: 'false',
},
{
label: 'authentication_administration_users_filters_status_all',
value: EUserStatus.ALL,
value: 'all',
},
];
export interface IUserFilters {
search: string;
role: string;
status: EUserStatus;
filteredUsers: AdminUser[];
status: EnabledStatusType;
isSearching: boolean;
filterUsers: (users: AdminUser[]) => AdminUser[];
setSearch: (value: string) => void;
setRole: (role: string) => void;
setStatus: (status: EUserStatus) => void;
setStatus: (status: EnabledStatusType) => void;
}
export function useUsersTableFilters(users: AdminUser[]) {
export function useUsersTableFilters() {
const filters: IUserFilters = useObservableRef(
() => ({
search: '',
role: USER_ROLE_ALL,
status: EUserStatus.ENABLED,
get filteredUsers() {
return this.users.filter(user => {
status: 'true',
get isSearching() {
return this.search.trim() !== '';
},
filterUsers(users: AdminUser[]) {
return users.filter(user => {
const matchSearch = user.userId.toLowerCase().includes(this.search.trim().toLowerCase());
const matchStatus = this.status === EUserStatus.ALL || (this.status === EUserStatus.ENABLED ? user.enabled : !user.enabled);
const matchStatus = String(user.enabled) === this.status || this.status === 'all';
const matchRole = this.role === USER_ROLE_ALL || this.role === user.authRole;
return matchSearch && matchStatus && matchRole;
@@ -71,7 +70,7 @@ export function useUsersTableFilters(users: AdminUser[]) {
setRole(role: string) {
this.role = role;
},
setStatus(status: EUserStatus) {
setStatus(status: EnabledStatusType) {
this.status = status;
},
}),
@@ -79,13 +78,11 @@ export function useUsersTableFilters(users: AdminUser[]) {
search: observable.ref,
role: observable.ref,
status: observable.ref,
users: observable.ref,
filteredUsers: computed<AdminUser[]>({ equals: (first, second) => isArraysEqual(first, second, undefined, true) }),
setSearch: action.bound,
setRole: action.bound,
setStatus: action.bound,
},
{ users },
false,
);
return filters;
@@ -9,11 +9,11 @@ import { observer } from 'mobx-react-lite';
import { useCallback, useContext } from 'react';
import styled, { css } from 'reshadow';
import { Loader, TableContext } from '@cloudbeaver/core-blocks';
import { useController } from '@cloudbeaver/core-di';
import { UsersResource } from '@cloudbeaver/core-authentication';
import { Loader, TableContext, useResource } from '@cloudbeaver/core-blocks';
import type { AdminUserInfo } from '@cloudbeaver/core-sdk';
import { UserForm } from '../UserForm/UserForm';
import { UserEditController } from './UserEditController';
const styles = css`
box {
@@ -31,13 +31,9 @@ interface Props {
}
export const UserEdit = observer<Props>(function UserEdit({ item }) {
const controller = useController(UserEditController, item);
const user = useResource(UserEdit, UsersResource, { key: item, includes: ['includeMetaParameters'] });
const tableContext = useContext(TableContext);
const collapse = useCallback(() => tableContext?.setItemExpand(item, false), [tableContext]);
return styled(styles)(
<box>
{controller.user ? <UserForm user={controller.user} editing onCancel={collapse} /> : <Loader />}
</box>,
);
return styled(styles)(<box>{user.data ? <UserForm user={user.data as AdminUserInfo} editing onCancel={collapse} /> : <Loader />}</box>);
});
@@ -1,65 +0,0 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2023 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, makeObservable, observable } from 'mobx';
import { UsersResource } from '@cloudbeaver/core-authentication';
import { IDestructibleController, IInitializableController, injectable } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
import { AdminUserInfo, GQLErrorCatcher, ResourceKey, ResourceKeySimple, ResourceKeyUtils } from '@cloudbeaver/core-sdk';
@injectable()
export class UserEditController implements IInitializableController, IDestructibleController {
isLoading = true;
user: AdminUserInfo | null = null;
get isDisabled() {
return this.usersResource.isLoading(this.userId);
}
userId!: string;
readonly error = new GQLErrorCatcher();
constructor(private readonly notificationService: NotificationService, private readonly usersResource: UsersResource) {
makeObservable(this, {
isLoading: observable,
user: observable,
isDisabled: computed,
});
this.updateUser = this.updateUser.bind(this);
}
async init(id: string): Promise<void> {
this.userId = id;
await this.loadUser();
this.usersResource.onItemUpdate.addHandler(this.updateUser);
}
destruct(): void {
this.usersResource.onItemUpdate.removeHandler(this.updateUser);
}
private async loadUser() {
try {
// we create a copy to protect the current value from mutation
await this.usersResource.load(this.userId, ['includeMetaParameters']);
await this.updateUser(this.userId);
} catch (exception: any) {
this.notificationService.logException(exception, `Can't load user ${this.userId}`);
}
}
private async updateUser(key: ResourceKeySimple<string>) {
if (!ResourceKeyUtils.isIntersect(key, this.userId)) {
return;
}
this.user = JSON.parse(JSON.stringify(await this.usersResource.load(this.userId, ['includeMetaParameters'])));
}
}
@@ -9,8 +9,8 @@ import { observer } from 'mobx-react-lite';
import styled, { css } from 'reshadow';
import { ADMINISTRATION_TOOLS_PANEL_STYLES, IAdministrationItemSubItem } from '@cloudbeaver/core-administration';
import { AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, AuthRolesResource, UsersResource } from '@cloudbeaver/core-authentication';
import { ColoredContainer, Container, Group, Loader, ToolsAction, ToolsPanel, useResource, useStyles, useTranslate } from '@cloudbeaver/core-blocks';
import { AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, AuthRolesResource } from '@cloudbeaver/core-authentication';
import { ColoredContainer, Container, Group, ToolsAction, ToolsPanel, useResource, useStyles, useTranslate } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { CachedMapAllKey } from '@cloudbeaver/core-sdk';
@@ -21,12 +21,6 @@ import { useUsersTableFilters } from './Filters/useUsersTableFilters';
import { UsersTable } from './UsersTable';
import { useUsersTable } from './useUsersTable';
const loaderStyle = css`
ExceptionMessage {
padding: 24px;
}
`;
const styles = css`
ToolsPanel {
border-bottom: none;
@@ -45,17 +39,16 @@ export const UsersPage = observer<Props>(function UsersPage({ sub, param }) {
const createUserService = useService(CreateUserService);
const usersResource = useResource(UsersPage, UsersResource, CachedMapAllKey);
const authProvidersResource = useResource(UsersPage, AuthProvidersResource, CachedMapAllKey);
const authRolesResource = useResource(UsersPage, AuthRolesResource, undefined);
const table = useUsersTable(usersResource.resource);
const filters = useUsersTableFilters(table.users);
const filters = useUsersTableFilters();
const table = useUsersTable(filters);
const isLocalProviderAvailable = authProvidersResource.resource.has(AUTH_PROVIDER_LOCAL_ID);
const create = param === 'create';
const displayAuthRole = authRolesResource.data.length > 0;
const keys = filters.filteredUsers.map(user => user.userId);
const loading = authRolesResource.isLoading() || table.loadableState.isLoading();
return styled(style)(
<ColoredContainer vertical wrap gap parent>
@@ -105,15 +98,15 @@ export const UsersPage = observer<Props>(function UsersPage({ sub, param }) {
)}
<Group boxNoOverflow>
<Loader style={loaderStyle} state={[usersResource, authRolesResource]} overlay>
<UsersTable
keys={keys}
selectedItems={table.state.selected}
expandedItems={table.state.expanded}
users={filters.filteredUsers}
displayAuthRole={displayAuthRole}
/>
</Loader>
<UsersTable
users={table.users}
selectedItems={table.state.selected}
expandedItems={table.state.expanded}
displayAuthRole={displayAuthRole}
loading={loading}
hasMore={table.hasMore}
onLoadMore={table.loadMore}
/>
</Group>
</Container>
</ColoredContainer>,
@@ -7,21 +7,43 @@
*/
import { observer } from 'mobx-react-lite';
import { Table, TableBody, TableColumnHeader, TableHeader, useTranslate } from '@cloudbeaver/core-blocks';
import {
Button,
Loader,
Table,
TableBody,
TableColumnHeader,
TableColumnValue,
TableHeader,
TableItem,
useTranslate,
} from '@cloudbeaver/core-blocks';
import type { AdminUserInfoFragment } from '@cloudbeaver/core-sdk';
import { User } from './User';
interface Props {
keys: string[];
users: AdminUserInfoFragment[];
selectedItems: Map<string, boolean>;
expandedItems: Map<string, boolean>;
displayAuthRole: boolean;
loading?: boolean;
hasMore: boolean;
onLoadMore?: () => void;
}
export const UsersTable = observer<Props>(function UsersTable({ keys, users, selectedItems, expandedItems, displayAuthRole }) {
export const UsersTable = observer<Props>(function UsersTable({
users,
selectedItems,
expandedItems,
displayAuthRole,
loading,
hasMore,
onLoadMore,
}) {
const translate = useTranslate();
const keys = users.map(user => user.userId);
const colSpan = displayAuthRole ? 6 : 5;
return (
<Table keys={keys} selectedItems={selectedItems} expandedItems={expandedItems} size="big">
@@ -31,7 +53,9 @@ export const UsersTable = observer<Props>(function UsersTable({ keys, users, sel
<TableSelect />
</TableColumnHeader>
)} */}
<TableColumnHeader min />
<TableColumnHeader min>
<Loader loading={loading} small />
</TableColumnHeader>
<TableColumnHeader>{translate('authentication_user_name')}</TableColumnHeader>
{displayAuthRole && <TableColumnHeader>{translate('authentication_user_role')}</TableColumnHeader>}
<TableColumnHeader>{translate('authentication_user_team')}</TableColumnHeader>
@@ -47,6 +71,22 @@ export const UsersTable = observer<Props>(function UsersTable({ keys, users, sel
// selectable={isLocalProviderAvailable}
/>
))}
{(loading || users.length === 0) && (
<TableItem item="load-more">
<TableColumnValue colSpan={colSpan} centerContent flex>
{translate(loading ? 'ui_processing_loading' : 'authentication_administration_users_empty')}
</TableColumnValue>
</TableItem>
)}
{hasMore && (
<TableItem item="load-more">
<TableColumnValue colSpan={colSpan} centerContent flex>
<Button type="button" mod={['outlined']} loading={loading} loader onClick={onLoadMore}>
{translate('ui_load_more')}
</Button>
</TableColumnValue>
</TableItem>
)}
</TableBody>
</Table>
);
@@ -7,24 +7,42 @@
*/
import { action, computed, observable } from 'mobx';
import type { AdminUser, UsersResource } from '@cloudbeaver/core-authentication';
import { TableState, useObservableRef, useTranslate } from '@cloudbeaver/core-blocks';
import { AdminUser, UsersResource, UsersResourceNewUsers, UsersResourceSearchUser } from '@cloudbeaver/core-authentication';
import { TableState, useObservableRef, usePagination, useResource, useTranslate } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { CommonDialogService, ConfirmationDialogDelete, DialogueStateResult } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
import { resourceKeyList } from '@cloudbeaver/core-sdk';
import { isArraysEqual } from '@cloudbeaver/core-utils';
import { ILoadableState, isArraysEqual, isDefined } from '@cloudbeaver/core-utils';
import type { IUserFilters } from './Filters/useUsersTableFilters';
interface State {
loading: boolean;
readonly hasMore: boolean;
state: TableState;
users: AdminUser[];
loadableState: ILoadableState;
loadMore(): void;
update: () => Promise<void>;
delete: () => Promise<void>;
}
export function useUsersTable(usersResource: UsersResource) {
export function useUsersTable(filters: IUserFilters) {
const translate = useTranslate();
const usersResource = useService(UsersResource);
const pagination = usePagination(UsersResource, {
getKey(offset, limit) {
return UsersResourceSearchUser(
offset,
limit,
filters.search,
filters.status === 'true' ? true : filters.status === 'false' ? false : undefined,
);
},
dependencies: [filters.search, filters.status],
});
const usersLoader = useResource(useUsersTable, usersResource, pagination.key);
const notificationService = useService(NotificationService);
const commonDialogService = useService(CommonDialogService);
@@ -32,20 +50,16 @@ export function useUsersTable(usersResource: UsersResource) {
() => ({
loading: false,
state: new TableState(),
get hasMore() {
return pagination.hasNextPage;
},
get users() {
return this.usersResource.values.slice().sort((a, b) => {
if (this.usersResource.isNew(a.userId) === this.usersResource.isNew(b.userId)) {
return a.userId.localeCompare(b.userId);
}
if (this.usersResource.isNew(a.userId)) {
return -1;
}
return 1;
});
const users = Array.from(new Set([UsersResourceNewUsers, ...pagination.loaded].map(key => this.usersLoader.resource.get(key)).flat()));
return filters.filterUsers(users.filter(isDefined));
},
async update() {
try {
await this.usersResource.refreshAll();
pagination.refresh();
notificationService.logSuccess({ title: 'authentication_administration_tools_refresh_success' });
} catch (exception: any) {
notificationService.logException(exception, 'authentication_administration_tools_refresh_fail');
@@ -77,7 +91,7 @@ export function useUsersTable(usersResource: UsersResource) {
this.loading = true;
try {
await this.usersResource.delete(resourceKeyList(deletionList));
await this.usersLoader.resource.delete(resourceKeyList(deletionList));
this.state.unselect();
for (const id of deletionList) {
@@ -89,14 +103,18 @@ export function useUsersTable(usersResource: UsersResource) {
this.loading = false;
}
},
loadMore() {
pagination.loadMore();
},
}),
{
loading: observable.ref,
users: computed<AdminUser[]>({ equals: (first, second) => isArraysEqual(first, second, undefined, true) }),
update: action.bound,
delete: action.bound,
loadMore: action.bound,
},
{ usersResource },
{ usersLoader, loadableState: usersLoader },
);
return state;
@@ -30,6 +30,7 @@ export default [
['authentication_administration_users_filters_status_enabled', 'ENABLED'],
['authentication_administration_users_filters_status_disabled', 'DISABLED'],
['authentication_administration_users_filters_status_all', 'ALL'],
['authentication_administration_users_empty', 'There are no users'],
['authentication_administration_user_remove_credentials_error', 'Failed to remove user credentials'],
['authentication_administration_user_remove_credentials_success', 'User credentials were removed'],
@@ -26,6 +26,7 @@ export default [
['authentication_administration_users_filters_status_enabled', 'ENABLED'],
['authentication_administration_users_filters_status_disabled', 'DISABLED'],
['authentication_administration_users_filters_status_all', 'ALL'],
['authentication_administration_users_empty', 'There are no users'],
['authentication_administration_user_remove_credentials_error', 'Failed to remove user credentials'],
['authentication_administration_user_remove_credentials_success', 'User credentials were removed'],
@@ -30,6 +30,7 @@ export default [
['authentication_administration_users_filters_status_enabled', 'ВКЛЮЧЕН'],
['authentication_administration_users_filters_status_disabled', 'ВЫКЛЮЧЕН'],
['authentication_administration_users_filters_status_all', 'ВСЕ'],
['authentication_administration_users_empty', 'Нет пользователей'],
['authentication_administration_user_remove_credentials_error', 'Не удалось удалить учетные данные пользователя'],
['authentication_administration_user_remove_credentials_success', 'Учетные данные пользователя удалены'],
@@ -29,6 +29,7 @@ export default [
['authentication_administration_users_filters_status_enabled', 'ENABLED'],
['authentication_administration_users_filters_status_disabled', 'DISABLED'],
['authentication_administration_users_filters_status_all', 'ALL'],
['authentication_administration_users_empty', 'There are no users'],
['authentication_administration_user_remove_credentials_error', 'Failed to remove user credentials'],
['authentication_administration_user_remove_credentials_success', 'User credentials were removed'],
@@ -10,7 +10,7 @@ import { observer } from 'mobx-react-lite';
import { useMemo } from 'react';
import styled, { css } from 'reshadow';
import { TeamsResource, UsersResource } from '@cloudbeaver/core-authentication';
import { TeamsResource, UsersResource, UsersResourceSearchUser } from '@cloudbeaver/core-authentication';
import {
ColoredContainer,
Container,
@@ -56,7 +56,7 @@ export const ConnectionAccess: TabContainerPanelComponent<IConnectionFormProps>
useAutoLoad(state, selected);
const users = useResource(ConnectionAccess, UsersResource, CachedMapAllKey, { active: selected });
const users = useResource(ConnectionAccess, UsersResource, UsersResourceSearchUser(0, 1000), { active: selected });
const teams = useResource(ConnectionAccess, TeamsResource, CachedMapAllKey, { active: selected });
const grantedUsers = useMemo(
+2 -2
View File
@@ -8,9 +8,9 @@
"printWidth": 150,
"arrowParens": "avoid",
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrderParserPlugins" : ["typescript", "jsx", "decorators-legacy"],
"importOrderParserPlugins": ["typescript", "jsx", "decorators-legacy"],
"importOrder": ["^@cloudbeaver/(.*)$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderCaseInsensitive": true
}
}