fix: document visibility logic (#1521)

Update the logic of document visibility logic and added some tests &
updated some existing ones.
This commit is contained in:
Catalin Pit
2024-12-16 09:10:40 +02:00
committed by GitHub
parent 861e9c976b
commit 2245812f0b
7 changed files with 303 additions and 57 deletions

View File

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { DocumentStatus, TeamMemberRole } from '@documenso/prisma/client';
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedDocuments, seedTeamDocuments } from '@documenso/prisma/seed/documents';
import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
@ -538,7 +539,7 @@ test('[TEAMS]: ensure recipient can see document regardless of visibility', asyn
await apiSignout({ page });
});
test('[TEAMS]: check that members cannot see ADMIN-only documents', async ({ page }) => {
test('[TEAMS]: check that MEMBER role cannot see ADMIN-only documents', async ({ page }) => {
const team = await seedTeam();
// Seed a member user
@ -575,7 +576,46 @@ test('[TEAMS]: check that members cannot see ADMIN-only documents', async ({ pag
await apiSignout({ page });
});
test('[TEAMS]: check that managers cannot see ADMIN-only documents', async ({ page }) => {
test('[TEAMS]: check that MEMBER role cannot see MANAGER_AND_ABOVE-only documents', async ({
page,
}) => {
const team = await seedTeam();
// Seed a member user
const memberUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
// Seed an ADMIN-only document
await seedDocuments([
{
sender: team.owner,
recipients: [],
type: DocumentStatus.COMPLETED,
documentOptions: {
teamId: team.id,
visibility: 'MANAGER_AND_ABOVE',
title: 'Manager and Above Only Document',
},
},
]);
await apiSignin({
page,
email: memberUser.email,
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
// Check that the member user cannot see the ADMIN-only document
await expect(
page.getByRole('link', { name: 'Admin Only Document', exact: true }),
).not.toBeVisible();
await apiSignout({ page });
});
test('[TEAMS]: check that MANAGER role cannot see ADMIN-only documents', async ({ page }) => {
const team = await seedTeam();
// Seed a manager user
@ -612,7 +652,7 @@ test('[TEAMS]: check that managers cannot see ADMIN-only documents', async ({ pa
await apiSignout({ page });
});
test('[TEAMS]: check that admin can see MANAGER_AND_ABOVE documents', async ({ page }) => {
test('[TEAMS]: check that ADMIN role can see MANAGER_AND_ABOVE documents', async ({ page }) => {
const team = await seedTeam();
// Seed an admin user
@ -649,6 +689,187 @@ test('[TEAMS]: check that admin can see MANAGER_AND_ABOVE documents', async ({ p
await apiSignout({ page });
});
test('[TEAMS]: check that ADMIN role can change document visibility', async ({ page }) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
},
},
});
const adminUser = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.ADMIN,
});
const document = await seedBlankDocument(adminUser, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: adminUser.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await page.getByTestId('documentVisibilitySelectValue').click();
await page.getByLabel('Admins only').click();
await page.getByRole('button', { name: 'Continue' }).click();
await expect(page.getByRole('heading', { name: 'Add Signers' })).toBeVisible();
await page.getByRole('button', { name: 'Go Back' }).click();
await expect(page.getByRole('heading', { name: 'General' })).toBeVisible();
await expect(page.getByTestId('documentVisibilitySelectValue')).toContainText('Admins only');
});
test('[TEAMS]: check that MEMBER role cannot change visibility of EVERYONE documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.EVERYONE,
},
},
},
});
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: teamMember.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByTestId('documentVisibilitySelectValue')).toHaveText('Everyone');
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MEMBER role cannot change visibility of MANAGER_AND_ABOVE documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.MANAGER_AND_ABOVE,
},
},
},
});
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: teamMember.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByTestId('documentVisibilitySelectValue')).toHaveText('Managers and above');
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MEMBER role cannot change visibility of ADMIN documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.ADMIN,
},
},
},
});
const teamMember = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MEMBER,
});
const document = await seedBlankDocument(teamMember, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: teamMember.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByTestId('documentVisibilitySelectValue')).toHaveText('Admins only');
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: check that MANAGER role cannot change visibility of ADMIN documents', async ({
page,
}) => {
const team = await seedTeam({
createTeamOptions: {
teamGlobalSettings: {
create: {
documentVisibility: DocumentVisibility.ADMIN,
},
},
},
});
const teamManager = await seedTeamMember({
teamId: team.id,
role: TeamMemberRole.MANAGER,
});
const document = await seedBlankDocument(teamManager, {
createDocumentOptions: {
teamId: team.id,
visibility: team.teamGlobalSettings?.documentVisibility,
},
});
await apiSignin({
page,
email: teamManager.email,
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
});
await expect(page.getByTestId('documentVisibilitySelectValue')).toHaveText('Admins only');
await expect(page.getByTestId('documentVisibilitySelectValue')).toBeDisabled();
});
test('[TEAMS]: users cannot see documents from other teams', async ({ page }) => {
// Seed two teams with documents
const { team: teamA, teamMember2: teamAMember } = await seedTeamDocuments();

View File

@ -89,17 +89,16 @@ export const createDocument = async ({
globalVisibility: DocumentVisibility | null | undefined,
userRole: TeamMemberRole,
): DocumentVisibility => {
const defaultVisibility = globalVisibility ?? DocumentVisibility.EVERYONE;
if (globalVisibility) {
return globalVisibility;
}
if (userRole === TeamMemberRole.ADMIN) {
return defaultVisibility;
return DocumentVisibility.ADMIN;
}
if (userRole === TeamMemberRole.MANAGER) {
if (defaultVisibility === DocumentVisibility.ADMIN) {
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return defaultVisibility;
return DocumentVisibility.MANAGER_AND_ABOVE;
}
return DocumentVisibility.EVERYONE;

View File

@ -91,39 +91,43 @@ export const updateDocumentSettings = async ({
if (teamId) {
const currentUserRole = document.team?.members[0]?.role;
const isDocumentOwner = document.userId === userId;
const requestedVisibility = data.visibility;
match(currentUserRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (!isDocumentOwner) {
match(currentUserRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(TeamMemberRole.MANAGER, () => {
const allowedVisibilities: DocumentVisibility[] = [
DocumentVisibility.EVERYONE,
DocumentVisibility.MANAGER_AND_ABOVE,
];
if (
!allowedVisibilities.includes(document.visibility) ||
(data.visibility && !allowedVisibilities.includes(data.visibility))
) {
if (
!allowedVisibilities.includes(document.visibility) ||
(requestedVisibility && !allowedVisibilities.includes(requestedVisibility))
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(requestedVisibility && requestedVisibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.otherwise(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
message: 'You do not have permission to update the document',
});
}
})
.with(TeamMemberRole.MEMBER, () => {
if (
document.visibility !== DocumentVisibility.EVERYONE ||
(data.visibility && data.visibility !== DocumentVisibility.EVERYONE)
) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document visibility',
});
}
})
.otherwise(() => {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to update the document',
});
});
}
}
const { documentAuthOption } = extractDocumentAuthMethods({

View File

@ -1,6 +1,7 @@
import { customAlphabet } from 'nanoid';
import { prisma } from '..';
import type { Prisma } from '../client';
import { TeamMemberInviteStatus, TeamMemberRole } from '../client';
import { seedUser } from './users';
@ -10,11 +11,13 @@ const nanoid = customAlphabet('1234567890abcdef', 10);
type SeedTeamOptions = {
createTeamMembers?: number;
createTeamEmail?: true | string;
createTeamOptions?: Partial<Prisma.TeamUncheckedCreateInput>;
};
export const seedTeam = async ({
createTeamMembers = 0,
createTeamEmail,
createTeamOptions = {},
}: SeedTeamOptions = {}) => {
const teamUrl = `team-${nanoid()}`;
const teamEmail = createTeamEmail === true ? `${teamUrl}@${EMAIL_DOMAIN}` : createTeamEmail;
@ -54,6 +57,7 @@ export const seedTeam = async ({
},
}
: undefined,
...createTeamOptions,
},
});
@ -69,6 +73,7 @@ export const seedTeam = async ({
},
},
teamEmail: true,
teamGlobalSettings: true,
},
});
};

View File

@ -1,7 +1,9 @@
import React, { forwardRef } from 'react';
import { TeamMemberRole } from '@prisma/client';
import type { SelectProps } from '@radix-ui/react-select';
import { InfoIcon } from 'lucide-react';
import { match } from 'ts-pattern';
import { DOCUMENT_VISIBILITY } from '@documenso/lib/constants/document-visibility';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
@ -18,15 +20,27 @@ export type DocumentVisibilitySelectType = SelectProps & {
currentMemberRole?: string;
isTeamSettings?: boolean;
disabled?: boolean;
visibility?: string;
};
export const DocumentVisibilitySelect = forwardRef<HTMLButtonElement, DocumentVisibilitySelectType>(
({ currentMemberRole, isTeamSettings = false, disabled, ...props }, ref) => {
const canUpdateVisibility =
currentMemberRole === 'ADMIN' || currentMemberRole === 'MANAGER' || isTeamSettings;
({ currentMemberRole, isTeamSettings = false, disabled, visibility, ...props }, ref) => {
const canUpdateVisibility = match(currentMemberRole)
.with(TeamMemberRole.ADMIN, () => true)
.with(
TeamMemberRole.MANAGER,
() =>
visibility === DocumentVisibility.EVERYONE ||
visibility === DocumentVisibility.MANAGER_AND_ABOVE,
)
.otherwise(() => false);
const isAdmin = currentMemberRole === TeamMemberRole.ADMIN;
const isManager = currentMemberRole === TeamMemberRole.MANAGER;
const canEdit = isTeamSettings || canUpdateVisibility;
return (
<Select {...props} disabled={(!canUpdateVisibility && !isTeamSettings) || disabled}>
<Select {...props} disabled={!canEdit || disabled}>
<SelectTrigger ref={ref} className="bg-background text-muted-foreground">
<SelectValue data-testid="documentVisibilitySelectValue" placeholder="Everyone" />
</SelectTrigger>
@ -35,13 +49,13 @@ export const DocumentVisibilitySelect = forwardRef<HTMLButtonElement, DocumentVi
<SelectItem value={DocumentVisibility.EVERYONE}>
{DOCUMENT_VISIBILITY.EVERYONE.value}
</SelectItem>
<SelectItem value={DocumentVisibility.MANAGER_AND_ABOVE} disabled={!canUpdateVisibility}>
<SelectItem
value={DocumentVisibility.MANAGER_AND_ABOVE}
disabled={!isAdmin && (!isManager || visibility === DocumentVisibility.ADMIN)}
>
{DOCUMENT_VISIBILITY.MANAGER_AND_ABOVE.value}
</SelectItem>
<SelectItem
value={DocumentVisibility.ADMIN}
disabled={currentMemberRole !== 'ADMIN' && !isTeamSettings}
>
<SelectItem value={DocumentVisibility.ADMIN} disabled={!isAdmin}>
{DOCUMENT_VISIBILITY.ADMIN.value}
</SelectItem>
</SelectContent>

View File

@ -238,6 +238,7 @@ export const AddSettingsFormPartial = ({
<FormControl>
<DocumentVisibilitySelect
currentMemberRole={currentTeamMemberRole}
visibility={document.visibility}
{...field}
onValueChange={field.onChange}
/>
@ -273,7 +274,7 @@ export const AddSettingsFormPartial = ({
</AccordionTrigger>
<AccordionContent className="text-muted-foreground -mx-1 px-1 pt-2 text-sm leading-relaxed">
<div className="flex flex-col space-y-6 ">
<div className="flex flex-col space-y-6">
<FormField
control={form.control}
name="externalId"