diff --git a/webapp/packages/core-authentication/src/UsersResource.ts b/webapp/packages/core-authentication/src/UsersResource.ts index 906393c39fd..775ff4b25cc 100644 --- a/webapp/packages/core-authentication/src/UsersResource.ts +++ b/webapp/packages/core-authentication/src/UsersResource.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -35,14 +35,12 @@ import { Executor } from '@cloudbeaver/core-executor'; import { AUTH_PROVIDER_LOCAL_ID } from './AUTH_PROVIDER_LOCAL_ID.js'; import { AuthInfoService } from './AuthInfoService.js'; import { AuthProviderService } from './AuthProviderService.js'; +import { compareUsersById, isNewUser, NEW_USER_SYMBOL, type AdminUserNew } from './compareUser.js'; import type { IAuthCredentials } from './IAuthCredentials.js'; -const NEW_USER_SYMBOL = Symbol('new-user'); - export type AdminUser = AdminUserInfoFragment; export type AdminUserOrigin = AdminUserInfoFragment['origins'][number]; -type AdminUserNew = AdminUser & { [NEW_USER_SYMBOL]: boolean; createdAt: number }; export type UserResourceIncludes = Omit; interface IUserResourceFilterOptions { @@ -96,7 +94,7 @@ export class UsersResource extends CachedMapResource { const orderedKeys = this.entries .filter(k => isNewUser(k[1])) - .sort((a, b) => compareUsers(a[1], b[1])) + .sort((a, b) => compareUsersById(a[1], b[1])) .map(([key]) => key); return resourceKeyList(orderedKeys); }); @@ -328,34 +326,3 @@ export class UsersResource extends CachedMapResource origin.type === AUTH_PROVIDER_LOCAL_ID); -} - -export function isNewUser(user: AdminUser | AdminUserNew): user is AdminUserNew { - return NEW_USER_SYMBOL in user && user[NEW_USER_SYMBOL] === true && 'createdAt' in user && Boolean(user.createdAt); -} - -export function compareUsers>(a: T, b: T): number { - return a.userId.localeCompare(b.userId); -} - -export function compareNewUsers(a: AdminUser, b: AdminUser): number { - const aIsNew = isNewUser(a); - const bIsNew = isNewUser(b); - - if (aIsNew && !bIsNew) { - return -1; - } - - if (!aIsNew && bIsNew) { - return 1; - } - - if (aIsNew && bIsNew) { - return b.createdAt - a.createdAt; - } - - return 0; -} diff --git a/webapp/packages/core-authentication/src/compareGrantSubjects.ts b/webapp/packages/core-authentication/src/compareGrantSubjects.ts new file mode 100644 index 00000000000..fc15c71c8c5 --- /dev/null +++ b/webapp/packages/core-authentication/src/compareGrantSubjects.ts @@ -0,0 +1,23 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 { compareUsersByLastLogin, isUser } from './compareUser.js'; +import type { TeamInfo } from './TeamsResource.js'; +import type { AdminUser } from './UsersResource.js'; + +export function compareGrantSubjectsByName(a: AdminUser | TeamInfo, b: AdminUser | TeamInfo): number { + const aName = isUser(a) ? a.userId : a.teamName; + const bName = isUser(b) ? b.userId : b.teamName; + return (aName ?? '').localeCompare(bName ?? ''); +} + +export function compareGrantSubjectsByLastLogin(a: AdminUser | TeamInfo, b: AdminUser | TeamInfo): number { + return compareUsersByLastLogin( + { lastLoginTime: isUser(a) ? a.lastLoginTime : undefined }, + { lastLoginTime: isUser(b) ? b.lastLoginTime : undefined }, + ); +} diff --git a/webapp/packages/core-authentication/src/compareUser.ts b/webapp/packages/core-authentication/src/compareUser.ts new file mode 100644 index 00000000000..310518d1d2e --- /dev/null +++ b/webapp/packages/core-authentication/src/compareUser.ts @@ -0,0 +1,54 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2026 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 { AUTH_PROVIDER_LOCAL_ID } from './AUTH_PROVIDER_LOCAL_ID.js'; +import type { AdminUser } from './UsersResource.js'; + +export const NEW_USER_SYMBOL = Symbol('new-user'); + +export type AdminUserNew = AdminUser & { [NEW_USER_SYMBOL]: boolean; createdAt: number }; + +export function isLocalUser(user: AdminUser): boolean { + return user.origins.some(origin => origin.type === AUTH_PROVIDER_LOCAL_ID); +} + +export function isNewUser(user: AdminUser | AdminUserNew): user is AdminUserNew { + return NEW_USER_SYMBOL in user && user[NEW_USER_SYMBOL] === true && 'createdAt' in user && Boolean(user.createdAt); +} + +export function isUser(user: unknown): user is AdminUser { + return !!user && typeof user === 'object' && 'userId' in user && 'grantedTeams' in user && 'enabled' in user; +} + +export function compareUsersById>(a: T, b: T): number { + return a.userId.localeCompare(b.userId); +} + +export function compareUsersByLastLogin>(a: T, b: T): number { + const aTime = a.lastLoginTime ? new Date(a.lastLoginTime).getTime() : 0; + const bTime = b.lastLoginTime ? new Date(b.lastLoginTime).getTime() : 0; + return aTime - bTime; +} + +export function compareUsersByNewness(a: T, b: T): number { + const aIsNew = isNewUser(a); + const bIsNew = isNewUser(b); + + if (aIsNew && !bIsNew) { + return -1; + } + + if (!aIsNew && bIsNew) { + return 1; + } + + if (aIsNew && bIsNew) { + return b.createdAt - a.createdAt; + } + + return 0; +} diff --git a/webapp/packages/core-authentication/src/index.ts b/webapp/packages/core-authentication/src/index.ts index fc811be9bf8..5a08d692a93 100644 --- a/webapp/packages/core-authentication/src/index.ts +++ b/webapp/packages/core-authentication/src/index.ts @@ -27,9 +27,11 @@ export * from './UserInfoResource.js'; export * from './UserMetaParametersResource.js'; export * from './UsersMetaParametersResource.js'; export * from './UsersResource.js'; +export * from './compareUser.js'; export * from './UsersOriginDetailsResource.js'; export * from './UserInfoMetaParametersResource.js'; export * from './TeamMetaParametersResource.js'; +export * from './compareGrantSubjects.js'; export * from './AUTH_SETTINGS_GROUP.js'; export * from './PasswordPolicyService.js'; export * from './TeamRolesResource.js'; diff --git a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsForm/GrantedUsers/GrantedUsersTable.tsx b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsForm/GrantedUsers/GrantedUsersTable.tsx index 8b03e0de9e4..77fa4194ee9 100644 --- a/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsForm/GrantedUsers/GrantedUsersTable.tsx +++ b/webapp/packages/plugin-authentication-administration/src/Administration/Users/Teams/TeamsForm/GrantedUsers/GrantedUsersTable.tsx @@ -14,7 +14,8 @@ import { CachedResourceOffsetPageListKey } from '@cloudbeaver/core-resource'; import { GrantManagementTable, type IGrantManagementTableColumn } from '@cloudbeaver/plugin-data-grid'; import { ServerConfigResource } from '@cloudbeaver/core-root'; import { - compareUsers, + compareUsersById, + compareUsersByLastLogin, TeamRolesResource, USER_TEAM_ROLE_SUPERVISOR, UsersResource, @@ -24,15 +25,21 @@ import { import type { TeamFormProps } from '../TeamsAdministrationFormService.js'; import type { GrantedUsersFormPart } from './GrantedUsersFormPart.js'; +import { useMemo } from 'react'; -const USER_ID_COLUMN: IGrantManagementTableColumn = { key: 'userId', label: 'administration_teams_team_granted_users_user_id' }; -const LAST_LOGIN_COLUMN: IGrantManagementTableColumn = { key: 'lastLogin', label: 'plugin_authentication_administration_user_last_login' }; -const TEAM_ROLE_COLUMN: IGrantManagementTableColumn = { - key: 'teamRole', - label: 'plugin_authentication_administration_team_user_team_role_supervisor', +const USER_ID_COLUMN: IGrantManagementTableColumn = { + key: 'userId', + label: 'administration_teams_team_granted_users_user_id', + compare: compareUsersById, }; +const LAST_LOGIN_COLUMN: IGrantManagementTableColumn = { + key: 'lastLogin', + label: 'plugin_authentication_administration_user_last_login', + compare: compareUsersByLastLogin, +}; +const TEAM_ROLE_COLUMN_KEY = 'teamRole'; -const COLUMNS: IGrantManagementTableColumn[] = [USER_ID_COLUMN, LAST_LOGIN_COLUMN]; +const COLUMNS: IGrantManagementTableColumn[] = [USER_ID_COLUMN, LAST_LOGIN_COLUMN]; export const GrantedUsersTable: TabContainerPanelComponent = observer(function GrantedUsersTable({ tabId, formState }) { const translate = useTranslate(); @@ -48,6 +55,7 @@ export const GrantedUsersTable: TabContainerPanelComponent = obse const usersLoader = useResource(GrantedUsersTable, UsersResource, CachedResourceOffsetPageListKey(0, 1000).setParent(UsersResourceFilterKey()), { active, }); + const grantedUsersIdsMap = useMemo(() => new Map(tabState.state.grantedUsers.map(user => [user.userId, user])), [tabState.state.grantedUsers]); useAutoLoad(GrantedUsersTable, tabState, active); @@ -78,10 +86,24 @@ export const GrantedUsersTable: TabContainerPanelComponent = obse return !usersLoader.resource.isActiveUser(user.userId); } + function getTeamRoleRank(user: AdminUser) { + const granted = grantedUsersIdsMap.get(user.userId); + + if (!granted) { + return 0; + } + + return granted.teamRole === USER_TEAM_ROLE_SUPERVISOR ? 2 : 1; + } + const columns = [...COLUMNS]; if (teamRolesResource.data.length > 0) { - columns.push(TEAM_ROLE_COLUMN); + columns.push({ + key: TEAM_ROLE_COLUMN_KEY, + label: 'plugin_authentication_administration_team_user_team_role_supervisor', + compare: (a, b) => getTeamRoleRank(a) - getTeamRoleRank(b), + }); } function getCell(user: AdminUser, colKey: string) { @@ -114,7 +136,7 @@ export const GrantedUsersTable: TabContainerPanelComponent = obse return {lastLoginDate}; } - if (colKey === TEAM_ROLE_COLUMN.key) { + if (colKey === TEAM_ROLE_COLUMN_KEY) { const granted = tabState.state.grantedUsers.find(grantedUser => grantedUser.userId === user.userId); if (granted) { @@ -134,7 +156,7 @@ export const GrantedUsersTable: TabContainerPanelComponent = obse return null; } - const items = (usersLoader.data.filter(user => user?.enabled) as AdminUser[]).sort(compareUsers); + const items = (usersLoader.data.filter(user => user?.enabled) as AdminUser[]).sort(compareUsersById); return ( ({ loading: false, @@ -61,8 +61,8 @@ export function useUsersTable(filters: IUserFilters) { ]), ) .filter(isDefined) - .sort(compareUsers) - .sort(compareNewUsers), + .sort(compareUsersById) + .sort(compareUsersByNewness), ); }, update() { diff --git a/webapp/packages/plugin-connections-administration/src/ConnectionFormAccess/ConnectionAccessTable.tsx b/webapp/packages/plugin-connections-administration/src/ConnectionFormAccess/ConnectionAccessTable.tsx index 4cb9f49f062..3d1710029f2 100644 --- a/webapp/packages/plugin-connections-administration/src/ConnectionFormAccess/ConnectionAccessTable.tsx +++ b/webapp/packages/plugin-connections-administration/src/ConnectionFormAccess/ConnectionAccessTable.tsx @@ -12,17 +12,33 @@ import { Alert, StaticImage, useAutoLoad, useResource, useTranslate } from '@clo import { useTab, type TabContainerPanelComponent } from '@cloudbeaver/core-ui'; import { getConnectionFormOptionsPart, type IConnectionFormProps } from '@cloudbeaver/plugin-connections'; import { CachedMapAllKey, CachedResourceOffsetPageListKey } from '@cloudbeaver/core-resource'; -import { TeamsResource, UsersResource, UsersResourceFilterKey, type AdminUser, type TeamInfo } from '@cloudbeaver/core-authentication'; +import { + compareGrantSubjectsByLastLogin, + compareGrantSubjectsByName, + TeamsResource, + UsersResource, + UsersResourceFilterKey, + type AdminUser, + type TeamInfo, +} from '@cloudbeaver/core-authentication'; import { ConnectionInfoOriginResource, ConnectionInfoResource, createConnectionParam, isCloudConnection } from '@cloudbeaver/core-connections'; import { GrantManagementTable, type IGrantManagementTableColumn } from '@cloudbeaver/plugin-data-grid'; import { getConnectionFormAccessPart } from './getConnectionFormAccessPart.js'; -const NAME_COLUMN: IGrantManagementTableColumn = { key: 'name', label: 'connections_connection_access_user_or_team_name' }; -const DESCRIPTION_COLUMN: IGrantManagementTableColumn = { key: 'description', label: 'connections_connection_description' }; -const LAST_LOGIN_COLUMN: IGrantManagementTableColumn = { key: 'lastLogin', label: 'plugin_connections_administration_user_last_login' }; - -const COLUMNS: IGrantManagementTableColumn[] = [NAME_COLUMN, DESCRIPTION_COLUMN, LAST_LOGIN_COLUMN]; +const NAME_COLUMN: IGrantManagementTableColumn = { + key: 'name', + label: 'connections_connection_access_user_or_team_name', + compare: compareGrantSubjectsByName, +}; +const DESCRIPTION_COLUMN: IGrantManagementTableColumn = { key: 'description', label: 'connections_connection_description' }; +const LAST_LOGIN_COLUMN: IGrantManagementTableColumn = { + key: 'lastLogin', + label: 'plugin_connections_administration_user_last_login', + compare: compareGrantSubjectsByLastLogin, +}; + +const COLUMNS: IGrantManagementTableColumn[] = [NAME_COLUMN, DESCRIPTION_COLUMN, LAST_LOGIN_COLUMN]; export const ConnectionAccessTable: TabContainerPanelComponent = observer(function ConnectionAccessTable({ tabId, formState }) { const translate = useTranslate(); diff --git a/webapp/packages/plugin-data-grid/src/GrantManagementTable.tsx b/webapp/packages/plugin-data-grid/src/GrantManagementTable.tsx index a238f759b1c..8b303b0337e 100644 --- a/webapp/packages/plugin-data-grid/src/GrantManagementTable.tsx +++ b/webapp/packages/plugin-data-grid/src/GrantManagementTable.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2025 DBeaver Corp and others + * Copyright (C) 2020-2026 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -21,19 +21,18 @@ import { TableSelectionContext } from './TableSelectionContext.js'; import { TableRowSelect } from './TableRowSelect.js'; import classes from './GrantManagementTable.module.css'; -export interface IGrantManagementTableColumn { +export interface IGrantManagementTableColumn { key: string; label: TLocalizationToken; + compare?: (a: T, b: T) => number; } const SELECT_COLUMN: IGrantManagementTableColumn = { key: 'gmt_select', label: '' }; -const STATUS_COLUMN: IGrantManagementTableColumn = { key: 'gmt_status', label: 'ui_granted' }; - -const DEFAULT_COLUMNS: IGrantManagementTableColumn[] = [SELECT_COLUMN, STATUS_COLUMN]; +const STATUS_COLUMN_KEY = 'gmt_status'; export interface IGrantManagementTableProps { items: T[]; - columns: IGrantManagementTableColumn[]; + columns: IGrantManagementTableColumn[]; getItemId: (item: T) => string; isGranted: (item: T) => boolean; isEdited: (item: T) => boolean; @@ -61,7 +60,7 @@ export const GrantManagementTable = observer(function GrantManagementTable({ const translate = useTranslate(); const styles = useS(classes); - const [sort, setSort] = useState<'asc' | 'desc' | null>('desc'); + const [sort, setSort] = useState<{ colIdx: number; order: 'asc' | 'desc' } | null>({ colIdx: 1, order: 'desc' }); const [filter, setFilter] = useState(''); const deferredFilter = useDeferredValue(filter); @@ -73,23 +72,28 @@ export const GrantManagementTable = observer(function GrantManagementTable({ return items; }, [isVisible, items, deferredFilter]); + const allColumns = useMemo[]>(() => { + const statusColumn: IGrantManagementTableColumn = { + key: STATUS_COLUMN_KEY, + label: 'ui_granted', + compare: (a, b) => Number(isGranted(a)) - Number(isGranted(b)), + }; + + return [SELECT_COLUMN, statusColumn, ...columns]; + }, [columns, isGranted]); + const sortedItems = useMemo(() => { if (sort) { - return visibleItems.slice().sort((a, b) => { - const aGranted = isGranted(a); - const bGranted = isGranted(b); + const column = allColumns[sort.colIdx]; - if (aGranted === bGranted) { - return 0; - } - - const granted = sort === 'asc' ? aGranted : !aGranted; - return granted ? 1 : -1; - }); + if (column?.compare) { + const compare = column.compare; + return visibleItems.slice().sort((a, b) => (sort.order === 'asc' ? compare(a, b) : compare(b, a))); + } } return visibleItems; - }, [visibleItems, sort, isGranted]); + }, [visibleItems, sort, allColumns]); const keys = useMemo(() => { const filtered = isManageable ? visibleItems.filter(isManageable) : visibleItems; @@ -114,11 +118,9 @@ export const GrantManagementTable = observer(function GrantManagementTable({ selection.clear(); } - const _columns = useMemo(() => [...DEFAULT_COLUMNS, ...columns], [columns]); - function _getCell(rowIdx: number, colIdx: number) { const row = sortedItems[rowIdx] as T; - const column = _columns[colIdx]; + const column = allColumns[colIdx]; if (!row || !column) { return null; @@ -128,7 +130,7 @@ export const GrantManagementTable = observer(function GrantManagementTable({ return ; } - if (column.key === STATUS_COLUMN.key) { + if (column.key === STATUS_COLUMN_KEY) { const granted = isGranted(row); return (
({ const cell = useCreateGridReactiveValue(_getCell, (onValueChange, rowIds, colIdx) => reaction(() => _getCell(rowIds, colIdx), onValueChange), [ sortedItems, - _columns, + allColumns, isGranted, isManageable, getCell, @@ -169,9 +171,9 @@ export const GrantManagementTable = observer(function GrantManagementTable({ ); const columnsCount = useCreateGridReactiveValue( - () => _columns.length, - onValueChange => reaction(() => _columns.length, onValueChange), - [_columns], + () => allColumns.length, + onValueChange => reaction(() => allColumns.length, onValueChange), + [allColumns], ); const rowsCount = useCreateGridReactiveValue( @@ -181,7 +183,7 @@ export const GrantManagementTable = observer(function GrantManagementTable({ ); function getHeaderText(colIdx: number) { - return translate(_columns[colIdx]?.label) ?? ''; + return translate(allColumns[colIdx]?.label) ?? ''; } function getHeaderElement(colIdx: number) { @@ -195,27 +197,27 @@ export const GrantManagementTable = observer(function GrantManagementTable({ const headerElement = useCreateGridReactiveValue( getHeaderElement, (onValueChange, colIdx) => reaction(() => getHeaderElement(colIdx), onValueChange), - [_columns, translate], + [allColumns, translate], ); const headerText = useCreateGridReactiveValue(getHeaderText, (onValueChange, colIdx) => reaction(() => getHeaderText(colIdx), onValueChange), [ - _columns, + allColumns, translate, ]); function getColumnSortable(colIdx: number) { - return colIdx === 1; + return allColumns[colIdx]?.compare !== undefined; } const columnSortable = useCreateGridReactiveValue( getColumnSortable, (onValueChange, colIdx) => reaction(() => getColumnSortable(colIdx), onValueChange), - [], + [allColumns], ); function getColumnSortingState(colIdx: number) { - if (colIdx === 1) { - return sort; + if (sort?.colIdx === colIdx) { + return sort.order; } return null; @@ -228,9 +230,7 @@ export const GrantManagementTable = observer(function GrantManagementTable({ ); function handleSort(colIdx: number, order: 'asc' | 'desc' | null) { - if (colIdx === 1) { - setSort(order); - } + setSort(order ? { colIdx, order } : null); } return (