Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/app-action-button-role-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@rocket.chat/apps-engine': minor
'@rocket.chat/meteor': minor
---

Accepts a role name in the `when.hasOneRole` and `when.hasAllRoles` filters of an app action button
67 changes: 67 additions & 0 deletions apps/meteor/client/hooks/useApplyButtonFilters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,73 @@ describe('useApplyButtonAuthFilter', () => {
});
});

describe('Custom role name resolution', () => {
Comment thread
d-gubert marked this conversation as resolved.
// A custom role gets a random id, so an app can only know its name.
const customRole = { _id: 'aBcDeF1234567890x', name: 'Support Agent' };

const buttonRequiring = (role: string): IUIActionButton => ({
appId: 'test-app',
actionId: 'test-action',
labelI18n: 'test_label',
context: UIActionButtonContext.USER_DROPDOWN_ACTION,
when: {
hasOneRole: [role],
},
});

it('should show button when the user holds the role given by name', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(),
});

expect(result.current(buttonRequiring(customRole.name))).toBe(true);
});

it('should filter button when the user does not hold the role given by name', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.withRoleDefinition(customRole)
.build(),
});

expect(result.current(buttonRequiring(customRole.name))).toBe(false);
});

it('should still show button when the role is given by id', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(),
});

expect(result.current(buttonRequiring(customRole._id))).toBe(true);
});

it('should filter button when the role name is unknown', () => {
const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot().withJohnDoe().withRoleDefinition(customRole).withRole(customRole._id).build(),
});

expect(result.current(buttonRequiring('No Such Role'))).toBe(false);
});

it('should prefer the id over a role whose name collides with it', () => {
// `decoy.name` equals `customRole._id`. The user holds only the decoy, so
// resolving by name would wrongly show the button.
const decoy = { _id: 'zZyYxXw9876543210', name: customRole._id };

const { result } = renderHook(() => useApplyButtonAuthFilter(), {
wrapper: mockAppRoot()
.withJohnDoe({ roles: ['user'] })
.withRoleDefinition(customRole)
.withRoleDefinition(decoy)
.withRole(decoy._id)
.build(),
});

expect(result.current(buttonRequiring(customRole._id))).toBe(false);
});
});

