mirror of
https://github.com/documenso/documenso.git
synced 2026-07-06 02:55:00 +10:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbbb0289c3 | |||
| c7d21c6587 | |||
| 2aa391f917 | |||
| 681540b501 | |||
| f3305ac306 | |||
| 68b4305b6a | |||
| 3de1ea0a02 | |||
| b8fc47b719 | |||
| cfceebd78f | |||
| b9b3ddfb98 | |||
| 8590502338 | |||
| 53f29daf50 |
@@ -18,6 +18,11 @@ The guide assumes you have a Documenso account. If you don't, you can create a f
|
||||
|
||||
Navigate to the [Documenso dashboard](https://app.documenso.com/documents) and click on the "Add a document" button. Select the document you want to upload and wait for the upload to complete.
|
||||
|
||||
<Callout type="info">
|
||||
The maximum file size for uploaded documents is 150MB in production. In staging, the limit is
|
||||
50MB.
|
||||
</Callout>
|
||||
|
||||

|
||||
|
||||
After the upload is complete, you will be redirected to the document's page. You can configure the document's settings and add recipients and fields here.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { type Recipient, SigningStatus } from '@prisma/client';
|
||||
import { History } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import * as z from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
@@ -85,6 +85,11 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
formState: { isSubmitting },
|
||||
} = form;
|
||||
|
||||
const selectedRecipients = useWatch({
|
||||
control: form.control,
|
||||
name: 'recipients',
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ recipients }: TResendDocumentFormSchema) => {
|
||||
try {
|
||||
await resendDocument({ documentId: document.id, recipients });
|
||||
@@ -151,7 +156,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
className="h-5 w-5 rounded-full"
|
||||
className="h-5 w-5 rounded-full border border-neutral-400"
|
||||
value={recipient.id}
|
||||
checked={value.includes(recipient.id)}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
@@ -182,7 +187,13 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
|
||||
</Button>
|
||||
</DialogClose>
|
||||
|
||||
<Button className="flex-1" loading={isSubmitting} type="submit" form={FORM_ID}>
|
||||
<Button
|
||||
className="flex-1"
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={isSubmitting || selectedRecipients.length === 0}
|
||||
>
|
||||
<Trans>Send reminder</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -3,10 +3,11 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { DocumentVisibility } from '@prisma/client';
|
||||
import { DocumentVisibility, OrganisationType } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
@@ -86,8 +87,10 @@ export const DocumentPreferencesForm = ({
|
||||
}: DocumentPreferencesFormProps) => {
|
||||
const { t } = useLingui();
|
||||
const { user, organisations } = useSession();
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
const isPersonalOrganisation = currentOrganisation.type === OrganisationType.PERSONAL;
|
||||
|
||||
const placeholderEmail = user.email ?? 'user@example.com';
|
||||
|
||||
@@ -331,7 +334,7 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{!isPersonalLayoutMode && (
|
||||
{!isPersonalLayoutMode && !isPersonalOrganisation && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeSenderDetails"
|
||||
|
||||
@@ -157,6 +157,7 @@ export const DocumentSigningDateField = ({
|
||||
'!text-right': parsedFieldMeta?.textAlign === 'right',
|
||||
},
|
||||
)}
|
||||
style={{ fontSize: `${parsedFieldMeta?.fontSize}px` }}
|
||||
>
|
||||
{localDateString}
|
||||
</p>
|
||||
|
||||
@@ -132,7 +132,10 @@ export const DocumentSigningEmailField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||
<DocumentSigningFieldsInserted
|
||||
textAlign={parsedFieldMeta?.textAlign}
|
||||
fontSize={parsedFieldMeta?.fontSize}
|
||||
>
|
||||
{field.customText}
|
||||
</DocumentSigningFieldsInserted>
|
||||
)}
|
||||
|
||||
@@ -27,14 +27,20 @@ type DocumentSigningFieldsInsertedProps = {
|
||||
* Defaults to left.
|
||||
*/
|
||||
textAlign?: 'left' | 'center' | 'right';
|
||||
|
||||
/**
|
||||
* The font size of the field in pixels.
|
||||
*/
|
||||
fontSize?: number;
|
||||
};
|
||||
|
||||
export const DocumentSigningFieldsInserted = ({
|
||||
children,
|
||||
textAlign = 'left',
|
||||
fontSize,
|
||||
}: DocumentSigningFieldsInsertedProps) => {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center overflow-hidden">
|
||||
<div className="flex h-full w-full items-center overflow-visible">
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
@@ -43,6 +49,7 @@ export const DocumentSigningFieldsInserted = ({
|
||||
'!text-right': textAlign === 'right',
|
||||
},
|
||||
)}
|
||||
style={{ fontSize: `${fontSize}px` }}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
|
||||
+4
-1
@@ -139,7 +139,10 @@ export const DocumentSigningInitialsField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||
<DocumentSigningFieldsInserted
|
||||
textAlign={parsedFieldMeta?.textAlign}
|
||||
fontSize={parsedFieldMeta?.fontSize}
|
||||
>
|
||||
{field.customText}
|
||||
</DocumentSigningFieldsInserted>
|
||||
)}
|
||||
|
||||
@@ -178,7 +178,10 @@ export const DocumentSigningNameField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||
<DocumentSigningFieldsInserted
|
||||
textAlign={parsedFieldMeta?.textAlign}
|
||||
fontSize={parsedFieldMeta?.fontSize}
|
||||
>
|
||||
{field.customText}
|
||||
</DocumentSigningFieldsInserted>
|
||||
)}
|
||||
|
||||
+4
-1
@@ -253,7 +253,10 @@ export const DocumentSigningNumberField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||
<DocumentSigningFieldsInserted
|
||||
textAlign={parsedFieldMeta?.textAlign}
|
||||
fontSize={parsedFieldMeta?.fontSize}
|
||||
>
|
||||
{field.customText}
|
||||
</DocumentSigningFieldsInserted>
|
||||
)}
|
||||
|
||||
@@ -160,6 +160,14 @@ export const DocumentSigningPageView = ({
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||
{document.team.teamGlobalSettings.brandingEnabled &&
|
||||
document.team.teamGlobalSettings.brandingLogo && (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${document.teamId}`}
|
||||
alt={`${document.team.name}'s Logo`}
|
||||
className="mb-4 h-12 w-12 md:mb-2"
|
||||
/>
|
||||
)}
|
||||
<h1
|
||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
|
||||
@@ -250,7 +250,10 @@ export const DocumentSigningTextField = ({
|
||||
)}
|
||||
|
||||
{field.inserted && (
|
||||
<DocumentSigningFieldsInserted textAlign={parsedFieldMeta?.textAlign}>
|
||||
<DocumentSigningFieldsInserted
|
||||
textAlign={parsedFieldMeta?.textAlign}
|
||||
fontSize={parsedFieldMeta?.fontSize}
|
||||
>
|
||||
{field.customText}
|
||||
</DocumentSigningFieldsInserted>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { Link, useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -108,15 +108,51 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
if (!fileRejections.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||
const { file, errors } = fileRejections[0];
|
||||
|
||||
if (!errors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorNodes = errors.map((error, index) => (
|
||||
<span key={index} className="block">
|
||||
{match(error.code)
|
||||
.with(ErrorCode.FileTooLarge, () => (
|
||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
||||
))
|
||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
||||
.with(ErrorCode.TooManyFiles, () => (
|
||||
<Trans>Only one file can be uploaded at a time</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Unknown error</Trans>
|
||||
))}
|
||||
</span>
|
||||
));
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<span className="font-medium">
|
||||
{file.name} <Trans>couldn't be uploaded:</Trans>
|
||||
</span>
|
||||
{errorNodes}
|
||||
</>
|
||||
);
|
||||
|
||||
toast({
|
||||
title: _(msg`Your document failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
title: _(msg`Upload failed`),
|
||||
description,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
@@ -129,8 +165,8 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
void onFileDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
onDropRejected: () => {
|
||||
void onFileDropRejected();
|
||||
onDropRejected: (fileRejections) => {
|
||||
onFileDropRejected(fileRejections);
|
||||
},
|
||||
noClick: true,
|
||||
noDragEventsBubbling: true,
|
||||
|
||||
@@ -4,8 +4,9 @@ import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { ErrorCode, type FileRejection, useDropzone } from 'react-dropzone';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
@@ -67,10 +68,47 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
const onFileDropRejected = (fileRejections: FileRejection[]) => {
|
||||
if (!fileRejections.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Since users can only upload only one file (no multi-upload), we only handle the first file rejection
|
||||
const { file, errors } = fileRejections[0];
|
||||
|
||||
if (!errors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorNodes = errors.map((error, index) => (
|
||||
<span key={index} className="block">
|
||||
{match(error.code)
|
||||
.with(ErrorCode.FileTooLarge, () => (
|
||||
<Trans>File is larger than {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB</Trans>
|
||||
))
|
||||
.with(ErrorCode.FileInvalidType, () => <Trans>Only PDF files are allowed</Trans>)
|
||||
.with(ErrorCode.FileTooSmall, () => <Trans>File is too small</Trans>)
|
||||
.with(ErrorCode.TooManyFiles, () => (
|
||||
<Trans>Only one file can be uploaded at a time</Trans>
|
||||
))
|
||||
.otherwise(() => (
|
||||
<Trans>Unknown error</Trans>
|
||||
))}
|
||||
</span>
|
||||
));
|
||||
|
||||
const description = (
|
||||
<>
|
||||
<span className="font-medium">
|
||||
{file.name} <Trans>couldn't be uploaded:</Trans>
|
||||
</span>
|
||||
{errorNodes}
|
||||
</>
|
||||
);
|
||||
|
||||
toast({
|
||||
title: _(msg`Your template failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
title: _(msg`Upload failed`),
|
||||
description,
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
@@ -88,8 +126,8 @@ export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZon
|
||||
void onFileDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
onDropRejected: () => {
|
||||
void onFileDropRejected();
|
||||
onDropRejected: (fileRejections) => {
|
||||
onFileDropRejected(fileRejections);
|
||||
},
|
||||
noClick: true,
|
||||
noDragEventsBubbling: true,
|
||||
|
||||
@@ -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(() => {
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
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]>[];
|
||||
}, [organisation]);
|
||||
|
||||
|
||||
@@ -101,5 +101,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.12.5"
|
||||
"version": "1.12.7"
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.5",
|
||||
"version": "1.12.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "1.12.5",
|
||||
"version": "1.12.7",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
@@ -89,7 +89,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "1.12.5",
|
||||
"version": "1.12.7",
|
||||
"dependencies": {
|
||||
"@documenso/api": "*",
|
||||
"@documenso/assets": "*",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "1.12.5",
|
||||
"version": "1.12.7",
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --filter=@documenso/remix",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -30,8 +30,8 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Australia/Perth' }).click();
|
||||
|
||||
// Set default date
|
||||
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm a' }).click();
|
||||
await page.getByRole('option', { name: 'DD/MM/YYYY' }).click();
|
||||
await page.getByRole('combobox').filter({ hasText: 'yyyy-MM-dd hh:mm AM/PM' }).click();
|
||||
await page.getByRole('option', { name: 'DD/MM/YYYY', exact: true }).click();
|
||||
|
||||
await page.getByTestId('signature-types-trigger').click();
|
||||
await page.getByRole('option', { name: 'Draw' }).click();
|
||||
@@ -51,7 +51,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(teamSettings.documentVisibility).toEqual(DocumentVisibility.MANAGER_AND_ABOVE);
|
||||
expect(teamSettings.documentLanguage).toEqual('de');
|
||||
expect(teamSettings.documentTimezone).toEqual('Australia/Perth');
|
||||
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(teamSettings.documentDateFormat).toEqual('dd/MM/yyyy');
|
||||
expect(teamSettings.includeSenderDetails).toEqual(false);
|
||||
expect(teamSettings.includeSigningCertificate).toEqual(false);
|
||||
expect(teamSettings.typedSignatureEnabled).toEqual(true);
|
||||
@@ -72,7 +72,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
|
||||
// Override team date format settings
|
||||
await page.getByTestId('document-date-format-trigger').click();
|
||||
await page.getByRole('option', { name: 'MM/DD/YYYY' }).click();
|
||||
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
@@ -85,7 +85,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(updatedTeamSettings.documentVisibility).toEqual(DocumentVisibility.EVERYONE);
|
||||
expect(updatedTeamSettings.documentLanguage).toEqual('pl');
|
||||
expect(updatedTeamSettings.documentTimezone).toEqual('Europe/London');
|
||||
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy hh:mm a');
|
||||
expect(updatedTeamSettings.documentDateFormat).toEqual('MM/dd/yyyy');
|
||||
expect(updatedTeamSettings.includeSenderDetails).toEqual(false);
|
||||
expect(updatedTeamSettings.includeSigningCertificate).toEqual(false);
|
||||
expect(updatedTeamSettings.typedSignatureEnabled).toEqual(true);
|
||||
@@ -108,7 +108,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
expect(documentMeta.drawSignatureEnabled).toEqual(false);
|
||||
expect(documentMeta.language).toEqual('pl');
|
||||
expect(documentMeta.timezone).toEqual('Europe/London');
|
||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy hh:mm a');
|
||||
expect(documentMeta.dateFormat).toEqual('MM/dd/yyyy');
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
|
||||
@@ -47,8 +47,8 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
@@ -96,7 +96,7 @@ test('[TEMPLATE]: should create a document from a template', async ({ page }) =>
|
||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
|
||||
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
@@ -150,8 +150,8 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
|
||||
// Set advanced options.
|
||||
await page.getByRole('button', { name: 'Advanced Options' }).click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD HH:mm a' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY').click();
|
||||
await page.locator('button').filter({ hasText: 'YYYY-MM-DD hh:mm AM/PM' }).click();
|
||||
await page.getByLabel('DD/MM/YYYY HH:mm', { exact: true }).click();
|
||||
|
||||
await page.locator('.time-zone-field').click();
|
||||
await page.getByRole('option', { name: 'Etc/UTC' }).click();
|
||||
@@ -200,7 +200,7 @@ test('[TEMPLATE]: should create a team document from a team template', async ({
|
||||
|
||||
expect(document.title).toEqual('TEMPLATE_TITLE');
|
||||
expect(documentAuth.documentAuthOption.globalAccessAuth).toContain('ACCOUNT');
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy hh:mm a');
|
||||
expect(document.documentMeta?.dateFormat).toEqual('dd/MM/yyyy HH:mm');
|
||||
expect(document.documentMeta?.message).toEqual('MESSAGE');
|
||||
expect(document.documentMeta?.redirectUrl).toEqual('https://documenso.com');
|
||||
expect(document.documentMeta?.subject).toEqual('SUBJECT');
|
||||
|
||||
@@ -48,7 +48,7 @@ export default defineConfig({
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
viewport: { width: 1920, height: 1200 },
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -7,14 +7,25 @@ export const DEFAULT_DOCUMENT_DATE_FORMAT = 'yyyy-MM-dd hh:mm a';
|
||||
export const VALID_DATE_FORMAT_VALUES = [
|
||||
DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
'yyyy-MM-dd',
|
||||
'dd/MM/yyyy',
|
||||
'MM/dd/yyyy',
|
||||
'yy-MM-dd',
|
||||
'MMMM dd, yyyy',
|
||||
'EEEE, MMMM dd, yyyy',
|
||||
'dd/MM/yyyy hh:mm a',
|
||||
'dd/MM/yyyy HH:mm',
|
||||
'MM/dd/yyyy hh:mm a',
|
||||
'MM/dd/yyyy HH:mm',
|
||||
'dd.MM.yyyy',
|
||||
'dd.MM.yyyy HH:mm',
|
||||
'yyyy-MM-dd HH:mm',
|
||||
'yy-MM-dd hh:mm a',
|
||||
'yy-MM-dd HH:mm',
|
||||
'yyyy-MM-dd HH:mm:ss',
|
||||
'MMMM dd, yyyy hh:mm a',
|
||||
'MMMM dd, yyyy HH:mm',
|
||||
'EEEE, MMMM dd, yyyy hh:mm a',
|
||||
'EEEE, MMMM dd, yyyy HH:mm',
|
||||
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
] as const;
|
||||
|
||||
@@ -22,10 +33,80 @@ export type ValidDateFormat = (typeof VALID_DATE_FORMAT_VALUES)[number];
|
||||
|
||||
export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'yyyy-MM-dd_hh:mm_a',
|
||||
label: 'YYYY-MM-DD HH:mm a',
|
||||
key: 'yyyy-MM-dd_HH:mm_12H',
|
||||
label: 'YYYY-MM-DD hh:mm AM/PM',
|
||||
value: DEFAULT_DOCUMENT_DATE_FORMAT,
|
||||
},
|
||||
{
|
||||
key: 'yyyy-MM-dd_HH:mm',
|
||||
label: 'YYYY-MM-DD HH:mm',
|
||||
value: 'yyyy-MM-dd HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYY_TIME',
|
||||
label: 'DD/MM/YYYY HH:mm',
|
||||
value: 'dd/MM/yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYY_TIME_12H',
|
||||
label: 'DD/MM/YYYY HH:mm AM/PM',
|
||||
value: 'dd/MM/yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'MMDDYYYY_TIME',
|
||||
label: 'MM/DD/YYYY HH:mm',
|
||||
value: 'MM/dd/yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'MMDDYYYY_TIME_12H',
|
||||
label: 'MM/DD/YYYY HH:mm AM/PM',
|
||||
value: 'MM/dd/yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYYHHMM',
|
||||
label: 'DD.MM.YYYY HH:mm',
|
||||
value: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'YYMMDD_TIME',
|
||||
label: 'YY-MM-DD HH:mm',
|
||||
value: 'yy-MM-dd HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'YYMMDD_TIME_12H',
|
||||
label: 'YY-MM-DD HH:mm AM/PM',
|
||||
value: 'yy-MM-dd hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'YYYY_MM_DD_HH_MM_SS',
|
||||
label: 'YYYY-MM-DD HH:mm:ss',
|
||||
value: 'yyyy-MM-dd HH:mm:ss',
|
||||
},
|
||||
{
|
||||
key: 'MonthDateYear_TIME',
|
||||
label: 'Month Date, Year HH:mm',
|
||||
value: 'MMMM dd, yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'MonthDateYear_TIME_12H',
|
||||
label: 'Month Date, Year HH:mm AM/PM',
|
||||
value: 'MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'DayMonthYear_TIME',
|
||||
label: 'Day, Month Year HH:mm',
|
||||
value: 'EEEE, MMMM dd, yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'DayMonthYear_TIME_12H',
|
||||
label: 'Day, Month Year HH:mm AM/PM',
|
||||
value: 'EEEE, MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'ISO8601',
|
||||
label: 'ISO 8601',
|
||||
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDD',
|
||||
label: 'YYYY-MM-DD',
|
||||
@@ -34,47 +115,32 @@ export const DATE_FORMATS = [
|
||||
{
|
||||
key: 'DDMMYYYY',
|
||||
label: 'DD/MM/YYYY',
|
||||
value: 'dd/MM/yyyy hh:mm a',
|
||||
value: 'dd/MM/yyyy',
|
||||
},
|
||||
{
|
||||
key: 'MMDDYYYY',
|
||||
label: 'MM/DD/YYYY',
|
||||
value: 'MM/dd/yyyy hh:mm a',
|
||||
value: 'MM/dd/yyyy',
|
||||
},
|
||||
{
|
||||
key: 'DDMMYYYYHHMM',
|
||||
label: 'DD.MM.YYYY HH:mm',
|
||||
value: 'dd.MM.yyyy HH:mm',
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDDHHmm',
|
||||
label: 'YYYY-MM-DD HH:mm',
|
||||
value: 'yyyy-MM-dd HH:mm',
|
||||
key: 'DDMMYYYY_DOT',
|
||||
label: 'DD.MM.YYYY',
|
||||
value: 'dd.MM.yyyy',
|
||||
},
|
||||
{
|
||||
key: 'YYMMDD',
|
||||
label: 'YY-MM-DD',
|
||||
value: 'yy-MM-dd hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'YYYYMMDDhhmmss',
|
||||
label: 'YYYY-MM-DD HH:mm:ss',
|
||||
value: 'yyyy-MM-dd HH:mm:ss',
|
||||
value: 'yy-MM-dd',
|
||||
},
|
||||
{
|
||||
key: 'MonthDateYear',
|
||||
label: 'Month Date, Year',
|
||||
value: 'MMMM dd, yyyy hh:mm a',
|
||||
value: 'MMMM dd, yyyy',
|
||||
},
|
||||
{
|
||||
key: 'DayMonthYear',
|
||||
label: 'Day, Month Year',
|
||||
value: 'EEEE, MMMM dd, yyyy hh:mm a',
|
||||
},
|
||||
{
|
||||
key: 'ISO8601',
|
||||
label: 'ISO 8601',
|
||||
value: "yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
|
||||
value: 'EEEE, MMMM dd, yyyy',
|
||||
},
|
||||
] satisfies {
|
||||
key: string;
|
||||
|
||||
@@ -91,6 +91,12 @@ export const getDocumentAndSenderByToken = async ({
|
||||
select: {
|
||||
name: true,
|
||||
teamEmail: true,
|
||||
teamGlobalSettings: {
|
||||
select: {
|
||||
brandingEnabled: true,
|
||||
brandingLogo: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 { getAdminOrganisationRoute } from './get-admin-organisation';
|
||||
import { getUserRoute } from './get-user';
|
||||
import { promoteMemberToOwnerRoute } from './promote-member-to-owner';
|
||||
import { resealDocumentRoute } from './reseal-document';
|
||||
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
|
||||
import { updateAdminOrganisationRoute } from './update-admin-organisation';
|
||||
@@ -27,6 +28,9 @@ export const adminRouter = router({
|
||||
create: createAdminOrganisationRoute,
|
||||
update: updateAdminOrganisationRoute,
|
||||
},
|
||||
organisationMember: {
|
||||
promoteToOwner: promoteMemberToOwnerRoute,
|
||||
},
|
||||
claims: {
|
||||
find: findSubscriptionClaimsRoute,
|
||||
create: createSubscriptionClaimRoute,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
@@ -104,6 +106,19 @@ export const updateOrganisationSettingsRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
const isPersonalOrganisation = organisation.type === OrganisationType.PERSONAL;
|
||||
const currentIncludeSenderDetails =
|
||||
organisation.organisationGlobalSettings.includeSenderDetails;
|
||||
|
||||
const isChangingIncludeSenderDetails =
|
||||
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
|
||||
|
||||
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Personal organisations cannot update the sender details',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisationId,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/teams';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { buildTeamWhereQuery } from '@documenso/lib/utils/teams';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -97,6 +100,35 @@ export const updateTeamSettingsRoute = authenticatedProcedure
|
||||
}
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId: team.organisationId,
|
||||
userId: user.id,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['MANAGE_ORGANISATION'],
|
||||
}),
|
||||
select: {
|
||||
type: true,
|
||||
organisationGlobalSettings: {
|
||||
select: {
|
||||
includeSenderDetails: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const isPersonalOrganisation = organisation?.type === OrganisationType.PERSONAL;
|
||||
const currentIncludeSenderDetails =
|
||||
organisation?.organisationGlobalSettings.includeSenderDetails;
|
||||
|
||||
const isChangingIncludeSenderDetails =
|
||||
includeSenderDetails !== undefined && includeSenderDetails !== currentIncludeSenderDetails;
|
||||
|
||||
if (isPersonalOrganisation && isChangingIncludeSenderDetails) {
|
||||
throw new AppError(AppErrorCode.INVALID_BODY, {
|
||||
message: 'Personal teams cannot update the sender details',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
|
||||
@@ -39,7 +39,9 @@ export interface BadgeProps
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
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 };
|
||||
|
||||
@@ -755,7 +755,6 @@ export const AddSignersFormPartial = ({
|
||||
handleRecipientAutoCompleteSelect(index, suggestion)
|
||||
}
|
||||
onSearchQueryChange={(query) => {
|
||||
console.log('onSearchQueryChange', query);
|
||||
field.onChange(query);
|
||||
setRecipientSearchQuery(query);
|
||||
}}
|
||||
|
||||
@@ -188,9 +188,11 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
|
||||
}
|
||||
|
||||
const textAlign = fieldMeta && 'textAlign' in fieldMeta ? fieldMeta.textAlign : 'left';
|
||||
const fontSize = fieldMeta && 'fontSize' in fieldMeta ? fieldMeta.fontSize : undefined;
|
||||
const fontSizeStyle = fontSize ? { fontSize: `${fontSize}px` } : {};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center overflow-hidden">
|
||||
<div className="overflow-visibile flex h-full w-full items-center">
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.07rem,25cqw,0.825rem)] duration-200',
|
||||
@@ -200,6 +202,7 @@ export const FieldContent = ({ field, documentMeta }: FieldIconProps) => {
|
||||
'font-signature text-[clamp(0.07rem,25cqw,1.125rem)]': isSignatureField,
|
||||
},
|
||||
)}
|
||||
style={fontSizeStyle}
|
||||
>
|
||||
{textToDisplay || labelToDisplay}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user