diff --git a/models/contact/src/index.ts b/models/contact/src/index.ts index 8eee6fdbc81..797f6feacf6 100644 --- a/models/contact/src/index.ts +++ b/models/contact/src/index.ts @@ -1157,7 +1157,8 @@ export function createModel (builder: Builder): void { mode: ['context'], group: 'remove' }, - secured: true + secured: true, + visibilityTester: contact.function.CanKickEmployee }, contact.action.KickEmployee ) diff --git a/plugins/contact-resources/package.json b/plugins/contact-resources/package.json index 898119415a8..3036f2141a1 100644 --- a/plugins/contact-resources/package.json +++ b/plugins/contact-resources/package.json @@ -8,12 +8,14 @@ "build": "compile ui", "build:docs": "api-extractor run --local", "format": "format src", + "test": "jest --passWithNoTests --silent", "svelte-check": "do-svelte-check", "_phase:svelte-check": "do-svelte-check", "build:watch": "compile ui", "_phase:build": "compile ui", "_phase:format": "format src", - "_phase:validate": "compile validate" + "_phase:validate": "compile validate", + "_phase:test": "jest --passWithNoTests --silent" }, "devDependencies": { "svelte-loader": "^3.2.0", diff --git a/plugins/contact-resources/src/__tests__/kickEmployee.test.ts b/plugins/contact-resources/src/__tests__/kickEmployee.test.ts new file mode 100644 index 00000000000..2ffb47f0256 --- /dev/null +++ b/plugins/contact-resources/src/__tests__/kickEmployee.test.ts @@ -0,0 +1,61 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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. +// + +import type { Person } from '@hcengineering/contact' +import type { AccountUuid, Ref } from '@hcengineering/core' +import { canKick, isCurrentUser } from '../kickEmployee' + +const me = 'account-me' as AccountUuid +const myPerson = 'person-me' as Ref + +const self = { _id: myPerson, personUuid: me } +const other = { _id: 'person-other' as Ref, personUuid: 'account-other' as AccountUuid } + +describe('isCurrentUser', () => { + it('detects the current user by account uuid', () => { + expect(isCurrentUser({ _id: 'x' as Ref, personUuid: me }, me, myPerson)).toBe(true) + }) + + it('detects the current user by employee ref', () => { + expect(isCurrentUser({ _id: myPerson }, me, myPerson)).toBe(true) + }) + + it('returns false for another person', () => { + expect(isCurrentUser(other, me, myPerson)).toBe(false) + }) + + it('returns false when the current user is unknown', () => { + expect(isCurrentUser({ _id: myPerson }, undefined, undefined)).toBe(false) + }) +}) + +describe('canKick', () => { + it('allows kicking another employee', () => { + expect(canKick(other, me, myPerson)).toBe(true) + }) + + it('does not allow kicking yourself', () => { + expect(canKick(self, me, myPerson)).toBe(false) + }) + + it('does not allow a selection that contains yourself', () => { + expect(canKick([other, self], me, myPerson)).toBe(false) + }) + + it('returns false for an empty or missing selection', () => { + expect(canKick([], me, myPerson)).toBe(false) + expect(canKick(undefined, me, myPerson)).toBe(false) + }) +}) diff --git a/plugins/contact-resources/src/index.ts b/plugins/contact-resources/src/index.ts index 81cc7b58705..c2a48728ecb 100644 --- a/plugins/contact-resources/src/index.ts +++ b/plugins/contact-resources/src/index.ts @@ -15,6 +15,7 @@ // /* eslint-disable @typescript-eslint/no-unused-vars */ import { + getCurrentEmployee, getGravatarUrl, getName, type AvatarInfo, @@ -32,7 +33,8 @@ import { type DocumentQuery, type Ref, type RelatedDocument, - type WithLookup + type WithLookup, + getCurrentAccount } from '@hcengineering/core' import login from '@hcengineering/login' import { getResource, type IntlString, type Resources } from '@hcengineering/platform' @@ -130,7 +132,8 @@ import PersonPreviewProvider from './components/person/PersonPreviewProvider.sve import TranslationSettings from './components/TranslationSettings.svelte' import { get } from 'svelte/store' -import { canMergePersons, canResendInvitation } from './visibilityTester' +import { canKickEmployee, canMergePersons, canResendInvitation } from './visibilityTester' +import { isCurrentUser } from './kickEmployee' import contact from './plugin' import { channelIdentifierProvider, @@ -301,6 +304,10 @@ async function resendInvite (doc: Person): Promise { } async function kickEmployee (doc: Person): Promise { + if (isCurrentUser(doc, getCurrentAccount().uuid, getCurrentEmployee())) { + // Kicking yourself would lock you out of the workspace, use "Leave workspace" in the profile instead. + return + } showPopup(MessageBox, { label: contact.string.KickEmployee, message: contact.string.KickEmployeeDescr, @@ -471,6 +478,7 @@ export default async (): Promise => ({ ChannelIdentifierProvider: channelIdentifierProvider, CanResendInvitation: canResendInvitation, CanMergePersons: canMergePersons, + CanKickEmployee: canKickEmployee, HideInactive: hideInactive }, resolver: { diff --git a/plugins/contact-resources/src/kickEmployee.ts b/plugins/contact-resources/src/kickEmployee.ts new file mode 100644 index 00000000000..84da4e0545b --- /dev/null +++ b/plugins/contact-resources/src/kickEmployee.ts @@ -0,0 +1,43 @@ +// +// Copyright © 2026 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public 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 https://www.eclipse.org/legal/epl-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. +// + +import type { Person } from '@hcengineering/contact' +import type { AccountUuid, Ref } from '@hcengineering/core' + +/** + * Returns true if the given person is the currently logged in user. + * Used to prevent kicking yourself out of the workspace; leaving is done from the profile settings. + */ +export function isCurrentUser ( + person: Pick, + currentAccount: AccountUuid | undefined, + currentEmployee: Ref | undefined +): boolean { + if (currentAccount !== undefined && person.personUuid === currentAccount) return true + return currentEmployee !== undefined && person._id === currentEmployee +} + +/** + * Kick is available only for a single employee which is not the current user. + */ +export function canKick ( + doc: Pick | Array> | undefined, + currentAccount: AccountUuid | undefined, + currentEmployee: Ref | undefined +): boolean { + if (doc === undefined) return false + const docs = Array.isArray(doc) ? doc : [doc] + return docs.length > 0 && !docs.some((it) => isCurrentUser(it, currentAccount, currentEmployee)) +} diff --git a/plugins/contact-resources/src/plugin.ts b/plugins/contact-resources/src/plugin.ts index 0b9e5c01304..92f20e0459e 100644 --- a/plugins/contact-resources/src/plugin.ts +++ b/plugins/contact-resources/src/plugin.ts @@ -121,7 +121,8 @@ export default mergeIds(contactId, contact, { FilterChannelHasNewMessagesResult: '' as FilterFunction, PersonTooltipProvider: '' as Resource<(client: Client, doc?: Doc | null) => Promise>, CanResendInvitation: '' as Resource, - CanMergePersons: '' as Resource + CanMergePersons: '' as Resource, + CanKickEmployee: '' as Resource }, aggregation: { CreatePersonAggregationManager: '' as CreateAggregationManagerFunc, diff --git a/plugins/contact-resources/src/visibilityTester.ts b/plugins/contact-resources/src/visibilityTester.ts index 67a5e693b88..b1413e46f9c 100644 --- a/plugins/contact-resources/src/visibilityTester.ts +++ b/plugins/contact-resources/src/visibilityTester.ts @@ -14,8 +14,10 @@ // limitations under the License. // -import contact, { type Person, type Employee } from '@hcengineering/contact' +import contact, { getCurrentEmployee, type Person, type Employee } from '@hcengineering/contact' +import { getCurrentAccount } from '@hcengineering/core' import { getClient } from '@hcengineering/presentation' +import { canKick } from './kickEmployee' export async function canResendInvitation (employee: Employee): Promise { return !employee.active @@ -27,3 +29,7 @@ export async function canMergePersons (person: Person): Promise { return hierarchy.hasMixin(person, contact.mixin.Employee) } + +export async function canKickEmployee (employee: Employee | Employee[] | undefined): Promise { + return canKick(employee, getCurrentAccount().uuid, getCurrentEmployee()) +}