Compare commits

..

44 Commits

Author SHA1 Message Date
Lucas Smith df678d7d69 v2.3.0 2025-12-17 22:10:47 +11:00
Lucas Smith 6739242554 fix: use cpu for skia-canvas rendering (#2334)
Seems there's a memory leak in gpu rendering with skia canvas
where contexts can live for much longer than expected escaping gc
cleanup

CPU rendering seems better albeit a bit slower.

Synthetic tests were ran with `--expose-gc` to simulate load over time.
2025-12-17 14:48:21 +11:00
Konrad a5e5eecf8b fix: mark links for translation (#2333) 2025-12-17 12:02:12 +11:00
Lucas Smith b0248c20eb v2.2.8 2025-12-16 16:04:07 +11:00
Lucas Smith f129968968 fix: ensure PDF form appearance streams have required /Subtype /Form entry (#2328)
When flattening PDF forms, some appearance streams lack the required
/Subtype /Form dictionary entry needed when used as XObjects. This
causes
corruption in Adobe Reader which fails to render these flattened fields.

Per PDF spec, Form XObject streams require:
- /Subtype /Form (required)
- /FormType 1 (optional)

The normalizeAppearanceStream function ensures these entries exist
before
adding appearance streams as XObjects to the page content stream.

Fixes rendering issues where flattened fields don't display in PDF
viewers.
2025-12-16 16:00:11 +11:00
Lucas Smith c5c87e3fd1 v2.2.7 2025-12-16 12:38:53 +11:00
Lucas Smith 24a74c7b57 chore: add translations (#2321)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-12-16 12:06:58 +11:00
Lucas Smith f0a5a7e816 feat: prefill typed signature with user's full name (#2324)
Add fullName prop to signature pad components to automatically populate
typed signature
field with signer's name. Updates signature dialog, type component, and
all signing forms
across embed, document, template, and envelope flows to pass through the
user's full
name for better user experience.
2025-12-16 12:06:04 +11:00
Catalin Pit 8462cd13fd fix: assignment operator for directRecipientName (#2323) 2025-12-16 12:04:19 +11:00
Lucas Smith 576846de32 fix: fallback for certficate sent date when using link distribution (#2316) 2025-12-16 11:40:16 +11:00
Lucas Smith 06071ea035 fix: memory leak in PDF to images conversion (#2325)
Add proper cleanup for PDF.js pages and loading task to prevent memory
leaks when
processing multiple PDF pages. Ensure page cleanup is called after each
page is
rendered and both PDF document and loading task are properly destroyed
with error
handling.
2025-12-16 11:34:30 +11:00
dzhou777 b45a2691ba fix: Unhide text field scrollbar (#2277) 2025-12-15 15:52:39 +11:00
Ted Liang f31cc575d0 fix: white-label for next-button, progress-bar, and steps (#2319) 2025-12-15 15:51:11 +11:00
github-actions[bot] 05d7015ef0 chore: extract translations (#2320) 2025-12-15 13:06:08 +11:00
Chenyang Gao 2ca5d6cfaa fix: local job retry loop for webhook calls (#2295) 2025-12-15 13:04:35 +11:00
Ryan Wagoner 04814ca14e fix: on error job should resubmit with isRetry (#2072) 2025-12-15 13:03:04 +11:00
Ryan Wagoner dd1dccdb6a fix: organisation invite member should be case insensitive (#2068) 2025-12-15 12:50:27 +11:00
Valentin Cocaud df4316ac5c fix: log unknown errors in the auth error handler (#2014) 2025-12-15 12:44:03 +11:00
Catalin Pit 02f1264eea feat: unlink documents from deleted organization (#2006) 2025-12-15 12:17:13 +11:00
github-actions[bot] 928edb8645 chore: extract translations (#2302) 2025-12-15 12:11:55 +11:00
Konrad 54b0e4964e chore(i18n): improve punctuation (#2307) 2025-12-15 12:00:51 +11:00
Konrad 68e6ccdd19 fix(i18n): mark sr-only strings for translation (#2309) 2025-12-15 11:51:02 +11:00
Konrad 09ab7e9a09 fix(i18n): mark "(Optional)" strings for translation (#2310) 2025-12-15 11:50:06 +11:00
Konrad 3bb0777914 fix(i18n): mark field content for translation (#2306) 2025-12-15 11:49:23 +11:00
Catalin Pit 4d6389e901 fix(api): replace generic errors with AppError in getApiTokenByToken (#2315) 2025-12-15 11:47:38 +11:00
Vincent Vu 51e3d5030d fix(security): CVE-2025-55184, CVE-2025-55183 (#2314) 2025-12-12 16:50:00 +11:00
David Nguyen 0cebdec637 fix: remove legacy envelope uploads (#2303) 2025-12-11 14:09:38 +11:00
Lucas Smith 43486d8448 v2.2.6 2025-12-09 21:11:01 +11:00
Lucas Smith 4d3d1b8d14 fix: make ai features more discoverable (#2305)
Previously you had to have explicit knowledge of the
feature and enable it in order to use AI assisted field
detection.

This surfaces it by having a secondary dialog prompting
for enablement.

Also includes a fix for CC recipients not getting marked
as signed in weird edge cases.
2025-12-09 15:30:48 +11:00
David Nguyen 0387f3c20a chore: add missing dropdown image (#2304)
## Description

Add missing dropdown image in the docs.
2025-12-09 12:37:45 +11:00
Ted Liang c5032d0c43 refactor: extract image-helpers (#2261) 2025-12-09 09:19:49 +11:00
Konrad 3bd34964cd fix(i18n): add pluralization to ai features (#2301) 2025-12-09 09:18:38 +11:00
Dailson Allves fe93b11a2c chore: update existing pt-BR translations after commit #2289 (#2300) 2025-12-09 09:17:22 +11:00
github-actions[bot] 7638faf27b chore: extract translations (#2289)
Automated translation extraction

Co-authored-by: github-actions <github-actions@documenso.com>
2025-12-08 19:20:21 +11:00
Ephraim Duncan 8fca029d96 fix: invalidate sessions on password reset and update (#2076) 2025-12-08 19:17:23 +11:00
Lucas Smith bac2bf11f4 v2.2.5 2025-12-08 14:33:00 +11:00
Lucas Smith d93b2a70a7 fix: upgrade react-email/render (#2297)
Upgrade the `@react-email/render` package to handle
suspense during renders.

We could have just swapped to `renderAsync` for the 0.0.x
version of the package but it's better to upgrade as part
of this change.

CI has been run locally and emails have been verified to
work and render as expected in our local mail trap.
2025-12-08 13:08:34 +11:00
Lucas Smith 5da915da38 fix: update server only urls to use private internal web app url (#2290)
Replaced instances of NEXT_PUBLIC_WEBAPP_URL with
NEXT_PRIVATE_INTERNAL_WEBAPP_URL
2025-12-08 12:56:41 +11:00
Ted Liang dcaecf1fc5 feat: resource restriction in presign token (#2150) 2025-12-08 12:55:54 +11:00
Ephraim Duncan f70b76d8b8 feat: add envelope audit logs endpoint (#2232) 2025-12-08 12:34:03 +11:00
David Nguyen 93137c6396 fix: translation extraction job (#2288)
## Description

Workaround until we can commit directly to main for translation
extractions

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-06 16:19:35 +11:00
Catalin Pit d058b7c705 feat: include CC role in removed recipient email check (#2285) 2025-12-06 14:20:25 +11:00
David Nguyen b51f562224 feat: add empty emails for envelopes (#2267) 2025-12-06 13:38:10 +11:00
David Nguyen f80aa4bf72 chore: optimize tests (#2280) 2025-12-06 12:59:53 +11:00
186 changed files with 10088 additions and 3760 deletions
+8 -6
View File
@@ -1,14 +1,19 @@
name: Playwright Tests
on:
push:
branches: ['main', 'feat/rr7']
branches: ['main']
pull_request:
branches: ['main']
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
e2e_tests:
name: 'E2E Tests'
timeout-minutes: 60
runs-on: warp-ubuntu-2204-x64-16x
runs-on: warp-ubuntu-2204-x64-8x
steps:
- uses: actions/checkout@v4
@@ -28,9 +33,6 @@ jobs:
- name: Seed the database
run: npm run prisma:seed
- name: Build app
run: npm run build
- name: Install playwright browsers
run: npx playwright install --with-deps
@@ -45,7 +47,7 @@ jobs:
with:
name: test-results
path: 'packages/app-tests/**/test-results/*'
retention-days: 30
retention-days: 7
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
+45 -2
View File
@@ -17,6 +17,7 @@ jobs:
environment: Translations
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -26,12 +27,54 @@ jobs:
- name: Extract translations
run: npm run translate:extract
- name: Check and commit any files created
- name: Commit changes and push to reserved branch
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
BRANCH="chore/extract-translations"
git config --global user.name 'github-actions'
git config --global user.email 'github-actions@documenso.com'
git fetch origin
# Create branch locally (always reset to main)
git checkout -B "$BRANCH" origin/main
# Stage translation output
git add packages/lib/translations
git diff --staged --quiet --exit-code || (git commit -m "chore: extract translations" && git push)
# If no changes, exit early
if git diff --staged --quiet; then
echo "No translation changes found."
exit 0
fi
# Commit fresh snapshot
git commit -m "chore: extract translations"
# Force push reserved branch
git push origin "$BRANCH" --force
# Does a PR already exist?
EXISTING_PR=$(gh pr list \
--state open \
--head "$BRANCH" \
--json number \
--jq '.[0].number // empty')
if [ -z "$EXISTING_PR" ]; then
echo "No existing PR — creating new one."
gh pr create \
--title "chore: extract translations" \
--body "Automated translation extraction" \
--base main \
--head "$BRANCH"
else
echo "PR #$EXISTING_PR already exists — not creating a new one."
fi
- name: Compile translations
id: compile_translations
+2 -2
View File
@@ -15,7 +15,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "^15.5.7",
"next": "15.5.9",
"next-plausible": "^3.12.5",
"nextra": "^3",
"nextra-theme-docs": "^3",
@@ -29,4 +29,4 @@
"pagefind": "^1.2.0",
"typescript": "5.6.2"
}
}
}
@@ -178,7 +178,7 @@ The dropdown/select field collects a single choice from a list of options.
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
{/* ![The dropdown/select field in the Documenso document editor](/document-signing/dropdown-field-document-editor-view.webp) */}
![The dropdown/select field in the Documenso document editor](/document-signing/dropdown-field-document-editor-view.webp)
The dropdown/select field settings include:
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+2 -2
View File
@@ -12,11 +12,11 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "^15.5.7"
"next": "15.5.9"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "18.3.27",
"typescript": "5.6.2"
}
}
}
@@ -156,8 +156,8 @@ export const AdminOrganisationMemberUpdateDialog = ({
<DialogDescription className="mt-4">
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
You are currently updating <span className="font-bold">{organisationMemberName}</span>
.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useCurrentTeam } from '~/providers/team';
type AiFeaturesEnableDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onEnabled: () => void;
};
export const AiFeaturesEnableDialog = ({
open,
onOpenChange,
onEnabled,
}: AiFeaturesEnableDialogProps) => {
const { t } = useLingui();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
const [error, setError] = useState<string | null>(null);
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
trpc.team.settings.update.useMutation();
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
trpc.organisation.settings.update.useMutation();
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
const onEnableClick = async () => {
if (!canEnableAiFeatures) {
return;
}
setError(null);
try {
if (isTeamAdmin) {
await updateTeamSettings({
teamId: team.id,
data: { aiFeaturesEnabled: true },
});
} else {
await updateOrganisationSettings({
organisationId: organisation.id,
data: { aiFeaturesEnabled: true },
});
}
onEnabled();
onOpenChange(false);
} catch (err) {
console.error('Failed to enable AI features', err);
setError(
err instanceof Error
? err.message
: t`We couldn't enable AI features right now. Please try again.`,
);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
<Trans>Enable AI features</Trans>
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<Trans>
Turn on AI detection to automatically find recipients and fields in your documents. AI
providers do not retain your data for training.
</Trans>
</p>
<Alert variant="neutral">
<AlertDescription>
<Trans>
Your document content will be sent securely to our AI provider solely for detection
and will not be stored or used for training.
</Trans>
</AlertDescription>
</Alert>
{canEnableAiFeatures ? (
<p className="text-sm text-muted-foreground">
<Trans>
You're an admin. You can enable AI features for this team right away. Everyone on
the team will see AI detection once enabled.
</Trans>
</p>
) : (
<p className="text-sm text-muted-foreground">
<Trans>
AI features are disabled for your team. Please ask your team owner or organisation
owner to enable them.
</Trans>
</p>
)}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
<Trans>Close</Trans>
</Button>
{canEnableAiFeatures ? (
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
<Trans>Enable AI features</Trans>
</Button>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
@@ -232,10 +232,19 @@ export const AiFieldDetectionDialog = ({
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
{progress.fieldsDetected} field(s) found
</Trans>
<Plural
value={progress.fieldsDetected}
one={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # field found
</Trans>
}
other={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # fields found
</Trans>
}
/>
</p>
)}
@@ -279,7 +288,11 @@ export const AiFieldDetectionDialog = ({
) : (
<>
<p className="text-sm text-muted-foreground">
<Trans>We found {detectedFields.length} field(s) in your document.</Trans>
<Plural
value={detectedFields.length}
one="We found # field in your document."
other="We found # fields in your document."
/>
</p>
<ul className="mt-4 divide-y rounded-lg border">
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
@@ -190,10 +190,19 @@ export const AiRecipientDetectionDialog = ({
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
{progress.recipientsDetected} recipient(s) found
</Trans>
<Plural
value={progress.recipientsDetected}
one={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # recipient found
</Trans>
}
other={
<Trans>
Page {progress.pagesProcessed} of {progress.totalPages} - # recipients found
</Trans>
}
/>
</p>
)}
@@ -237,9 +246,11 @@ export const AiRecipientDetectionDialog = ({
) : (
<>
<p className="text-sm text-muted-foreground">
<Trans>
We found {detectedRecipients.length} recipient(s) in your document.
</Trans>
<Plural
value={detectedRecipients.length}
one="We found # recipient in your document."
other="We found # recipients in your document."
/>
</p>
<ul className="mt-4 divide-y rounded-lg border">
@@ -19,6 +19,7 @@ import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
@@ -129,18 +130,43 @@ export const EnvelopeDistributeDialog = ({
const distributionMethod = watch('meta.distributionMethod');
const recipientsWithIndex = useMemo(
() =>
envelope.recipients.map((recipient, index) => ({
...recipient,
index,
})),
[envelope.recipients],
);
const recipientsMissingSignatureFields = useMemo(
() =>
envelope.recipients.filter(
recipientsWithIndex.filter(
(recipient) =>
recipient.role === RecipientRole.SIGNER &&
!envelope.fields.some(
(field) => field.type === FieldType.SIGNATURE && field.recipientId === recipient.id,
),
),
[envelope.recipients, envelope.fields],
[recipientsWithIndex, envelope.fields],
);
/**
* List of recipients who must have an email due to having auth enabled.
*/
const recipientsMissingRequiredEmail = useMemo(() => {
return recipientsWithIndex.filter((recipient) => {
const auth = extractDocumentAuthMethods({
documentAuth: envelope.authOptions,
recipientAuth: recipient.authOptions,
});
return (
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
);
});
}, [recipientsWithIndex, envelope.authOptions]);
const invalidEnvelopeCode = useMemo(() => {
if (recipientsMissingSignatureFields.length > 0) {
return 'MISSING_SIGNATURES';
@@ -150,8 +176,12 @@ export const EnvelopeDistributeDialog = ({
return 'MISSING_RECIPIENTS';
}
if (recipientsMissingRequiredEmail.length > 0) {
return 'MISSING_REQUIRED_EMAIL';
}
return null;
}, [envelope.recipients, envelope.fields, recipientsMissingSignatureFields]);
}, [envelope.recipients, recipientsMissingRequiredEmail, recipientsMissingSignatureFields]);
const onFormSubmit = async ({ meta }: TEnvelopeDistributeFormSchema) => {
try {
@@ -323,8 +353,10 @@ export const EnvelopeDistributeDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply To Email</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
@@ -342,8 +374,10 @@ export const EnvelopeDistributeDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Subject</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Trans>
Subject{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
@@ -360,8 +394,10 @@ export const EnvelopeDistributeDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Trans>
Message{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger type="button">
<InfoIcon className="mx-2 h-4 w-4" />
@@ -444,7 +480,22 @@ export const EnvelopeDistributeDialog = ({
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>{recipient.email}</li>
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
))
.with('MISSING_REQUIRED_EMAIL', () => (
<AlertDescription>
<Trans>The following recipients require an email address:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
{recipientsMissingRequiredEmail.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
</li>
))}
</ul>
</AlertDescription>
@@ -117,7 +117,7 @@ export const EnvelopeItemDeleteDialog = ({
<DialogDescription className="mt-4">
<Trans>
You cannot delete this item because the document has been sent to recipients
You cannot delete this item because the document has been sent to recipients.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -121,7 +121,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
<Trans>
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
Once the DNS propagation is complete you will need to come back and press the "Sync"
domains button
domains button.
</Trans>
</AlertDescription>
</Alert>
@@ -148,8 +148,8 @@ export const OrganisationMemberUpdateDialog = ({
<DialogDescription className="mt-4">
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationMemberName}.</span>
You are currently updating <span className="font-bold">{organisationMemberName}</span>
.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -24,7 +24,10 @@ import {
import { Input } from '@documenso/ui/primitives/input';
const ZSignFieldEmailFormSchema = z.object({
email: z.string().min(1, { message: msg`Email is required`.id }),
email: z
.string()
.email()
.min(1, { message: msg`Email is required`.id }),
});
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
@@ -17,6 +17,7 @@ import { DocumentSigningDisclosure } from '../general/document-signing/document-
export type SignFieldSignatureDialogProps = {
initialSignature?: string;
fullName?: string;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
drawSignatureEnabled?: boolean;
@@ -28,6 +29,7 @@ export const SignFieldSignatureDialog = createCallable<
>(
({
call,
fullName,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
@@ -46,6 +48,7 @@ export const SignFieldSignatureDialog = createCallable<
</DialogHeader>
<SignaturePad
fullName={fullName}
value={localSignature ?? ''}
onChange={({ value }) => setLocalSignature(value)}
typedSignatureEnabled={typedSignatureEnabled}
@@ -8,6 +8,7 @@ import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
@@ -30,6 +31,13 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
@@ -53,26 +61,34 @@ export const TeamDeleteDialog = ({
const { toast } = useToast();
const { refreshSession } = useSession();
const currentOrganisation = useCurrentOrganisation();
const deleteMessage = _(msg`delete ${teamName}`);
const filteredTeams = currentOrganisation.teams.filter((team) => team.id !== teamId);
const ZDeleteTeamFormSchema = z.object({
teamName: z.literal(deleteMessage, {
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
}),
transferTeamId: z.string().optional(),
});
const form = useForm({
resolver: zodResolver(ZDeleteTeamFormSchema),
defaultValues: {
teamName: '',
transferTeamId: undefined,
},
});
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
const onFormSubmit = async () => {
const onFormSubmit = async (data: z.infer<typeof ZDeleteTeamFormSchema>) => {
try {
await deleteTeam({ teamId });
const transferTeamId = data.transferTeamId ? parseInt(data.transferTeamId, 10) : undefined;
await deleteTeam({ teamId, transferTeamId: transferTeamId || undefined });
await refreshSession();
@@ -168,6 +184,43 @@ export const TeamDeleteDialog = ({
)}
/>
{filteredTeams.length > 0 && (
<FormField
control={form.control}
name="transferTeamId"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Transfer documents to a different team</Trans>
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue
placeholder={_(msg`Don't transfer (Delete all documents)`)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="-1">
<Trans>Don't transfer (Delete all documents)</Trans>
</SelectItem>
{filteredTeams.map((team) => (
<SelectItem key={team.id} value={team.id.toString()}>
{team.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
@@ -146,7 +146,7 @@ export const TeamMemberUpdateDialog = ({
<DialogDescription>
<Trans>
You are currently updating <span className="font-bold">{memberName}.</span>
You are currently updating <span className="font-bold">{memberName}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,136 +0,0 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { FilePlus, Loader } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type TemplateCreateDialogProps = {
folderId?: string;
};
export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) => {
const navigate = useNavigate();
const { user } = useSession();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam();
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
const [isUploadingFile, setIsUploadingFile] = useState(false);
const onFileDrop = async (files: File[]) => {
const file = files[0];
if (isUploadingFile) {
return;
}
setIsUploadingFile(true);
try {
const payload = {
title: file.name,
folderId: folderId,
} satisfies TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createTemplate(formData);
toast({
title: _(msg`Template document uploaded`),
description: _(
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
setShowTemplateCreateDialog(false);
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
setIsUploadingFile(false);
}
};
return (
<Dialog
open={showTemplateCreateDialog}
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
>
<DialogTrigger asChild>
<Button className="cursor-pointer" disabled={!user.emailVerified}>
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
<Trans>Template (Legacy)</Trans>
</Button>
</DialogTrigger>
<DialogContent className="w-full max-w-xl">
<DialogHeader>
<DialogTitle>
<Trans>New Template</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Templates allow you to quickly generate documents with pre-filled recipients and
fields.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="relative">
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
{isUploadingFile && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isUploadingFile}>
<Trans>Close</Trans>
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -21,6 +21,7 @@ import {
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -65,7 +66,7 @@ const ZAddRecipientsForNewDocumentSchema = z.object({
recipients: z.array(
z.object({
id: z.number(),
email: z.string().email(),
email: ZRecipientEmailSchema,
name: z.string(),
signingOrder: z.number().optional(),
}),
@@ -100,12 +101,29 @@ export function TemplateUseDialog({
const [open, setOpen] = useState(false);
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: {
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
enabled: open,
},
);
const envelopeItems = response?.data ?? [];
const generateDefaultFormValues = () => {
return {
distributeDocument: false,
useCustomDocument: false,
customDocumentData: [],
customDocumentData: envelopeItems.map((item) => ({
title: item.title,
data: undefined,
envelopeItemId: item.id,
})),
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
@@ -124,7 +142,12 @@ export function TemplateUseDialog({
signingOrder: recipient.signingOrder ?? undefined,
};
}),
},
};
};
const form = useForm<TAddRecipientsForNewDocumentSchema>({
resolver: zodResolver(ZAddRecipientsForNewDocumentSchema),
defaultValues: generateDefaultFormValues(),
});
const { replace, fields: localCustomDocumentData } = useFieldArray({
@@ -132,19 +155,6 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { data: response, isLoading: isLoadingEnvelopeItems } = trpc.envelope.item.getMany.useQuery(
{
envelopeId,
},
{
placeholderData: (previousData) => previousData,
...SKIP_QUERY_BATCH_META,
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const envelopeItems = response?.data ?? [];
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
@@ -214,8 +224,8 @@ export function TemplateUseDialog({
});
useEffect(() => {
if (!open) {
form.reset();
if (open) {
form.reset(generateDefaultFormValues());
}
}, [open, form]);
@@ -322,7 +332,7 @@ export function TemplateUseDialog({
<Input
{...field}
aria-label="Name"
placeholder={recipients[index].name || _(msg`Name`)}
placeholder={recipients[index].name || _(msg`Recipient ${index + 1}`)}
/>
</FormControl>
<FormMessage />
@@ -349,7 +359,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
@@ -358,7 +368,7 @@ export function TemplateUseDialog({
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
The document will be immediately sent to recipients if this
@@ -378,7 +388,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
@@ -386,7 +396,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Create the document as pending and ready to sign.
@@ -432,7 +442,7 @@ export function TemplateUseDialog({
}}
/>
<label
className="text-muted-foreground ml-2 flex items-center text-sm"
className="ml-2 flex items-center text-sm text-muted-foreground"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
@@ -440,7 +450,7 @@ export function TemplateUseDialog({
<TooltipTrigger type="button">
<InfoIcon className="mx-1 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-md space-y-2 p-4">
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Upload a custom document to use instead of the template's default
@@ -470,19 +480,19 @@ export function TemplateUseDialog({
<FormControl>
<div
key={item.id}
className="border-border bg-card hover:bg-accent/10 flex items-center gap-4 rounded-lg border p-4 transition-colors"
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/10"
>
<div className="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="min-w-0 flex-1">
<h4 className="text-foreground truncate text-sm font-medium">
<h4 className="truncate text-sm font-medium text-foreground">
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<p className="mt-0.5 text-xs text-muted-foreground">
{field.value ? (
<div>
<Trans>
@@ -219,9 +219,8 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
<FormDescription>
<Trans>
A secret that will be sent to your URL so you can verify that the request
has been sent by Documenso
has been sent by Documenso.
</Trans>
.
</FormDescription>
<FormMessage />
</FormItem>
@@ -5,6 +5,7 @@ import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
// Define the schema for configuration
@@ -55,7 +56,7 @@ export const ZConfigureTemplateEmbedFormSchema = ZConfigureEmbedFormSchema.exten
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.union([z.string().length(0), z.string().email('Invalid email address')]),
email: ZRecipientEmailSchema,
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -438,6 +438,7 @@ export const EmbedDirectTemplateClientPage = ({
className="mt-2"
disabled={isThrottled || isSubmitting}
disableAnimation
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
@@ -455,6 +455,7 @@ export const EmbedSignDocumentV1ClientPage = ({
className="mt-2"
disabled={isThrottled || isSubmitting}
disableAnimation
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={metadata?.typedSignatureEnabled}
@@ -319,6 +319,7 @@ export const MultiSignDocumentSigningView = ({
className="mt-2"
disabled={isSubmitting}
disableAnimation
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={
+2 -1
View File
@@ -110,7 +110,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
<Label htmlFor="email" className="text-muted-foreground">
<Trans>Email</Trans>
</Label>
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
<Input id="email" type="email" className="mt-2 bg-muted" value={user.email} disabled />
</div>
<FormField
@@ -124,6 +124,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
<FormControl>
<SignaturePadDialog
disabled={isSubmitting}
fullName={user.name ?? ''}
value={value}
onChange={(v) => onChange(v ?? '')}
/>
+2 -2
View File
@@ -244,11 +244,11 @@ export const SignInForm = ({
const errorMessage = match(error.code)
.with(
AuthenticationErrorCode.InvalidCredentials,
() => msg`The email or password provided is incorrect`,
() => msg`The email or password provided is incorrect.`,
)
.with(
AuthenticationErrorCode.InvalidTwoFactorCode,
() => msg`The two-factor authentication code provided is incorrect`,
() => msg`The two-factor authentication code provided is incorrect.`,
)
.otherwise(() => handleFallbackErrorMessages(error.code));
+1 -1
View File
@@ -131,7 +131,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
const errorMessage = match(error.code)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You do not have permission to create a token for this team`,
() => msg`You do not have permission to create a token for this team.`,
)
.otherwise(() => msg`Something went wrong. Please try again later.`);
@@ -56,13 +56,13 @@ export function AvatarWithRecipient({ recipient, documentStatus }: AvatarWithRec
/>
<div
className="text-muted-foreground text-sm"
className="text-sm text-muted-foreground"
title={
signingToken ? _(msg`Click to copy signing link for sending to recipient`) : undefined
}
>
<p>{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p>{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -417,6 +417,7 @@ export const DirectTemplateSigningForm = ({
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
fullName={fullName}
value={signature ?? ''}
onChange={(value) => setSignature(value)}
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
@@ -433,7 +434,7 @@ export const DirectTemplateSigningForm = ({
<div className="mt-4 flex gap-x-4">
<Button
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
size="lg"
variant="secondary"
disabled={isSubmitting}
@@ -57,12 +57,13 @@ export type DocumentSigningCompleteDialogProps = {
name: string;
email: string;
};
directTemplatePayload?: {
recipientPayload?: {
name: string;
email: string;
};
buttonSize?: 'sm' | 'lg';
position?: 'start' | 'end' | 'center';
disableNameInput?: boolean;
};
const ZNextSignerFormSchema = z.object({
@@ -89,10 +90,11 @@ export const DocumentSigningCompleteDialog = ({
recipient,
disabled = false,
allowDictateNextSigner = false,
directTemplatePayload,
recipientPayload,
defaultNextSigner,
buttonSize = 'lg',
position,
disableNameInput = false,
}: DocumentSigningCompleteDialogProps) => {
const { t } = useLingui();
@@ -113,11 +115,11 @@ export const DocumentSigningCompleteDialog = ({
},
});
const directRecipientForm = useForm<TDirectRecipientFormSchema>({
const recipientForm = useForm<TDirectRecipientFormSchema>({
resolver: zodResolver(ZDirectRecipientFormSchema),
defaultValues: {
name: directTemplatePayload?.name ?? '',
email: directTemplatePayload?.email ?? '',
name: recipientPayload?.name ?? '',
email: recipientPayload?.email ?? '',
},
});
@@ -145,16 +147,16 @@ export const DocumentSigningCompleteDialog = ({
const onFormSubmit = async (data: TNextSignerFormSchema) => {
try {
let directRecipient: { name: string; email: string } | undefined;
let recipientOverridePayload: { name: string; email: string } | undefined;
if (directTemplatePayload && !directTemplatePayload.email) {
const isFormValid = await directRecipientForm.trigger();
if (recipientPayload && !recipientPayload.email) {
const isFormValid = await recipientForm.trigger();
if (!isFormValid) {
return;
}
directRecipient = directRecipientForm.getValues();
recipientOverridePayload = recipientForm.getValues();
}
// Check if 2FA is required
@@ -168,7 +170,7 @@ export const DocumentSigningCompleteDialog = ({
? { name: data.name, email: data.email }
: undefined;
await onSignatureComplete(nextSigner, data.accessAuthOptions, directRecipient);
await onSignatureComplete(nextSigner, data.accessAuthOptions, recipientOverridePayload);
} catch (error) {
const err = AppError.parseError(error);
@@ -222,7 +224,7 @@ export const DocumentSigningCompleteDialog = ({
<Trans>Are you sure?</Trans>
</DialogTitle>
<DialogDescription>
<div className="text-muted-foreground max-w-[50ch]">
<div className="max-w-[50ch] text-muted-foreground">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<span className="inline-flex flex-wrap">
@@ -250,19 +252,19 @@ export const DocumentSigningCompleteDialog = ({
</DialogDescription>
</DialogHeader>
<div className="border-border bg-muted/50 rounded-lg border p-4 text-center">
<p className="text-muted-foreground text-sm font-medium">{documentTitle}</p>
<div className="rounded-lg border border-border bg-muted/50 p-4 text-center">
<p className="text-sm font-medium text-muted-foreground">{documentTitle}</p>
</div>
{!showTwoFactorForm && (
<>
<fieldset disabled={form.formState.isSubmitting} className="border-none p-0">
{directTemplatePayload && !directTemplatePayload.email && (
<Form {...directRecipientForm}>
{recipientPayload && !recipientPayload.email && (
<Form {...recipientForm}>
<div className="mb-4 flex flex-col gap-4">
<div className="flex flex-col gap-4 md:flex-row">
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
@@ -274,7 +276,7 @@ export const DocumentSigningCompleteDialog = ({
{...field}
className="mt-2"
placeholder={t`Enter your name`}
disabled={isNameLocked}
disabled={isNameLocked || disableNameInput}
/>
</FormControl>
@@ -284,7 +286,7 @@ export const DocumentSigningCompleteDialog = ({
/>
<FormField
control={directRecipientForm.control}
control={recipientForm.control}
name="email"
render={({ field }) => (
<FormItem className="flex-1">
@@ -280,6 +280,7 @@ export const DocumentSigningForm = ({
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
@@ -106,7 +106,7 @@ export const DocumentSigningMobileWidget = () => {
<motion.div
layout="size"
layoutId="document-signing-mobile-widget-progress-bar"
className="bg-documenso absolute inset-y-0 left-0"
className="bg-primary absolute inset-y-0 left-0"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
@@ -109,7 +109,7 @@ export const DocumentSigningPageViewV2 = () => {
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-documenso"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
}}
@@ -56,8 +56,11 @@ export const DocumentSigningSignatureField = ({
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState(2);
const { signature: providedSignature, setSignature: setProvidedSignature } =
useRequiredDocumentSigningContext();
const {
fullName,
signature: providedSignature,
setSignature: setProvidedSignature,
} = useRequiredDocumentSigningContext();
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
@@ -236,13 +239,13 @@ export const DocumentSigningSignatureField = ({
type="Signature"
>
{isLoading && (
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background">
<Loader className="h-5 w-5 animate-spin text-primary md:h-8 md:w-8" />
</div>
)}
{state === 'empty' && (
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
<p className="font-signature text-[clamp(0.575rem,25cqw,1.2rem)] text-xl text-muted-foreground duration-200 group-hover:text-primary group-hover:text-recipient-green">
<Trans>Signature</Trans>
</p>
)}
@@ -259,7 +262,7 @@ export const DocumentSigningSignatureField = ({
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
<p
ref={signatureRef}
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
className="w-full overflow-hidden break-all text-center font-signature leading-tight text-muted-foreground duration-200"
style={{ fontSize: `${fontSize}rem` }}
>
{signature?.typedSignature}
@@ -272,12 +275,13 @@ export const DocumentSigningSignatureField = ({
<DialogTitle>
<Trans>
Sign as {recipient.name}{' '}
<div className="text-muted-foreground h-5">({recipient.email})</div>
<div className="h-5 text-muted-foreground">({recipient.email})</div>
</Trans>
</DialogTitle>
<SignaturePad
className="mt-2"
fullName={fullName}
value={localSignature ?? ''}
onChange={({ value }) => setLocalSignature(value)}
typedSignatureEnabled={typedSignatureEnabled}
@@ -14,9 +14,10 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
import {
@@ -31,9 +32,13 @@ import { useCurrentTeam } from '~/providers/team';
export type DocumentUploadButtonLegacyProps = {
className?: string;
type: EnvelopeType;
};
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
export const DocumentUploadButtonLegacy = ({
className,
type,
}: DocumentUploadButtonLegacyProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { user } = useSession();
@@ -54,8 +59,18 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const disabledMessage = useMemo(() => {
if (!user.emailVerified) {
return msg`Verify your email to upload documents.`;
}
// No errors for templates.
if (type === EnvelopeType.TEMPLATE) {
return;
}
if (organisation.subscription && remaining.documents === 0) {
return msg`Document upload disabled due to unpaid invoices`;
}
@@ -64,11 +79,8 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
return msg`You have reached your document limit.`;
}
if (!user.emailVerified) {
return msg`Verify your email to upload documents.`;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [remaining.documents, user.emailVerified, team]);
}, [remaining.documents, user.emailVerified, team, type]);
const onFileDrop = async (file: File) => {
try {
@@ -80,44 +92,62 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
meta: {
timezone: userTimezone,
},
} satisfies TCreateDocumentPayloadSchema;
} satisfies TCreateDocumentPayloadSchema | TCreateTemplatePayloadSchema;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
const { envelopeId: id } = await createDocument(formData);
// Handle legacy document creation.
if (type === EnvelopeType.DOCUMENT) {
const { envelopeId: id } = await createDocument(formData);
void refreshLimits();
void refreshLimits();
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
toast({
title: _(msg`Document uploaded`),
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
toast({
title: _(msg`Document uploaded`),
description: _(msg`Your document has been uploaded successfully.`),
duration: 5000,
});
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
analytics.capture('App: Document Uploaded', {
userId: user.id,
documentId: id,
timestamp: new Date().toISOString(),
});
}
// Handle legacy template creation.
if (type === EnvelopeType.TEMPLATE) {
const { envelopeId: id } = await createTemplate(formData);
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
toast({
title: _(msg`Template document uploaded`),
description: _(
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
}
} catch (err) {
const error = AppError.parseError(err);
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => msg`You have reached the limit of the number of files per envelope`,
() => msg`You have reached the limit of the number of files per envelope.`,
)
.otherwise(() => msg`An error occurred while uploading your document.`);
@@ -149,17 +179,18 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
<div>
<DocumentUploadButtonPrimitive
loading={isLoading}
disabled={remaining.documents === 0 || !user.emailVerified}
disabled={disabledMessage !== undefined}
disabledMessage={disabledMessage}
onDrop={async (files) => onFileDrop(files[0])}
onDropRejected={onFileDropRejected}
type={EnvelopeType.DOCUMENT}
type={type}
internalVersion="1"
/>
</div>
</TooltipTrigger>
{team?.id === undefined &&
type === EnvelopeType.DOCUMENT &&
remaining.documents > 0 &&
Number.isFinite(remaining.documents) && (
<TooltipContent>
@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
import { FileTextIcon, SparklesIcon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { Link, useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual } from 'remeda';
import { match } from 'ts-pattern';
@@ -34,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
import { Button } from '@documenso/ui/primitives/button';
import { Separator } from '@documenso/ui/primitives/separator';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
@@ -81,6 +82,8 @@ export const EnvelopeEditorFieldsPage = () => {
const { _ } = useLingui();
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
const { revalidate } = useRevalidator();
const selectedField = useMemo(
() => structuredClone(editorFields.selectedField),
@@ -135,6 +138,22 @@ export const EnvelopeEditorFieldsPage = () => {
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
}, []);
const onDetectClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
}
setIsAiFieldDialogOpen(true);
};
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiFieldDialogOpen(true);
});
};
return (
<div className="relative flex h-full">
<div className="flex w-full flex-col overflow-y-auto">
@@ -230,34 +249,36 @@ export const EnvelopeEditorFieldsPage = () => {
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
/>
{team.preferences.aiFeaturesEnabled && (
<>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={() => setIsAiFieldDialogOpen(true)}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="mt-4 w-full"
onClick={onDetectClick}
disabled={envelope.status !== DocumentStatus.DRAFT}
title={
envelope.status !== DocumentStatus.DRAFT
? _(msg`You can only detect fields in draft envelopes`)
: undefined
}
>
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
<Trans>Detect with AI</Trans>
</Button>
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
</>
)}
<AiFieldDetectionDialog
open={isAiFieldDialogOpen}
onOpenChange={setIsAiFieldDialogOpen}
onComplete={onFieldDetectionComplete}
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</section>
{/* Field details section. */}
@@ -8,13 +8,13 @@ import {
type SensorAPI,
} from '@hello-pangea/dnd';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { plural } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import { useRevalidator, useSearchParams } from 'react-router';
import { isDeepEqual, prop, sortBy } from 'remeda';
import { z } from 'zod';
@@ -28,6 +28,7 @@ import {
ZRecipientActionAuthTypesSchema,
ZRecipientAuthOptionsSchema,
} from '@documenso/lib/types/document-auth';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { canRecipientBeModified as utilCanRecipientBeModified } from '@documenso/lib/utils/recipients';
import { trpc } from '@documenso/trpc/react';
@@ -62,6 +63,7 @@ import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
import { useCurrentTeam } from '~/providers/team';
@@ -70,10 +72,7 @@ const ZEnvelopeRecipientsForm = z.object({
z.object({
formId: z.string().min(1),
id: z.number().optional(),
email: z
.string()
.email({ message: msg`Invalid email`.id })
.min(1),
email: ZRecipientEmailSchema,
name: z.string(),
role: z.nativeEnum(RecipientRole),
signingOrder: z.number().optional(),
@@ -99,11 +98,19 @@ export const EnvelopeEditorRecipientForm = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
// AI recipient detection dialog state
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
const { revalidate } = useRevalidator();
const onAiDialogOpenChange = (open: boolean) => {
if (open && !team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
setIsAiDialogOpen(false);
return;
}
setIsAiDialogOpen(open);
if (!open && searchParams.get('ai') === 'true') {
@@ -120,6 +127,22 @@ export const EnvelopeEditorRecipientForm = () => {
}
};
const onDetectRecipientsClick = () => {
if (!team.preferences.aiFeaturesEnabled) {
setIsAiEnableDialogOpen(true);
return;
}
setIsAiDialogOpen(true);
};
const onAiFeaturesEnabled = () => {
void revalidate().then(() => {
setIsAiEnableDialogOpen(false);
setIsAiDialogOpen(true);
});
};
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
const initialId = useId();
@@ -228,12 +251,13 @@ export const EnvelopeEditorRecipientForm = () => {
keyName: 'nativeId',
});
const emptySigners = useCallback(
() => form.getValues('signers').filter((signer) => signer.email === ''),
[form],
const emptySignerIndex = watchedSigners.findIndex(
(signer) =>
!signer.name &&
!signer.email &&
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
);
const emptySignerIndex = watchedSigners.findIndex((signer) => !signer.name && !signer.email);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
);
@@ -331,8 +355,14 @@ export const EnvelopeEditorRecipientForm = () => {
});
toast({
title: t`Recipients added`,
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
title: plural(detectedRecipients.length, {
one: `Recipient added`,
other: `Recipients added`,
}),
description: plural(detectedRecipients.length, {
one: `# recipient have been added from AI detection.`,
other: `# recipients have been added from AI detection.`,
}),
});
};
@@ -558,21 +588,7 @@ export const EnvelopeEditorRecipientForm = () => {
return;
}
const formValueSigners = formValues.signers || [];
// Remove the last signer if it's empty.
const nonEmptyRecipients = formValueSigners.filter((signer, i) => {
if (i === formValueSigners.length - 1 && signer.email === '') {
return false;
}
return true;
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse({
...formValues,
signers: nonEmptyRecipients,
});
const validatedFormValues = ZEnvelopeRecipientsForm.safeParse(formValues);
if (!validatedFormValues.success) {
return;
@@ -641,25 +657,27 @@ export const EnvelopeEditorRecipientForm = () => {
</div>
<div className="flex flex-row items-center space-x-2">
{team.preferences.aiFeaturesEnabled && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={() => setIsAiDialogOpen(true)}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
type="button"
size="sm"
disabled={isSubmitting}
onClick={onDetectRecipientsClick}
>
<SparklesIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<TooltipContent>
{team.preferences.aiFeaturesEnabled ? (
<Trans>Detect recipients with AI</Trans>
</TooltipContent>
</Tooltip>
)}
) : (
<Trans>Enable AI detection</Trans>
)}
</TooltipContent>
</Tooltip>
<Button
variant="outline"
@@ -736,9 +754,7 @@ export const EnvelopeEditorRecipientForm = () => {
});
}
}}
disabled={
isSubmitting || hasDocumentBeenSent || emptySigners().length !== 0
}
disabled={isSubmitting || hasDocumentBeenSent}
/>
</FormControl>
@@ -924,7 +940,7 @@ export const EnvelopeEditorRecipientForm = () => {
})}
>
{!showAdvancedSettings && index === 0 && (
<FormLabel required>
<FormLabel>
<Trans>Email</Trans>
</FormLabel>
)}
@@ -978,7 +994,7 @@ export const EnvelopeEditorRecipientForm = () => {
<FormControl>
<RecipientAutoCompleteInput
type="text"
placeholder={t`Name`}
placeholder={t`Recipient ${index + 1}`}
{...field}
disabled={
snapshot.isDragging ||
@@ -1118,6 +1134,12 @@ export const EnvelopeEditorRecipientForm = () => {
envelopeId={envelope.id}
teamId={envelope.teamId}
/>
<AiFeaturesEnableDialog
open={isAiEnableDialogOpen}
onOpenChange={setIsAiEnableDialogOpen}
onEnabled={onAiFeaturesEnabled}
/>
</CardContent>
</Card>
);
@@ -687,8 +687,10 @@ export const EnvelopeEditorSettingsDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Reply To Email</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
<FormControl>
@@ -726,8 +728,9 @@ export const EnvelopeEditorSettingsDialog = ({
render={({ field }) => (
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>Message</Trans>{' '}
<span className="text-muted-foreground">(Optional)</span>
<Trans>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
@@ -175,7 +175,7 @@ export default function EnvelopeEditor() {
<motion.div
layout="size"
layoutId="document-flow-container-step"
className="absolute inset-y-0 left-0 bg-documenso"
className="absolute inset-y-0 left-0 bg-primary"
style={{
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
}}
@@ -1,5 +1,7 @@
import { useCallback, useState } from 'react';
import type { I18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Field, Recipient } from '@prisma/client';
@@ -39,8 +41,15 @@ export const EnvelopeRecipientSelector = ({
fields,
align = 'start',
}: EnvelopeRecipientSelectorProps) => {
const { i18n } = useLingui();
const [showRecipientsSelector, setShowRecipientsSelector] = useState(false);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
return (
<Popover open={showRecipientsSelector} onOpenChange={setShowRecipientsSelector}>
<PopoverTrigger asChild>
@@ -49,7 +58,7 @@ export const EnvelopeRecipientSelector = ({
variant="outline"
role="combobox"
className={cn(
'bg-background text-muted-foreground hover:text-foreground justify-between font-normal',
'justify-between bg-background font-normal text-muted-foreground hover:text-foreground',
getRecipientColorStyles(
Math.max(
recipients.findIndex((r) => r.id === selectedRecipient?.id),
@@ -59,16 +68,12 @@ export const EnvelopeRecipientSelector = ({
className,
)}
>
{selectedRecipient?.email && (
{selectedRecipient && (
<span className="flex-1 truncate text-left">
{selectedRecipient?.name} ({selectedRecipient?.email})
{getRecipientLabel(selectedRecipient)}
</span>
)}
{!selectedRecipient?.email && (
<span className="flex-1 truncate text-left">{selectedRecipient?.email}</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
@@ -105,7 +110,7 @@ export const EnvelopeRecipientSelectorCommand = ({
fields,
placeholder,
}: EnvelopeRecipientSelectorCommandProps) => {
const { t } = useLingui();
const { t, i18n } = useLingui();
const recipientsByRole = useCallback(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
@@ -154,6 +159,11 @@ export const EnvelopeRecipientSelectorCommand = ({
[fields, recipients],
);
const getRecipientLabel = useCallback(
(recipient: Recipient) => extractRecipientLabel(recipient, recipients, i18n),
[recipients],
);
return (
<Command
value={selectedRecipient ? selectedRecipient.id.toString() : undefined}
@@ -162,21 +172,21 @@ export const EnvelopeRecipientSelectorCommand = ({
<CommandInput placeholder={placeholder} />
<CommandEmpty>
<span className="text-muted-foreground inline-block px-4">
<span className="inline-block px-4 text-muted-foreground">
<Trans>No recipient matching this description was found.</Trans>
</span>
</CommandEmpty>
{recipientsByRoleToDisplay().map(([role, roleRecipients], roleIndex) => (
<CommandGroup key={roleIndex}>
<div className="text-muted-foreground mb-1 ml-2 mt-2 text-xs font-medium">
<div className="mb-1 ml-2 mt-2 text-xs font-medium text-muted-foreground">
{t(RECIPIENT_ROLES_DESCRIPTION[role].roleNamePlural)}
</div>
{roleRecipients.length === 0 && (
<div
key={`${role}-empty`}
className="text-muted-foreground/80 px-4 pb-4 pt-2.5 text-center text-xs"
className="px-4 pb-4 pt-2.5 text-center text-xs text-muted-foreground/80"
>
<Trans>No recipients with this role</Trans>
</div>
@@ -205,18 +215,12 @@ export const EnvelopeRecipientSelectorCommand = ({
}}
>
<span
className={cn('text-foreground/70 truncate', {
className={cn('truncate text-foreground/70', {
'text-foreground/80': recipient.id === selectedRecipient?.id,
'opacity-50': isRecipientDisabled(recipient.id),
})}
>
{recipient.name && (
<span title={`${recipient.name} (${recipient.email})`}>
{recipient.name} ({recipient.email})
</span>
)}
{!recipient.name && <span title={recipient.email}>{recipient.email}</span>}
{getRecipientLabel(recipient)}
</span>
<div className="ml-auto flex items-center justify-center">
@@ -234,7 +238,7 @@ export const EnvelopeRecipientSelectorCommand = ({
<Info className="z-50 ml-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>
This document has already been sent to this recipient. You can no longer
edit this recipient.
@@ -250,3 +254,22 @@ export const EnvelopeRecipientSelectorCommand = ({
</Command>
);
};
const extractRecipientLabel = (recipient: Recipient, recipients: Recipient[], i18n: I18n) => {
if (recipient.name && recipient.email) {
return `${recipient.name} (${recipient.email})`;
}
if (recipient.name) {
return recipient.name;
}
if (recipient.email) {
return recipient.email;
}
// Since objects are basically pointers we can use `indexOf` rather than `findIndex`
const index = recipients.indexOf(recipient);
return i18n._(msg`Recipient ${index + 1}`);
};
@@ -41,7 +41,7 @@ export default function EnvelopeSignerForm() {
if (recipient.role === RecipientRole.ASSISTANT) {
return (
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
<fieldset className="embed--DocumentWidgetForm rounded-2xl border-border sm:border sm:p-3 dark:bg-background">
<RadioGroup
className="gap-0 space-y-2 shadow-none sm:space-y-3"
value={selectedAssistantRecipient?.id?.toString()}
@@ -54,7 +54,7 @@ export default function EnvelopeSignerForm() {
.map((r) => (
<div
key={r.id}
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
@@ -69,15 +69,15 @@ export default function EnvelopeSignerForm() {
{r.name}
{r.id === recipient.id && (
<span className="text-muted-foreground ml-2">
<span className="ml-2 text-muted-foreground">
<Trans>(You)</Trans>
</span>
)}
</Label>
<p className="text-muted-foreground text-xs">{r.email}</p>
<p className="text-xs text-muted-foreground">{r.email}</p>
</div>
</div>
<div className="text-muted-foreground text-xs leading-[inherit]">
<div className="text-xs leading-[inherit] text-muted-foreground">
<Plural
value={assistantFields.filter((field) => field.recipientId === r.id).length}
one="# field"
@@ -103,7 +103,7 @@ export default function EnvelopeSignerForm() {
<Input
type="text"
id="full-name"
className="bg-background mt-2"
className="mt-2 bg-background"
value={fullName}
disabled={isNameLocked}
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
@@ -119,6 +119,7 @@ export default function EnvelopeSignerForm() {
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
fullName={fullName}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
@@ -374,6 +374,7 @@ export default function EnvelopeSignerPageRenderer() {
.with({ type: FieldType.SIGNATURE }, (field) => {
handleSignatureFieldClick({
field,
fullName,
signature,
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
@@ -80,12 +80,14 @@ export const EnvelopeSignerCompleteDialog = () => {
const handleOnCompleteClick = async (
nextSigner?: { name: string; email: string },
accessAuthOptions?: TRecipientAccessAuth,
recipientDetails?: { name: string; email: string },
) => {
try {
await completeDocument({
token: recipient.token,
documentId: mapSecondaryIdToDocumentId(envelope.secondaryId),
accessAuthOptions,
recipientOverride: recipientDetails,
...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}),
});
@@ -205,21 +207,30 @@ export const EnvelopeSignerCompleteDialog = () => {
}
};
const directTemplatePayload = useMemo(() => {
const recipientPayload = useMemo(() => {
if (!isDirectTemplate) {
return;
return {
name:
recipient.name ||
recipient.fields.find((field) => field.type === FieldType.NAME)?.customText ||
'',
email:
recipient.email ||
recipient.fields.find((field) => field.type === FieldType.EMAIL)?.customText ||
'',
};
}
return {
name: fullName,
email: email,
};
}, [email, fullName, isDirectTemplate]);
}, [email, fullName, isDirectTemplate, recipient.email, recipient.name, recipient.fields]);
return (
<DocumentSigningCompleteDialog
isSubmitting={isPending}
directTemplatePayload={directTemplatePayload}
recipientPayload={recipientPayload}
onSignatureComplete={
isDirectTemplate ? handleDirectTemplateCompleteClick : handleOnCompleteClick
}
@@ -230,6 +241,7 @@ export const EnvelopeSignerCompleteDialog = () => {
allowDictateNextSigner={Boolean(
nextRecipient && envelope.documentMeta.allowDictateNextSigner,
)}
disableNameInput={!isDirectTemplate && recipient.name !== ''}
defaultNextSigner={
nextRecipient ? { name: nextRecipient.name, email: nextRecipient.email } : undefined
}
@@ -123,14 +123,14 @@ export const EnvelopeDropZoneWrapper = ({
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope`,
() => t`You have reached the limit of the number of files per envelope.`,
)
.otherwise(() => t`An error occurred during upload.`);
@@ -126,14 +126,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
console.error(err);
const errorMessage = match(error.code)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
.with(
AppErrorCode.LIMIT_EXCEEDED,
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
)
.with(
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
() => t`You have reached the limit of the number of files per envelope`,
() => t`You have reached the limit of the number of files per envelope.`,
)
.otherwise(() => t`An error occurred while uploading your document.`);
@@ -15,7 +15,6 @@ import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
import { useCurrentTeam } from '~/providers/team';
@@ -70,7 +69,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div>
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div
className="text-muted-foreground hover:text-muted-foreground/80 flex flex-1 items-center text-sm font-medium"
className="flex flex-1 items-center text-sm font-medium text-muted-foreground hover:text-muted-foreground/80"
data-testid="folder-grid-breadcrumbs"
>
<Link to={formatRootPath()} className="flex items-center">
@@ -100,10 +99,9 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
<div className="flex gap-4 sm:flex-row sm:justify-end">
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
{type === FolderType.DOCUMENT ? (
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well.
) : (
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well.
{/* If you delete this, delete the component as well. */}
{organisation.organisationClaim.flags.allowLegacyEnvelopes && (
<DocumentUploadButtonLegacy type={type} />
)}
<FolderCreateDialog type={type} />
@@ -113,7 +111,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
{isPending ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="border-border bg-card h-full rounded-lg border px-4 py-5">
<div key={index} className="h-full rounded-lg border border-border bg-card px-4 py-5">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded" />
<div className="flex w-full items-center justify-between">
@@ -194,7 +192,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
{foldersData.folders.length > 12 && (
<div className="mt-2 flex items-center justify-center">
<Link
className="text-muted-foreground hover:text-foreground text-sm font-medium"
className="text-sm font-medium text-muted-foreground hover:text-foreground"
to={formatViewAllFoldersPath()}
>
View all folders
@@ -83,8 +83,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -107,8 +107,8 @@ export const StackAvatarsWithTooltip = ({
fallbackText={recipientAbbreviation(recipient)}
/>
<div>
<p className="text-muted-foreground text-sm">{recipient.email}</p>
<p className="text-muted-foreground/70 text-xs">
<p className="text-sm text-muted-foreground">{recipient.email || recipient.name}</p>
<p className="text-xs text-muted-foreground/70">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
</div>
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useTransition } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Role, Subscription } from '@prisma/client';
import { Edit, Loader } from 'lucide-react';
import { Link } from 'react-router';
@@ -82,7 +83,7 @@ export const AdminDashboardUsersTable = ({
<Button className="w-24" asChild>
<Link to={`/admin/users/${row.original.id}`}>
<Edit className="-ml-1 mr-2 h-4 w-4" />
Edit
<Trans>Edit</Trans>
</Link>
</Button>
);
@@ -82,7 +82,9 @@ export const OrganisationGroupsDataTable = () => {
cell: ({ row }) => (
<div className="flex justify-end space-x-2">
<Button asChild variant="outline">
<Link to={`/o/${organisation.url}/settings/groups/${row.original.id}`}>Manage</Link>
<Link to={`/o/${organisation.url}/settings/groups/${row.original.id}`}>
<Trans>Manage</Trans>
</Link>
</Button>
<OrganisationGroupDeleteDialog
+16 -2
View File
@@ -46,12 +46,16 @@ export async function loader({ request }: Route.LoaderArgs) {
const { getTheme } = await themeSessionResolver(request);
let lang: SupportedLanguageCodes = await langCookie.parse(request.headers.get('cookie') ?? '');
const cookieHeader = request.headers.get('cookie') ?? '';
let lang: SupportedLanguageCodes = await langCookie.parse(cookieHeader);
if (!APP_I18N_OPTIONS.supportedLangs.includes(lang)) {
lang = extractLocaleData({ headers: request.headers }).lang;
}
const disableAnimations = cookieHeader.includes('__disable_animations=true');
let organisations = null;
if (session.isAuthenticated) {
@@ -62,6 +66,7 @@ export async function loader({ request }: Route.LoaderArgs) {
{
lang,
theme: getTheme(),
disableAnimations,
session: session.isAuthenticated
? {
user: session.user,
@@ -92,7 +97,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export function LayoutContent({ children }: { children: React.ReactNode }) {
const { publicEnv, session, lang, ...data } = useLoaderData<typeof loader>() || {};
const { publicEnv, session, lang, disableAnimations, ...data } =
useLoaderData<typeof loader>() || {};
const [theme] = useTheme();
@@ -111,6 +117,14 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
<meta name="google" content="notranslate" />
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
{disableAnimations && (
<style
dangerouslySetInnerHTML={{
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
}}
/>
)}
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
<script>0</script>
</head>
@@ -1,7 +1,7 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType, SigningStatus } from '@prisma/client';
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
@@ -86,7 +86,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
)}
</div>
<div className="text-muted-foreground mt-4 text-sm">
<div className="mt-4 text-sm text-muted-foreground">
<div>
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
</div>
@@ -112,7 +112,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
disabled={envelope.recipients.some(
(recipient) =>
recipient.signingStatus !== SigningStatus.SIGNED &&
recipient.signingStatus !== SigningStatus.REJECTED,
recipient.signingStatus !== SigningStatus.REJECTED &&
recipient.role !== RecipientRole.CC,
)}
onClick={() => resealDocument({ id: envelope.id })}
>
@@ -120,7 +120,9 @@ export default function OrganisationSettingsTeamsPage() {
</div>
<Button asChild>
<Link to={`/o/${organisation.url}/settings`}>Manage Organisation</Link>
<Link to={`/o/${organisation.url}/settings`}>
<Trans>Manage Organisation</Trans>
</Link>
</Button>
</div>
@@ -178,7 +180,9 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreVerticalIcon className="h-4 w-4" />
<span className="sr-only">Open menu</span>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
@@ -87,9 +87,9 @@ export default function OrganisationSettingsBrandingPage() {
const settingsHeaderText = t`Branding Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general branding preferences`
? t`Here you can set your general branding preferences.`
: team
? t`Here you can set branding preferences for your team`
? t`Here you can set branding preferences for your team.`
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
return (
@@ -112,7 +112,7 @@ export default function OrganisationSettingsDocumentPage() {
const settingsHeaderText = t`Document Preferences`;
const settingsHeaderSubtitle = isPersonalLayoutMode
? t`Here you can set your general document preferences`
? t`Here you can set your general document preferences.`
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
return (
@@ -65,7 +65,7 @@ export default function OrganisationSettingsGeneral() {
<div className="max-w-2xl">
<SettingsHeader
title={t`Email Preferences`}
subtitle={t`You can manage your email preferences here`}
subtitle={t`You can manage your email preferences here.`}
/>
<section>
@@ -28,8 +28,6 @@ export const loader = () => {
export default function TeamsSettingsPage() {
const { isAiFeaturesConfigured } = useLoaderData<typeof loader>();
console.log('isAiFeaturesConfigured', isAiFeaturesConfigured);
const team = useCurrentTeam();
const { t } = useLingui();
@@ -63,7 +63,7 @@ export default function TeamEmailSettingsGeneral() {
<div className="max-w-2xl">
<SettingsHeader
title={t`Email Preferences`}
subtitle={t`You can manage your email preferences here`}
subtitle={t`You can manage your email preferences here.`}
/>
<section>
@@ -185,6 +185,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
(log) =>
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT
].filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT),
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
].filter(
@@ -245,11 +248,11 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
<div className="break-all">{recipient.email}</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
@@ -273,13 +276,13 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
)}
{signature.signature?.typedSignature && (
<p className="font-signature text-center text-sm">
<p className="text-center font-signature text-sm">
{signature.signature?.typedSignature}
</p>
)}
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
@@ -290,14 +293,14 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<p className="text-muted-foreground">N/A</p>
)}
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
<p className="mt-1 text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
@@ -307,18 +310,22 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<TableCell truncate={false} className="w-[min-content] align-top">
<div className="space-y-1">
<p className="text-muted-foreground text-sm print:text-xs">
<p className="text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0]
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
: logs.DOCUMENT_SENT[0]
? DateTime.fromJSDate(logs.DOCUMENT_SENT[0].createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
: _(msg`Unknown`)}
</span>
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<p className="text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0]
@@ -330,7 +337,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
<p className="text-muted-foreground text-sm print:text-xs">
<p className="text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
@@ -341,7 +348,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</span>
</p>
) : (
<p className="text-muted-foreground text-sm print:text-xs">
<p className="text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
@@ -355,7 +362,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
)}
<p className="text-muted-foreground text-sm print:text-xs">
<p className="text-sm text-muted-foreground print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="inline-block">
{recipient.signingStatus === SigningStatus.REJECTED
@@ -371,8 +371,9 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
to="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
Check out Documenso
</Link>
.
</Trans>
</p>
)}
@@ -470,8 +471,9 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
to="https://documenso.com"
className="text-documenso-700 hover:text-documenso-600"
>
Check out Documenso.
Check out Documenso
</Link>
.
</Trans>
</p>
)}
@@ -115,7 +115,9 @@ export default function RejectedSigningPage({ loaderData }: Route.ComponentProps
{user && (
<Button className="mt-6" asChild>
<Link to={`/`}>Return Home</Link>
<Link to={`/`}>
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
@@ -98,7 +98,9 @@ export default function WaitingForTurnToSignPage({ loaderData }: Route.Component
</Button>
) : (
<Button variant="link" asChild>
<Link to="/">Return Home</Link>
<Link to="/">
<Trans>Return Home</Trans>
</Link>
</Button>
)}
</div>
@@ -1,9 +1,9 @@
import satori from 'satori';
import sharp from 'sharp';
import { P, match } from 'ts-pattern';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/document/get-recipient-or-sender-by-share-link-slug';
import { svgToPng } from '@documenso/lib/utils/images/svg-to-png';
import type { Route } from './+types/share.$slug.opengraph';
@@ -181,8 +181,7 @@ export const loader = async ({ params }: Route.LoaderArgs) => {
},
);
// Convert SVG to PNG using sharp
const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
const pngBuffer = await svgToPng(svg.toString());
return new Response(pngBuffer, {
headers: {
@@ -1,6 +1,5 @@
import sharp from 'sharp';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { loadLogo } from '@documenso/lib/utils/images/logo';
import { prisma } from '@documenso/prisma';
import type { Route } from './+types/branding.logo.organisation.$orgId';
@@ -63,16 +62,12 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
const { content, contentType } = await loadLogo(file);
return new Response(Buffer.from(img), {
return new Response(content, {
headers: {
'Content-Type': 'image/png',
'Content-Length': img.length.toString(),
'Content-Type': contentType,
'Content-Length': content.length.toString(),
// Stale while revalidate for 1 hours to 24 hours
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
@@ -1,7 +1,6 @@
import sharp from 'sharp';
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { loadLogo } from '@documenso/lib/utils/images/logo';
import type { Route } from './+types/branding.logo.team.$teamId';
@@ -56,16 +55,12 @@ export async function loader({ params }: Route.LoaderArgs) {
);
}
const img = await sharp(file)
.toFormat('png', {
quality: 80,
})
.toBuffer();
const { content, contentType } = await loadLogo(file);
return new Response(img, {
return new Response(content, {
headers: {
'Content-Type': 'image/png',
'Content-Length': img.length.toString(),
'Content-Type': contentType,
'Content-Length': content.length.toString(),
// Stale while revalidate for 1 hours to 24 hours
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
},
@@ -41,7 +41,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
const result = await verifyEmbeddingPresignToken({ token, scope: `documentId:${id}` }).catch(
() => null,
);
if (!result) {
throw new Error('Invalid token');
@@ -41,7 +41,9 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
const token = url.searchParams.get('token') || '';
// We also know that the token is valid, but we need the userId + teamId
const result = await verifyEmbeddingPresignToken({ token }).catch(() => null);
const result = await verifyEmbeddingPresignToken({ token, scope: `templateId:${id}` }).catch(
() => null,
);
if (!result) {
throw new Error('Invalid token');
@@ -8,6 +8,7 @@ import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signat
type HandleSignatureFieldClickOptions = {
field: TFieldSignature;
fullName?: string;
signature: string | null;
typedSignatureEnabled?: boolean;
uploadSignatureEnabled?: boolean;
@@ -17,8 +18,14 @@ type HandleSignatureFieldClickOptions = {
export const handleSignatureFieldClick = async (
options: HandleSignatureFieldClickOptions,
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
const { field, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } =
options;
const {
field,
fullName,
signature,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
} = options;
if (field.type !== FieldType.SIGNATURE) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
@@ -37,6 +44,7 @@ export const handleSignatureFieldClick = async (
if (!signatureToInsert) {
signatureToInsert = await SignFieldSignatureDialog.call({
fullName,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
+1 -2
View File
@@ -70,7 +70,6 @@
"remeda": "^2.32.0",
"remix-themes": "^2.0.4",
"satori": "^0.18.3",
"sharp": "0.34.5",
"tailwindcss": "^3.4.18",
"ts-pattern": "^5.9.0",
"ua-parser-js": "^1.0.41",
@@ -108,5 +107,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.2.4"
"version": "2.3.0"
}
+290 -547
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.2.4",
"version": "2.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.2.4",
"version": "2.3.0",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -78,7 +78,7 @@
"@documenso/tailwind-config": "*",
"@documenso/trpc": "*",
"@documenso/ui": "*",
"next": "^15.5.7",
"next": "15.5.9",
"next-plausible": "^3.12.5",
"nextra": "^3",
"nextra-theme-docs": "^3",
@@ -99,7 +99,7 @@
"dependencies": {
"@documenso/prisma": "*",
"luxon": "^3.7.2",
"next": "^15.5.7"
"next": "15.5.9"
},
"devDependencies": {
"@types/node": "^20",
@@ -109,7 +109,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.2.4",
"version": "2.3.0",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
@@ -167,7 +167,6 @@
"remeda": "^2.32.0",
"remix-themes": "^2.0.4",
"satori": "^0.18.3",
"sharp": "0.34.5",
"tailwindcss": "^3.4.18",
"ts-pattern": "^5.9.0",
"ua-parser-js": "^1.0.41",
@@ -666,58 +665,6 @@
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/client-ses": {
"version": "3.936.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-ses/-/client-ses-3.936.0.tgz",
"integrity": "sha512-2toHYwRkcYGasPHYGwOwaIAa2Api/uFhmL3px0Tyt4bne2ilqhSwq+6a/0UVMd8JYwWaLMJolTbWKFt2jUlmGg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.936.0",
"@aws-sdk/credential-provider-node": "3.936.0",
"@aws-sdk/middleware-host-header": "3.936.0",
"@aws-sdk/middleware-logger": "3.936.0",
"@aws-sdk/middleware-recursion-detection": "3.936.0",
"@aws-sdk/middleware-user-agent": "3.936.0",
"@aws-sdk/region-config-resolver": "3.936.0",
"@aws-sdk/types": "3.936.0",
"@aws-sdk/util-endpoints": "3.936.0",
"@aws-sdk/util-user-agent-browser": "3.936.0",
"@aws-sdk/util-user-agent-node": "3.936.0",
"@smithy/config-resolver": "^4.4.3",
"@smithy/core": "^3.18.5",
"@smithy/fetch-http-handler": "^5.3.6",
"@smithy/hash-node": "^4.2.5",
"@smithy/invalid-dependency": "^4.2.5",
"@smithy/middleware-content-length": "^4.2.5",
"@smithy/middleware-endpoint": "^4.3.12",
"@smithy/middleware-retry": "^4.4.12",
"@smithy/middleware-serde": "^4.2.6",
"@smithy/middleware-stack": "^4.2.5",
"@smithy/node-config-provider": "^4.3.5",
"@smithy/node-http-handler": "^4.4.5",
"@smithy/protocol-http": "^5.3.5",
"@smithy/smithy-client": "^4.9.8",
"@smithy/types": "^4.9.0",
"@smithy/url-parser": "^4.2.5",
"@smithy/util-base64": "^4.3.0",
"@smithy/util-body-length-browser": "^4.2.0",
"@smithy/util-body-length-node": "^4.2.1",
"@smithy/util-defaults-mode-browser": "^4.3.11",
"@smithy/util-defaults-mode-node": "^4.2.14",
"@smithy/util-endpoints": "^3.2.5",
"@smithy/util-middleware": "^4.2.5",
"@smithy/util-retry": "^4.2.5",
"@smithy/util-utf8": "^4.2.0",
"@smithy/util-waiter": "^4.2.5",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/client-sesv2": {
"version": "3.936.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.936.0.tgz",
@@ -5089,9 +5036,9 @@
}
},
"node_modules/@next/env": {
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
"version": "15.5.9",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@@ -5963,12 +5910,6 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/@one-ini/wasm": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
"integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
"license": "MIT"
},
"node_modules/@opentelemetry/api": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
@@ -12161,6 +12102,7 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -14407,21 +14349,20 @@
}
},
"node_modules/@react-email/render": {
"version": "0.0.17",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-0.0.17.tgz",
"integrity": "sha512-xBQ+/73+WsGuXKY7r1U73zMBNV28xdV0cp9cFjhNYipBReDHhV97IpA6v7Hl0dDtDzt+yS/72dY5vYXrF1v8NA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.0.tgz",
"integrity": "sha512-rdjNj6iVzv8kRKDPFas+47nnoe6B40+nwukuXwY4FCwM7XBg6tmYr+chQryCuavUj2J65MMf6fztk1bxOUiSVA==",
"license": "MIT",
"dependencies": {
"html-to-text": "9.0.5",
"js-beautify": "^1.14.11",
"react-promise-suspense": "0.3.4"
"html-to-text": "^9.0.5",
"prettier": "^3.5.3"
},
"engines": {
"node": ">=18.0.0"
"node": ">=22.0.0"
},
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react": "^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^18.0 || ^19.0 || ^19.0.0-rc"
}
},
"node_modules/@react-email/row": {
@@ -17107,13 +17048,13 @@
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.21",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.21.tgz",
"integrity": "sha512-Eix+sb/Nj28MNnWvO2X1OLrk5vuD4C9SMnb2Vf4itWnxphYeSceqkFX7IdmxTzn+dvmnNz7paMbg4Uc60wSfJg==",
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.4.tgz",
"integrity": "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@aws-sdk/client-ses": "^3.731.1",
"@aws-sdk/client-sesv2": "^3.839.0",
"@types/node": "*"
}
},
@@ -17787,15 +17728,6 @@
"integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==",
"license": "BSD-2-Clause"
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
"integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
"license": "ISC",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -19861,6 +19793,7 @@
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -20063,22 +19996,6 @@
"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
"license": "MIT"
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
"integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
"license": "MIT",
"dependencies": {
"ini": "^1.3.4",
"proto-list": "~1.2.1"
}
},
"node_modules/config-chain/node_modules/ini": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC"
},
"node_modules/consola": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz",
@@ -21571,48 +21488,6 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
"integrity": "sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q==",
"license": "MIT",
"dependencies": {
"@one-ini/wasm": "0.1.1",
"commander": "^10.0.0",
"minimatch": "9.0.1",
"semver": "^7.5.3"
},
"bin": {
"editorconfig": "bin/editorconfig"
},
"engines": {
"node": ">=14"
}
},
"node_modules/editorconfig/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/editorconfig/node_modules/minimatch": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.1.tgz",
"integrity": "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -26314,117 +26189,6 @@
"node": ">=10"
}
},
"node_modules/js-beautify": {
"version": "1.15.4",
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
"integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
"license": "MIT",
"dependencies": {
"config-chain": "^1.1.13",
"editorconfig": "^1.0.4",
"glob": "^10.4.2",
"js-cookie": "^3.0.5",
"nopt": "^7.2.1"
},
"bin": {
"css-beautify": "js/bin/css-beautify.js",
"html-beautify": "js/bin/html-beautify.js",
"js-beautify": "js/bin/js-beautify.js"
},
"engines": {
"node": ">=14"
}
},
"node_modules/js-beautify/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/js-beautify/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/js-beautify/node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-beautify/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
"node_modules/js-beautify/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/js-beautify/node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-sdsl": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
@@ -26632,12 +26396,12 @@
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
@@ -29148,12 +28912,12 @@
}
},
"node_modules/next": {
"version": "15.5.7",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
"version": "15.5.9",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.7",
"@next/env": "15.5.9",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
@@ -29480,21 +29244,6 @@
"node": ">=6.0.0"
}
},
"node_modules/nopt": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
"integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
"license": "ISC",
"dependencies": {
"abbrev": "^2.0.0"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
"node_modules/normalize-package-data": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-5.0.0.tgz",
@@ -31586,12 +31335,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
"integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
"license": "ISC"
},
"node_modules/protobufjs": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
@@ -31927,263 +31670,6 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-email": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.4.tgz",
"integrity": "sha512-pI6b4ePMWC1AFtyd0gyVMsJ6Nhg4nnmDxVo1EQP+r011WQafU8UGAUfPUiE5Wh7x28+3yFw7qXCnD33y71guUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"chokidar": "^4.0.3",
"commander": "^13.0.0",
"conf": "^15.0.2",
"debounce": "^2.0.0",
"esbuild": "^0.25.0",
"glob": "^11.0.0",
"jiti": "2.4.2",
"log-symbols": "^7.0.0",
"mime-types": "^3.0.0",
"normalize-path": "^3.0.0",
"nypm": "0.6.0",
"ora": "^8.0.0",
"prompts": "2.4.2",
"socket.io": "^4.8.1",
"tsconfig-paths": "4.2.0"
},
"bin": {
"email": "dist/index.js"
},
"engines": {
"node": ">=22.0.0"
}
},
"node_modules/react-email/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/react-email/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/react-email/node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/react-email/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"node_modules/react-email/node_modules/is-interactive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-email/node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-email/node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/react-email/node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
"integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-email/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/react-email/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/react-email/node_modules/ora": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
"integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"cli-cursor": "^5.0.0",
"cli-spinners": "^2.9.2",
"is-interactive": "^2.0.0",
"is-unicode-supported": "^2.0.0",
"log-symbols": "^6.0.0",
"stdin-discarder": "^0.2.2",
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-email/node_modules/ora/node_modules/log-symbols": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
"integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"is-unicode-supported": "^1.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-email/node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-email/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/react-email/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/react-email/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/react-email/node_modules/tsconfig-paths": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
"license": "MIT",
"dependencies": {
"json5": "^2.2.2",
"minimist": "^1.2.6",
"strip-bom": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/react-hook-form": {
"version": "7.66.1",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
@@ -37453,18 +36939,275 @@
"@react-email/img": "0.0.11",
"@react-email/link": "0.0.12",
"@react-email/preview": "0.0.13",
"@react-email/render": "0.0.17",
"@react-email/render": "2.0.0",
"@react-email/row": "0.0.12",
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"nodemailer": "^7.0.10",
"react-email": "^5.0.4",
"react-email": "^5.0.6",
"resend": "^6.5.2"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.21"
"@types/nodemailer": "^7.0.4"
}
},
"packages/email/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"packages/email/node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"packages/email/node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/email/node_modules/emoji-regex": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT"
},
"packages/email/node_modules/is-interactive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/email/node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/email/node_modules/jiti": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"packages/email/node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
"integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
"license": "MIT",
"dependencies": {
"is-unicode-supported": "^2.0.0",
"yoctocolors": "^2.1.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/email/node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"packages/email/node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"packages/email/node_modules/ora": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/ora/-/ora-8.2.0.tgz",
"integrity": "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"cli-cursor": "^5.0.0",
"cli-spinners": "^2.9.2",
"is-interactive": "^2.0.0",
"is-unicode-supported": "^2.0.0",
"log-symbols": "^6.0.0",
"stdin-discarder": "^0.2.2",
"string-width": "^7.2.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/email/node_modules/ora/node_modules/log-symbols": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-6.0.0.tgz",
"integrity": "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",
"is-unicode-supported": "^1.3.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/email/node_modules/ora/node_modules/log-symbols/node_modules/is-unicode-supported": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz",
"integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/email/node_modules/react-email": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.6.tgz",
"integrity": "sha512-DEGzWpEiC3CquPEaaEJuipNT3WZ9mK58rbkpOe4Slbgyf60PLa1wONnt5a3afbBBRbNdW2aYhIvVI41yS6UIRA==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.27.0",
"@babel/traverse": "^7.27.0",
"chokidar": "^4.0.3",
"commander": "^13.0.0",
"conf": "^15.0.2",
"debounce": "^2.0.0",
"esbuild": "^0.25.0",
"glob": "^11.0.0",
"jiti": "2.4.2",
"log-symbols": "^7.0.0",
"mime-types": "^3.0.0",
"normalize-path": "^3.0.0",
"nypm": "0.6.0",
"ora": "^8.0.0",
"prompts": "2.4.2",
"socket.io": "^4.8.1",
"tsconfig-paths": "4.2.0"
},
"bin": {
"email": "dist/index.js"
},
"engines": {
"node": ">=20.0.0"
}
},
"packages/email/node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
"integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
"license": "MIT",
"engines": {
"node": ">= 14.18.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"packages/email/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
"strip-ansi": "^7.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"packages/email/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"packages/email/node_modules/tsconfig-paths": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz",
"integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==",
"license": "MIT",
"dependencies": {
"json5": "^2.2.2",
"minimist": "^1.2.6",
"strip-bom": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"packages/eslint-config": {
+1 -1
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.2.4",
"version": "2.3.0",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -1,6 +1,9 @@
import { expect, test } from '@playwright/test';
import type { APIRequestContext } from 'playwright-core';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import type { CreateEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
import type { VerifyEmbeddingPresignTokenOptions } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { seedUser } from '@documenso/prisma/seed/users';
@@ -17,18 +20,7 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
},
},
);
const response = await createPresignToken(request, token);
const responseData = await response.json();
@@ -54,19 +46,9 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
expiresIn: 120, // 2 hours
},
},
);
const response = await createPresignToken(request, token, {
expiresIn: 120, // 2 hours
});
const responseData = await response.json();
@@ -92,19 +74,9 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
expiresIn: 0, // Immediate expiration
},
},
);
const response = await createPresignToken(request, token, {
expiresIn: 0, // Immediate expiration
});
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
@@ -129,18 +101,7 @@ test.describe('Embedding Presign API', () => {
});
// First create a token
const createResponse = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
apiToken: token,
},
},
);
const createResponse = await createPresignToken(request, token);
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
@@ -150,18 +111,9 @@ test.describe('Embedding Presign API', () => {
const presignToken = createResponseData.token;
// Then verify it
const verifyResponse = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
token: presignToken,
},
},
);
const verifyResponse = await verifyPresignToken(request, token, {
token: presignToken,
});
expect(verifyResponse.ok()).toBeTruthy();
expect(verifyResponse.status()).toBe(200);
@@ -183,18 +135,87 @@ test.describe('Embedding Presign API', () => {
expiresIn: null,
});
const response = await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
data: {
token: 'invalid-token',
},
},
);
const response = await verifyPresignToken(request, token, {
token: 'invalid-token',
});
const responseData = await response.json();
console.log('Invalid token response:', responseData);
expect(response.ok()).toBeTruthy();
expect(response.status()).toBe(200);
expect(responseData.success).toBe(false);
});
test('verifyEmbeddingPresignToken: should verify a valid scoped token', async ({ request }) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// First create a token
const createResponse = await createPresignToken(request, token, {
scope: 'documentId:1',
});
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
console.log('Create response:', createResponseData);
const presignToken = createResponseData.token;
// Then verify it
const verifyResponse = await verifyPresignToken(request, token, {
token: presignToken,
scope: 'documentId:1',
});
expect(verifyResponse.ok()).toBeTruthy();
expect(verifyResponse.status()).toBe(200);
const verifyResponseData = await verifyResponse.json();
console.log('Verify response:', verifyResponseData);
expect(verifyResponseData.success).toBe(true);
});
test('verifyEmbeddingPresignToken: should reject a scope mismatched token', async ({
request,
}) => {
const { user, team } = await seedUser();
const { token } = await createApiToken({
userId: user.id,
teamId: team.id,
tokenName: 'test',
expiresIn: null,
});
// First create a token
const createResponse = await createPresignToken(request, token, {
scope: 'documentId:1',
});
expect(createResponse.ok()).toBeTruthy();
const createResponseData = await createResponse.json();
console.log('Create response:', createResponseData);
const presignToken = createResponseData.token;
// Then verify it
const response = await verifyPresignToken(request, token, {
token: presignToken,
scope: 'documentId:2',
});
const responseData = await response.json();
@@ -206,3 +227,40 @@ test.describe('Embedding Presign API', () => {
expect(responseData.success).toBe(false);
});
});
const createPresignToken = async (
request: APIRequestContext,
apiToken: string,
data?: Partial<CreateEmbeddingPresignTokenOptions>,
) => {
return await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/create-presign-token`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
data: {
apiToken,
...data,
},
},
);
};
const verifyPresignToken = async (
request: APIRequestContext,
apiToken: string,
data: VerifyEmbeddingPresignTokenOptions,
) => {
return await request.post(
`${NEXT_PUBLIC_WEBAPP_URL()}/api/v2-beta/embedding/verify-presign-token`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
data,
},
);
};
@@ -6,6 +6,7 @@ import { pick } from 'remeda';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
import { DocumentAccessAuth } from '@documenso/lib/types/document-auth';
import { prisma } from '@documenso/prisma';
import {
DocumentDistributionMethod,
@@ -23,7 +24,9 @@ import type {
TCreateEnvelopePayload,
TCreateEnvelopeResponse,
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
import type { TUpdateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/update-envelope-recipients.types';
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
import type { TUpdateEnvelopeRequest } from '@documenso/trpc/server/envelope-router/update-envelope.types';
@@ -144,6 +147,9 @@ test.describe('API V2 Envelopes', () => {
externalId: 'externalId',
visibility: DocumentVisibility.MANAGER_AND_ABOVE,
globalAccessAuth: ['ACCOUNT'],
// Ignore this error in the test since it doesn't actually exist in the PDF:
// - Error setting value for field hello: PDFDocument has no form field with the name "hello"
// We want to check if the form value is set in the DB.
formValues: {
hello: 'world',
},
@@ -262,8 +268,6 @@ test.describe('API V2 Envelopes', () => {
},
});
console.log(userB.email);
expect(envelope.envelopeItems.length).toBe(2);
expect(envelope.envelopeItems[0].title).toBe('field-meta.pdf');
expect(envelope.envelopeItems[1].title).toBe('field-font-alignment.pdf');
@@ -557,4 +561,543 @@ test.describe('API V2 Envelopes', () => {
userEmail: userA.email,
});
});
test.describe('Empty recipient tests', () => {
test('Create template envelope with empty email recipient', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Template with Empty Email Recipient',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Create recipient with empty email
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: response.id,
data: [
{
email: '',
name: 'Test Recipient',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
expect(createRecipientsRes.status()).toBe(200);
const recipientsResponse = await createRecipientsRes.json();
const recipient = recipientsResponse.data[0];
expect(recipient.email).toBe('');
expect(recipient.name).toBe('Test Recipient');
// Get envelope items to assign fields
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
const envelopeItem = envelope.envelopeItems[0];
// Create field for the recipient with empty email
const createFieldsRequest = {
envelopeId: response.id,
data: [
{
recipientId: recipient.id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createFieldsRequest,
});
expect(createFieldsRes.ok()).toBeTruthy();
expect(createFieldsRes.status()).toBe(200);
});
test('Create document envelope with empty email recipient', async ({ request }) => {
const payload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Empty Email Recipient',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const response = (await res.json()) as TCreateEnvelopeResponse;
// Create recipient with empty email
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: response.id,
data: [
{
email: '',
name: 'Document Recipient No Email',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
const recipientsResponse = await createRecipientsRes.json();
const recipient = recipientsResponse.data[0];
expect(recipient.email).toBe('');
expect(recipient.name).toBe('Document Recipient No Email');
});
test('Update recipient to have empty email', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Update Recipient Email Test',
recipients: [
{
email: userA.email,
name: 'Test User',
role: RecipientRole.SIGNER,
},
],
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// Get the envelope to get recipient ID
const getRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getRes.json();
const recipientId = envelope.recipients[0].id;
// Update recipient to have empty email
const updateRequest: TUpdateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
id: recipientId,
email: '',
name: 'Updated Name No Email',
},
],
};
const updateRes = await request.post(`${baseUrl}/envelope/recipient/update-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: updateRequest,
});
expect(updateRes.ok()).toBeTruthy();
const updateResponse = await updateRes.json();
const updatedRecipient = updateResponse.data[0];
expect(updatedRecipient.email).toBe('');
expect(updatedRecipient.name).toBe('Updated Name No Email');
});
test('Mixed recipients with and without emails', async ({ request }) => {
const payload = {
type: EnvelopeType.TEMPLATE,
title: 'Mixed Recipients Test',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const res = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(res.ok()).toBeTruthy();
const response = (await res.json()) as TCreateEnvelopeResponse;
// Create multiple recipients, some with email, some without
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: response.id,
data: [
{
email: userA.email,
name: 'Recipient With Email',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: '',
name: 'Recipient Without Email 1',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: userB.email,
name: 'Another With Email',
role: RecipientRole.APPROVER,
accessAuth: [],
actionAuth: [],
},
{
email: '',
name: 'Recipient Without Email 2',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
const recipientsResponse = await createRecipientsRes.json();
const recipients = recipientsResponse.data;
expect(recipients.length).toBe(4);
expect(recipients[0].email).toBe(userA.email.toLowerCase());
expect(recipients[1].email).toBe('');
expect(recipients[2].email).toBe(userB.email.toLowerCase());
expect(recipients[3].email).toBe('');
// Get envelope to assign fields
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${response.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
const envelopeItem = envelope.envelopeItems[0];
// Create fields for all recipients including those without emails
const createFieldsRequest = {
envelopeId: response.id,
data: recipients.map((recipient, index) => ({
recipientId: recipient.id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 0 + index,
width: 50,
height: 50,
})),
};
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createFieldsRequest,
});
expect(createFieldsRes.ok()).toBeTruthy();
});
test('Distribute envelope with empty email recipients', async ({ request }) => {
const payload = {
type: EnvelopeType.DOCUMENT,
title: 'Document for Distribution with Empty Email',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// Create recipients with empty emails
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: '',
name: 'Recipient One',
role: RecipientRole.SIGNER,
accessAuth: [],
actionAuth: [],
},
{
email: '',
name: 'Recipient Two',
role: RecipientRole.APPROVER,
accessAuth: [],
actionAuth: [],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
const recipientsResponse = await createRecipientsRes.json();
const recipients = recipientsResponse.data;
// Get envelope to assign fields
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
const envelopeItem = envelope.envelopeItems[0];
// Create fields for recipients
const createFieldsRequest = {
envelopeId: createResponse.id,
data: recipients.map((recipient, index) => ({
recipientId: recipient.id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 0 + index,
width: 50,
height: 50,
})),
};
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createFieldsRequest,
});
expect(createFieldsRes.ok()).toBeTruthy();
// Distribute the envelope
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: createResponse.id,
} satisfies TDistributeEnvelopeRequest,
});
expect(distributeRes.ok()).toBeTruthy();
expect(distributeRes.status()).toBe(200);
const distributeResponse = await distributeRes.json();
expect(distributeResponse.success).toBe(true);
expect(distributeResponse.id).toBe(createResponse.id);
expect(distributeResponse.recipients).toHaveLength(2);
// Verify recipients have empty emails and signing URLs
expect(distributeResponse.recipients[0].email).toBe('');
expect(distributeResponse.recipients[0].signingUrl).toBeTruthy();
expect(distributeResponse.recipients[1].email).toBe('');
expect(distributeResponse.recipients[1].signingUrl).toBeTruthy();
});
test('Distribute envelope with empty email recipient and auth requirements fails', async ({
request,
}) => {
const payload = {
type: EnvelopeType.DOCUMENT,
title: 'Document with Auth Requirements',
} satisfies TCreateEnvelopePayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
const files = [
{
name: 'example.pdf',
data: fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf')),
},
];
for (const file of files) {
formData.append('files', new File([file.data], file.name, { type: 'application/pdf' }));
}
const createRes = await request.post(`${baseUrl}/envelope/create`, {
headers: { Authorization: `Bearer ${tokenA}` },
multipart: formData,
});
expect(createRes.ok()).toBeTruthy();
const createResponse = (await createRes.json()) as TCreateEnvelopeResponse;
// Create recipient with empty email and TWO_FACTOR_AUTH action auth
const createRecipientsRequest: TCreateEnvelopeRecipientsRequest = {
envelopeId: createResponse.id,
data: [
{
email: '',
name: 'Recipient With Auth',
role: RecipientRole.SIGNER,
accessAuth: [DocumentAccessAuth.TWO_FACTOR_AUTH],
},
],
};
const createRecipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createRecipientsRequest,
});
expect(createRecipientsRes.ok()).toBeTruthy();
const recipientsResponse = await createRecipientsRes.json();
const recipient = recipientsResponse.data[0];
// Get envelope to assign fields
const getEnvelopeRes = await request.get(`${baseUrl}/envelope/${createResponse.id}`, {
headers: { Authorization: `Bearer ${tokenA}` },
});
const envelope: TGetEnvelopeResponse = await getEnvelopeRes.json();
const envelopeItem = envelope.envelopeItems[0];
// Create field for the recipient
const createFieldsRequest = {
envelopeId: createResponse.id,
data: [
{
recipientId: recipient.id,
envelopeItemId: envelopeItem.id,
type: FieldType.SIGNATURE,
page: 1,
positionX: 100,
positionY: 100,
width: 50,
height: 50,
},
],
};
const createFieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: createFieldsRequest,
});
expect(createFieldsRes.ok()).toBeTruthy();
// Try to distribute the envelope - should fail
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: createResponse.id,
},
});
// Expect distribution to fail
expect(distributeRes.ok()).toBeFalsy();
expect(distributeRes.status()).toBe(400);
const errorResponse = await distributeRes.json();
expect(errorResponse.message).toContain('requires an email');
});
});
});
@@ -3103,7 +3103,7 @@ test.describe('Document API V2', () => {
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope use endpoint', async ({ page, request }) => {
test('should allow authorized access to envelope use endpoint', async ({ request }) => {
const doc = await seedTemplate({
title: 'Team template 1',
userId: userA.id,
@@ -4313,5 +4313,62 @@ test.describe('Document API V2', () => {
expect(res.status()).toBe(200);
});
});
test.describe('Envelope audit logs endpoint', () => {
test('should block unauthorized access to envelope audit logs endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}/audit-log`,
{
headers: { Authorization: `Bearer ${tokenB}` },
},
);
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(404);
});
test('should allow authorized access to envelope audit logs endpoint', async ({
request,
}) => {
const doc = await seedBlankDocument(userA, teamA.id);
// Add a recipient which will trigger an audit log.
await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/envelope/recipient/create-many`, {
headers: { Authorization: `Bearer ${tokenA}` },
data: {
envelopeId: doc.id,
data: [
{
name: 'Test',
email: 'test@example.com',
role: RecipientRole.SIGNER,
},
],
},
});
const res = await request.get(
`${WEBAPP_BASE_URL}/api/v2-beta/envelope/${doc.id}/audit-log`,
{
headers: { Authorization: `Bearer ${tokenA}` },
},
);
expect(res.ok()).toBeTruthy();
expect(res.status()).toBe(200);
const data = await res.json();
expect(Array.isArray(data.data)).toBe(true);
expect(data.count).toEqual(1);
expect(data.data[0].type).toEqual('RECIPIENT_CREATED');
expect(data.currentPage).toBeGreaterThanOrEqual(1);
expect(data.perPage).toBeGreaterThanOrEqual(1);
});
});
});
});
@@ -89,9 +89,8 @@ test.describe('AutoSave Fields Step', () => {
});
expect(retrievedFields.length).toBe(3);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
expect(retrievedFields.filter((field) => field.type === 'SIGNATURE')).toHaveLength(2);
expect(retrievedFields.filter((field) => field.type === 'TEXT')).toHaveLength(1);
}).toPass();
});
@@ -214,10 +213,8 @@ test.describe('AutoSave Fields Step', () => {
});
expect(retrievedFields.length).toBe(4);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
expect(retrievedFields[2].type).toBe('SIGNATURE');
expect(retrievedFields[3].type).toBe('SIGNATURE');
expect(retrievedFields.filter((field) => field.type === 'SIGNATURE')).toHaveLength(3);
expect(retrievedFields.filter((field) => field.type === 'TEXT')).toHaveLength(1);
}).toPass();
});
@@ -259,10 +256,16 @@ test.describe('AutoSave Fields Step', () => {
});
expect(retrievedFields.length).toBe(2);
expect(retrievedFields[0].type).toBe('SIGNATURE');
expect(retrievedFields[1].type).toBe('TEXT');
const textField = retrievedFields.find((field) => field.type === 'TEXT');
const signatureField = retrievedFields.find((field) => field.type === 'SIGNATURE');
expect(signatureField).toBeDefined();
expect(textField).toBeDefined();
if (!signatureField || !textField) {
throw new Error('No signature or text field');
}
const textField = retrievedFields[1];
expect(textField.fieldMeta).toBeDefined();
if (
@@ -9,6 +9,7 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'serial' });
@@ -83,14 +84,13 @@ test('[DOCUMENTS]: deleting a completed document should not remove it from recip
});
// Open document action menu.
await page
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(200);
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
// delete document
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
@@ -128,14 +128,13 @@ test('[DOCUMENTS]: deleting a pending document should remove it from recipients'
});
// Open document action menu.
await page
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(200);
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
// delete document
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
@@ -169,19 +168,17 @@ test('[DOCUMENTS]: deleting draft documents should permanently remove it', async
});
// Open document action menu.
await page
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Draft' })
.getByTestId('document-table-action-btn')
.click();
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
await page.waitForTimeout(200);
// delete document
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible(); // Required to reduce flakiness.
await page.getByRole('menuitem', { name: 'Delete' }).click();
await expect(page.getByPlaceholder("Type 'delete' to confirm")).not.toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click();
await page.waitForTimeout(2500);
await expectToastTextToBeVisible(page, 'Document deleted');
await expect(page.getByRole('row', { name: /Document 1 - Draft/ })).not.toBeVisible();
@@ -203,14 +200,13 @@ test('[DOCUMENTS]: deleting pending documents should permanently remove it', asy
});
// Open document action menu.
await page
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(200);
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
// Delete document.
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
@@ -239,14 +235,13 @@ test('[DOCUMENTS]: deleting completed documents as an owner should hide it from
});
// Open document action menu.
await page
const documentActionBtn = page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(200);
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, documentActionBtn);
// Delete document.
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click();
@@ -292,36 +287,24 @@ test('[DOCUMENTS]: deleting documents as a recipient should only hide it for the
});
// Open document action menu.
await expect(async () => {
await page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
}).toPass();
const completedDocActionBtn = page
.locator('tr', { hasText: 'Document 1 - Completed' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, completedDocActionBtn);
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
await page.waitForTimeout(2000);
await expect(async () => {
await page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn')
.click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
}).toPass();
const pendingDocActionBtn = page
.locator('tr', { hasText: 'Document 1 - Pending' })
.getByTestId('document-table-action-btn');
await openDropdownMenu(page, pendingDocActionBtn);
// Delete document.
await page.getByRole('menuitem', { name: 'Hide' }).waitFor({ state: 'visible' });
await expect(page.getByRole('menuitem', { name: 'Hide' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Hide' }).click({ force: true });
await page.getByRole('button', { name: 'Hide' }).click({ force: true });
@@ -1,4 +1,4 @@
import { type Page } from '@playwright/test';
import type { Page } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
@@ -58,3 +58,15 @@ const getCsrfToken = async (page: Page) => {
return csrfToken;
};
export const checkSessionValid = async (page: Page): Promise<boolean> => {
const { request } = page.context();
const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/session`, {
method: 'get',
});
const session = await response.json();
return session.isAuthenticated === true;
};
@@ -1,3 +1,4 @@
import type { Locator } from '@playwright/test';
import { type Page, expect } from '@playwright/test';
export const expectTextToBeVisible = async (page: Page, text: string) => {
@@ -7,3 +8,22 @@ export const expectTextToBeVisible = async (page: Page, text: string) => {
export const expectTextToNotBeVisible = async (page: Page, text: string) => {
await expect(page.getByText(text).first()).not.toBeVisible();
};
export const expectToastTextToBeVisible = async (page: Page, text: string) => {
await expect(page.locator('[role="status"]').getByText(text)).toBeVisible();
};
export const openDropdownMenu = async (page: Page, dropdownButton: Locator) => {
await page.waitForTimeout(500); // Initial timeout incase table remounts which will close the dropdown.
await dropdownButton.focus();
await page.keyboard.press('Enter');
await page.waitForTimeout(500);
await page.keyboard.press('Escape');
await page.waitForTimeout(500);
await dropdownButton.focus();
await page.keyboard.press('Enter');
await expect(page.getByRole('menuitem').first()).toBeVisible();
};
@@ -9,7 +9,7 @@ import { seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedBlankTemplate } from '@documenso/prisma/seed/templates';
import { apiSignin } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
import { expectTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
test.describe.configure({ mode: 'parallel' });
@@ -117,7 +117,9 @@ test('[TEAMS]: can pin a document folder', async ({ page }) => {
redirectPath: `/t/${team.url}/documents`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page.reload();
@@ -142,7 +144,9 @@ test('[TEAMS]: can unpin a document folder', async ({ page }) => {
redirectPath: `/t/${team.url}/documents`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Unpin' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page.reload();
@@ -166,7 +170,9 @@ test('[TEAMS]: can rename a document folder', async ({ page }) => {
redirectPath: `/t/${team.url}/documents`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Team Archive');
@@ -191,7 +197,9 @@ test('[TEAMS]: document folder visibility is visible to team member', async ({ p
redirectPath: `/t/${team.url}/documents`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible();
@@ -220,7 +228,9 @@ test('[TEAMS]: document folder can be moved to another document folder', async (
redirectPath: `/t/${team.url}/documents`,
});
await page.getByTestId('folder-card-more-button').nth(0).click();
const folderMoreBtn = page.getByTestId('folder-card-more-button').nth(0);
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Team Clients' }).click();
@@ -271,7 +281,9 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
redirectPath: `/t/${team.url}/documents`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('textbox').fill(`delete ${folder.name}`);
@@ -280,12 +292,8 @@ test('[TEAMS]: document folder and its contents can be deleted', async ({ page }
await page.goto(`/t/${team.url}/documents`);
await expect(page.locator(`[data-folder-id="${folder.id}"]`)).not.toBeVisible();
await expect(page.getByText(proposal.title)).not.toBeVisible();
await page.goto(`/t/${team.url}/documents/f/${folder.id}`);
await expect(page.getByText(report.title)).not.toBeVisible();
await expect(page.locator(`[data-folder-id="${reportsFolder.id}"]`)).not.toBeVisible();
await expect(page.getByText(proposal.title)).toBeVisible();
await expect(page.getByText(report.title)).toBeVisible();
});
test('[TEAMS]: create folder button is visible on templates page', async ({ page }) => {
@@ -356,6 +364,25 @@ test('[TEAMS]: can create a template subfolder inside a template folder', async
test('[TEAMS]: can create a template inside a template folder', async ({ page }) => {
const { team, teamOwner } = await seedTeamDocuments();
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
where: {
organisation: {
id: team.organisationId,
},
},
});
await prisma.organisationClaim.update({
where: {
id: organisationClaim.id,
},
data: {
flags: {
allowLegacyEnvelopes: true,
},
},
});
const folder = await seedBlankFolder(teamOwner, team.id, {
createFolderOptions: {
name: 'Team Client Templates',
@@ -372,16 +399,15 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
await expect(page.getByText('Team Client Templates')).toBeVisible();
await page.getByRole('button', { name: 'Template (Legacy)' }).click();
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.getByRole('button', { name: 'Template (Legacy)' }).click(),
]);
await page.getByText('Upload Template Document').click();
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
await page
.locator('input[type="file"]')
.nth(0)
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
await fileChooser.setFiles(
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
);
await page.waitForTimeout(3000);
@@ -410,7 +436,9 @@ test('[TEAMS]: can pin a template folder', async ({ page }) => {
redirectPath: `/t/${team.url}/templates`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Pin' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Pin' }).click();
await page.reload();
@@ -436,7 +464,9 @@ test('[TEAMS]: can unpin a template folder', async ({ page }) => {
redirectPath: `/t/${team.url}/templates`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Unpin' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Unpin' }).click();
await page.reload();
@@ -462,7 +492,9 @@ test('[TEAMS]: can rename a template folder', async ({ page }) => {
redirectPath: `/t/${team.url}/templates`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await page.getByLabel('Name').fill('Updated Team Template Folder');
@@ -488,7 +520,9 @@ test('[TEAMS]: template folder visibility is not visible to team member', async
redirectPath: `/t/${team.url}/templates`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('menuitem', { name: 'Visibility' })).not.toBeVisible();
@@ -519,7 +553,9 @@ test('[TEAMS]: template folder can be moved to another template folder', async (
redirectPath: `/t/${team.url}/templates`,
});
await page.getByTestId('folder-card-more-button').nth(0).click();
const folderMoreBtn = page.getByTestId('folder-card-more-button').nth(0);
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move' }).click();
await page.getByRole('button', { name: 'Team Client Templates' }).click();
@@ -572,7 +608,9 @@ test('[TEAMS]: template folder can be deleted', async ({ page }) => {
redirectPath: `/t/${team.url}/templates`,
});
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('textbox').fill(`delete ${folder.name}`);
@@ -761,7 +799,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
await page.goto(`/t/${team.url}/documents/`);
await page.getByTestId('folder-card-more-button').click();
const folderMoreBtn1 = page.getByTestId('folder-card-more-button');
await openDropdownMenu(page, folderMoreBtn1);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Admins only');
@@ -781,7 +821,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
await page.goto(`/t/${team.url}/documents`);
await page.getByTestId('folder-card-more-button').nth(0).click();
const folderMoreBtn2 = page.getByTestId('folder-card-more-button').nth(0);
await openDropdownMenu(page, folderMoreBtn2);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Managers and above');
@@ -801,7 +843,9 @@ test('[TEAMS]: folder inherits team visibility settings', async ({ page }) => {
await page.goto(`/t/${team.url}/documents/`);
await page.getByTestId('folder-card-more-button').nth(0).click();
const folderMoreBtn3 = page.getByTestId('folder-card-more-button').nth(0);
await openDropdownMenu(page, folderMoreBtn3);
await expect(page.getByRole('menuitem', { name: 'Settings' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Settings' }).click();
await expect(page.getByRole('combobox', { name: 'Visibility' })).toHaveText('Everyone');
@@ -966,7 +1010,9 @@ test('[TEAMS]: team member can move documents to everyone folder', async ({ page
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
await everyoneDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1018,7 +1064,9 @@ test('[TEAMS]: team manager can move manager document to manager folder', async
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
await managerDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1071,7 +1119,9 @@ test('[TEAMS]: team manager can move manager document to everyone folder', async
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
await managerDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1124,7 +1174,9 @@ test('[TEAMS]: team manager can move everyone document to manager folder', async
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
await everyoneDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1177,7 +1229,9 @@ test('[TEAMS]: team admin can move admin document to admin folder', async ({ pag
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
await adminDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1228,7 +1282,9 @@ test('[TEAMS]: team admin can move admin document to manager folder', async ({ p
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
await adminDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1279,7 +1335,9 @@ test('[TEAMS]: team admin can move admin document to everyone folder', async ({
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
await adminDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1330,7 +1388,9 @@ test('[TEAMS]: team admin can move manager document to admin folder', async ({ p
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
await managerDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1381,7 +1441,9 @@ test('[TEAMS]: team admin can move manager document to manager folder', async ({
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
await managerDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1432,7 +1494,9 @@ test('[TEAMS]: team admin can move manager document to everyone folder', async (
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
await managerDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click({ force: true });
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1483,7 +1547,9 @@ test('[TEAMS]: team admin can move everyone document to admin folder', async ({
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
await everyoneDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1534,7 +1600,9 @@ test('[TEAMS]: team admin can move everyone document to manager folder', async (
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
await everyoneDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1585,7 +1653,9 @@ test('[TEAMS]: team admin can move everyone document to everyone folder', async
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
await everyoneDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1630,7 +1700,9 @@ test('[TEAMS]: team owner can move admin document to admin folder', async ({ pag
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
await adminDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1675,7 +1747,9 @@ test('[TEAMS]: team owner can move admin document to manager folder', async ({ p
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
await adminDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1720,7 +1794,9 @@ test('[TEAMS]: team owner can move admin document to everyone folder', async ({
await expect(page.getByText('[TEST] Admin Document')).toBeVisible();
const adminDocRow = page.getByRole('row', { name: /\[TEST\] Admin Document/ });
await adminDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = adminDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1765,7 +1841,9 @@ test('[TEAMS]: team owner can move manager document to admin folder', async ({ p
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
await managerDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1810,7 +1888,9 @@ test('[TEAMS]: team owner can move manager document to manager folder', async ({
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
await managerDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1855,7 +1935,9 @@ test('[TEAMS]: team owner can move manager document to everyone folder', async (
await expect(page.getByText('[TEST] Manager Document')).toBeVisible();
const managerDocRow = page.getByRole('row', { name: /\[TEST\] Manager Document/ });
await managerDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = managerDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -1900,7 +1982,9 @@ test('[TEAMS]: team owner can move everyone document to admin folder', async ({
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
await everyoneDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Admin Folder' })).toBeVisible();
@@ -1945,7 +2029,9 @@ test('[TEAMS]: team owner can move everyone document to manager folder', async (
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
await everyoneDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Manager Folder' })).toBeVisible();
@@ -1990,7 +2076,9 @@ test('[TEAMS]: team owner can move everyone document to everyone folder', async
await expect(page.getByText('[TEST] Everyone Document')).toBeVisible();
const everyoneDocRow = page.getByRole('row', { name: /\[TEST\] Everyone Document/ });
await everyoneDocRow.getByTestId('document-table-action-btn').click();
const docActionBtn = everyoneDocRow.getByTestId('document-table-action-btn');
await openDropdownMenu(page, docActionBtn);
await expect(page.getByRole('menuitem', { name: 'Move to Folder' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Move to Folder' }).click();
await expect(page.getByRole('button', { name: 'Everyone Folder' })).toBeVisible();
@@ -6,7 +6,11 @@ import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectTextToBeVisible, expectTextToNotBeVisible } from '../fixtures/generic';
import {
expectTextToBeVisible,
expectTextToNotBeVisible,
openDropdownMenu,
} from '../fixtures/generic';
test('[ORGANISATIONS]: create and delete organisation', async ({ page }) => {
const { user, organisation } = await seedUser({
@@ -399,7 +403,9 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await expect(page.getByText('Team members have been added').first()).toBeVisible();
// Update CUSTOM_GROUP_B
await page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
const updateBtn = page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button');
await openDropdownMenu(page, updateBtn);
await expect(page.getByRole('menuitem', { name: 'Update role' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Update role' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Team Admin' }).click();
@@ -409,7 +415,9 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.reload();
// Delete CUSTOM_GROUP_B
await page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button').click();
const deleteBtn = page.getByRole('row', { name: 'CUSTOM_GROUP_B' }).getByRole('button');
await openDropdownMenu(page, deleteBtn);
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expectTextToBeVisible(page, 'You have successfully removed this group from the team.');
@@ -477,7 +485,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
await expect(page.getByText(user2.email)).toBeVisible();
await expect(page.getByText(user3.email)).toBeVisible();
await page.getByRole('row', { name: user3.email }).getByRole('button').click();
const inviteActionBtn = page.getByRole('row', { name: user3.email }).getByRole('button');
await openDropdownMenu(page, inviteActionBtn);
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await expect(page.getByText('Invitation has been deleted').first()).toBeVisible();
await expect(page.getByText(user3.email)).not.toBeVisible();
@@ -508,7 +518,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).toBeVisible();
await page.getByRole('row', { name: user2.email }).getByRole('button').click();
const memberActionBtn = page.getByRole('row', { name: user2.email }).getByRole('button');
await openDropdownMenu(page, memberActionBtn);
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
await expect(page.getByText('You have successfully removed').first()).toBeVisible();
@@ -522,7 +534,9 @@ test('[ORGANISATIONS]: member invites', async ({ page }) => {
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(user2.email)).toBeVisible();
await page.getByRole('row', { name: user2.email }).getByRole('button').click();
const orgMemberBtn = page.getByRole('row', { name: user2.email }).getByRole('button');
await openDropdownMenu(page, orgMemberBtn);
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('You have successfully removed this user').first()).toBeVisible();
@@ -1,10 +1,12 @@
import { expect, test } from '@playwright/test';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { expectToastTextToBeVisible } from '../fixtures/generic';
test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
const { user, team } = await seedUser();
@@ -44,6 +46,9 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
.fill('public-direct-template-description');
await page.getByRole('button', { name: 'Update' }).click();
// Wait for toast
await expectToastTextToBeVisible(page, 'Template has been updated');
// Check that public profile is disabled.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
await expect(page.locator('body')).toContainText('404 Profile not found');
@@ -51,7 +56,21 @@ test('[PUBLIC_PROFILE]: create team profile', async ({ page }) => {
// Go back to public profile page.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/settings/public-profile`);
await page.getByRole('switch').click();
await page.waitForTimeout(1000);
// Expect profile to be enabled via db.
await expect
.poll(
async () => {
const profile = await prisma.teamProfile.findFirst({
where: { teamId: team.id },
});
return profile?.enabled;
},
{
timeout: 1000,
},
)
.toBeTruthy();
// Assert values.
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/p/${publicProfileUrl}`);
@@ -11,7 +11,11 @@ import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { checkDocumentTabCount } from '../fixtures/documents';
import { expectTextToBeVisible } from '../fixtures/generic';
import {
expectTextToBeVisible,
expectToastTextToBeVisible,
openDropdownMenu,
} from '../fixtures/generic';
test('[TEAMS]: check team documents count', async ({ page }) => {
const { team, teamOwner, teamMember2 } = await seedTeamDocuments();
@@ -239,21 +243,15 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
}).toPass();
await page.getByRole('menuitem').filter({ hasText: 'Resend' }).click();
const actionBtn = page.getByTestId('document-table-action-btn').first();
await expect(actionBtn).toBeAttached();
await openDropdownMenu(page, actionBtn);
await expect(page.getByRole('menuitem', { name: 'Resend' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Resend' }).click();
await page.getByLabel('test.documenso.com').first().click();
await page.getByRole('button', { name: 'Send reminder' }).click();
await expect(
page.getByRole('status').filter({ hasText: 'Document re-sent' }).first(),
).toBeVisible();
await expectToastTextToBeVisible(page, 'Document re-sent');
});
test('[TEAMS]: delete draft team document', async ({ page }) => {
@@ -265,14 +263,12 @@ test('[TEAMS]: delete draft team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=DRAFT`,
});
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
const actionBtn = page.getByTestId('document-table-action-btn').first();
await expect(actionBtn).toBeVisible({
timeout: 500,
});
await openDropdownMenu(page, actionBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
@@ -309,14 +305,12 @@ test('[TEAMS]: delete pending team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=PENDING`,
});
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
const actionBtn = page.getByTestId('document-table-action-btn').first();
await expect(actionBtn).toBeVisible({
timeout: 500,
});
await openDropdownMenu(page, actionBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click({ force: true });
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click({ force: true });
@@ -354,14 +348,12 @@ test('[TEAMS]: delete completed team document', async ({ page }) => {
redirectPath: `/t/${team.url}/documents?status=COMPLETED`,
});
await expect(async () => {
await page.getByTestId('document-table-action-btn').first().click();
await page.waitForTimeout(1000);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
}).toPass();
const actionBtn = page.getByTestId('document-table-action-btn').first();
await expect(actionBtn).toBeVisible({
timeout: 500,
});
await openDropdownMenu(page, actionBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click({ force: true });
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
await page.getByRole('button', { name: 'Delete' }).click({ force: true });
@@ -5,6 +5,7 @@ import { seedTeamEmailVerification } from '@documenso/prisma/seed/teams';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
test('[TEAMS]: send team email request', async ({ page }) => {
const { user, team } = await seedUser();
@@ -54,8 +55,13 @@ test('[TEAMS]: delete team email', async ({ page }) => {
redirectPath: `/t/${team.url}/settings`,
});
await page.locator('section div').filter({ hasText: 'Team email' }).getByRole('button').click();
const settingsBtn = page
.locator('section div')
.filter({ hasText: 'Team email' })
.getByRole('button');
await openDropdownMenu(page, settingsBtn);
await expect(page.getByRole('menuitem', { name: 'Remove' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Remove' }).click();
await page.getByRole('button', { name: 'Remove' }).click();
@@ -296,7 +296,13 @@ test.describe('AutoSave Fields Step', () => {
['SIGNATURE', 'TEXT'].toSorted(),
);
const textField = fields[1];
const textField = fields.find((field) => field.type === 'TEXT');
expect(textField).toBeDefined();
if (!textField) {
throw new Error('No text field');
}
expect(textField.fieldMeta).toBeDefined();
if (
@@ -5,6 +5,7 @@ import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
import { seedTemplate } from '@documenso/prisma/seed/templates';
import { apiSignin } from '../fixtures/authentication';
import { openDropdownMenu } from '../fixtures/generic';
test('[TEMPLATES]: view templates', async ({ page }) => {
const { team, owner, organisation } = await seedTeam({
@@ -71,13 +72,14 @@ test('[TEMPLATES]: delete template', async ({ page }) => {
});
for (const template of ['Team template 1', 'Team template 2']) {
await page
const actionBtn = page
.getByRole('row', { name: template })
.getByRole('cell', { name: 'Use Template' })
.getByRole('button')
.nth(1)
.click();
.nth(1);
await openDropdownMenu(page, actionBtn);
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('button', { name: 'Delete' }).click();
await expect(page.getByText('Template deleted').first()).toBeVisible();
@@ -110,7 +112,9 @@ test('[TEMPLATES]: duplicate template', async ({ page }) => {
});
// Duplicate team template.
await page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1).click();
const actionBtn = page.getByRole('cell', { name: 'Use Template' }).getByRole('button').nth(1);
await openDropdownMenu(page, actionBtn);
await expect(page.getByRole('menuitem', { name: 'Duplicate' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Duplicate' }).click();
await page.getByRole('button', { name: 'Duplicate' }).click();
await expect(page.getByText('Template duplicated').first()).toBeVisible();
+118 -2
View File
@@ -3,7 +3,7 @@ import { type Page, expect, test } from '@playwright/test';
import { prisma } from '@documenso/prisma';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication';
test.use({ storageState: { cookies: [], origins: [] } });
@@ -17,6 +17,7 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
await page.goto('http://localhost:3000/signin');
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).click();
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
@@ -24,7 +25,9 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
await expect(page.getByRole('button', { name: 'Reset Password' })).toBeEnabled();
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent', { timeout: 10000 });
await expect(page.locator('body')).toContainText('Reset email sent', {
timeout: 10000,
});
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
where: {
@@ -109,3 +112,116 @@ test('[USER] can reset password via user settings', async ({ page }: { page: Pag
await page.waitForURL('/settings/profile');
await expect(page).toHaveURL('/settings/profile');
});
test('[USER] password reset invalidates all sessions', async ({ page }: { page: Page }) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const { user } = await seedUser({
password: oldPassword,
});
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
const initialCookies = await page.context().cookies();
await page.context().clearCookies();
await page.goto('http://localhost:3000/signin');
await page.getByRole('link', { name: 'Forgot your password?' }).click();
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Reset email sent', {
timeout: 10000,
});
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
where: { userId: user.id },
});
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
await page.getByLabel('Password', { exact: true }).fill(newPassword);
await page.getByLabel('Repeat Password').fill(newPassword);
await page.getByRole('button', { name: 'Reset Password' }).click();
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
await page.context().addCookies(initialCookies);
await page.goto('http://localhost:3000/settings/profile');
await expect(page).toHaveURL('http://localhost:3000/signin');
expect(await checkSessionValid(page)).toBe(false);
await apiSignin({
page,
email: user.email,
password: newPassword,
redirectPath: '/settings/profile',
});
await page.waitForURL('/settings/profile');
expect(await checkSessionValid(page)).toBe(true);
});
test('[USER] password update invalidates other sessions but keeps current', async ({
page,
}: {
page: Page;
}) => {
const oldPassword = 'Test123!';
const newPassword = 'Test124!';
const { user } = await seedUser({
password: oldPassword,
});
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
const initialCookies = await page.context().cookies();
await page.context().clearCookies();
await apiSignin({
page,
email: user.email,
password: oldPassword,
redirectPath: '/settings/profile',
});
expect(await checkSessionValid(page)).toBe(true);
await page.goto('http://localhost:3000/settings/security');
await page.getByLabel('Current password').fill(oldPassword);
await page.getByLabel('New password').fill(newPassword);
await page.getByLabel('Repeat password').fill(newPassword);
await page.getByRole('button', { name: 'Update password' }).click();
await expect(page.locator('body')).toContainText('Password updated');
const finalCookies = await page.context().cookies();
await page.context().clearCookies();
await page.context().addCookies(initialCookies);
await page.goto('http://localhost:3000/settings/profile');
await expect(page).toHaveURL('http://localhost:3000/signin');
expect(await checkSessionValid(page)).toBe(false);
await page.context().clearCookies();
await page.context().addCookies(finalCookies);
await page.goto('http://localhost:3000/settings/security');
await expect(page).toHaveURL('http://localhost:3000/settings/security');
expect(await checkSessionValid(page)).toBe(true);
});
@@ -7,7 +7,7 @@ import { seedBlankDocument } from '@documenso/prisma/seed/documents';
import { seedUser } from '@documenso/prisma/seed/users';
import { apiSignin, apiSignout } from '../fixtures/authentication';
import { expectTextToBeVisible } from '../fixtures/generic';
import { expectTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
/**
* Helper function to seed a webhook directly in the database for testing.
@@ -147,9 +147,11 @@ test('[WEBHOOKS]: delete webhook', async ({ page }) => {
// Find the row with the webhook and click the action dropdown
const webhookRow = page.locator('tr', { hasText: webhookUrl });
await webhookRow.getByTestId('webhook-table-action-btn').click();
const actionBtn = webhookRow.getByTestId('webhook-table-action-btn');
await openDropdownMenu(page, actionBtn);
// Click Delete menu item
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Delete' }).click();
// Fill in confirmation field
@@ -196,9 +198,11 @@ test('[WEBHOOKS]: update webhook', async ({ page }) => {
// Find the row with the webhook and click the action dropdown
const webhookRow = page.locator('tr', { hasText: originalWebhookUrl });
await webhookRow.getByTestId('webhook-table-action-btn').click();
const actionBtn = webhookRow.getByTestId('webhook-table-action-btn');
await openDropdownMenu(page, actionBtn);
// Click Edit menu item
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
// Wait for dialog to open
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"test:dev": "NODE_OPTIONS=--experimental-require-module playwright test",
"test-ui:dev": "NODE_OPTIONS=--experimental-require-module playwright test --ui",
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
"test:e2e": "NODE_OPTIONS=--experimental-require-module NODE_ENV=test NEXT_PRIVATE_LOGGER_FILE_PATH=./logs.json start-server-and-test \"npm run start -w @documenso/remix\" http://localhost:3000 \"playwright test $E2E_TEST_PATH\""
},
"keywords": [],
"author": "",
+49 -7
View File
@@ -1,7 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import os from 'os';
import path from 'path';
function calculateWorkers() {
const total = os.cpus().length;
// Reserve 2 cores for the system
const usable = Math.max(total - 2, 1);
// 1 worker per 2 cores, minimum 1
const workers = Math.max(Math.floor(usable / 2), 1);
// Max 6 workers
return Math.min(workers, 6);
}
const ENV_FILES = ['.env', '.env.local', `.env.${process.env.NODE_ENV || 'development'}`];
ENV_FILES.forEach((file) => {
@@ -15,9 +29,8 @@ ENV_FILES.forEach((file) => {
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: false,
workers: 2,
fullyParallel: true,
workers: 10, // See Projects where 10 is utilized for API tests. We're not running 10 workers for UI tests.
maxFailures: process.env.CI ? 1 : undefined,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
@@ -31,25 +44,54 @@ export default defineConfig({
baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on',
video: 'on-first-retry',
trace: 'retain-on-failure',
video: 'retain-on-failure',
/* Add explicit timeouts for actions */
actionTimeout: 15_000,
navigationTimeout: 30_000,
contextOptions: {
reducedMotion: 'reduce',
},
/* Disable animations via cookie for more stable tests */
storageState: {
cookies: [
{
name: '__disable_animations',
value: 'true',
domain: 'localhost',
path: '/',
expires: -1,
httpOnly: false,
secure: false,
sameSite: 'Lax' as const,
},
],
origins: [],
},
},
timeout: 60_000,
/* Configure projects for major browsers */
projects: [
// API Tests e2e/api/**/*.spec.ts
{
name: 'chromium',
name: 'api',
testMatch: /e2e\/api\/.*\.spec\.ts/,
workers: 10, // Limited by DB connections before it gets flakey.
},
// Run UI Tests
{
name: 'ui',
testMatch: /e2e\/(?!api\/).*\.spec\.ts/,
use: {
...devices['Desktop Chrome'],
viewport: { width: 1920, height: 1200 },
},
workers: calculateWorkers(),
},
// {
+1
View File
@@ -81,6 +81,7 @@ auth.onError((err, c) => {
}
// Handle other errors
console.error('Unknown Error:', err);
return c.json(
{
code: AppErrorCode.UNKNOWN_ERROR,
+48 -3
View File
@@ -24,6 +24,7 @@ import { env } from '@documenso/lib/utils/env';
import { prisma } from '@documenso/prisma';
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
import { invalidateSessions } from '../lib/session/session';
import { getCsrfCookie } from '../lib/session/session-cookies';
import { onAuthorize } from '../lib/utils/authorizer';
import { getSession } from '../lib/utils/get-session';
@@ -170,15 +171,38 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const { password, currentPassword } = c.req.valid('json');
const requestMetadata = c.get('requestMetadata');
const session = await getSession(c);
const { session, user } = await getSession(c);
await updatePassword({
userId: session.user.id,
userId: user.id,
password,
currentPassword,
requestMetadata,
});
const userSessionIds = await prisma.session
.findMany({
where: {
userId: user.id satisfies number, // Incase we pass undefined somehow.
id: {
not: session.id,
},
},
select: {
id: true,
},
})
.then((sessions) => sessions.map((s) => s.id));
if (userSessionIds.length > 0) {
await invalidateSessions({
userId: user.id,
sessionIds: userSessionIds,
metadata: requestMetadata,
isRevoke: true,
});
}
return c.text('OK', 201);
})
/**
@@ -231,12 +255,33 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
const requestMetadata = c.get('requestMetadata');
await resetPassword({
const { userId } = await resetPassword({
token,
password,
requestMetadata,
});
// Invalidate all sessions after successful password reset
const userSessionIds = await prisma.session
.findMany({
where: {
userId: userId satisfies number, // Incase we pass undefined somehow.
},
select: {
id: true,
},
})
.then((sessions) => sessions.map((session) => session.id));
if (userSessionIds.length > 0) {
await invalidateSessions({
userId,
sessionIds: userSessionIds,
metadata: requestMetadata,
isRevoke: true,
});
}
return c.text('OK', 201);
})
/**
+4 -4
View File
@@ -32,17 +32,17 @@
"@react-email/img": "0.0.11",
"@react-email/link": "0.0.12",
"@react-email/preview": "0.0.13",
"@react-email/render": "0.0.17",
"@react-email/render": "2.0.0",
"@react-email/row": "0.0.12",
"@react-email/section": "0.0.16",
"@react-email/tailwind": "^2.0.1",
"@react-email/text": "0.1.5",
"nodemailer": "^7.0.10",
"react-email": "^5.0.4",
"react-email": "^5.0.6",
"resend": "^6.5.2"
},
"devDependencies": {
"@documenso/tsconfig": "*",
"@types/nodemailer": "^6.4.21"
"@types/nodemailer": "^7.0.4"
}
}
}
+2 -23
View File
@@ -15,7 +15,7 @@ export type RenderOptions = ReactEmail.Options & {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const colors = (config.theme?.extend?.colors || {}) as Record<string, string>;
export const render = (element: React.ReactNode, options?: RenderOptions) => {
export const render = async (element: React.ReactNode, options?: RenderOptions) => {
const { branding, ...otherOptions } = options ?? {};
return ReactEmail.render(
@@ -36,7 +36,7 @@ export const render = (element: React.ReactNode, options?: RenderOptions) => {
);
};
export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions) => {
export const renderWithI18N = async (element: React.ReactNode, options?: RenderOptions) => {
const { branding, i18n, ...otherOptions } = options ?? {};
if (!i18n) {
@@ -62,24 +62,3 @@ export const renderWithI18N = (element: React.ReactNode, options?: RenderOptions
otherOptions,
);
};
export const renderAsync = async (element: React.ReactNode, options?: RenderOptions) => {
const { branding, ...otherOptions } = options ?? {};
return await ReactEmail.renderAsync(
<BrandingProvider branding={branding}>
<Tailwind
config={{
theme: {
extend: {
colors,
},
},
}}
>
{element}
</Tailwind>
</BrandingProvider>,
otherOptions,
);
};
@@ -17,8 +17,9 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
<Trans>
This document was sent using{' '}
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
Documenso.
Documenso
</Link>
.
</Trans>
</Text>
)}
@@ -106,7 +106,7 @@ export const ConfirmTeamEmailTemplate = ({
<Text className="mt-2 text-sm">
<Trans>
You can revoke access at any time in your team settings on Documenso{' '}
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
<Link href={`${baseUrl}/settings/teams`}>here</Link>.
</Trans>
</Text>
</Section>
+2 -1
View File
@@ -73,8 +73,9 @@ export const ResetPasswordTemplate = ({
Didn't request a password change? We are here to help you secure your account,
just{' '}
<Link className="text-documenso-700 font-normal" href="mailto:hi@documenso.com">
contact us.
contact us
</Link>
.
</Trans>
</Text>
</Section>
+2
View File
@@ -63,6 +63,7 @@ export class LocalJobProvider extends BaseJobProvider {
jobId: pendingJob.id,
jobDefinitionId: pendingJob.jobId,
data: options,
isRetry: false,
});
}),
);
@@ -198,6 +199,7 @@ export class LocalJobProvider extends BaseJobProvider {
jobId,
jobDefinitionId: backgroundJob.jobId,
data: options,
isRetry: true,
});
}
@@ -5,6 +5,7 @@ import { EnvelopeType, ReadStatus, SendStatus, SigningStatus } from '@prisma/cli
import { mailer } from '@documenso/email/mailer';
import DocumentCancelTemplate from '@documenso/email/templates/document-cancel';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -77,7 +78,8 @@ export const run = async ({
const recipientsToNotify = envelope.recipients.filter(
(recipient) =>
(recipient.sendStatus === SendStatus.SENT || recipient.readStatus === ReadStatus.OPENED) &&
recipient.signingStatus !== SigningStatus.REJECTED,
recipient.signingStatus !== SigningStatus.REJECTED &&
isRecipientEmailValidForSending(recipient),
);
await io.runTask('send-cancellation-emails', async () => {
@@ -12,6 +12,7 @@ import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app';
import { getEmailContext } from '../../../server-only/email/get-email-context';
import { extractDerivedDocumentEmailSettings } from '../../../types/document-email';
import { unsafeBuildEnvelopeIdQuery } from '../../../utils/envelope';
import { isRecipientEmailValidForSending } from '../../../utils/recipients';
import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n';
import type { JobRunIO } from '../../client/_internal/job';
import type { TSendRecipientSignedEmailJobDefinition } from './send-recipient-signed-email';
@@ -79,8 +80,8 @@ export const run = async ({
const recipientReference = recipientName || recipientEmail;
// Don't send notification if the owner is the one who signed
if (owner.email === recipientEmail) {
// Don't send notification if the owner is the one who signed.
if (owner.email === recipientEmail || !isRecipientEmailValidForSending(recipient)) {
return;
}
@@ -6,6 +6,7 @@ import { EnvelopeType, SendStatus, SigningStatus } from '@prisma/client';
import { mailer } from '@documenso/email/mailer';
import DocumentRejectedEmail from '@documenso/email/templates/document-rejected';
import DocumentRejectionConfirmedEmail from '@documenso/email/templates/document-rejection-confirmed';
import { isRecipientEmailValidForSending } from '@documenso/lib/utils/recipients';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../../client-only/providers/i18n-server';
@@ -85,36 +86,38 @@ export const run = async ({
const i18n = await getI18nInstance(emailLanguage);
// Send confirmation email to the recipient who rejected
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: envelope.title,
documentOwnerName: envelope.user.name || envelope.user.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
if (isRecipientEmailValidForSending(recipient)) {
await io.runTask('send-rejection-confirmation-email', async () => {
const recipientTemplate = createElement(DocumentRejectionConfirmedEmail, {
recipientName: recipient.name,
documentName: envelope.title,
documentOwnerName: envelope.user.name || envelope.user.email,
reason: recipient.rejectionReason || '',
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
const [html, text] = await Promise.all([
renderEmailWithI18N(recipientTemplate, { lang: emailLanguage, branding }),
renderEmailWithI18N(recipientTemplate, {
lang: emailLanguage,
branding,
plainText: true,
}),
]);
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
html,
text,
await mailer.sendMail({
to: {
name: recipient.name,
address: recipient.email,
},
from: senderEmail,
replyTo: replyToEmail,
subject: i18n._(msg`Document "${envelope.title}" - Rejection Confirmed`),
html,
text,
});
});
});
}
// Send notification email to document owner
await io.runTask('send-owner-notification-email', async () => {

Some files were not shown because too many files have changed in this diff Show More