mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-27 20:14:57 +02:00
Do not allow kick last owner (#10559)
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
@@ -122,6 +122,8 @@
|
||||
"WorkspaceName": "Název pracovního prostoru",
|
||||
"Workspace": "Pracovní prostor",
|
||||
"OwnerOrMaintainerRequired": "Musíte být vlastníkem nebo správcem pracovního prostoru",
|
||||
"LastOwnerLeaveTitle": "Nelze opustit pracovní prostor",
|
||||
"LastOwnerLeaveMessage": "Jste jediným vlastníkem tohoto pracovního prostoru. Abyste jej mohli opustit, nejprve předejte oprávnění vlastníka jinému členovi. Pokud tento pracovní prostor už nikdo nepotřebuje, zvažte jeho smazání.",
|
||||
"Backup": "Záloha",
|
||||
"BackupLast": "Poslední záloha",
|
||||
"BackupTotalSnapshots": "Celkový počet snímků",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"WorkspaceName": "Arbeitsbereich-Name",
|
||||
"Workspace": "Arbeitsbereich",
|
||||
"OwnerOrMaintainerRequired": "Sie müssen Eigentümer oder Betreuer des Arbeitsbereichs sein",
|
||||
"LastOwnerLeaveTitle": "Arbeitsbereich kann nicht verlassen werden",
|
||||
"LastOwnerLeaveMessage": "Sie sind der einzige Eigentümer dieses Arbeitsbereichs. Um ihn zu verlassen, übertragen Sie die Eigentümerrechte zunächst auf ein anderes Mitglied. Wenn niemand diesen Arbeitsbereich mehr benötigt, sollten Sie ihn stattdessen löschen.",
|
||||
"Backup": "Sicherung",
|
||||
"BackupLast": "Letzte Sicherung",
|
||||
"BackupTotalSnapshots": "Gesamtanzahl der Schnappschüsse",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"WorkspaceName": "Workspace name",
|
||||
"Workspace": "Workspace",
|
||||
"OwnerOrMaintainerRequired": "You need to be a workspace Owner or Maintainer",
|
||||
"LastOwnerLeaveTitle": "Cannot leave workspace",
|
||||
"LastOwnerLeaveMessage": "You are the only owner of this workspace. To leave, first grant owner permissions to another member. If nobody needs this workspace anymore, consider deleting this workspace instead.",
|
||||
"Backup": "Backup",
|
||||
"BackupLast": "Last backup",
|
||||
"BackupTotalSnapshots": "Total Snapshots",
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
"WorkspaceName": "Nombre del espacio de trabajo",
|
||||
"Workspace": "Espacio de trabajo",
|
||||
"OwnerOrMaintainerRequired": "Necesitas ser Propietario o Mantenedor del espacio de trabajo",
|
||||
"LastOwnerLeaveTitle": "No puedes abandonar el espacio de trabajo",
|
||||
"LastOwnerLeaveMessage": "Eres el único propietario de este espacio de trabajo. Para salir, primero asigna permisos de propietario a otro miembro. Si nadie más necesita este espacio de trabajo, considera eliminarlo.",
|
||||
"Backup": "Copia de seguridad",
|
||||
"BackupLast": "Última copia de seguridad",
|
||||
"BackupTotalSnapshots": "Total instantáneas",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"WorkspaceName": "Nom de l'espace de travail",
|
||||
"Workspace": "Espace de travail",
|
||||
"OwnerOrMaintainerRequired": "Vous devez être propriétaire ou responsable d'un espace de travail",
|
||||
"LastOwnerLeaveTitle": "Impossible de quitter l'espace de travail",
|
||||
"LastOwnerLeaveMessage": "Vous êtes le seul propriétaire de cet espace de travail. Pour le quitter, attribuez d'abord les droits de propriétaire à un autre membre. Si personne n'a plus besoin de cet espace de travail, envisagez de le supprimer.",
|
||||
"Backup": "Sauvegarde",
|
||||
"BackupLast": "Dernière sauvegarde",
|
||||
"BackupTotalSnapshots": "Total des instantanés",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"WorkspaceName": "Nome spazio di lavoro",
|
||||
"Workspace": "Spazio di lavoro",
|
||||
"OwnerOrMaintainerRequired": "Devi essere un proprietario o un manutentore dello spazio di lavoro",
|
||||
"LastOwnerLeaveTitle": "Impossibile uscire dallo spazio di lavoro",
|
||||
"LastOwnerLeaveMessage": "Sei l'unico proprietario di questo spazio di lavoro. Per uscire, assegna prima i permessi di proprietario a un altro membro. Se nessuno ha più bisogno di questo spazio di lavoro, valuta di eliminarlo.",
|
||||
"Backup": "Backup",
|
||||
"BackupLast": "Ultimo backup",
|
||||
"BackupTotalSnapshots": "Totale istantanee",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"WorkspaceName": "ワークスペース名",
|
||||
"Workspace": "ワークスペース",
|
||||
"OwnerOrMaintainerRequired": "ワークスペースの所有者またはメンテナーである必要があります",
|
||||
"LastOwnerLeaveTitle": "ワークスペースから退出できません",
|
||||
"LastOwnerLeaveMessage": "あなたはこのワークスペースの唯一の所有者です。退出するには、先に別のメンバーに所有者権限を付与してください。このワークスペースが誰にも必要ない場合は、削除することも検討してください。",
|
||||
"Backup": "バックアップ",
|
||||
"BackupLast": "最後のバックアップ",
|
||||
"BackupTotalSnapshots": "合計スナップショット",
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
"WorkspaceName": "Nome do espaço de trabalho",
|
||||
"Workspace": "Espaço de trabalho",
|
||||
"OwnerOrMaintainerRequired": "Precisa ser proprietário ou mantenedor do espaço de trabalho",
|
||||
"LastOwnerLeaveTitle": "Não é possível sair do espaço de trabalho",
|
||||
"LastOwnerLeaveMessage": "Você é o único proprietário deste espaço de trabalho. Para sair, primeiro conceda permissões de proprietário a outro membro. Se ninguém mais precisar deste espaço de trabalho, considere excluí-lo.",
|
||||
"Backup": "Backup",
|
||||
"BackupLast": "Último backup",
|
||||
"BackupTotalSnapshots": "Total de Snapshots",
|
||||
|
||||
@@ -119,6 +119,8 @@
|
||||
"WorkspaceName": "Nome do espaço de trabalho",
|
||||
"Workspace": "Espaço de trabalho",
|
||||
"OwnerOrMaintainerRequired": "Precisa de ser proprietário ou mantenedor do espaço de trabalho",
|
||||
"LastOwnerLeaveTitle": "Não é possível sair do espaço de trabalho",
|
||||
"LastOwnerLeaveMessage": "É o único proprietário deste espaço de trabalho. Para sair, primeiro conceda permissões de proprietário a outro membro. Se ninguém mais precisar deste espaço de trabalho, considere eliminar o espaço de trabalho.",
|
||||
"Backup": "Backup",
|
||||
"BackupLast": "Último backup",
|
||||
"BackupTotalSnapshots": "Total de Snapshots",
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
"WorkspaceName": "Название рабочего пространства",
|
||||
"Workspace": "Рабочее пространство",
|
||||
"OwnerOrMaintainerRequired": "Вы должны иметь роль Владельца/Сопровождаемого",
|
||||
"LastOwnerLeaveTitle": "Невозможно выйти из рабочего пространства",
|
||||
"LastOwnerLeaveMessage": "Вы являетесь единственным владельцем этого рабочего пространства. Чтобы выйти, сначала назначьте владельцем другого участника. Если это рабочее пространство больше никому не нужно, рассмотрите возможность его удаления.",
|
||||
"Backup": "Резервное копирование",
|
||||
"BackupLast": "Последнее резервное копирование",
|
||||
"BackupTotalSnapshots": "Всего снимков",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"WorkspaceName": "Çalışma alanı adı",
|
||||
"Workspace": "Çalışma alanı",
|
||||
"OwnerOrMaintainerRequired": "Çalışma alanı Sahibi veya Bakımcısı olmanız gerekiyor",
|
||||
"LastOwnerLeaveTitle": "Çalışma alanından ayrılamazsınız",
|
||||
"LastOwnerLeaveMessage": "Bu çalışma alanının tek sahibisiniz. Ayrılmak için önce başka bir üyeye sahiplik yetkisi verin. Bu çalışma alanına artık kimsenin ihtiyacı yoksa, silmeyi de düşünebilirsiniz.",
|
||||
"Backup": "Yedek",
|
||||
"BackupLast": "Son yedekleme",
|
||||
"BackupTotalSnapshots": "Toplam Anlık Görüntü",
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
"WorkspaceName": "工作区名称",
|
||||
"Workspace": "工作区",
|
||||
"OwnerOrMaintainerRequired": "您需要成为工作空间所有者或维护者",
|
||||
"LastOwnerLeaveTitle": "无法离开工作区",
|
||||
"LastOwnerLeaveMessage": "您是此工作区的唯一所有者。要离开,请先将所有者权限授予其他成员。如果不再有人需要此工作区,请考虑删除该工作区。",
|
||||
"Backup": "备份",
|
||||
"BackupLast": "上次备份",
|
||||
"BackupTotalSnapshots": "快照总数",
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
{@const personUuid = employee.personUuid ?? undefined}
|
||||
{@const role = personUuid !== undefined ? workspaceMembers[personUuid] : undefined}
|
||||
{#if personUuid !== undefined && role !== undefined && employee.name?.includes(search)}
|
||||
<div class="flex-row-center p-2 flex-no-shrink">
|
||||
<div class="flex-row-center p-2 flex-no-shrink" data-id="owners-member-row">
|
||||
<div class="p-1 min-w-80">
|
||||
<EmployeePresenter value={employee} disabled={false} />
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
<script lang="ts">
|
||||
import contact, { combineName, getFirstName, getLastName } from '@hcengineering/contact'
|
||||
import { ChannelsEditor, EditableAvatar, myEmployeeStore } from '@hcengineering/contact-resources'
|
||||
import { getCurrentAccount, SocialIdType } from '@hcengineering/core'
|
||||
import { AccountRole, getCurrentAccount, SocialIdType } from '@hcengineering/core'
|
||||
import login, { loginId } from '@hcengineering/login'
|
||||
import { getResource } from '@hcengineering/platform'
|
||||
import platform, { getResource, PlatformError } from '@hcengineering/platform'
|
||||
import { AttributeEditor, createQuery, getClient, hasResource, MessageBox } from '@hcengineering/presentation'
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -64,7 +64,7 @@
|
||||
}
|
||||
|
||||
let avatarEditor: EditableAvatar
|
||||
async function onAvatarDone (e: any): Promise<void> {
|
||||
async function onAvatarDone (): Promise<void> {
|
||||
if ($myEmployeeStore === undefined) return
|
||||
|
||||
if ($myEmployeeStore.avatar != null) {
|
||||
@@ -82,14 +82,30 @@
|
||||
message: setting.string.LeaveDescr,
|
||||
action: async () => {
|
||||
const leaveWorkspace = await getResource(login.function.LeaveWorkspace)
|
||||
const loginInfo = await leaveWorkspace(account.uuid)
|
||||
try {
|
||||
const loginInfo = await leaveWorkspace(account.uuid)
|
||||
|
||||
if (loginInfo?.token != null) {
|
||||
await logIn(loginInfo)
|
||||
navigate({ path: [loginId, 'selectWorkspace'] })
|
||||
} else {
|
||||
await logOut()
|
||||
navigate({ path: [loginId] })
|
||||
if (loginInfo?.token != null) {
|
||||
await logIn(loginInfo)
|
||||
navigate({ path: [loginId, 'selectWorkspace'] })
|
||||
} else {
|
||||
await logOut()
|
||||
navigate({ path: [loginId] })
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (
|
||||
err instanceof PlatformError &&
|
||||
err.status?.code === platform.status.Forbidden &&
|
||||
account.role === AccountRole.Owner
|
||||
) {
|
||||
showPopup(MessageBox, {
|
||||
label: setting.string.LastOwnerLeaveTitle,
|
||||
message: setting.string.LastOwnerLeaveMessage,
|
||||
canSubmit: false
|
||||
})
|
||||
} else {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -157,6 +157,8 @@ export default mergeIds(settingId, setting, {
|
||||
PasswordAgingRule: '' as IntlString,
|
||||
PasswordAgingRuleDescription: '' as IntlString,
|
||||
ShowInTitle: '' as IntlString,
|
||||
SpaceMembersOnly: '' as IntlString
|
||||
SpaceMembersOnly: '' as IntlString,
|
||||
LastOwnerLeaveTitle: '' as IntlString,
|
||||
LastOwnerLeaveMessage: '' as IntlString
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,7 +49,8 @@ import {
|
||||
getPerson,
|
||||
getSocialIds,
|
||||
createAccessLink,
|
||||
getSubscriptions
|
||||
getSubscriptions,
|
||||
leaveWorkspace
|
||||
} from '../operations'
|
||||
import { accountPlugin } from '../plugin'
|
||||
|
||||
@@ -105,6 +106,8 @@ describe('account operations', () => {
|
||||
update: jest.fn()
|
||||
},
|
||||
getWorkspaceRole: jest.fn(),
|
||||
getWorkspaceMembers: jest.fn(),
|
||||
unassignWorkspace: jest.fn(),
|
||||
person: {
|
||||
findOne: jest.fn()
|
||||
},
|
||||
@@ -1553,6 +1556,82 @@ describe('account operations', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('workspace membership operations', () => {
|
||||
const ownerAccount = 'owner-account-uuid' as AccountUuid
|
||||
const maintainerAccount = 'maintainer-account-uuid' as AccountUuid
|
||||
const otherOwnerAccount = 'other-owner-account-uuid' as AccountUuid
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
||||
account: maintainerAccount,
|
||||
workspace: mockWorkspace.uuid,
|
||||
extra: {}
|
||||
})
|
||||
})
|
||||
|
||||
describe('leaveWorkspace', () => {
|
||||
test('should forbid maintainer from removing owner from workspace', async () => {
|
||||
;(mockDb.getWorkspaceRole as jest.Mock).mockImplementation(async (accountId: AccountUuid) => {
|
||||
if (accountId === maintainerAccount) return AccountRole.Maintainer
|
||||
if (accountId === ownerAccount) return AccountRole.Owner
|
||||
return null
|
||||
})
|
||||
|
||||
await expect(
|
||||
leaveWorkspace(mockCtx, mockDb, mockBranding, mockToken, { account: ownerAccount })
|
||||
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
|
||||
|
||||
expect(mockDb.unassignWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should forbid last owner from leaving workspace', async () => {
|
||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
||||
account: ownerAccount,
|
||||
workspace: mockWorkspace.uuid,
|
||||
extra: {}
|
||||
})
|
||||
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Owner)
|
||||
;(mockDb.getWorkspaceMembers as jest.Mock).mockResolvedValue([
|
||||
{ accountUuid: ownerAccount, role: AccountRole.Owner }
|
||||
])
|
||||
|
||||
await expect(
|
||||
leaveWorkspace(mockCtx, mockDb, mockBranding, mockToken, { account: ownerAccount })
|
||||
).rejects.toThrow(new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {})))
|
||||
|
||||
expect(mockDb.unassignWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('should allow owner to leave when there are multiple owners', async () => {
|
||||
;(decodeTokenVerbose as jest.Mock).mockReturnValue({
|
||||
account: ownerAccount,
|
||||
workspace: mockWorkspace.uuid,
|
||||
extra: {}
|
||||
})
|
||||
;(mockDb.getWorkspaceRole as jest.Mock).mockResolvedValue(AccountRole.Owner)
|
||||
;(mockDb.getWorkspaceMembers as jest.Mock).mockResolvedValue([
|
||||
{ accountUuid: ownerAccount, role: AccountRole.Owner },
|
||||
{ accountUuid: otherOwnerAccount, role: AccountRole.Owner }
|
||||
])
|
||||
;(mockDb.person.findOne as jest.Mock).mockResolvedValue({
|
||||
uuid: ownerAccount,
|
||||
firstName: 'Owner',
|
||||
lastName: 'User'
|
||||
})
|
||||
|
||||
const result = await leaveWorkspace(mockCtx, mockDb, mockBranding, mockToken, { account: ownerAccount })
|
||||
|
||||
expect(mockDb.unassignWorkspace).toHaveBeenCalledWith(ownerAccount, mockWorkspace.uuid)
|
||||
expect(result).toEqual({
|
||||
account: ownerAccount,
|
||||
name: 'Owner User',
|
||||
token: expect.any(String)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('registration operations', () => {
|
||||
const mockCtx = {
|
||||
error: jest.fn(),
|
||||
|
||||
@@ -1479,6 +1479,7 @@ export async function leaveWorkspace (
|
||||
}
|
||||
|
||||
const initiatorRole = await db.getWorkspaceRole(account, workspace)
|
||||
const targetRole = await db.getWorkspaceRole(targetAccount, workspace)
|
||||
|
||||
if (account !== targetAccount) {
|
||||
if (initiatorRole == null || getRolePower(initiatorRole) < getRolePower(AccountRole.Maintainer)) {
|
||||
@@ -1489,6 +1490,30 @@ export async function leaveWorkspace (
|
||||
})
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
|
||||
if (targetRole === AccountRole.Owner && initiatorRole === AccountRole.Maintainer) {
|
||||
ctx.warn('Maintainer cannot remove owner from workspace', {
|
||||
account,
|
||||
targetAccount,
|
||||
workspace,
|
||||
initiatorRole,
|
||||
targetRole
|
||||
})
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
}
|
||||
|
||||
if (account === targetAccount && initiatorRole === AccountRole.Owner) {
|
||||
const members = await db.getWorkspaceMembers(workspace)
|
||||
const owners = members.filter((m) => m.role === AccountRole.Owner)
|
||||
|
||||
if (owners.length === 1) {
|
||||
ctx.warn('Owner cannot remove themselves as the last owner of the workspace', {
|
||||
account,
|
||||
workspace
|
||||
})
|
||||
throw new PlatformError(new Status(Severity.ERROR, platform.status.Forbidden, {}))
|
||||
}
|
||||
}
|
||||
|
||||
await db.unassignWorkspace(targetAccount, workspace)
|
||||
|
||||
@@ -22,7 +22,6 @@ test.describe('Workspace tests', () => {
|
||||
let leftSideMenuPage: LeftSideMenuPage
|
||||
let trackerNavigationMenuPage: TrackerNavigationMenuPage
|
||||
let issuesPage: IssuesPage
|
||||
let userProfilePage: UserProfilePage
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page)
|
||||
@@ -31,7 +30,6 @@ test.describe('Workspace tests', () => {
|
||||
leftSideMenuPage = new LeftSideMenuPage(page)
|
||||
trackerNavigationMenuPage = new TrackerNavigationMenuPage(page)
|
||||
issuesPage = new IssuesPage(page)
|
||||
userProfilePage = new UserProfilePage(page)
|
||||
})
|
||||
|
||||
test('Create a workspace with a custom name', async () => {
|
||||
@@ -213,7 +211,7 @@ test.describe('Workspace tests', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('User can leave workspace', async ({ page }) => {
|
||||
test('User can leave workspace', async ({ page, browser }) => {
|
||||
const newUser: SignUpData = {
|
||||
firstName: faker.person.firstName(),
|
||||
lastName: faker.person.lastName(),
|
||||
@@ -226,13 +224,40 @@ test.describe('Workspace tests', () => {
|
||||
await signUpPage.signUp(newUser)
|
||||
await selectWorkspacePage.createWorkspace(newWorkspaceName)
|
||||
await trackerNavigationMenuPage.checkIfTrackerSidebarIsVisible()
|
||||
await userProfilePage.openProfileMenu()
|
||||
await userProfilePage.selectProfileByName(newUser.lastName + ' ' + newUser.firstName)
|
||||
await userProfilePage.clickLeaveWorkspaceButton()
|
||||
await userProfilePage.clickLeaveWorkspaceCancelButton()
|
||||
await userProfilePage.clickLeaveWorkspaceButton()
|
||||
await userProfilePage.clickLeaveWorkspaceConfirmButton()
|
||||
await expect(page.locator('form')).toContainText('Select workspace')
|
||||
await expect(page.getByText(newWorkspaceName)).toHaveCount(0)
|
||||
|
||||
// Invite a second user so the first user is no longer the only owner (required to leave)
|
||||
await leftSideMenuPage.openProfileMenu()
|
||||
await leftSideMenuPage.inviteToWorkspace()
|
||||
await leftSideMenuPage.getInviteLink()
|
||||
const linkText = await page.locator('.antiPopup .link').textContent()
|
||||
await leftSideMenuPage.clickOnCloseInvite()
|
||||
|
||||
const newUser2: SignUpData = {
|
||||
firstName: faker.person.firstName(),
|
||||
lastName: faker.person.lastName(),
|
||||
email: faker.internet.email(),
|
||||
password: '1234'
|
||||
}
|
||||
const page2 = await browser.newPage()
|
||||
try {
|
||||
await page2.goto(linkText ?? '')
|
||||
const signUpPage2 = new SignUpPage(page2)
|
||||
await signUpPage2.signUp(newUser2)
|
||||
const leftSideMenuPage2 = new LeftSideMenuPage(page2)
|
||||
await leftSideMenuPage2.clickTracker()
|
||||
|
||||
// Second user leaves the workspace
|
||||
const userProfilePage2 = new UserProfilePage(page2)
|
||||
await userProfilePage2.openProfileMenu()
|
||||
await userProfilePage2.selectProfileByName(`${newUser2.lastName} ${newUser2.firstName}`)
|
||||
await userProfilePage2.clickLeaveWorkspaceButton()
|
||||
await userProfilePage2.clickLeaveWorkspaceCancelButton()
|
||||
await userProfilePage2.clickLeaveWorkspaceButton()
|
||||
await userProfilePage2.clickLeaveWorkspaceConfirmButton()
|
||||
await expect(page2.locator('form')).toContainText('Select workspace')
|
||||
await expect(page2.getByText(newWorkspaceName)).toHaveCount(0)
|
||||
} finally {
|
||||
await page2.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user