mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
feat: change organisation owner in admin panel (#2047)
Allows changing the owner of an organisation within the admin panel, useful for support requests to change ownership from a testing account to the main admin account. <img width="890" height="431" alt="image" src="https://github.com/user-attachments/assets/475bbbdd-0f26-4f74-aacf-3e793366551d" />
This commit is contained in:
@@ -71,6 +71,23 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { mutateAsync: promoteToOwner, isPending: isPromotingToOwner } =
|
||||||
|
trpc.admin.organisationMember.promoteToOwner.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Success`,
|
||||||
|
description: t`Member promoted to owner successfully`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: t`We couldn't promote the member to owner. Please try again.`,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const teamsColumns = useMemo(() => {
|
const teamsColumns = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -101,6 +118,26 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
|||||||
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
|
<Link to={`/admin/users/${row.original.user.id}`}>{row.original.user.email}</Link>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t`Actions`,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled={row.original.userId === organisation?.ownerUserId}
|
||||||
|
loading={isPromotingToOwner}
|
||||||
|
onClick={async () =>
|
||||||
|
promoteToOwner({
|
||||||
|
organisationId,
|
||||||
|
userId: row.original.userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Trans>Promote to owner</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
] satisfies DataTableColumnDef<TGetAdminOrganisationResponse['members'][number]>[];
|
||||||
}, [organisation]);
|
}, [organisation]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
import { nanoid } from '@documenso/lib/universal/id';
|
||||||
|
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||||
|
import { seedUser } from '@documenso/prisma/seed/users';
|
||||||
|
|
||||||
|
import { apiSignin } from '../../fixtures/authentication';
|
||||||
|
|
||||||
|
test('[ADMIN]: promote member to owner', async ({ page }) => {
|
||||||
|
// Create an admin user who can access the admin panel
|
||||||
|
const { user: adminUser } = await seedUser({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an organisation owner
|
||||||
|
const { user: ownerUser, organisation } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create organisation members with different roles
|
||||||
|
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
||||||
|
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
|
||||||
|
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
|
||||||
|
|
||||||
|
const [memberUser, managerUser, adminMemberUser] = await seedOrganisationMembers({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
email: memberEmail,
|
||||||
|
name: 'Test Member',
|
||||||
|
organisationRole: 'MEMBER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: managerEmail,
|
||||||
|
name: 'Test Manager',
|
||||||
|
organisationRole: 'MANAGER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: adminMemberEmail,
|
||||||
|
name: 'Test Admin Member',
|
||||||
|
organisationRole: 'ADMIN',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as admin and navigate to the organisation admin page
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: adminUser.email,
|
||||||
|
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify we're on the admin organisation page
|
||||||
|
await expect(page.getByText(`Manage organisation`)).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Organisation Name')).toHaveValue(organisation.name);
|
||||||
|
|
||||||
|
// Check that the organisation members table shows the correct roles
|
||||||
|
const ownerRow = page.getByRole('row', { name: ownerUser.email });
|
||||||
|
|
||||||
|
await expect(ownerRow).toBeVisible();
|
||||||
|
await expect(ownerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
|
||||||
|
await expect(page.getByRole('row', { name: memberUser.email })).toBeVisible();
|
||||||
|
await expect(page.getByRole('row', { name: adminMemberUser.email })).toBeVisible();
|
||||||
|
await expect(page.getByRole('row', { name: managerUser.email })).toBeVisible();
|
||||||
|
|
||||||
|
// Test promoting a MEMBER to owner
|
||||||
|
const memberRow = page.getByRole('row', { name: memberUser.email });
|
||||||
|
|
||||||
|
// Find and click the "Promote to owner" button for the member
|
||||||
|
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await expect(promoteButton).toBeVisible();
|
||||||
|
await expect(promoteButton).not.toBeDisabled();
|
||||||
|
|
||||||
|
await promoteButton.click();
|
||||||
|
|
||||||
|
// Verify success toast appears
|
||||||
|
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Reload the page to see the changes
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify that the member is now the owner
|
||||||
|
const newOwnerRow = page.getByRole('row', { name: memberUser.email });
|
||||||
|
await expect(newOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
|
||||||
|
// Verify that the previous owner is no longer marked as owner
|
||||||
|
const previousOwnerRow = page.getByRole('row', { name: ownerUser.email });
|
||||||
|
await expect(previousOwnerRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify that the promote button is now disabled for the new owner
|
||||||
|
const newOwnerPromoteButton = newOwnerRow.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await expect(newOwnerPromoteButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Test that we can't promote the current owner (button should be disabled)
|
||||||
|
await expect(newOwnerPromoteButton).toHaveAttribute('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN]: promote manager to owner', async ({ page }) => {
|
||||||
|
// Create an admin user who can access the admin panel
|
||||||
|
const { user: adminUser } = await seedUser({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an organisation with owner and manager
|
||||||
|
const { organisation } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const managerEmail = `manager-${nanoid()}@test.documenso.com`;
|
||||||
|
|
||||||
|
const [managerUser] = await seedOrganisationMembers({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
email: managerEmail,
|
||||||
|
name: 'Test Manager',
|
||||||
|
organisationRole: 'MANAGER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as admin
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: adminUser.email,
|
||||||
|
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Promote the manager to owner
|
||||||
|
const managerRow = page.getByRole('row', { name: managerUser.email });
|
||||||
|
const promoteButton = managerRow.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
|
||||||
|
await promoteButton.click();
|
||||||
|
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Reload and verify the change
|
||||||
|
await page.reload();
|
||||||
|
await expect(managerRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN]: promote admin member to owner', async ({ page }) => {
|
||||||
|
// Create an admin user who can access the admin panel
|
||||||
|
const { user: adminUser } = await seedUser({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an organisation with owner and admin member
|
||||||
|
const { organisation } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminMemberEmail = `admin-member-${nanoid()}@test.documenso.com`;
|
||||||
|
|
||||||
|
const [adminMemberUser] = await seedOrganisationMembers({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
email: adminMemberEmail,
|
||||||
|
name: 'Test Admin Member',
|
||||||
|
organisationRole: 'ADMIN',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as admin
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: adminUser.email,
|
||||||
|
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Promote the admin member to owner
|
||||||
|
const adminMemberRow = page.getByRole('row', { name: adminMemberUser.email });
|
||||||
|
const promoteButton = adminMemberRow.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
|
||||||
|
await promoteButton.click();
|
||||||
|
|
||||||
|
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload and verify the change
|
||||||
|
await page.reload();
|
||||||
|
await expect(adminMemberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN]: cannot promote non-existent user', async ({ page }) => {
|
||||||
|
// Create an admin user
|
||||||
|
const { user: adminUser } = await seedUser({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create an organisation
|
||||||
|
const { organisation } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as admin
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: adminUser.email,
|
||||||
|
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to manually call the API with invalid data - this should be handled by the UI validation
|
||||||
|
// In a real scenario, the promote button wouldn't be available for non-existent users
|
||||||
|
// But we can test that the API properly handles invalid requests
|
||||||
|
|
||||||
|
// For now, just verify that non-existent users don't show up in the members table
|
||||||
|
await expect(page.getByRole('row', { name: 'Non Existent User' })).not.toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN]: verify role hierarchy after promotion', async ({ page }) => {
|
||||||
|
// Create an admin user
|
||||||
|
const { user: adminUser } = await seedUser({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create organisation with a member
|
||||||
|
const { organisation } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
||||||
|
|
||||||
|
const [memberUser] = await seedOrganisationMembers({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
email: memberEmail,
|
||||||
|
name: 'Test Member',
|
||||||
|
organisationRole: 'MEMBER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as admin
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: adminUser.email,
|
||||||
|
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Before promotion - verify member has MEMBER role
|
||||||
|
let memberRow = page.getByRole('row', { name: memberUser.email });
|
||||||
|
await expect(memberRow).toBeVisible();
|
||||||
|
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Promote member to owner
|
||||||
|
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await promoteButton.click();
|
||||||
|
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload page to see updated state
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// After promotion - verify member is now owner and has admin permissions
|
||||||
|
memberRow = page.getByRole('row', { name: memberUser.email });
|
||||||
|
await expect(memberRow.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
|
||||||
|
// Verify the promote button is now disabled for the new owner
|
||||||
|
const newOwnerPromoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await expect(newOwnerPromoteButton).toBeDisabled();
|
||||||
|
|
||||||
|
// Sign in as the newly promoted user to verify they have owner permissions
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: memberUser.email,
|
||||||
|
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify they can access organisation settings (owner permission)
|
||||||
|
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN]: error handling for invalid organisation', async ({ page }) => {
|
||||||
|
// Create an admin user
|
||||||
|
const { user: adminUser } = await seedUser({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as admin and try to access non-existent organisation
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: adminUser.email,
|
||||||
|
redirectPath: `/admin/organisations/non-existent-org-id`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should show 404 error
|
||||||
|
await expect(page.getByRole('heading', { name: 'Organisation not found' })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN]: multiple promotions in sequence', async ({ page }) => {
|
||||||
|
// Create an admin user
|
||||||
|
const { user: adminUser } = await seedUser({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create organisation with multiple members
|
||||||
|
const { organisation } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const member1Email = `member1-${nanoid()}@test.documenso.com`;
|
||||||
|
const member2Email = `member2-${nanoid()}@test.documenso.com`;
|
||||||
|
|
||||||
|
const [member1User, member2User] = await seedOrganisationMembers({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
email: member1Email,
|
||||||
|
name: 'Test Member 1',
|
||||||
|
organisationRole: 'MEMBER',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
email: member2Email,
|
||||||
|
name: 'Test Member 2',
|
||||||
|
organisationRole: 'MANAGER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as admin
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: adminUser.email,
|
||||||
|
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First promotion: Member 1 becomes owner
|
||||||
|
let member1Row = page.getByRole('row', { name: member1User.email });
|
||||||
|
let promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await promoteButton1.click();
|
||||||
|
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify Member 1 is now owner and button is disabled
|
||||||
|
member1Row = page.getByRole('row', { name: member1User.email });
|
||||||
|
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
promoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await expect(promoteButton1).toBeDisabled();
|
||||||
|
|
||||||
|
// Second promotion: Member 2 becomes the new owner
|
||||||
|
const member2Row = page.getByRole('row', { name: member2User.email });
|
||||||
|
const promoteButton2 = member2Row.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await expect(promoteButton2).not.toBeDisabled();
|
||||||
|
await promoteButton2.click();
|
||||||
|
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// Verify Member 2 is now owner and Member 1 is no longer owner
|
||||||
|
await expect(member2Row.getByRole('status').filter({ hasText: 'Owner' })).toBeVisible();
|
||||||
|
await expect(member1Row.getByRole('status').filter({ hasText: 'Owner' })).not.toBeVisible();
|
||||||
|
|
||||||
|
// Verify Member 1's promote button is now enabled again
|
||||||
|
const newPromoteButton1 = member1Row.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await expect(newPromoteButton1).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[ADMIN]: verify organisation access after ownership change', async ({ page }) => {
|
||||||
|
// Create admin user
|
||||||
|
const { user: adminUser } = await seedUser({
|
||||||
|
isAdmin: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create organisation with owner and member
|
||||||
|
const { user: originalOwner, organisation } = await seedUser({
|
||||||
|
isPersonalOrganisation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberEmail = `member-${nanoid()}@test.documenso.com`;
|
||||||
|
|
||||||
|
const [memberUser] = await seedOrganisationMembers({
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
email: memberEmail,
|
||||||
|
name: 'Test Member',
|
||||||
|
organisationRole: 'MEMBER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
organisationId: organisation.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sign in as admin and promote member to owner
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: adminUser.email,
|
||||||
|
redirectPath: `/admin/organisations/${organisation.id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberRow = page.getByRole('row', { name: memberUser.email });
|
||||||
|
const promoteButton = memberRow.getByRole('button', { name: 'Promote to owner' });
|
||||||
|
await promoteButton.click();
|
||||||
|
await expect(page.getByText('Member promoted to owner successfully').first()).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test that the new owner can access organisation settings
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: memberUser.email,
|
||||||
|
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should be able to access organisation settings
|
||||||
|
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
|
||||||
|
|
||||||
|
// Should have delete permissions
|
||||||
|
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||||
|
|
||||||
|
// Test that the original owner no longer has owner-level access
|
||||||
|
await apiSignin({
|
||||||
|
page,
|
||||||
|
email: originalOwner.email,
|
||||||
|
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should still be able to access settings (as they should now be an admin)
|
||||||
|
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||||
|
});
|
||||||
@@ -48,7 +48,7 @@ export default defineConfig({
|
|||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
use: {
|
use: {
|
||||||
...devices['Desktop Chrome'],
|
...devices['Desktop Chrome'],
|
||||||
viewport: { width: 1920, height: 1080 },
|
viewport: { width: 1920, height: 1200 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||||
|
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||||
|
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
|
||||||
|
import { prisma } from '@documenso/prisma';
|
||||||
|
|
||||||
|
import { adminProcedure } from '../trpc';
|
||||||
|
import {
|
||||||
|
ZPromoteMemberToOwnerRequestSchema,
|
||||||
|
ZPromoteMemberToOwnerResponseSchema,
|
||||||
|
} from './promote-member-to-owner.types';
|
||||||
|
|
||||||
|
export const promoteMemberToOwnerRoute = adminProcedure
|
||||||
|
.input(ZPromoteMemberToOwnerRequestSchema)
|
||||||
|
.output(ZPromoteMemberToOwnerResponseSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const { organisationId, userId } = input;
|
||||||
|
|
||||||
|
ctx.logger.info({
|
||||||
|
input: {
|
||||||
|
organisationId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// First, verify the organisation exists and get member details with groups
|
||||||
|
const organisation = await prisma.organisation.findUnique({
|
||||||
|
where: {
|
||||||
|
id: organisationId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
groups: {
|
||||||
|
where: {
|
||||||
|
type: OrganisationGroupType.INTERNAL_ORGANISATION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
organisationGroupMembers: {
|
||||||
|
include: {
|
||||||
|
group: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!organisation) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'Organisation not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the user is a member of the organisation
|
||||||
|
const [member] = organisation.members;
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||||
|
message: 'User is not a member of this organisation',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the user is not already the owner
|
||||||
|
if (organisation.ownerUserId === userId) {
|
||||||
|
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||||
|
message: 'User is already the owner of this organisation',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current organisation role
|
||||||
|
const currentOrganisationRole = getHighestOrganisationRoleInGroup(
|
||||||
|
member.organisationGroupMembers.flatMap((member) => member.group),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the current and target organisation groups
|
||||||
|
const currentMemberGroup = organisation.groups.find(
|
||||||
|
(group) => group.organisationRole === currentOrganisationRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminGroup = organisation.groups.find(
|
||||||
|
(group) => group.organisationRole === OrganisationMemberRole.ADMIN,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!currentMemberGroup) {
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: 'Current member group not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adminGroup) {
|
||||||
|
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||||
|
message: 'Admin group not found',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the organisation owner and member role in a transaction
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
// Update the organisation to set the new owner
|
||||||
|
await tx.organisation.update({
|
||||||
|
where: {
|
||||||
|
id: organisationId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
ownerUserId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update role if the user is not already an admin then add them to the admin group
|
||||||
|
if (currentOrganisationRole !== OrganisationMemberRole.ADMIN) {
|
||||||
|
await tx.organisationGroupMember.create({
|
||||||
|
data: {
|
||||||
|
id: generateDatabaseId('group_member'),
|
||||||
|
organisationMemberId: member.id,
|
||||||
|
groupId: adminGroup.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const ZPromoteMemberToOwnerRequestSchema = z.object({
|
||||||
|
organisationId: z.string().min(1),
|
||||||
|
userId: z.number().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ZPromoteMemberToOwnerResponseSchema = z.void();
|
||||||
|
|
||||||
|
export type TPromoteMemberToOwnerRequest = z.infer<typeof ZPromoteMemberToOwnerRequestSchema>;
|
||||||
|
export type TPromoteMemberToOwnerResponse = z.infer<typeof ZPromoteMemberToOwnerResponseSchema>;
|
||||||
@@ -12,6 +12,7 @@ import { findDocumentsRoute } from './find-documents';
|
|||||||
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
|
||||||
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
import { getAdminOrganisationRoute } from './get-admin-organisation';
|
||||||
import { getUserRoute } from './get-user';
|
import { getUserRoute } from './get-user';
|
||||||
|
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||||
import { resealDocumentRoute } from './reseal-document';
|
import { resealDocumentRoute } from './reseal-document';
|
||||||
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
||||||
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
||||||
@@ -27,6 +28,9 @@ export const adminRouter = router({
|
|||||||
create: createAdminOrganisationRoute,
|
create: createAdminOrganisationRoute,
|
||||||
update: updateAdminOrganisationRoute,
|
update: updateAdminOrganisationRoute,
|
||||||
},
|
},
|
||||||
|
organisationMember: {
|
||||||
|
promoteToOwner: promoteMemberToOwnerRoute,
|
||||||
|
},
|
||||||
claims: {
|
claims: {
|
||||||
find: findSubscriptionClaimsRoute,
|
find: findSubscriptionClaimsRoute,
|
||||||
create: createSubscriptionClaimRoute,
|
create: createSubscriptionClaimRoute,
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ export interface BadgeProps
|
|||||||
VariantProps<typeof badgeVariants> {}
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
function Badge({ className, variant, size, ...props }: BadgeProps) {
|
||||||
return <div className={cn(badgeVariants({ variant, size }), className)} {...props} />;
|
return (
|
||||||
|
<div role="status" className={cn(badgeVariants({ variant, size }), className)} {...props} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Badge, badgeVariants };
|
export { Badge, badgeVariants };
|
||||||
|
|||||||
Reference in New Issue
Block a user