describe('Permission-based filtering', () => {
it('should filter button when user does not have required permission (hasAllPermissions)', () => {
const button: IUIActionButton = {
Expand Down
12 changes: 8 additions & 4 deletions apps/meteor/client/hooks/useApplyButtonFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
isPublicDiscussion,
isPublicTeamRoom,
} from '@rocket.chat/core-typings';
import { AuthorizationContext, useUserId } from '@rocket.chat/ui-contexts';
import { AuthorizationContext, useRoleIdResolver, useUserId } from '@rocket.chat/ui-contexts';
import { useCallback, useContext } from 'react';

import { useRoom } from '../views/room/contexts/RoomContext';
Expand Down Expand Up @@ -69,17 +69,21 @@ export const useApplyButtonAuthFilter = (): ((button: IUIActionButton, room?: IR

const { queryAllPermissions, queryAtLeastOnePermission, queryRole } = useContext(AuthorizationContext);

// An app knows the name of a custom role, not the random id the workspace gave it,
// so accept either form in the role filters.
const resolveRoleId = useRoleIdResolver();

return useCallback(
(button: IUIActionButton, room?: IRoom) => {
const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {};

const hasAllPermissionsResult = hasAllPermissions ? queryAllPermissions(hasAllPermissions)[1]() : true;
const hasOnePermissionResult = hasOnePermission ? queryAtLeastOnePermission(hasOnePermission)[1]() : true;
const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(role, room?._id)[1]()) : true;
const hasOneRoleResult = hasOneRole ? !!uid && hasOneRole.some((role) => queryRole(role, room?._id)[1]()) : true;
const hasAllRolesResult = hasAllRoles ? !!uid && hasAllRoles.every((role) => queryRole(resolveRoleId(role), room?._id)[1]()) : true;
const hasOneRoleResult = hasOneRole ? !!uid && hasOneRole.some((role) => queryRole(resolveRoleId(role), room?._id)[1]()) : true;

return hasAllPermissionsResult && hasOnePermissionResult && hasAllRolesResult && hasOneRoleResult;
},
[queryAllPermissions, queryAtLeastOnePermission, queryRole, uid],
[queryAllPermissions, queryAtLeastOnePermission, queryRole, resolveRoleId, uid],
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,29 @@ export interface IUActionButtonWhen {
messageActionContext?: Array<MessageActionContext>;
hasOnePermission?: Array<string>;
hasAllPermissions?: Array<string>;
/**
* Show the button when the user holds at least one of these roles.
*
* Each entry is a role id or a role name. Prefer the name for a custom role,
* because its id differs between workspaces.
*
* A role scoped to `Subscriptions` — `owner`, `moderator`, `leader`, or a custom
* one — is granted per room, so it matches only on surfaces bound to a room. On a
* surface with no room of its own, the user dropdown for instance, only roles
* scoped to `Users` match.
*/
hasOneRole?: Array<string>;
/**
* Show the button when the user holds every one of these roles.
*
* Each entry is a role id or a role name. Prefer the name for a custom role,
* because its id differs between workspaces.
*
* A role scoped to `Subscriptions` — `owner`, `moderator`, `leader`, or a custom
* one — is granted per room, so it matches only on surfaces bound to a room. On a
* surface with no room of its own, the user dropdown for instance, only roles
* scoped to `Users` match.
*/
hasAllRoles?: Array<string>;
}

Expand Down
41 changes: 29 additions & 12 deletions packages/mock-providers/src/MockedAppRootBuilder.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
CallPreferences,
DirectCallData,
IRole,
IRoom,
ISetting,
IUser,
Expand Down Expand Up @@ -236,18 +237,18 @@ export class MockedAppRootBuilder {
},
};

private authorization: ContextType<typeof AuthorizationContext> = (() => {
const dummyRolesMap: ReturnType<ContextType<typeof AuthorizationContext>['getRoles']> = new Map();

return {
queryPermission: () => [() => () => undefined, () => false],
queryAtLeastOnePermission: () => [() => () => undefined, () => false],
queryAllPermissions: () => [() => () => undefined, () => false],
queryRole: () => [() => () => undefined, () => false],
getRoles: () => dummyRolesMap,
subscribeToRoles: () => () => undefined,
};
})();
// Mutated by `withRoleDefinition` before render, then held stable, so the identity is
// a safe `useSyncExternalStore` snapshot.
private rolesMap = new Map<IRole['_id'], IRole>();

private authorization: ContextType<typeof AuthorizationContext> = {
queryPermission: () => [() => () => undefined, () => false],
queryAtLeastOnePermission: () => [() => () => undefined, () => false],
queryAllPermissions: () => [() => () => undefined, () => false],
queryRole: () => [() => () => undefined, () => false],
getRoles: () => this.rolesMap,
subscribeToRoles: () => () => undefined,
};

private authServices: LoginService[] = [];

Expand Down Expand Up @@ -558,6 +559,22 @@ export class MockedAppRootBuilder {
return this;
}

/**
* Registers a role in the workspace roles map without granting it. A custom role
* has an id that differs from its name, so pass both to exercise code that
* resolves a name to an id. Chain `withRole(_id)` to grant it to the user.
*/
withRoleDefinition(role: Pick<IRole, '_id' | 'name'> & Partial<IRole>): this {
this.rolesMap.set(role._id, {
Comment thread
d-gubert marked this conversation as resolved.
description: '',
protected: false,
scope: 'Users',
...role,
} as IRole);

return this;
}

withSetting(id: string, value: SettingValue, settingStructure?: Partial<ISetting>): this {
const setting = {
...settingStructure,
Expand Down
40 changes: 40 additions & 0 deletions packages/ui-contexts/src/hooks/useRoleIdResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { IRole } from '@rocket.chat/core-typings';
import { useCallback, useContext, useMemo, useSyncExternalStore } from 'react';

import { AuthorizationContext } from '../AuthorizationContext';

/**
* Returns a function that maps a role reference to a role id.
*
* Authorization checks such as `queryRole` match on `IRole._id`. The default roles
* are seeded with `_id === name`, but a custom role gets a random id, so its name
* never matches. Use this resolver whenever the caller only knows the role name —
* an app that declares an action button filter, for example.
*
* A known id wins over a name, so an existing caller that already passes an id is
* unaffected. An unknown value passes through unchanged, which keeps the failure
* mode of the check it feeds.
*/
export const useRoleIdResolver = (): ((role: string) => IRole['_id']) => {
const { getRoles, subscribeToRoles } = useContext(AuthorizationContext);

const roles = useSyncExternalStore(subscribeToRoles, getRoles);

const idsByName = useMemo(() => {
const index = new Map<IRole['name'], IRole['_id']>();
for (const role of roles.values()) {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
// `roles.create` and `roles.update` reject a name already taken by another role, so a
// name maps to at most one role. Should duplicates still exist (e.g. written straight
// to the database), the first one iterated wins instead of the last, so adding another
// duplicate later does not silently repoint every check that names it.
if (index.has(role.name)) {
continue;
}

index.set(role.name, role._id);
}
return index;
}, [roles]);

return useCallback((role: string) => (roles.has(role) ? role : (idsByName.get(role) ?? role)), [idsByName, roles]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
d-gubert marked this conversation as resolved.
};
1 change: 1 addition & 0 deletions packages/ui-contexts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export { useModal } from './hooks/useModal';
export { usePermission } from './hooks/usePermission';
export { usePermissionWithScopedRoles } from './hooks/usePermissionWithScopedRoles';
export { useRole } from './hooks/useRole';
export { useRoleIdResolver } from './hooks/useRoleIdResolver';
export { useRolesDescription } from './hooks/useRolesDescription';
export { useRoomAvatarPath } from './hooks/useRoomAvatarPath';
export { useRoomToolbox } from './hooks/useRoomToolbox';
Expand Down
Loading