From a05251d5eebab9be69eed93b7446f8bcaa1ab9d2 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Tue, 3 Mar 2026 16:19:38 +1100 Subject: [PATCH 001/110] v2.7.0 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index 4246c008f..4130066fd 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -105,5 +105,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.6.1" + "version": "2.7.0" } diff --git a/package-lock.json b/package-lock.json index e6d244ac6..903e1306c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.6.1", + "version": "2.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.6.1", + "version": "2.7.0", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -651,7 +651,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.6.1", + "version": "2.7.0", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index a730123d3..4d4956bad 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.6.1", + "version": "2.7.0", "scripts": { "postinstall": "patch-package", "build": "turbo run build", From f1323679aa0a47c24be1158930de3e453dac2821 Mon Sep 17 00:00:00 2001 From: Catalin Pit Date: Tue, 3 Mar 2026 13:24:57 +0200 Subject: [PATCH 002/110] fix: use default field meta for embedding template fields (#2556) --- packages/lib/server-only/field/set-fields-for-document.ts | 3 ++- packages/lib/server-only/field/set-fields-for-template.ts | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/lib/server-only/field/set-fields-for-document.ts b/packages/lib/server-only/field/set-fields-for-document.ts index c69a7f77a..16b496559 100644 --- a/packages/lib/server-only/field/set-fields-for-document.ts +++ b/packages/lib/server-only/field/set-fields-for-document.ts @@ -8,6 +8,7 @@ import { validateRadioField } from '@documenso/lib/advanced-fields-validation/va import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs'; import { + FIELD_META_DEFAULT_VALUES, type TFieldMetaSchema as FieldMeta, ZCheckboxFieldMeta, ZDropdownFieldMeta, @@ -143,7 +144,7 @@ export const setFieldsForDocument = async ({ const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) - : undefined; + : FIELD_META_DEFAULT_VALUES[field.type]; if (field.type === FieldType.TEXT && field.fieldMeta) { const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta); diff --git a/packages/lib/server-only/field/set-fields-for-template.ts b/packages/lib/server-only/field/set-fields-for-template.ts index 9d0d81f52..0e6e95cb5 100644 --- a/packages/lib/server-only/field/set-fields-for-template.ts +++ b/packages/lib/server-only/field/set-fields-for-template.ts @@ -6,6 +6,7 @@ import { validateNumberField } from '@documenso/lib/advanced-fields-validation/v import { validateRadioField } from '@documenso/lib/advanced-fields-validation/validate-radio'; import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; import { + FIELD_META_DEFAULT_VALUES, type TFieldMetaSchema as FieldMeta, ZCheckboxFieldMeta, ZDropdownFieldMeta, @@ -116,7 +117,9 @@ export const setFieldsForTemplate = async ({ // Disabling as wrapping promises here causes type issues // eslint-disable-next-line @typescript-eslint/promise-function-async linkedFields.map(async (field) => { - const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined; + const parsedFieldMeta = field.fieldMeta + ? ZFieldMetaSchema.parse(field.fieldMeta) + : FIELD_META_DEFAULT_VALUES[field.type]; if (field.type === FieldType.TEXT && field.fieldMeta) { const textFieldParsedMeta = ZTextFieldMeta.parse(field.fieldMeta); From 7d3a56a0060cc205b760151298b6616f8c56622e Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Wed, 4 Mar 2026 22:34:53 +1100 Subject: [PATCH 003/110] feat: add admin ability to move subscription between orgs (#2558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Adds a new admin action to move a subscription (and Stripe customerId) from one organisation to another owned by the same user - The target organisation must be on the free plan (no active subscription) — enforces paid → free only - The source organisation's claim is reset to the free plan after the move ## How it works A "Move Subscription" option appears in the actions dropdown of the organisations table (on the admin user detail page) for any org with an active or past-due subscription. Clicking it opens a dialog where the admin selects a target org from a filtered list of eligible (free-plan) orgs owned by the same user. The backend performs the swap atomically in a single Prisma transaction: 1. Deletes any stale inactive subscription on the target org 2. Moves the `customerId` from source to target org 3. Reassigns the `Subscription` record to the target org 4. Copies claim entitlements to the target org 5. Resets the source org's claim to FREE No Stripe API calls are made — the Stripe subscription and customer remain unchanged; only the DB-level org association is updated. ## Files changed - **New:** `packages/trpc/server/admin-router/swap-organisation-subscription.types.ts` — Zod schemas - **New:** `packages/trpc/server/admin-router/swap-organisation-subscription.ts` — Admin mutation - **New:** `apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx` — Dialog component - **Modified:** `packages/trpc/server/admin-router/router.ts` — Register route - **Modified:** `apps/remix/app/components/tables/admin-organisations-table.tsx` — Add action menu item --- ...zel-beam-swap-subscription-between-orgs.md | 151 ++++++++++++++ .../admin-swap-subscription-dialog.tsx | 197 ++++++++++++++++++ .../tables/admin-organisations-table.tsx | 44 +++- packages/trpc/server/admin-router/router.ts | 2 + .../swap-organisation-subscription.ts | 140 +++++++++++++ .../swap-organisation-subscription.types.ts | 15 ++ 6 files changed, 547 insertions(+), 2 deletions(-) create mode 100644 .agents/plans/dark-hazel-beam-swap-subscription-between-orgs.md create mode 100644 apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx create mode 100644 packages/trpc/server/admin-router/swap-organisation-subscription.ts create mode 100644 packages/trpc/server/admin-router/swap-organisation-subscription.types.ts diff --git a/.agents/plans/dark-hazel-beam-swap-subscription-between-orgs.md b/.agents/plans/dark-hazel-beam-swap-subscription-between-orgs.md new file mode 100644 index 000000000..676c16808 --- /dev/null +++ b/.agents/plans/dark-hazel-beam-swap-subscription-between-orgs.md @@ -0,0 +1,151 @@ +--- +date: 2026-03-04 +title: Swap Subscription Between Orgs +--- + +## Overview + +Add the ability for admins to move a subscription (and its associated Stripe customerId) from one organisation to another, when viewing a user in the admin panel. The target org must be owned by the same user and must be on the free plan (no existing active subscription). + +## Context & Data Model + +- `Organisation` has a 1:1 optional `Subscription` and a `customerId` (Stripe customer ID, `@unique`) +- `Organisation` has a 1:1 `OrganisationClaim` that tracks entitlements (team count, member count, feature flags) +- `Subscription` also stores a redundant `customerId` and has `organisationId` (`@unique`) +- When a subscription is removed from an org, its `OrganisationClaim` should be reset to the FREE claim +- Relationship chain: `User --owns--> Organisation --has--> Subscription + OrganisationClaim` + +## Constraints + +- **paid → free only**: The target org must NOT have an active subscription (status ACTIVE or PAST_DUE). It must be on the free plan. +- **same owner**: Both source and target orgs must be owned by the same user (the user being viewed). +- The `customerId` must move with the subscription to the target org (cleared from source, set on target). +- The Stripe subscription object itself is NOT modified — only the DB-level mapping changes. The Stripe customer stays the same; we just reassociate it to a different org. + +## Implementation Plan + +### 1. Backend: TRPC Admin Route + +**Files to create:** + +- `packages/trpc/server/admin-router/swap-organisation-subscription.types.ts` +- `packages/trpc/server/admin-router/swap-organisation-subscription.ts` + +**Request schema (`ZSwapOrganisationSubscriptionRequestSchema`):** + +```ts +z.object({ + sourceOrganisationId: z.string(), + targetOrganisationId: z.string(), +}); +``` + +**Response schema:** `z.void()` + +**Route logic (in a single `prisma.$transaction`):** + +1. Fetch source org with `subscription` + `organisationClaim` +2. Fetch target org with `subscription` + `organisationClaim` +3. Validate: + - Source org has an active subscription (status `ACTIVE` or `PAST_DUE`) + - Target org does NOT have an active subscription (no subscription record, or status `INACTIVE`) + - Both orgs have the same `ownerUserId` +4. In a transaction: + a. Clear `customerId` on source org (set to `null`) + b. Set `customerId` on target org to the source's `customerId` + c. Move the `Subscription` record: update `organisationId` to target org ID + d. Copy the source org's `OrganisationClaim` entitlements to the target org's `OrganisationClaim` (`originalSubscriptionClaimId`, `teamCount`, `memberCount`, `envelopeItemCount`, `flags`) + e. Reset the source org's `OrganisationClaim` to the FREE claim (using `createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE])` pattern from `on-subscription-deleted.ts`) + +**Note on ordering:** Because `Organisation.customerId` is `@unique`, we must clear the source first, then set the target — or do both in a transaction that handles the constraint. Prisma transactions handle this correctly as they apply all writes atomically. + +**Register the route:** + +- Import in `packages/trpc/server/admin-router/router.ts` +- Add under `organisation` as `swapSubscription` +- Call path: `trpc.admin.organisation.swapSubscription` + +### 2. Frontend: Dialog Component + +**File to create:** + +- `apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx` + +**Props:** + +```ts +type AdminSwapSubscriptionDialogProps = { + trigger?: React.ReactNode; + sourceOrganisationId: string; + sourceOrganisationName: string; + userId: number; +} & Omit; +``` + +**Dialog behavior:** + +1. Opens when the trigger is clicked (from the organisations table actions dropdown) +2. Fetches the user's owned orgs via `trpc.admin.organisation.find.useQuery({ ownerUserId: userId })` +3. Filters to only show orgs that are on the free plan (no active subscription) and excludes the source org +4. Displays a select dropdown to pick the target org +5. Shows a warning alert: "This will move the subscription from {source} to {target}. The source organisation will be reset to the free plan." +6. On submit, calls `trpc.admin.organisation.swapSubscription.useMutation()` +7. On success, shows a toast, invalidates relevant queries, and closes the dialog + +**UI layout (following existing dialog patterns like `admin-organisation-create-dialog.tsx`):** + +- `DialogHeader` with title "Move Subscription" and description +- A select dropdown listing eligible target orgs (name + url) +- An `Alert` explaining what will happen +- `DialogFooter` with Cancel + "Move Subscription" buttons (submit button uses `loading` prop) + +### 3. Frontend: Wire into the Organisations Table + +**File to modify:** + +- `apps/remix/app/components/tables/admin-organisations-table.tsx` + +**Changes:** + +- Import the `AdminSwapSubscriptionDialog` +- Add a new prop `ownerUserId?: number` to `AdminOrganisationsTableOptions` (needed so the dialog can query other owned orgs) +- Add a new dropdown menu item in the actions column: "Move Subscription" with `ArrowRightLeftIcon` from lucide +- Only render this item when the org row has an active subscription (`subscription?.status === 'ACTIVE' || subscription?.status === 'PAST_DUE'`) +- The menu item renders inside `AdminSwapSubscriptionDialog` with `trigger` prop as the menu item + +### 4. Frontend: Pass userId from User Detail Page + +**File to modify:** + +- `apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx` + +**Changes:** + +- Pass `ownerUserId={user.id}` to `` so it can forward this to the swap dialog + +## File Change Summary + +| File | Action | Description | +| --------------------------------------------------------------------------- | ---------- | ---------------------------------------------------------- | +| `packages/trpc/server/admin-router/swap-organisation-subscription.types.ts` | **Create** | Request/response Zod schemas + TS types | +| `packages/trpc/server/admin-router/swap-organisation-subscription.ts` | **Create** | Admin mutation with prisma transaction | +| `packages/trpc/server/admin-router/router.ts` | **Modify** | Register route at `organisation.swapSubscription` | +| `apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx` | **Create** | Dialog for selecting target org | +| `apps/remix/app/components/tables/admin-organisations-table.tsx` | **Modify** | Add "Move Subscription" action + accept `ownerUserId` prop | +| `apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx` | **Modify** | Pass `ownerUserId={user.id}` to table | + +## Edge Cases & Considerations + +1. **Stripe customer stays the same**: The Stripe subscription is tied to a Stripe customer. We move the `customerId` to the target org, so webhook lookups (`findFirst where customerId`) will correctly resolve to the target org going forward. + +2. **`@unique` constraint on `Organisation.customerId`**: Must clear source before setting target within the transaction. Prisma interactive transactions handle this correctly. + +3. **`@unique` constraint on `Subscription.organisationId`**: Since the target org should not have a subscription record, updating the existing subscription's `organisationId` to the target should work. If the target has an INACTIVE subscription record, we need to delete it first. + +4. **Target org has INACTIVE subscription**: The target org might have a stale INACTIVE subscription from a previous cancellation. In this case, delete the target's old subscription record before moving the source's subscription over. + +5. **Seat-based plans**: If the subscription is seat-based, the Stripe quantity may not match the target org's member count. Consider calling `syncMemberCountWithStripeSeatPlan` after the swap as a post-transaction step. + +6. **OrganisationClaim transfer**: Copy `originalSubscriptionClaimId`, `teamCount`, `memberCount`, `envelopeItemCount`, and `flags` from source claim to target claim. Reset source claim to FREE. + +7. **No Stripe API calls needed**: This is purely a DB-level reassociation. The Stripe subscription, customer, and payment method all remain unchanged. diff --git a/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx b/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx new file mode 100644 index 000000000..a912fd28d --- /dev/null +++ b/apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx @@ -0,0 +1,197 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; + +import { AppError } from '@documenso/lib/errors/app-error'; +import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@documenso/ui/primitives/dialog'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type AdminSwapSubscriptionDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + sourceOrganisationId: string; + sourceOrganisationName: string; + userId: number; +}; + +export const AdminSwapSubscriptionDialog = ({ + open, + onOpenChange, + sourceOrganisationId, + sourceOrganisationName, + userId, +}: AdminSwapSubscriptionDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [selectedOrgId, setSelectedOrgId] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const { data: orgsData } = trpc.admin.organisation.find.useQuery( + { + ownerUserId: userId, + perPage: 100, + }, + { + enabled: open, + }, + ); + + const trpcUtils = trpc.useUtils(); + + const eligibleOrgs = useMemo(() => { + if (!orgsData?.data) { + return []; + } + + return orgsData.data.filter((org) => { + if (org.id === sourceOrganisationId) { + return false; + } + + const hasActiveSubscription = + org.subscription && + (org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE'); + + return !hasActiveSubscription; + }); + }, [orgsData, sourceOrganisationId]); + + const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId); + + const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation(); + + const onSubmit = async () => { + if (!selectedOrgId) { + return; + } + + setIsSubmitting(true); + + try { + await swapSubscription({ + sourceOrganisationId, + targetOrganisationId: selectedOrgId, + }); + + await trpcUtils.admin.organisation.find.invalidate(); + await trpcUtils.admin.organisation.get.invalidate(); + + onOpenChange(false); + + toast({ + title: t`Success`, + description: t`Subscription moved successfully`, + duration: 5000, + }); + } catch (err) { + const error = AppError.parseError(err); + + console.error(error); + + toast({ + title: t`Error`, + description: t`Failed to move subscription. Please try again.`, + variant: 'destructive', + }); + } finally { + setIsSubmitting(false); + } + }; + + useEffect(() => { + if (!open) { + setSelectedOrgId(''); + } + }, [open]); + + return ( + !isSubmitting && onOpenChange(value)}> + + + + Move Subscription + + + + + Move the subscription from "{sourceOrganisationName}" to another organisation owned by + this user. + + + + +
+
+ + + + + {eligibleOrgs.length === 0 && orgsData && ( +

+ No eligible organisations found. The target must be on the free plan. +

+ )} +
+ + {selectedOrg && ( + + + + This will move the subscription from "{sourceOrganisationName}" to " + {selectedOrg.name}". The source organisation will be reset to the free plan. + + + + )} + + + + + + +
+
+
+ ); +}; diff --git a/apps/remix/app/components/tables/admin-organisations-table.tsx b/apps/remix/app/components/tables/admin-organisations-table.tsx index eab94c199..1da49067d 100644 --- a/apps/remix/app/components/tables/admin-organisations-table.tsx +++ b/apps/remix/app/components/tables/admin-organisations-table.tsx @@ -1,8 +1,9 @@ -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useLingui } from '@lingui/react/macro'; import { Trans } from '@lingui/react/macro'; import { + ArrowRightLeftIcon, CreditCardIcon, ExternalLinkIcon, MoreHorizontalIcon, @@ -29,6 +30,8 @@ import { import { Skeleton } from '@documenso/ui/primitives/skeleton'; import { TableCell } from '@documenso/ui/primitives/table'; +import { AdminSwapSubscriptionDialog } from '~/components/dialogs/admin-swap-subscription-dialog'; + type AdminOrganisationsTableOptions = { ownerUserId?: number; memberUserId?: number; @@ -44,6 +47,12 @@ export const AdminOrganisationsTable = ({ }: AdminOrganisationsTableOptions) => { const { t, i18n } = useLingui(); + const [swapSource, setSwapSource] = useState<{ + id: string; + name: string; + ownerId: number; + } | null>(null); + const [searchParams] = useSearchParams(); const updateSearchParams = useUpdateSearchParams(); @@ -143,7 +152,7 @@ export const AdminOrganisationsTable = ({ cell: ({ row }) => ( - + @@ -172,6 +181,23 @@ export const AdminOrganisationsTable = ({ {!row.original.customerId &&  (N/A)} + + {row.original.subscription && + (row.original.subscription.status === 'ACTIVE' || + row.original.subscription.status === 'PAST_DUE') && ( + + setSwapSource({ + id: row.original.id, + name: row.original.name, + ownerId: row.original.owner.id, + }) + } + > + + Move Subscription + + )} ), @@ -227,6 +253,20 @@ export const AdminOrganisationsTable = ({ ) : null } + + {swapSource && ( + { + if (!open) { + setSwapSource(null); + } + }} + /> + )} ); }; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index dd472705f..47fa286e0 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -22,6 +22,7 @@ import { reregisterEmailDomainRoute } from './reregister-email-domain'; import { resealDocumentRoute } from './reseal-document'; import { resetTwoFactorRoute } from './reset-two-factor-authentication'; import { resyncLicenseRoute } from './resync-license'; +import { swapOrganisationSubscriptionRoute } from './swap-organisation-subscription'; import { updateAdminOrganisationRoute } from './update-admin-organisation'; import { updateOrganisationMemberRoleRoute } from './update-organisation-member-role'; import { updateRecipientRoute } from './update-recipient'; @@ -35,6 +36,7 @@ export const adminRouter = router({ get: getAdminOrganisationRoute, create: createAdminOrganisationRoute, update: updateAdminOrganisationRoute, + swapSubscription: swapOrganisationSubscriptionRoute, }, organisationMember: { promoteToOwner: promoteMemberToOwnerRoute, diff --git a/packages/trpc/server/admin-router/swap-organisation-subscription.ts b/packages/trpc/server/admin-router/swap-organisation-subscription.ts new file mode 100644 index 000000000..d7b100047 --- /dev/null +++ b/packages/trpc/server/admin-router/swap-organisation-subscription.ts @@ -0,0 +1,140 @@ +import { SubscriptionStatus } from '@prisma/client'; + +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation'; +import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription'; +import { prisma } from '@documenso/prisma'; + +import { adminProcedure } from '../trpc'; +import { + ZSwapOrganisationSubscriptionRequestSchema, + ZSwapOrganisationSubscriptionResponseSchema, +} from './swap-organisation-subscription.types'; + +export const swapOrganisationSubscriptionRoute = adminProcedure + .input(ZSwapOrganisationSubscriptionRequestSchema) + .output(ZSwapOrganisationSubscriptionResponseSchema) + .mutation(async ({ input, ctx }) => { + const { sourceOrganisationId, targetOrganisationId } = input; + + ctx.logger.info({ + input: { + sourceOrganisationId, + targetOrganisationId, + }, + }); + + if (sourceOrganisationId === targetOrganisationId) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Source and target organisations must be different', + }); + } + + const sourceOrg = await prisma.organisation.findUnique({ + where: { id: sourceOrganisationId }, + include: { + subscription: true, + organisationClaim: true, + }, + }); + + if (!sourceOrg) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Source organisation not found', + }); + } + + if ( + !sourceOrg.subscription || + (sourceOrg.subscription.status !== SubscriptionStatus.ACTIVE && + sourceOrg.subscription.status !== SubscriptionStatus.PAST_DUE) + ) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Source organisation does not have an active subscription', + }); + } + + const targetOrg = await prisma.organisation.findUnique({ + where: { id: targetOrganisationId }, + include: { + subscription: true, + organisationClaim: true, + }, + }); + + if (!targetOrg) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Target organisation not found', + }); + } + + if (sourceOrg.ownerUserId !== targetOrg.ownerUserId) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Both organisations must be owned by the same user', + }); + } + + if ( + targetOrg.subscription && + (targetOrg.subscription.status === SubscriptionStatus.ACTIVE || + targetOrg.subscription.status === SubscriptionStatus.PAST_DUE) + ) { + throw new AppError(AppErrorCode.INVALID_REQUEST, { + message: 'Target organisation already has an active subscription', + }); + } + + const customerId = sourceOrg.customerId ?? sourceOrg.subscription.customerId; + + await prisma.$transaction(async (tx) => { + // Delete stale INACTIVE subscription on target if present. + if (targetOrg.subscription) { + await tx.subscription.delete({ + where: { id: targetOrg.subscription.id }, + }); + } + + // Clear customerId on source org to avoid unique constraint violation. + await tx.organisation.update({ + where: { id: sourceOrganisationId }, + data: { customerId: null }, + }); + + // Set customerId on target org. + await tx.organisation.update({ + where: { id: targetOrganisationId }, + data: { customerId }, + }); + + // Move the subscription record to the target org. + await tx.subscription.update({ + where: { id: sourceOrg.subscription!.id }, + data: { organisationId: targetOrganisationId }, + }); + + // Copy source org's claim entitlements to target org's claim. + if (sourceOrg.organisationClaim && targetOrg.organisationClaim) { + await tx.organisationClaim.update({ + where: { id: targetOrg.organisationClaim.id }, + data: { + originalSubscriptionClaimId: sourceOrg.organisationClaim.originalSubscriptionClaimId, + teamCount: sourceOrg.organisationClaim.teamCount, + memberCount: sourceOrg.organisationClaim.memberCount, + envelopeItemCount: sourceOrg.organisationClaim.envelopeItemCount, + flags: sourceOrg.organisationClaim.flags, + }, + }); + } + + // Reset source org's claim to FREE. + if (sourceOrg.organisationClaim) { + await tx.organisationClaim.update({ + where: { id: sourceOrg.organisationClaim.id }, + data: { + originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE, + ...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]), + }, + }); + } + }); + }); diff --git a/packages/trpc/server/admin-router/swap-organisation-subscription.types.ts b/packages/trpc/server/admin-router/swap-organisation-subscription.types.ts new file mode 100644 index 000000000..5a82f9aa0 --- /dev/null +++ b/packages/trpc/server/admin-router/swap-organisation-subscription.types.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const ZSwapOrganisationSubscriptionRequestSchema = z.object({ + sourceOrganisationId: z.string(), + targetOrganisationId: z.string(), +}); + +export const ZSwapOrganisationSubscriptionResponseSchema = z.void(); + +export type TSwapOrganisationSubscriptionRequest = z.infer< + typeof ZSwapOrganisationSubscriptionRequestSchema +>; +export type TSwapOrganisationSubscriptionResponse = z.infer< + typeof ZSwapOrganisationSubscriptionResponseSchema +>; From 7f5f2b22ed18fbf794d148dac0e11fe469bf3e36 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 5 Mar 2026 13:56:40 +1100 Subject: [PATCH 004/110] feat: add seal-document sweep job and admin unsealed documents page (#2563) --- .../routes/_authenticated+/admin+/_layout.tsx | 15 ++ .../admin+/unsealed-documents._index.tsx | 186 ++++++++++++++++++ package.json | 4 +- packages/lib/jobs/client.ts | 2 + .../internal/seal-document-sweep.handler.ts | 105 ++++++++++ .../internal/seal-document-sweep.ts | 30 +++ .../admin/admin-find-unsealed-documents.ts | 102 ++++++++++ .../admin-router/find-unsealed-documents.ts | 16 ++ .../find-unsealed-documents.types.ts | 31 +++ packages/trpc/server/admin-router/router.ts | 2 + 10 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx create mode 100644 packages/lib/jobs/definitions/internal/seal-document-sweep.handler.ts create mode 100644 packages/lib/jobs/definitions/internal/seal-document-sweep.ts create mode 100644 packages/lib/server-only/admin/admin-find-unsealed-documents.ts create mode 100644 packages/trpc/server/admin-router/find-unsealed-documents.ts create mode 100644 packages/trpc/server/admin-router/find-unsealed-documents.types.ts diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index 43fe8feda..a3e6ac2f2 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -1,5 +1,6 @@ import { Trans } from '@lingui/react/macro'; import { + AlertTriangleIcon, BarChart3, Building2Icon, FileStack, @@ -123,6 +124,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) { + + + ); + }, + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, [isResealing]); + + const onPaginationChange = (newPage: number, newPerPage: number) => { + updateSearchParams({ + page: newPage, + perPage: newPerPage, + }); + }; + + return ( +
+
+ +

+ Unsealed Documents +

+
+ +

+ + Documents where all recipients have signed but the document has not been sealed. Documents + stuck for more than 6 hours are no longer retried by the sweep job. + +

+ +
+ + {(table) => } + + + {isLoading && ( +
+ +
+ )} +
+
+ ); +} diff --git a/package.json b/package.json index 4d4956bad..62550e78f 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "scripts": { "postinstall": "patch-package", "build": "turbo run build", - "dev": "turbo run dev --filter=@documenso/remix", - "dev:remix": "turbo run dev --filter=@documenso/remix", + "dev": "npm run translate:compile && turbo run dev --filter=@documenso/remix", + "dev:remix": "npm run translate:compile && turbo run dev --filter=@documenso/remix", "dev:docs": "turbo run dev --filter=@documenso/documentation", "dev:openpage-api": "turbo run dev --filter=@documenso/openpage-api", "start": "turbo run start --filter=@documenso/remix --filter=@documenso/documentation --filter=@documenso/openpage-api", diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index af6241320..33ea6ee52 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -16,6 +16,7 @@ import { EXECUTE_WEBHOOK_JOB_DEFINITION } from './definitions/internal/execute-w import { EXPIRE_RECIPIENTS_SWEEP_JOB_DEFINITION } from './definitions/internal/expire-recipients-sweep'; import { PROCESS_RECIPIENT_EXPIRED_JOB_DEFINITION } from './definitions/internal/process-recipient-expired'; import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-document'; +import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep'; import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains'; /** @@ -29,6 +30,7 @@ export const jobsClient = new JobClient([ SEND_ORGANISATION_MEMBER_LEFT_EMAIL_JOB_DEFINITION, SEND_TEAM_DELETED_EMAIL_JOB_DEFINITION, SEAL_DOCUMENT_JOB_DEFINITION, + SEAL_DOCUMENT_SWEEP_JOB_DEFINITION, SEND_PASSWORD_RESET_SUCCESS_EMAIL_JOB_DEFINITION, SEND_SIGNING_REJECTION_EMAILS_JOB_DEFINITION, SEND_RECIPIENT_SIGNED_EMAIL_JOB_DEFINITION, diff --git a/packages/lib/jobs/definitions/internal/seal-document-sweep.handler.ts b/packages/lib/jobs/definitions/internal/seal-document-sweep.handler.ts new file mode 100644 index 000000000..a1d8dc527 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/seal-document-sweep.handler.ts @@ -0,0 +1,105 @@ +import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; +import { DateTime } from 'luxon'; + +import { kyselyPrisma, sql } from '@documenso/prisma'; + +import { mapSecondaryIdToDocumentId } from '../../../utils/envelope'; +import { jobs } from '../../client'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSealDocumentSweepJobDefinition } from './seal-document-sweep'; + +export const run = async ({ io }: { payload: TSealDocumentSweepJobDefinition; io: JobRunIO }) => { + const now = DateTime.now(); + const fifteenMinutesAgo = now.minus({ minutes: 15 }).toJSDate(); + const sixHoursAgo = now.minus({ hours: 6 }).toJSDate(); + + // Find all PENDING envelopes that should have been sealed but weren't. + // + // A document is ready to seal when either: + // 1. All recipients are SIGNED or have role CC (normal completion) + // 2. Any recipient has REJECTED (rejection triggers immediate seal) + // + // We only look at documents where the last action was between 15 minutes + // and 6 hours ago. The lower bound avoids racing with the normal seal-document + // job that fires on completion. The upper bound stops us from endlessly retrying + // documents that are stuck due to a deeper issue (e.g. corrupt PDF). + const unsealedEnvelopes = await kyselyPrisma.$kysely + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.secondaryId']) + .where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)) + .where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)) + .where('Envelope.deletedAt', 'is', null) + // Ensure there is at least one recipient. + .where((eb) => + eb.exists(eb.selectFrom('Recipient').whereRef('Recipient.envelopeId', '=', 'Envelope.id')), + ) + // Document is ready to seal: all recipients are SIGNED/CC, or any recipient REJECTED. + .where((eb) => + eb.or([ + // Case 1: All recipients are either SIGNED or CC. + eb.not( + eb.exists( + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.signingStatus', '!=', sql.lit(SigningStatus.SIGNED)) + .where('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ), + ), + // Case 2: Any recipient has rejected. + eb.exists( + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)), + ), + ]), + ) + // Exclude envelopes where a recipient signed/rejected within the last 15 minutes + // to avoid racing with the standard completion flow. + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.signedAt', '>', fifteenMinutesAgo), + ), + ), + ) + // Exclude envelopes where all activity is older than 6 hours. + // These are likely stuck due to a deeper issue and should not be retried. + .where((eb) => + eb.exists( + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.signedAt', '>', sixHoursAgo), + ), + ) + .limit(100) + .execute(); + + if (unsealedEnvelopes.length === 0) { + io.logger.info('No unsealed documents found'); + return; + } + + io.logger.info(`Found ${unsealedEnvelopes.length} unsealed documents`); + + await Promise.allSettled( + unsealedEnvelopes.map(async (envelope) => { + const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId); + + io.logger.info(`Triggering seal for document ${documentId} (${envelope.id})`); + + await jobs.triggerJob({ + name: 'internal.seal-document', + payload: { + documentId, + isResealing: true, + }, + }); + }), + ); +}; diff --git a/packages/lib/jobs/definitions/internal/seal-document-sweep.ts b/packages/lib/jobs/definitions/internal/seal-document-sweep.ts new file mode 100644 index 000000000..a8798a134 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/seal-document-sweep.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +import { type JobDefinition } from '../../client/_internal/job'; + +const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID = 'internal.seal-document-sweep'; + +const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA = z.object({}); + +export type TSealDocumentSweepJobDefinition = z.infer< + typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA +>; + +export const SEAL_DOCUMENT_SWEEP_JOB_DEFINITION = { + id: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID, + name: 'Seal Document Sweep', + version: '1.0.0', + trigger: { + name: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID, + schema: SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_SCHEMA, + cron: '*/15 * * * *', // Every 15 minutes. + }, + handler: async ({ payload, io }) => { + const handler = await import('./seal-document-sweep.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SEAL_DOCUMENT_SWEEP_JOB_DEFINITION_ID, + TSealDocumentSweepJobDefinition +>; diff --git a/packages/lib/server-only/admin/admin-find-unsealed-documents.ts b/packages/lib/server-only/admin/admin-find-unsealed-documents.ts new file mode 100644 index 000000000..41eefc9e8 --- /dev/null +++ b/packages/lib/server-only/admin/admin-find-unsealed-documents.ts @@ -0,0 +1,102 @@ +import { DocumentStatus, EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client'; + +import { kyselyPrisma, sql } from '@documenso/prisma'; + +import type { FindResultResponse } from '../../types/search-params'; + +export type AdminUnsealedDocument = { + id: string; + secondaryId: string; + title: string; + status: string; + createdAt: Date; + updatedAt: Date; + userId: number; + teamId: number; + ownerName: string | null; + ownerEmail: string; + lastSignedAt: Date | null; +}; + +export type AdminFindUnsealedDocumentsOptions = { + page?: number; + perPage?: number; +}; + +export const adminFindUnsealedDocuments = async ({ + page = 1, + perPage = 20, +}: AdminFindUnsealedDocumentsOptions): Promise> => { + const offset = Math.max(page - 1, 0) * perPage; + + const baseQuery = kyselyPrisma.$kysely + .selectFrom('Envelope') + .where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)) + .where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)) + .where('Envelope.deletedAt', 'is', null) + // Must have at least one recipient. + .where((eb) => + eb.exists(eb.selectFrom('Recipient').whereRef('Recipient.envelopeId', '=', 'Envelope.id')), + ) + // Document is ready to seal: all recipients are SIGNED/CC, or any recipient REJECTED. + .where((eb) => + eb.or([ + // Case 1: All recipients are either SIGNED or CC. + eb.not( + eb.exists( + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.signingStatus', '!=', sql.lit(SigningStatus.SIGNED)) + .where('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ), + ), + // Case 2: Any recipient has rejected. + eb.exists( + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)), + ), + ]), + ); + + const [data, countResult] = await Promise.all([ + baseQuery + .innerJoin('User', 'User.id', 'Envelope.userId') + .select([ + 'Envelope.id', + 'Envelope.secondaryId', + 'Envelope.title', + 'Envelope.status', + 'Envelope.createdAt', + 'Envelope.updatedAt', + 'Envelope.userId', + 'Envelope.teamId', + 'User.name as ownerName', + 'User.email as ownerEmail', + ]) + .select((eb) => + eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .select(sql`max("Recipient"."signedAt")`.as('lastSignedAt')) + .as('lastSignedAt'), + ) + .orderBy('Envelope.createdAt', 'desc') + .limit(perPage) + .offset(offset) + .execute(), + baseQuery.select(({ fn }) => [fn.countAll().as('count')]).execute(), + ]); + + const count = Number(countResult[0]?.count ?? 0); + + return { + data: data as unknown as AdminUnsealedDocument[], + count, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(count / perPage), + }; +}; diff --git a/packages/trpc/server/admin-router/find-unsealed-documents.ts b/packages/trpc/server/admin-router/find-unsealed-documents.ts new file mode 100644 index 000000000..965203fd5 --- /dev/null +++ b/packages/trpc/server/admin-router/find-unsealed-documents.ts @@ -0,0 +1,16 @@ +import { adminFindUnsealedDocuments } from '@documenso/lib/server-only/admin/admin-find-unsealed-documents'; + +import { adminProcedure } from '../trpc'; +import { + ZFindUnsealedDocumentsRequestSchema, + ZFindUnsealedDocumentsResponseSchema, +} from './find-unsealed-documents.types'; + +export const findUnsealedDocumentsRoute = adminProcedure + .input(ZFindUnsealedDocumentsRequestSchema) + .output(ZFindUnsealedDocumentsResponseSchema) + .query(async ({ input }) => { + const { page, perPage } = input; + + return await adminFindUnsealedDocuments({ page, perPage }); + }); diff --git a/packages/trpc/server/admin-router/find-unsealed-documents.types.ts b/packages/trpc/server/admin-router/find-unsealed-documents.types.ts new file mode 100644 index 000000000..9afa7fed4 --- /dev/null +++ b/packages/trpc/server/admin-router/find-unsealed-documents.types.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; + +import { ZFindResultResponse, ZFindSearchParamsSchema } from '@documenso/lib/types/search-params'; + +export const ZFindUnsealedDocumentsRequestSchema = ZFindSearchParamsSchema.pick({ + page: true, + perPage: true, +}).extend({ + perPage: z.number().optional().default(20), +}); + +export const ZAdminUnsealedDocumentSchema = z.object({ + id: z.string(), + secondaryId: z.string(), + title: z.string(), + status: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + userId: z.number(), + teamId: z.number(), + ownerName: z.string().nullable(), + ownerEmail: z.string(), + lastSignedAt: z.date().nullable(), +}); + +export const ZFindUnsealedDocumentsResponseSchema = ZFindResultResponse.extend({ + data: ZAdminUnsealedDocumentSchema.array(), +}); + +export type TFindUnsealedDocumentsRequest = z.infer; +export type TFindUnsealedDocumentsResponse = z.infer; diff --git a/packages/trpc/server/admin-router/router.ts b/packages/trpc/server/admin-router/router.ts index 47fa286e0..33d094d41 100644 --- a/packages/trpc/server/admin-router/router.ts +++ b/packages/trpc/server/admin-router/router.ts @@ -13,6 +13,7 @@ import { findDocumentJobsRoute } from './find-document-jobs'; import { findDocumentsRoute } from './find-documents'; import { findEmailDomainsRoute } from './find-email-domains'; import { findSubscriptionClaimsRoute } from './find-subscription-claims'; +import { findUnsealedDocumentsRoute } from './find-unsealed-documents'; import { findUserTeamsRoute } from './find-user-teams'; import { getAdminOrganisationRoute } from './get-admin-organisation'; import { getEmailDomainRoute } from './get-email-domain'; @@ -65,6 +66,7 @@ export const adminRouter = router({ }, document: { find: findDocumentsRoute, + findUnsealed: findUnsealedDocumentsRoute, delete: deleteDocumentRoute, reseal: resealDocumentRoute, findJobs: findDocumentJobsRoute, From 03e2e4f171b3d35f7464294d149724aa19456379 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Thu, 5 Mar 2026 02:58:29 +0000 Subject: [PATCH 005/110] docs: clarify placeholder support is envelope.* only (#2560) --- apps/docs/content/docs/developers/api/documents.mdx | 4 ++++ apps/docs/content/docs/developers/api/fields.mdx | 4 ++++ apps/docs/content/docs/developers/api/templates.mdx | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/apps/docs/content/docs/developers/api/documents.mdx b/apps/docs/content/docs/developers/api/documents.mdx index 05433cc3c..a21a2740b 100644 --- a/apps/docs/content/docs/developers/api/documents.mdx +++ b/apps/docs/content/docs/developers/api/documents.mdx @@ -269,6 +269,10 @@ Returns the full document object including recipients, fields, and envelope item Create a new document with optional recipients and fields in a single request. + + This endpoint automatically scans uploaded PDFs for [placeholder patterns](/docs/users/documents/advanced/pdf-placeholders) like `{"{{signature, r1}}"}` and creates fields at those locations. + + ``` POST /envelope/create Content-Type: multipart/form-data diff --git a/apps/docs/content/docs/developers/api/fields.mdx b/apps/docs/content/docs/developers/api/fields.mdx index 6d55fb2d7..71f680a62 100644 --- a/apps/docs/content/docs/developers/api/fields.mdx +++ b/apps/docs/content/docs/developers/api/fields.mdx @@ -473,6 +473,10 @@ Instead of specifying exact coordinates, you can position fields using placehold This approach is useful when generating PDFs programmatically or using templates with consistent layouts. + + Placeholder support is only available in `envelope.*` endpoints. `POST /template/use` does not support placeholder parsing. + + See the [PDF Placeholders](/docs/users/documents/advanced/pdf-placeholders) guide for the full placeholder format reference, including supported field types, recipient identifiers, and field options. --- diff --git a/apps/docs/content/docs/developers/api/templates.mdx b/apps/docs/content/docs/developers/api/templates.mdx index 91bdf5a07..8f5c5b667 100644 --- a/apps/docs/content/docs/developers/api/templates.mdx +++ b/apps/docs/content/docs/developers/api/templates.mdx @@ -240,6 +240,10 @@ Returns the full template object including recipients, fields, and metadata. Create a new document using a template. This is the primary way to use templates programmatically. + + This endpoint does not support [PDF placeholder parsing](/docs/users/documents/advanced/pdf-placeholders). Use `POST /envelope/create` for placeholder-based field positioning. + + ``` POST /template/use From 8b0231825f04a03331b97589f11419ce4901557d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 14:11:53 +1100 Subject: [PATCH 006/110] chore: extract translations (#2539) --- packages/lib/translations/de/web.po | 132 +++++++++++++- packages/lib/translations/en/web.po | 228 ++++++++++++++++++++++++- packages/lib/translations/es/web.po | 132 +++++++++++++- packages/lib/translations/fr/web.po | 132 +++++++++++++- packages/lib/translations/it/web.po | 132 +++++++++++++- packages/lib/translations/ja/web.po | 132 +++++++++++++- packages/lib/translations/ko/web.po | 132 +++++++++++++- packages/lib/translations/nl/web.po | 132 +++++++++++++- packages/lib/translations/pl/web.po | 136 ++++++++++++++- packages/lib/translations/pt-BR/web.po | 228 ++++++++++++++++++++++++- packages/lib/translations/zh/web.po | 132 +++++++++++++- 11 files changed, 1594 insertions(+), 54 deletions(-) diff --git a/packages/lib/translations/de/web.po b/packages/lib/translations/de/web.po index c4c8867c5..eacf3147b 100644 --- a/packages/lib/translations/de/web.po +++ b/packages/lib/translations/de/web.po @@ -1084,6 +1084,7 @@ msgstr "Aktion" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "Person nicht gefunden?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "Ccers" msgid "Center" msgstr "Zentrum" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "Empfänger ändern" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "Zeichenbeschränkung" @@ -2423,6 +2433,10 @@ msgstr "Checkbox-Werte" msgid "Checkout" msgstr "Abrechnung" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "Wählen Sie einen vorhandenen Empfänger unten aus, um fortzufahren" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "Dokumenteneinstellungen und Optionen vor dem Versand konfigurieren." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "E-Mail-Einstellungen für das Dokument konfigurieren" +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "Konfigurieren Sie die allgemeinen Einstellungen für die Vorlage." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "Sicherheitseinstellungen für das Dokument konfigurieren" +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "Erstellen Sie Ihr Konto und beginnen Sie mit dem modernen Dokumentensign #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "Dokumente, die Ihre Aufmerksamkeit erfordern, erscheinen hier" msgid "Documents Viewed" msgstr "Dokumente angesehen" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "Vorlage duplizieren" msgid "Duplicate values are not allowed" msgstr "Doppelte Werte sind nicht erlaubt" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Z. B. 0" @@ -4528,6 +4551,10 @@ msgstr "Beigefügte Dokument" msgid "Enclosed Documents" msgstr "Beigefügte Dokumente" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "Umschlagtitel" msgid "Envelope updated" msgstr "Umschlag aktualisiert" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "Fehler beim Laden des Dokuments" msgid "Failed to move folder" msgstr "Ordner konnte nicht verschoben werden" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "E-Mail-Domain konnte nicht erneut registriert werden" @@ -4887,6 +4919,10 @@ msgstr "Fehler beim Abmelden aller Sitzungen" msgid "Failed to sync license" msgstr "Lizenz konnte nicht synchronisiert werden" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "Konto konnte nicht entkoppelt werden" @@ -5099,6 +5135,10 @@ msgstr "Freie Unterschrift" msgid "Free Signature Settings" msgstr "Einstellungen für freie Unterschrift" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "DKIM-Datensätze generieren" msgid "Generate Links" msgstr "Links generieren" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "Globale Empfängerauthentifizierung" @@ -5668,6 +5712,14 @@ msgstr "Es ist momentan nicht Ihre Runde zum Unterschreiben. Bitte schauen Sie b msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "Es ist derzeit nicht deine Reihe zu unterschreiben. Du erhältst eine E-Mail mit Anweisungen, sobald es deine Reihe ist, das Dokument zu unterschreiben." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Tritt {organisationName} auf Documenso bei" @@ -5685,6 +5737,10 @@ msgstr "Beigetreten" msgid "Joined {0}" msgstr "Beigetreten {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "Zuletzt geändert" msgid "Last Retried" msgstr "Zuletzt erneut versucht" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "Dokumente in Ordner verschieben" msgid "Move Folder" msgstr "Ordner verschieben" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "Vorlage in Ordner verschieben" @@ -6248,6 +6314,10 @@ msgstr "Vorlage in Ordner verschieben" msgid "Move Templates to Folder" msgstr "Vorlagen in Ordner verschieben" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "Keine aktiven Entwürfe" msgid "No documents found" msgstr "Keine Dokumente gefunden" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "Keine E‑Mail erkannt" @@ -6840,6 +6914,7 @@ msgstr "Organisationseinstellungen überschreiben" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "Bitte laden Sie ein Dokument hoch, um fortzufahren." msgid "Please upload a logo" msgstr "Bitte laden Sie ein Logo hoch" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "Erforderliches Feld" msgid "Required scopes" msgstr "Erforderliche Bereiche" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "Dokument wieder versiegeln" @@ -8026,6 +8113,10 @@ msgstr "Speichern fehlgeschlagen" msgid "Save Template" msgstr "Vorlage speichern" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "Versiegelungsauftrag gestartet" @@ -8173,6 +8264,10 @@ msgstr "Wählen Sie einen Ereignistyp aus" msgid "Select an option" msgstr "Option auswählen" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "Wählen Sie eine Organisation, um Teams anzuzeigen" @@ -8873,6 +8968,10 @@ msgstr "Quelle" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "Durch Leerzeichen getrennte Liste von Domains. Leer lassen, um alle Domains zuzulassen." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8922,6 +9021,10 @@ msgstr "Stripe-Kunde erfolgreich erstellt" msgid "Stripe Customer ID" msgstr "Stripe-Kunden-ID" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "Betreff" @@ -8973,12 +9076,17 @@ msgstr "Abonnementansprüche" msgid "Subscription invalid" msgstr "Abonnement ungültig" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "Abonnementstatus" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "Systemanforderungen" msgid "System Theme" msgstr "Systemthema" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10070,6 +10182,11 @@ msgstr "Dadurch werden der Status aller E-Mail-Domains dieser Organisation über msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "Dadurch wird die bestehende SES-Identität für <0>{0} gelöscht und mit denselben DKIM-Schlüsseln neu erstellt. Der Benutzer muss seine DNS-Einträge nicht aktualisieren. Der Domainstatus wird auf \"Ausstehend\" zurückgesetzt." +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "Diese werden NUR Funktionsflags zurückspielen, die auf wahr gesetzt sind; alles, was im ursprünglichen Anspruch deaktiviert ist, wird nicht zurückportiert." @@ -10107,6 +10224,7 @@ msgstr "Zeitzone" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "Titel" @@ -10539,6 +10657,11 @@ msgstr "Verknüpfung aufheben" msgid "Unpin" msgstr "Lösen" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "Unbetitelte Gruppe" @@ -12666,4 +12789,3 @@ msgstr "Ihr Verifizierungscode:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/en/web.po b/packages/lib/translations/en/web.po index 092118758..af80512ce 100644 --- a/packages/lib/translations/en/web.po +++ b/packages/lib/translations/en/web.po @@ -1077,7 +1077,9 @@ msgstr "Action" #: apps/remix/app/components/tables/team-members-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -1085,6 +1087,9 @@ msgid "Actions" msgstr "Actions" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.members.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx msgid "Active" @@ -1325,6 +1330,7 @@ msgid "Admin" msgstr "Admin" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Admin Actions" msgstr "Admin Actions" @@ -1433,6 +1439,10 @@ msgstr "All signatures have been voided." msgid "All signing links have been copied to your clipboard." msgstr "All signing links have been copied to your clipboard." +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +msgid "All Statuses" +msgstr "All Statuses" + #: apps/remix/app/components/general/app-command-menu.tsx msgid "All templates" msgstr "All templates" @@ -2222,6 +2232,7 @@ msgstr "Can't find someone?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2310,6 +2321,7 @@ msgstr "Can't find someone?" #: apps/remix/app/components/general/teams/team-email-usage.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx #: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx #: packages/ui/primitives/document-flow/send-document-action-dialog.tsx @@ -2363,10 +2375,18 @@ msgstr "Ccers" msgid "Center" msgstr "Center" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "Change language" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "Change Recipient" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "Change theme" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "Character limit" @@ -2408,6 +2428,10 @@ msgstr "Checkbox values" msgid "Checkout" msgstr "Checkout" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "Chinese" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "Choose an existing recipient from below to continue" @@ -2615,8 +2639,8 @@ msgid "Configure document settings and options before sending." msgstr "Configure document settings and options before sending." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "Configure email settings for the document" +msgid "Configure email settings for the document." +msgstr "Configure email settings for the document." #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2633,8 +2657,8 @@ msgid "Configure general settings for the template." msgstr "Configure general settings for the template." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "Configure security settings for the document" +msgid "Configure security settings for the document." +msgstr "Configure security settings for the document." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -2826,6 +2850,7 @@ msgstr "Copied field to clipboard" #: apps/remix/app/components/general/webhook-logs-sheet.tsx #: apps/remix/app/components/tables/admin-document-logs-table.tsx #: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: packages/ui/components/document/document-share-button.tsx @@ -2859,6 +2884,10 @@ msgstr "Copy Signing Links" msgid "Copy token" msgstr "Copy token" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Copy Value" +msgstr "Copy Value" + #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -3059,6 +3088,10 @@ msgstr "Create your account and start using state-of-the-art document signing. O #: apps/remix/app/components/tables/settings-security-passkey-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -3574,6 +3607,7 @@ msgstr "Discord" #: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Display Name" msgstr "Display Name" @@ -3593,6 +3627,7 @@ msgstr "Distribution Method" msgid "DKIM records generated. Please add the DNS records to verify your domain." msgstr "DKIM records generated. Please add the DNS records to verify your domain." +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx msgid "DNS Records" msgstr "DNS Records" @@ -4004,7 +4039,12 @@ msgstr "Documents that require your attention will appear here" msgid "Documents Viewed" msgstr "Documents Viewed" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" msgstr "Domain" @@ -4020,6 +4060,10 @@ msgstr "Domain already in use" msgid "Domain Name" msgstr "Domain Name" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Domain re-registered" +msgstr "Domain re-registered" + #: apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx #: apps/remix/app/routes/_unauthenticated+/signin.tsx msgid "Don't have an account? <0>Sign up" @@ -4157,6 +4201,10 @@ msgstr "Duplicate Template" msgid "Duplicate values are not allowed" msgstr "Duplicate values are not allowed" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "Dutch" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "E.g. 0" @@ -4229,6 +4277,7 @@ msgstr "Electronic Signature Disclosure" #: apps/remix/app/components/tables/admin-dashboard-users-table.tsx #: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -4278,6 +4327,10 @@ msgstr "Email Confirmed!" msgid "Email Created" msgstr "Email Created" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Email Domain" +msgstr "Email Domain" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx msgid "Email domain not found" msgstr "Email domain not found" @@ -4286,6 +4339,8 @@ msgstr "Email domain not found" msgid "Email Domain Settings" msgstr "Email Domain Settings" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx @@ -4381,6 +4436,8 @@ msgid "Email verification has been resent" msgstr "Email verification has been resent" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx msgid "Emails" msgstr "Emails" @@ -4489,6 +4546,10 @@ msgstr "Enclosed Document" msgid "Enclosed Documents" msgstr "Enclosed Documents" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "English" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4623,6 +4684,7 @@ msgstr "Envelope Title" msgid "Envelope updated" msgstr "Envelope updated" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4687,6 +4749,7 @@ msgstr "Envelope updated" #: apps/remix/app/components/general/template/template-edit-form.tsx #: apps/remix/app/components/general/verify-email-banner.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx @@ -4823,6 +4886,14 @@ msgstr "Failed to load document" msgid "Failed to move folder" msgstr "Failed to move folder" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "Failed to move subscription. Please try again." + +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Failed to re-register email domain" +msgstr "Failed to re-register email domain" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Failed to reseal document" msgstr "Failed to reseal document" @@ -4843,6 +4914,10 @@ msgstr "Failed to sign out all sessions" msgid "Failed to sync license" msgstr "Failed to sync license" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "Failed to trigger seal" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "Failed to unlink account" @@ -4963,6 +5038,10 @@ msgstr "File size exceeds the limit of {APP_DOCUMENT_UPLOAD_SIZE_LIMIT} MB" msgid "Fill in the details to create a new subscription claim." msgstr "Fill in the details to create a new subscription claim." +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +msgid "Filter by status" +msgstr "Filter by status" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx @@ -5051,6 +5130,10 @@ msgstr "Free Signature" msgid "Free Signature Settings" msgstr "Free Signature Settings" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "French" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5083,6 +5166,10 @@ msgstr "Generate DKIM Records" msgid "Generate Links" msgstr "Generate Links" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "German" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "Global recipient action authentication" @@ -5349,6 +5436,7 @@ msgstr "I'm sure! Delete it" #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-dashboard-users-table.tsx #: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "ID" msgstr "ID" @@ -5619,6 +5707,14 @@ msgstr "It's currently not your turn to sign. Please check back soon as this doc msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "Italian" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "Japanese" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Join {organisationName} on Documenso" @@ -5636,6 +5732,10 @@ msgstr "Joined" msgid "Joined {0}" msgstr "Joined {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "Korean" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5689,6 +5789,10 @@ msgstr "Last modified" msgid "Last Retried" msgstr "Last Retried" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "Last Signed" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -5707,6 +5811,11 @@ msgstr "Last updated at" msgid "Last used" msgstr "Last used" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Last Verified" +msgstr "Last Verified" + #: apps/remix/app/components/filters/date-range-filter.tsx msgid "Last Year" msgstr "Last Year" @@ -5849,6 +5958,7 @@ msgstr "Loading suggestions..." #: apps/remix/app/components/embed/embed-client-loading.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/document/document-page-view-recent-activity.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx #: packages/ui/primitives/pdf-viewer/lazy.tsx msgid "Loading..." @@ -6185,6 +6295,12 @@ msgstr "Move Documents to Folder" msgid "Move Folder" msgstr "Move Folder" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "Move Subscription" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "Move Template to Folder" @@ -6193,6 +6309,10 @@ msgstr "Move Template to Folder" msgid "Move Templates to Folder" msgstr "Move Templates to Folder" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6236,6 +6356,7 @@ msgstr "N/A" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx #: apps/remix/app/components/tables/settings-security-passkey-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: packages/lib/utils/fields.ts @@ -6329,10 +6450,18 @@ msgstr "No active drafts" msgid "No documents found" msgstr "No documents found" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "No eligible organisations found. The target must be on the free plan." + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "No email detected" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "No emails configured for this domain." +msgstr "No emails configured for this domain." + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No features enabled" msgstr "No features enabled" @@ -6627,6 +6756,8 @@ msgstr "Or continue with" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/components/tables/user-organisations-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx msgid "Organisation" msgstr "Organisation" @@ -6778,6 +6909,7 @@ msgstr "Override organisation settings" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -6907,6 +7039,9 @@ msgstr "PDF Document" #: apps/remix/app/components/general/envelope-editor/envelope-editor-header.tsx #: apps/remix/app/components/general/template/template-page-view-documents-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.members.tsx #: packages/lib/constants/document.ts #: packages/ui/components/document/document-read-only-fields.tsx @@ -6934,6 +7069,10 @@ msgstr "Pending documents will have their signing process cancelled" msgid "Pending invitations" msgstr "Pending invitations" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Pending since" +msgstr "Pending since" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -7201,6 +7340,14 @@ msgstr "Please upload a document to continue" msgid "Please upload a logo" msgstr "Please upload a logo" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "Polish" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "Portuguese (Brazil)" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7353,6 +7500,15 @@ msgstr "Radio Settings" msgid "Radio values" msgstr "Radio values" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Re-register" +msgstr "Re-register" + +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Re-register Email Domain" +msgstr "Re-register Email Domain" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx @@ -7533,6 +7689,10 @@ msgstr "Recipients will be able to sign the document once sent" msgid "Recipients will still retain their copy of the document" msgstr "Recipients will still retain their copy of the document" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Record" +msgstr "Record" + #: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx msgid "Record Name" msgstr "Record Name" @@ -7738,6 +7898,10 @@ msgstr "Required Field" msgid "Required scopes" msgstr "Required scopes" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "Reseal" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "Reseal document" @@ -7944,6 +8108,10 @@ msgstr "Save failed" msgid "Save Template" msgstr "Save Template" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "Seal job triggered" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "Sealing job started" @@ -7970,6 +8138,10 @@ msgstr "Search by claim ID or name" msgid "Search by document title" msgstr "Search by document title" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +msgid "Search by domain or organisation name" +msgstr "Search by domain or organisation name" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Search by ID" msgstr "Search by ID" @@ -8087,6 +8259,10 @@ msgstr "Select an event type" msgid "Select an option" msgstr "Select an option" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "Select an organisation" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "Select an organisation to view teams" @@ -8230,6 +8406,10 @@ msgstr "Selected Recipient" msgid "Selected templates will be permanently deleted" msgstr "Selected templates will be permanently deleted" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Selector" +msgstr "Selector" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8783,6 +8963,10 @@ msgstr "Source" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "Space-separated list of domains. Leave empty to allow all domains." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "Spanish" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8801,6 +8985,7 @@ msgstr "Stats" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -8831,6 +9016,10 @@ msgstr "Stripe customer created successfully" msgid "Stripe Customer ID" msgstr "Stripe Customer ID" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "Stuck For" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "Subject" @@ -8882,12 +9071,17 @@ msgstr "Subscription Claims" msgid "Subscription invalid" msgstr "Subscription invalid" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "Subscription moved successfully" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "Subscription Status" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9002,6 +9196,10 @@ msgstr "System Requirements" msgid "System Theme" msgstr "System Theme" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "Target Organisation" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -9562,6 +9760,10 @@ msgstr "The recipient is required to sign the document for it to be completed." msgid "The recipient is required to view the document for it to be completed." msgstr "The recipient is required to view the document for it to be completed." +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "The SES identity has been deleted and recreated with the same keys. DNS records remain unchanged." +msgstr "The SES identity has been deleted and recreated with the same keys. DNS records remain unchanged." + #: packages/ui/components/document/document-share-button.tsx msgid "The sharing link could not be created at this time. Please try again." msgstr "The sharing link could not be created at this time. Please try again." @@ -9970,6 +10172,16 @@ msgstr "This will be sent to the document owner when a recipient's signing windo msgid "This will check and sync the status of all email domains for this organisation" msgstr "This will check and sync the status of all email domains for this organisation" +#. placeholder {0}: emailDomain.domain +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." +msgstr "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." + +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10007,6 +10219,7 @@ msgstr "Time Zone" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "Title" @@ -10439,6 +10652,11 @@ msgstr "Unlink" msgid "Unpin" msgstr "Unpin" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "Unsealed Documents" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "Untitled Group" @@ -10773,6 +10991,7 @@ msgstr "Validation failed" #: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "Value" @@ -10838,6 +11057,7 @@ msgstr "Vertical Align" #: apps/remix/app/components/tables/inbox-table.tsx #: apps/remix/app/components/tables/inbox-table.tsx #: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "View" msgstr "View" diff --git a/packages/lib/translations/es/web.po b/packages/lib/translations/es/web.po index 0c4a18378..af2e838c7 100644 --- a/packages/lib/translations/es/web.po +++ b/packages/lib/translations/es/web.po @@ -1084,6 +1084,7 @@ msgstr "Acción" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "¿No puedes encontrar a alguien?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "Ccers" msgid "Center" msgstr "Centro" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "Cambiar destinatario" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "Límite de caracteres" @@ -2423,6 +2433,10 @@ msgstr "Valores de Checkbox" msgid "Checkout" msgstr "Checkout" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "Elija un destinatario existente de abajo para continuar" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "Configura la configuración del documento y las opciones antes de enviar." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "Configura las opciones de correo electrónico para el documento" +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "Configurar ajustes generales para la plantilla." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "Configura la configuración de seguridad del documento" +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "Crea tu cuenta y comienza a utilizar la firma de documentos de última g #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "Los documentos que requieren tu atención aparecerán aquí" msgid "Documents Viewed" msgstr "Documentos vistos" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "Plantilla Duplicada" msgid "Duplicate values are not allowed" msgstr "No se permiten valores duplicados" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Ej.: 0" @@ -4528,6 +4551,10 @@ msgstr "Documento Adjunto" msgid "Enclosed Documents" msgstr "Documentos adjuntos" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "Título del Sobre" msgid "Envelope updated" msgstr "Sobre actualizado" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "Error al cargar el documento" msgid "Failed to move folder" msgstr "Error al mover la carpeta" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "Error al volver a registrar el dominio de correo electrónico" @@ -4887,6 +4919,10 @@ msgstr "Error al cerrar sesión en todas las sesiones" msgid "Failed to sync license" msgstr "Error al sincronizar la licencia" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "No se pudo desvincular la cuenta" @@ -5099,6 +5135,10 @@ msgstr "Firma gratuita" msgid "Free Signature Settings" msgstr "Configuración de Firma Gratuita" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "Generar registros DKIM" msgid "Generate Links" msgstr "Generar enlaces" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "Autenticación de acción de destinatario global" @@ -5668,6 +5712,14 @@ msgstr "Actualmente no es tu turno para firmar. Por favor, vuelve pronto ya que msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "Actualmente no es tu turno para firmar. Recibirás un correo electrónico con instrucciones una vez sea tu turno para firmar el documento." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Únete a {organisationName} en Documenso" @@ -5685,6 +5737,10 @@ msgstr "Unido" msgid "Joined {0}" msgstr "Se unió a {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "Última modificación" msgid "Last Retried" msgstr "Último reintento" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "Mover documentos a la carpeta" msgid "Move Folder" msgstr "Mover Carpeta" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "Mover Plantilla a Carpeta" @@ -6248,6 +6314,10 @@ msgstr "Mover Plantilla a Carpeta" msgid "Move Templates to Folder" msgstr "Mover plantillas a la carpeta" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "No hay borradores activos" msgid "No documents found" msgstr "No se encontraron documentos" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "No se detectó ningún correo electrónico" @@ -6840,6 +6914,7 @@ msgstr "Anular la configuración de la organización" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "Por favor, carga un documento para continuar" msgid "Please upload a logo" msgstr "Por favor, suba un logotipo" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "Campo obligatorio" msgid "Required scopes" msgstr "Ámbitos requeridos" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "Re-sellar documento" @@ -8026,6 +8113,10 @@ msgstr "Guardado fallido" msgid "Save Template" msgstr "Guardar plantilla" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "Se inició el trabajo de sellado" @@ -8173,6 +8264,10 @@ msgstr "Selecciona un tipo de evento" msgid "Select an option" msgstr "Seleccionar una opción" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "Seleccione una organización para ver los equipos" @@ -8873,6 +8968,10 @@ msgstr "Fuente" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "Lista de dominios separados por espacios. Deje vacío para permitir todos los dominios." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8922,6 +9021,10 @@ msgstr "Cliente de Stripe creado con éxito" msgid "Stripe Customer ID" msgstr "ID de Cliente de Stripe" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "Asunto" @@ -8973,12 +9076,17 @@ msgstr "Reclamos de suscripción" msgid "Subscription invalid" msgstr "Suscripción inválida" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "Estado de la suscripción" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "Requisitos del Sistema" msgid "System Theme" msgstr "Tema del sistema" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10070,6 +10182,11 @@ msgstr "Esto verificará y sincronizará el estado de todos los dominios de corr msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "Esto eliminará la identidad de SES existente para <0>{0} y la volverá a crear usando las mismas claves DKIM. El usuario no tendrá que actualizar sus registros DNS. El estado del dominio se restablecerá a Pendiente." +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "Esto solo retroalimentará las banderas de características que estén configuradas como verdaderas, cualquier cosa desactivada en la reclamo inicial no será retroalimentada" @@ -10107,6 +10224,7 @@ msgstr "Zona horaria" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "Título" @@ -10539,6 +10657,11 @@ msgstr "Desvincular" msgid "Unpin" msgstr "Desanclar" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "Grupo sin título" @@ -12666,4 +12789,3 @@ msgstr "Su código de verificación:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "su-dominio.com otro-dominio.com" - diff --git a/packages/lib/translations/fr/web.po b/packages/lib/translations/fr/web.po index adcf41400..56afe36e1 100644 --- a/packages/lib/translations/fr/web.po +++ b/packages/lib/translations/fr/web.po @@ -1084,6 +1084,7 @@ msgstr "Action" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "Vous ne trouvez pas quelqu’un ?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "CCers" msgid "Center" msgstr "Centre" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "Modifier le destinataire" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "Limite de caractères" @@ -2423,6 +2433,10 @@ msgstr "Valeurs de la case à cocher" msgid "Checkout" msgstr "Passer à la caisse" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "Choisissez un destinataire existant ci-dessous pour continuer" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "Configurez les paramètres et options du document avant l'envoi." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "Configurez les paramètres d'email pour le document" +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "Configurer les paramètres généraux pour le modèle." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "Configurez les paramètres de sécurité pour le document" +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "Créez votre compte et commencez à utiliser la signature de documents #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "Les documents qui nécessitent votre attention apparaîtront ici" msgid "Documents Viewed" msgstr "Documents consultés" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "Dupliquer le modèle" msgid "Duplicate values are not allowed" msgstr "Les valeurs en double ne sont pas autorisées" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Par ex. 0" @@ -4528,6 +4551,10 @@ msgstr "Document joint" msgid "Enclosed Documents" msgstr "Documents joints" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "Titre de l'enveloppe" msgid "Envelope updated" msgstr "Enveloppe mise à jour" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "Échec du chargement du document" msgid "Failed to move folder" msgstr "Échec du déplacement du dossier" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "Échec de la nouvelle inscription du domaine e-mail" @@ -4887,6 +4919,10 @@ msgstr "Impossible de se déconnecter de toutes les sessions" msgid "Failed to sync license" msgstr "Échec de la synchronisation de la licence" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "Échec de la dissociation du compte" @@ -5099,6 +5135,10 @@ msgstr "Signature gratuite" msgid "Free Signature Settings" msgstr "Paramètres de la signature libre" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "Générer des enregistrements DKIM" msgid "Generate Links" msgstr "Générer des liens" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "Authentification d'action de destinataire globale" @@ -5668,6 +5712,14 @@ msgstr "Ce n'est actuellement pas votre tour de signer. Veuillez revenir bientô msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "Ce n'est actuellement pas votre tour de signer. Vous recevrez un e-mail avec des instructions une fois que ce sera votre tour de signer le document." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Rejoindre {organisationName} sur Documenso" @@ -5685,6 +5737,10 @@ msgstr "Joint" msgid "Joined {0}" msgstr "A rejoint {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "Dernière modification" msgid "Last Retried" msgstr "Dernière relance" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "Déplacer les documents vers le dossier" msgid "Move Folder" msgstr "Déplacer le Dossier" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "Déplacer le modèle vers un dossier" @@ -6248,6 +6314,10 @@ msgstr "Déplacer le modèle vers un dossier" msgid "Move Templates to Folder" msgstr "Déplacer les modèles vers le dossier" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "Pas de brouillons actifs" msgid "No documents found" msgstr "Aucun document trouvé" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "Aucun e-mail détecté" @@ -6840,6 +6914,7 @@ msgstr "Ignorer les paramètres de l'organisation" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "Veuillez télécharger un document pour continuer" msgid "Please upload a logo" msgstr "Veuillez télécharger un logo" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "Champ Requis" msgid "Required scopes" msgstr "Portées requises" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "Rescellage du document" @@ -8026,6 +8113,10 @@ msgstr "Échec de l'enregistrement" msgid "Save Template" msgstr "Sauvegarder le modèle" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "Le travail d'étanchéité a commencé" @@ -8173,6 +8264,10 @@ msgstr "Sélectionnez un type d’événement" msgid "Select an option" msgstr "Sélectionner une option" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "Sélectionnez une organisation pour voir les équipes" @@ -8873,6 +8968,10 @@ msgstr "Source" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "Liste des domaines séparée par des espaces. Laissez vide pour autoriser tous les domaines." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8922,6 +9021,10 @@ msgstr "Le client Stripe a été créé avec succès" msgid "Stripe Customer ID" msgstr "ID client Stripe" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "Sujet" @@ -8973,12 +9076,17 @@ msgstr "Réclamations d'abonnement" msgid "Subscription invalid" msgstr "Abonnement non valide" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "Statut de l’abonnement" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "Exigences du système" msgid "System Theme" msgstr "Thème système" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10070,6 +10182,11 @@ msgstr "Cela vérifiera et synchronisera l'état de tous les domaines de message msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "Cela supprimera l’identité SES existante pour <0>{0} et la recréera en utilisant les mêmes clés DKIM. L’utilisateur n’aura pas besoin de mettre à jour ses enregistrements DNS. Le statut du domaine sera réinitialisé sur En attente." +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "Cela ne fera que rétroporter les drapeaux de fonctionnalité qui sont activés, tout ce qui est désactivé dans la réclamation initiale ne sera pas rétroporté" @@ -10107,6 +10224,7 @@ msgstr "Fuseau horaire" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "Titre" @@ -10539,6 +10657,11 @@ msgstr "Délier" msgid "Unpin" msgstr "Détacher" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "Groupe sans titre" @@ -12666,4 +12789,3 @@ msgstr "Votre code de vérification :" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/it/web.po b/packages/lib/translations/it/web.po index dfc35f0e0..2fa20713a 100644 --- a/packages/lib/translations/it/web.po +++ b/packages/lib/translations/it/web.po @@ -1084,6 +1084,7 @@ msgstr "Azione" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "Non riesci a trovare qualcuno?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "Copiatori" msgid "Center" msgstr "Centro" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "Cambia destinatario" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "Limite di caratteri" @@ -2423,6 +2433,10 @@ msgstr "Valori della casella di controllo" msgid "Checkout" msgstr "Pagamento" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "Scegli un destinatario esistente qui sotto per continuare" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "Configura le impostazioni e le opzioni del documento prima di inviare." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "Configura le impostazioni email per il documento" +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "Configura le impostazioni generali per il modello." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "Configura le impostazioni di sicurezza per il documento" +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "Crea il tuo account e inizia a utilizzare firme digitali all'avanguardia #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "I documenti che richiedono la tua attenzione verranno visualizzati qui" msgid "Documents Viewed" msgstr "Documenti visualizzati" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "Duplica Modello" msgid "Duplicate values are not allowed" msgstr "I valori duplicati non sono ammessi" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Es. 0" @@ -4528,6 +4551,10 @@ msgstr "Documento Allegato" msgid "Enclosed Documents" msgstr "Documenti allegati" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "Titolo della Busta" msgid "Envelope updated" msgstr "Busta aggiornata" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "Caricamento documento fallito." msgid "Failed to move folder" msgstr "Impossibile spostare la cartella" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "Impossibile registrare nuovamente il dominio email" @@ -4887,6 +4919,10 @@ msgstr "Non è stato possibile disconnettere tutte le sessioni" msgid "Failed to sync license" msgstr "Impossibile sincronizzare la licenza" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "Impossibile scollegare l'account" @@ -5099,6 +5135,10 @@ msgstr "Firma gratuita" msgid "Free Signature Settings" msgstr "Impostazioni Firma Gratuita" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "Genera Record DKIM" msgid "Generate Links" msgstr "Genera link" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "Autenticazione globale del destinatario" @@ -5668,6 +5712,14 @@ msgstr "Attualmente non è il tuo turno di firmare. Torna presto a controllare p msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "Al momento, non è il tuo turno di firmare. Riceverai un'email con le istruzioni quando sarà il tuo turno di firmare il documento." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Unisciti a {organisationName} su Documenso" @@ -5685,6 +5737,10 @@ msgstr "Iscritto" msgid "Joined {0}" msgstr "Iscritto a {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "Ultima modifica" msgid "Last Retried" msgstr "Ultima Riprova" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "Sposta documenti nella cartella" msgid "Move Folder" msgstr "Sposta Cartella" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "Sposta modello nella cartella" @@ -6248,6 +6314,10 @@ msgstr "Sposta modello nella cartella" msgid "Move Templates to Folder" msgstr "Sposta modelli nella cartella" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "Nessuna bozza attiva" msgid "No documents found" msgstr "Nessun documento trovato" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "Nessuna email rilevata" @@ -6840,6 +6914,7 @@ msgstr "Sovrascrivi impostazioni organizzazione" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "Per favore carica un documento per continuare" msgid "Please upload a logo" msgstr "Si prega di caricare un logo" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "Campo obbligatorio" msgid "Required scopes" msgstr "Scope richiesti" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "Risigilla documento" @@ -8026,6 +8113,10 @@ msgstr "Salvataggio fallito" msgid "Save Template" msgstr "Salva modello" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "Operazione di sigillatura iniziata" @@ -8173,6 +8264,10 @@ msgstr "Seleziona un tipo di evento" msgid "Select an option" msgstr "Seleziona un'opzione" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "Seleziona un'organizzazione per visualizzare i team" @@ -8873,6 +8968,10 @@ msgstr "Fonte" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "Elenco di domini separato da spazi. Lascia vuoto per consentire tutti i domini." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8922,6 +9021,10 @@ msgstr "Cliente Stripe creato con successo" msgid "Stripe Customer ID" msgstr "ID cliente di Stripe" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "Soggetto" @@ -8973,12 +9076,17 @@ msgstr "Rivendicazioni di abbonamento" msgid "Subscription invalid" msgstr "Abbonamento non valido" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "Stato dell’abbonamento" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "Requisiti di sistema" msgid "System Theme" msgstr "Tema del sistema" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10070,6 +10182,11 @@ msgstr "Questo controllerà e sincronizzerà lo stato di tutti i domini email pe msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "Questo eliminerà l’identità SES esistente per <0>{0} e la ricreerà usando le stesse chiavi DKIM. L’utente non dovrà aggiornare i propri record DNS. Lo stato del dominio sarà reimpostato su In sospeso." +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "Questo farà SOLO il retroporting degli indicatori delle funzionalità impostati su vero, qualsiasi cosa disabilitata nel reclamo iniziale non sarà retroportata" @@ -10107,6 +10224,7 @@ msgstr "Fuso orario" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "Titolo" @@ -10539,6 +10657,11 @@ msgstr "Scollega" msgid "Unpin" msgstr "Rimuovi" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "Gruppo senza nome" @@ -12666,4 +12789,3 @@ msgstr "Il tuo codice di verifica:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "tuo-dominio.com altro-dominio.com" - diff --git a/packages/lib/translations/ja/web.po b/packages/lib/translations/ja/web.po index 3ef65093e..321f43a60 100644 --- a/packages/lib/translations/ja/web.po +++ b/packages/lib/translations/ja/web.po @@ -1084,6 +1084,7 @@ msgstr "操作" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "メンバーが見つかりませんか?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "Cc 受信者" msgid "Center" msgstr "中央" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "受信者を変更" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "文字数制限" @@ -2423,6 +2433,10 @@ msgstr "チェックボックスの値" msgid "Checkout" msgstr "チェックアウト" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "続行するには、下から既存の受信者を選択してください" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "送信前に、ドキュメント設定とオプションを構成します。" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "このドキュメントのメール設定を構成" +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "このテンプレートの一般設定を構成します。" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "このドキュメントのセキュリティ設定を構成" +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "アカウントを作成して、最先端の文書署名を今すぐ始 #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "対応が必要なドキュメントがここに表示されます" msgid "Documents Viewed" msgstr "閲覧された文書" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "テンプレートを複製" msgid "Duplicate values are not allowed" msgstr "同じ値を重複して使用することはできません" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "例: 0" @@ -4528,6 +4551,10 @@ msgstr "同封文書" msgid "Enclosed Documents" msgstr "同封された文書" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "封筒タイトル" msgid "Envelope updated" msgstr "封筒を更新しました" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "ドキュメントの読み込みに失敗しました" msgid "Failed to move folder" msgstr "フォルダの移動に失敗しました" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "メールドメインの再登録に失敗しました" @@ -4887,6 +4919,10 @@ msgstr "すべてのセッションからのサインアウトに失敗しまし msgid "Failed to sync license" msgstr "ライセンスの同期に失敗しました" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "アカウントのリンク解除に失敗しました" @@ -5099,6 +5135,10 @@ msgstr "フリー署名" msgid "Free Signature Settings" msgstr "手書き署名の設定" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "DKIM レコードを生成" msgid "Generate Links" msgstr "リンクを生成" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "グローバル受信者アクション認証" @@ -5668,6 +5712,14 @@ msgstr "現在はあなたの署名順ではありません。まもなくこの msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "現在はあなたの署名順ではありません。順番が来ると、文書への署名方法を記載したメールが届きます。" +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "{organisationName} に Documenso で参加" @@ -5685,6 +5737,10 @@ msgstr "参加日" msgid "Joined {0}" msgstr "{0} に参加" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "最終更新" msgid "Last Retried" msgstr "最終再試行日時" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "ドキュメントをフォルダーに移動" msgid "Move Folder" msgstr "フォルダを移動" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "テンプレートをフォルダに移動" @@ -6248,6 +6314,10 @@ msgstr "テンプレートをフォルダに移動" msgid "Move Templates to Folder" msgstr "テンプレートをフォルダーに移動" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "有効な下書きはありません" msgid "No documents found" msgstr "文書が見つかりません" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "メールアドレスが検出されませんでした" @@ -6840,6 +6914,7 @@ msgstr "組織設定を上書き" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "続行するには文書をアップロードしてください" msgid "Please upload a logo" msgstr "ロゴをアップロードしてください" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "必須フィールド" msgid "Required scopes" msgstr "必要なスコープ" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "文書を再封止" @@ -8026,6 +8113,10 @@ msgstr "保存に失敗しました" msgid "Save Template" msgstr "テンプレートを保存" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "封印ジョブを開始しました" @@ -8173,6 +8264,10 @@ msgstr "イベントタイプを選択" msgid "Select an option" msgstr "オプションを選択" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "組織を選択してチームを表示" @@ -8873,6 +8968,10 @@ msgstr "ソース" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "ドメインを半角スペース区切りで入力します。空のままにするとすべてのドメインが許可されます。" +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8922,6 +9021,10 @@ msgstr "Stripe 顧客が正常に作成されました" msgid "Stripe Customer ID" msgstr "Stripe 顧客 ID" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "件名" @@ -8973,12 +9076,17 @@ msgstr "サブスクリプションクレーム" msgid "Subscription invalid" msgstr "サブスクリプションが無効です" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "サブスクリプション状況" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "システム要件" msgid "System Theme" msgstr "システムテーマ" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10070,6 +10182,11 @@ msgstr "この操作により、この組織のすべてのメールドメイン msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "これにより、<0>{0} の既存のSESアイデンティティが削除され、同じDKIMキーを使用して再作成されます。ユーザーがDNSレコードを更新する必要はありません。ドメインのステータスは「保留中」にリセットされます。" +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "ここでバックポートされるのは true に設定されている機能フラグのみであり、初期クレームで無効になっているものはバックポートされません。" @@ -10107,6 +10224,7 @@ msgstr "タイムゾーン" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "タイトル" @@ -10539,6 +10657,11 @@ msgstr "リンク解除" msgid "Unpin" msgstr "ピン留めを解除" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "無題のグループ" @@ -12666,4 +12789,3 @@ msgstr "認証コード:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/ko/web.po b/packages/lib/translations/ko/web.po index 7a85566c8..ac8f15c98 100644 --- a/packages/lib/translations/ko/web.po +++ b/packages/lib/translations/ko/web.po @@ -1084,6 +1084,7 @@ msgstr "동작" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "누군가를 찾을 수 없나요?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "Cc 수신자" msgid "Center" msgstr "가운데" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "수신자 변경" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "문자 수 제한" @@ -2423,6 +2433,10 @@ msgstr "체크박스 값" msgid "Checkout" msgstr "결제하기" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "계속하려면 아래에서 기존 수신자를 선택하세요" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "문서를 보내기 전에 설정 및 옵션을 구성합니다." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "문서의 이메일 설정을 구성합니다." +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "템플릿의 일반 설정을 구성합니다." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "문서의 보안 설정을 구성합니다." +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "계정을 만들고 최첨단 전자 서명 서비스를 시작하세요 #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "관심이 필요한 문서는 여기에 표시됩니다." msgid "Documents Viewed" msgstr "열람된 문서" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "템플릿 복제" msgid "Duplicate values are not allowed" msgstr "중복 값은 허용되지 않습니다" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "예: 0" @@ -4528,6 +4551,10 @@ msgstr "동봉 문서" msgid "Enclosed Documents" msgstr "동봉된 문서들" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "봉투 제목" msgid "Envelope updated" msgstr "봉투가 업데이트되었습니다" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "문서를 불러오지 못했습니다." msgid "Failed to move folder" msgstr "폴더를 이동하지 못했습니다." +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "이메일 도메인 재등록에 실패했습니다." @@ -4887,6 +4919,10 @@ msgstr "모든 세션에서 로그아웃하지 못했습니다." msgid "Failed to sync license" msgstr "라이선스 동기화에 실패했습니다" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "계정 연결을 해제하지 못했습니다" @@ -5099,6 +5135,10 @@ msgstr "자유 서명" msgid "Free Signature Settings" msgstr "무료 서명 설정" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "DKIM 레코드 생성" msgid "Generate Links" msgstr "링크 생성" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "전역 수신자 액션 인증" @@ -5668,6 +5712,14 @@ msgstr "현재는 귀하의 서명 순서가 아닙니다. 곧 이 문서에 서 msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "현재는 귀하의 서명 순서가 아닙니다. 순서가 되면 문서 서명 방법이 안내된 이메일을 받게 됩니다." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Documenso에서 {organisationName} 조직에 참여하세요." @@ -5685,6 +5737,10 @@ msgstr "가입일" msgid "Joined {0}" msgstr "{0}에 가입함" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "마지막 수정" msgid "Last Retried" msgstr "마지막 재시도 시간" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "문서를 폴더로 이동" msgid "Move Folder" msgstr "폴더 이동" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "템플릿을 폴더로 이동" @@ -6248,6 +6314,10 @@ msgstr "템플릿을 폴더로 이동" msgid "Move Templates to Folder" msgstr "템플릿을 폴더로 이동" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "활성 초안 없음" msgid "No documents found" msgstr "문서를 찾을 수 없습니다" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "이메일이 감지되지 않았습니다." @@ -6840,6 +6914,7 @@ msgstr "조직 설정 재정의" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "계속하려면 문서를 업로드하세요" msgid "Please upload a logo" msgstr "로고를 업로드해 주세요." +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "필수 필드" msgid "Required scopes" msgstr "필요한 범위" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "문서 다시 봉인" @@ -8026,6 +8113,10 @@ msgstr "저장 실패" msgid "Save Template" msgstr "템플릿 저장" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "봉인 작업이 시작되었습니다" @@ -8173,6 +8264,10 @@ msgstr "이벤트 유형을 선택하세요" msgid "Select an option" msgstr "옵션을 선택하세요." +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "조직을 선택하여 팀을 확인하세요." @@ -8873,6 +8968,10 @@ msgstr "소스" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "도메인을 공백으로 구분하여 입력하세요. 비워 두면 모든 도메인이 허용됩니다." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8922,6 +9021,10 @@ msgstr "Stripe 고객이 성공적으로 생성되었습니다." msgid "Stripe Customer ID" msgstr "Stripe 고객 ID" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "제목" @@ -8973,12 +9076,17 @@ msgstr "구독 클레임" msgid "Subscription invalid" msgstr "구독이 유효하지 않습니다." +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "구독 상태" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "시스템 요구 사항" msgid "System Theme" msgstr "시스템 테마" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10070,6 +10182,11 @@ msgstr "이 작업은 이 조직의 모든 이메일 도메인 상태를 확인 msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "이 작업은 <0>{0}에 대한 기존 SES ID를 삭제하고 동일한 DKIM 키를 사용하여 다시 생성합니다. 사용자는 DNS 레코드를 수정할 필요가 없습니다. 도메인 상태는 \"보류 중\"으로 초기화됩니다." +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "이 작업은 true로 설정된 기능 플래그만 다시 반영합니다. 초기 클레임에서 비활성화된 플래그는 다시 반영되지 않습니다." @@ -10107,6 +10224,7 @@ msgstr "시간대" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "제목" @@ -10539,6 +10657,11 @@ msgstr "연결 해제" msgid "Unpin" msgstr "고정 해제" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "제목 없는 그룹" @@ -12666,4 +12789,3 @@ msgstr "인증 코드:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/nl/web.po b/packages/lib/translations/nl/web.po index 00b483b68..818f288af 100644 --- a/packages/lib/translations/nl/web.po +++ b/packages/lib/translations/nl/web.po @@ -1084,6 +1084,7 @@ msgstr "Actie" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "Kunt u niemand vinden?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "Ccers" msgid "Center" msgstr "Centreren" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "Ontvanger wijzigen" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "Tekenlimiet" @@ -2423,6 +2433,10 @@ msgstr "Waarden selectievakje" msgid "Checkout" msgstr "Afrekenen" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "Kies hieronder een bestaande ontvanger om verder te gaan" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "Configureer documentinstellingen en opties voordat je verzendt." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "E-mailinstellingen voor het document configureren" +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "Configureer algemene instellingen voor de sjabloon." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "Beveiligingsinstellingen voor het document configureren" +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "Maak je account aan en begin met het gebruik van moderne documentenonder #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "Documenten die je aandacht vereisen, verschijnen hier" msgid "Documents Viewed" msgstr "Bekeken documenten" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "Sjabloon dupliceren" msgid "Duplicate values are not allowed" msgstr "Dubbele waarden zijn niet toegestaan" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Bijv. 0" @@ -4528,6 +4551,10 @@ msgstr "Bijgevoegd document" msgid "Enclosed Documents" msgstr "Bijgevoegde documenten" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "Enveloptitel" msgid "Envelope updated" msgstr "Envelope bijgewerkt" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "Document laden mislukt" msgid "Failed to move folder" msgstr "Map verplaatsen mislukt" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "Opnieuw registreren van e-maildomein is mislukt" @@ -4887,6 +4919,10 @@ msgstr "Uitloggen van alle sessies mislukt" msgid "Failed to sync license" msgstr "Licentie synchroniseren is mislukt" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "Account ontkoppelen mislukt" @@ -5099,6 +5135,10 @@ msgstr "Vrije handtekening" msgid "Free Signature Settings" msgstr "Gratis handtekening-instellingen" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "DKIM-records genereren" msgid "Generate Links" msgstr "Links genereren" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "Globale ontvangeractie-authenticatie" @@ -5668,6 +5712,14 @@ msgstr "Het is momenteel niet jouw beurt om te ondertekenen. Kom binnenkort teru msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "Het is momenteel niet jouw beurt om te ondertekenen. Je ontvangt een e‑mail met instructies zodra jij aan de beurt bent om het document te ondertekenen." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Word lid van {organisationName} op Documenso" @@ -5685,6 +5737,10 @@ msgstr "Lid geworden" msgid "Joined {0}" msgstr "Toegetreden {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "Laatst gewijzigd" msgid "Last Retried" msgstr "Laatst opnieuw geprobeerd" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "Documenten naar map verplaatsen" msgid "Move Folder" msgstr "Map verplaatsen" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "Sjabloon naar map verplaatsen" @@ -6248,6 +6314,10 @@ msgstr "Sjabloon naar map verplaatsen" msgid "Move Templates to Folder" msgstr "Templates naar map verplaatsen" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "Geen actieve concepten" msgid "No documents found" msgstr "Geen documenten gevonden" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "Geen e-mailadres gedetecteerd" @@ -6840,6 +6914,7 @@ msgstr "Organisatie-instellingen overschrijven" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "Upload een document om door te gaan" msgid "Please upload a logo" msgstr "Upload een logo" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "Veld verplicht" msgid "Required scopes" msgstr "Vereiste scopes" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "Document opnieuw verzegelen" @@ -8026,6 +8113,10 @@ msgstr "Opslaan mislukt" msgid "Save Template" msgstr "Sjabloon opslaan" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "Verzegelingstaak gestart" @@ -8173,6 +8264,10 @@ msgstr "Selecteer een gebeurtenistype" msgid "Select an option" msgstr "Selecteer een optie" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "Selecteer een organisatie om teams te bekijken" @@ -8873,6 +8968,10 @@ msgstr "Bron" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "Door spaties gescheiden lijst met domeinen. Laat leeg om alle domeinen toe te staan." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8922,6 +9021,10 @@ msgstr "Stripe-klant succesvol aangemaakt" msgid "Stripe Customer ID" msgstr "Stripe-klant-ID" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "Onderwerp" @@ -8973,12 +9076,17 @@ msgstr "Abonnementsclaims" msgid "Subscription invalid" msgstr "Abonnement ongeldig" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "Abonnementsstatus" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "Systeemvereisten" msgid "System Theme" msgstr "Systeemthema" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10070,6 +10182,11 @@ msgstr "Hiermee wordt de status van alle e-maildomeinen voor deze organisatie ge msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "Dit verwijdert de bestaande SES-identiteit voor <0>{0} en maakt deze opnieuw aan met dezelfde DKIM-sleutels. De gebruiker hoeft de DNS-records niet bij te werken. De domeinstatus wordt teruggezet naar In behandeling." +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "Hiermee worden ALLE feature-flags die op true staan teruggezet; alles wat in de oorspronkelijke claim is uitgeschakeld, wordt niet teruggezet" @@ -10107,6 +10224,7 @@ msgstr "Tijdzone" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "Titel" @@ -10539,6 +10657,11 @@ msgstr "Ontkoppelen" msgid "Unpin" msgstr "Losmaken" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "Naamloze groep" @@ -12666,4 +12789,3 @@ msgstr "Uw verificatiecode:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/pl/web.po b/packages/lib/translations/pl/web.po index d5be72346..2b24156d1 100644 --- a/packages/lib/translations/pl/web.po +++ b/packages/lib/translations/pl/web.po @@ -1084,6 +1084,7 @@ msgstr "Akcja" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "Nie możesz kogoś znaleźć?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "Pobierający kopię" msgid "Center" msgstr "Wyśrodkuj" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "Zmień odbiorcę" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "Limit znaków" @@ -2423,6 +2433,10 @@ msgstr "Opcje pola wyboru" msgid "Checkout" msgstr "Zamów" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "Wybierz odbiorcę, aby kontynuować" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "Skonfiguruj ustawienia dokumentu przed ich wysłaniem." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "Skonfiguruj ustawienia powiadomień dla dokumentu." +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "Skonfiguruj ogólne ustawienia szablonu." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "Skonfiguruj ustawienia zabezpieczeń dokumentu." +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "Utwórz konto i zacznij korzystać z nowoczesnego podpisywania dokument #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "Dokumenty wymagające Twojej uwagi pojawią się tutaj" msgid "Documents Viewed" msgstr "Wyświetlone dokumenty" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "Duplikuj szablon" msgid "Duplicate values are not allowed" msgstr "Zduplikowane wartości nie są dozwolone" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Np. 0" @@ -4528,6 +4551,10 @@ msgstr "Załączony dokument" msgid "Enclosed Documents" msgstr "Załączniki" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "Tytuł koperty" msgid "Envelope updated" msgstr "Koperta została zaktualizowana" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "Nie udało się załadować dokumentu" msgid "Failed to move folder" msgstr "Nie udało się przenieść folderu" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "Nie udało się ponownie zarejestrować domeny" @@ -4887,6 +4919,10 @@ msgstr "Nie udało się wylogować ze wszystkich sesji" msgid "Failed to sync license" msgstr "Nie udało się zsynchronizować licencji" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "Nie udało się rozłączyć konta" @@ -5099,6 +5135,10 @@ msgstr "Swobodny podpis" msgid "Free Signature Settings" msgstr "Ustawienia swobodnego podpisu" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "Wygeneruj rekordy DKIM" msgid "Generate Links" msgstr "Wygeneruj linki" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "Domyślne metody uwierzytelniania odbiorcy" @@ -5668,6 +5712,14 @@ msgstr "Obecnie nie jest Twoja kolej na podpisanie dokumentu. Sprawdź dokument msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "To nie jest Twoja kolej na podpisanie dokumentu. Gdy nadejdzie Twoja kolej, otrzymasz wiadomość z instrukcjami." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Dołącz do organizacji {organisationName} w Documenso" @@ -5685,6 +5737,10 @@ msgstr "Dołączył" msgid "Joined {0}" msgstr "Dołączył {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "Zmodyfikowano" msgid "Last Retried" msgstr "Ostatnie ponowienie" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "Przenieś dokumenty do folderu" msgid "Move Folder" msgstr "Przenieś folder" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "Przenieś szablon do folderu" @@ -6248,6 +6314,10 @@ msgstr "Przenieś szablon do folderu" msgid "Move Templates to Folder" msgstr "Przenieś szablony do folderu" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "Brak aktywnych szkiców" msgid "No documents found" msgstr "Nie znaleziono dokumentów" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "Nie wykryto adresu e-mail" @@ -6840,6 +6914,7 @@ msgstr "Nadpisz ustawienia organizacji" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "Prześlij dokument, aby kontynuować" msgid "Please upload a logo" msgstr "Prześlij logo" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "Pole jest wymagane" msgid "Required scopes" msgstr "Wymagane zakresy" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "Zapieczętuj ponownie dokument" @@ -8026,6 +8113,10 @@ msgstr "Zapisywanie nie powiodło się" msgid "Save Template" msgstr "Zapisz szablon" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "Rozpoczęto zapieczętowanie dokumentu" @@ -8173,6 +8264,10 @@ msgstr "Wybierz rodzaj zdarzenia" msgid "Select an option" msgstr "Wybierz opcję" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "Wybierz organizację, aby zobaczyć zespoły" @@ -8873,6 +8968,10 @@ msgstr "Źródło" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "Lista domen oddzielonych spacjami. Pozostaw puste pole, aby zezwolić na wszystkie domeny." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "Logowanie SSO" @@ -8922,6 +9021,10 @@ msgstr "Klient Stripe został utworzony" msgid "Stripe Customer ID" msgstr "Identyfikator klienta Stripe" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "Temat" @@ -8973,12 +9076,17 @@ msgstr "Subskrypcja" msgid "Subscription invalid" msgstr "Subskrypcja jest nieprawidłowa" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "Status subskrypcji" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "Wymagania systemowe" msgid "System Theme" msgstr "Motyw systemowy" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10059,7 +10171,9 @@ msgstr "Zostanie wysłana do właściciela po zakończeniu dokumentu." #: packages/ui/components/document/document-email-checkboxes.tsx msgid "This will be sent to the document owner when a recipient's signing window has expired." -msgstr "Zostanie wysłana do właściciela dokumentu, gdy minie czas na podpisanie przez odbiorcę\n" +msgstr "" +"Zostanie wysłana do właściciela dokumentu, gdy minie czas na podpisanie przez odbiorcę\n" +"" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx msgid "This will check and sync the status of all email domains for this organisation" @@ -10070,6 +10184,11 @@ msgstr "Spowoduje to synchronizowanie statusu wszystkich domen organizacji" msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "Spowoduje to usunięcie obecnej tożsamości SES dla domeny <0>{0} i ponowne utworzenie jej przy użyciu tych samych kluczy DKIM. Użytkownik nie będzie musiał aktualizować rekordów DNS. Status domeny zostanie zresetowany do stanu „Oczekujący”." +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "Spowoduje to TYLKO przeniesienie flag funkcji ustawionych na wartość „true”. Wszystkie elementy wyłączone w początkowej subskrypcji nie zostaną przeniesione" @@ -10107,6 +10226,7 @@ msgstr "Strefa czasowa" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "Tytuł" @@ -10539,6 +10659,11 @@ msgstr "Rozłącz" msgid "Unpin" msgstr "Odepnij" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "Grupa bez nazwy" @@ -12666,4 +12791,3 @@ msgstr "Twój kod weryfikacyjny:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - diff --git a/packages/lib/translations/pt-BR/web.po b/packages/lib/translations/pt-BR/web.po index b2db7d103..fa3741294 100644 --- a/packages/lib/translations/pt-BR/web.po +++ b/packages/lib/translations/pt-BR/web.po @@ -1077,7 +1077,9 @@ msgstr "Ação" #: apps/remix/app/components/tables/team-members-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -1085,6 +1087,9 @@ msgid "Actions" msgstr "Ações" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.members.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings._index.tsx msgid "Active" @@ -1325,6 +1330,7 @@ msgid "Admin" msgstr "Admin" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Admin Actions" msgstr "Ações de Admin" @@ -1433,6 +1439,10 @@ msgstr "Todas as assinaturas foram anuladas." msgid "All signing links have been copied to your clipboard." msgstr "Todos os links de assinatura foram copiados para sua área de transferência." +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +msgid "All Statuses" +msgstr "" + #: apps/remix/app/components/general/app-command-menu.tsx msgid "All templates" msgstr "Todos os modelos" @@ -2222,6 +2232,7 @@ msgstr "" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2310,6 +2321,7 @@ msgstr "" #: apps/remix/app/components/general/teams/team-email-usage.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx #: packages/ui/primitives/document-flow/field-item-advanced-settings.tsx #: packages/ui/primitives/document-flow/send-document-action-dialog.tsx @@ -2363,10 +2375,18 @@ msgstr "Destinatários em cópia" msgid "Center" msgstr "Centro" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "Alterar Destinatário" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "Limite de caracteres" @@ -2408,6 +2428,10 @@ msgstr "Valores da caixa de seleção" msgid "Checkout" msgstr "Checkout" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "Escolha um destinatário existente abaixo para continuar" @@ -2615,8 +2639,8 @@ msgid "Configure document settings and options before sending." msgstr "Configure as configurações e opções do documento antes de enviar." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "Configurar configurações de e-mail para o documento" +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2633,8 +2657,8 @@ msgid "Configure general settings for the template." msgstr "Configurar configurações gerais para o modelo." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "Configurar configurações de segurança para o documento" +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -2826,6 +2850,7 @@ msgstr "Campo copiado para a área de transferência" #: apps/remix/app/components/general/webhook-logs-sheet.tsx #: apps/remix/app/components/tables/admin-document-logs-table.tsx #: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx #: packages/ui/components/document/document-share-button.tsx @@ -2859,6 +2884,10 @@ msgstr "Copiar Links de Assinatura" msgid "Copy token" msgstr "Copiar token" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Copy Value" +msgstr "" + #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/folder-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx @@ -3059,6 +3088,10 @@ msgstr "Crie sua conta e comece a usar a assinatura de documentos de última ger #: apps/remix/app/components/tables/settings-security-passkey-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -3574,6 +3607,7 @@ msgstr "Discord" #: apps/remix/app/components/dialogs/organisation-email-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-email-update-dialog.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Display Name" msgstr "Nome de Exibição" @@ -3593,6 +3627,7 @@ msgstr "Método de Distribuição" msgid "DKIM records generated. Please add the DNS records to verify your domain." msgstr "Registros DKIM gerados. Adicione os registros DNS para verificar seu domínio." +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx msgid "DNS Records" msgstr "Registros DNS" @@ -4004,7 +4039,12 @@ msgstr "Documentos que requerem sua atenção aparecerão aqui" msgid "Documents Viewed" msgstr "Documentos Visualizados" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" msgstr "Domínio" @@ -4020,6 +4060,10 @@ msgstr "Domínio já em uso" msgid "Domain Name" msgstr "Nome do Domínio" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Domain re-registered" +msgstr "" + #: apps/remix/app/routes/_unauthenticated+/reset-password.$token.tsx #: apps/remix/app/routes/_unauthenticated+/signin.tsx msgid "Don't have an account? <0>Sign up" @@ -4157,6 +4201,10 @@ msgstr "Duplicar Modelo" msgid "Duplicate values are not allowed" msgstr "Valores duplicados não são permitidos" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Ex: 0" @@ -4229,6 +4277,7 @@ msgstr "Divulgação de Assinatura Eletrônica" #: apps/remix/app/components/tables/admin-dashboard-users-table.tsx #: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx @@ -4278,6 +4327,10 @@ msgstr "E-mail Confirmado!" msgid "Email Created" msgstr "E-mail Criado" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Email Domain" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx msgid "Email domain not found" msgstr "Domínio de e-mail não encontrado" @@ -4286,6 +4339,8 @@ msgstr "Domínio de e-mail não encontrado" msgid "Email Domain Settings" msgstr "Configurações de Domínio de E-mail" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains._index.tsx @@ -4381,6 +4436,8 @@ msgid "Email verification has been resent" msgstr "A verificação de e-mail foi reenviada" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx msgid "Emails" msgstr "E-mails" @@ -4489,6 +4546,10 @@ msgstr "Documento Anexo" msgid "Enclosed Documents" msgstr "" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4623,6 +4684,7 @@ msgstr "Título do Envelope" msgid "Envelope updated" msgstr "Envelope atualizado" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4687,6 +4749,7 @@ msgstr "Envelope atualizado" #: apps/remix/app/components/general/template/template-edit-form.tsx #: apps/remix/app/components/general/verify-email-banner.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx @@ -4823,6 +4886,14 @@ msgstr "Falha ao carregar documento" msgid "Failed to move folder" msgstr "Falha ao mover pasta" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Failed to re-register email domain" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Failed to reseal document" msgstr "Falha ao selar novamente o documento" @@ -4843,6 +4914,10 @@ msgstr "Falha ao sair de todas as sessões" msgid "Failed to sync license" msgstr "" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "Falha ao desvincular conta" @@ -4963,6 +5038,10 @@ msgstr "O tamanho do arquivo excede o limite de {APP_DOCUMENT_UPLOAD_SIZE_LIMIT} msgid "Fill in the details to create a new subscription claim." msgstr "Preencha os detalhes para criar uma nova reivindicação de assinatura." +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +msgid "Filter by status" +msgstr "" + #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx @@ -5051,6 +5130,10 @@ msgstr "Assinatura Gratuita" msgid "Free Signature Settings" msgstr "Configurações de Assinatura Gratuita" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5083,6 +5166,10 @@ msgstr "Gerar Registros DKIM" msgid "Generate Links" msgstr "Gerar Links" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "Autenticação de ação de destinatário global" @@ -5349,6 +5436,7 @@ msgstr "Tenho certeza! Excluir" #: apps/remix/app/components/tables/admin-claims-table.tsx #: apps/remix/app/components/tables/admin-dashboard-users-table.tsx #: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "ID" msgstr "ID" @@ -5619,6 +5707,14 @@ msgstr "No momento, não é sua vez de assinar. Verifique novamente em breve, po msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "No momento, não é sua vez de assinar. Você receberá um e-mail com instruções assim que for sua vez de assinar o documento." +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "Junte-se a {organisationName} no Documenso" @@ -5636,6 +5732,10 @@ msgstr "Entrou" msgid "Joined {0}" msgstr "Entrou {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5689,6 +5789,10 @@ msgstr "Última modificação" msgid "Last Retried" msgstr "Última Tentativa" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -5707,6 +5811,11 @@ msgstr "Última atualização em" msgid "Last used" msgstr "Último uso" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Last Verified" +msgstr "" + #: apps/remix/app/components/filters/date-range-filter.tsx msgid "Last Year" msgstr "Ano Passado" @@ -5849,6 +5958,7 @@ msgstr "Carregando sugestões..." #: apps/remix/app/components/embed/embed-client-loading.tsx #: apps/remix/app/components/general/default-recipients-multiselect-combobox.tsx #: apps/remix/app/components/general/document/document-page-view-recent-activity.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx #: packages/ui/primitives/pdf-viewer/lazy.tsx msgid "Loading..." @@ -6185,6 +6295,12 @@ msgstr "" msgid "Move Folder" msgstr "Mover Pasta" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "Mover Modelo para Pasta" @@ -6193,6 +6309,10 @@ msgstr "Mover Modelo para Pasta" msgid "Move Templates to Folder" msgstr "" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6236,6 +6356,7 @@ msgstr "N/A" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/settings-security-passkey-table-actions.tsx #: apps/remix/app/components/tables/settings-security-passkey-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: packages/lib/utils/fields.ts @@ -6329,10 +6450,18 @@ msgstr "Nenhum rascunho ativo" msgid "No documents found" msgstr "Nenhum documento encontrado" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "Nenhum e-mail detectado" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "No emails configured for this domain." +msgstr "" + #: apps/remix/app/components/general/admin-license-card.tsx msgid "No features enabled" msgstr "" @@ -6627,6 +6756,8 @@ msgstr "Ou continue com" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/components/tables/user-organisations-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx msgid "Organisation" msgstr "Organização" @@ -6778,6 +6909,7 @@ msgstr "Substituir configurações da organização" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -6907,6 +7039,9 @@ msgstr "Documento PDF" #: apps/remix/app/components/general/envelope-editor/envelope-editor-header.tsx #: apps/remix/app/components/general/template/template-page-view-documents-table.tsx #: apps/remix/app/components/tables/organisation-email-domains-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.members.tsx #: packages/lib/constants/document.ts #: packages/ui/components/document/document-read-only-fields.tsx @@ -6934,6 +7069,10 @@ msgstr "" msgid "Pending invitations" msgstr "Convites pendentes" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Pending since" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/general/billing-plans.tsx msgid "per month" @@ -7201,6 +7340,14 @@ msgstr "Por favor, faça upload de um documento para continuar" msgid "Please upload a logo" msgstr "Por favor, faça upload de um logotipo" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7353,6 +7500,15 @@ msgstr "Configurações de Radio" msgid "Radio values" msgstr "Valores de Radio" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Re-register" +msgstr "" + +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Re-register Email Domain" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx @@ -7533,6 +7689,10 @@ msgstr "Os destinatários poderão assinar o documento assim que enviado" msgid "Recipients will still retain their copy of the document" msgstr "Os destinatários ainda manterão sua cópia do documento" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Record" +msgstr "" + #: apps/remix/app/components/dialogs/organisation-email-domain-records-dialog.tsx msgid "Record Name" msgstr "Nome do Registro" @@ -7738,6 +7898,10 @@ msgstr "Campo Obrigatório" msgid "Required scopes" msgstr "Escopos obrigatórios" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "Selar novamente o documento" @@ -7944,6 +8108,10 @@ msgstr "Falha ao salvar" msgid "Save Template" msgstr "Salvar Modelo" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "Trabalho de selagem iniciado" @@ -7970,6 +8138,10 @@ msgstr "Pesquisar por ID ou nome da reivindicação" msgid "Search by document title" msgstr "Pesquisar por título do documento" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx +msgid "Search by domain or organisation name" +msgstr "" + #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx msgid "Search by ID" msgstr "Pesquisar por ID" @@ -8087,6 +8259,10 @@ msgstr "Selecione um tipo de evento" msgid "Select an option" msgstr "Selecione uma opção" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "Selecione uma organização para ver as equipes" @@ -8230,6 +8406,10 @@ msgstr "Destinatário Selecionado" msgid "Selected templates will be permanently deleted" msgstr "" +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "Selector" +msgstr "" + #: apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx #: apps/remix/app/components/dialogs/webhook-test-dialog.tsx #: packages/ui/primitives/document-flow/add-subject.tsx @@ -8783,6 +8963,10 @@ msgstr "Fonte" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "Lista de domínios separados por espaço. Deixe em branco para permitir todos os domínios." +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8801,6 +8985,7 @@ msgstr "Estatísticas" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks.$id._index.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -8831,6 +9016,10 @@ msgstr "Cliente Stripe criado com sucesso" msgid "Stripe Customer ID" msgstr "ID do Cliente Stripe" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "Assunto" @@ -8882,12 +9071,17 @@ msgstr "Reivindicações de Assinatura" msgid "Subscription invalid" msgstr "Assinatura inválida" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "Status da Assinatura" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9002,6 +9196,10 @@ msgstr "Requisitos do Sistema" msgid "System Theme" msgstr "Tema do Sistema" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -9562,6 +9760,10 @@ msgstr "O destinatário deve assinar o documento para que ele seja concluído." msgid "The recipient is required to view the document for it to be completed." msgstr "O destinatário deve visualizar o documento para que ele seja concluído." +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "The SES identity has been deleted and recreated with the same keys. DNS records remain unchanged." +msgstr "" + #: packages/ui/components/document/document-share-button.tsx msgid "The sharing link could not be created at this time. Please try again." msgstr "O link de compartilhamento não pôde ser criado neste momento. Por favor, tente novamente." @@ -9970,6 +10172,16 @@ msgstr "" msgid "This will check and sync the status of all email domains for this organisation" msgstr "Isso verificará e sincronizará o status de todos os domínios de e-mail para esta organização" +#. placeholder {0}: emailDomain.domain +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." +msgstr "" + +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "Isso APENAS transferirá sinalizadores de recurso definidos como verdadeiro, qualquer coisa desativada na reivindicação inicial não será transferida" @@ -10007,6 +10219,7 @@ msgstr "Fuso Horário" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "Título" @@ -10439,6 +10652,11 @@ msgstr "Desvincular" msgid "Unpin" msgstr "Desafixar" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "Grupo sem título" @@ -10773,6 +10991,7 @@ msgstr "Falha na validação" #: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: apps/remix/app/components/tables/admin-document-recipient-item-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "Value" @@ -10838,6 +11057,7 @@ msgstr "Alinhamento Vertical" #: apps/remix/app/components/tables/inbox-table.tsx #: apps/remix/app/components/tables/inbox-table.tsx #: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx +#: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "View" msgstr "Visualizar" diff --git a/packages/lib/translations/zh/web.po b/packages/lib/translations/zh/web.po index f50e7a257..bdafe26dc 100644 --- a/packages/lib/translations/zh/web.po +++ b/packages/lib/translations/zh/web.po @@ -1084,6 +1084,7 @@ msgstr "操作" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx @@ -2236,6 +2237,7 @@ msgstr "找不到某个人?" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/ai-field-detection-dialog.tsx #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx #: apps/remix/app/components/dialogs/assistant-confirmation-dialog.tsx @@ -2378,10 +2380,18 @@ msgstr "抄送人" msgid "Center" msgstr "居中" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change language" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" msgstr "更改收件人" +#: apps/remix/app/components/general/app-command-menu.tsx +msgid "Change theme" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" msgstr "字符限制" @@ -2423,6 +2433,10 @@ msgstr "复选框值" msgid "Checkout" msgstr "结账" +#: packages/lib/constants/i18n.ts +msgid "Chinese" +msgstr "" + #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" msgstr "从下方选择一个已存在的收件人以继续" @@ -2630,8 +2644,8 @@ msgid "Configure document settings and options before sending." msgstr "在发送前配置文档设置和选项。" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure email settings for the document" -msgstr "为文档配置邮件设置" +msgid "Configure email settings for the document." +msgstr "" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2648,8 +2662,8 @@ msgid "Configure general settings for the template." msgstr "配置模板的一般设置。" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx -msgid "Configure security settings for the document" -msgstr "为文档配置安全设置" +msgid "Configure security settings for the document." +msgstr "" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3082,6 +3096,7 @@ msgstr "创建你的账号,开始使用最先进的文档签署功能。开放 #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/settings+/security.sessions.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.webhooks._index.tsx msgid "Created" @@ -4029,6 +4044,10 @@ msgstr "需要您关注的文档会显示在此处" msgid "Documents Viewed" msgstr "已查看的文档" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." +msgstr "" + #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx msgid "Domain" @@ -4187,6 +4206,10 @@ msgstr "复制模板" msgid "Duplicate values are not allowed" msgstr "不允许重复的值" +#: packages/lib/constants/i18n.ts +msgid "Dutch" +msgstr "" + #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "例如:0" @@ -4528,6 +4551,10 @@ msgstr "随附文档" msgid "Enclosed Documents" msgstr "随附文件" +#: packages/lib/constants/i18n.ts +msgid "English" +msgstr "" + #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-name-dialog.tsx @@ -4662,6 +4689,7 @@ msgstr "信封标题" msgid "Envelope updated" msgstr "信封已更新" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-delete-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-disable-dialog.tsx #: apps/remix/app/components/dialogs/admin-user-enable-dialog.tsx @@ -4863,6 +4891,10 @@ msgstr "加载文档失败" msgid "Failed to move folder" msgstr "移动文件夹失败" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Failed to move subscription. Please try again." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" msgstr "重新注册电子邮件域名失败" @@ -4887,6 +4919,10 @@ msgstr "注销所有会话失败" msgid "Failed to sync license" msgstr "许可证同步失败" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Failed to trigger seal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" msgstr "取消关联账户失败" @@ -5099,6 +5135,10 @@ msgstr "自由签名" msgid "Free Signature Settings" msgstr "免费签名设置" +#: packages/lib/constants/i18n.ts +msgid "French" +msgstr "" + #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx #: apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -5131,6 +5171,10 @@ msgstr "生成 DKIM 记录" msgid "Generate Links" msgstr "生成链接" +#: packages/lib/constants/i18n.ts +msgid "German" +msgstr "" + #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" msgstr "全局收件人操作认证" @@ -5668,6 +5712,14 @@ msgstr "当前还不是您签署的顺序。请稍后再来查看,此文档很 msgid "It's currently not your turn to sign. You will receive an email with instructions once it's your turn to sign the document." msgstr "现在还不是你签署的顺序。轮到你签署时,你会收到一封包含说明的邮件。" +#: packages/lib/constants/i18n.ts +msgid "Italian" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Japanese" +msgstr "" + #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" msgstr "加入 {organisationName},使用 Documenso" @@ -5685,6 +5737,10 @@ msgstr "加入时间" msgid "Joined {0}" msgstr "加入时间 {0}" +#: packages/lib/constants/i18n.ts +msgid "Korean" +msgstr "" + #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx #: apps/remix/app/components/general/document/document-attachments-popover.tsx @@ -5738,6 +5794,10 @@ msgstr "上次修改时间" msgid "Last Retried" msgstr "上次重试时间" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Last Signed" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx msgid "Last updated" @@ -6240,6 +6300,12 @@ msgstr "将文档移动到文件夹" msgid "Move Folder" msgstr "移动文件夹" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +#: apps/remix/app/components/tables/admin-organisations-table.tsx +msgid "Move Subscription" +msgstr "" + #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" msgstr "将模板移动到文件夹" @@ -6248,6 +6314,10 @@ msgstr "将模板移动到文件夹" msgid "Move Templates to Folder" msgstr "将模板移动到文件夹" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." +msgstr "" + #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx #: apps/remix/app/components/tables/templates-table-action-dropdown.tsx @@ -6385,6 +6455,10 @@ msgstr "没有活动草稿" msgid "No documents found" msgstr "未找到文档" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "No eligible organisations found. The target must be on the free plan." +msgstr "" + #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" msgstr "未检测到电子邮件" @@ -6840,6 +6914,7 @@ msgstr "覆盖组织设置" #: apps/remix/app/components/tables/user-organisations-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_authenticated+/dashboard.tsx #: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx @@ -7270,6 +7345,14 @@ msgstr "请上传文档后继续" msgid "Please upload a logo" msgstr "请上传一个 Logo" +#: packages/lib/constants/i18n.ts +msgid "Polish" +msgstr "" + +#: packages/lib/constants/i18n.ts +msgid "Portuguese (Brazil)" +msgstr "" + #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx msgid "Pos X:" @@ -7820,6 +7903,10 @@ msgstr "必填字段" msgid "Required scopes" msgstr "必需的作用域" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Reseal" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" msgstr "重新封存文档" @@ -8026,6 +8113,10 @@ msgstr "保存失败" msgid "Save Template" msgstr "保存模板" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Seal job triggered" +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" msgstr "封装作业已开始" @@ -8173,6 +8264,10 @@ msgstr "选择一个事件类型" msgid "Select an option" msgstr "选择一个选项" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Select an organisation" +msgstr "" + #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" msgstr "选择一个组织以查看团队" @@ -8873,6 +8968,10 @@ msgstr "来源" msgid "Space-separated list of domains. Leave empty to allow all domains." msgstr "以空格分隔的域名列表。留空则允许所有域名。" +#: packages/lib/constants/i18n.ts +msgid "Spanish" +msgstr "" + #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" msgstr "SSO" @@ -8922,6 +9021,10 @@ msgstr "Stripe 客户创建成功" msgid "Stripe Customer ID" msgstr "Stripe 客户 ID" +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Stuck For" +msgstr "" + #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" msgstr "主题" @@ -8973,12 +9076,17 @@ msgstr "订阅声明" msgid "Subscription invalid" msgstr "订阅无效" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Subscription moved successfully" +msgstr "" + #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" msgstr "订阅状态" #: apps/remix/app/components/dialogs/admin-organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/admin-organisation-member-update-dialog.tsx +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/dialogs/envelope-item-delete-dialog.tsx #: apps/remix/app/components/dialogs/organisation-create-dialog.tsx #: apps/remix/app/components/dialogs/organisation-delete-dialog.tsx @@ -9093,6 +9201,10 @@ msgstr "系统要求" msgid "System Theme" msgstr "系统主题" +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "Target Organisation" +msgstr "" + #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/organisation-teams-table.tsx @@ -10070,6 +10182,11 @@ msgstr "这将检查并同步此组织所有邮箱域名的状态" msgid "This will delete the existing SES identity for <0>{0} and recreate it using the same DKIM keys. The user will not need to update their DNS records. The domain status will be reset to Pending." msgstr "这将删除 <0>{0} 的现有 SES 身份,并使用相同的 DKIM 密钥重新创建。用户无需更新其 DNS 记录。域名状态将被重置为“待处理”。" +#. placeholder {0}: selectedOrg.name +#: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx +msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." +msgstr "" + #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" msgstr "这将只回溯设置为 true 的功能标记,初始声明中被禁用的内容不会被回溯。" @@ -10107,6 +10224,7 @@ msgstr "时区" #: apps/remix/app/components/tables/organisation-insights-table.tsx #: apps/remix/app/components/tables/templates-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx #: packages/ui/primitives/document-flow/add-settings.tsx msgid "Title" msgstr "标题" @@ -10539,6 +10657,11 @@ msgstr "取消关联" msgid "Unpin" msgstr "取消固定" +#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +#: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx +msgid "Unsealed Documents" +msgstr "" + #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" msgstr "未命名组" @@ -12666,4 +12789,3 @@ msgstr "您的验证码:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" - From dfbf68e4cd182dc82c1b7b2a4c2571ce01dfc122 Mon Sep 17 00:00:00 2001 From: Konrad <11725227+mKoonrad@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:31:24 +0100 Subject: [PATCH 007/110] fix(i18n): mark editor field number form placeholder for translation (#2536) --- .../app/components/forms/editor/editor-field-number-form.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/remix/app/components/forms/editor/editor-field-number-form.tsx b/apps/remix/app/components/forms/editor/editor-field-number-form.tsx index bc6e7ae6a..ed4598324 100644 --- a/apps/remix/app/components/forms/editor/editor-field-number-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-number-form.tsx @@ -258,7 +258,7 @@ export const EditorFieldNumberForm = ({ @@ -282,7 +282,7 @@ export const EditorFieldNumberForm = ({ From d21b99825d6ebf21bbc5fa3de716548dc89d80ae Mon Sep 17 00:00:00 2001 From: Konrad <11725227+mKoonrad@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:32:12 +0100 Subject: [PATCH 008/110] fix(i18n): add pluralization to expiration period picker (#2535) --- .../document/expiration-period-picker.tsx | 34 +++++++------------ 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/ui/components/document/expiration-period-picker.tsx b/packages/ui/components/document/expiration-period-picker.tsx index f9e3094b9..ee1c39263 100644 --- a/packages/ui/components/document/expiration-period-picker.tsx +++ b/packages/ui/components/document/expiration-period-picker.tsx @@ -1,7 +1,4 @@ -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 type { TEnvelopeExpirationDurationPeriod, @@ -16,16 +13,6 @@ import { SelectValue, } from '@documenso/ui/primitives/select'; -const EXPIRATION_UNITS: Array<{ - value: TEnvelopeExpirationDurationPeriod['unit']; - label: MessageDescriptor; -}> = [ - { value: 'day', label: msg`Days` }, - { value: 'week', label: msg`Weeks` }, - { value: 'month', label: msg`Months` }, - { value: 'year', label: msg`Years` }, -]; - type ExpirationMode = 'duration' | 'disabled' | 'inherit'; const getMode = (value: TEnvelopeExpirationPeriod | null | undefined): ExpirationMode => { @@ -71,8 +58,6 @@ export const ExpirationPeriodPicker = ({ disabled = false, inheritLabel, }: ExpirationPeriodPickerProps) => { - const { _ } = useLingui(); - const mode = getMode(value); const amount = getAmount(value); const unit = getUnit(value); @@ -139,11 +124,18 @@ export const ExpirationPeriodPicker = ({ - {EXPIRATION_UNITS.map((u) => ( - - {_(u.label)} - - ))} + + + + + + + + + + + + From 525dd92a56426086e879c8e29b249b10629ec9dd Mon Sep 17 00:00:00 2001 From: Konrad <11725227+mKoonrad@users.noreply.github.com> Date: Thu, 5 Mar 2026 04:42:40 +0100 Subject: [PATCH 009/110] fix(i18n): mark SUBSCRIPTION_STATUS_MAP for translation (#2515) --- .../tables/admin-organisations-table.tsx | 4 ++-- .../_authenticated+/admin+/organisations.$id.tsx | 9 +++++---- packages/lib/constants/billing.ts | 16 +++++++++++++--- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/remix/app/components/tables/admin-organisations-table.tsx b/apps/remix/app/components/tables/admin-organisations-table.tsx index 1da49067d..144fb20b8 100644 --- a/apps/remix/app/components/tables/admin-organisations-table.tsx +++ b/apps/remix/app/components/tables/admin-organisations-table.tsx @@ -140,7 +140,7 @@ export const AdminOrganisationsTable = ({ target="_blank" className="flex flex-row items-center gap-2" > - {SUBSCRIPTION_STATUS_MAP[row.original.subscription.status]} + {i18n._(SUBSCRIPTION_STATUS_MAP[row.original.subscription.status])} ) : ( @@ -203,7 +203,7 @@ export const AdminOrganisationsTable = ({ ), }, ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; - }, []); + }, [i18n, t, memberUserId, showOwnerColumn]); return (
diff --git a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx index c0f6e8ea7..9e9dfaeea 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx @@ -56,7 +56,7 @@ export default function OrganisationGroupSettingsPage({ }: Route.ComponentProps) { const { licenseFlags } = loaderData; - const { t } = useLingui(); + const { t, i18n } = useLingui(); const { toast } = useToast(); const navigate = useNavigate(); @@ -98,7 +98,7 @@ export default function OrganisationGroupSettingsPage({ accessorKey: 'url', }, ] satisfies DataTableColumnDef[]; - }, []); + }, [t]); const organisationMembersColumns = useMemo(() => { return [ @@ -143,7 +143,7 @@ export default function OrganisationGroupSettingsPage({ }, }, ] satisfies DataTableColumnDef[]; - }, [organisation]); + }, [organisation, t]); if (isLoadingOrganisation) { return ( @@ -209,7 +209,8 @@ export default function OrganisationGroupSettingsPage({ {organisation.subscription ? ( - {SUBSCRIPTION_STATUS_MAP[organisation.subscription.status]} subscription found + {i18n._(SUBSCRIPTION_STATUS_MAP[organisation.subscription.status])} subscription + found ) : ( diff --git a/packages/lib/constants/billing.ts b/packages/lib/constants/billing.ts index 48c16802d..b275cadf7 100644 --- a/packages/lib/constants/billing.ts +++ b/packages/lib/constants/billing.ts @@ -1,3 +1,4 @@ +import { msg } from '@lingui/core/macro'; import { SubscriptionStatus } from '@prisma/client'; export enum STRIPE_PLAN_TYPE { @@ -12,7 +13,16 @@ export enum STRIPE_PLAN_TYPE { export const FREE_TIER_DOCUMENT_QUOTA = 5; export const SUBSCRIPTION_STATUS_MAP = { - [SubscriptionStatus.ACTIVE]: 'Active', - [SubscriptionStatus.INACTIVE]: 'Inactive', - [SubscriptionStatus.PAST_DUE]: 'Past Due', + [SubscriptionStatus.ACTIVE]: msg({ + message: 'Active', + context: 'Subscription status', + }), + [SubscriptionStatus.INACTIVE]: msg({ + message: 'Inactive', + context: 'Subscription status', + }), + [SubscriptionStatus.PAST_DUE]: msg({ + message: 'Past Due', + context: 'Subscription status', + }), }; From 1f985e2cd36bc5dfa4d2eef1659b79f06e4f9721 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Thu, 5 Mar 2026 14:54:36 +1100 Subject: [PATCH 010/110] fix: invalid po translations (#2567) --- packages/lib/translations/de/web.po | 4 ++-- packages/lib/translations/en/web.po | 2 +- packages/lib/translations/es/web.po | 2 +- packages/lib/translations/it/web.po | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/lib/translations/de/web.po b/packages/lib/translations/de/web.po index eacf3147b..e4c98fb7e 100644 --- a/packages/lib/translations/de/web.po +++ b/packages/lib/translations/de/web.po @@ -2004,11 +2004,11 @@ msgstr "Mindestens ein Signaturtyp muss aktiviert sein" #: apps/remix/app/components/general/document/document-attachments-popover.tsx msgid "Attachment added successfully." -msgstr "Anlage erfolgreich hinzugefügt." +msgstr "Anhang erfolgreich hinzugefügt." #: apps/remix/app/components/general/document/document-attachments-popover.tsx msgid "Attachment removed successfully." -msgstr "<<<<<<< Updated upstream=======" +msgstr "Anhang erfolgreich entfernt." #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx diff --git a/packages/lib/translations/en/web.po b/packages/lib/translations/en/web.po index af80512ce..18468ab63 100644 --- a/packages/lib/translations/en/web.po +++ b/packages/lib/translations/en/web.po @@ -2003,7 +2003,7 @@ msgstr "Attachment added successfully." #: apps/remix/app/components/general/document/document-attachments-popover.tsx msgid "Attachment removed successfully." -msgstr "Attachment removed successfully.<<<<<<< Updated upstream=======" +msgstr "Attachment removed successfully." #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx diff --git a/packages/lib/translations/es/web.po b/packages/lib/translations/es/web.po index af2e838c7..dc2861fe8 100644 --- a/packages/lib/translations/es/web.po +++ b/packages/lib/translations/es/web.po @@ -2008,7 +2008,7 @@ msgstr "Adjunto añadido exitosamente." #: apps/remix/app/components/general/document/document-attachments-popover.tsx msgid "Attachment removed successfully." -msgstr "<<<<<<< Updated upstream=======" +msgstr "Adjunto eliminado exitosamente." #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx diff --git a/packages/lib/translations/it/web.po b/packages/lib/translations/it/web.po index 2fa20713a..4d49dcc00 100644 --- a/packages/lib/translations/it/web.po +++ b/packages/lib/translations/it/web.po @@ -2008,7 +2008,7 @@ msgstr "Allegato aggiunto con successo." #: apps/remix/app/components/general/document/document-attachments-popover.tsx msgid "Attachment removed successfully." -msgstr "<<<<<<< Updated upstream=======" +msgstr "Allegato rimosso con successo." #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx From ef0a5b54ba6b0ad6bbb47c07fe98f5bc6137ba45 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 5 Mar 2026 15:12:20 +1100 Subject: [PATCH 011/110] fix: verify before re-registering in email sync (#2568) --- .../internal/sync-email-domains.handler.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts b/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts index 8d580a010..2b9768244 100644 --- a/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts +++ b/packages/lib/jobs/definitions/internal/sync-email-domains.handler.ts @@ -46,6 +46,14 @@ export const run = async ({ io }: { payload: TSyncEmailDomainsJobDefinition; io: batch.map(async (domain) => { const shouldReregister = domain.createdAt < reregisterCutoff; + const { isVerified } = await verifyEmailDomain(domain.id); + + if (isVerified) { + io.logger.info(`Domain "${domain.domain}" is verified`); + + return 'verified' as const; + } + if (shouldReregister) { io.logger.info( `Domain "${domain.domain}" has been pending since ${domain.createdAt.toISOString()}, attempting re-registration`, @@ -55,9 +63,7 @@ export const run = async ({ io }: { payload: TSyncEmailDomainsJobDefinition; io: return 'reregistered' as const; } - const { isVerified } = await verifyEmailDomain(domain.id); - - return isVerified ? ('verified' as const) : ('pending' as const); + return 'pending' as const; }), ); From db1087d76d39f5fb7379eae7fe9b3bce8b8cb029 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 5 Mar 2026 15:16:45 +1100 Subject: [PATCH 012/110] v2.7.1 --- apps/remix/package.json | 2 +- package-lock.json | 6 +++--- package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/remix/package.json b/apps/remix/package.json index 4130066fd..8ee535270 100644 --- a/apps/remix/package.json +++ b/apps/remix/package.json @@ -105,5 +105,5 @@ "vite-plugin-babel-macros": "^1.0.6", "vite-tsconfig-paths": "^5.1.4" }, - "version": "2.7.0" + "version": "2.7.1" } diff --git a/package-lock.json b/package-lock.json index 903e1306c..7dd25c0e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@documenso/root", - "version": "2.7.0", + "version": "2.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@documenso/root", - "version": "2.7.0", + "version": "2.7.1", "hasInstallScript": true, "workspaces": [ "apps/*", @@ -651,7 +651,7 @@ }, "apps/remix": { "name": "@documenso/remix", - "version": "2.7.0", + "version": "2.7.1", "dependencies": { "@cantoo/pdf-lib": "^2.5.3", "@documenso/api": "*", diff --git a/package.json b/package.json index 62550e78f..38de5787e 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "apps/*", "packages/*" ], - "version": "2.7.0", + "version": "2.7.1", "scripts": { "postinstall": "patch-package", "build": "turbo run build", From bff360b084bade297ceafb065cb0c2d56259dead Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 5 Mar 2026 15:34:40 +1100 Subject: [PATCH 013/110] fix: upgrade @libpdf/core (#2569) --- package-lock.json | 15 ++++++++------- package.json | 2 +- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7dd25c0e1..db09f4e47 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.2.9", + "@libpdf/core": "^0.2.11", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "@prisma/extension-read-replicas": "^0.4.1", @@ -4645,14 +4645,15 @@ "license": "MIT" }, "node_modules/@libpdf/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.9.tgz", - "integrity": "sha512-BpZvJr9mf2ALQwv7jPMNLmOjiYxytF8+2UO4mt/S3cg5Brw62/UEpBs8IYa4zXsAR/yQW4vVkkfE4OnOYd6Ktw==", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.11.tgz", + "integrity": "sha512-VRHPG0pH98+F0DdxAAo0W074CoKHhhemx/HyIdTBn/usPwOI8WmFyfSq3f2sl0VNddij14X69BMD7lZ0c3AQTw==", "license": "MIT", "dependencies": { "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "@scure/base": "^2.0.0", + "asn1js": "^3.0.7", "lru-cache": "^11.2.6", "pako": "^2.1.0", "pkijs": "^3.3.3" @@ -18607,9 +18608,9 @@ "license": "MIT" }, "node_modules/asn1js": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", - "integrity": "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", "license": "BSD-3-Clause", "dependencies": { "pvtsutils": "^1.3.6", diff --git a/package.json b/package.json index 38de5787e..7b179f0b4 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.2.9", + "@libpdf/core": "^0.2.11", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "@prisma/extension-read-replicas": "^0.4.1", From 406e77e4bee6e647fda04f9c3dbbec54c3e095ef Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Thu, 5 Mar 2026 17:33:36 +1100 Subject: [PATCH 014/110] chore: add translations (#2570) --- packages/lib/translations/de/web.po | 100 +++++++++++++------------- packages/lib/translations/es/web.po | 102 ++++++++++++++------------- packages/lib/translations/fr/web.po | 100 +++++++++++++------------- packages/lib/translations/it/web.po | 102 ++++++++++++++------------- packages/lib/translations/ja/web.po | 100 +++++++++++++------------- packages/lib/translations/ko/web.po | 100 +++++++++++++------------- packages/lib/translations/nl/web.po | 100 +++++++++++++------------- packages/lib/translations/pl/web.po | 104 +++++++++++++++------------- packages/lib/translations/zh/web.po | 100 +++++++++++++------------- 9 files changed, 480 insertions(+), 428 deletions(-) diff --git a/packages/lib/translations/de/web.po b/packages/lib/translations/de/web.po index e4c98fb7e..a4973e438 100644 --- a/packages/lib/translations/de/web.po +++ b/packages/lib/translations/de/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: de\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 04:37\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: German\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -284,6 +284,22 @@ msgstr "{0} im Namen von \"{1}\" hat Sie eingeladen, das Dokument \"{2}\" {recip msgid "{0} Teams" msgstr "{0} Teams" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, one {Tag} other {Tage}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, one {Monat} other {Monate}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, one {Woche} other {Wochen}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, one {Jahr} other {Jahre}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "Aktiv" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "Aktiv" @@ -2382,7 +2399,7 @@ msgstr "Zentrum" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "Sprache ändern" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "Empfänger ändern" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "Design ändern" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "Abrechnung" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "Chinesisch" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "Dokumenteneinstellungen und Optionen vor dem Versand konfigurieren." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "E-Mail-Einstellungen für das Dokument konfigurieren." #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "Konfigurieren Sie die allgemeinen Einstellungen für die Vorlage." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "Sicherheitseinstellungen für das Dokument konfigurieren." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "Datumseinstellungen" msgid "David is the Employee, Lucas is the Manager" msgstr "David ist der Mitarbeiter, Lucas ist der Manager" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "Tage" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "Dokumente angesehen" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "Dokumente, bei denen alle Empfänger unterschrieben haben, das Dokument aber noch nicht versiegelt wurde. Dokumente, die länger als 6 Stunden hängen bleiben, werden vom Sweep-Job nicht mehr erneut versucht." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "Doppelte Werte sind nicht erlaubt" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "Niederländisch" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Z. B. 0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "Z. B. 100" @@ -4553,7 +4568,7 @@ msgstr "Beigefügte Dokumente" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "Englisch" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "Ordner konnte nicht verschoben werden" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "Abonnement konnte nicht verschoben werden. Bitte versuchen Sie es erneut." #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "Lizenz konnte nicht synchronisiert werden" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "Auslösen der Versiegelung fehlgeschlagen" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "Einstellungen für freie Unterschrift" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "Französisch" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "Links generieren" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "Deutsch" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "Wichtig: Was dies bedeutet" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "Inaktiv" @@ -5714,11 +5730,11 @@ msgstr "Es ist derzeit nicht deine Reihe zu unterschreiben. Du erhältst eine E- #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "Italienisch" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "Japanisch" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "Beigetreten {0}" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "Koreanisch" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "Zuletzt erneut versucht" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "Zuletzt unterschrieben" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "Monatlich aktive Benutzer: Benutzer, die mindestens ein Dokument erstell msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "Monatlich aktive Benutzer: Benutzer, die mindestens eines ihrer Dokumente abgeschlossen haben" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "Monate" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "Ordner verschieben" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "Abonnement verschieben" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "Vorlagen in Ordner verschieben" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "Verschieben Sie das Abonnement von \"{sourceOrganisationName}\" zu einer anderen Organisation, die diesem Benutzer gehört." #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "Keine Dokumente gefunden" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "Keine geeigneten Organisationen gefunden. Das Ziel muss im kostenlosen Tarif sein." #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "Passwort aktualisiert!" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "Überfällig" @@ -7347,11 +7360,11 @@ msgstr "Bitte laden Sie ein Logo hoch" #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "Polnisch" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "Portugiesisch (Brasilien)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "Erforderliche Bereiche" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "Erneut versiegeln" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "Vorlage speichern" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "Versiegelungsauftrag ausgelöst" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "Option auswählen" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "Organisation auswählen" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "Durch Leerzeichen getrennte Liste von Domains. Leer lassen, um alle Doma #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "Spanisch" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "Stripe-Kunden-ID" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "Blockiert seit" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "Abonnement ungültig" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "Abonnement erfolgreich verschoben" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "Systemthema" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "Zielorganisation" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10185,7 +10198,7 @@ msgstr "Dadurch wird die bestehende SES-Identität für <0>{0} gelöscht und #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "Dadurch wird das Abonnement von \"{sourceOrganisationName}\" zu \"{0}\" verschoben. Die Quellorganisation wird auf den kostenlosen Tarif zurückgesetzt." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10660,7 +10673,7 @@ msgstr "Lösen" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "Unversiegelte Dokumente" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11650,10 +11663,6 @@ msgstr "Webhook-URL" msgid "Webhooks" msgstr "Webhooks" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "Wochen" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "Willkommen" @@ -11739,10 +11748,6 @@ msgstr "Schreiben Sie eine Beschreibung, die in Ihrem öffentlichen Profil angez msgid "Yearly" msgstr "Jährlich" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "Jahre" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12789,3 +12794,4 @@ msgstr "Ihr Verifizierungscode:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/es/web.po b/packages/lib/translations/es/web.po index dc2861fe8..a380ec729 100644 --- a/packages/lib/translations/es/web.po +++ b/packages/lib/translations/es/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: es\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 04:37\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: Spanish\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -284,6 +284,22 @@ msgstr "{0} en nombre de \"{1}\" te ha invitado a {recipientActionVerb} el docum msgid "{0} Teams" msgstr "{0} Equipos" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, one {Día} other {Días}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, one {Mes} other {Meses}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, one {Semana} other {Semanas}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, one {Año} other {Años}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "Activo" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "Activa" @@ -2008,7 +2025,7 @@ msgstr "Adjunto añadido exitosamente." #: apps/remix/app/components/general/document/document-attachments-popover.tsx msgid "Attachment removed successfully." -msgstr "Adjunto eliminado exitosamente." +msgstr "<<<<<<< Updated upstream=======" #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx @@ -2382,7 +2399,7 @@ msgstr "Centro" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "Cambiar idioma" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "Cambiar destinatario" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "Cambiar tema" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "Checkout" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "Chino" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "Configura la configuración del documento y las opciones antes de enviar #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "Configura los ajustes de correo electrónico para el documento." #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "Configurar ajustes generales para la plantilla." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "Configura los ajustes de seguridad para el documento." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "Configuración de Fecha" msgid "David is the Employee, Lucas is the Manager" msgstr "David es el empleado, Lucas es el gerente" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "Días" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "Documentos vistos" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "Documentos en los que todos los destinatarios han firmado pero el documento no se ha sellado. Los documentos atascados durante más de 6 horas ya no se vuelven a procesar mediante la tarea de barrido." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "No se permiten valores duplicados" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "Neerlandés" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Ej.: 0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "Ej.: 100" @@ -4553,7 +4568,7 @@ msgstr "Documentos adjuntos" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "Inglés" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "Error al mover la carpeta" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "No se pudo mover la suscripción. Inténtalo de nuevo." #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "Error al sincronizar la licencia" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "Error al activar el sellado" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "Configuración de Firma Gratuita" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "Francés" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "Generar enlaces" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "Alemán" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "Importante: Lo que esto significa" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "Inactiva" @@ -5714,11 +5730,11 @@ msgstr "Actualmente no es tu turno para firmar. Recibirás un correo electrónic #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "Italiano" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "Japonés" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "Se unió a {0}" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "Coreano" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "Último reintento" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "Última firma" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "Usuarios activos mensuales: Usuarios que crearon al menos un documento" msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "Usuarios activos mensuales: Usuarios que completaron al menos uno de sus documentos" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "Meses" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "Mover Carpeta" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "Mover suscripción" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "Mover plantillas a la carpeta" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "Mover la suscripción de \"{sourceOrganisationName}\" a otra organización propiedad de este usuario." #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "No se encontraron documentos" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "No se encontraron organizaciones aptas. El destino debe estar en el plan gratuito." #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "¡Contraseña actualizada!" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "Vencida" @@ -7347,11 +7360,11 @@ msgstr "Por favor, suba un logotipo" #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "Polaco" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "Portugués (Brasil)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "Ámbitos requeridos" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "Volver a sellar" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "Guardar plantilla" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "Tarea de sellado activada" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "Seleccionar una opción" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "Seleccionar una organización" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "Lista de dominios separados por espacios. Deje vacío para permitir todo #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "Español" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "ID de Cliente de Stripe" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "Atascado durante" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "Suscripción inválida" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "La suscripción se movió correctamente" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "Tema del sistema" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "Organización de destino" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10185,7 +10198,7 @@ msgstr "Esto eliminará la identidad de SES existente para <0>{0} y la volve #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "Esto moverá la suscripción de \"{sourceOrganisationName}\" a \"{0}\". La organización de origen se restablecerá al plan gratuito." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10660,7 +10673,7 @@ msgstr "Desanclar" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "Documentos sin sellar" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11650,10 +11663,6 @@ msgstr "URL del Webhook" msgid "Webhooks" msgstr "Webhooks" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "Semanas" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "Bienvenido" @@ -11739,10 +11748,6 @@ msgstr "Escribe una descripción para mostrar en tu perfil público" msgid "Yearly" msgstr "Anual" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "Años" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12789,3 +12794,4 @@ msgstr "Su código de verificación:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "su-dominio.com otro-dominio.com" + diff --git a/packages/lib/translations/fr/web.po b/packages/lib/translations/fr/web.po index 56afe36e1..c984900ab 100644 --- a/packages/lib/translations/fr/web.po +++ b/packages/lib/translations/fr/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: fr\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 04:37\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: French\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" @@ -284,6 +284,22 @@ msgstr "{0} représentant \"{1}\" vous a invité à {recipientActionVerb} le doc msgid "{0} Teams" msgstr "{0} Équipes" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, one {Jour} other {Jours}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, one {Mois} other {Mois}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, one {Semaine} other {Semaines}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, one {Année} other {Années}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "Actif" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "Actif" @@ -2382,7 +2399,7 @@ msgstr "Centre" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "Changer de langue" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "Modifier le destinataire" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "Changer de thème" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "Passer à la caisse" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "Chinois" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "Configurez les paramètres et options du document avant l'envoi." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "Configurer les paramètres d’e-mail pour le document." #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "Configurer les paramètres généraux pour le modèle." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "Configurer les paramètres de sécurité pour le document." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "Paramètres de la date" msgid "David is the Employee, Lucas is the Manager" msgstr "David est l'employé, Lucas est le manager" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "Jours" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "Documents consultés" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "Documents pour lesquels tous les destinataires ont signé mais qui n’ont pas été scellés. Les documents bloqués pendant plus de 6 heures ne sont plus relancés par la tâche de balayage." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "Les valeurs en double ne sont pas autorisées" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "Néerlandais" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Par ex. 0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "Par ex. 100" @@ -4553,7 +4568,7 @@ msgstr "Documents joints" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "Anglais" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "Échec du déplacement du dossier" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "Échec du déplacement de l’abonnement. Veuillez réessayer." #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "Échec de la synchronisation de la licence" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "Échec du déclenchement du scellage" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "Paramètres de la signature libre" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "Français" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "Générer des liens" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "Allemand" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "Important : Ce que cela signifie" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "Inactif" @@ -5714,11 +5730,11 @@ msgstr "Ce n'est actuellement pas votre tour de signer. Vous recevrez un e-mail #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "Italien" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "Japonais" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "A rejoint {0}" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "Coréen" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "Dernière relance" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "Dernière signature" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "Utilisateurs actifs mensuels : utilisateurs ayant créé au moins un doc msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "Utilisateurs actifs mensuels : utilisateurs ayant terminé au moins un de leurs documents" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "Mois" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "Déplacer le Dossier" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "Déplacer l’abonnement" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "Déplacer les modèles vers le dossier" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "Déplacer l’abonnement de \"{sourceOrganisationName}\" vers une autre organisation appartenant à cet utilisateur." #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "Aucun document trouvé" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "Aucune organisation éligible trouvée. La cible doit être sur l’offre gratuite." #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "Mot de passe mis à jour !" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "En retard de paiement" @@ -7347,11 +7360,11 @@ msgstr "Veuillez télécharger un logo" #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "Polonais" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "Portugais (Brésil)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "Portées requises" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "Rescellage" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "Sauvegarder le modèle" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "Tâche de scellage déclenchée" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "Sélectionner une option" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "Sélectionner une organisation" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "Liste des domaines séparée par des espaces. Laissez vide pour autorise #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "Espagnol" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "ID client Stripe" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "Bloqué depuis" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "Abonnement non valide" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "Abonnement déplacé avec succès" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "Thème système" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "Organisation cible" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10185,7 +10198,7 @@ msgstr "Cela supprimera l’identité SES existante pour <0>{0} et la recré #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "Cela déplacera l’abonnement de \"{sourceOrganisationName}\" vers \"{0}\". L’organisation source sera réinitialisée sur l’offre gratuite." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10660,7 +10673,7 @@ msgstr "Détacher" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "Documents non scellés" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11650,10 +11663,6 @@ msgstr "URL du webhook" msgid "Webhooks" msgstr "Webhooks" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "Semaines" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "Bienvenue" @@ -11739,10 +11748,6 @@ msgstr "Écrivez une description à afficher sur votre profil public" msgid "Yearly" msgstr "Annuel" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "Années" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12789,3 +12794,4 @@ msgstr "Votre code de vérification :" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/it/web.po b/packages/lib/translations/it/web.po index 4d49dcc00..33854c1a6 100644 --- a/packages/lib/translations/it/web.po +++ b/packages/lib/translations/it/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: it\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 04:37\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: Italian\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -284,6 +284,22 @@ msgstr "{0} per conto di \"{1}\" ti ha invitato a {recipientActionVerb} il docum msgid "{0} Teams" msgstr "{0} Squadre" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, one {Giorno} other {Giorni}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, one {Mese} other {Mesi}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, one {Settimana} other {Settimane}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, one {Anno} other {Anni}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "Attivo" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "Attivo" @@ -2008,7 +2025,7 @@ msgstr "Allegato aggiunto con successo." #: apps/remix/app/components/general/document/document-attachments-popover.tsx msgid "Attachment removed successfully." -msgstr "Allegato rimosso con successo." +msgstr "<<<<<<< Updated upstream=======" #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx #: apps/remix/app/components/general/document-signing/document-signing-attachments-popover.tsx @@ -2382,7 +2399,7 @@ msgstr "Centro" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "Cambia lingua" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "Cambia destinatario" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "Cambia tema" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "Pagamento" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "Cinese" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "Configura le impostazioni e le opzioni del documento prima di inviare." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "Configura le impostazioni email per il documento." #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "Configura le impostazioni generali per il modello." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "Configura le impostazioni di sicurezza per il documento." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "Impostazioni della data" msgid "David is the Employee, Lucas is the Manager" msgstr "David è il dipendente, Lucas è il manager" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "Giorni" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "Documenti visualizzati" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "Documenti in cui tutti i destinatari hanno firmato ma il documento non è stato sigillato. I documenti bloccati per più di 6 ore non vengono più ritentati dal processo di sweep." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "I valori duplicati non sono ammessi" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "Olandese" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Es. 0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "Es. 100" @@ -4553,7 +4568,7 @@ msgstr "Documenti allegati" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "Inglese" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "Impossibile spostare la cartella" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "Impossibile spostare l’abbonamento. Riprova." #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "Impossibile sincronizzare la licenza" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "Impossibile avviare la sigillatura" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "Impostazioni Firma Gratuita" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "Francese" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "Genera link" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "Tedesco" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "Importante: Cosa Significa" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "Inattivo" @@ -5714,11 +5730,11 @@ msgstr "Al momento, non è il tuo turno di firmare. Riceverai un'email con le is #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "Italiano" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "Giapponese" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "Iscritto a {0}" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "Coreano" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "Ultima Riprova" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "Ultima firma" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "Utenti attivi mensili: Utenti che hanno creato almeno un documento" msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "Utenti attivi mensili: Utenti con almeno uno dei loro documenti completati" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "Mesi" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "Sposta Cartella" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "Sposta abbonamento" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "Sposta modelli nella cartella" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "Sposta l’abbonamento da \"{sourceOrganisationName}\" a un’altra organizzazione di proprietà di questo utente." #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "Nessun documento trovato" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "Nessuna organizzazione idonea trovata. La destinazione deve essere sul piano gratuito." #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "Password aggiornata!" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "In ritardo" @@ -7347,11 +7360,11 @@ msgstr "Si prega di caricare un logo" #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "Polacco" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "Portoghese (Brasile)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "Scope richiesti" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "Sigilla di nuovo" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "Salva modello" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "Processo di sigillatura avviato" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "Seleziona un'opzione" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "Seleziona un’organizzazione" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "Elenco di domini separato da spazi. Lascia vuoto per consentire tutti i #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "Spagnolo" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "ID cliente di Stripe" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "Bloccato da" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "Abbonamento non valido" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "Abbonamento spostato correttamente" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "Tema del sistema" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "Organizzazione di destinazione" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10185,7 +10198,7 @@ msgstr "Questo eliminerà l’identità SES esistente per <0>{0} e la ricree #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "Questo sposterà l’abbonamento da \"{sourceOrganisationName}\" a \"{0}\". L’organizzazione di origine verrà reimpostata sul piano gratuito." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10660,7 +10673,7 @@ msgstr "Rimuovi" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "Documenti non sigillati" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11650,10 +11663,6 @@ msgstr "URL del webhook" msgid "Webhooks" msgstr "Webhook" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "Settimane" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "Benvenuto" @@ -11739,10 +11748,6 @@ msgstr "Scrivi una descrizione da mostrare sul tuo profilo pubblico" msgid "Yearly" msgstr "Annuale" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "Anni" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12789,3 +12794,4 @@ msgstr "Il tuo codice di verifica:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "tuo-dominio.com altro-dominio.com" + diff --git a/packages/lib/translations/ja/web.po b/packages/lib/translations/ja/web.po index 321f43a60..572872084 100644 --- a/packages/lib/translations/ja/web.po +++ b/packages/lib/translations/ja/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: ja\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 04:37\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: Japanese\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -284,6 +284,22 @@ msgstr "{0} が「{1}」を代表してドキュメント「{2}」の{recipientA msgid "{0} Teams" msgstr "{0} のチーム" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, other {日}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, other {か月}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, other {週間}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, other {年}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "有効" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "有効" @@ -2382,7 +2399,7 @@ msgstr "中央" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "言語を変更する" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "受信者を変更" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "テーマを変更する" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "チェックアウト" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "中国語" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "送信前に、ドキュメント設定とオプションを構成しま #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "この文書のメール設定を構成します。" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "このテンプレートの一般設定を構成します。" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "この文書のセキュリティ設定を構成します。" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "日付設定" msgid "David is the Employee, Lucas is the Manager" msgstr "David は従業員で、Lucas はマネージャーです" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "日数" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "閲覧された文書" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "すべての受信者が署名したものの、まだ封印されていない文書です。6時間以上スタックしている文書は、スイープジョブによって再試行されなくなります。" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "同じ値を重複して使用することはできません" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "オランダ語" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "例: 0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "例: 100" @@ -4553,7 +4568,7 @@ msgstr "同封された文書" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "英語" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "フォルダの移動に失敗しました" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "サブスクリプションの移動に失敗しました。もう一度お試しください。" #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "ライセンスの同期に失敗しました" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "封印処理のトリガーに失敗しました" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "手書き署名の設定" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "フランス語" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "リンクを生成" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "ドイツ語" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "重要: これが意味すること" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "無効" @@ -5714,11 +5730,11 @@ msgstr "現在はあなたの署名順ではありません。順番が来ると #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "イタリア語" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "日本語" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "{0} に参加" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "韓国語" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "最終再試行日時" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "最終署名日時" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "月間アクティブユーザー:1 つ以上の文書を作成した msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "月間アクティブユーザー:1 つ以上の文書が完了したユーザー" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "か月" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "フォルダを移動" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "サブスクリプションを移動" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "テンプレートをフォルダーに移動" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "サブスクリプションを「{sourceOrganisationName}」から、このユーザーが所有する別の組織へ移動します。" #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "文書が見つかりません" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "対象となる組織が見つかりません。対象は無料プランである必要があります。" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "パスワードを更新しました。" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "支払い遅延" @@ -7347,11 +7360,11 @@ msgstr "ロゴをアップロードしてください" #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "ポーランド語" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "ポルトガル語(ブラジル)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "必要なスコープ" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "再封印" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "テンプレートを保存" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "封印ジョブをトリガーしました" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "オプションを選択" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "組織を選択" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "ドメインを半角スペース区切りで入力します。空のま #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "スペイン語" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "Stripe 顧客 ID" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "スタック期間" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "サブスクリプションが無効です" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "サブスクリプションを移動しました" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "システムテーマ" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "対象の組織" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10185,7 +10198,7 @@ msgstr "これにより、<0>{0} の既存のSESアイデンティティが #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "サブスクリプションを「{sourceOrganisationName}」から「{0}」へ移動します。元の組織は無料プランにリセットされます。" #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10660,7 +10673,7 @@ msgstr "ピン留めを解除" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "未封印の文書" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11650,10 +11663,6 @@ msgstr "Webhook URL" msgid "Webhooks" msgstr "Webhook" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "週" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "ようこそ" @@ -11739,10 +11748,6 @@ msgstr "公開プロフィールに表示する説明文を入力してくださ msgid "Yearly" msgstr "年額" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "年" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12789,3 +12794,4 @@ msgstr "認証コード:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/ko/web.po b/packages/lib/translations/ko/web.po index ac8f15c98..54370af13 100644 --- a/packages/lib/translations/ko/web.po +++ b/packages/lib/translations/ko/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: ko\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 04:37\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: Korean\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -284,6 +284,22 @@ msgstr "{0}이(가) \"{1}\"을(를) 대신하여 \"{2}\" 문서에 대해 귀하 msgid "{0} Teams" msgstr "{0} 팀" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, other {일}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, other {개월}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, other {주}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, other {년}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "활성" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "활성" @@ -2382,7 +2399,7 @@ msgstr "가운데" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "언어 변경하기" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "수신자 변경" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "테마 변경하기" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "결제하기" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "중국어" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "문서를 보내기 전에 설정 및 옵션을 구성합니다." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "문서의 이메일 설정을 구성합니다." #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "템플릿의 일반 설정을 구성합니다." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "문서의 보안 설정을 구성합니다." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "날짜 설정" msgid "David is the Employee, Lucas is the Manager" msgstr "David는 직원이고, Lucas는 관리자입니다." -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "일" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "열람된 문서" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "모든 수신자가 서명했지만 문서가 아직 봉인되지 않은 문서입니다. 6시간 이상 멈춰 있는 문서는 스윕 작업에 의해 더 이상 재시도되지 않습니다." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "중복 값은 허용되지 않습니다" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "네덜란드어" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "예: 0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "예: 100" @@ -4553,7 +4568,7 @@ msgstr "동봉된 문서들" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "영어" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "폴더를 이동하지 못했습니다." #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "구독을 이동하지 못했습니다. 다시 시도해 주세요." #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "라이선스 동기화에 실패했습니다" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "봉인 트리거에 실패했습니다" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "무료 서명 설정" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "프랑스어" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "링크 생성" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "독일어" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "중요: 이것이 의미하는 것" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "비활성" @@ -5714,11 +5730,11 @@ msgstr "현재는 귀하의 서명 순서가 아닙니다. 순서가 되면 문 #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "이탈리아어" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "일본어" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "{0}에 가입함" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "한국어" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "마지막 재시도 시간" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "마지막 서명 시각" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "월간 활성 사용자: 문서를 하나 이상 생성한 사용자" msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "월간 활성 사용자: 문서가 하나 이상 완료된 사용자" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "개월" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "폴더 이동" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "구독 이동" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "템플릿을 폴더로 이동" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "\"{sourceOrganisationName}\"의 구독을 이 사용자가 소유한 다른 조직으로 이동합니다." #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "문서를 찾을 수 없습니다" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "적합한 조직을 찾을 수 없습니다. 대상 조직은 무료 플랜이어야 합니다." #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "비밀번호가 업데이트되었습니다!" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "연체" @@ -7347,11 +7360,11 @@ msgstr "로고를 업로드해 주세요." #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "폴란드어" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "포르투갈어(브라질)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "필요한 범위" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "다시 봉인" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "템플릿 저장" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "봉인 작업이 트리거되었습니다" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "옵션을 선택하세요." #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "조직 선택" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "도메인을 공백으로 구분하여 입력하세요. 비워 두면 #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "스페인어" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "Stripe 고객 ID" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "멈춰 있던 시간" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "구독이 유효하지 않습니다." #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "구독이 성공적으로 이동되었습니다." #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "시스템 테마" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "대상 조직" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10185,7 +10198,7 @@ msgstr "이 작업은 <0>{0}에 대한 기존 SES ID를 삭제하고 동일 #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "이 작업을 수행하면 구독이 \"{sourceOrganisationName}\"에서 \"{0}\"(으)로 이동됩니다. 원본 조직은 무료 플랜으로 재설정됩니다." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10660,7 +10673,7 @@ msgstr "고정 해제" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "봉인되지 않은 문서" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11650,10 +11663,6 @@ msgstr "웹훅 URL" msgid "Webhooks" msgstr "웹훅" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "주" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "환영합니다" @@ -11739,10 +11748,6 @@ msgstr "공개 프로필에 표시될 설명을 작성하세요." msgid "Yearly" msgstr "연간" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "년" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12789,3 +12794,4 @@ msgstr "인증 코드:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/nl/web.po b/packages/lib/translations/nl/web.po index 818f288af..fd258acf1 100644 --- a/packages/lib/translations/nl/web.po +++ b/packages/lib/translations/nl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: nl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 04:37\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: Dutch\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" @@ -284,6 +284,22 @@ msgstr "{0} heeft namens \"{1}\" je uitgenodigd om het document \"{2}\" te {reci msgid "{0} Teams" msgstr "{0} teams" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, one {Dag} other {Dagen}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, one {Maand} other {Maanden}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, one {Week} other {Weken}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, one {Jaar} other {Jaren}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "Actief" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "Actief" @@ -2382,7 +2399,7 @@ msgstr "Centreren" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "Taal wijzigen" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "Ontvanger wijzigen" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "Thema wijzigen" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "Afrekenen" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "Chinees" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "Configureer documentinstellingen en opties voordat je verzendt." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "E-mailinstellingen voor het document configureren." #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "Configureer algemene instellingen voor de sjabloon." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "Beveiligingsinstellingen voor het document configureren." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "Datuminstellingen" msgid "David is the Employee, Lucas is the Manager" msgstr "David is de werknemer, Lucas is de manager" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "Dagen" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "Bekeken documenten" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "Documenten waarvoor alle ontvangers hebben getekend, maar die nog niet zijn verzegeld. Documenten die langer dan 6 uur vastzitten, worden niet langer opnieuw geprobeerd door de sweep-taak." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "Dubbele waarden zijn niet toegestaan" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "Nederlands" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Bijv. 0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "Bijv. 100" @@ -4553,7 +4568,7 @@ msgstr "Bijgevoegde documenten" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "Engels" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "Map verplaatsen mislukt" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "Het verplaatsen van het abonnement is mislukt. Probeer het opnieuw." #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "Licentie synchroniseren is mislukt" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "Verzegeling triggeren mislukt" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "Gratis handtekening-instellingen" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "Frans" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "Links genereren" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "Duits" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "Belangrijk: wat dit betekent" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "Inactief" @@ -5714,11 +5730,11 @@ msgstr "Het is momenteel niet jouw beurt om te ondertekenen. Je ontvangt een e #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "Italiaans" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "Japans" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "Toegetreden {0}" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "Koreaans" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "Laatst opnieuw geprobeerd" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "Laatst getekend" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "Maandelijks actieve gebruikers: gebruikers die ten minste één document msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "Maandelijks actieve gebruikers: gebruikers van wie ten minste één document is voltooid" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "Maanden" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "Map verplaatsen" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "Abonnement verplaatsen" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "Templates naar map verplaatsen" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "Verplaats het abonnement van \"{sourceOrganisationName}\" naar een andere organisatie die eigendom is van deze gebruiker." #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "Geen documenten gevonden" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "Geen geschikte organisaties gevonden. De doelorganisatie moet op het gratis abonnement staan." #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "Wachtwoord bijgewerkt." #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "Achterstallig" @@ -7347,11 +7360,11 @@ msgstr "Upload een logo" #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "Pools" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "Portugees (Brazilië)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "Vereiste scopes" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "Opnieuw verzegelen" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "Sjabloon opslaan" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "Verzegelingstaak gestart" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "Selecteer een optie" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "Selecteer een organisatie" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "Door spaties gescheiden lijst met domeinen. Laat leeg om alle domeinen t #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "Spaans" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "Stripe-klant-ID" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "Vast gedurende" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "Abonnement ongeldig" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "Abonnement is verplaatst" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "Systeemthema" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "Doelorganisatie" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10185,7 +10198,7 @@ msgstr "Dit verwijdert de bestaande SES-identiteit voor <0>{0} en maakt deze #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "Dit zal het abonnement verplaatsen van \"{sourceOrganisationName}\" naar \"{0}\". De bronorganisatie wordt teruggezet naar het gratis abonnement." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10660,7 +10673,7 @@ msgstr "Losmaken" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "Onverzegelde documenten" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11650,10 +11663,6 @@ msgstr "Webhook‑URL" msgid "Webhooks" msgstr "Webhooks" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "Weken" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "Welkom" @@ -11739,10 +11748,6 @@ msgstr "Schrijf een beschrijving die op je openbare profiel wordt weergegeven" msgid "Yearly" msgstr "Jaarlijks" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "Jaren" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12789,3 +12794,4 @@ msgstr "Uw verificatiecode:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/pl/web.po b/packages/lib/translations/pl/web.po index 2b24156d1..e8ea2e7d2 100644 --- a/packages/lib/translations/pl/web.po +++ b/packages/lib/translations/pl/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: pl\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 12:12\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: Polish\n" "Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" @@ -284,6 +284,22 @@ msgstr "Sprawdź i {recipientActionVerb} dokument „{2}” utworzony przez uż msgid "{0} Teams" msgstr "Zespół {0}" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, one {Dzień} few {Dni} many {Dni} other {Dni}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, one {Miesiąc} few {Miesiące} many {Miesięcy} other {Miesiąca}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, one {Tydzień} few {Tygodnie} many {Tygodni} other {Tygodnia}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, one {Rok} few {Lata} many {Lat} other {Roku}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "Aktywny" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "Aktywna" @@ -2382,7 +2399,7 @@ msgstr "Wyśrodkuj" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "Zmień język" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "Zmień odbiorcę" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "Zmień motyw" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "Zamów" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "Chiński" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "Skonfiguruj ustawienia dokumentu przed ich wysłaniem." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "Skonfiguruj ustawienia powiadomień dla dokumentu." #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "Skonfiguruj ogólne ustawienia szablonu." #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "Skonfiguruj ustawienia zabezpieczeń dokumentu." #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "Ustawienia daty" msgid "David is the Employee, Lucas is the Manager" msgstr "David jest pracownikiem. Lucas jest managerem." -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "Dni" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "Wyświetlone dokumenty" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "Dokumenty, w których wszyscy adresaci złożyli podpis, ale dokument nie został opatrzony pieczęcią. Dokumenty zablokowane na ponad 6 godzin nie są ponownie przetwarzane przez zadanie porządkujące." #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "Zduplikowane wartości nie są dozwolone" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "Holenderski" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "Np. 0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "Np. 100" @@ -4553,7 +4568,7 @@ msgstr "Załączniki" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "Angielski" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "Nie udało się przenieść folderu" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "Nie udało się przenieść subskrypcji. Spróbuj ponownie." #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "Nie udało się zsynchronizować licencji" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "Nie udało się wywołać opieczętowania" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "Ustawienia swobodnego podpisu" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "Francuski" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "Wygeneruj linki" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "Niemiecki" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "Uwaga: Co to oznacza" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "Nieaktywna" @@ -5714,11 +5730,11 @@ msgstr "To nie jest Twoja kolej na podpisanie dokumentu. Gdy nadejdzie Twoja kol #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "Włoski" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "Japoński" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "Dołączył {0}" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "Koreański" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "Ostatnie ponowienie" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "Ostatni podpis" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "Miesięczna liczba aktywnych użytkowników: Użytkownicy, którzy utwor msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "Miesięczna liczba aktywnych użytkowników: Użytkownicy, którzy zakończyli co najmniej jeden dokument" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "Miesiące" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "Przenieś folder" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "Przenieś subskrypcję" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "Przenieś szablony do folderu" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "Przenieś subskrypcję z organizacji „{sourceOrganisationName}” do innej należącej do tego użytkownika." #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "Nie znaleziono dokumentów" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "Nie znaleziono kwalifikujących się organizacji. Docelowa organizacja musi być objęta planem darmowym." #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "Hasło zostało zaktualizowane!" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "Przeterminowana" @@ -7347,11 +7360,11 @@ msgstr "Prześlij logo" #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "Polski" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "Portugalski (Brazylia)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "Wymagane zakresy" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "Opięczętuj ponownie" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "Zapisz szablon" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "Zadanie opieczętowania zostało wywołane" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "Wybierz opcję" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "Wybierz organizację" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "Lista domen oddzielonych spacjami. Pozostaw puste pole, aby zezwolić na #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "Hiszpański" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "Identyfikator klienta Stripe" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "Zablokowane od" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "Subskrypcja jest nieprawidłowa" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "Subskrypcja została przeniesiona" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "Motyw systemowy" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "Docelowa organizacja" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10171,9 +10184,7 @@ msgstr "Zostanie wysłana do właściciela po zakończeniu dokumentu." #: packages/ui/components/document/document-email-checkboxes.tsx msgid "This will be sent to the document owner when a recipient's signing window has expired." -msgstr "" -"Zostanie wysłana do właściciela dokumentu, gdy minie czas na podpisanie przez odbiorcę\n" -"" +msgstr "Zostanie wysłana do właściciela dokumentu, gdy minie czas na podpisanie przez odbiorcę\n" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx msgid "This will check and sync the status of all email domains for this organisation" @@ -10187,7 +10198,7 @@ msgstr "Spowoduje to usunięcie obecnej tożsamości SES dla domeny <0>{0} i #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "Spowoduje to przeniesienie subskrypcji z organizacji „{sourceOrganisationName}” do „{0}”. Organizacja źródłowa zostanie zresetowana do planu darmowego." #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10662,7 +10673,7 @@ msgstr "Odepnij" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "Nieopieczętowane dokumenty" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11652,10 +11663,6 @@ msgstr "Adres URL webhooka" msgid "Webhooks" msgstr "Webhooki" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "Tygodnie" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "Witaj" @@ -11741,10 +11748,6 @@ msgstr "Wpisz opis, który będzie wyświetlany w profilu publicznym" msgid "Yearly" msgstr "Rocznie" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "Lata" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12791,3 +12794,4 @@ msgstr "Twój kod weryfikacyjny:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + diff --git a/packages/lib/translations/zh/web.po b/packages/lib/translations/zh/web.po index bdafe26dc..eda0404e8 100644 --- a/packages/lib/translations/zh/web.po +++ b/packages/lib/translations/zh/web.po @@ -8,7 +8,7 @@ msgstr "" "Language: zh\n" "Project-Id-Version: documenso-app\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2026-02-25 04:37\n" +"PO-Revision-Date: 2026-03-05 04:33\n" "Last-Translator: \n" "Language-Team: Chinese Simplified\n" "Plural-Forms: nplurals=1; plural=0;\n" @@ -284,6 +284,22 @@ msgstr "{0} 代表“{1}”邀请您 {recipientActionVerb} 文档“{2}”。" msgid "{0} Teams" msgstr "{0} 个团队" +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Day} other {Days}}" +msgstr "{amount, plural, other {天}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Month} other {Months}}" +msgstr "{amount, plural, other {个月}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Week} other {Weeks}}" +msgstr "{amount, plural, other {周}}" + +#: packages/ui/components/document/expiration-period-picker.tsx +msgid "{amount, plural, one {Year} other {Years}}" +msgstr "{amount, plural, other {年}}" + #: apps/remix/app/components/tables/internal-audit-log-table.tsx #: packages/lib/server-only/pdf/render-audit-logs.ts msgid "{browserInfo} on {os}" @@ -1102,6 +1118,7 @@ msgstr "启用" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Active" msgstr "启用" @@ -2382,7 +2399,7 @@ msgstr "居中" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change language" -msgstr "" +msgstr "更改语言" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page-renderer.tsx msgid "Change Recipient" @@ -2390,7 +2407,7 @@ msgstr "更改收件人" #: apps/remix/app/components/general/app-command-menu.tsx msgid "Change theme" -msgstr "" +msgstr "更改主题" #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx msgid "Character limit" @@ -2435,7 +2452,7 @@ msgstr "结账" #: packages/lib/constants/i18n.ts msgid "Chinese" -msgstr "" +msgstr "中文" #: apps/remix/app/components/dialogs/template-direct-link-dialog.tsx msgid "Choose an existing recipient from below to continue" @@ -2645,7 +2662,7 @@ msgstr "在发送前配置文档设置和选项。" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure email settings for the document." -msgstr "" +msgstr "为文档配置电子邮件设置。" #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx #: apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -2663,7 +2680,7 @@ msgstr "配置模板的一般设置。" #: apps/remix/app/components/general/envelope-editor/envelope-editor-settings-dialog.tsx msgid "Configure security settings for the document." -msgstr "" +msgstr "为文档配置安全设置。" #: apps/remix/app/components/dialogs/public-profile-template-manage-dialog.tsx msgid "Configure template" @@ -3206,10 +3223,6 @@ msgstr "日期设置" msgid "David is the Employee, Lucas is the Manager" msgstr "David 是员工,Lucas 是经理" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Days" -msgstr "天" - #: apps/remix/app/components/general/organisations/organisation-invitations.tsx #: apps/remix/app/routes/_unauthenticated+/organisation.sso.confirmation.$token.tsx #: packages/email/templates/organisation-invite.tsx @@ -4046,7 +4059,7 @@ msgstr "已查看的文档" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Documents where all recipients have signed but the document has not been sealed. Documents stuck for more than 6 hours are no longer retried by the sweep job." -msgstr "" +msgstr "所有收件人都已签署但文档尚未加盖签章的文档。卡住超过 6 小时的文档将不再由清理任务重试。" #: apps/remix/app/components/tables/organisation-email-domains-table.tsx #: apps/remix/app/routes/_authenticated+/admin+/email-domains._index.tsx @@ -4208,12 +4221,14 @@ msgstr "不允许重复的值" #: packages/lib/constants/i18n.ts msgid "Dutch" -msgstr "" +msgstr "荷兰语" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 0" msgstr "例如:0" +#: apps/remix/app/components/forms/editor/editor-field-number-form.tsx #: packages/ui/primitives/document-flow/field-items-advanced-settings/number-field.tsx msgid "E.g. 100" msgstr "例如:100" @@ -4553,7 +4568,7 @@ msgstr "随附文件" #: packages/lib/constants/i18n.ts msgid "English" -msgstr "" +msgstr "英语" #: apps/remix/app/components/dialogs/sign-field-email-dialog.tsx #: apps/remix/app/components/dialogs/sign-field-initials-dialog.tsx @@ -4893,7 +4908,7 @@ msgstr "移动文件夹失败" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Failed to move subscription. Please try again." -msgstr "" +msgstr "移动订阅失败。请重试。" #: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx msgid "Failed to re-register email domain" @@ -4921,7 +4936,7 @@ msgstr "许可证同步失败" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Failed to trigger seal" -msgstr "" +msgstr "触发签章失败" #: apps/remix/app/routes/_authenticated+/settings+/security.linked-accounts.tsx msgid "Failed to unlink account" @@ -5137,7 +5152,7 @@ msgstr "免费签名设置" #: packages/lib/constants/i18n.ts msgid "French" -msgstr "" +msgstr "法语" #: apps/remix/app/components/embed/embed-direct-template-client-page.tsx #: apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -5173,7 +5188,7 @@ msgstr "生成链接" #: packages/lib/constants/i18n.ts msgid "German" -msgstr "" +msgstr "德语" #: packages/ui/components/document/document-global-auth-action-select.tsx msgid "Global recipient action authentication" @@ -5482,6 +5497,7 @@ msgid "Important: What This Means" msgstr "重要:这意味着什么" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Inactive" msgstr "未启用" @@ -5714,11 +5730,11 @@ msgstr "现在还不是你签署的顺序。轮到你签署时,你会收到一 #: packages/lib/constants/i18n.ts msgid "Italian" -msgstr "" +msgstr "意大利语" #: packages/lib/constants/i18n.ts msgid "Japanese" -msgstr "" +msgstr "日语" #: packages/email/templates/organisation-invite.tsx msgid "Join {organisationName} on Documenso" @@ -5739,7 +5755,7 @@ msgstr "加入时间 {0}" #: packages/lib/constants/i18n.ts msgid "Korean" -msgstr "" +msgstr "韩语" #: apps/remix/app/components/forms/editor/editor-field-generic-field-forms.tsx #: apps/remix/app/components/forms/editor/editor-field-text-form.tsx @@ -5796,7 +5812,7 @@ msgstr "上次重试时间" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Last Signed" -msgstr "" +msgstr "最后签署时间" #: apps/remix/app/routes/_authenticated+/admin+/documents._index.tsx #: apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.logs.tsx @@ -6272,10 +6288,6 @@ msgstr "月活跃用户:至少创建过一份文档的用户" msgid "Monthly Active Users: Users that had at least one of their documents completed" msgstr "月活跃用户:至少有一份文档被完成的用户" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Months" -msgstr "月" - #: apps/remix/app/components/dialogs/document-move-to-folder-dialog.tsx #: apps/remix/app/components/dialogs/envelopes-bulk-move-dialog.tsx #: apps/remix/app/components/dialogs/folder-move-dialog.tsx @@ -6304,7 +6316,7 @@ msgstr "移动文件夹" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx #: apps/remix/app/components/tables/admin-organisations-table.tsx msgid "Move Subscription" -msgstr "" +msgstr "移动订阅" #: apps/remix/app/components/dialogs/template-move-to-folder-dialog.tsx msgid "Move Template to Folder" @@ -6316,7 +6328,7 @@ msgstr "将模板移动到文件夹" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Move the subscription from \"{sourceOrganisationName}\" to another organisation owned by this user." -msgstr "" +msgstr "将订阅从“{sourceOrganisationName}”移动到该用户拥有的另一家组织。" #: apps/remix/app/components/tables/documents-table-action-dropdown.tsx #: apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx @@ -6457,7 +6469,7 @@ msgstr "未找到文档" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "No eligible organisations found. The target must be on the free plan." -msgstr "" +msgstr "未找到符合条件的组织。目标组织必须使用免费方案。" #: apps/remix/app/components/dialogs/ai-recipient-detection-dialog.tsx msgid "No email detected" @@ -7025,6 +7037,7 @@ msgstr "密码已更新!" #: apps/remix/app/components/general/admin-license-card.tsx #: apps/remix/app/components/tables/user-billing-organisations-table.tsx +#: packages/lib/constants/billing.ts msgctxt "Subscription status" msgid "Past Due" msgstr "逾期" @@ -7347,11 +7360,11 @@ msgstr "请上传一个 Logo" #: packages/lib/constants/i18n.ts msgid "Polish" -msgstr "" +msgstr "波兰语" #: packages/lib/constants/i18n.ts msgid "Portuguese (Brazil)" -msgstr "" +msgstr "葡萄牙语(巴西)" #: apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx #: packages/ui/primitives/document-flow/field-item.tsx @@ -7905,7 +7918,7 @@ msgstr "必需的作用域" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Reseal" -msgstr "" +msgstr "重新签章" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Reseal document" @@ -8115,7 +8128,7 @@ msgstr "保存模板" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Seal job triggered" -msgstr "" +msgstr "签章任务已触发" #: apps/remix/app/routes/_authenticated+/admin+/documents.$id.tsx msgid "Sealing job started" @@ -8266,7 +8279,7 @@ msgstr "选择一个选项" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Select an organisation" -msgstr "" +msgstr "选择一个组织" #: apps/remix/app/components/general/org-menu-switcher.tsx msgid "Select an organisation to view teams" @@ -8970,7 +8983,7 @@ msgstr "以空格分隔的域名列表。留空则允许所有域名。" #: packages/lib/constants/i18n.ts msgid "Spanish" -msgstr "" +msgstr "西班牙语" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings._layout.tsx msgid "SSO" @@ -9023,7 +9036,7 @@ msgstr "Stripe 客户 ID" #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Stuck For" -msgstr "" +msgstr "已卡住时长" #: apps/remix/app/components/forms/support-ticket-form.tsx msgid "Subject" @@ -9078,7 +9091,7 @@ msgstr "订阅无效" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Subscription moved successfully" -msgstr "" +msgstr "订阅已成功移动" #: apps/remix/app/components/tables/user-billing-organisations-table.tsx msgid "Subscription Status" @@ -9203,7 +9216,7 @@ msgstr "系统主题" #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "Target Organisation" -msgstr "" +msgstr "目标组织" #: apps/remix/app/components/tables/admin-user-teams-table.tsx #: apps/remix/app/components/tables/organisation-insights-table.tsx @@ -10185,7 +10198,7 @@ msgstr "这将删除 <0>{0} 的现有 SES 身份,并使用相同的 DKIM #. placeholder {0}: selectedOrg.name #: apps/remix/app/components/dialogs/admin-swap-subscription-dialog.tsx msgid "This will move the subscription from \"{sourceOrganisationName}\" to \"{0}\". The source organisation will be reset to the free plan." -msgstr "" +msgstr "这将把订阅从“{sourceOrganisationName}”移动到“{0}”。源组织将被重置为免费方案。" #: apps/remix/app/routes/_authenticated+/admin+/organisations.$id.tsx msgid "This will ONLY backport feature flags which are set to true, anything disabled in the initial claim will not be backported" @@ -10660,7 +10673,7 @@ msgstr "取消固定" #: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx #: apps/remix/app/routes/_authenticated+/admin+/unsealed-documents._index.tsx msgid "Unsealed Documents" -msgstr "" +msgstr "未签章文档" #: apps/remix/app/components/dialogs/team-group-create-dialog.tsx msgid "Untitled Group" @@ -11650,10 +11663,6 @@ msgstr "Webhook URL" msgid "Webhooks" msgstr "Webhooks" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Weeks" -msgstr "周" - #: apps/remix/app/routes/_unauthenticated+/articles.signature-disclosure.tsx msgid "Welcome" msgstr "欢迎" @@ -11739,10 +11748,6 @@ msgstr "撰写将在您的公共主页上展示的简介" msgid "Yearly" msgstr "按年" -#: packages/ui/components/document/expiration-period-picker.tsx -msgid "Years" -msgstr "年" - #: apps/remix/app/components/forms/branding-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx #: apps/remix/app/components/forms/document-preferences-form.tsx @@ -12789,3 +12794,4 @@ msgstr "您的验证码:" #: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx msgid "your-domain.com another-domain.com" msgstr "your-domain.com another-domain.com" + From 7f271379b9b2bf01e1c4ef07bc255931569853f8 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 6 Mar 2026 10:08:58 +1100 Subject: [PATCH 015/110] fix: upgrade @libpdf/core (#2572) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index db09f4e47..c90398d0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.2.11", + "@libpdf/core": "^0.2.12", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "@prisma/extension-read-replicas": "^0.4.1", @@ -4645,9 +4645,9 @@ "license": "MIT" }, "node_modules/@libpdf/core": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.11.tgz", - "integrity": "sha512-VRHPG0pH98+F0DdxAAo0W074CoKHhhemx/HyIdTBn/usPwOI8WmFyfSq3f2sl0VNddij14X69BMD7lZ0c3AQTw==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@libpdf/core/-/core-0.2.12.tgz", + "integrity": "sha512-z22SyNEXa8YsCJarJBkgQv4SvvDn0Opw21cNQOQ0Xax9Ys1qjpAyVTSjlGExYVI8bT9b02VNy+nsOcJ79SzsQg==", "license": "MIT", "dependencies": { "@noble/ciphers": "^2.1.1", diff --git a/package.json b/package.json index 7b179f0b4..83ec113fa 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "dependencies": { "@ai-sdk/google-vertex": "3.0.81", "@documenso/prisma": "*", - "@libpdf/core": "^0.2.11", + "@libpdf/core": "^0.2.12", "@lingui/conf": "^5.6.0", "@lingui/core": "^5.6.0", "@prisma/extension-read-replicas": "^0.4.1", From 0ce909a2981fdf06057c4b8b455b741a9b935f87 Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Fri, 6 Mar 2026 12:38:40 +1100 Subject: [PATCH 016/110] refactor: find envelopes (#2557) --- .../t.$teamUrl+/documents._index.tsx | 7 +- .../e2e/api/v2/find-documents.spec.ts | 1595 +++++++++++++++++ .../e2e/api/v2/find-envelopes.spec.ts | 1079 +++++++++++ .../e2e/documents/find-documents.spec.ts | 1217 +++++++++++++ packages/lib/constants/document.ts | 6 + .../server-only/document/find-documents.ts | 999 +++++------ .../lib/server-only/document/get-stats.ts | 611 +++---- .../server-only/envelope/find-envelopes.ts | 340 ++-- .../migration.sql | 17 + packages/prisma/schema.prisma | 5 + .../find-documents-internal.ts | 31 +- .../server/document-router/find-documents.ts | 1 + .../server/envelope-router/find-envelopes.ts | 1 + 13 files changed, 4886 insertions(+), 1023 deletions(-) create mode 100644 packages/app-tests/e2e/api/v2/find-documents.spec.ts create mode 100644 packages/app-tests/e2e/api/v2/find-envelopes.spec.ts create mode 100644 packages/app-tests/e2e/documents/find-documents.spec.ts create mode 100644 packages/prisma/migrations/20260302223702_optimize_recipient_indexes/migration.sql diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx index b0db12bab..673da91a2 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx @@ -9,6 +9,7 @@ import { z } from 'zod'; import { useSessionStorage } from '@documenso/lib/client-only/hooks/use-session-storage'; import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation'; +import { STATS_COUNT_CAP } from '@documenso/lib/constants/document'; import { formatAvatarUrl } from '@documenso/lib/utils/avatars'; import { parseToIntegerArray } from '@documenso/lib/utils/params'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; @@ -172,7 +173,11 @@ export default function DocumentsPage() { {value !== ExtendedDocumentStatus.ALL && ( - {stats[value]} + + {stats[value] >= STATS_COUNT_CAP + ? `${STATS_COUNT_CAP.toLocaleString()}+` + : stats[value]} + )} diff --git a/packages/app-tests/e2e/api/v2/find-documents.spec.ts b/packages/app-tests/e2e/api/v2/find-documents.spec.ts new file mode 100644 index 000000000..448de9b83 --- /dev/null +++ b/packages/app-tests/e2e/api/v2/find-documents.spec.ts @@ -0,0 +1,1595 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client'; +import { + seedBlankDocument, + seedCompletedDocument, + seedDocuments, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/find-documents.types'; + +import { apiSignin } from '../../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2`; + +test.describe.configure({ + mode: 'parallel', +}); + +// Helper to make authenticated GET requests to the find documents endpoint. +const findDocuments = async ( + request: import('@playwright/test').APIRequestContext, + token: string, + params: Record = {}, +) => { + const searchParams = new URLSearchParams(params); + const url = `${baseUrl}/document${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + const res = await request.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return { + res, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + json: res.ok() ? ((await res.json()) as TFindDocumentsResponse) : null, + }; +}; + +test.describe('Find Documents API - Personal Context', () => { + let userA: User, teamA: Team, tokenA: string; + let userB: User, teamB: Team, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'tokenB', + expiresIn: null, + })); + }); + + test('should return empty results when no documents exist', async ({ request }) => { + const { res, json } = await findDocuments(request, tokenA); + + expect(res.ok()).toBeTruthy(); + expect(json).toBeDefined(); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + expect(json!.currentPage).toBe(1); + expect(json!.totalPages).toBe(0); + }); + + test('should return only documents owned by the user and not the other user', async ({ + request, + }) => { + // The v2 API token scopes to a team. A personal team token only returns + // docs belonging to that team — cross-team received docs are NOT included. + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Draft 1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Draft 2' }, + }); + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'UserA Pending' }, + }); + + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'UserB Draft 1' }, + }); + await seedPendingDocument(userB, teamB.id, [userA], { + createDocumentOptions: { title: 'UserB Pending' }, + }); + await seedCompletedDocument(userB, teamB.id, [userA], { + createDocumentOptions: { title: 'UserB Completed' }, + }); + + const { json: jsonA } = await findDocuments(request, tokenA); + const { json: jsonB } = await findDocuments(request, tokenB); + + const titlesA = jsonA!.data.map((d) => d.title); + // UserA sees only their own team's docs + expect(titlesA).toContain('UserA Draft 1'); + expect(titlesA).toContain('UserA Draft 2'); + expect(titlesA).toContain('UserA Pending'); + // Cross-team received docs are NOT visible via personal team token + expect(titlesA).not.toContain('UserB Pending'); + expect(titlesA).not.toContain('UserB Completed'); + expect(titlesA).not.toContain('UserB Draft 1'); + expect(jsonA!.count).toBe(3); + + const titlesB = jsonB!.data.map((d) => d.title); + expect(titlesB).toContain('UserB Draft 1'); + expect(titlesB).toContain('UserB Pending'); + expect(titlesB).toContain('UserB Completed'); + expect(titlesB).not.toContain('UserA Draft 1'); + expect(titlesB).not.toContain('UserA Draft 2'); + expect(titlesB).not.toContain('UserA Pending'); + expect(jsonB!.count).toBe(3); + }); + + test('should only return documents belonging to the personal team, not cross-team received docs', async ({ + request, + }) => { + // The v2 API scopes to a team. Cross-team docs where the user is a recipient + // are NOT returned — they belong to the sender's team, not the recipient's. + await seedPendingDocument(userB, teamB.id, [userA], { + createDocumentOptions: { title: 'Pending for A from B Team' }, + }); + await seedCompletedDocument(userB, teamB.id, [userA], { + createDocumentOptions: { title: 'Completed for A from B Team' }, + }); + + // UserA's own docs (should be returned) + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Own Draft' }, + }); + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'UserA Own Pending' }, + }); + + const { json } = await findDocuments(request, tokenA); + const titles = json!.data.map((d) => d.title); + + expect(titles).toContain('UserA Own Draft'); + expect(titles).toContain('UserA Own Pending'); + // Cross-team received docs NOT visible + expect(titles).not.toContain('Pending for A from B Team'); + expect(titles).not.toContain('Completed for A from B Team'); + expect(json!.count).toBe(2); + }); + + test('should NOT leak documents between unrelated users', async ({ request }) => { + const { user: userC, team: teamC } = await seedUser(); + + // Each user has their own docs + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Own Doc' }, + }); + + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'UserB Own Doc' }, + }); + + await seedDraftDocument(userC, teamC.id, [], { + createDocumentOptions: { title: 'UserC Private Draft' }, + }); + await seedPendingDocument(userC, teamC.id, [userC], { + createDocumentOptions: { title: 'UserC Pending' }, + }); + await seedCompletedDocument(userC, teamC.id, [userC], { + createDocumentOptions: { title: 'UserC Completed' }, + }); + + const { json: jsonA } = await findDocuments(request, tokenA); + const { json: jsonB } = await findDocuments(request, tokenB); + + // UserA should see only their own doc + expect(jsonA!.data).toHaveLength(1); + expect(jsonA!.data[0].title).toBe('UserA Own Doc'); + + // UserB should see only their own doc + expect(jsonB!.data).toHaveLength(1); + expect(jsonB!.data[0].title).toBe('UserB Own Doc'); + }); + + test('should filter by status correctly across all statuses', async ({ request }) => { + const { user: userC } = await seedUser(); + + // Seed all three statuses with 2 docs each plus noise from received docs + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Draft 1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Draft 2' }, + }); + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Pending 1' }, + }); + await seedPendingDocument(userA, teamA.id, [userC], { + createDocumentOptions: { title: 'Pending 2' }, + }); + await seedCompletedDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Completed 1' }, + }); + await seedCompletedDocument(userA, teamA.id, [userC], { + createDocumentOptions: { title: 'Completed 2' }, + }); + + const { json: draftResults } = await findDocuments(request, tokenA, { status: 'DRAFT' }); + expect(draftResults!.data).toHaveLength(2); + expect(draftResults!.data.every((d) => d.status === DocumentStatus.DRAFT)).toBe(true); + + const { json: pendingResults } = await findDocuments(request, tokenA, { status: 'PENDING' }); + expect(pendingResults!.data).toHaveLength(2); + expect(pendingResults!.data.every((d) => d.status === DocumentStatus.PENDING)).toBe(true); + + const { json: completedResults } = await findDocuments(request, tokenA, { + status: 'COMPLETED', + }); + expect(completedResults!.data).toHaveLength(2); + expect(completedResults!.data.every((d) => d.status === DocumentStatus.COMPLETED)).toBe(true); + }); + + test('should paginate correctly', async ({ request }) => { + // Create 5 documents + for (let i = 0; i < 5; i++) { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: `Paginated Doc ${i}` }, + }); + } + + // Also seed noise docs for userB to ensure isolation across pages + for (let i = 0; i < 3; i++) { + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: `UserB Noise Doc ${i}` }, + }); + } + + const { json: page1 } = await findDocuments(request, tokenA, { page: '1', perPage: '2' }); + expect(page1!.data).toHaveLength(2); + expect(page1!.count).toBe(5); + expect(page1!.currentPage).toBe(1); + expect(page1!.totalPages).toBe(3); + expect(page1!.perPage).toBe(2); + + const { json: page2 } = await findDocuments(request, tokenA, { page: '2', perPage: '2' }); + expect(page2!.data).toHaveLength(2); + expect(page2!.currentPage).toBe(2); + + const { json: page3 } = await findDocuments(request, tokenA, { page: '3', perPage: '2' }); + expect(page3!.data).toHaveLength(1); + expect(page3!.currentPage).toBe(3); + + // Ensure no duplicates across pages and no B docs leaked + const allTitles = [ + ...page1!.data.map((d) => d.title), + ...page2!.data.map((d) => d.title), + ...page3!.data.map((d) => d.title), + ]; + const uniqueTitles = new Set(allTitles); + expect(uniqueTitles.size).toBe(5); + expect(allTitles.every((t) => t.startsWith('Paginated Doc'))).toBe(true); + }); + + test('should search by document title and exclude non-matching docs', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Quarterly Report 2024' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Annual Budget Plan' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Monthly Summary' }, + }); + + const { json } = await findDocuments(request, tokenA, { query: 'Quarterly' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('Quarterly Report 2024'); + }); + + test('should search by recipient email and not return docs with different recipients', async ({ + request, + }) => { + const { user: userC } = await seedUser(); + + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Doc with Recipient B' }, + }); + await seedPendingDocument(userA, teamA.id, [userC], { + createDocumentOptions: { title: 'Doc with Recipient C' }, + }); + await seedPendingDocument(userA, teamA.id, [userB, userC], { + createDocumentOptions: { title: 'Doc with Both Recipients' }, + }); + + const { json } = await findDocuments(request, tokenA, { query: userB.email }); + // Should find the doc with B and the doc with both, but not the doc with only C + expect(json!.data).toHaveLength(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Doc with Recipient B'); + expect(titles).toContain('Doc with Both Recipients'); + expect(titles).not.toContain('Doc with Recipient C'); + }); + + test('should search case-insensitively', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Important Contract' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Other Document' }, + }); + + const { json: lowerCase } = await findDocuments(request, tokenA, { + query: 'important contract', + }); + expect(lowerCase!.data).toHaveLength(1); + expect(lowerCase!.data[0].title).toBe('Important Contract'); + + const { json: upperCase } = await findDocuments(request, tokenA, { + query: 'IMPORTANT CONTRACT', + }); + expect(upperCase!.data).toHaveLength(1); + expect(upperCase!.data[0].title).toBe('Important Contract'); + }); + + test('should order by createdAt descending by default', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'First Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Second Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Third Created' }, + }); + + const { json } = await findDocuments(request, tokenA); + expect(json!.data).toHaveLength(3); + expect(json!.data[0].title).toBe('Third Created'); + expect(json!.data[1].title).toBe('Second Created'); + expect(json!.data[2].title).toBe('First Created'); + }); + + test('should support ascending order', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'First Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Second Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Third Created' }, + }); + + const { json } = await findDocuments(request, tokenA, { + orderByColumn: 'createdAt', + orderByDirection: 'asc', + }); + + expect(json!.data[0].title).toBe('First Created'); + expect(json!.data[1].title).toBe('Second Created'); + expect(json!.data[2].title).toBe('Third Created'); + }); + + test('owner should see all recipient tokens on their documents', async ({ request }) => { + // Full token masking (non-owner sees masked tokens) can't be tested via API + // since only ADMIN/MANAGER can create tokens and they have full visibility. + // This test verifies the owner sees all tokens; masking is tested in the UI file. + const { user: recipient1 } = await seedUser(); + const { user: recipient2 } = await seedUser(); + + await seedPendingDocument(userA, teamA.id, [recipient1, recipient2], { + createDocumentOptions: { title: 'Token Visibility Test' }, + }); + + const { json } = await findDocuments(request, tokenA); + const doc = json!.data.find((d) => d.title === 'Token Visibility Test'); + expect(doc).toBeDefined(); + expect(doc!.recipients.length).toBe(2); + // Owner should see all recipient tokens (not masked) + for (const r of doc!.recipients) { + expect(r.token).not.toBe(''); + } + }); + + test('should only show root-level documents when no folderId is provided', async ({ + request, + }) => { + const folder = await prisma.folder.create({ + data: { + name: 'Test Folder', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Root Document 1', folderId: null }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Root Document 2', folderId: null }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Foldered Document 1', folderId: folder.id }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Foldered Document 2', folderId: folder.id }, + }); + + const { json } = await findDocuments(request, tokenA); + expect(json!.count).toBe(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Root Document 1'); + expect(titles).toContain('Root Document 2'); + expect(titles).not.toContain('Foldered Document 1'); + expect(titles).not.toContain('Foldered Document 2'); + }); + + test('should filter by folderId and not show root or other folder docs', async ({ request }) => { + const folder1 = await prisma.folder.create({ + data: { + name: 'Folder 1', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + const folder2 = await prisma.folder.create({ + data: { + name: 'Folder 2', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Root Document', folderId: null }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Folder1 Document', folderId: folder1.id }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Folder2 Document', folderId: folder2.id }, + }); + + const { json } = await findDocuments(request, tokenA, { folderId: folder1.id }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('Folder1 Document'); + }); + + test('should return correct response schema fields', async ({ request }) => { + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Schema Check Doc' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Schema Check Draft' }, + }); + + const { json } = await findDocuments(request, tokenA); + expect(json!.count).toBe(2); + expect(json!.currentPage).toBeDefined(); + expect(json!.perPage).toBeDefined(); + expect(json!.totalPages).toBeDefined(); + + const doc = json!.data.find((d) => d.title === 'Schema Check Doc')!; + expect(doc.id).toBeDefined(); + expect(doc.status).toBe(DocumentStatus.PENDING); + expect(doc.createdAt).toBeDefined(); + expect(doc.updatedAt).toBeDefined(); + expect(doc.userId).toBe(userA.id); + expect(doc.teamId).toBe(teamA.id); + expect(doc.user).toBeDefined(); + expect(doc.user.id).toBe(userA.id); + expect(doc.user.email).toBe(userA.email); + expect(doc.recipients).toHaveLength(1); + expect(doc.recipients[0].email).toBe(userB.email); + }); + + test('should not return deleted documents but should return non-deleted ones', async ({ + request, + }) => { + const deletedDoc = await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Deleted Document' }, + }); + await prisma.envelope.update({ + where: { id: deletedDoc.id }, + data: { deletedAt: new Date() }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Active Document 1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Active Document 2' }, + }); + + const { json } = await findDocuments(request, tokenA); + expect(json!.count).toBe(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Active Document 1'); + expect(titles).toContain('Active Document 2'); + expect(titles).not.toContain('Deleted Document'); + }); + + test('should search by externalId', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { + title: 'External ID Doc', + externalId: 'EXT-12345-UNIQUE', + }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Other Doc 1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Other Doc 2' }, + }); + + const { json } = await findDocuments(request, tokenA, { query: 'EXT-12345-UNIQUE' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('External ID Doc'); + }); + + test('should search by recipient name', async ({ request }) => { + const { user: recipient } = await seedUser({ name: 'Unique Recipient Name' }); + const { user: otherRecipient } = await seedUser({ name: 'Other Person' }); + + await seedPendingDocument(userA, teamA.id, [recipient], { + createDocumentOptions: { title: 'Doc for Unique Person' }, + }); + await seedPendingDocument(userA, teamA.id, [otherRecipient], { + createDocumentOptions: { title: 'Doc for Other Person' }, + }); + + const { json } = await findDocuments(request, tokenA, { query: 'Unique Recipient' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('Doc for Unique Person'); + }); +}); + +test.describe('Find Documents API - Team Context', () => { + test('should return team documents for team members and exclude non-team docs', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + // Team docs + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Team Doc 1' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Doc 2' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team Doc 3' }, + }, + ]); + + // Non-team docs (noise - should NOT appear) + await seedDocuments([ + { + sender: outsideUser, + teamId: outsideTeam.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Outside Draft' }, + }, + { + sender: outsideUser, + teamId: outsideTeam.id, + recipients: [member], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Outside Completed with Member as Recipient' }, + }, + ]); + + const { token: memberToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'member-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, memberToken); + expect(json!.data.length).toBe(3); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Doc 1'); + expect(titles).toContain('Team Doc 2'); + expect(titles).toContain('Team Doc 3'); + expect(titles).not.toContain('Outside Draft'); + }); + + test('should NOT leak team documents to non-members', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + // Team docs + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Secret Team Draft' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Secret Team Completed' }, + }, + ]); + + // Outside user's own docs (positive control) + await seedDraftDocument(outsideUser, outsideTeam.id, [], { + createDocumentOptions: { title: 'Outside Own Doc' }, + }); + + const { token: outsideToken } = await createApiToken({ + userId: outsideUser.id, + teamId: outsideTeam.id, + tokenName: 'outside-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, outsideToken); + const titles = json!.data.map((d) => d.title); + // Outside user should see their own doc but not team docs + expect(titles).toContain('Outside Own Doc'); + expect(titles).not.toContain('Secret Team Draft'); + expect(titles).not.toContain('Secret Team Completed'); + }); + + test('should NOT show documents from other teams', async ({ request }) => { + const { team: teamX, owner: ownerX } = await seedTeam(); + const { team: teamY, owner: ownerY } = await seedTeam(); + + // Multiple docs in each team + await seedDocuments([ + { + sender: ownerX, + teamId: teamX.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team X Completed' }, + }, + { + sender: ownerX, + teamId: teamX.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team X Draft' }, + }, + { + sender: ownerY, + teamId: teamY.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team Y Completed' }, + }, + { + sender: ownerY, + teamId: teamY.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Y Draft' }, + }, + ]); + + const { token: tokenX } = await createApiToken({ + userId: ownerX.id, + teamId: teamX.id, + tokenName: 'teamX-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, tokenX); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team X Completed'); + expect(titles).toContain('Team X Draft'); + expect(titles).not.toContain('Team Y Completed'); + expect(titles).not.toContain('Team Y Draft'); + expect(json!.count).toBe(2); + }); + + test('should enforce visibility across admin and manager levels with adequate data', async ({ + request, + }) => { + // Note: MEMBER role cannot create API tokens (requires MANAGE_TEAM permission). + // MEMBER visibility is tested in the UI test file instead. + const { team, owner } = await seedTeam(); + + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + // Seed 2 docs per visibility level (6 total) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Everyone Doc 1', visibility: DocumentVisibility.EVERYONE }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Everyone Doc 2', visibility: DocumentVisibility.EVERYONE }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Manager Doc 1', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Manager Doc 2', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Admin Doc 1', visibility: DocumentVisibility.ADMIN }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Admin Doc 2', visibility: DocumentVisibility.ADMIN }, + }, + ]); + + // Admin sees all 6 + const { token: adminToken } = await createApiToken({ + userId: admin.id, + teamId: team.id, + tokenName: 'admin-token', + expiresIn: null, + }); + const { json: adminJson } = await findDocuments(request, adminToken); + expect(adminJson!.count).toBe(6); + + // Manager sees 4 (Everyone + Manager) + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-token', + expiresIn: null, + }); + const { json: managerJson } = await findDocuments(request, managerToken); + expect(managerJson!.count).toBe(4); + const managerTitles = managerJson!.data.map((d) => d.title); + expect(managerTitles).toContain('Everyone Doc 1'); + expect(managerTitles).toContain('Manager Doc 1'); + expect(managerTitles).not.toContain('Admin Doc 1'); + }); + + test('document owner should see their document regardless of visibility even when other restricted docs are hidden', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + // Use MANAGER (can create tokens but can't see ADMIN-vis docs by default) + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + // Manager creates an ADMIN-only doc (they should still see it as owner) + // Owner creates an ADMIN-only doc (manager should NOT see this one) + await seedDocuments([ + { + sender: manager, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Manager Owned Admin Vis', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Owner Admin Vis (hidden from manager)', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Vis Control', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, managerToken); + const titles = json!.data.map((d) => d.title); + // Manager sees their own ADMIN doc + EVERYONE + MANAGER_AND_ABOVE docs, but NOT the owner's ADMIN doc + expect(titles).toContain('Manager Owned Admin Vis'); + expect(titles).toContain('Everyone Vis Control'); + expect(titles).not.toContain('Owner Admin Vis (hidden from manager)'); + }); + + test('recipient should see document regardless of visibility even when other restricted docs are hidden', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + // Use MANAGER (can create tokens but can't see ADMIN-vis docs by default) + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + // Admin doc with manager as recipient (manager should see despite ADMIN visibility) + // Admin doc WITHOUT manager as recipient (manager should NOT see) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [manager], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc with Manager Recipient', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc without Manager', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Doc Control', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, managerToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Admin Doc with Manager Recipient'); + expect(titles).toContain('Everyone Doc Control'); + expect(titles).not.toContain('Admin Doc without Manager'); + }); +}); + +test.describe('Find Documents API - Team with Team Email', () => { + test('should show documents sent by team email and received by team email, but not external noise', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-email-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + const { user: externalUser2, team: externalTeam2 } = await seedUser(); + + // Doc owned by team + await seedPendingDocument(owner, team.id, [externalUser], { + createDocumentOptions: { title: 'Team Owned Pending' }, + }); + + // Doc sent TO team email (external sender) + await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { title: 'Received by Team Email' }, + }); + await seedCompletedDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { title: 'Completed for Team Email' }, + }); + + // Draft sent to team email (should NOT show) + await seedDraftDocument(externalUser2, externalTeam2.id, [teamEmail], { + createDocumentOptions: { title: 'Draft To Team Email (hidden)' }, + }); + + // Doc between two external users (noise) + await seedPendingDocument(externalUser, externalTeam.id, [externalUser2], { + createDocumentOptions: { title: 'External Noise Doc' }, + }); + + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { token: adminToken } = await createApiToken({ + userId: admin.id, + teamId: team.id, + tokenName: 'admin-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, adminToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Owned Pending'); + expect(titles).toContain('Received by Team Email'); + expect(titles).toContain('Completed for Team Email'); + expect(titles).not.toContain('Draft To Team Email (hidden)'); + expect(titles).not.toContain('External Noise Doc'); + }); + + test('team email documents should respect visibility rules with adequate controls', async ({ + request, + }) => { + const { team } = await seedTeam(); + + const teamEmail = `team-vis-email-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + const { user: externalUser, team: externalTeam } = await seedUser(); + + // External user sends admin-only doc to team email + await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { + title: 'Admin Email Doc', + visibility: DocumentVisibility.ADMIN, + }, + }); + + // External user sends everyone-visible doc to team email + await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { + title: 'Everyone Email Doc', + visibility: DocumentVisibility.EVERYONE, + }, + }); + + // Admin should see both + const { token: adminToken } = await createApiToken({ + userId: admin.id, + teamId: team.id, + tokenName: 'admin-token', + expiresIn: null, + }); + const { json: adminJson } = await findDocuments(request, adminToken); + const adminTitles = adminJson!.data.map((d) => d.title); + expect(adminTitles).toContain('Admin Email Doc'); + expect(adminTitles).toContain('Everyone Email Doc'); + + // Manager should see both (Everyone + Manager_and_above, and ADMIN email doc should not be visible) + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'manager-token', + expiresIn: null, + }); + const { json: managerJson } = await findDocuments(request, managerToken); + const managerTitles = managerJson!.data.map((d) => d.title); + expect(managerTitles).toContain('Everyone Email Doc'); + expect(managerTitles).not.toContain('Admin Email Doc'); + }); +}); + +test.describe('Find Documents API - Deleted Document Handling', () => { + test('should not show soft-deleted documents for owner but show non-deleted ones', async ({ + request, + }) => { + const { user, team } = await seedUser(); + + const deletedDoc = await seedPendingDocument(user, team.id, [], { + createDocumentOptions: { title: 'Soft Deleted by Owner' }, + }); + await prisma.envelope.update({ + where: { id: deletedDoc.id }, + data: { deletedAt: new Date() }, + }); + + await seedPendingDocument(user, team.id, [], { + createDocumentOptions: { title: 'Still Active Doc 1' }, + }); + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Still Active Doc 2' }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, token); + expect(json!.count).toBe(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Still Active Doc 1'); + expect(titles).toContain('Still Active Doc 2'); + expect(titles).not.toContain('Soft Deleted by Owner'); + }); + + test('should not show documents where owner soft-deleted their copy in personal context', async ({ + request, + }) => { + // In personal context, documentDeletedAt on recipient hides the doc for that user. + // Note: the v2 API scopes to a team, so we test this by having the owner + // soft-delete a doc from their own personal team. + const { user, team } = await seedUser(); + + const deletedDoc = await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Owner Soft Deleted' }, + }); + await prisma.envelope.update({ + where: { id: deletedDoc.id }, + data: { deletedAt: new Date() }, + }); + + // Non-deleted doc (positive control) + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Owner Active Doc' }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, token); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Owner Active Doc'); + expect(titles).not.toContain('Owner Soft Deleted'); + }); + + test('should not show deleted team documents for any team member but show non-deleted ones', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + + const deletedDoc = await seedBlankDocument(owner, team.id, { + createDocumentOptions: { title: 'Deleted Team Doc' }, + }); + await prisma.envelope.update({ + where: { id: deletedDoc.id }, + data: { deletedAt: new Date() }, + }); + + await seedBlankDocument(owner, team.id, { + createDocumentOptions: { title: 'Active Team Doc 1' }, + }); + await seedBlankDocument(owner, team.id, { + createDocumentOptions: { title: 'Active Team Doc 2' }, + }); + + const { token: memberToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'member-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, memberToken); + expect(json!.count).toBe(2); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Active Team Doc 1'); + expect(titles).toContain('Active Team Doc 2'); + expect(titles).not.toContain('Deleted Team Doc'); + }); +}); + +test.describe('Find Documents API - Edge Cases', () => { + test('should handle empty search query gracefully', async ({ request }) => { + const { user, team } = await seedUser(); + + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Test Doc 1' }, + }); + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Test Doc 2' }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, token, { query: '' }); + expect(json!.data).toHaveLength(2); + }); + + test('should handle page beyond total pages', async ({ request }) => { + const { user, team } = await seedUser(); + + await seedDraftDocument(user, team.id, [], { + createDocumentOptions: { title: 'Single Doc' }, + }); + + const { token } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, token, { page: '999', perPage: '10' }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(1); + expect(json!.totalPages).toBe(1); + }); + + test('should reject unauthenticated requests', async ({ request }) => { + const res = await request.get(`${baseUrl}/document`, { + headers: {}, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(401); + }); + + test('should reject invalid API tokens', async ({ request }) => { + const res = await request.get(`${baseUrl}/document`, { + headers: { Authorization: 'Bearer invalid_token_here' }, + }); + + expect(res.ok()).toBeFalsy(); + }); + + test('personal documents should not appear in team context even with adequate team data', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + + const teamMember = await seedTeamMember({ + teamId: team.id, + role: TeamMemberRole.ADMIN, + }); + + // Find member's personal team (seedUser creates an org with ownerUserId set) + const teamMemberOrg = await prisma.organisation.findFirstOrThrow({ + where: { + ownerUserId: teamMember.id, + }, + include: { + teams: true, + }, + }); + + const memberPersonalTeamId = teamMemberOrg.teams[0].id; + + // Multiple personal docs + await seedDraftDocument(teamMember, memberPersonalTeamId, [], { + createDocumentOptions: { title: 'Personal Doc 1' }, + }); + await seedDraftDocument(teamMember, memberPersonalTeamId, [], { + createDocumentOptions: { title: 'Personal Doc 2' }, + }); + + // Multiple team docs + await seedDraftDocument(teamMember, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Member 1' }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Owner' }, + }); + + const { token: teamToken } = await createApiToken({ + userId: owner.id, + teamId: team.id, + tokenName: 'team-token', + expiresIn: null, + }); + + const { json } = await findDocuments(request, teamToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Doc by Member 1'); + expect(titles).toContain('Team Doc by Owner'); + expect(titles).not.toContain('Personal Doc 1'); + expect(titles).not.toContain('Personal Doc 2'); + expect(json!.count).toBe(2); + }); +}); + +// ─── Adversarial / Parameter Manipulation Tests ────────────────────────────── +// These tests target attack vectors where an authenticated user attempts to +// access data they shouldn't by manipulating request parameters. +// Session-based tests hit the tRPC endpoint with GET (queries require GET, not POST) +// and pass the x-team-id header to simulate header spoofing. + +const trpcQuery = async ( + page: import('@playwright/test').Page, + route: string, + teamId: number, + input: Record = {}, +) => { + const inputParam = encodeURIComponent(JSON.stringify({ json: input })); + const url = `${WEBAPP_BASE_URL}/api/trpc/${route}?input=${inputParam}`; + + return page.context().request.get(url, { + headers: { + 'x-team-id': String(teamId), + }, + }); +}; + +test.describe('Find Documents API - Adversarial: x-team-id Header Spoofing', () => { + test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({ + page, + }) => { + // Setup: two separate teams with documents + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'Secret TeamA Doc 1' }, + }); + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'Secret TeamA Doc 2' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Own Doc' }, + }); + + // Sign in as ownerB (who has NO access to teamA) + await apiSignin({ page, email: ownerB.email }); + + // Attempt to query findDocumentsInternal with x-team-id pointing to teamA + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + page: 1, + perPage: 100, + }); + + // Should be rejected — ownerB is not a member of teamA + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should return only own team data when user provides their legitimate x-team-id (positive control)', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Legit Doc' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Legit Doc' }, + }); + + // Sign in as ownerA + await apiSignin({ page, email: ownerA.email }); + + // Query with legitimate x-team-id + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + const titles = docs.map((d: { title: string }) => d.title); + expect(titles).toContain('TeamA Legit Doc'); + expect(titles).not.toContain('TeamB Legit Doc'); + }); + + test('team member should not access another team via x-team-id even if they belong to a different team', async ({ + page, + }) => { + // User belongs to teamA but NOT teamB — tries to access teamB via header + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const member = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN }); + + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Secret' }, + }); + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Doc' }, + }); + + // Sign in as member (belongs to teamA only) + await apiSignin({ page, email: member.email }); + + // Attempt to access teamB + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamB.id); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); +}); + +test.describe('Find Documents API - Adversarial: Cross-Team folderId', () => { + test('should NOT return documents from another team when folderId belongs to that team', async ({ + request, + }) => { + // Setup: two teams each with a folder and documents + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + const folderA = await prisma.folder.create({ + data: { + name: 'Team A Folder', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + const folderB = await prisma.folder.create({ + data: { + name: 'Team B Folder', + teamId: teamB.id, + userId: userB.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Folder Doc', folderId: folderA.id }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Root Doc' }, + }); + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Folder Doc', folderId: folderB.id }, + }); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + // UserA tries to query with teamB's folderId — should return empty, not teamB's docs + const { json } = await findDocuments(request, tokenA, { folderId: folderB.id }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + + // Positive control: querying own folder works + const { json: ownFolder } = await findDocuments(request, tokenA, { folderId: folderA.id }); + expect(ownFolder!.data).toHaveLength(1); + expect(ownFolder!.data[0].title).toBe('TeamA Folder Doc'); + }); + + test('cross-team folderId via session/tRPC should also return empty', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const folderB = await prisma.folder.create({ + data: { + name: 'Target Folder', + teamId: teamB.id, + userId: ownerB.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'Folder Target Doc', folderId: folderB.id }, + }); + + // Sign in as ownerA, request with own team but teamB's folderId + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + folderId: folderB.id, + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + expect(docs).toHaveLength(0); + }); +}); + +test.describe('Find Documents API - Adversarial: Cross-Team senderIds', () => { + test('should NOT return documents when senderIds contains users from another team', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + // Both teams have documents + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Doc by OwnerA' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Doc by OwnerB' }, + }); + + // Sign in as ownerA, try to use senderIds with ownerB's userId + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + senderIds: [ownerB.id], + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + // senderIds narrows within team scope — ownerB is not on teamA, so no results + expect(docs).toHaveLength(0); + + // Positive control: senderIds with own userId returns own docs + const res2 = await trpcQuery(page, 'document.findDocumentsInternal', teamA.id, { + senderIds: [ownerA.id], + page: 1, + perPage: 100, + }); + + expect(res2.ok()).toBeTruthy(); + const data2 = await res2.json(); + const docs2 = data2.result.data.json.data; + expect(docs2).toHaveLength(1); + expect(docs2[0].title).toBe('TeamA Doc by OwnerA'); + }); + + test('senderIds with mix of valid and cross-team userIds should only return matching team docs', async ({ + page, + }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { user: outsider } = await seedUser(); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Owner Doc' }, + }); + await seedDraftDocument(member, team.id, [], { + createDocumentOptions: { title: 'Member Doc' }, + }); + + // Find outsider's personal team + const outsiderOrg = await prisma.organisation.findFirstOrThrow({ + where: { ownerUserId: outsider.id }, + include: { teams: true }, + }); + const outsiderTeamId = outsiderOrg.teams[0].id; + + await seedDraftDocument(outsider, outsiderTeamId, [], { + createDocumentOptions: { title: 'Outsider Doc' }, + }); + + await apiSignin({ page, email: owner.email }); + + // Include outsider.id in senderIds — should be silently ignored (no results from them) + const res = await trpcQuery(page, 'document.findDocumentsInternal', team.id, { + senderIds: [member.id, outsider.id], + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + const titles = docs.map((d: { title: string }) => d.title); + // Only member doc should appear — outsider's docs are on a different team + expect(titles).toContain('Member Doc'); + expect(titles).not.toContain('Owner Doc'); // owner not in senderIds + expect(titles).not.toContain('Outsider Doc'); + expect(docs).toHaveLength(1); + }); +}); + +test.describe('Find Documents API - Adversarial: Cross-Team templateId', () => { + test('should NOT return documents from another team when filtering by their templateId', async ({ + request, + }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + // Use a shared templateId integer (simulates a template that exists on teamB) + const fakeTemplateId = 999888; + + // Create a doc in teamB with this templateId + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { + title: 'TeamB Doc from Template', + templateId: fakeTemplateId, + }, + }); + + // Create a doc in teamA with a different templateId (positive control) + const teamATemplateId = 999777; + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { + title: 'TeamA Doc from Template', + templateId: teamATemplateId, + }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Regular Doc' }, + }); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + // UserA tries to filter by teamB's templateId — should return empty, not teamB's docs + const { json } = await findDocuments(request, tokenA, { + templateId: String(fakeTemplateId), + }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + + // Positive control: own templateId returns own docs + const { json: ownTemplate } = await findDocuments(request, tokenA, { + templateId: String(teamATemplateId), + }); + expect(ownTemplate!.data).toHaveLength(1); + expect(ownTemplate!.data[0].title).toBe('TeamA Doc from Template'); + }); +}); diff --git a/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts new file mode 100644 index 000000000..50ed121ff --- /dev/null +++ b/packages/app-tests/e2e/api/v2/find-envelopes.spec.ts @@ -0,0 +1,1079 @@ +import { expect, test } from '@playwright/test'; +import type { Team, User } from '@prisma/client'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prisma } from '@documenso/prisma'; +import { + DocumentStatus, + DocumentVisibility, + EnvelopeType, + TeamMemberRole, +} from '@documenso/prisma/client'; +import { + seedBlankDocument, + seedCompletedDocument, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; +import type { TFindEnvelopesResponse } from '@documenso/trpc/server/envelope-router/find-envelopes.types'; + +import { apiSignin } from '../../fixtures/authentication'; + +const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL(); +const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`; + +test.describe.configure({ + mode: 'parallel', +}); + +// Helper to make authenticated GET requests to the find envelopes endpoint. +const findEnvelopes = async ( + request: import('@playwright/test').APIRequestContext, + token: string, + params: Record = {}, +) => { + const searchParams = new URLSearchParams(params); + const url = `${baseUrl}/envelope${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + const res = await request.get(url, { + headers: { Authorization: `Bearer ${token}` }, + }); + + return { + res, + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + json: res.ok() ? ((await res.json()) as TFindEnvelopesResponse) : null, + }; +}; + +// ─── Expected Output Tests ─────────────────────────────────────────────────── + +test.describe('Find Envelopes API - Basic', () => { + let userA: User, teamA: Team, tokenA: string; + let userB: User, teamB: Team, tokenB: string; + + test.beforeEach(async () => { + ({ user: userA, team: teamA } = await seedUser()); + ({ token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + })); + + ({ user: userB, team: teamB } = await seedUser()); + ({ token: tokenB } = await createApiToken({ + userId: userB.id, + teamId: teamB.id, + tokenName: 'tokenB', + expiresIn: null, + })); + }); + + test('should return empty results when no envelopes exist', async ({ request }) => { + const { json } = await findEnvelopes(request, tokenA); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + expect(json!.currentPage).toBe(1); + expect(json!.totalPages).toBe(0); + }); + + test('should return only envelopes owned by the user and not the other user', async ({ + request, + }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UserA Doc' }, + }); + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'UserB Doc' }, + }); + + const { json: jsonA } = await findEnvelopes(request, tokenA); + expect(jsonA!.data).toHaveLength(1); + expect(jsonA!.data[0].title).toBe('UserA Doc'); + + const { json: jsonB } = await findEnvelopes(request, tokenB); + expect(jsonB!.data).toHaveLength(1); + expect(jsonB!.data[0].title).toBe('UserB Doc'); + }); + + test('should NOT leak envelopes between unrelated users', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Secret A1' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Secret A2' }, + }); + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'Secret B1' }, + }); + + const { json } = await findEnvelopes(request, tokenB); + const titles = json!.data.map((d) => d.title); + expect(titles).not.toContain('Secret A1'); + expect(titles).not.toContain('Secret A2'); + expect(titles).toContain('Secret B1'); + expect(json!.count).toBe(1); + }); + + test('should filter by status correctly', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Draft Doc' }, + }); + await seedPendingDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Pending Doc' }, + }); + await seedCompletedDocument(userA, teamA.id, [userB], { + createDocumentOptions: { title: 'Completed Doc' }, + }); + + // DRAFT only + const { json: draftJson } = await findEnvelopes(request, tokenA, { status: 'DRAFT' }); + expect(draftJson!.data.every((d) => d.status === DocumentStatus.DRAFT)).toBe(true); + expect(draftJson!.data.some((d) => d.title === 'Draft Doc')).toBe(true); + expect(draftJson!.data.some((d) => d.title === 'Pending Doc')).toBe(false); + + // PENDING only + const { json: pendingJson } = await findEnvelopes(request, tokenA, { status: 'PENDING' }); + expect(pendingJson!.data.every((d) => d.status === DocumentStatus.PENDING)).toBe(true); + + // COMPLETED only + const { json: completedJson } = await findEnvelopes(request, tokenA, { status: 'COMPLETED' }); + expect(completedJson!.data.every((d) => d.status === DocumentStatus.COMPLETED)).toBe(true); + }); + + test('should filter by type (DOCUMENT vs TEMPLATE)', async ({ request }) => { + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { title: 'A Document', type: EnvelopeType.DOCUMENT }, + }); + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { title: 'A Template', type: EnvelopeType.TEMPLATE }, + }); + + const { json: docJson } = await findEnvelopes(request, tokenA, { type: 'DOCUMENT' }); + expect(docJson!.data.every((d) => d.type === EnvelopeType.DOCUMENT)).toBe(true); + expect(docJson!.data.some((d) => d.title === 'A Document')).toBe(true); + expect(docJson!.data.some((d) => d.title === 'A Template')).toBe(false); + + const { json: templateJson } = await findEnvelopes(request, tokenA, { type: 'TEMPLATE' }); + expect(templateJson!.data.every((d) => d.type === EnvelopeType.TEMPLATE)).toBe(true); + expect(templateJson!.data.some((d) => d.title === 'A Template')).toBe(true); + expect(templateJson!.data.some((d) => d.title === 'A Document')).toBe(false); + }); + + test('should paginate correctly', async ({ request }) => { + // Create 5 docs, paginate with perPage=2 + for (let i = 1; i <= 5; i++) { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: `Paginated Doc ${i}` }, + }); + } + + const { json: page1 } = await findEnvelopes(request, tokenA, { + page: '1', + perPage: '2', + }); + expect(page1!.data).toHaveLength(2); + expect(page1!.count).toBe(5); + expect(page1!.totalPages).toBe(3); + expect(page1!.currentPage).toBe(1); + + const { json: page3 } = await findEnvelopes(request, tokenA, { + page: '3', + perPage: '2', + }); + expect(page3!.data).toHaveLength(1); + expect(page3!.currentPage).toBe(3); + + // Page beyond total + const { json: pageBeyond } = await findEnvelopes(request, tokenA, { + page: '10', + perPage: '2', + }); + expect(pageBeyond!.data).toHaveLength(0); + }); + + test('should search by title', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Annual Budget Report' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Quarterly Review' }, + }); + + const { json } = await findEnvelopes(request, tokenA, { query: 'Budget' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('Annual Budget Report'); + }); + + test('should search by externalId', async ({ request }) => { + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { title: 'External Doc', externalId: 'ext-abc-123' }, + }); + await seedBlankDocument(userA, teamA.id, { + createDocumentOptions: { title: 'Other Doc', externalId: 'ext-xyz-789' }, + }); + + const { json } = await findEnvelopes(request, tokenA, { query: 'abc-123' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('External Doc'); + }); + + test('should search by recipient email and name', async ({ request }) => { + const { user: recipientUser } = await seedUser(); + + await seedPendingDocument(userA, teamA.id, [recipientUser], { + createDocumentOptions: { title: 'Doc with Specific Recipient' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Doc without Recipients' }, + }); + + // Search by recipient email + const { json: emailSearch } = await findEnvelopes(request, tokenA, { + query: recipientUser.email, + }); + expect(emailSearch!.data).toHaveLength(1); + expect(emailSearch!.data[0].title).toBe('Doc with Specific Recipient'); + + // Search by recipient name + if (recipientUser.name) { + const { json: nameSearch } = await findEnvelopes(request, tokenA, { + query: recipientUser.name, + }); + expect(nameSearch!.data.some((d) => d.title === 'Doc with Specific Recipient')).toBe(true); + } + }); + + test('should search case-insensitively', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'UPPERCASE TITLE' }, + }); + + const { json } = await findEnvelopes(request, tokenA, { query: 'uppercase title' }); + expect(json!.data).toHaveLength(1); + expect(json!.data[0].title).toBe('UPPERCASE TITLE'); + }); + + test('should order by createdAt descending by default', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'First Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Second Created' }, + }); + + const { json } = await findEnvelopes(request, tokenA); + expect(json!.data).toHaveLength(2); + expect(json!.data[0].title).toBe('Second Created'); + expect(json!.data[1].title).toBe('First Created'); + }); + + test('should support ascending order', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'First Created' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 50); + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Second Created' }, + }); + + const { json } = await findEnvelopes(request, tokenA, { + orderByColumn: 'createdAt', + orderByDirection: 'asc', + }); + expect(json!.data[0].title).toBe('First Created'); + expect(json!.data[1].title).toBe('Second Created'); + }); + + test('should filter by folderId and show root-level when no folderId', async ({ request }) => { + const folder = await prisma.folder.create({ + data: { + name: 'Test Folder', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'In Folder', folderId: folder.id }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'At Root' }, + }); + + // No folderId → root-level only (folderId: null) + const { json: rootJson } = await findEnvelopes(request, tokenA); + const rootTitles = rootJson!.data.map((d) => d.title); + expect(rootTitles).toContain('At Root'); + expect(rootTitles).not.toContain('In Folder'); + + // With folderId → only docs in that folder + const { json: folderJson } = await findEnvelopes(request, tokenA, { folderId: folder.id }); + expect(folderJson!.data).toHaveLength(1); + expect(folderJson!.data[0].title).toBe('In Folder'); + }); + + test('should not return deleted envelopes', async ({ request }) => { + const doc = await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Deleted Doc' }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Active Doc' }, + }); + + // Soft-delete + await prisma.envelope.update({ + where: { id: doc.id }, + data: { deletedAt: new Date() }, + }); + + const { json } = await findEnvelopes(request, tokenA); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Active Doc'); + expect(titles).not.toContain('Deleted Doc'); + }); + + test('should return correct response schema fields', async ({ request }) => { + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'Schema Check' }, + }); + + const { json } = await findEnvelopes(request, tokenA); + const envelope = json!.data[0]; + + // Pagination fields + expect(json!.count).toBeGreaterThan(0); + expect(json!.currentPage).toBe(1); + expect(json!.perPage).toBe(10); + expect(json!.totalPages).toBeGreaterThan(0); + + // Envelope fields + expect(envelope.id).toBeDefined(); + expect(envelope.title).toBe('Schema Check'); + expect(envelope.status).toBe(DocumentStatus.DRAFT); + expect(envelope.type).toBe(EnvelopeType.DOCUMENT); + expect(envelope.createdAt).toBeDefined(); + expect(envelope.updatedAt).toBeDefined(); + expect(envelope.deletedAt).toBeNull(); + + // Included relations + expect(envelope.user).toBeDefined(); + expect(envelope.user.id).toBe(userA.id); + expect(envelope.user.email).toBe(userA.email); + expect(envelope.recipients).toBeDefined(); + expect(envelope.team).toBeDefined(); + }); + + test('should reject unauthenticated requests', async ({ request }) => { + const { res } = await findEnvelopes(request, ''); + expect(res.ok()).toBeFalsy(); + }); + + test('should reject invalid API tokens', async ({ request }) => { + const { res } = await findEnvelopes(request, 'invalid-token-abc123'); + expect(res.ok()).toBeFalsy(); + }); +}); + +// ─── Token Masking ─────────────────────────────────────────────────────────── + +test.describe('Find Envelopes API - Token Masking', () => { + test('owner should see all recipient tokens on their envelopes', async ({ request }) => { + const { user: owner, team } = await seedUser(); + const { user: recipient } = await seedUser(); + + const { token } = await createApiToken({ + userId: owner.id, + teamId: team.id, + tokenName: 'ownerToken', + expiresIn: null, + }); + + await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Owner Token Test' }, + }); + + const { json } = await findEnvelopes(request, token); + const doc = json!.data.find((d) => d.title === 'Owner Token Test'); + expect(doc).toBeDefined(); + expect(doc!.recipients.length).toBeGreaterThan(0); + // Owner sees actual token values (non-empty) + doc!.recipients.forEach((r) => { + expect(r.token).not.toBe(''); + }); + }); + + test('non-owner team member should have recipient tokens masked', async ({ request }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { user: recipient } = await seedUser(); + + await seedPendingDocument(owner, team.id, [recipient], { + createDocumentOptions: { title: 'Masked Token Test' }, + }); + + const { token: memberToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'memberToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, memberToken); + const doc = json!.data.find((d) => d.title === 'Masked Token Test'); + expect(doc).toBeDefined(); + expect(doc!.recipients.length).toBeGreaterThan(0); + // Non-owner should see masked tokens (empty string) + doc!.recipients.forEach((r) => { + expect(r.token).toBe(''); + }); + }); +}); + +// ─── Team Context & Visibility ─────────────────────────────────────────────── + +test.describe('Find Envelopes API - Team Context', () => { + // Regression test: findEnvelopes previously had `{ userId }` as a top-level OR branch with no + // teamId constraint, so personal docs leaked into team context. Fixed in the Kysely refactor. + test('should return team envelopes for team members and exclude personal docs', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + + // Team docs + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Owner' }, + }); + await seedDraftDocument(member, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Member' }, + }); + + // Member's personal doc — should NOT appear in team context + const memberOrg = await prisma.organisation.findFirstOrThrow({ + where: { ownerUserId: member.id }, + include: { teams: true }, + }); + const memberPersonalTeamId = memberOrg.teams[0].id; + await seedDraftDocument(member, memberPersonalTeamId, [], { + createDocumentOptions: { title: 'Member Personal Doc' }, + }); + + const { token: memberToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'memberTeamToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, memberToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Doc by Owner'); + expect(titles).toContain('Team Doc by Member'); + expect(titles).not.toContain('Member Personal Doc'); + }); + + test('should NOT show envelopes from other teams', async ({ request }) => { + const { team: teamX, owner: ownerX } = await seedTeam(); + const { team: teamY, owner: ownerY } = await seedTeam(); + + await seedDraftDocument(ownerX, teamX.id, [], { + createDocumentOptions: { title: 'TeamX Doc' }, + }); + await seedDraftDocument(ownerY, teamY.id, [], { + createDocumentOptions: { title: 'TeamY Doc' }, + }); + + const { token: tokenX } = await createApiToken({ + userId: ownerX.id, + teamId: teamX.id, + tokenName: 'tokenX', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, tokenX); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('TeamX Doc'); + expect(titles).not.toContain('TeamY Doc'); + }); + + test('should NOT leak team envelopes to non-members', async ({ request }) => { + const { team, owner } = await seedTeam(); + const { user: outsider, team: outsiderTeam } = await seedUser(); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Team Secret Doc' }, + }); + + const { token: outsiderToken } = await createApiToken({ + userId: outsider.id, + teamId: outsiderTeam.id, + tokenName: 'outsiderToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, outsiderToken); + const titles = json!.data.map((d) => d.title); + expect(titles).not.toContain('Team Secret Doc'); + }); + + test('should enforce visibility: ADMIN sees all levels', async ({ request }) => { + const { team, owner } = await seedTeam(); + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Admin Vis', visibility: DocumentVisibility.ADMIN }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'Manager Vis', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Everyone Vis', visibility: DocumentVisibility.EVERYONE }, + }); + + const { token: adminToken } = await createApiToken({ + userId: admin.id, + teamId: team.id, + tokenName: 'adminToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, adminToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Admin Vis'); + expect(titles).toContain('Manager Vis'); + expect(titles).toContain('Everyone Vis'); + }); + + test('should enforce visibility: MANAGER cannot see ADMIN-only docs', async ({ request }) => { + const { team, owner } = await seedTeam(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Admin Only', visibility: DocumentVisibility.ADMIN }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'Manager Visible', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'Everyone Visible', + visibility: DocumentVisibility.EVERYONE, + }, + }); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'managerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, managerToken); + const titles = json!.data.map((d) => d.title); + expect(titles).not.toContain('Admin Only'); + expect(titles).toContain('Manager Visible'); + expect(titles).toContain('Everyone Visible'); + }); + + test('document owner should see their doc regardless of visibility', async ({ request }) => { + const { team, owner } = await seedTeam(); + + // Another user creates an ADMIN-only doc — owner shouldn't see it + const admin = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + await seedDraftDocument(admin, team.id, [], { + createDocumentOptions: { + title: 'Admin Created Doc', + visibility: DocumentVisibility.ADMIN, + }, + }); + + // Owner creates their own ADMIN-only doc — should see it because they own it + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Owner ADMIN Doc', visibility: DocumentVisibility.ADMIN }, + }); + + // Owner is implicitly ADMIN of their own team, so let's test with a MANAGER member + // who owns an ADMIN-visibility doc + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + await seedDraftDocument(manager, team.id, [], { + createDocumentOptions: { + title: 'Manager Own ADMIN Doc', + visibility: DocumentVisibility.ADMIN, + }, + }); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'managerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, managerToken); + const titles = json!.data.map((d) => d.title); + // Manager should see their own ADMIN doc (owner override) + expect(titles).toContain('Manager Own ADMIN Doc'); + // Manager should NOT see admin's ADMIN doc (visibility restricted) + expect(titles).not.toContain('Admin Created Doc'); + }); + + test('being a recipient does not override ADMIN visibility in the API', async ({ request }) => { + const { team, owner } = await seedTeam(); + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + + // Owner creates an ADMIN-only doc with manager as recipient + await seedPendingDocument(owner, team.id, [manager], { + createDocumentOptions: { + title: 'ADMIN Doc With Manager Recipient', + visibility: DocumentVisibility.ADMIN, + }, + }); + + // Owner creates another ADMIN-only doc WITHOUT manager as recipient + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'ADMIN Doc Without Manager', + visibility: DocumentVisibility.ADMIN, + }, + }); + + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'managerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, managerToken); + const titles = json!.data.map((d) => d.title); + // Unlike findDocuments (UI), the API does not let recipient status bypass visibility + expect(titles).not.toContain('ADMIN Doc With Manager Recipient'); + expect(titles).not.toContain('ADMIN Doc Without Manager'); + }); +}); + +// ─── Team with Team Email ──────────────────────────────────────────────────── + +test.describe('Find Envelopes API - Team Email', () => { + test('should include envelopes received by team email from external senders', async ({ + request, + }) => { + const { team, owner } = await seedTeam(); + const teamEmailAddr = `team-find-env-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmailAddr, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + + // Regular team doc (should be included) + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Regular Team Doc' }, + }); + + // Doc sent TO team email from external user (should be included — recipient match) + await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], { + createDocumentOptions: { title: 'Received by Team Email' }, + }); + + // External noise (should NOT be included) + const { user: externalUser2 } = await seedUser(); + await seedPendingDocument(externalUser, externalTeam.id, [externalUser2], { + createDocumentOptions: { title: 'External Noise Doc' }, + }); + + const { token } = await createApiToken({ + userId: owner.id, + teamId: team.id, + tokenName: 'ownerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, token); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Regular Team Doc'); + expect(titles).toContain('Received by Team Email'); + expect(titles).not.toContain('External Noise Doc'); + }); + + test('should NOT include external noise from other teams when team has team email', async ({ + request, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const teamEmailAddr = `team-noise-${teamA.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmailAddr, teamId: teamA.id }); + + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Doc' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB External Doc' }, + }); + + const { token: tokenA } = await createApiToken({ + userId: ownerA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, tokenA); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('TeamA Doc'); + expect(titles).not.toContain('TeamB External Doc'); + }); + + test('team email received docs bypass visibility for managers', async ({ request }) => { + const { team, owner } = await seedTeam(); + const teamEmailAddr = `team-vis-env-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmailAddr, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + + // External user sends ADMIN-visibility doc to team email + await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], { + createDocumentOptions: { + title: 'External ADMIN to Team Email', + visibility: DocumentVisibility.ADMIN, + }, + }); + + // External user sends EVERYONE-visibility doc to team email + await seedPendingDocument(externalUser, externalTeam.id, [teamEmailAddr], { + createDocumentOptions: { + title: 'External EVERYONE to Team Email', + visibility: DocumentVisibility.EVERYONE, + }, + }); + + // Regular team doc with ADMIN visibility (manager shouldn't see via visibility filter) + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { + title: 'Team ADMIN Doc', + visibility: DocumentVisibility.ADMIN, + }, + }); + + const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER }); + const { token: managerToken } = await createApiToken({ + userId: manager.id, + teamId: team.id, + tokenName: 'managerToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, managerToken); + const titles = json!.data.map((d) => d.title); + + // Team email recipient filter bypasses visibility — both docs visible + expect(titles).toContain('External EVERYONE to Team Email'); + expect(titles).toContain('External ADMIN to Team Email'); + // Regular ADMIN doc should NOT be visible to manager (no bypass) + expect(titles).not.toContain('Team ADMIN Doc'); + }); +}); + +// ─── Adversarial / Parameter Manipulation Tests ────────────────────────────── + +const trpcQuery = async ( + page: import('@playwright/test').Page, + route: string, + teamId: number, + input: Record = {}, +) => { + const inputParam = encodeURIComponent(JSON.stringify({ json: input })); + const url = `${WEBAPP_BASE_URL}/api/trpc/${route}?input=${inputParam}`; + + return page.context().request.get(url, { + headers: { + 'x-team-id': String(teamId), + }, + }); +}; + +test.describe('Find Envelopes API - Adversarial: x-team-id Header Spoofing', () => { + test('should reject request when user spoofs x-team-id to a team they do not belong to', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'Secret TeamA Envelope' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Envelope' }, + }); + + // Sign in as ownerB (NOT a member of teamA) + await apiSignin({ page, email: ownerB.email }); + + // Spoof x-team-id to teamA + const res = await trpcQuery(page, 'envelope.find', teamA.id, { + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); + + test('should return only own team data when user provides legitimate x-team-id (positive control)', async ({ + page, + }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + await seedDraftDocument(ownerA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Legit Envelope' }, + }); + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Legit Envelope' }, + }); + + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'envelope.find', teamA.id, { + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + const titles = docs.map((d: { title: string }) => d.title); + expect(titles).toContain('TeamA Legit Envelope'); + expect(titles).not.toContain('TeamB Legit Envelope'); + }); + + test('member of TeamA should not access TeamB via x-team-id', async ({ page }) => { + const { team: teamA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const member = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.ADMIN }); + + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Secret' }, + }); + + await apiSignin({ page, email: member.email }); + + const res = await trpcQuery(page, 'envelope.find', teamB.id); + + expect(res.ok()).toBeFalsy(); + expect(res.status()).toBe(404); + }); +}); + +test.describe('Find Envelopes API - Adversarial: Cross-Team folderId', () => { + test('should NOT return envelopes from another team when folderId belongs to that team', async ({ + request, + }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + const folderA = await prisma.folder.create({ + data: { + name: 'TeamA Folder', + teamId: teamA.id, + userId: userA.id, + type: 'DOCUMENT', + }, + }); + + const folderB = await prisma.folder.create({ + data: { + name: 'TeamB Folder', + teamId: teamB.id, + userId: userB.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Folder Env', folderId: folderA.id }, + }); + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Folder Env', folderId: folderB.id }, + }); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + // UserA tries teamB's folderId — should return empty + const { json } = await findEnvelopes(request, tokenA, { folderId: folderB.id }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + + // Positive control: own folderId works + const { json: ownFolder } = await findEnvelopes(request, tokenA, { folderId: folderA.id }); + expect(ownFolder!.data).toHaveLength(1); + expect(ownFolder!.data[0].title).toBe('TeamA Folder Env'); + }); + + test('cross-team folderId via session/tRPC should also return empty', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const folderB = await prisma.folder.create({ + data: { + name: 'Target Folder', + teamId: teamB.id, + userId: ownerB.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(ownerB, teamB.id, [], { + createDocumentOptions: { title: 'Target Folder Env', folderId: folderB.id }, + }); + + await apiSignin({ page, email: ownerA.email }); + + const res = await trpcQuery(page, 'envelope.find', teamA.id, { + folderId: folderB.id, + page: 1, + perPage: 100, + }); + + expect(res.ok()).toBeTruthy(); + const data = await res.json(); + const docs = data.result.data.json.data; + expect(docs).toHaveLength(0); + }); +}); + +test.describe('Find Envelopes API - Adversarial: Cross-Team templateId', () => { + test('should NOT return envelopes from another team when filtering by their templateId', async ({ + request, + }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + const fakeTemplateId = 888777; + const ownTemplateId = 888666; + + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'TeamB Template Env', templateId: fakeTemplateId }, + }); + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'TeamA Template Env', templateId: ownTemplateId }, + }); + + const { token: tokenA } = await createApiToken({ + userId: userA.id, + teamId: teamA.id, + tokenName: 'tokenA', + expiresIn: null, + }); + + // UserA tries teamB's templateId — should return empty + const { json } = await findEnvelopes(request, tokenA, { + templateId: String(fakeTemplateId), + }); + expect(json!.data).toHaveLength(0); + expect(json!.count).toBe(0); + + // Positive control: own templateId works + const { json: ownTemplate } = await findEnvelopes(request, tokenA, { + templateId: String(ownTemplateId), + }); + expect(ownTemplate!.data).toHaveLength(1); + expect(ownTemplate!.data[0].title).toBe('TeamA Template Env'); + }); +}); + +// ─── Personal vs Team Isolation ────────────────────────────────────────────── + +test.describe('Find Envelopes API - Cross-User Isolation', () => { + test('other users personal envelopes should never appear regardless of context', async ({ + request, + }) => { + const { team } = await seedTeam(); + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN }); + const { user: outsider, team: outsiderTeam } = await seedUser(); + + // Outsider creates personal doc + await seedDraftDocument(outsider, outsiderTeam.id, [], { + createDocumentOptions: { title: 'Outsider Personal Env' }, + }); + + // Member creates team doc + await seedDraftDocument(member, team.id, [], { + createDocumentOptions: { title: 'Team Env' }, + }); + + // Query with team token — outsider's doc should never appear + const { token: teamToken } = await createApiToken({ + userId: member.id, + teamId: team.id, + tokenName: 'teamToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, teamToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Team Env'); + expect(titles).not.toContain('Outsider Personal Env'); + }); + + // Regression test: Same { userId } OR clause issue — user's team docs leaked into personal + // context. Fixed in the Kysely refactor. + test('team envelopes should not appear in personal context', async ({ request }) => { + const { team: orgTeam } = await seedTeam(); + // Add a member — seedTeamMember creates a separate user with their own personal team + const member = await seedTeamMember({ teamId: orgTeam.id, role: TeamMemberRole.ADMIN }); + + // Find the member's personal team + const memberOrg = await prisma.organisation.findFirstOrThrow({ + where: { ownerUserId: member.id }, + include: { teams: true }, + }); + const memberPersonalTeamId = memberOrg.teams[0].id; + + // Member creates a doc on the org team + await seedDraftDocument(member, orgTeam.id, [], { + createDocumentOptions: { title: 'Member Org Team Env' }, + }); + + // Member creates a doc on their personal team + await seedDraftDocument(member, memberPersonalTeamId, [], { + createDocumentOptions: { title: 'Member Personal Env' }, + }); + + // Query with member's personal team token + const { token: personalToken } = await createApiToken({ + userId: member.id, + teamId: memberPersonalTeamId, + tokenName: 'personalToken', + expiresIn: null, + }); + + const { json } = await findEnvelopes(request, personalToken); + const titles = json!.data.map((d) => d.title); + expect(titles).toContain('Member Personal Env'); + // Org team doc should NOT appear in personal context + expect(titles).not.toContain('Member Org Team Env'); + }); +}); diff --git a/packages/app-tests/e2e/documents/find-documents.spec.ts b/packages/app-tests/e2e/documents/find-documents.spec.ts new file mode 100644 index 000000000..42bc69b13 --- /dev/null +++ b/packages/app-tests/e2e/documents/find-documents.spec.ts @@ -0,0 +1,1217 @@ +import { expect, test } from '@playwright/test'; +import { + DocumentStatus, + DocumentVisibility, + OrganisationMemberRole, + TeamMemberRole, +} from '@prisma/client'; + +import { generateDatabaseId } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; +import { + seedCompletedDocument, + seedDocuments, + seedDraftDocument, + seedPendingDocument, +} from '@documenso/prisma/seed/documents'; +import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations'; +import { seedTeam, seedTeamEmail, seedTeamMember } from '@documenso/prisma/seed/teams'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { checkDocumentTabCount } from '../fixtures/documents'; + +test.describe.configure({ + mode: 'parallel', +}); + +test.describe('Find Documents UI - Personal Context', () => { + test('should show all owned documents across statuses', async ({ page }) => { + const { user: owner, team, organisation } = await seedUser(); + const { user: recipient } = await seedUser(); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Personal Draft Doc' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [recipient], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Personal Pending Doc' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [recipient], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Personal Completed Doc' }, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 3); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + }); + + test('received documents from other teams should NOT appear in personal context', async ({ + page, + }) => { + // The UI always uses the team code path (findTeamDocumentsFilter) which filters by teamId. + // Documents sent TO a user by another user's team live on the sender's teamId, + // so they do NOT appear in the recipient's personal team context. + const { user: owner, team: ownerTeam } = await seedUser(); + const { user: sender, team: senderTeam } = await seedUser(); + + // Owner has their own doc (positive control — should appear) + await seedDraftDocument(owner, ownerTeam.id, [], { + createDocumentOptions: { title: 'Owner Own Draft' }, + }); + + // Sender sends docs to owner (these live on senderTeam, NOT ownerTeam) + await seedPendingDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Received Pending Doc' }, + }); + await seedCompletedDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Received Completed Doc' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${ownerTeam.url}/documents`, + }); + + // Only the owner's own doc should appear (received docs are on sender's team) + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Owner Own Draft' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Received Pending Doc', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'Received Completed Doc', exact: true }), + ).not.toBeVisible(); + }); + + test('should NOT show documents from other users', async ({ page }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + await seedDraftDocument(userB, teamB.id, [], { + createDocumentOptions: { title: 'UserB Secret Document' }, + }); + + await apiSignin({ + page, + email: userA.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 0); + await expect( + page.getByRole('link', { name: 'UserB Secret Document', exact: true }), + ).not.toBeVisible(); + }); + + test('personal context without team email should show 0 inbox', async ({ page }) => { + // The UI uses the team code path. Without a teamEmail, INBOX returns null (empty). + // Received docs from other teams don't appear in the user's personal team context. + const { user: owner, team: ownerTeam } = await seedUser(); + const { user: sender, team: senderTeam } = await seedUser(); + + // Sender sends a pending doc to owner (lives on sender's team, not owner's) + await seedPendingDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Inbox Document for Owner' }, + }); + + // Owner has their own doc (positive control) + await seedDraftDocument(owner, ownerTeam.id, [], { + createDocumentOptions: { title: 'Owner Draft Control' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${ownerTeam.url}/documents`, + }); + + // Inbox should be 0 since there's no team email and received docs are on sender's team + await checkDocumentTabCount(page, 'Inbox', 0); + // Owner's own doc should still show in All + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Owner Draft Control' })).toBeVisible(); + }); + + test('should filter documents by search query', async ({ page }) => { + const { user: owner, team } = await seedUser(); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Quarterly Report 2024' }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Annual Budget Plan' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Quarterly'); + await page.waitForURL(/query=Quarterly/); + + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Quarterly Report 2024' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Annual Budget Plan', exact: true }), + ).not.toBeVisible(); + }); + + test('should not show deleted documents', async ({ page }) => { + const { user: owner, team } = await seedUser(); + + const doc = await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Deleted Personal Doc' }, + }); + await prisma.envelope.update({ + where: { id: doc.id }, + data: { deletedAt: new Date() }, + }); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Active Personal Doc' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Active Personal Doc' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Deleted Personal Doc', exact: true }), + ).not.toBeVisible(); + }); + + test('should only show root-level documents when not in a folder', async ({ page }) => { + const { user: owner, team } = await seedUser(); + + const folder = await prisma.folder.create({ + data: { + name: 'My Folder', + teamId: team.id, + userId: owner.id, + type: 'DOCUMENT', + }, + }); + + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Root Level Doc', folderId: null }, + }); + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Folder Level Doc', folderId: folder.id }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Root Level Doc' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Folder Level Doc', exact: true }), + ).not.toBeVisible(); + }); +}); + +test.describe('Find Documents UI - Team Context', () => { + test('should show team documents to all team members', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + // Seed multiple team docs across statuses + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Team Pending Doc' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Draft Doc' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team Completed Doc' }, + }, + ]); + + // Noise: outside user's docs should NOT appear + await seedDraftDocument(outsideUser, outsideTeam.id, [], { + createDocumentOptions: { title: 'Outside Noise Doc' }, + }); + + // Both owner and member should see the 3 team documents + for (const user of [owner, member]) { + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 3); + await expect(page.getByRole('link', { name: 'Team Pending Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team Draft Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team Completed Doc' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Outside Noise Doc', exact: true }), + ).not.toBeVisible(); + + await apiSignout({ page }); + } + }); + + test('should NOT show documents from other teams', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER }); + + // Multiple docs per team to ensure isolation is thorough + await seedDocuments([ + { + sender: ownerA, + teamId: teamA.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team A Draft' }, + }, + { + sender: ownerA, + teamId: teamA.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team A Completed' }, + }, + { + sender: ownerB, + teamId: teamB.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team B Draft' }, + }, + { + sender: ownerB, + teamId: teamB.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team B Completed' }, + }, + ]); + + await apiSignin({ + page, + email: memberA.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 2); + await expect(page.getByRole('link', { name: 'Team A Draft' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team A Completed' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Team B Draft', exact: true })).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'Team B Completed', exact: true }), + ).not.toBeVisible(); + }); + + test('should NOT show personal documents in team context', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + // Get member's personal team (seedUser creates an org with ownerUserId set) + const memberOrg = await prisma.organisation.findFirstOrThrow({ + where: { + ownerUserId: member.id, + }, + include: { + teams: true, + }, + }); + + const personalTeamId = memberOrg.teams[0].id; + + await seedDraftDocument(member, personalTeamId, [], { + createDocumentOptions: { title: 'Personal Doc not in Team' }, + }); + + await seedDraftDocument(member, team.id, [], { + createDocumentOptions: { title: 'Team Doc by Member' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await expect(page.getByRole('link', { name: 'Team Doc by Member' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Personal Doc not in Team', exact: true }), + ).not.toBeVisible(); + }); + + test('should enforce ADMIN visibility correctly across roles', async ({ page }) => { + const { user: owner, organisation, team } = await seedUser(); + + const [adminUser, managerUser, memberUser] = await seedOrganisationMembers({ + organisationId: organisation.id, + members: [ + { organisationRole: OrganisationMemberRole.ADMIN }, + { organisationRole: OrganisationMemberRole.MEMBER }, + { organisationRole: OrganisationMemberRole.MEMBER }, + ], + }); + + // Make managerUser actually a MANAGER in the team + const managerTeamGroup = await prisma.teamGroup.findFirstOrThrow({ + where: { teamId: team.id, teamRole: TeamMemberRole.MANAGER }, + include: { organisationGroup: true }, + }); + const managerOrgMember = await prisma.organisationMember.findFirstOrThrow({ + where: { organisationId: organisation.id, userId: managerUser.id }, + }); + await prisma.organisationGroupMember.create({ + data: { + id: generateDatabaseId('group_member'), + groupId: managerTeamGroup.organisationGroupId, + organisationMemberId: managerOrgMember.id, + }, + }); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Only Doc', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Manager Plus Doc', + visibility: DocumentVisibility.MANAGER_AND_ABOVE, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Doc', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + // Admin sees all 3 + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + await checkDocumentTabCount(page, 'Completed', 3); + await apiSignout({ page }); + + // Manager sees 2 (Everyone + Manager+) + await apiSignin({ + page, + email: managerUser.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + await checkDocumentTabCount(page, 'Completed', 2); + await expect(page.getByRole('link', { name: 'Everyone Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Manager Plus Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Admin Only Doc', exact: true })).not.toBeVisible(); + await apiSignout({ page }); + + // Member sees 1 (Everyone only) + await apiSignin({ + page, + email: memberUser.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + await checkDocumentTabCount(page, 'Completed', 1); + await expect(page.getByRole('link', { name: 'Everyone Doc' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Manager Plus Doc', exact: true }), + ).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Admin Only Doc', exact: true })).not.toBeVisible(); + await apiSignout({ page }); + }); + + test('document owner sees their document regardless of visibility restriction', async ({ + page, + }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + // Member creates an ADMIN-visibility document (should see as owner) + // Owner also creates an ADMIN doc (member should NOT see this one) + // Owner creates an EVERYONE doc (positive control, member should see) + await seedDocuments([ + { + sender: member, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Member Owned Admin Doc', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Owner Admin Doc Hidden', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Doc Control', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + await apiSignin({ + page, + email: member.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + + // Member sees: their own ADMIN doc + EVERYONE doc, but NOT owner's ADMIN doc + await checkDocumentTabCount(page, 'Completed', 2); + await expect(page.getByRole('link', { name: 'Member Owned Admin Doc' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Everyone Doc Control' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Owner Admin Doc Hidden', exact: true }), + ).not.toBeVisible(); + + await apiSignout({ page }); + }); + + test('recipient sees document regardless of visibility restriction', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + // Owner creates ADMIN-only doc WITH member as recipient (member should see) + // Owner creates ADMIN-only doc WITHOUT member as recipient (member should NOT see) + // Owner creates EVERYONE doc (positive control) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [member], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc Member Recipient', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc No Member', + visibility: DocumentVisibility.ADMIN, + }, + }, + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Everyone Doc Baseline', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + await apiSignin({ + page, + email: member.email, + redirectPath: `/t/${team.url}/documents?status=COMPLETED`, + }); + + // Member sees: ADMIN doc as recipient + EVERYONE doc, but NOT the ADMIN doc without them + await checkDocumentTabCount(page, 'Completed', 2); + await expect(page.getByRole('link', { name: 'Admin Doc Member Recipient' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Everyone Doc Baseline' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Admin Doc No Member', exact: true }), + ).not.toBeVisible(); + + await apiSignout({ page }); + }); +}); + +test.describe('Find Documents UI - Team with Team Email', () => { + test('should show documents sent TO team email', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-ui-email-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + const { user: externalUser2, team: externalTeam2 } = await seedUser(); + + await seedDocuments([ + { + sender: externalUser, + teamId: externalTeam.id, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Email Received Pending' }, + }, + { + sender: externalUser, + teamId: externalTeam.id, + recipients: [teamEmail], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Email Received Completed' }, + }, + ]); + + // Noise: docs between external users (should NOT appear in team context) + await seedPendingDocument(externalUser, externalTeam.id, [externalUser2], { + createDocumentOptions: { title: 'External Noise Pending' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Should show received pending and completed docs but not noise + // Note: Received-via-email docs render as not since they belong to external teams + await checkDocumentTabCount(page, 'All', 2); + await expect(page.getByRole('cell', { name: 'Email Received Pending' })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'External Noise Pending' })).not.toBeVisible(); + + await checkDocumentTabCount(page, 'Completed', 1); + await expect(page.getByRole('cell', { name: 'Email Received Completed' })).toBeVisible(); + }); + + test('should NOT show drafts sent TO team email', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-ui-draft-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + + // Draft to team email (should NOT appear) + await seedDraftDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { title: 'Draft To Team Email' }, + }); + + // Pending to team email (positive control - SHOULD appear) + await seedPendingDocument(externalUser, externalTeam.id, [teamEmail], { + createDocumentOptions: { title: 'Pending To Team Email' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Should see the pending doc but NOT the draft + // Received-via-email docs render as not + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('cell', { name: 'Pending To Team Email' })).toBeVisible(); + await expect(page.getByRole('cell', { name: 'Draft To Team Email' })).not.toBeVisible(); + }); + + test('should show inbox count for team email recipients', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-ui-inbox-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: sender1, team: sender1Team } = await seedUser(); + const { user: sender2, team: sender2Team } = await seedUser(); + + await seedDocuments([ + { + sender: sender1, + teamId: sender1Team.id, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Inbox Doc 1' }, + }, + { + sender: sender2, + teamId: sender2Team.id, + recipients: [teamEmail], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Inbox Doc 2' }, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Inbox', 2); + }); + + test('team without team email should show 0 inbox', async ({ page }) => { + const { team, owner } = await seedTeam(); + + // No team email set up + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + await seedPendingDocument(owner, team.id, [outsideUser], { + createDocumentOptions: { title: 'Pending Without Inbox' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Inbox', 0); + // But pending should still show + await checkDocumentTabCount(page, 'Pending', 1); + }); + + test('documents sent BY team email user should appear in team context', async ({ page }) => { + const { team, owner } = await seedTeam({ createTeamMembers: 1 }); + + const teamEmailHolder = owner; + + await seedTeamEmail({ + email: teamEmailHolder.email, + teamId: team.id, + }); + + const { user: externalUser, team: externalTeam } = await seedUser(); + + // Team email holder sends multiple documents + await seedDocuments([ + { + sender: teamEmailHolder, + teamId: team.id, + recipients: [externalUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Sent by Holder Pending' }, + }, + { + sender: teamEmailHolder, + teamId: team.id, + recipients: [externalUser], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Sent by Holder Completed' }, + }, + ]); + + // Noise: external user's own docs (should NOT appear in team context) + await seedDraftDocument(externalUser, externalTeam.id, [], { + createDocumentOptions: { title: 'External Own Draft' }, + }); + + const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER }); + + await apiSignin({ + page, + email: member.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'All', 2); + await expect(page.getByRole('link', { name: 'Sent by Holder Pending' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Sent by Holder Completed' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'External Own Draft', exact: true }), + ).not.toBeVisible(); + }); +}); + +test.describe('Find Documents UI - Data Isolation & No Leaking', () => { + test('user cannot see another user personal documents via any status tab', async ({ page }) => { + const { user: userA, team: teamA } = await seedUser(); + const { user: userB, team: teamB } = await seedUser(); + + // UserA has their own docs (positive control to verify the page works) + await seedDraftDocument(userA, teamA.id, [], { + createDocumentOptions: { title: 'A Own Draft' }, + }); + await seedPendingDocument(userA, teamA.id, [userA], { + createDocumentOptions: { title: 'A Own Pending' }, + }); + await seedCompletedDocument(userA, teamA.id, [userA], { + createDocumentOptions: { title: 'A Own Completed' }, + }); + + // UserB has their own docs (noise — should NOT appear for userA) + await seedDocuments([ + { + sender: userB, + teamId: teamB.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'B Draft Private' }, + }, + { + sender: userB, + teamId: teamB.id, + recipients: [userB], + type: DocumentStatus.PENDING, + documentOptions: { title: 'B Pending Private' }, + }, + { + sender: userB, + teamId: teamB.id, + recipients: [userB], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'B Completed Private' }, + }, + ]); + + await apiSignin({ + page, + email: userA.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + // UserA should see only their own docs + await checkDocumentTabCount(page, 'All', 3); + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'Completed', 1); + + // Verify no B docs leaked + await page.getByRole('tab', { name: 'All' }).click(); + await expect(page.getByRole('link', { name: 'A Own Draft' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'B Draft Private', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'B Pending Private', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'B Completed Private', exact: true }), + ).not.toBeVisible(); + }); + + test('team member cannot see documents from another team via search', async ({ page }) => { + const { team: teamA, owner: ownerA } = await seedTeam(); + const { team: teamB, owner: ownerB } = await seedTeam(); + + const memberA = await seedTeamMember({ teamId: teamA.id, role: TeamMemberRole.MEMBER }); + + // TeamA has a doc with "Super Secret" in the title (positive control) + await seedDocuments([ + { + sender: ownerA, + teamId: teamA.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Super Secret TeamA Document', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + // TeamB has a doc with "Super Secret" in the title (should NOT appear) + await seedDocuments([ + { + sender: ownerB, + teamId: teamB.id, + recipients: [], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Super Secret TeamB Document', + visibility: DocumentVisibility.EVERYONE, + }, + }, + ]); + + await apiSignin({ + page, + email: memberA.email, + redirectPath: `/t/${teamA.url}/documents`, + }); + + await page.getByPlaceholder('Search documents...').fill('Super Secret'); + await page.waitForURL(/query=Super/); + + // Should find the TeamA doc but NOT the TeamB doc + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Super Secret TeamA Document' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Super Secret TeamB Document', exact: true }), + ).not.toBeVisible(); + }); + + test('search by recipient name should respect team visibility', async ({ page }) => { + const { user: owner, organisation, team } = await seedUser(); + + const [adminUser, memberUser] = await seedOrganisationMembers({ + organisationId: organisation.id, + members: [ + { organisationRole: OrganisationMemberRole.ADMIN }, + { organisationRole: OrganisationMemberRole.MEMBER }, + ], + }); + + const { user: uniqueRecipient } = await seedUser({ name: 'Very Unique Recipient Name' }); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [uniqueRecipient], + type: DocumentStatus.COMPLETED, + documentOptions: { + title: 'Admin Doc with Unique Recipient', + visibility: DocumentVisibility.ADMIN, + }, + }, + ]); + + // Admin can find by recipient name + await apiSignin({ + page, + email: adminUser.email, + redirectPath: `/t/${team.url}/documents`, + }); + await page.getByPlaceholder('Search documents...').fill('Very Unique Recipient'); + await page.waitForURL(/query=Very/); + await checkDocumentTabCount(page, 'All', 1); + await apiSignout({ page }); + + // Member cannot find by recipient name (visibility blocks it) + await apiSignin({ + page, + email: memberUser.email, + redirectPath: `/t/${team.url}/documents`, + }); + await page.getByPlaceholder('Search documents...').fill('Very Unique Recipient'); + await page.waitForURL(/query=Very/); + await checkDocumentTabCount(page, 'All', 0); + await apiSignout({ page }); + }); + + test('outside user does NOT see cross-team received docs in their personal context', async ({ + page, + }) => { + // The UI always uses the team code path (findTeamDocumentsFilter) which filters by teamId. + // Documents from team.id will NOT appear in outsideTeam's context. + const { team, owner } = await seedTeam(); + const { user: outsideUser, team: outsideTeam } = await seedUser(); + const { user: otherUser } = await seedUser(); + + // Team doc sent to outside user (lives on team.id, NOT outsideTeam.id) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { + title: 'Team Doc For Outside User', + visibility: DocumentVisibility.ADMIN, + }, + }, + ]); + + // Team doc NOT sent to outside user (noise) + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [otherUser], + type: DocumentStatus.PENDING, + documentOptions: { + title: 'Team Doc For Other User Only', + visibility: DocumentVisibility.ADMIN, + }, + }, + ]); + + // Outside user has their own draft (positive control) + await seedDraftDocument(outsideUser, outsideTeam.id, [], { + createDocumentOptions: { title: 'Outside Own Draft' }, + }); + + await apiSignin({ + page, + email: outsideUser.email, + redirectPath: `/t/${outsideTeam.url}/documents`, + }); + + // Only the outside user's own draft should appear (cross-team docs are not visible) + await checkDocumentTabCount(page, 'Inbox', 0); // No team email → 0 + await checkDocumentTabCount(page, 'All', 1); // Check All tab last so we can verify visible links + await expect(page.getByRole('link', { name: 'Outside Own Draft' })).toBeVisible(); + await expect( + page.getByRole('link', { name: 'Team Doc For Outside User', exact: true }), + ).not.toBeVisible(); + await expect( + page.getByRole('link', { name: 'Team Doc For Other User Only', exact: true }), + ).not.toBeVisible(); + }); +}); + +test.describe('Find Documents UI - Tab Counts Consistency', () => { + test('personal context tab counts should be accurate', async ({ page }) => { + // In the UI, personal team uses findTeamDocumentsFilter (team code path). + // Only docs with teamId = ownerTeam.id are shown. + // Docs sent TO owner by other users live on the sender's team and won't appear. + // Without a teamEmail, INBOX returns 0. + const { user: owner, team: ownerTeam } = await seedUser(); + const { user: sender, team: senderTeam } = await seedUser(); + const { user: recipient } = await seedUser(); + + // Owner's own documents (all on ownerTeam) + await seedDraftDocument(owner, ownerTeam.id, [], { + createDocumentOptions: { title: 'My Draft 1' }, + }); + await seedDraftDocument(owner, ownerTeam.id, [], { + createDocumentOptions: { title: 'My Draft 2' }, + }); + await seedPendingDocument(owner, ownerTeam.id, [recipient], { + createDocumentOptions: { title: 'My Pending 1' }, + }); + await seedCompletedDocument(owner, ownerTeam.id, [recipient], { + createDocumentOptions: { title: 'My Completed 1' }, + }); + + // Documents sent TO owner (these live on senderTeam, NOT ownerTeam — won't appear) + await seedPendingDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Received Pending 1' }, + }); + await seedCompletedDocument(sender, senderTeam.id, [owner], { + createDocumentOptions: { title: 'Received Completed 1' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${ownerTeam.url}/documents`, + }); + + // Only owner's own docs appear (received docs are on sender's team) + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Inbox', 0); // No team email → inbox returns null → 0 + await checkDocumentTabCount(page, 'Completed', 1); // Only owned completed (received is on sender's team) + await checkDocumentTabCount(page, 'All', 4); // 2 drafts + 1 pending + 1 completed + }); + + test('team context tab counts should be accurate with mixed documents', async ({ page }) => { + const { team, owner } = await seedTeam({ createTeamMembers: 2 }); + + const member1 = ( + await prisma.organisation.findFirstOrThrow({ + where: { teams: { some: { id: team.id } } }, + include: { members: { include: { user: true } } }, + }) + ).members[1].user; + + const { user: outsideUser, team: outsideTeam } = await seedUser(); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Draft 1' }, + }, + { + sender: member1, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Team Draft 2' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Team Pending 1' }, + }, + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.COMPLETED, + documentOptions: { title: 'Team Completed 1' }, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Draft', 2); + await checkDocumentTabCount(page, 'Pending', 1); + await checkDocumentTabCount(page, 'Completed', 1); + await checkDocumentTabCount(page, 'All', 4); + }); + + test('team with team email tab counts should include received documents', async ({ page }) => { + const { team, owner } = await seedTeam(); + + const teamEmail = `team-count-${team.id}@test.documenso.com`; + await seedTeamEmail({ email: teamEmail, teamId: team.id }); + + const { user: external1, team: ext1Team } = await seedUser(); + const { user: external2, team: ext2Team } = await seedUser(); + + // Team's own documents + await seedDraftDocument(owner, team.id, [], { + createDocumentOptions: { title: 'Own Draft' }, + }); + await seedPendingDocument(owner, team.id, [external1], { + createDocumentOptions: { title: 'Own Pending' }, + }); + + // Documents sent TO team email + await seedPendingDocument(external1, ext1Team.id, [teamEmail], { + createDocumentOptions: { title: 'Received Pending via Email' }, + }); + await seedCompletedDocument(external2, ext2Team.id, [teamEmail], { + createDocumentOptions: { title: 'Received Completed via Email' }, + }); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + await checkDocumentTabCount(page, 'Draft', 1); + await checkDocumentTabCount(page, 'Inbox', 1); // One pending doc received by team email (NOT_SIGNED) + await checkDocumentTabCount(page, 'Pending', 1); // Own pending + await checkDocumentTabCount(page, 'Completed', 1); // Received completed via email + await checkDocumentTabCount(page, 'All', 4); // All of the above + }); +}); + +test.describe('Find Documents UI - Sender Filter', () => { + test('sender filter should narrow results correctly', async ({ page }) => { + const { team, owner } = await seedTeam({ createTeamMembers: 2 }); + + const org = await prisma.organisation.findFirstOrThrow({ + where: { teams: { some: { id: team.id } } }, + include: { members: { include: { user: true }, orderBy: { id: 'asc' } } }, + }); + + const member1 = org.members[1].user; + const member2 = org.members[2].user; + + const { user: outsideUser } = await seedUser(); + + await seedDocuments([ + { + sender: owner, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Owner Sent Doc' }, + }, + { + sender: member1, + teamId: team.id, + recipients: [outsideUser], + type: DocumentStatus.PENDING, + documentOptions: { title: 'Member1 Sent Doc' }, + }, + { + sender: member2, + teamId: team.id, + recipients: [], + type: DocumentStatus.DRAFT, + documentOptions: { title: 'Member2 Draft Doc' }, + }, + ]); + + await apiSignin({ + page, + email: owner.email, + redirectPath: `/t/${team.url}/documents`, + }); + + // Unfiltered: 3 docs total + await checkDocumentTabCount(page, 'All', 3); + + // Filter by member1 + await page.locator('button').filter({ hasText: 'Sender: All' }).click(); + await page.getByRole('option', { name: member1.name ?? '' }).click(); + await page.waitForURL(/senderIds/); + + // Should only show member1's doc + await checkDocumentTabCount(page, 'All', 1); + await expect(page.getByRole('link', { name: 'Member1 Sent Doc' })).toBeVisible(); + }); +}); diff --git a/packages/lib/constants/document.ts b/packages/lib/constants/document.ts index 130cb865a..91443f56f 100644 --- a/packages/lib/constants/document.ts +++ b/packages/lib/constants/document.ts @@ -9,6 +9,12 @@ import { DocumentSignatureType } from '@documenso/lib/utils/teams'; export { DocumentSignatureType }; +/** + * Maximum count returned per status bucket in document stats. The server clamps + * each count to this value; the UI should display "10,000+" when it sees it. + */ +export const STATS_COUNT_CAP = 10_000; + export const DOCUMENT_STATUS: { [status in DocumentStatus]: { description: MessageDescriptor }; } = { diff --git a/packages/lib/server-only/document/find-documents.ts b/packages/lib/server-only/document/find-documents.ts index 8b1eafcc7..21d2a923f 100644 --- a/packages/lib/server-only/document/find-documents.ts +++ b/packages/lib/server-only/document/find-documents.ts @@ -1,12 +1,15 @@ -import type { DocumentSource, Envelope, Prisma, Team, TeamEmail, User } from '@prisma/client'; +import type { DocumentSource, Envelope, Team, TeamEmail } from '@prisma/client'; +import { DocumentVisibility } from '@prisma/client'; +import { DocumentStatus } from '@prisma/client'; import { EnvelopeType, RecipientRole, SigningStatus, TeamMemberRole } from '@prisma/client'; +import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely'; import { DateTime } from 'luxon'; import { match } from 'ts-pattern'; -import { prisma } from '@documenso/prisma'; +import { kyselyPrisma, prisma, sql } from '@documenso/prisma'; +import type { DB } from '@documenso/prisma/generated/types'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; -import { DocumentVisibility } from '../../types/document-visibility'; import { type FindResultResponse } from '../../types/search-params'; import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document'; import { getTeamById } from '../team/get-team'; @@ -29,8 +32,72 @@ export type FindDocumentsOptions = { senderIds?: number[]; query?: string; folderId?: string; + /** + * When true (default), use a windowed count that caps early for faster pagination. + * When false, use a full COUNT(*) for exact totals — preferred for external API consumers. + */ + useWindowedCount?: boolean; }; +/** + * The number of pages ahead of the current page we'll scan for pagination. + * + * Instead of COUNT(*) over the entire result set (which must scan all qualifying rows), + * we fetch at most `offset + COUNT_WINDOW_SIZE * perPage + 1` IDs. This lets Postgres + * stop early once it has enough rows. The offset ensures the count always reaches past + * the current page, and the window provides look-ahead for the pagination UI. + */ +const COUNT_WINDOW_SIZE = 100; + +/** + * Cap for the recipient search subquery. When searching by recipient email/name, + * we pre-compute matching envelope IDs up to this limit. This prevents + * pathological cases where a broad search (e.g. "gmail") matches millions of + * recipients and causes a heap scan. + */ +const RECIPIENT_SEARCH_CAP = 1000; + +// Kysely query builder type for Envelope queries. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EnvelopeQueryBuilder = SelectQueryBuilder; + +// Expression builder type scoped to Envelope table context. +type EnvelopeExpressionBuilder = ExpressionBuilder; +type RecipientExpressionBuilder = ExpressionBuilder; + +/** + * Reusable EXISTS subquery: checks that a Recipient row exists for the given + * envelope with the given email, plus optional extra conditions. + */ +const recipientExists = ( + eb: EnvelopeExpressionBuilder, + email: string, + extra?: (qb: RecipientExpressionBuilder) => Expression, +) => { + let sub = eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.email', '=', email); + + if (extra) { + sub = sub.where(extra); + } + + return eb.exists(sub.select(sql.lit(1).as('one'))); +}; + +/** + * Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email. + */ +const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) => + eb.exists( + eb + .selectFrom('User') + .whereRef('User.id', '=', 'Envelope.userId') + .where('User.email', '=', email) + .select(sql.lit(1).as('one')), + ); + export const findDocuments = async ({ userId, teamId, @@ -44,584 +111,424 @@ export const findDocuments = async ({ senderIds, query = '', folderId, + useWindowedCount = true, }: FindDocumentsOptions) => { const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - select: { - id: true, - email: true, - name: true, - }, + where: { id: userId }, + select: { id: true, email: true, name: true }, }); let team = null; if (teamId !== undefined) { - team = await getTeamById({ - userId, - teamId, - }); + team = await getTeamById({ userId, teamId }); } const orderByColumn = orderBy?.column ?? 'createdAt'; const orderByDirection = orderBy?.direction ?? 'desc'; - const teamMemberRole = team?.currentTeamRole ?? null; + const searchQuery = query.trim(); - const searchFilter: Prisma.EnvelopeWhereInput = { - OR: [ - { title: { contains: query, mode: 'insensitive' } }, - { externalId: { contains: query, mode: 'insensitive' } }, - { recipients: { some: { name: { contains: query, mode: 'insensitive' } } } }, - { recipients: { some: { email: { contains: query, mode: 'insensitive' } } } }, - ], + const hasSearch = searchQuery.length > 0; + const searchPattern = `%${searchQuery}%`; + + // ─── Base query with common filters ────────────────────────────────── + // + // Every code path starts from this base: Envelope rows filtered by type, + // folder, period, sender, source, template, and search. + + const buildBaseQuery = () => { + let qb = kyselyPrisma.$kysely + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.createdAt']); + + // Type must be DOCUMENT (enum cast requires raw sql — this is the one escape hatch) + qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)); + + // Folder filter + qb = + folderId !== undefined + ? qb.where('Envelope.folderId', '=', folderId) + : qb.where('Envelope.folderId', 'is', null); + + // Period filter + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + + qb = qb.where('Envelope.createdAt', '>=', startOfPeriod.toJSDate()); + } + + // Sender filter + if (senderIds && senderIds.length > 0) { + qb = qb.where('Envelope.userId', 'in', senderIds); + } + + // Source filter (enum cast) + if (source) { + qb = qb.where('Envelope.source', '=', sql.lit(source)); + } + + // Template filter + if (templateId) { + qb = qb.where('Envelope.templateId', '=', templateId); + } + + // Search filter: title, externalId, or recipient match via capped subquery + if (hasSearch) { + qb = qb.where(({ or, eb }) => + or([ + eb('Envelope.title', 'ilike', searchPattern), + eb('Envelope.externalId', 'ilike', searchPattern), + // Capped recipient search subquery (uses trigram indexes) + eb( + 'Envelope.id', + 'in', + eb + .selectFrom('Recipient') + .select('Recipient.envelopeId') + .where(({ or: innerOr, eb: innerEb }) => + innerOr([ + innerEb('Recipient.email', 'ilike', searchPattern), + innerEb('Recipient.name', 'ilike', searchPattern), + ]), + ) + .distinct() + .limit(RECIPIENT_SEARCH_CAP), + ), + ]), + ); + } + + return qb; }; - const visibilityFilters = [ - match(teamMemberRole) - .with(TeamMemberRole.ADMIN, () => ({ - visibility: { - in: [ - DocumentVisibility.EVERYONE, - DocumentVisibility.MANAGER_AND_ABOVE, - DocumentVisibility.ADMIN, - ], - }, - })) - .with(TeamMemberRole.MANAGER, () => ({ - visibility: { - in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE], - }, - })) - .otherwise(() => ({ visibility: DocumentVisibility.EVERYONE })), - { - OR: [ - { - recipients: { - some: { - email: user.email, - }, - }, - }, - { - userId: user.id, - }, - ], - }, - ]; + // ─── Personal path filters ─────────────────────────────────────────── - let filters: Prisma.EnvelopeWhereInput | null = findDocumentsFilter(status, user, folderId); + const applyPersonalFilters = (qb: EnvelopeQueryBuilder): EnvelopeQueryBuilder | null => { + // Deleted filter: owned → deletedAt IS NULL, received → documentDeletedAt IS NULL + const personalDeletedFilter = (eb: EnvelopeExpressionBuilder) => + eb.or([ + eb.and([eb('Envelope.userId', '=', user.id), eb('Envelope.deletedAt', 'is', null)]), + recipientExists(eb, user.email, (reb) => reb('Recipient.documentDeletedAt', 'is', null)), + ]); - if (team) { - filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId); - } + return match(status) + .with(ExtendedDocumentStatus.ALL, () => + qb.where((eb) => + eb.and([ + personalDeletedFilter(eb), + eb.or([ + eb('Envelope.userId', '=', user.id), + eb.and([ + eb('Envelope.status', 'in', [ + sql.lit(DocumentStatus.COMPLETED), + sql.lit(DocumentStatus.PENDING), + ]), + recipientExists(eb, user.email), + ]), + ]), + ]), + ), + ) + .with(ExtendedDocumentStatus.INBOX, () => + qb.where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) => + // Single EXISTS check: the recipient must be NOT_SIGNED, non-CC, and + // not soft-deleted. This replaces the previous personalDeletedFilter + + // separate recipientExists pair, eliminating a hashed SubPlan that + // materialised all recipient rows for this email (~125k for heavy users). + recipientExists(eb, user.email, (reb) => + reb.and([ + reb('Recipient.documentDeletedAt', 'is', null), + reb('signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)), + reb('role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ), + ) + .with(ExtendedDocumentStatus.DRAFT, () => + qb + .where('Envelope.userId', '=', user.id) + .where('Envelope.deletedAt', 'is', null) + .where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)), + ) + .with(ExtendedDocumentStatus.PENDING, () => + qb + .where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)) + .where((eb) => + eb.and([ + personalDeletedFilter(eb), + eb.or([ + eb('Envelope.userId', '=', user.id), + recipientExists(eb, user.email, (reb) => + reb.and([ + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ]), + ]), + ), + ) + .with(ExtendedDocumentStatus.COMPLETED, () => + qb + .where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED)) + .where((eb) => + eb.and([ + personalDeletedFilter(eb), + eb.or([eb('Envelope.userId', '=', user.id), recipientExists(eb, user.email)]), + ]), + ), + ) + .with(ExtendedDocumentStatus.REJECTED, () => + qb + .where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED)) + .where((eb) => + eb.and([ + personalDeletedFilter(eb), + eb.or([ + eb('Envelope.userId', '=', user.id), + recipientExists(eb, user.email, (reb) => + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)), + ), + ]), + ]), + ), + ) + .exhaustive(); + }; - if (filters === null) { + // ─── Team path filters ─────────────────────────────────────────────── + + const applyTeamFilters = ( + qb: EnvelopeQueryBuilder, + teamData: Team & { teamEmail: TeamEmail | null; currentTeamRole: TeamMemberRole }, + ): EnvelopeQueryBuilder | null => { + const teamEmail = teamData.teamEmail?.email ?? null; + + const allowedVisibilities = match(teamData.currentTeamRole) + .with(TeamMemberRole.ADMIN, () => [ + DocumentVisibility.EVERYONE, + DocumentVisibility.MANAGER_AND_ABOVE, + DocumentVisibility.ADMIN, + ]) + .with(TeamMemberRole.MANAGER, () => [ + DocumentVisibility.EVERYONE, + DocumentVisibility.MANAGER_AND_ABOVE, + ]) + .otherwise(() => [DocumentVisibility.EVERYONE]); + + // Visibility: meets role threshold OR directly involved + const visibilityFilter = (eb: EnvelopeExpressionBuilder) => + eb.or([ + eb( + 'Envelope.visibility', + 'in', + allowedVisibilities.map((v) => sql.lit(v)), + ), + eb('Envelope.userId', '=', user.id), + recipientExists(eb, user.email), + ]); + + // Deleted filter for team path + const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => { + const branches = [ + eb.and([eb('Envelope.teamId', '=', teamData.id), eb('Envelope.deletedAt', 'is', null)]), + ]; + + if (teamEmail) { + branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)])); + branches.push( + recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)), + ); + } + + return eb.or(branches); + }; + + return match(status) + .with(ExtendedDocumentStatus.ALL, () => + qb.where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + eb.and([ + eb('status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)), + recipientExists(eb, teamEmail), + ]), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .with(ExtendedDocumentStatus.INBOX, () => { + if (!teamEmail) { + return null; + } + + return qb + .where('Envelope.status', '!=', sql.lit(ExtendedDocumentStatus.DRAFT)) + .where((eb) => + eb.and([ + visibilityFilter(eb), + // Single EXISTS check: the team-email recipient must be NOT_SIGNED, + // non-CC, and not soft-deleted. Replaces teamDeletedFilter + separate + // recipientExists, eliminating a hashed SubPlan (~79k rows). + recipientExists(eb, teamEmail, (reb) => + reb.and([ + reb('Recipient.documentDeletedAt', 'is', null), + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ]), + ); + }) + .with(ExtendedDocumentStatus.DRAFT, () => + qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.DRAFT)).where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .with(ExtendedDocumentStatus.PENDING, () => + qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.PENDING)).where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + recipientExists(eb, teamEmail, (reb) => + reb.and([ + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .with(ExtendedDocumentStatus.COMPLETED, () => + qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.COMPLETED)).where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push(recipientExists(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .with(ExtendedDocumentStatus.REJECTED, () => + qb.where('Envelope.status', '=', sql.lit(ExtendedDocumentStatus.REJECTED)).where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', teamData.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + recipientExists(eb, teamEmail, (reb) => + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)), + ), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }), + ) + .exhaustive(); + }; + + // ─── Assemble and execute ──────────────────────────────────────────── + + const baseQuery = buildBaseQuery(); + + const filteredQuery = team ? applyTeamFilters(baseQuery, team) : applyPersonalFilters(baseQuery); + + if (filteredQuery === null) { return { data: [], count: 0, - currentPage: 1, + currentPage: Math.max(page, 1), perPage, totalPages: 0, }; } - let deletedFilter: Prisma.EnvelopeWhereInput = { - AND: { - OR: [ - { - userId: user.id, - deletedAt: null, - }, - { - recipients: { - some: { - email: user.email, - documentDeletedAt: null, - }, - }, - }, - ], - }, - }; + const offset = Math.max(page - 1, 0) * perPage; - if (team) { - deletedFilter = { - AND: { - OR: team.teamEmail - ? [ - { - teamId: team.id, - deletedAt: null, - }, - { - user: { - email: team.teamEmail.email, - }, - deletedAt: null, - }, - { - recipients: { - some: { - email: team.teamEmail.email, - documentDeletedAt: null, - }, - }, - }, - ] - : [ - { - teamId: team.id, - deletedAt: null, - }, - ], - }, - }; - } + // Data query: paginated, executed directly via Kysely query builder + const dataQuery = filteredQuery + .orderBy(`Envelope.${orderByColumn}`, orderByDirection) + .limit(perPage) + .offset(offset); - const whereAndClause: Prisma.EnvelopeWhereInput['AND'] = [ - { ...filters }, - { ...deletedFilter }, - { ...searchFilter }, - ]; + // Count query: either windowed (fast, capped) or full (exact, for API consumers). + const baseCountQuery = filteredQuery.clearSelect().select('Envelope.id'); - if (templateId) { - whereAndClause.push({ - templateId, - }); - } + const countQuery = useWindowedCount + ? kyselyPrisma.$kysely + .selectFrom(baseCountQuery.limit(offset + COUNT_WINDOW_SIZE * perPage + 1).as('windowed')) + .select(({ fn }) => fn.count('id').as('total')) + : kyselyPrisma.$kysely + .selectFrom(baseCountQuery.as('filtered')) + .select(({ fn }) => fn.count('id').as('total')); - if (source) { - whereAndClause.push({ - source, - }); - } - - const whereClause: Prisma.EnvelopeWhereInput = { - type: EnvelopeType.DOCUMENT, - AND: whereAndClause, - }; - - if (period) { - const daysAgo = parseInt(period.replace(/d$/, ''), 10); - - const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); - - whereClause.createdAt = { - gte: startOfPeriod.toJSDate(), - }; - } - - if (senderIds && senderIds.length > 0) { - whereClause.userId = { - in: senderIds, - }; - } - - if (folderId !== undefined) { - whereClause.folderId = folderId; - } else { - whereClause.folderId = null; - } - - const [data, count] = await Promise.all([ - prisma.envelope.findMany({ - where: whereClause, - skip: Math.max(page - 1, 0) * perPage, - take: perPage, - orderBy: { - [orderByColumn]: orderByDirection, - }, - include: { - user: { - select: { - id: true, - name: true, - email: true, - }, - }, - recipients: true, - team: { - select: { - id: true, - url: true, - }, - }, - envelopeItems: { - select: { - id: true, - envelopeId: true, - title: true, - order: true, - }, - }, - }, - }), - prisma.envelope.count({ - where: whereClause, - }), + const [dataResult, countResult] = await Promise.all([ + dataQuery.execute(), + countQuery.executeTakeFirstOrThrow(), ]); - const maskedData = data.map((document) => - maskRecipientTokensForDocument({ - document, - user, - }), - ); + const ids = dataResult.map((row) => row.id); + + const totalCount = useWindowedCount + ? Math.min(Number(countResult.total ?? 0), offset + COUNT_WINDOW_SIZE * perPage) + : Number(countResult.total ?? 0); + + // ─── Hydrate with Prisma ───────────────────────────────────────────── + + if (ids.length === 0) { + return { + data: [], + count: totalCount, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(totalCount / perPage), + }; + } + + const data = await prisma.envelope.findMany({ + where: { id: { in: ids } }, + orderBy: { [orderByColumn]: orderByDirection }, + include: { + user: { select: { id: true, name: true, email: true } }, + recipients: true, + team: { select: { id: true, url: true } }, + envelopeItems: { + select: { id: true, envelopeId: true, title: true, order: true }, + }, + }, + }); + + // Preserve ordering from the Kysely query + const idOrder = new Map(ids.map((id, index) => [id, index])); + data.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0)); + + const maskedData = data.map((document) => maskRecipientTokensForDocument({ document, user })); return { data: maskedData, - count, + count: totalCount, currentPage: Math.max(page, 1), perPage, - totalPages: Math.ceil(count / perPage), + totalPages: Math.ceil(totalCount / perPage), } satisfies FindResultResponse; }; - -const findDocumentsFilter = ( - status: ExtendedDocumentStatus, - user: Pick, - folderId?: string | null, -) => { - return match(status) - .with(ExtendedDocumentStatus.ALL, () => ({ - OR: [ - { - userId: user.id, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - recipients: { - some: { - email: user.email, - }, - }, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: user.email, - }, - }, - folderId: folderId, - }, - ], - })) - .with(ExtendedDocumentStatus.INBOX, () => ({ - status: { - not: ExtendedDocumentStatus.DRAFT, - }, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.NOT_SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - })) - .with(ExtendedDocumentStatus.DRAFT, () => ({ - userId: user.id, - status: ExtendedDocumentStatus.DRAFT, - })) - .with(ExtendedDocumentStatus.PENDING, () => ({ - OR: [ - { - userId: user.id, - status: ExtendedDocumentStatus.PENDING, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - folderId: folderId, - }, - ], - })) - .with(ExtendedDocumentStatus.COMPLETED, () => ({ - OR: [ - { - userId: user.id, - status: ExtendedDocumentStatus.COMPLETED, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - recipients: { - some: { - email: user.email, - }, - }, - folderId: folderId, - }, - ], - })) - .with(ExtendedDocumentStatus.REJECTED, () => ({ - OR: [ - { - userId: user.id, - status: ExtendedDocumentStatus.REJECTED, - folderId: folderId, - }, - { - status: ExtendedDocumentStatus.REJECTED, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.REJECTED, - }, - }, - folderId: folderId, - }, - ], - })) - .exhaustive(); -}; - -/** - * Create a Prisma filter for the Document schema to find documents for a team. - * - * Status All: - * - Documents that belong to the team - * - Documents that have been sent by the team email - * - Non draft documents that have been sent to the team email - * - * Status Inbox: - * - Non draft documents that have been sent to the team email that have not been signed - * - * Status Draft: - * - Documents that belong to the team that are draft - * - Documents that belong to the team email that are draft - * - * Status Pending: - * - Documents that belong to the team that are pending - * - Documents that have been sent by the team email that is pending to be signed by someone else - * - Documents that have been sent to the team email that is pending to be signed by someone else - * - * Status Completed: - * - Documents that belong to the team that are completed - * - Documents that have been sent to the team email that are completed - * - Documents that have been sent by the team email that are completed - * - * @param status The status of the documents to find. - * @param team The team to find the documents for. - * @returns A filter which can be applied to the Prisma Document schema. - */ -const findTeamDocumentsFilter = ( - status: ExtendedDocumentStatus, - team: Team & { teamEmail: TeamEmail | null }, - visibilityFilters: Prisma.EnvelopeWhereInput[], - folderId?: string, -) => { - const teamEmail = team.teamEmail?.email ?? null; - - return match(status) - .with(ExtendedDocumentStatus.ALL, () => { - const filter: Prisma.EnvelopeWhereInput = { - // Filter to display all documents that belong to the team. - OR: [ - { - teamId: team.id, - folderId: folderId, - OR: visibilityFilters, - }, - ], - }; - - if (teamEmail && filter.OR) { - // Filter to display all documents received by the team email that are not draft. - filter.OR.push({ - status: { - not: ExtendedDocumentStatus.DRAFT, - }, - recipients: { - some: { - email: teamEmail, - }, - }, - OR: visibilityFilters, - folderId: folderId, - }); - - // Filter to display all documents that have been sent by the team email. - filter.OR.push({ - user: { - email: teamEmail, - }, - OR: visibilityFilters, - folderId: folderId, - }); - } - - return filter; - }) - .with(ExtendedDocumentStatus.INBOX, () => { - // Return a filter that will return nothing. - if (!teamEmail) { - return null; - } - - return { - status: { - not: ExtendedDocumentStatus.DRAFT, - }, - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.NOT_SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - OR: visibilityFilters, - folderId: folderId, - }; - }) - .with(ExtendedDocumentStatus.DRAFT, () => { - const filter: Prisma.EnvelopeWhereInput = { - OR: [ - { - teamId: team.id, - status: ExtendedDocumentStatus.DRAFT, - OR: visibilityFilters, - folderId: folderId, - }, - ], - }; - - if (teamEmail && filter.OR) { - filter.OR.push({ - status: ExtendedDocumentStatus.DRAFT, - user: { - email: teamEmail, - }, - OR: visibilityFilters, - folderId: folderId, - }); - } - - return filter; - }) - .with(ExtendedDocumentStatus.PENDING, () => { - const filter: Prisma.EnvelopeWhereInput = { - OR: [ - { - teamId: team.id, - status: ExtendedDocumentStatus.PENDING, - OR: visibilityFilters, - folderId: folderId, - }, - ], - }; - - if (teamEmail && filter.OR) { - filter.OR.push({ - status: ExtendedDocumentStatus.PENDING, - OR: [ - { - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.SIGNED, - role: { - not: RecipientRole.CC, - }, - }, - }, - OR: visibilityFilters, - folderId: folderId, - }, - { - user: { - email: teamEmail, - }, - OR: visibilityFilters, - folderId: folderId, - }, - ], - }); - } - - return filter; - }) - .with(ExtendedDocumentStatus.COMPLETED, () => { - const filter: Prisma.EnvelopeWhereInput = { - status: ExtendedDocumentStatus.COMPLETED, - OR: [ - { - teamId: team.id, - OR: visibilityFilters, - }, - ], - }; - - if (teamEmail && filter.OR) { - filter.OR.push( - { - recipients: { - some: { - email: teamEmail, - }, - }, - OR: visibilityFilters, - }, - { - user: { - email: teamEmail, - }, - OR: visibilityFilters, - }, - ); - } - - return filter; - }) - .with(ExtendedDocumentStatus.REJECTED, () => { - const filter: Prisma.EnvelopeWhereInput = { - status: ExtendedDocumentStatus.REJECTED, - OR: [ - { - teamId: team.id, - OR: visibilityFilters, - }, - ], - }; - - if (teamEmail && filter.OR) { - filter.OR.push( - { - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.REJECTED, - }, - }, - OR: visibilityFilters, - }, - { - user: { - email: teamEmail, - }, - OR: visibilityFilters, - }, - ); - } - - return filter; - }) - .exhaustive(); -}; diff --git a/packages/lib/server-only/document/get-stats.ts b/packages/lib/server-only/document/get-stats.ts index 4d47b4e48..4f8af0a7f 100644 --- a/packages/lib/server-only/document/get-stats.ts +++ b/packages/lib/server-only/document/get-stats.ts @@ -1,368 +1,307 @@ -import type { Prisma, User } from '@prisma/client'; -import { DocumentVisibility, EnvelopeType, SigningStatus, TeamMemberRole } from '@prisma/client'; +import { + DocumentStatus, + EnvelopeType, + RecipientRole, + SigningStatus, + TeamMemberRole, +} from '@prisma/client'; +import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely'; import { DateTime } from 'luxon'; -import { match } from 'ts-pattern'; import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents'; -import { prisma } from '@documenso/prisma'; -import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status'; +import { kyselyPrisma, prisma, sql } from '@documenso/prisma'; +import type { DB } from '@documenso/prisma/generated/types'; import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status'; +import { STATS_COUNT_CAP } from '../../constants/document'; +import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; +import { getTeamById } from '../team/get-team'; + +// Kysely query builder type for Envelope queries. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EnvelopeQueryBuilder = SelectQueryBuilder; + +// Expression builder type scoped to Envelope table context. +type EnvelopeExpressionBuilder = ExpressionBuilder; +type RecipientExpressionBuilder = ExpressionBuilder; + +/** + * Reusable EXISTS subquery: checks that a Recipient row exists for the given + * envelope with the given email, plus optional extra conditions. + */ +const recipientExists = ( + eb: EnvelopeExpressionBuilder, + email: string, + extra?: (qb: RecipientExpressionBuilder) => Expression, +) => { + let sub = eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.email', '=', email); + + if (extra) { + sub = sub.where(extra); + } + + return eb.exists(sub.select(sql.lit(1).as('one'))); +}; + +/** + * Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email. + */ +const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) => + eb.exists( + eb + .selectFrom('User') + .whereRef('User.id', '=', 'Envelope.userId') + .where('User.email', '=', email) + .select(sql.lit(1).as('one')), + ); + export type GetStatsInput = { - user: Pick; - team?: Omit; + userId: number; + teamId: number; period?: PeriodSelectorValue; search?: string; folderId?: string; + senderIds?: number[]; +}; + +/** + * Builds a capped count from a query builder: wraps it as + * `SELECT COUNT(*) FROM (SELECT id FROM ... LIMIT cap+1) sub` + * and clamps the result to STATS_COUNT_CAP. + */ +const cappedCount = async (qb: EnvelopeQueryBuilder): Promise => { + const result = await kyselyPrisma.$kysely + .selectFrom( + qb + .clearSelect() + .select('Envelope.id') + .limit(STATS_COUNT_CAP + 1) + .as('capped'), + ) + .select(({ fn }) => fn.count('id').as('total')) + .executeTakeFirstOrThrow(); + + return Math.min(Number(result.total ?? 0), STATS_COUNT_CAP); }; export const getStats = async ({ - user, + userId, + teamId, period, search = '', folderId, - ...options + senderIds, }: GetStatsInput) => { - let createdAt: Prisma.EnvelopeWhereInput['createdAt']; + const user = await prisma.user.findFirstOrThrow({ + where: { id: userId }, + select: { id: true, email: true }, + }); - if (period) { - const daysAgo = parseInt(period.replace(/d$/, ''), 10); + const team = await getTeamById({ userId, teamId }); - const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + const teamEmail = team.teamEmail?.email ?? null; + const currentTeamRole = team.currentTeamRole ?? TeamMemberRole.MEMBER; + const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[currentTeamRole]; - createdAt = { - gte: startOfPeriod.toJSDate(), - }; - } + const searchQuery = search.trim(); + const hasSearch = searchQuery.length > 0; + const searchPattern = `%${searchQuery}%`; - const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team - ? getTeamCounts({ - ...options.team, - createdAt, - currentUserEmail: user.email, - userId: user.id, - search, - folderId, - }) - : getCounts({ user, createdAt, search, folderId })); + // ─── Base query builder ────────────────────────────────────────────── - const stats: Record = { - [ExtendedDocumentStatus.DRAFT]: 0, - [ExtendedDocumentStatus.PENDING]: 0, - [ExtendedDocumentStatus.COMPLETED]: 0, - [ExtendedDocumentStatus.REJECTED]: 0, - [ExtendedDocumentStatus.INBOX]: 0, - [ExtendedDocumentStatus.ALL]: 0, + const buildBaseQuery = (): EnvelopeQueryBuilder => { + let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely + .selectFrom('Envelope') + .select('Envelope.id'); + + // Type = DOCUMENT + qb = qb.where('Envelope.type', '=', sql.lit(EnvelopeType.DOCUMENT)); + + // Folder filter + qb = + folderId !== undefined + ? qb.where('Envelope.folderId', '=', folderId) + : qb.where('Envelope.folderId', 'is', null); + + // Period filter + if (period) { + const daysAgo = parseInt(period.replace(/d$/, ''), 10); + const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day'); + + qb = qb.where('Envelope.createdAt', '>=', startOfPeriod.toJSDate()); + } + + // Sender filter + if (senderIds && senderIds.length > 0) { + qb = qb.where('Envelope.userId', 'in', senderIds); + } + + // Search filter + if (hasSearch) { + qb = qb.where(({ or, eb }) => + or([ + eb('Envelope.title', 'ilike', searchPattern), + eb('Envelope.externalId', 'ilike', searchPattern), + eb( + 'Envelope.id', + 'in', + eb + .selectFrom('Recipient') + .select('Recipient.envelopeId') + .where(({ or: innerOr, eb: innerEb }) => + innerOr([ + innerEb('Recipient.email', 'ilike', searchPattern), + innerEb('Recipient.name', 'ilike', searchPattern), + ]), + ) + .distinct() + .limit(1000), + ), + ]), + ); + } + + return qb; }; - ownerCounts.forEach((stat) => { - stats[stat.status] = stat._count._all; - }); + // ─── Shared filter helpers ─────────────────────────────────────────── - notSignedCounts.forEach((stat) => { - stats[ExtendedDocumentStatus.INBOX] += stat._count._all; - }); + const visibilityFilter = (eb: EnvelopeExpressionBuilder) => + eb.or([ + eb( + 'Envelope.visibility', + 'in', + allowedVisibilities.map((v) => sql.lit(v)), + ), + eb('Envelope.userId', '=', user.id), + recipientExists(eb, user.email), + ]); - hasSignedCounts.forEach((stat) => { - if (stat.status === ExtendedDocumentStatus.COMPLETED) { - stats[ExtendedDocumentStatus.COMPLETED] += stat._count._all; + const teamDeletedFilter = (eb: EnvelopeExpressionBuilder) => { + const branches = [ + eb.and([eb('Envelope.teamId', '=', team.id), eb('Envelope.deletedAt', 'is', null)]), + ]; + + if (teamEmail) { + branches.push(eb.and([senderEmailIs(eb, teamEmail), eb('Envelope.deletedAt', 'is', null)])); + branches.push( + recipientExists(eb, teamEmail, (reb) => reb('Recipient.documentDeletedAt', 'is', null)), + ); } - if (stat.status === ExtendedDocumentStatus.PENDING) { - stats[ExtendedDocumentStatus.PENDING] += stat._count._all; - } + return eb.or(branches); + }; - if (stat.status === ExtendedDocumentStatus.REJECTED) { - stats[ExtendedDocumentStatus.REJECTED] += stat._count._all; - } - }); + // ─── Per-status query builders ─────────────────────────────────────── - Object.keys(stats).forEach((key) => { - if (key !== ExtendedDocumentStatus.ALL && isExtendedDocumentStatus(key)) { - stats[ExtendedDocumentStatus.ALL] += stats[key]; - } - }); + // DRAFT: team-owned drafts visible to the user + const draftQuery = buildBaseQuery() + .where('Envelope.status', '=', sql.lit(DocumentStatus.DRAFT)) + .where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }); + + // PENDING: team-owned pending + team-email signed-pending docs + const pendingQuery = buildBaseQuery() + .where('Envelope.status', '=', sql.lit(DocumentStatus.PENDING)) + .where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + recipientExists(eb, teamEmail, (reb) => + reb.and([ + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }); + + // COMPLETED: team-owned completed + team-email received completed + const completedQuery = buildBaseQuery() + .where('Envelope.status', '=', sql.lit(DocumentStatus.COMPLETED)) + .where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push(recipientExists(eb, teamEmail)); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }); + + // REJECTED: team-owned rejected + team-email rejected docs + const rejectedQuery = buildBaseQuery() + .where('Envelope.status', '=', sql.lit(DocumentStatus.REJECTED)) + .where((eb) => { + const accessBranches = [eb('Envelope.teamId', '=', team.id)]; + + if (teamEmail) { + accessBranches.push(senderEmailIs(eb, teamEmail)); + accessBranches.push( + recipientExists(eb, teamEmail, (reb) => + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.REJECTED)), + ), + ); + } + + return eb.and([teamDeletedFilter(eb), visibilityFilter(eb), eb.or(accessBranches)]); + }); + + // INBOX: non-draft docs where team email is a NOT_SIGNED, non-CC recipient + // Returns 0 if the team has no team email. + const inboxQuery = teamEmail + ? buildBaseQuery() + .where('Envelope.status', '!=', sql.lit(DocumentStatus.DRAFT)) + .where((eb) => + eb.and([ + visibilityFilter(eb), + recipientExists(eb, teamEmail, (reb) => + reb.and([ + reb('Recipient.documentDeletedAt', 'is', null), + reb('Recipient.signingStatus', '=', sql.lit(SigningStatus.NOT_SIGNED)), + reb('Recipient.role', '!=', sql.lit(RecipientRole.CC)), + ]), + ), + ]), + ) + : null; + + // ─── Execute all counts in parallel ────────────────────────────────── + + const [draft, pending, completed, rejected, inbox] = await Promise.all([ + cappedCount(draftQuery), + cappedCount(pendingQuery), + cappedCount(completedQuery), + cappedCount(rejectedQuery), + inboxQuery ? cappedCount(inboxQuery) : Promise.resolve(0), + ]); + + const all = Math.min(draft + pending + completed + rejected + inbox, STATS_COUNT_CAP); + + const stats: Record = { + [ExtendedDocumentStatus.DRAFT]: draft, + [ExtendedDocumentStatus.PENDING]: pending, + [ExtendedDocumentStatus.COMPLETED]: completed, + [ExtendedDocumentStatus.REJECTED]: rejected, + [ExtendedDocumentStatus.INBOX]: inbox, + [ExtendedDocumentStatus.ALL]: all, + }; return stats; }; - -type GetCountsOption = { - user: Pick; - createdAt: Prisma.EnvelopeWhereInput['createdAt']; - search?: string; - folderId?: string | null; -}; - -const getCounts = async ({ user, createdAt, search, folderId }: GetCountsOption) => { - const searchFilter: Prisma.EnvelopeWhereInput = { - OR: [ - { title: { contains: search, mode: 'insensitive' } }, - { recipients: { some: { name: { contains: search, mode: 'insensitive' } } } }, - { recipients: { some: { email: { contains: search, mode: 'insensitive' } } } }, - ], - }; - - const rootPageFilter = folderId === undefined ? { folderId: null } : {}; - - return Promise.all([ - // Owner counts. - prisma.envelope.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - userId: user.id, - createdAt, - deletedAt: null, - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - }), - // Not signed counts. - prisma.envelope.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.NOT_SIGNED, - documentDeletedAt: null, - }, - }, - createdAt, - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - }), - // Has signed counts. - prisma.envelope.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - createdAt, - user: { - email: { - not: user.email, - }, - }, - OR: [ - { - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.SIGNED, - documentDeletedAt: null, - }, - }, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - recipients: { - some: { - email: user.email, - signingStatus: SigningStatus.SIGNED, - documentDeletedAt: null, - }, - }, - }, - ], - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - }), - ]); -}; - -type GetTeamCountsOption = { - teamId: number; - teamEmail?: string; - senderIds?: number[]; - currentUserEmail: string; - userId: number; - createdAt: Prisma.EnvelopeWhereInput['createdAt']; - currentTeamMemberRole?: TeamMemberRole; - search?: string; - folderId?: string | null; -}; - -const getTeamCounts = async (options: GetTeamCountsOption) => { - const { createdAt, teamId, teamEmail, folderId } = options; - - const senderIds = options.senderIds ?? []; - - const userIdWhereClause: Prisma.EnvelopeWhereInput['userId'] = - senderIds.length > 0 - ? { - in: senderIds, - } - : undefined; - - const searchFilter: Prisma.EnvelopeWhereInput = { - OR: [ - { title: { contains: options.search, mode: 'insensitive' } }, - { recipients: { some: { name: { contains: options.search, mode: 'insensitive' } } } }, - { recipients: { some: { email: { contains: options.search, mode: 'insensitive' } } } }, - ], - }; - - const rootPageFilter = folderId === undefined ? { folderId: null } : {}; - - let ownerCountsWhereInput: Prisma.EnvelopeWhereInput = { - type: EnvelopeType.DOCUMENT, - userId: userIdWhereClause, - createdAt, - teamId, - deletedAt: null, - }; - - let notSignedCountsGroupByArgs = null; - let hasSignedCountsGroupByArgs = null; - - const visibilityFiltersWhereInput: Prisma.EnvelopeWhereInput = { - AND: [ - { deletedAt: null }, - { - OR: [ - match(options.currentTeamMemberRole) - .with(TeamMemberRole.ADMIN, () => ({ - visibility: { - in: [ - DocumentVisibility.EVERYONE, - DocumentVisibility.MANAGER_AND_ABOVE, - DocumentVisibility.ADMIN, - ], - }, - })) - .with(TeamMemberRole.MANAGER, () => ({ - visibility: { - in: [DocumentVisibility.EVERYONE, DocumentVisibility.MANAGER_AND_ABOVE], - }, - })) - .otherwise(() => ({ - visibility: { - equals: DocumentVisibility.EVERYONE, - }, - })), - { - OR: [ - { userId: options.userId }, - { recipients: { some: { email: options.currentUserEmail } } }, - ], - }, - ], - }, - ], - }; - - ownerCountsWhereInput = { - ...ownerCountsWhereInput, - AND: [ - ...(Array.isArray(visibilityFiltersWhereInput.AND) - ? visibilityFiltersWhereInput.AND - : visibilityFiltersWhereInput.AND - ? [visibilityFiltersWhereInput.AND] - : []), - searchFilter, - rootPageFilter, - folderId ? { folderId } : {}, - ], - }; - - if (teamEmail) { - ownerCountsWhereInput = { - type: EnvelopeType.DOCUMENT, - userId: userIdWhereClause, - createdAt, - OR: [ - { - teamId, - }, - { - user: { - email: teamEmail, - }, - }, - ], - deletedAt: null, - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }; - - notSignedCountsGroupByArgs = { - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - userId: userIdWhereClause, - createdAt, - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.NOT_SIGNED, - documentDeletedAt: null, - }, - }, - deletedAt: null, - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - } satisfies Prisma.EnvelopeGroupByArgs; - - hasSignedCountsGroupByArgs = { - by: ['status'], - _count: { - _all: true, - }, - where: { - type: EnvelopeType.DOCUMENT, - userId: userIdWhereClause, - createdAt, - OR: [ - { - status: ExtendedDocumentStatus.PENDING, - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.SIGNED, - documentDeletedAt: null, - }, - }, - deletedAt: null, - }, - { - status: ExtendedDocumentStatus.COMPLETED, - recipients: { - some: { - email: teamEmail, - signingStatus: SigningStatus.SIGNED, - documentDeletedAt: null, - }, - }, - }, - ], - AND: [searchFilter, rootPageFilter, folderId ? { folderId } : {}], - }, - } satisfies Prisma.EnvelopeGroupByArgs; - } - - return Promise.all([ - prisma.envelope.groupBy({ - by: ['status'], - _count: { - _all: true, - }, - where: ownerCountsWhereInput, - }), - notSignedCountsGroupByArgs ? prisma.envelope.groupBy(notSignedCountsGroupByArgs) : [], - hasSignedCountsGroupByArgs ? prisma.envelope.groupBy(hasSignedCountsGroupByArgs) : [], - ]); -}; diff --git a/packages/lib/server-only/envelope/find-envelopes.ts b/packages/lib/server-only/envelope/find-envelopes.ts index 03906d816..2abd97ef8 100644 --- a/packages/lib/server-only/envelope/find-envelopes.ts +++ b/packages/lib/server-only/envelope/find-envelopes.ts @@ -1,12 +1,8 @@ -import type { - DocumentSource, - DocumentStatus, - Envelope, - EnvelopeType, - Prisma, -} from '@prisma/client'; +import type { DocumentSource, DocumentStatus, Envelope, EnvelopeType } from '@prisma/client'; +import type { Expression, ExpressionBuilder, SelectQueryBuilder, SqlBool } from 'kysely'; -import { prisma } from '@documenso/prisma'; +import { kyselyPrisma, prisma, sql } from '@documenso/prisma'; +import type { DB } from '@documenso/prisma/generated/types'; import { TEAM_DOCUMENT_VISIBILITY_MAP } from '../../constants/teams'; import type { FindResultResponse } from '../../types/search-params'; @@ -28,8 +24,77 @@ export type FindEnvelopesOptions = { }; query?: string; folderId?: string; + /** + * When true (default), use a windowed count that caps early for faster pagination. + * When false, use a full COUNT(*) for exact totals — preferred for external API consumers. + */ + useWindowedCount?: boolean; }; +/** + * The number of pages ahead of the current page we'll scan for pagination. + * + * Instead of COUNT(*) over the entire result set (which must scan all qualifying rows), + * we fetch at most `offset + COUNT_WINDOW_SIZE * perPage + 1` IDs. This lets Postgres + * stop early once it has enough rows. + */ +const COUNT_WINDOW_SIZE = 100; + +/** + * Cap for the recipient search subquery. When searching by recipient email/name, + * we pre-compute matching envelope IDs up to this limit to prevent pathological + * heap scans on broad searches. + */ +const RECIPIENT_SEARCH_CAP = 1000; + +// Kysely query builder type for Envelope queries. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EnvelopeQueryBuilder = SelectQueryBuilder; + +// Expression builder type scoped to Envelope table context. +type EnvelopeExpressionBuilder = ExpressionBuilder; +type RecipientExpressionBuilder = ExpressionBuilder; + +/** + * Reusable EXISTS subquery: checks that a Recipient row exists for the given + * envelope with the given email, plus optional extra conditions. + */ +const recipientExists = ( + eb: EnvelopeExpressionBuilder, + email: string, + extra?: (qb: RecipientExpressionBuilder) => Expression, +) => { + let sub = eb + .selectFrom('Recipient') + .whereRef('Recipient.envelopeId', '=', 'Envelope.id') + .where('Recipient.email', '=', email); + + if (extra) { + sub = sub.where(extra); + } + + return eb.exists(sub.select(sql.lit(1).as('one'))); +}; + +/** + * Reusable EXISTS subquery: checks that the envelope's sender (User) has the given email. + */ +const senderEmailIs = (eb: EnvelopeExpressionBuilder, email: string) => + eb.exists( + eb + .selectFrom('User') + .whereRef('User.id', '=', 'Envelope.userId') + .where('User.email', '=', email) + .select(sql.lit(1).as('one')), + ); + +/** + * Find envelopes visible to the requesting user within a team. + * + * Unlike `findDocuments` (used by the UI), being a recipient does NOT override + * document visibility. A user will only see an envelope if its visibility level + * is within their role's threshold, or they are the document owner. + */ export const findEnvelopes = async ({ userId, teamId, @@ -42,134 +107,175 @@ export const findEnvelopes = async ({ orderBy, query = '', folderId, + useWindowedCount = true, }: FindEnvelopesOptions) => { const user = await prisma.user.findFirstOrThrow({ - where: { - id: userId, - }, - select: { - id: true, - email: true, - name: true, - }, + where: { id: userId }, + select: { id: true, email: true, name: true }, }); - const team = await getTeamById({ - userId, - teamId, - }); + const team = await getTeamById({ userId, teamId }); const orderByColumn = orderBy?.column ?? 'createdAt'; const orderByDirection = orderBy?.direction ?? 'desc'; + const searchQuery = query.trim(); + const hasSearch = searchQuery.length > 0; + const searchPattern = `%${searchQuery}%`; - const searchFilter: Prisma.EnvelopeWhereInput = query - ? { - OR: [ - { title: { contains: query, mode: 'insensitive' } }, - { externalId: { contains: query, mode: 'insensitive' } }, - { recipients: { some: { name: { contains: query, mode: 'insensitive' } } } }, - { recipients: { some: { email: { contains: query, mode: 'insensitive' } } } }, - ], - } - : {}; + const teamEmail = team.teamEmail?.email ?? null; + const allowedVisibilities = TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole]; - const visibilityFilter: Prisma.EnvelopeWhereInput = { - visibility: { - in: TEAM_DOCUMENT_VISIBILITY_MAP[team.currentTeamRole], - }, - }; + // ─── Build Kysely query ────────────────────────────────────────────── - const teamEmailFilters: Prisma.EnvelopeWhereInput[] = []; + let qb: EnvelopeQueryBuilder = kyselyPrisma.$kysely + .selectFrom('Envelope') + .select(['Envelope.id', 'Envelope.createdAt']); - if (team.teamEmail) { - teamEmailFilters.push( - { - user: { - email: team.teamEmail.email, - }, - }, - { - recipients: { - some: { - email: team.teamEmail.email, - }, - }, - }, + // Folder filter + qb = + folderId !== undefined + ? qb.where('Envelope.folderId', '=', folderId) + : qb.where('Envelope.folderId', 'is', null); + + // Exclude soft-deleted envelopes + qb = qb.where('Envelope.deletedAt', 'is', null); + + // Type filter (enum cast) + if (type) { + qb = qb.where('Envelope.type', '=', sql.lit(type)); + } + + // Template filter + if (templateId) { + qb = qb.where('Envelope.templateId', '=', templateId); + } + + // Source filter (enum cast) + if (source) { + qb = qb.where('Envelope.source', '=', sql.lit(source)); + } + + // Status filter (enum cast) + if (status) { + qb = qb.where('Envelope.status', '=', sql.lit(status)); + } + + // Search filter: title, externalId, or recipient match via capped subquery + if (hasSearch) { + qb = qb.where(({ or, eb }) => + or([ + eb('Envelope.title', 'ilike', searchPattern), + eb('Envelope.externalId', 'ilike', searchPattern), + eb( + 'Envelope.id', + 'in', + eb + .selectFrom('Recipient') + .select('Recipient.envelopeId') + .where(({ or: innerOr, eb: innerEb }) => + innerOr([ + innerEb('Recipient.email', 'ilike', searchPattern), + innerEb('Recipient.name', 'ilike', searchPattern), + ]), + ) + .distinct() + .limit(RECIPIENT_SEARCH_CAP), + ), + ]), ); } - const whereClause: Prisma.EnvelopeWhereInput = { - AND: [ - { - OR: [ - { - teamId: team.id, - ...visibilityFilter, - }, - { - userId, - }, - ...teamEmailFilters, - ], - }, - { - folderId: folderId ?? null, - deletedAt: null, - }, - searchFilter, - ], - }; + // ─── Access control ────────────────────────────────────────────────── + // + // An envelope is visible if ANY of: + // 1. It belongs to this team AND (meets the visibility threshold OR the requesting user is the owner) + // 2. (If team email) The sender's email matches the team email + // 3. (If team email) A recipient's email matches the team email - if (type) { - whereClause.type = type; - } + const visibilityFilter = (eb: EnvelopeExpressionBuilder) => + eb.or([ + eb( + 'Envelope.visibility', + 'in', + allowedVisibilities.map((v) => sql.lit(v)), + ), + // Owner always sees their own docs within this team + eb('Envelope.userId', '=', user.id), + ]); - if (templateId) { - whereClause.templateId = templateId; - } + qb = qb.where((eb) => { + const accessBranches: Expression[] = [ + // Team docs that pass visibility (or are owned by the user) + eb.and([eb('Envelope.teamId', '=', team.id), visibilityFilter(eb)]), + ]; - if (source) { - whereClause.source = source; - } + if (teamEmail) { + // Docs sent by the team email user + accessBranches.push(senderEmailIs(eb, teamEmail)); + // Docs received by the team email + accessBranches.push(recipientExists(eb, teamEmail)); + } - if (status) { - whereClause.status = status; - } + return eb.or(accessBranches); + }); - const [data, count] = await Promise.all([ - prisma.envelope.findMany({ - where: whereClause, - skip: Math.max(page - 1, 0) * perPage, - take: perPage, - orderBy: { - [orderByColumn]: orderByDirection, - }, - include: { - user: { - select: { - id: true, - name: true, - email: true, - }, - }, - recipients: { - orderBy: { - id: 'asc', - }, - }, - team: { - select: { - id: true, - url: true, - }, - }, - }, - }), - prisma.envelope.count({ - where: whereClause, - }), + // ─── Execute: paginated data + count ────────────────────────────────── + + const offset = Math.max(page - 1, 0) * perPage; + + const dataQuery = qb + .orderBy(`Envelope.${orderByColumn}`, orderByDirection) + .limit(perPage) + .offset(offset); + + // Count query: either windowed (fast, capped) or full (exact, for API consumers). + const baseCountQuery = qb.clearSelect().select('Envelope.id'); + + const countQuery = useWindowedCount + ? kyselyPrisma.$kysely + .selectFrom(baseCountQuery.limit(offset + COUNT_WINDOW_SIZE * perPage + 1).as('windowed')) + .select(({ fn }) => fn.count('id').as('total')) + : kyselyPrisma.$kysely + .selectFrom(baseCountQuery.as('filtered')) + .select(({ fn }) => fn.count('id').as('total')); + + const [dataResult, countResult] = await Promise.all([ + dataQuery.execute(), + countQuery.executeTakeFirstOrThrow(), ]); + const ids = dataResult.map((row) => row.id); + + const totalCount = useWindowedCount + ? Math.min(Number(countResult.total ?? 0), offset + COUNT_WINDOW_SIZE * perPage) + : Number(countResult.total ?? 0); + + // ─── Hydrate with Prisma ───────────────────────────────────────────── + + if (ids.length === 0) { + return { + data: [], + count: totalCount, + currentPage: Math.max(page, 1), + perPage, + totalPages: Math.ceil(totalCount / perPage), + } satisfies FindResultResponse; + } + + const data = await prisma.envelope.findMany({ + where: { id: { in: ids } }, + orderBy: { [orderByColumn]: orderByDirection }, + include: { + user: { select: { id: true, name: true, email: true } }, + recipients: { orderBy: { id: 'asc' } }, + team: { select: { id: true, url: true } }, + }, + }); + + // Preserve ordering from the Kysely query + const idOrder = new Map(ids.map((id, index) => [id, index])); + data.sort((a, b) => (idOrder.get(a.id) ?? 0) - (idOrder.get(b.id) ?? 0)); + const maskedData = data.map((envelope) => maskRecipientTokensForDocument({ document: envelope, @@ -189,9 +295,9 @@ export const findEnvelopes = async ({ return { data: mappedData, - count, + count: totalCount, currentPage: Math.max(page, 1), perPage, - totalPages: Math.ceil(count / perPage), + totalPages: Math.ceil(totalCount / perPage), } satisfies FindResultResponse; }; diff --git a/packages/prisma/migrations/20260302223702_optimize_recipient_indexes/migration.sql b/packages/prisma/migrations/20260302223702_optimize_recipient_indexes/migration.sql new file mode 100644 index 000000000..8331a85cb --- /dev/null +++ b/packages/prisma/migrations/20260302223702_optimize_recipient_indexes/migration.sql @@ -0,0 +1,17 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_email_documentDeletedAt_envelopeId_idx" ON "Recipient"("email", "documentDeletedAt", "envelopeId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_email_envelopeId_idx" ON "Recipient"("email", "envelopeId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_email_signingStatus_envelopeId_role_idx" ON "Recipient"("email", "signingStatus", "envelopeId", "role"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_email_trgm_idx" ON "Recipient" USING GIN ("email" gin_trgm_ops); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Recipient_name_trgm_idx" ON "Recipient" USING GIN ("name" gin_trgm_ops); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 467be8d2e..d41931a5c 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -598,6 +598,11 @@ model Recipient { @@index([envelopeId]) @@index([signedAt]) @@index([expiresAt]) + @@index([email, documentDeletedAt, envelopeId], map: "Recipient_email_documentDeletedAt_envelopeId_idx") + @@index([email, envelopeId], map: "Recipient_email_envelopeId_idx") + @@index([email, signingStatus, envelopeId, role], map: "Recipient_email_signingStatus_envelopeId_role_idx") + @@index([email(ops: raw("gin_trgm_ops"))], map: "Recipient_email_trgm_idx", type: Gin) + @@index([name(ops: raw("gin_trgm_ops"))], map: "Recipient_name_trgm_idx", type: Gin) } enum FieldType { diff --git a/packages/trpc/server/document-router/find-documents-internal.ts b/packages/trpc/server/document-router/find-documents-internal.ts index 2dfb38254..ff31b882d 100644 --- a/packages/trpc/server/document-router/find-documents-internal.ts +++ b/packages/trpc/server/document-router/find-documents-internal.ts @@ -1,7 +1,5 @@ import { findDocuments } from '@documenso/lib/server-only/document/find-documents'; -import type { GetStatsInput } from '@documenso/lib/server-only/document/get-stats'; import { getStats } from '@documenso/lib/server-only/document/get-stats'; -import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { mapEnvelopesToDocumentMany } from '@documenso/lib/utils/document'; import { authenticatedProcedure } from '../trpc'; @@ -30,28 +28,15 @@ export const findDocumentsInternalRoute = authenticatedProcedure folderId, } = input; - const getStatOptions: GetStatsInput = { - user, - period, - search: query, - folderId, - }; - - if (teamId) { - const team = await getTeamById({ userId: user.id, teamId }); - - getStatOptions.team = { - teamId: team.id, - teamEmail: team.teamEmail?.email, - senderIds, - currentTeamMemberRole: team.currentTeamRole, - currentUserEmail: user.email, - userId: user.id, - }; - } - const [stats, documents] = await Promise.all([ - getStats(getStatOptions), + getStats({ + userId: user.id, + teamId, + period, + search: query, + folderId, + senderIds, + }), findDocuments({ userId: user.id, teamId, diff --git a/packages/trpc/server/document-router/find-documents.ts b/packages/trpc/server/document-router/find-documents.ts index d7b0be598..b7a829b21 100644 --- a/packages/trpc/server/document-router/find-documents.ts +++ b/packages/trpc/server/document-router/find-documents.ts @@ -38,6 +38,7 @@ export const findDocumentsRoute = authenticatedProcedure perPage, folderId, orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, + useWindowedCount: false, }); return { diff --git a/packages/trpc/server/envelope-router/find-envelopes.ts b/packages/trpc/server/envelope-router/find-envelopes.ts index a16cf46f9..26e6755a1 100644 --- a/packages/trpc/server/envelope-router/find-envelopes.ts +++ b/packages/trpc/server/envelope-router/find-envelopes.ts @@ -52,5 +52,6 @@ export const findEnvelopesRoute = authenticatedProcedure perPage, folderId, orderBy: orderByColumn ? { column: orderByColumn, direction: orderByDirection } : undefined, + useWindowedCount: false, }); }); From 6faa01d38404e7542a905e95a21c7d62e1d5718c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 6 Mar 2026 12:39:03 +1100 Subject: [PATCH 017/110] feat: add pdf image renderer (#2554) ## Description Replace the PDF renderer with an custom image renderer. This allows us to remove the "react-pdf" dependency and allows us to use a virtual list to improve performance. --- .../dialogs/document-duplicate-dialog.tsx | 30 -- .../embed/authoring/configure-fields-view.tsx | 60 +-- .../embed-direct-template-client-page.tsx | 37 +- .../embed/embed-document-fields.tsx | 4 +- .../embed/embed-document-signing-page-v1.tsx | 41 +- .../multi-sign-document-signing-view.tsx | 43 +- .../direct-template/direct-template-page.tsx | 17 +- .../direct-template-signing-form.tsx | 6 +- .../document-signing-field-container.tsx | 118 +++-- .../document-signing-page-view-v1.tsx | 23 +- .../document-signing-page-view-v2.tsx | 24 +- .../document/document-certificate-qr-view.tsx | 43 +- .../general/document/document-edit-form.tsx | 17 +- .../envelope-editor-fields-drag-drop.tsx | 15 +- .../envelope-editor-fields-page-renderer.tsx | 55 +- .../envelope-editor-fields-page.tsx | 21 +- .../envelope-editor-preview-page.tsx | 24 +- .../envelope-generic-page-renderer.tsx | 49 +- .../envelope-signer-page-renderer.tsx | 50 +- .../envelope-signing-complete-dialog.tsx | 9 + .../pdf-viewer/envelope-pdf-viewer.tsx | 63 +++ .../pdf-viewer/pdf-viewer-page-image.tsx | 34 ++ .../general/pdf-viewer/pdf-viewer-states.tsx | 26 + .../general/pdf-viewer/pdf-viewer.tsx | 478 ++++++++++++++++++ .../general/pdf-viewer/use-scroll-to-page.ts | 46 ++ .../general/template/template-edit-form.tsx | 17 +- .../general/virtual-list/use-virtual-list.ts | 355 +++++++++++++ .../t.$teamUrl+/documents.$id._index.tsx | 34 +- .../t.$teamUrl+/documents.$id.edit.tsx | 6 +- .../t.$teamUrl+/templates.$id._index.tsx | 34 +- .../routes/_recipient+/d.$token+/_index.tsx | 9 +- .../_recipient+/sign.$token+/_index.tsx | 7 +- .../app/routes/embed+/_v0+/direct.$token.tsx | 7 +- .../app/routes/embed+/_v0+/sign.$token.tsx | 7 +- .../v1+/authoring+/template.edit.$id.tsx | 5 +- apps/remix/server/api/files/files.ts | 7 + .../routes/get-envelope-item-pdf-by-token.ts | 81 +++ .../api/files/routes/get-envelope-item-pdf.ts | 145 ++++++ package-lock.json | 84 --- .../autosave-fields-step.spec.ts | 23 +- .../autosave-subject-step.spec.ts | 35 +- .../duplicate-recipients-simple.spec.ts | 5 +- .../duplicate-recipients.spec.ts | 20 +- .../document-flow/stepper-component.spec.ts | 23 +- .../e2e/pdf-viewer/pdf-viewer.spec.ts | 416 +++++++++++++++ .../duplicate-recipients.spec.ts | 16 +- .../template-autosave-fields-step.spec.ts | 23 +- .../client-only/get-bounding-client-rect.ts | 2 +- .../client-only/hooks/use-document-element.ts | 5 +- .../client-only/hooks/use-editor-fields.ts | 13 +- .../client-only/hooks/use-element-bounds.ts | 8 +- .../hooks/use-field-page-coords.ts | 63 ++- .../client-only/hooks/use-is-page-in-dom.ts | 39 ++ .../client-only/hooks/use-page-renderer.ts | 144 ++---- .../providers/envelope-render-provider.tsx | 164 +++--- packages/lib/constants/pdf-viewer-i18n.ts | 22 + packages/lib/constants/pdf-viewer.ts | 19 +- .../internal/seal-document.handler.ts | 26 +- packages/lib/package.json | 1 - .../document-data/create-document-data.ts | 15 +- .../document/get-document-by-token.ts | 1 + .../get-envelope-for-recipient-signing.ts | 1 + .../get-template-by-direct-link-token.ts | 1 + packages/lib/types/document.ts | 2 + packages/lib/types/envelope.ts | 1 + .../lib/universal/upload/put-file.server.ts | 4 +- packages/lib/utils/envelope-download.ts | 38 ++ packages/lib/utils/fields.ts | 37 +- packages/lib/utils/templates.ts | 2 +- .../create-envelope-items.types.ts | 1 + .../document/document-read-only-fields.tsx | 6 +- packages/ui/components/field/field.tsx | 29 +- .../pdf-viewer/pdf-viewer-konva-lazy.tsx | 33 -- .../pdf-viewer/pdf-viewer-konva.tsx | 213 -------- packages/ui/package.json | 3 +- .../primitives/document-flow/add-fields.tsx | 14 +- .../primitives/document-flow/field-item.tsx | 13 +- .../ui/primitives/pdf-viewer/base.client.tsx | 10 - packages/ui/primitives/pdf-viewer/base.tsx | 290 ----------- packages/ui/primitives/pdf-viewer/index.ts | 1 - packages/ui/primitives/pdf-viewer/lazy.tsx | 19 - .../template-flow/add-template-fields.tsx | 14 +- 82 files changed, 2581 insertions(+), 1365 deletions(-) create mode 100644 apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx create mode 100644 apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx create mode 100644 apps/remix/app/components/general/pdf-viewer/pdf-viewer-states.tsx create mode 100644 apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx create mode 100644 apps/remix/app/components/general/pdf-viewer/use-scroll-to-page.ts create mode 100644 apps/remix/app/components/general/virtual-list/use-virtual-list.ts create mode 100644 apps/remix/server/api/files/routes/get-envelope-item-pdf-by-token.ts create mode 100644 apps/remix/server/api/files/routes/get-envelope-item-pdf.ts create mode 100644 packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts create mode 100644 packages/lib/client-only/hooks/use-is-page-in-dom.ts create mode 100644 packages/lib/constants/pdf-viewer-i18n.ts delete mode 100644 packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx delete mode 100644 packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx delete mode 100644 packages/ui/primitives/pdf-viewer/base.client.tsx delete mode 100644 packages/ui/primitives/pdf-viewer/base.tsx delete mode 100644 packages/ui/primitives/pdf-viewer/index.ts delete mode 100644 packages/ui/primitives/pdf-viewer/lazy.tsx diff --git a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx index 4ca68ea77..9b7c82404 100644 --- a/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx +++ b/apps/remix/app/components/dialogs/document-duplicate-dialog.tsx @@ -13,7 +13,6 @@ import { DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useCurrentTeam } from '~/providers/team'; @@ -38,19 +37,6 @@ export const DocumentDuplicateDialog = ({ const team = useCurrentTeam(); - const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } = - trpcReact.envelope.item.getManyByToken.useQuery( - { - envelopeId: id, - access: token ? { type: 'recipient', token } : { type: 'user' }, - }, - { - enabled: open, - }, - ); - - const envelopeItems = envelopeItemsPayload?.data || []; - const documentsPath = formatDocumentsPath(team.url); const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = @@ -88,22 +74,6 @@ export const DocumentDuplicateDialog = ({ Duplicate - {isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? ( -
-

- Loading Document... -

-
- ) : ( -
- -
- )}
diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 0a8b43e58..1a4bee33c 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -5,17 +5,17 @@ import { useLingui } from '@lingui/react'; import { Trans } from '@lingui/react/macro'; import type { EnvelopeItem, FieldType } from '@prisma/client'; import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client'; -import { base64 } from '@scure/base'; import { ChevronsUpDown } from 'lucide-react'; import { useFieldArray, useForm } from 'react-hook-form'; import { useHotkeys } from 'react-hotkeys-hook'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer'; import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { nanoid } from '@documenso/lib/universal/id'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { useRecipientColors } from '@documenso/ui/lib/recipient-colors'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -24,14 +24,15 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { FieldSelector } from '@documenso/ui/primitives/field-selector'; import { Form } from '@documenso/ui/primitives/form/form'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; + import type { TConfigureEmbedFormSchema } from './configure-document-view.types'; import type { TConfigureFieldsFormSchema } from './configure-fields-view.types'; -import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer'; const MIN_HEIGHT_PX = 12; const MIN_WIDTH_PX = 36; @@ -42,7 +43,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5; export type ConfigureFieldsViewProps = { configData: TConfigureEmbedFormSchema; presignToken?: string | undefined; - envelopeItem?: Pick; + envelopeItem?: Pick; defaultValues?: Partial; onBack?: (data: TConfigureFieldsFormSchema) => void; onSubmit: (data: TConfigureFieldsFormSchema) => void; @@ -86,23 +87,22 @@ export const ConfigureFieldsView = ({ const normalizedDocumentData = useMemo(() => { if (envelopeItem) { - return undefined; + return getDocumentDataUrl({ + envelopeId: envelopeItem.envelopeId, + envelopeItemId: envelopeItem.id, + documentDataId: envelopeItem.documentDataId, + version: 'current', + token: undefined, + presignToken, + }); } if (!configData.documentData) { return undefined; } - return base64.encode(configData.documentData.data); - }, [configData.documentData]); - - const normalizedEnvelopeItem = useMemo(() => { - if (envelopeItem) { - return envelopeItem; - } - - return { id: '', envelopeId: '' }; - }, [envelopeItem]); + return configData.documentData.data; + }, [configData.documentData, envelopeItem, presignToken]); const recipients = useMemo(() => { return configData.signers.map((signer, index) => ({ @@ -179,8 +179,6 @@ export const ConfigureFieldsView = ({ name: 'fields', }); - const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber)); - const onFieldCopy = useCallback( (event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => { const { duplicate = false, duplicateAll = false } = options ?? {}; @@ -205,13 +203,15 @@ export const ConfigureFieldsView = ({ } if (duplicateAll) { - const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR)); + const totalPages = getPdfPagesCount(); - pages.forEach((_, index) => { - const pageNumber = index + 1; + if (totalPages < 1) { + return; + } + for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) { if (pageNumber === lastActiveField.pageNumber) { - return; + continue; } const newField: TConfigureFieldsFormSchema['fields'][0] = { @@ -224,7 +224,7 @@ export const ConfigureFieldsView = ({ }; append(newField); - }); + } return; } @@ -548,17 +548,11 @@ export const ConfigureFieldsView = ({
- + {normalizedDocumentData && ( + + )} - + {localFields.map((field, index) => { const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId); diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index fc143e163..6b02a3b03 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -23,7 +23,8 @@ import { isFieldUnsignedAndRequired, isRequiredField, } from '@documenso/lib/utils/advanced-fields-helpers'; -import { validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { trpc } from '@documenso/trpc/react'; import type { @@ -35,11 +36,11 @@ import { Button } from '@documenso/ui/primitives/button'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema'; import { injectCss } from '~/utils/css-vars'; @@ -54,7 +55,7 @@ export type EmbedDirectTemplateClientPageProps = { token: string; envelopeId: string; updatedAt: Date; - envelopeItems: Pick[]; + envelopeItems: Pick[]; recipient: Recipient; fields: Field[]; metadata?: DocumentMeta | null; @@ -97,12 +98,10 @@ export const EmbedDirectTemplateClientPage = ({ const [localFields, setLocalFields] = useState(() => fields); const [pendingFields, _completedFields] = [ - localFields.filter((field) => isFieldUnsignedAndRequired(field)), + sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))), localFields.filter((field) => field.inserted), ]; - const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); - const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type)); const signatureValid = !hasSignatureField || (signature && signature.trim() !== ''); @@ -341,10 +340,16 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
- setHasDocumentLoaded(true)} />
@@ -478,15 +483,15 @@ export const EmbedDirectTemplateClientPage = ({
- - {showPendingFieldTooltip && pendingFields.length > 0 && ( + {showPendingFieldTooltip && pendingFields.length > 0 && ( + Click to insert field - )} - + + )} {/* Fields */} { - const highestPageNumber = Math.max(...fields.map((field) => field.page)); - return ( - + {fields.map((field) => match(field.type) .with(FieldType.SIGNATURE, () => ( diff --git a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx index ecb642d49..aff4447aa 100644 --- a/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx +++ b/apps/remix/app/components/embed/embed-document-signing-page-v1.tsx @@ -10,7 +10,8 @@ import { LucideChevronDown, LucideChevronUp } from 'lucide-react'; import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; -import { validateFieldsInserted } from '@documenso/lib/utils/fields'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; +import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; import { trpc } from '@documenso/trpc/react'; @@ -23,12 +24,12 @@ import { Button } from '@documenso/ui/primitives/button'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { injectCss } from '~/utils/css-vars'; import { ZSignDocumentEmbedDataSchema } from '../../types/embed-document-sign-schema'; @@ -45,7 +46,7 @@ export type EmbedSignDocumentV1ClientPageProps = { token: string; documentId: number; envelopeId: string; - envelopeItems: Pick[]; + envelopeItems: (Pick & { documentData: { id: string } })[]; recipient: RecipientWithFields; fields: Field[]; completedFields: DocumentField[]; @@ -100,14 +101,14 @@ export const EmbedSignDocumentV1ClientPage = ({ const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500); const [pendingFields, _completedFields] = [ - fields.filter( - (field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field), + sortFieldsByPosition( + fields.filter( + (field) => field.recipientId === recipient.id && isFieldUnsignedAndRequired(field), + ), ), fields.filter((field) => field.inserted), ]; - const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); - const { mutateAsync: completeDocumentWithToken, isPending: isSubmitting } = trpc.recipient.completeDocumentWithToken.useMutation(); @@ -287,10 +288,16 @@ export const EmbedSignDocumentV1ClientPage = ({
{/* Viewer */}
- setHasDocumentLoaded(true)} />
@@ -491,15 +498,15 @@ export const EmbedSignDocumentV1ClientPage = ({
- - {showPendingFieldTooltip && pendingFields.length > 0 && ( + {showPendingFieldTooltip && pendingFields.length > 0 && ( + Click to insert field - )} - + + )} {/* Fields */} diff --git a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx index 473722390..992ce9821 100644 --- a/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx +++ b/apps/remix/app/components/embed/multisign/multi-sign-document-signing-view.tsx @@ -9,6 +9,8 @@ import { P, match } from 'ts-pattern'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; +import { sortFieldsByPosition } from '@documenso/lib/utils/fields'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { trpc } from '@documenso/trpc/react'; import type { @@ -22,10 +24,11 @@ import { Button } from '@documenso/ui/primitives/button'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { Input } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; + import { useRequiredDocumentSigningContext } from '../../general/document-signing/document-signing-provider'; import { DocumentSigningRejectDialog } from '../../general/document-signing/document-signing-reject-dialog'; import { EmbedDocumentFields } from '../embed-document-fields'; @@ -87,14 +90,14 @@ export const MultiSignDocumentSigningView = ({ const hasSignatureField = document?.fields.some((field) => isSignatureFieldType(field.type)); const [pendingFields, completedFields] = [ - document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? - [], + sortFieldsByPosition( + document?.fields.filter((field) => field.recipient.signingStatus !== SigningStatus.SIGNED) ?? + [], + ), document?.fields.filter((field) => field.recipient.signingStatus === SigningStatus.SIGNED) ?? [], ]; - const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page)); - const uninsertedFields = document?.fields.filter((field) => !field.inserted) ?? []; const onSignField = async (payload: TSignFieldWithTokenMutationSchema) => { @@ -226,10 +229,16 @@ export const MultiSignDocumentSigningView = ({ 'md:mx-auto md:max-w-2xl': document.status === DocumentStatus.COMPLETED, })} > - { setHasDocumentLoaded(true); onDocumentReady?.(); @@ -362,19 +371,13 @@ export const MultiSignDocumentSigningView = ({ )}
- {hasDocumentLoaded && ( + {hasDocumentLoaded && showPendingFieldTooltip && pendingFields.length > 0 && ( - {showPendingFieldTooltip && pendingFields.length > 0 && ( - - Click to insert field - - )} + + Click to insert field + )} diff --git a/apps/remix/app/components/general/direct-template/direct-template-page.tsx b/apps/remix/app/components/general/direct-template/direct-template-page.tsx index 2b668ab86..7bd25376c 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-page.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-page.tsx @@ -9,16 +9,17 @@ import { useNavigate, useSearchParams } from 'react-router'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import type { TTemplate } from '@documenso/lib/types/template'; import { isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { trpc } from '@documenso/trpc/react'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentSigningAuthContext } from '~/components/general/document-signing/document-signing-auth-provider'; import { useRequiredDocumentSigningContext } from '~/components/general/document-signing/document-signing-provider'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { DirectTemplateConfigureForm, @@ -151,11 +152,17 @@ export const DirectTemplatePageView = ({ gradient > - setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 69d1ae8b1..ddf2fcbe1 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -82,8 +82,6 @@ export const DirectTemplateSigningForm = ({ const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const highestPageNumber = Math.max(...localFields.map((field) => field.page)); - const fieldsRequiringValidation = useMemo(() => { return localFields.filter((field) => isFieldUnsignedAndRequired(field)); }, [localFields]); @@ -250,9 +248,7 @@ export const DirectTemplateSigningForm = ({ - + {validateUninsertedFields && uninsertedFields[0] && ( Click to insert field diff --git a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx index 7c67ae559..9f823bec2 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-field-container.tsx @@ -130,69 +130,67 @@ export const DocumentSigningFieldContainer = ({ }; return ( -
- - {!field.inserted && !loading && !readOnlyField && ( - + )} + + {type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && ( + + + + + + - - - - + {tooltipText &&

{tooltipText}

} + + Remove + +
+
+ )} + + {(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && + field.fieldMeta?.label && ( +
+ {field.fieldMeta.label} +
)} - {type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && ( - - - - - - - {tooltipText &&

{tooltipText}

} - - Remove - -
-
- )} - - {(field.type === FieldType.RADIO || field.type === FieldType.CHECKBOX) && - field.fieldMeta?.label && ( -
- {field.fieldMeta.label} -
- )} - - {children} -
-
+ {children} + ); }; diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx index 33e05b749..493dc2e5c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v1.tsx @@ -22,6 +22,7 @@ import { } from '@documenso/lib/types/field-meta'; import type { CompletedField } from '@documenso/lib/types/fields'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { validateFieldsInserted } from '@documenso/lib/utils/fields'; import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; @@ -30,7 +31,6 @@ import { DocumentReadOnlyFields } from '@documenso/ui/components/document/docume import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { DocumentSigningAttachmentsPopover } from '~/components/general/document-signing/document-signing-attachments-popover'; import { DocumentSigningAutoSign } from '~/components/general/document-signing/document-signing-auto-sign'; @@ -46,6 +46,7 @@ import { DocumentSigningRadioField } from '~/components/general/document-signing import { DocumentSigningRejectDialog } from '~/components/general/document-signing/document-signing-reject-dialog'; import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; @@ -162,8 +163,6 @@ export const DocumentSigningPageViewV1 = ({ : undefined; }, [document.documentMeta?.signingOrder, allRecipients, recipient.id]); - const highestPageNumber = Math.max(...fields.map((field) => field.page)); - const pendingFields = fieldsRequiringValidation.filter((field) => !field.inserted); const hasPendingFields = pendingFields.length > 0; @@ -274,11 +273,17 @@ export const DocumentSigningPageViewV1 = ({
- @@ -400,9 +405,7 @@ export const DocumentSigningPageViewV1 = ({ )} - + {fields .filter( (field) => diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx index d4dbb72bd..57a37bd0c 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view-v2.tsx @@ -1,4 +1,4 @@ -import { lazy, useMemo } from 'react'; +import { useMemo, useRef } from 'react'; import { Plural, Trans } from '@lingui/react/macro'; import { EnvelopeType, RecipientRole } from '@prisma/client'; @@ -8,8 +8,8 @@ import { Link } from 'react-router'; import { match } from 'ts-pattern'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Button } from '@documenso/ui/primitives/button'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -23,6 +23,8 @@ import { SignFieldNumberDialog } from '~/components/dialogs/sign-field-number-di import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signature-dialog'; import { SignFieldTextDialog } from '~/components/dialogs/sign-field-text-dialog'; import { useEmbedSigningContext } from '~/components/embed/embed-signing-context'; +import { EnvelopeSignerPageRenderer } from '~/components/general/envelope-signing/envelope-signer-page-renderer'; +import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; import { BrandingLogo } from '../branding-logo'; import { DocumentSigningAttachmentsPopover } from '../document-signing/document-signing-attachments-popover'; @@ -33,13 +35,11 @@ import { DocumentSigningMobileWidget } from './document-signing-mobile-widget'; import { DocumentSigningRejectDialog } from './document-signing-reject-dialog'; import { useRequiredEnvelopeSigningContext } from './envelope-signing-provider'; -const EnvelopeSignerPageRenderer = lazy( - async () => import('~/components/general/envelope-signing/envelope-signer-page-renderer'), -); - export const DocumentSigningPageViewV2 = () => { const { envelopeItems, currentEnvelopeItem, setCurrentEnvelopeItem } = useCurrentEnvelopeRender(); + const scrollableContainerRef = useRef(null); + const { isDirectTemplate, envelope, @@ -199,7 +199,10 @@ export const DocumentSigningPageViewV2 = () => {
-
+
{/* Horizontal envelope item selector */} {envelopeItems.length > 1 && ( @@ -228,15 +231,16 @@ export const DocumentSigningPageViewV2 = () => { {/* Document View */}
{currentEnvelopeItem ? ( - ) : (

- No documents found + No document selected

)} diff --git a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx index a01e315cf..a874c61ed 100644 --- a/apps/remix/app/components/general/document/document-certificate-qr-view.tsx +++ b/apps/remix/app/components/general/document/document-certificate-qr-view.tsx @@ -1,4 +1,4 @@ -import { lazy, useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Trans } from '@lingui/react/macro'; import { type DocumentData, DocumentStatus, type EnvelopeItem, EnvelopeType } from '@prisma/client'; @@ -9,9 +9,10 @@ import { EnvelopeRenderProvider, useCurrentEnvelopeRender, } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, @@ -21,15 +22,13 @@ import { DialogHeader, DialogTitle, } from '@documenso/ui/primitives/dialog'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { EnvelopeDownloadDialog } from '~/components/dialogs/envelope-download-dialog'; +import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { EnvelopeRendererFileSelector } from '../envelope-editor/envelope-file-selector'; - -const EnvelopeGenericPageRenderer = lazy( - async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'), -); +import { EnvelopeGenericPageRenderer } from '../envelope-editor/envelope-generic-page-renderer'; export type DocumentCertificateQRViewProps = { documentId: number; @@ -104,11 +103,13 @@ export const DocumentCertificateQRView = ({ {internalVersion === 2 ? (
-
@@ -175,7 +182,9 @@ const DocumentCertificateQrV2 = ({ formattedDate, token, }: DocumentCertificateQrV2Props) => { - const { currentEnvelopeItem, envelopeItems } = useCurrentEnvelopeRender(); + const { envelopeItems } = useCurrentEnvelopeRender(); + + const scrollableContainerRef = useRef(null); return (
@@ -207,10 +216,14 @@ const DocumentCertificateQrV2 = ({ />
-
+
- +
); diff --git a/apps/remix/app/components/general/document/document-edit-form.tsx b/apps/remix/app/components/general/document/document-edit-form.tsx index 835e0c292..2584ff577 100644 --- a/apps/remix/app/components/general/document/document-edit-form.tsx +++ b/apps/remix/app/components/general/document/document-edit-form.tsx @@ -14,6 +14,7 @@ import { } from '@documenso/lib/constants/trpc'; import type { TDocument } from '@documenso/lib/types/document'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; @@ -27,10 +28,10 @@ import { AddSubjectFormPartial } from '@documenso/ui/primitives/document-flow/ad import type { TAddSubjectFormSchema } from '@documenso/ui/primitives/document-flow/add-subject.types'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { useCurrentTeam } from '~/providers/team'; export type DocumentEditFormProps = { @@ -440,11 +441,17 @@ export const DocumentEditForm = ({ gradient > - setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx index bca1db219..2de39668b 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-drag-drop.tsx @@ -175,15 +175,6 @@ export const EnvelopeEditorFieldDragDrop = ({ const { top, left, height, width } = getBoundingClientRect($page); - console.log({ - top, - left, - height, - width, - rawPageX: event.pageX, - rawPageY: event.pageY, - }); - const pageNumber = parseInt($page.getAttribute('data-page-number') ?? '1', 10); // Calculate x and y as a percentage of the page width and height @@ -278,13 +269,13 @@ export const EnvelopeEditorFieldDragDrop = ({ onMouseDown={() => setSelectedField(field.type)} data-selected={selectedField === field.type ? true : undefined} className={cn( - 'border-border group flex h-12 cursor-pointer items-center justify-center rounded-lg border px-4 transition-colors', + 'group flex h-12 cursor-pointer items-center justify-center rounded-lg border border-border px-4 transition-colors', RECIPIENT_COLOR_STYLES[selectedRecipientColor].fieldButton, )} >

{ const { t, i18n } = useLingui(); const { envelope, editorFields, getRecipientColorKey } = useCurrentEnvelopeEditor(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); @@ -37,34 +40,22 @@ export default function EnvelopeEditorFieldsPageRenderer() { const [isFieldChanging, setIsFieldChanging] = useState(false); const [pendingFieldCreation, setPendingFieldCreation] = useState(null); - const { - stage, - pageLayer, - canvasElement, - konvaContainer, - pageContext, - scaledViewport, - unscaledViewport, - } = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer)); + const { stage, pageLayer, konvaContainer, scaledViewport, unscaledViewport } = usePageRenderer( + ({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), + pageData, + ); - const { _className, scale } = pageContext; + const { scale, pageNumber } = pageData; const localPageFields = useMemo( () => editorFields.localFields.filter( - (field) => - field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, + (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, ), - [editorFields.localFields, pageContext.pageNumber], + [editorFields.localFields, pageNumber, currentEnvelopeItem?.id], ); const handleResizeOrMove = (event: KonvaEventObject) => { - const { current: container } = canvasElement; - - if (!container) { - return; - } - const isDragEvent = event.type === 'dragend'; const fieldGroup = event.target as Konva.Group; @@ -344,7 +335,6 @@ export default function EnvelopeEditorFieldsPageRenderer() { // Create a field if no items are selected or the size is too small. if ( selectedFieldGroups.length === 0 && - canvasElement.current && unscaledBoxWidth > MIN_FIELD_WIDTH_PX && unscaledBoxHeight > MIN_FIELD_HEIGHT_PX && editorFields.selectedRecipient && @@ -531,7 +521,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { removePendingField(); - if (!canvasElement.current || !currentEnvelopeItem || !editorFields.selectedRecipient) { + if (!currentEnvelopeItem || !editorFields.selectedRecipient) { return; } @@ -546,7 +536,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { editorFields.addField({ envelopeItemId: currentEnvelopeItem.id, - page: pageContext.pageNumber, + page: pageNumber, type, positionX: fieldX, positionY: fieldY, @@ -575,10 +565,7 @@ export default function EnvelopeEditorFieldsPageRenderer() { } return ( -

+ <> {selectedKonvaFieldGroups.length > 0 && interactiveTransformer.current && !isFieldChanging && ( @@ -640,17 +627,9 @@ export default function EnvelopeEditorFieldsPageRenderer() { {/* The element Konva will inject it's canvas into. */}
- - {/* Canvas the PDF will be rendered on. */} - -
+ ); -} +}; type FieldActionButtonsProps = React.HTMLAttributes & { handleDuplicateSelectedFields: () => void; diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx index 8eba4eb8a..3ef6992a2 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-fields-page.tsx @@ -1,4 +1,4 @@ -import { lazy, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { MessageDescriptor } from '@lingui/core'; import { msg } from '@lingui/core/macro'; @@ -12,6 +12,7 @@ import { match } from 'ts-pattern'; import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types'; import { FIELD_META_DEFAULT_VALUES, @@ -29,7 +30,6 @@ import { } from '@documenso/lib/types/field-meta'; import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Separator } from '@documenso/ui/primitives/separator'; @@ -46,16 +46,14 @@ import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-nu import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form'; import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form'; import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form'; +import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; import { useCurrentTeam } from '~/providers/team'; import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop'; +import { EnvelopeEditorFieldsPageRenderer } from './envelope-editor-fields-page-renderer'; import { EnvelopeRendererFileSelector } from './envelope-file-selector'; import { EnvelopeRecipientSelector } from './envelope-recipient-selector'; -const EnvelopeEditorFieldsPageRenderer = lazy( - async () => import('~/components/general/envelope-editor/envelope-editor-fields-page-renderer'), -); - const FieldSettingsTypeTranslations: Record = { [FieldType.SIGNATURE]: msg`Signature Settings`, [FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`, @@ -75,6 +73,8 @@ export const EnvelopeEditorFieldsPage = () => { const team = useCurrentTeam(); + const scrollableContainerRef = useRef(null); + const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor(); const { currentEnvelopeItem } = useCurrentEnvelopeRender(); @@ -156,12 +156,12 @@ export const EnvelopeEditorFieldsPage = () => { return (
-
+
{/* Horizontal envelope item selector */} {/* Document View */} -
+
{envelope.recipients.length === 0 && ( { )} {currentEnvelopeItem !== null ? ( - ) : (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx index 3fc331d72..086eaa199 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-editor-preview-page.tsx @@ -1,4 +1,4 @@ -import { lazy, useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { faker } from '@faker-js/faker/locale/en'; import { Trans } from '@lingui/react/macro'; @@ -11,21 +11,20 @@ import { EnvelopeRenderProvider, useCurrentEnvelopeRender, } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import { ZFieldAndMetaSchema } from '@documenso/lib/types/field-meta'; import { extractFieldInsertionValues } from '@documenso/lib/utils/envelope-signing'; import { toCheckboxCustomText } from '@documenso/lib/utils/fields'; import { extractInitials } from '@documenso/lib/utils/recipient-formatter'; import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector'; import { Separator } from '@documenso/ui/primitives/separator'; -import { EnvelopeRendererFileSelector } from './envelope-file-selector'; +import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer'; +import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; -const EnvelopeGenericPageRenderer = lazy( - async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'), -); +import { EnvelopeRendererFileSelector } from './envelope-file-selector'; // Todo: Envelopes - Dynamically import faker export const EnvelopeEditorPreviewPage = () => { @@ -33,6 +32,8 @@ export const EnvelopeEditorPreviewPage = () => { const { currentEnvelopeItem, fields } = useCurrentEnvelopeRender(); + const scrollableContainerRef = useRef(null); + const [selectedPreviewMode, setSelectedPreviewMode] = useState<'recipient' | 'signed'>( 'recipient', ); @@ -200,7 +201,9 @@ export const EnvelopeEditorPreviewPage = () => { // Override the parent renderer provider so we can inject custom fields. return ( ({ @@ -212,12 +215,12 @@ export const EnvelopeEditorPreviewPage = () => { }} >
-
+
{/* Horizontal envelope item selector */} {/* Document View */} -
+
Preview Mode @@ -228,9 +231,10 @@ export const EnvelopeEditorPreviewPage = () => { {currentEnvelopeItem !== null ? ( - ) : (
diff --git a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx index 370d35240..9e2b1e4aa 100644 --- a/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-editor/envelope-generic-page-renderer.tsx @@ -5,7 +5,10 @@ import { DocumentStatus, type Recipient, SigningStatus } from '@prisma/client'; import type Konva from 'konva'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { + type PageRenderData, + useCurrentEnvelopeRender, +} from '@documenso/lib/client-only/providers/envelope-render-provider'; import type { TEnvelope } from '@documenso/lib/types/envelope'; import { renderField } from '@documenso/lib/universal/field-renderer/render-field'; import { getClientSideFieldTranslations } from '@documenso/lib/utils/fields'; @@ -15,7 +18,7 @@ type GenericLocalField = TEnvelope['fields'][number] & { recipient: Pick; }; -export default function EnvelopeGenericPageRenderer() { +export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRenderData }) => { const { i18n } = useLingui(); const { @@ -28,19 +31,14 @@ export default function EnvelopeGenericPageRenderer() { overrideSettings, } = useCurrentEnvelopeRender(); - const { - stage, - pageLayer, - canvasElement, - konvaContainer, - pageContext, - scaledViewport, - unscaledViewport, - } = usePageRenderer(({ stage, pageLayer }) => { - createPageCanvas(stage, pageLayer); - }); + const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer( + ({ stage, pageLayer }) => { + createPageCanvas(stage, pageLayer); + }, + pageData, + ); - const { _className, scale } = pageContext; + const { scale, pageNumber } = pageData; const localPageFields = useMemo((): GenericLocalField[] => { if (envelopeStatus === DocumentStatus.COMPLETED) { @@ -49,8 +47,7 @@ export default function EnvelopeGenericPageRenderer() { return fields .filter( - (field) => - field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, + (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, ) .map((field) => { const recipient = recipients.find((recipient) => recipient.id === field.recipientId); @@ -73,7 +70,7 @@ export default function EnvelopeGenericPageRenderer() { (recipient.signingStatus === SigningStatus.SIGNED ? inserted : true) || fieldMeta?.readOnly, ); - }, [fields, pageContext.pageNumber, currentEnvelopeItem?.id, recipients]); + }, [fields, pageNumber, currentEnvelopeItem?.id, recipients, envelopeStatus]); const unsafeRenderFieldOnLayer = (field: GenericLocalField) => { if (!pageLayer.current) { @@ -160,11 +157,9 @@ export default function EnvelopeGenericPageRenderer() { } return ( -
+ <> {overrideSettings?.showRecipientTooltip && + pageData.imageLoadingState === 'loaded' && localPageFields.map((field) => (
- - {/* Canvas the PDF will be rendered on. */} - -
+ ); -} +}; diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx index b47e22cea..76432c229 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signer-page-renderer.tsx @@ -14,7 +14,10 @@ import type { KonvaEventObject } from 'konva/lib/Node'; import { match } from 'ts-pattern'; import { usePageRenderer } from '@documenso/lib/client-only/hooks/use-page-renderer'; -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { + type PageRenderData, + useCurrentEnvelopeRender, +} from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates'; import { isBase64Image } from '@documenso/lib/constants/signatures'; @@ -49,7 +52,7 @@ type GenericLocalField = TEnvelope['fields'][number] & { recipient: Pick; }; -export default function EnvelopeSignerPageRenderer() { +export const EnvelopeSignerPageRenderer = ({ pageData }: { pageData: PageRenderData }) => { const { t, i18n } = useLingui(); const { currentEnvelopeItem, setRenderError } = useCurrentEnvelopeRender(); const { sessionData } = useOptionalSession(); @@ -77,17 +80,12 @@ export default function EnvelopeSignerPageRenderer() { const { onFieldSigned, onFieldUnsigned } = useEmbedSigningContext() || {}; - const { - stage, - pageLayer, - canvasElement, - konvaContainer, - pageContext, - scaledViewport, - unscaledViewport, - } = usePageRenderer(({ stage, pageLayer }) => createPageCanvas(stage, pageLayer)); + const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer( + ({ stage, pageLayer }) => createPageCanvas(stage, pageLayer), + pageData, + ); - const { _className, scale } = pageContext; + const { scale, pageNumber } = pageData; const { envelope } = envelopeData; @@ -99,10 +97,9 @@ export default function EnvelopeSignerPageRenderer() { } return fieldsToRender.filter( - (field) => - field.page === pageContext.pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, + (field) => field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id, ); - }, [recipientFields, selectedAssistantRecipientFields, pageContext.pageNumber]); + }, [recipientFields, selectedAssistantRecipientFields, pageNumber, currentEnvelopeItem?.id]); /** * Returns fields that have been fully signed by other recipients for this specific @@ -117,7 +114,7 @@ export default function EnvelopeSignerPageRenderer() { return recipient.fields .filter( (field) => - field.page === pageContext.pageNumber && + field.page === pageNumber && field.envelopeItemId === currentEnvelopeItem?.id && (field.inserted || field.fieldMeta?.readOnly), ) @@ -132,7 +129,7 @@ export default function EnvelopeSignerPageRenderer() { }, })); }); - }, [envelope.recipients, pageContext.pageNumber]); + }, [envelope.recipients, pageNumber, currentEnvelopeItem?.id]); const unsafeRenderFieldOnLayer = (unparsedField: Field & { signature?: Signature | null }) => { if (!pageLayer.current) { @@ -534,14 +531,11 @@ export default function EnvelopeSignerPageRenderer() { } return ( -
+ <> {showPendingFieldTooltip && recipientFieldsRemaining.length > 0 && recipientFieldsRemaining[0]?.envelopeItemId === currentEnvelopeItem?.id && - recipientFieldsRemaining[0]?.page === pageContext.pageNumber && ( + recipientFieldsRemaining[0]?.page === pageNumber && (
- - {/* Canvas the PDF will be rendered on. */} - -
+ ); -} +}; diff --git a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx index ca1a80729..76a6133f2 100644 --- a/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/envelope-signing/envelope-signing-complete-dialog.tsx @@ -6,6 +6,7 @@ import { useNavigate, useRevalidator, useSearchParams } from 'react-router'; import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics'; import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { isBase64Image } from '@documenso/lib/constants/signatures'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth'; @@ -71,6 +72,14 @@ export const EnvelopeSignerCompleteDialog = () => { if (fieldTooltip) { fieldTooltip.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + // Tooltip not in DOM (page virtualized away) — signal the PDF viewer + // to scroll to the correct page via the data attribute. + const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR); + + if (pdfContent) { + pdfContent.setAttribute('data-scroll-to-page', String(nextField.page)); + } } }, isEnvelopeItemSwitch ? 150 : 50, diff --git a/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx new file mode 100644 index 000000000..af2ea1690 --- /dev/null +++ b/apps/remix/app/components/general/pdf-viewer/envelope-pdf-viewer.tsx @@ -0,0 +1,63 @@ +import React, { useRef } from 'react'; + +import type { MessageDescriptor } from '@lingui/core'; +import { Trans, useLingui } from '@lingui/react/macro'; + +import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; + +import { PDFViewer, type PDFViewerProps } from './pdf-viewer'; + +export type EnvelopePdfViewerProps = { + /** + * The error message to render when there is an error. + */ + errorMessage: { title: MessageDescriptor; description: MessageDescriptor } | null; +} & Omit; + +export const EnvelopePdfViewer = ({ + errorMessage, + className, + ...props +}: EnvelopePdfViewerProps) => { + const { t } = useLingui(); + + const $el = useRef(null); + + const { currentEnvelopeItem, renderError } = useCurrentEnvelopeRender(); + + if (renderError || !currentEnvelopeItem) { + return ( +
+ {renderError ? ( + + + {t(errorMessage?.title || PDF_VIEWER_ERROR_MESSAGES.default.title)} + + + {t(errorMessage?.description || PDF_VIEWER_ERROR_MESSAGES.default.description)} + + + ) : ( +
+

+ No document selected +

+
+ )} +
+ ); + } + + return ( + + ); +}; + +export default EnvelopePdfViewer; diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx new file mode 100644 index 000000000..271cedd2c --- /dev/null +++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-page-image.tsx @@ -0,0 +1,34 @@ +import { Trans } from '@lingui/react/macro'; + +import type { ImageLoadingState } from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { cn } from '@documenso/ui/lib/utils'; +import { Spinner } from '@documenso/ui/primitives/spinner'; + +type PdfViewerPageImageProps = { + imageLoadingState: ImageLoadingState; + imageProps: React.ImgHTMLAttributes & Record & { alt: '' }; +}; + +export const PdfViewerPageImage = ({ imageLoadingState, imageProps }: PdfViewerPageImageProps) => { + return ( + <> + {/* Loading State */} + {imageLoadingState === 'loading' && ( +
+ +
+ )} + + {imageLoadingState === 'error' && ( +
+

+ Error loading page +

+
+ )} + + {/* The PDF image. */} + {imageProps.src && } + + ); +}; diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer-states.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-states.tsx new file mode 100644 index 000000000..975a5efde --- /dev/null +++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer-states.tsx @@ -0,0 +1,26 @@ +import { Trans } from '@lingui/react/macro'; + +import { Spinner } from '@documenso/ui/primitives/spinner'; + +export const PdfViewerLoadingState = () => { + return ( +
+ +
+ ); +}; + +export const PdfViewerErrorState = () => { + return ( +
+
+

+ Something went wrong while loading the document. +

+

+ Please try again or contact our support. +

+
+
+ ); +}; diff --git a/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx new file mode 100644 index 000000000..d6d2f34e5 --- /dev/null +++ b/apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx @@ -0,0 +1,478 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { Trans, useLingui } from '@lingui/react/macro'; +import pMap from 'p-map'; +import * as pdfjsLib from 'pdfjs-dist'; +import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?url'; + +import type { + ImageLoadingState, + PageRenderData, +} from '@documenso/lib/client-only/providers/envelope-render-provider'; +import { PDF_VIEWER_PAGE_CLASSNAME } from '@documenso/lib/constants/pdf-viewer'; +import { cn } from '@documenso/ui/lib/utils'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import type { ScrollTarget } from '../virtual-list/use-virtual-list'; +import { useVirtualList } from '../virtual-list/use-virtual-list'; +import { PdfViewerPageImage } from './pdf-viewer-page-image'; +import { PdfViewerErrorState, PdfViewerLoadingState } from './pdf-viewer-states'; +import { useScrollToPage } from './use-scroll-to-page'; + +pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker; + +type PageMeta = { + width: number; + height: number; +}; + +type LoadingState = 'loading' | 'loaded' | 'error'; + +const LOW_RENDER_RESOLUTION = 1; +const HIGH_RENDER_RESOLUTION = 2; +const IDLE_RENDER_DELAY = 200; + +export type PDFViewerProps = { + className?: string; + + /** + * The PDF data to render. + * + * If it's a URL, it will be fetched and rendered. + */ + data: Uint8Array | string; + + /** + * Ref to the scrollable parent container that handles scrolling. + * + * This must point to an element with `overflow-y: auto` or `overflow-y: scroll` + * that is an ancestor of this component, or `'window'` to use the browser + * window as the scroll container. + */ + scrollParentRef: ScrollTarget; + + onDocumentLoad?: () => void; + + /** + * Additional component to render next to the image, such as a Konva canvas + * for rendering fields. + */ + customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>; +} & React.HTMLAttributes; + +export const PDFViewer = ({ + className, + data, + scrollParentRef, + onDocumentLoad, + customPageRenderer, + ...props +}: PDFViewerProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const $el = useRef(null); + + const [loadingState, setLoadingState] = useState('loading'); + + const [pdf, setPdf] = useState(null); + + const [pages, setPages] = useState([]); + + useEffect(() => { + const fetchMetadata = async () => { + try { + setLoadingState('loading'); + setPages([]); + + let result: Uint8Array | null = typeof data === 'string' ? null : new Uint8Array(data); + + if (typeof data === 'string') { + const response = await fetch(data); + + if (!response.ok) { + throw new Error(`Failed to fetch PDF data: ${response.status}`); + } + + result = new Uint8Array(await response.arrayBuffer()); + } + + const loadedPdf = await pdfjsLib.getDocument({ data: result! }).promise; + + if (pdf) { + await pdf.destroy(); + } + + setPdf(loadedPdf); + + // Fetch the pages + const pages = await pMap( + Array.from({ length: loadedPdf.numPages }), + async (_, pageIndex) => { + const page = await loadedPdf.getPage(pageIndex + 1); + const viewport = page.getViewport({ scale: 1 }); + + return { + width: viewport.width, + height: viewport.height, + }; + }, + ); + + setPages(pages); + + setLoadingState('loaded'); + } catch (err) { + console.error(err); + setLoadingState('error'); + + toast({ + title: t`Error`, + description: t`An error occurred while loading the document.`, + variant: 'destructive', + }); + } + }; + + void fetchMetadata(); + + return () => { + if (pdf) { + void pdf.destroy(); + } + }; + }, [data]); + + // Notify when document is loaded + useEffect(() => { + if (loadingState === 'loaded' && onDocumentLoad) { + onDocumentLoad(); + } + }, [loadingState, onDocumentLoad]); + + const isLoading = loadingState === 'loading'; + const hasError = loadingState === 'error'; + + return ( +
+ {/* Loading State */} + {isLoading && } + + {/* Error State */} + {hasError && } + + {/* Loaded State */} + {loadingState === 'loaded' && pages.length > 0 && pdf && ( + + )} +
+ ); +}; + +type VirtualizedPageListProps = { + scrollParentRef: ScrollTarget; + constraintRef: React.RefObject; + pages: PageMeta[]; + numPages: number; + pdf: pdfjsLib.PDFDocumentProxy; + customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>; +}; + +const VirtualizedPageList = ({ + scrollParentRef, + constraintRef, + pages, + numPages, + pdf, + customPageRenderer, +}: VirtualizedPageListProps) => { + const contentRef = useRef(null); + + const { virtualItems, totalSize, constraintWidth, scrollToItem } = useVirtualList({ + scrollRef: scrollParentRef, + constraintRef, + contentRef, + itemCount: numPages, + itemSize: (index, width) => { + const pageMeta = pages[index]; + + // Calculate height based on aspect ratio and available width + const aspectRatio = pageMeta.height / pageMeta.width; + const scaledHeight = width * aspectRatio; + + // Add 32px for the page number text and margins (my-2 = 8px * 2 + text height ~16px) + // Add additional 2px for the top and bottom borders. + return scaledHeight + 32 + 2; + }, + overscan: 5, + }); + + useScrollToPage(contentRef, scrollToItem); + + return ( +
+ {virtualItems.map((virtualItem) => { + const index = virtualItem.index; + const pageMeta = pages[index]; + const pageNumber = index + 1; + + // Calculate scale based on constraint width + const scale = constraintWidth / pageMeta.width; + + const scaledWidth = Math.floor(pageMeta.width * scale); + const scaledHeight = Math.floor(pageMeta.height * scale); + + return ( +
+ + +

+ + Page {pageNumber} of {numPages} + +

+
+ ); + })} +
+ ); +}; + +type PdfViewerPageProps = { + pageNumber: number; + pdf: pdfjsLib.PDFDocumentProxy; + unscaledWidth: number; + unscaledHeight: number; + scaledWidth: number; + scaledHeight: number; + scale: number; + customPageRenderer?: React.FunctionComponent<{ pageData: PageRenderData }>; +}; + +const PdfViewerPage = ({ + pageNumber, + pdf, + unscaledWidth, + unscaledHeight, + scaledWidth, + scaledHeight, + scale, + customPageRenderer: CustomPageRenderer, +}: PdfViewerPageProps) => { + const { imageProps, imageLoadingState } = usePdfPageImage({ + pageNumber, + pdf, + unscaledWidth, + unscaledHeight, + scaledWidth, + scaledHeight, + scale, + }); + + return ( +
+ {CustomPageRenderer && imageLoadingState === 'loaded' && ( + + )} + + +
+ ); +}; + +/** + * Manages rendering a page from a pdf. + */ +const usePdfPageImage = ({ + pageNumber, + pdf, + scale, + scaledWidth, + scaledHeight, +}: PdfViewerPageProps) => { + const [imageLoadingState, setImageLoadingState] = useState('loading'); + + const [imageUrl, setImageUrl] = useState(''); + const renderTaskRef = useRef(null); + const idleTimerRef = useRef | null>(null); + + const renderedResolutionRef = useRef(null); + const renderedPageNumberRef = useRef(null); + const renderedPdfRef = useRef(null); + + useEffect(() => { + let isCancelled = false; + + const cancelRenderTask = () => { + if (!renderTaskRef.current) { + return; + } + + renderTaskRef.current.cancel(); + renderTaskRef.current = null; + }; + + const hasMatchingRenderedImage = (resolution: number) => { + return ( + renderedPdfRef.current === pdf && + renderedPageNumberRef.current === pageNumber && + renderedResolutionRef.current === resolution + ); + }; + + const setRenderedImageMeta = (resolution: number) => { + renderedPdfRef.current = pdf; + renderedPageNumberRef.current = pageNumber; + renderedResolutionRef.current = resolution; + }; + + const renderAtResolution = async (resolution: number) => { + let currentTask: pdfjsLib.RenderTask | null = null; + + try { + if (isCancelled) { + return; + } + + if (hasMatchingRenderedImage(resolution)) { + return; + } + + cancelRenderTask(); + + const page = await pdf.getPage(pageNumber); + + if (isCancelled) { + return; + } + + const renderScale = scale * resolution; + const viewport = page.getViewport({ scale: renderScale }); + const canvas = document.createElement('canvas'); + canvas.width = Math.floor(viewport.width); + canvas.height = Math.floor(viewport.height); + + const context = canvas.getContext('2d'); + + if (!context) { + throw new Error('Failed to get canvas context'); + } + + currentTask = page.render({ + canvasContext: context, + viewport, + canvas, + }); + renderTaskRef.current = currentTask; + + await currentTask.promise; + + if (isCancelled || renderTaskRef.current !== currentTask) { + return; + } + + setRenderedImageMeta(resolution); + + setImageUrl(canvas.toDataURL('image/jpeg')); + } catch (err) { + if (err instanceof Error && err.name === 'RenderingCancelledException') { + return; + } + + if (!isCancelled) { + console.error(err); + setImageLoadingState('error'); + } + } finally { + if (renderTaskRef.current === currentTask) { + renderTaskRef.current = null; + } + } + }; + + void renderAtResolution(LOW_RENDER_RESOLUTION); + + idleTimerRef.current = setTimeout(() => { + void renderAtResolution(HIGH_RENDER_RESOLUTION); + }, IDLE_RENDER_DELAY); + + return () => { + isCancelled = true; + + if (idleTimerRef.current) { + clearTimeout(idleTimerRef.current); + idleTimerRef.current = null; + } + + cancelRenderTask(); + }; + }, [pdf, pageNumber, scale]); + + const imageProps = useMemo( + (): React.ImgHTMLAttributes & Record & { alt: '' } => ({ + className: PDF_VIEWER_PAGE_CLASSNAME, + width: Math.floor(scaledWidth), + height: Math.floor(scaledHeight), + alt: '', + onLoad: () => setImageLoadingState('loaded'), + onError: () => setImageLoadingState('error'), + src: imageUrl, + 'data-page-number': pageNumber, + draggable: false, + }), + [scaledWidth, scaledHeight, imageUrl, pageNumber], + ); + + return { + imageProps, + imageLoadingState, + }; +}; diff --git a/apps/remix/app/components/general/pdf-viewer/use-scroll-to-page.ts b/apps/remix/app/components/general/pdf-viewer/use-scroll-to-page.ts new file mode 100644 index 000000000..54247a16a --- /dev/null +++ b/apps/remix/app/components/general/pdf-viewer/use-scroll-to-page.ts @@ -0,0 +1,46 @@ +import { type RefObject, useEffect } from 'react'; + +/** + * Watch for `data-scroll-to-page` attribute changes on a container element. + * + * When set (by `validateFieldsInserted`, `handleOnNextFieldClick`, or similar), + * scroll the virtual list to the requested page and clear the attribute. + * + * This is the communication bridge between field validation logic (which knows + * which page to scroll to) and the virtual list (which knows how to scroll). + */ +export const useScrollToPage = ( + contentRef: RefObject, + scrollToItem: (index: number) => void, +) => { + useEffect(() => { + const el = contentRef.current; + + if (!el) { + return; + } + + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'attributes' && mutation.attributeName === 'data-scroll-to-page') { + const raw = el.getAttribute('data-scroll-to-page'); + + if (raw) { + const pageNumber = parseInt(raw, 10); + + if (!isNaN(pageNumber) && pageNumber >= 1) { + // Pages are 1-indexed, virtual list items are 0-indexed. + scrollToItem(pageNumber - 1); + } + + el.removeAttribute('data-scroll-to-page'); + } + } + } + }); + + observer.observe(el, { attributes: true, attributeFilter: ['data-scroll-to-page'] }); + + return () => observer.disconnect(); + }, [contentRef, scrollToItem]); +}; diff --git a/apps/remix/app/components/general/template/template-edit-form.tsx b/apps/remix/app/components/general/template/template-edit-form.tsx index 254694117..c084f6357 100644 --- a/apps/remix/app/components/general/template/template-edit-form.tsx +++ b/apps/remix/app/components/general/template/template-edit-form.tsx @@ -13,12 +13,12 @@ import { } from '@documenso/lib/constants/trpc'; import { ZDocumentAccessAuthTypesSchema } from '@documenso/lib/types/document-auth'; import type { TTemplate } from '@documenso/lib/types/template'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { trpc } from '@documenso/trpc/react'; import { cn } from '@documenso/ui/lib/utils'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { DocumentFlowFormContainer } from '@documenso/ui/primitives/document-flow/document-flow-root'; import type { DocumentFlowStep } from '@documenso/ui/primitives/document-flow/types'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { Stepper } from '@documenso/ui/primitives/stepper'; import { AddTemplateFieldsFormPartial } from '@documenso/ui/primitives/template-flow/add-template-fields'; import type { TAddTemplateFieldsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-fields.types'; @@ -28,6 +28,7 @@ import { AddTemplateSettingsFormPartial } from '@documenso/ui/primitives/templat import type { TAddTemplateSettingsFormSchema } from '@documenso/ui/primitives/template-flow/add-template-settings.types'; import { useToast } from '@documenso/ui/primitives/use-toast'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { useCurrentTeam } from '~/providers/team'; export type TemplateEditFormProps = { @@ -312,11 +313,17 @@ export const TemplateEditForm = ({ gradient > - setIsDocumentPdfLoaded(true)} /> diff --git a/apps/remix/app/components/general/virtual-list/use-virtual-list.ts b/apps/remix/app/components/general/virtual-list/use-virtual-list.ts new file mode 100644 index 000000000..21db2e5c9 --- /dev/null +++ b/apps/remix/app/components/general/virtual-list/use-virtual-list.ts @@ -0,0 +1,355 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +export type ScrollTarget = React.RefObject | 'window'; + +export type VirtualListOptions = { + scrollRef: ScrollTarget; + constraintRef?: React.RefObject; + + /** + * Ref to the element that contains the virtual list content. + * + * Used to calculate the offset between the scroll container and the virtual + * list when the scroll container is a parent element higher in the DOM tree. + * + * When the virtual list is not at the top of the scroll container (e.g. there + * are headers, alerts, or other content above it), this offset ensures the + * scroll position is correctly adjusted for virtualization calculations. + */ + contentRef?: React.RefObject; + + itemCount: number; + itemSize: number | ((index: number, constraintWidth: number) => number); + overscan?: number; +}; + +export type VirtualItem = { + index: number; + start: number; + size: number; + key: string; +}; + +export type VirtualListResult = { + virtualItems: VirtualItem[]; + totalSize: number; + constraintWidth: number; + + /** + * Scroll the scroll container so that the item at the given index is visible. + * + * The scroll position is calculated from the precomputed item offsets and + * adjusted for any content offset (e.g. headers above the virtual list). + */ + scrollToItem: (index: number) => void; +}; + +/** + * A minimal list virtualizer hook that supports fixed item sizes and external scroll containers. + * + * @param options - Configuration options for the virtual list + * @returns Virtual items to render, total size, and constraint width + */ +export const useVirtualList = (options: VirtualListOptions): VirtualListResult => { + const { scrollRef, constraintRef, contentRef, itemCount, itemSize, overscan = 3 } = options; + + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + const [constraintWidth, setConstraintWidth] = useState(0); + + /** + * The offset of the content element relative to the scroll container. + * + * This is recalculated on scroll to handle cases where dynamic content + * above the virtual list changes size. + */ + const contentOffsetRef = useRef(0); + + // Track constraint element width with ResizeObserver + useEffect(() => { + const el = constraintRef?.current; + + if (!el) { + return; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + + if (entry) { + setConstraintWidth(entry.contentRect.width); + } + }); + + observer.observe(el); + + // Set initial width + setConstraintWidth(el.getBoundingClientRect().width); + + return () => observer.disconnect(); + }, [constraintRef]); + + // Track scroll container dimensions with ResizeObserver + useEffect(() => { + if (scrollRef === 'window') { + const handleResize = () => { + setViewportHeight(window.innerHeight); + }; + + window.addEventListener('resize', handleResize); + + // Set initial height + setViewportHeight(window.innerHeight); + + return () => window.removeEventListener('resize', handleResize); + } + + const el = scrollRef.current; + + if (!el) { + return; + } + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + + if (entry) { + setViewportHeight(entry.contentRect.height); + } + }); + + observer.observe(el); + + // Set initial height + setViewportHeight(el.getBoundingClientRect().height); + + return () => observer.disconnect(); + }, [scrollRef]); + + // Handle scroll events and calculate content offset + useEffect(() => { + if (scrollRef === 'window') { + const calculateOffset = () => { + const contentEl = contentRef?.current; + + if (!contentEl) { + contentOffsetRef.current = 0; + return; + } + + // For window scrolling, the offset is the distance from the top of the + // content element to the top of the document, which is its bounding rect + // top plus the current scroll position. + contentOffsetRef.current = contentEl.getBoundingClientRect().top + window.scrollY; + }; + + const handleScroll = () => { + calculateOffset(); + + const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current); + setScrollTop(adjustedScrollTop); + }; + + window.addEventListener('scroll', handleScroll, { passive: true }); + + // Set initial values + calculateOffset(); + const adjustedScrollTop = Math.max(0, window.scrollY - contentOffsetRef.current); + setScrollTop(adjustedScrollTop); + + return () => window.removeEventListener('scroll', handleScroll); + } + + const scrollEl = scrollRef.current; + + if (!scrollEl) { + return; + } + + const calculateOffset = () => { + const contentEl = contentRef?.current; + + if (!contentEl) { + contentOffsetRef.current = 0; + return; + } + + const scrollRect = scrollEl.getBoundingClientRect(); + const contentRect = contentEl.getBoundingClientRect(); + + // The offset is the distance from the top of the content element to + // the top of the scroll container, adjusted for current scroll position. + contentOffsetRef.current = contentRect.top - scrollRect.top + scrollEl.scrollTop; + }; + + const handleScroll = () => { + calculateOffset(); + + const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current); + setScrollTop(adjustedScrollTop); + }; + + scrollEl.addEventListener('scroll', handleScroll, { passive: true }); + + // Set initial values + calculateOffset(); + const adjustedScrollTop = Math.max(0, scrollEl.scrollTop - contentOffsetRef.current); + setScrollTop(adjustedScrollTop); + + return () => scrollEl.removeEventListener('scroll', handleScroll); + }, [scrollRef, contentRef]); + + // Get item size helper + const getItemSize = useCallback( + (index: number): number => { + if (typeof itemSize === 'function') { + return itemSize(index, constraintWidth); + } + + return itemSize; + }, + [itemSize, constraintWidth], + ); + + // Precompute item offsets for O(1) lookup + const { offsets, totalSize } = useMemo(() => { + const result: number[] = []; + let offset = 0; + + for (let i = 0; i < itemCount; i++) { + result.push(offset); + offset += getItemSize(i); + } + + return { offsets: result, totalSize: offset }; + }, [itemCount, getItemSize]); + + // Binary search to find the first visible item + const findStartIndex = useCallback( + (scrollTop: number): number => { + let low = 0; + let high = itemCount - 1; + + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const offset = offsets[mid]; + + if (offset < scrollTop) { + low = mid + 1; + } else { + high = mid - 1; + } + } + + return Math.max(0, low - 1); + }, + [offsets, itemCount], + ); + + // Calculate virtual items to render + const virtualItems = useMemo((): VirtualItem[] => { + if (itemCount === 0 || constraintWidth === 0) { + return []; + } + + const startIndex = findStartIndex(scrollTop); + const items: VirtualItem[] = []; + + // Apply overscan before visible area + const overscanStart = Math.max(0, startIndex - overscan); + + // Find items within the visible area + overscan + for (let i = overscanStart; i < itemCount; i++) { + const start = offsets[i]; + const size = getItemSize(i); + + // Stop if we've gone past the visible area + overscan + if (start > scrollTop + viewportHeight) { + // Add overscan items after visible area + const overscanEnd = Math.min(itemCount, i + overscan); + + for (let j = i; j < overscanEnd; j++) { + items.push({ + index: j, + start: offsets[j], + size: getItemSize(j), + key: `virtual-item-${j}`, + }); + } + + break; + } + + items.push({ + index: i, + start, + size, + key: `virtual-item-${i}`, + }); + } + + return items; + }, [ + itemCount, + constraintWidth, + scrollTop, + viewportHeight, + overscan, + offsets, + getItemSize, + findStartIndex, + ]); + + /** + * Imperatively scroll the scroll container so that the item at the given + * index is at the top of the viewport. + */ + const scrollToItem = useCallback( + (index: number) => { + if (index < 0 || index >= itemCount) { + return; + } + + const itemOffset = offsets[index] ?? 0; + + if (scrollRef === 'window') { + const contentEl = contentRef?.current; + const contentTop = contentEl ? contentEl.getBoundingClientRect().top + window.scrollY : 0; + + window.scrollTo({ + top: contentTop + itemOffset, + behavior: 'smooth', + }); + } else { + const scrollEl = scrollRef.current; + + if (!scrollEl) { + return; + } + + // Recalculate content offset to get the most up-to-date value. + const contentEl = contentRef?.current; + let contentOffset = 0; + + if (contentEl) { + const scrollRect = scrollEl.getBoundingClientRect(); + const contentRect = contentEl.getBoundingClientRect(); + contentOffset = contentRect.top - scrollRect.top + scrollEl.scrollTop; + } + + scrollEl.scrollTo({ + top: contentOffset + itemOffset, + behavior: 'smooth', + }); + } + }, + [scrollRef, contentRef, offsets, itemCount], + ); + + return { + virtualItems, + totalSize, + constraintWidth, + scrollToItem, + }; +}; diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx index fc9646811..cc28707ef 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id._index.tsx @@ -1,5 +1,3 @@ -import { lazy } from 'react'; - import { msg } from '@lingui/core/macro'; import { Plural, Trans, useLingui } from '@lingui/react/macro'; import { DocumentStatus } from '@prisma/client'; @@ -9,19 +7,19 @@ import { match } from 'ts-pattern'; import { EnvelopeRenderProvider } from '@documenso/lib/client-only/providers/envelope-render-provider'; import { useSession } from '@documenso/lib/client-only/providers/session'; +import { PDF_VIEWER_ERROR_MESSAGES } from '@documenso/lib/constants/pdf-viewer-i18n'; import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import { formatDocumentsPath } from '@documenso/lib/utils/teams'; import { trpc } from '@documenso/trpc/react'; import { DocumentReadOnlyFields, mapFieldsWithRecipients, } from '@documenso/ui/components/document/document-read-only-fields'; -import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy'; import { cn } from '@documenso/ui/lib/utils'; import { Badge } from '@documenso/ui/primitives/badge'; import { Button } from '@documenso/ui/primitives/button'; import { Card, CardContent } from '@documenso/ui/primitives/card'; -import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy'; import { Spinner } from '@documenso/ui/primitives/spinner'; import { DocumentPageViewButton } from '~/components/general/document/document-page-view-button'; @@ -35,16 +33,15 @@ import { FRIENDLY_STATUS_MAP, } from '~/components/general/document/document-status'; import { EnvelopeRendererFileSelector } from '~/components/general/envelope-editor/envelope-file-selector'; +import { EnvelopeGenericPageRenderer } from '~/components/general/envelope-editor/envelope-generic-page-renderer'; import { GenericErrorLayout } from '~/components/general/generic-error-layout'; +import { EnvelopePdfViewer } from '~/components/general/pdf-viewer/envelope-pdf-viewer'; +import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip'; import { useCurrentTeam } from '~/providers/team'; import type { Route } from './+types/documents.$id._index'; -const EnvelopeGenericPageRenderer = lazy( - async () => import('~/components/general/envelope-editor/envelope-generic-page-renderer'), -); - export default function DocumentPage({ params }: Route.ComponentProps) { const { t } = useLingui(); const { user } = useSession(); @@ -154,7 +151,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) { {envelope.internalVersion === 2 ? (
- @@ -193,11 +193,17 @@ export default function DocumentPage({ params }: Route.ComponentProps) { /> )} - diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx index c85cfb796..115d93bc8 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents.$id.edit.tsx @@ -58,7 +58,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) { if (envelope && (envelope.teamId !== team.id || envelope.internalVersion !== 2)) { return ( -
+
Redirecting
@@ -67,7 +67,7 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) { if (isLoadingEnvelope) { return ( -
+
Loading
@@ -99,7 +99,9 @@ export default function EnvelopeEditorPage({ params }: Route.ComponentProps) { return ( import('~/components/general/envelope-editor/envelope-generic-page-renderer'), -); - export default function TemplatePage({ params }: Route.ComponentProps) { const { t } = useLingui(); const { user } = useSession(); @@ -173,7 +170,9 @@ export default function TemplatePage({ params }: Route.ComponentProps) { {envelope.internalVersion === 2 ? (
- @@ -210,11 +210,17 @@ export default function TemplatePage({ params }: Route.ComponentProps) { documentMeta={mockedDocumentMeta} /> - diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index 1484e70e7..7045c1f30 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -198,7 +198,7 @@ const DirectSigningPageV1 = ({ data }: { data: Awaited -
+

@@ -246,7 +246,12 @@ const DirectSigningPageV2 = ({ data }: { data: Awaited - + diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx index c7ba1eaaa..204abfbb9 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx @@ -504,7 +504,12 @@ const SigningPageV2 = ({ data }: { data: Awaited - + diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx index e5791264e..01788540d 100644 --- a/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx +++ b/apps/remix/app/routes/embed+/_v0+/direct.$token.tsx @@ -320,7 +320,12 @@ const EmbedDirectTemplatePageV2 = ({ user={user} isDirectTemplate={true} > - + - + () /** @@ -319,3 +321,8 @@ export const filesRoute = new Hono() }); }, ); + +// PDF routes for both tokens and auth based +// Is different to the other file endpoints since it uses documentDataId for hard caching. +filesRoute.route('/', getEnvelopeItemPdfRoute); +filesRoute.route('/', getEnvelopeItemPdfByTokenRoute); diff --git a/apps/remix/server/api/files/routes/get-envelope-item-pdf-by-token.ts b/apps/remix/server/api/files/routes/get-envelope-item-pdf-by-token.ts new file mode 100644 index 000000000..81d74a860 --- /dev/null +++ b/apps/remix/server/api/files/routes/get-envelope-item-pdf-by-token.ts @@ -0,0 +1,81 @@ +import { sValidator } from '@hono/standard-validator'; +import type { Prisma } from '@prisma/client'; +import { Hono } from 'hono'; +import { z } from 'zod'; + +import { prisma } from '@documenso/prisma'; + +import type { HonoEnv } from '../../../router'; +import { handleEnvelopeItemPdfRequest } from './get-envelope-item-pdf'; + +const route = new Hono(); + +const ZGetEnvelopeItemByTokenParamsSchema = z.object({ + token: z.string().min(1), + envelopeId: z.string().min(1), + envelopeItemId: z.string().min(1), + documentDataId: z.string().min(1), + version: z.enum(['initial', 'current']), +}); + +/** + * Returns a PDF file for an envelope item using a token. + */ +route.get( + '/token/:token/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/item.pdf', + sValidator('param', ZGetEnvelopeItemByTokenParamsSchema), + async (c) => { + const { token, envelopeId, envelopeItemId, documentDataId, version } = c.req.valid('param'); + + if (!token) { + return c.json({ error: 'Not found' }, 404); + } + + // Recipient token based query. + let envelopeItemWhereQuery: Prisma.EnvelopeItemWhereInput = { + id: envelopeItemId, + documentDataId, + envelope: { + id: envelopeId, + recipients: { + some: { + token, + }, + }, + }, + }; + + // QR token based query. + if (token.startsWith('qr_')) { + envelopeItemWhereQuery = { + id: envelopeItemId, + documentDataId, + envelope: { + id: envelopeId, + qrToken: token, + }, + }; + } + + // Validate envelope access. + const envelopeItem = await prisma.envelopeItem.findFirst({ + where: envelopeItemWhereQuery, + include: { + documentData: true, + }, + }); + + if (!envelopeItem) { + return c.json({ error: 'Not found' }, 404); + } + + return await handleEnvelopeItemPdfRequest({ + c, + envelopeItem, + version, + cacheStrategy: 'private', + }); + }, +); + +export default route; diff --git a/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts b/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts new file mode 100644 index 000000000..587be164e --- /dev/null +++ b/apps/remix/server/api/files/routes/get-envelope-item-pdf.ts @@ -0,0 +1,145 @@ +import { sValidator } from '@hono/standard-validator'; +import type { DocumentData, EnvelopeItem } from '@prisma/client'; +import { type Context, Hono } from 'hono'; +import { z } from 'zod'; + +import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; +import { verifyEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/verify-embedding-presign-token'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; +import type { DocumentDataVersion } from '@documenso/lib/types/document'; +import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server'; +import { prisma } from '@documenso/prisma'; + +import type { HonoEnv } from '../../../router'; + +const route = new Hono(); + +const ZGetEnvelopeItemPdfRequestParamsSchema = z.object({ + envelopeId: z.string().min(1), + envelopeItemId: z.string().min(1), + documentDataId: z.string().min(1), + version: z.enum(['initial', 'current']), +}); + +const ZGetEnvelopeItemPdfRequestQuerySchema = z.object({ + presignToken: z.string().optional(), +}); + +/** + * Returns a PDF file for an envelope item. + */ +route.get( + '/envelope/:envelopeId/envelopeItem/:envelopeItemId/dataId/:documentDataId/:version/item.pdf', + sValidator('param', ZGetEnvelopeItemPdfRequestParamsSchema), + sValidator('query', ZGetEnvelopeItemPdfRequestQuerySchema), + async (c) => { + const { envelopeId, envelopeItemId, documentDataId, version } = c.req.valid('param'); + + const { presignToken } = c.req.valid('query'); + + const session = await getOptionalSession(c); + + let userId = session.user?.id; + + // Check presignToken if provided + if (presignToken) { + const verifiedToken = await verifyEmbeddingPresignToken({ + token: presignToken, + }).catch(() => undefined); + + userId = verifiedToken?.userId; + } + + if (!userId) { + return c.json({ error: 'Not found' }, 404); + } + + // Note: We authenticate whether the user can access this in the `getTeamById` below. + const envelopeItem = await prisma.envelopeItem.findFirst({ + where: { + id: envelopeItemId, + envelopeId, + documentDataId, + }, + include: { + documentData: true, + envelope: { + select: { + id: true, + teamId: true, + }, + }, + }, + }); + + if (!envelopeItem) { + return c.json({ error: 'Not found' }, 404); + } + + // Check whether the user has access to the document. + const team = await getTeamById({ + userId, + teamId: envelopeItem.envelope.teamId, + }).catch(() => null); + + if (!team) { + return c.json({ error: 'Not found' }, 404); + } + + return await handleEnvelopeItemPdfRequest({ + c, + envelopeItem, + version, + cacheStrategy: 'private', + }); + }, +); + +type HandleEnvelopeItemPdfRequestOptions = { + c: Context; + envelopeItem: EnvelopeItem & { + documentData: DocumentData; + }; + version: DocumentDataVersion; + + /** + * The type of cache strategy to use. + * + * For access via tokens, we can use a public cache to allow the CDN to cache it. + * + * For access via session, we must use a private cache. + */ + cacheStrategy: 'private' | 'public'; +}; + +export const handleEnvelopeItemPdfRequest = async ({ + c, + envelopeItem, + version, + cacheStrategy, +}: HandleEnvelopeItemPdfRequestOptions) => { + // Determine which PDF data to use based on version requested. + const documentDataToUse = + version === 'current' ? envelopeItem.documentData.data : envelopeItem.documentData.initialData; + + const file = await getFileServerSide({ + type: envelopeItem.documentData.type, + data: documentDataToUse, + }).catch((error) => { + console.error(error); + + return null; + }); + + if (!file) { + return c.json({ error: 'Not found' }, 404); + } + + // Note: Only set these headers on success. + c.header('Content-Type', 'application/pdf'); + c.header('Cache-Control', `${cacheStrategy}, max-age=31536000, immutable`); + + return c.body(file); +}; + +export default route; diff --git a/package-lock.json b/package-lock.json index c90398d0f..ca1f32b63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27753,24 +27753,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/make-cancellable-promise": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", - "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", - "license": "MIT", - "funding": { - "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" - } - }, - "node_modules/make-event-props": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", - "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", - "license": "MIT", - "funding": { - "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" - } - }, "node_modules/map-stream": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/map-stream/-/map-stream-0.1.0.tgz", @@ -28155,23 +28137,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-refs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", - "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", - "license": "MIT", - "funding": { - "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -32012,44 +31977,6 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/react-pdf": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.3.0.tgz", - "integrity": "sha512-2LQzC9IgNVAX8gM+6F+1t/70a9/5RWThYxc+CWAmT2LW/BRmnj+35x1os5j/nR2oldyf8L+hCAMBmVKU8wrYFA==", - "license": "MIT", - "dependencies": { - "clsx": "^2.0.0", - "dequal": "^2.0.3", - "make-cancellable-promise": "^2.0.0", - "make-event-props": "^2.0.0", - "merge-refs": "^2.0.0", - "pdfjs-dist": "5.4.296", - "tiny-invariant": "^1.0.0", - "warning": "^4.0.0" - }, - "funding": { - "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" - }, - "peerDependencies": { - "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/react-pdf/node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/react-promise-suspense": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz", @@ -36264,15 +36191,6 @@ "node": ">=20.0.0" } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -37189,7 +37107,6 @@ "posthog-js": "^1.297.2", "posthog-node": "4.18.0", "react": "^18", - "react-pdf": "^10.3.0", "remeda": "^2.32.0", "sharp": "0.34.5", "skia-canvas": "^3.0.8", @@ -37347,7 +37264,6 @@ "react-day-picker": "^8.10.1", "react-dom": "^18", "react-hook-form": "^7.66.1", - "react-pdf": "^10.3.0", "react-rnd": "^10.5.2", "remeda": "^2.32.0", "tailwind-merge": "^1.14.0", diff --git a/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts index db33897b3..96aa46142 100644 --- a/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/autosave-fields-step.spec.ts @@ -1,6 +1,7 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { prisma } from '@documenso/prisma'; import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; @@ -46,7 +47,7 @@ test.describe('AutoSave Fields Step', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -54,7 +55,7 @@ test.describe('AutoSave Fields Step', () => { }); await page.getByRole('button', { name: 'Text' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -74,7 +75,7 @@ test.describe('AutoSave Fields Step', () => { await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 500, @@ -100,7 +101,7 @@ test.describe('AutoSave Fields Step', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -108,7 +109,7 @@ test.describe('AutoSave Fields Step', () => { }); await page.getByRole('button', { name: 'Text' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -128,7 +129,7 @@ test.describe('AutoSave Fields Step', () => { await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 500, @@ -162,7 +163,7 @@ test.describe('AutoSave Fields Step', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -170,7 +171,7 @@ test.describe('AutoSave Fields Step', () => { }); await page.getByRole('button', { name: 'Text' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -190,7 +191,7 @@ test.describe('AutoSave Fields Step', () => { await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 500, @@ -224,7 +225,7 @@ test.describe('AutoSave Fields Step', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -232,7 +233,7 @@ test.describe('AutoSave Fields Step', () => { }); await page.getByRole('button', { name: 'Text' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, diff --git a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts index 404a03ae1..074c49626 100644 --- a/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts +++ b/packages/app-tests/e2e/document-flow/autosave-subject-step.spec.ts @@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; import { EnvelopeType } from '@prisma/client'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getEnvelopeById } from '@documenso/lib/server-only/envelope/get-envelope-by-id'; import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; @@ -28,7 +29,7 @@ export const setupDocumentAndNavigateToSubjectStep = async (page: Page) => { await page.getByRole('button', { name: 'Continue' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -108,7 +109,9 @@ test.describe('AutoSave Subject Step', () => { // Toggle some email settings checkboxes (randomly - some checked, some unchecked) await page.getByText('Email the owner when a recipient signs').click(); await page.getByText("Email recipients when they're removed from a pending document").click(); - await page.getByText('Email recipients when the document is completed', { exact: true }).click(); + await page + .getByText('Email recipients when the document is completed', { exact: true }) + .click(); await page.getByText('Email recipients when a pending document is deleted').click(); await triggerAutosave(page); @@ -139,16 +142,20 @@ test.describe('AutoSave Subject Step', () => { ).toBeChecked({ checked: emailSettings?.documentCompleted, }); - await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({ + await expect( + page.getByText('Email recipients when a pending document is deleted'), + ).toBeChecked({ checked: emailSettings?.documentDeleted, }); await expect(page.getByText('Email recipients with a signing request')).toBeChecked({ checked: emailSettings?.recipientSigningRequest, }); - await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({ - checked: emailSettings?.documentPending, - }); + await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked( + { + checked: emailSettings?.documentPending, + }, + ); await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({ checked: emailSettings?.ownerDocumentCompleted, }); @@ -167,7 +174,9 @@ test.describe('AutoSave Subject Step', () => { await page.getByText('Email the owner when a recipient signs').click(); await page.getByText("Email recipients when they're removed from a pending document").click(); - await page.getByText('Email recipients when the document is completed', { exact: true }).click(); + await page + .getByText('Email recipients when the document is completed', { exact: true }) + .click(); await page.getByText('Email recipients when a pending document is deleted').click(); await triggerAutosave(page); @@ -207,16 +216,20 @@ test.describe('AutoSave Subject Step', () => { ).toBeChecked({ checked: retrievedDocumentData.documentMeta?.emailSettings?.documentCompleted, }); - await expect(page.getByText('Email recipients when a pending document is deleted')).toBeChecked({ + await expect( + page.getByText('Email recipients when a pending document is deleted'), + ).toBeChecked({ checked: retrievedDocumentData.documentMeta?.emailSettings?.documentDeleted, }); await expect(page.getByText('Email recipients with a signing request')).toBeChecked({ checked: retrievedDocumentData.documentMeta?.emailSettings?.recipientSigningRequest, }); - await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked({ - checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending, - }); + await expect(page.getByText('Email the signer if the document is still pending')).toBeChecked( + { + checked: retrievedDocumentData.documentMeta?.emailSettings?.documentPending, + }, + ); await expect(page.getByText('Email the owner when the document is completed')).toBeChecked({ checked: retrievedDocumentData.documentMeta?.emailSettings?.ownerDocumentCompleted, }); diff --git a/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts b/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts index ea0bfd43e..135a7aee8 100644 --- a/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts +++ b/packages/app-tests/e2e/document-flow/duplicate-recipients-simple.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { seedBlankDocument } from '@documenso/prisma/seed/documents'; import { seedUser } from '@documenso/prisma/seed/users'; @@ -33,14 +34,14 @@ test('[DOCUMENT_FLOW]: Simple duplicate recipients test', async ({ page }) => { // Step 3: Add fields await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } }); await page.getByRole('combobox').first().click(); // Switch to second duplicate and add field await page.getByText('Duplicate 2 (duplicate@example.com)').first().click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } }); // Continue to send await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts b/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts index 6eb2bb370..bb0e3b2d8 100644 --- a/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts +++ b/packages/app-tests/e2e/document-flow/duplicate-recipients.spec.ts @@ -44,21 +44,21 @@ const completeDocumentFlowWithDuplicateRecipients = async (options: { // Step 3: Add fields for each recipient // Add signature field for first duplicate recipient await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } }); await page.getByText('Duplicate Recipient 1 (duplicate@example.com)').click(); // Switch to second duplicate recipient and add their field await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } }); await page.getByText('Duplicate Recipient 2 (duplicate@example.com)').click(); // Switch to unique recipient and add their field await page.getByText('Unique Recipient (unique@example.com)').click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 300, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } }); // Continue to subject await page.getByRole('button', { name: 'Continue' }).click(); @@ -122,7 +122,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } }); // Save the document by going to subject await page.getByRole('button', { name: 'Continue' }).click(); @@ -149,7 +149,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => { await page.getByText('Test Recipient Duplicate (test@example.com)').first().click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } }); // Complete the flow await page.getByRole('button', { name: 'Continue' }).click(); @@ -270,24 +270,24 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => { // Add signature for first recipient await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } }); // Add name field for second recipient await page.getByRole('combobox').first().click(); await page.getByText('Approver Role (signer@example.com)').first().click(); await page.getByRole('button', { name: 'Name' }).click(); - await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } }); // Add date field for second recipient await page.getByRole('button', { name: 'Date' }).click(); - await page.locator('canvas').click({ position: { x: 200, y: 150 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 150 } }); // If second recipient is still a SIGNER (role change wasn't available), // add a signature field for them to pass validation if (!secondRecipientIsApprover) { await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 200, y: 200 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 200 } }); } // Complete the document @@ -349,7 +349,7 @@ test.describe('[DOCUMENT_FLOW]: Duplicate Recipients', () => { // Add another field to the second duplicate await page.getByRole('button', { name: 'Name' }).click(); - await page.locator('canvas').click({ position: { x: 250, y: 150 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 250, y: 150 } }); // Save changes await page.getByRole('button', { name: 'Continue' }).click(); diff --git a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts index 4220731ad..3f43718dc 100644 --- a/packages/app-tests/e2e/document-flow/stepper-component.spec.ts +++ b/packages/app-tests/e2e/document-flow/stepper-component.spec.ts @@ -9,6 +9,7 @@ import { import { DateTime } from 'luxon'; import path from 'node:path'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { prisma } from '@documenso/prisma'; import { seedBlankDocument, @@ -92,7 +93,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -100,7 +101,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document', async ({ page }) => }); await page.getByRole('button', { name: 'Email' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -158,7 +159,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -166,7 +167,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie }); await page.getByRole('button', { name: 'Email' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -177,7 +178,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await page.getByText('User 2 (user2@example.com)').click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 500, y: 100, @@ -185,7 +186,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie }); await page.getByRole('button', { name: 'Email' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 500, y: 200, @@ -256,7 +257,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await page.getByRole('option', { name: 'User 1 (user1@example.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -264,7 +265,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie }); await page.getByRole('button', { name: 'Email' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -275,7 +276,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie await page.getByRole('option', { name: 'User 3 (user3@example.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 500, y: 100, @@ -283,7 +284,7 @@ test('[DOCUMENT_FLOW]: should be able to create a document with multiple recipie }); await page.getByRole('button', { name: 'Email' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 500, y: 200, @@ -576,7 +577,7 @@ test('[DOCUMENT_FLOW]: should be able to create and sign a document with 3 recip } await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 * i, diff --git a/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts b/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts new file mode 100644 index 000000000..a5c12854d --- /dev/null +++ b/packages/app-tests/e2e/pdf-viewer/pdf-viewer.spec.ts @@ -0,0 +1,416 @@ +import { expect, test } from '@playwright/test'; +import { FieldType } from '@prisma/client'; +import path from 'node:path'; + +import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; +import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token'; +import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token'; +import { prefixedId } from '@documenso/lib/universal/id'; +import { + mapSecondaryIdToDocumentId, + mapSecondaryIdToTemplateId, +} from '@documenso/lib/utils/envelope'; +import { formatDirectTemplatePath } from '@documenso/lib/utils/templates'; +import { prisma } from '@documenso/prisma'; +import { + seedBlankDocument, + seedCompletedDocument, + seedPendingDocumentWithFullFields, +} from '@documenso/prisma/seed/documents'; +import { seedBlankTemplate, seedDirectTemplate } from '@documenso/prisma/seed/templates'; +import { seedUser } from '@documenso/prisma/seed/users'; + +import { apiSignin } from '../fixtures/authentication'; + +const PDF_PAGE_SELECTOR = 'img[data-page-number]'; + +async function addSecondEnvelopeItem(envelopeId: string) { + const firstItem = await prisma.envelopeItem.findFirstOrThrow({ + where: { envelopeId }, + orderBy: { order: 'asc' }, + include: { documentData: true }, + }); + + const newDocumentData = await prisma.documentData.create({ + data: { + type: firstItem.documentData.type, + data: firstItem.documentData.data, + initialData: firstItem.documentData.initialData, + }, + }); + + await prisma.envelopeItem.create({ + data: { + id: prefixedId('envelope_item'), + title: `${firstItem.title} - Page 2`, + documentDataId: newDocumentData.id, + order: 2, + envelopeId, + }, + }); +} + +test.describe('PDF Viewer Rendering', () => { + test.describe('Authenticated Pages', () => { + test('should render PDF on all authenticated pages (V1 and V2)', async ({ page }) => { + const { user, team } = await seedUser(); + + const documentV1 = await seedBlankDocument(user, team.id); + const documentV2 = await seedBlankDocument(user, team.id, { internalVersion: 2 }); + await addSecondEnvelopeItem(documentV2.id); + + const templateV1 = await seedBlankTemplate(user, team.id); + const templateV2 = await seedBlankTemplate(user, team.id, { + createTemplateOptions: { internalVersion: 2 }, + }); + await addSecondEnvelopeItem(templateV2.id); + + await apiSignin({ + page, + email: user.email, + redirectPath: `/t/${team.url}/documents/${documentV1.id}`, + }); + + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/t/${team.url}/documents/${documentV2.id}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /Page 2/ }).click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/t/${team.url}/templates/${templateV1.id}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/t/${team.url}/templates/${templateV2.id}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /Page 2/ }).click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/t/${team.url}/documents/${documentV1.id}/edit`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/t/${team.url}/documents/${documentV2.id}/edit?step=addFields`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /Page 2/ }).click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/t/${team.url}/templates/${templateV1.id}/edit`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/t/${team.url}/templates/${templateV2.id}/edit?step=addFields`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /Page 2/ }).click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/t/${team.url}/documents/${documentV1.id}`); + await page.locator(PDF_PAGE_SELECTOR).first().waitFor({ state: 'visible', timeout: 30_000 }); + }); + }); + + test.describe('Recipient Signing', () => { + test('should render PDF on signing page (V1 and V2)', async ({ page }) => { + const { user, team } = await seedUser(); + + const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: ['signer-v1@test.documenso.com'], + fields: [FieldType.SIGNATURE], + }); + + const { document: documentV2, recipients: recipientsV2 } = + await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: ['signer-v2@test.documenso.com'], + fields: [FieldType.SIGNATURE], + updateDocumentOptions: { internalVersion: 2 }, + }); + await addSecondEnvelopeItem(documentV2.id); + + await page.goto(`/sign/${recipientsV1[0].token}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/sign/${recipientsV2[0].token}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /Page 2/ }).click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + }); + + test.describe('Direct Template', () => { + test('should render PDF on direct template page (V1 and V2)', async ({ page }) => { + const { user, team } = await seedUser(); + + const templateV1 = await seedDirectTemplate({ + title: 'PDF Viewer Test Template V1', + userId: user.id, + teamId: team.id, + }); + + const templateV2 = await seedDirectTemplate({ + title: 'PDF Viewer Test Template V2', + userId: user.id, + teamId: team.id, + internalVersion: 2, + }); + await addSecondEnvelopeItem(templateV2.id); + + await page.goto(formatDirectTemplatePath(templateV1.directLink?.token || '')); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(formatDirectTemplatePath(templateV2.directLink?.token || '')); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /Page 2/ }).click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + }); + + test.describe('Share Page', () => { + test('should render PDF on share page (V1 and V2)', async ({ page }) => { + const { user, team } = await seedUser(); + + const qrTokenV1 = prefixedId('qr'); + const qrTokenV2 = prefixedId('qr'); + + const documentV1 = await seedCompletedDocument( + user, + team.id, + ['share-v1@test.documenso.com'], + { + createDocumentOptions: { qrToken: qrTokenV1 }, + }, + ); + + const documentV2 = await seedCompletedDocument( + user, + team.id, + ['share-v2@test.documenso.com'], + { + createDocumentOptions: { qrToken: qrTokenV2 }, + internalVersion: 2, + }, + ); + await addSecondEnvelopeItem(documentV2.id); + + await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV1}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/share/${qrTokenV2}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.getByRole('button', { name: /Page 2/ }).click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + }); + + test.describe('Embed Pages', () => { + test('should render PDF on embed sign page (V1 and V2)', async ({ page }) => { + const { user, team } = await seedUser(); + + const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: ['embed-signer-v1@test.documenso.com'], + fields: [FieldType.SIGNATURE], + }); + + const { document: documentV2, recipients: recipientsV2 } = + await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: ['embed-signer-v2@test.documenso.com'], + fields: [FieldType.SIGNATURE], + updateDocumentOptions: { internalVersion: 2 }, + }); + await addSecondEnvelopeItem(documentV2.id); + + await page.goto(`/embed/sign/${recipientsV1[0].token}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/embed/sign/${recipientsV2[0].token}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + // Todo: Multisign does not support multiple envelope items. + // await page.getByRole('button', { name: /Page 2/ }).click(); + // await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('should render PDF on embed direct template page (V1 and V2)', async ({ page }) => { + const { user, team } = await seedUser(); + + const templateV1 = await seedDirectTemplate({ + title: 'Embed Direct Template V1', + userId: user.id, + teamId: team.id, + }); + + const templateV2 = await seedDirectTemplate({ + title: 'Embed Direct Template V2', + userId: user.id, + teamId: team.id, + internalVersion: 2, + }); + await addSecondEnvelopeItem(templateV2.id); + + await page.goto(`/embed/direct/${templateV1.directLink?.token || ''}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/embed/direct/${templateV2.directLink?.token || ''}`); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: /Page 2/ }).click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('should render PDF on embed multisign page (V1 and V2)', async ({ page }) => { + const { user, team } = await seedUser(); + + const { recipients: recipientsV1 } = await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: ['multisign-v1@test.documenso.com'], + fields: [FieldType.SIGNATURE], + }); + + const { document: documentV2, recipients: recipientsV2 } = + await seedPendingDocumentWithFullFields({ + owner: user, + teamId: team.id, + recipients: ['multisign-v2@test.documenso.com'], + fields: [FieldType.SIGNATURE], + updateDocumentOptions: { internalVersion: 2 }, + }); + await addSecondEnvelopeItem(documentV2.id); + + await page.goto(`/embed/v1/multisign?token=${recipientsV1[0].token}`); + await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 }); + + // Todo: Multisign does not support multiple envelope items. + // await page.getByRole('button', { name: /View/i }).first().click(); + // await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + await page.goto(`/embed/v1/multisign?token=${recipientsV2[0].token}`); + await expect(page.getByText('Sign Documents')).toBeVisible({ timeout: 15_000 }); + await page.getByRole('button', { name: /View/i }).first().click(); + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + + // Todo: Multisign does not support multiple envelope items. + // await page.getByRole('button', { name: /Page 2/ }).click(); + // await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('should render PDF on embed authoring document create page', async ({ page }) => { + const { user, team } = await seedUser(); + + const { token: apiToken } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'pdf-viewer-test', + expiresIn: null, + }); + + const { token: presignToken } = await createEmbeddingPresignToken({ + apiToken, + }); + + const embedParams = { darkModeDisabled: false, features: {} }; + const hash = btoa(encodeURIComponent(JSON.stringify(embedParams))); + + await page.goto( + `${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/create?token=${presignToken}#${hash}`, + ); + + await expect(page.getByText('Configure Document')).toBeVisible({ timeout: 15_000 }); + + const titleInput = page.getByLabel('Title'); + await titleInput.click(); + await titleInput.fill('PDF Viewer E2E Test'); + + const emailInput = page.getByPlaceholder('Email').first(); + await emailInput.click(); + await emailInput.fill('test-signer@documenso.com'); + + const [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser'), + page + .locator('input[type=file]') + .first() + .evaluate((el) => { + if (el instanceof HTMLInputElement) { + el.click(); + } + }), + ]); + + await fileChooser.setFiles(path.join(__dirname, '../../../../assets/example.pdf')); + + await page.getByRole('button', { name: 'Continue' }).click(); + + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('should render PDF on embed document edit page', async ({ page }) => { + const { user, team } = await seedUser(); + + const document = await seedBlankDocument(user, team.id); + const documentId = mapSecondaryIdToDocumentId(document.secondaryId); + + const { token: apiToken } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'pdf-viewer-doc-edit-test', + expiresIn: null, + }); + + const { token: presignToken } = await createEmbeddingPresignToken({ + apiToken, + scope: `documentId:${documentId}`, + }); + + const embedParams = { + darkModeDisabled: false, + features: {}, + onlyEditFields: true, + }; + const hash = btoa(encodeURIComponent(JSON.stringify(embedParams))); + + await page.goto( + `${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/document/edit/${documentId}?token=${presignToken}#${hash}`, + ); + + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + + test('should render PDF on embed template edit page', async ({ page }) => { + const { user, team } = await seedUser(); + + const template = await seedBlankTemplate(user, team.id); + const templateId = mapSecondaryIdToTemplateId(template.secondaryId); + + const { token: apiToken } = await createApiToken({ + userId: user.id, + teamId: team.id, + tokenName: 'pdf-viewer-template-edit-test', + expiresIn: null, + }); + + const { token: presignToken } = await createEmbeddingPresignToken({ + apiToken, + scope: `templateId:${templateId}`, + }); + + const embedParams = { + darkModeDisabled: false, + features: {}, + onlyEditFields: true, + }; + const hash = btoa(encodeURIComponent(JSON.stringify(embedParams))); + + await page.goto( + `${NEXT_PUBLIC_WEBAPP_URL()}/embed/v1/authoring/template/edit/${templateId}?token=${presignToken}#${hash}`, + ); + + await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 }); + }); + }); +}); diff --git a/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts b/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts index 8982aef52..280478246 100644 --- a/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts +++ b/packages/app-tests/e2e/templates-flow/duplicate-recipients.spec.ts @@ -42,21 +42,21 @@ const completeTemplateFlowWithDuplicateRecipients = async (options: { // Step 3: Add fields for each recipient instance // Add signature field for first instance await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } }); // Switch to second instance and add their field await page.getByRole('combobox').first().click(); await page.getByText('Second Instance').first().click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } }); // Switch to different recipient and add their fields await page.getByRole('combobox').first().click(); await page.getByText('Different Recipient').first().click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 300, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 100 } }); await page.getByRole('button', { name: 'Name' }).click(); - await page.locator('canvas').click({ position: { x: 300, y: 150 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 300, y: 150 } }); // Save template await page.getByRole('button', { name: 'Save Template' }).click(); @@ -209,17 +209,17 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => { // Add fields for each recipient await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ position: { x: 100, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100 } }); await page.getByRole('combobox').first().click(); await page.getByText('Duplicate Recipient 2').first().click(); await page.getByRole('button', { name: 'Date' }).click(); - await page.locator('canvas').click({ position: { x: 200, y: 100 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 200, y: 100 } }); await page.getByRole('combobox').first().click(); await page.getByText('Different Recipient').first().click(); await page.getByRole('button', { name: 'Name' }).click(); - await page.locator('canvas').click({ position: { x: 100, y: 200 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200 } }); // Save template await page.getByRole('button', { name: 'Save Template' }).click(); @@ -272,7 +272,7 @@ test.describe('[TEMPLATE_FLOW]: Duplicate Recipients', () => { await page.getByRole('combobox').first().click(); await page.getByRole('option', { name: 'First Instance' }).first().click(); await page.getByRole('button', { name: 'Name' }).click(); - await page.locator('canvas').click({ position: { x: 100, y: 300 } }); + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 300 } }); await page.waitForTimeout(2500); diff --git a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts index c7d1b5583..8a8b88ad5 100644 --- a/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts +++ b/packages/app-tests/e2e/templates-flow/template-autosave-fields-step.spec.ts @@ -1,6 +1,7 @@ import type { Page } from '@playwright/test'; import { expect, test } from '@playwright/test'; +import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { getTemplateById } from '@documenso/lib/server-only/template/get-template-by-id'; import { mapSecondaryIdToTemplateId } from '@documenso/lib/utils/envelope'; import { seedBlankTemplate } from '@documenso/prisma/seed/templates'; @@ -47,7 +48,7 @@ test.describe('AutoSave Fields Step', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -55,7 +56,7 @@ test.describe('AutoSave Fields Step', () => { }); await page.getByRole('button', { name: 'Text' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -75,7 +76,7 @@ test.describe('AutoSave Fields Step', () => { await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 500, @@ -110,7 +111,7 @@ test.describe('AutoSave Fields Step', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -118,7 +119,7 @@ test.describe('AutoSave Fields Step', () => { }); await page.getByRole('button', { name: 'Text' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -138,7 +139,7 @@ test.describe('AutoSave Fields Step', () => { await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 500, @@ -179,7 +180,7 @@ test.describe('AutoSave Fields Step', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -187,7 +188,7 @@ test.describe('AutoSave Fields Step', () => { }); await page.getByRole('button', { name: 'Text' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, @@ -207,7 +208,7 @@ test.describe('AutoSave Fields Step', () => { await page.getByRole('option', { name: 'Recipient 2 (recipient2@documenso.com)' }).click(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 500, @@ -250,7 +251,7 @@ test.describe('AutoSave Fields Step', () => { await expect(page.getByRole('heading', { name: 'Add Fields' })).toBeVisible(); await page.getByRole('button', { name: 'Signature' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 100, @@ -258,7 +259,7 @@ test.describe('AutoSave Fields Step', () => { }); await page.getByRole('button', { name: 'Text' }).click(); - await page.locator('canvas').click({ + await page.locator(PDF_VIEWER_PAGE_SELECTOR).click({ position: { x: 100, y: 200, diff --git a/packages/lib/client-only/get-bounding-client-rect.ts b/packages/lib/client-only/get-bounding-client-rect.ts index 5d8d3e02f..495a49a9c 100644 --- a/packages/lib/client-only/get-bounding-client-rect.ts +++ b/packages/lib/client-only/get-bounding-client-rect.ts @@ -1,4 +1,4 @@ -export const getBoundingClientRect = (element: HTMLElement) => { +export const getBoundingClientRect = (element: HTMLElement | Element) => { const rect = element.getBoundingClientRect(); const { width, height } = rect; diff --git a/packages/lib/client-only/hooks/use-document-element.ts b/packages/lib/client-only/hooks/use-document-element.ts index 6366a7eee..3ee870da3 100644 --- a/packages/lib/client-only/hooks/use-document-element.ts +++ b/packages/lib/client-only/hooks/use-document-element.ts @@ -14,7 +14,10 @@ export const useDocumentElement = () => { const target = event.target; const $page = - target.closest(pageSelector) ?? target.querySelector(pageSelector); + target.closest(pageSelector) ?? + document + .elementsFromPoint(event.clientX, event.clientY) + .find((el) => el.matches(pageSelector)); if (!$page) { return null; diff --git a/packages/lib/client-only/hooks/use-editor-fields.ts b/packages/lib/client-only/hooks/use-editor-fields.ts index 6b0b37776..1f6ab98df 100644 --- a/packages/lib/client-only/hooks/use-editor-fields.ts +++ b/packages/lib/client-only/hooks/use-editor-fields.ts @@ -6,6 +6,7 @@ import { FieldType } from '@prisma/client'; import { useFieldArray, useForm } from 'react-hook-form'; import { z } from 'zod'; +import { getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer'; import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { nanoid } from '@documenso/lib/universal/id'; @@ -222,14 +223,16 @@ export const useEditorFields = ({ const duplicateFieldToAllPages = useCallback( (field: TLocalField): TLocalField[] => { - const pages = Array.from(document.querySelectorAll('[data-page-number]')); + const totalPages = getPdfPagesCount(); const newFields: TLocalField[] = []; - pages.forEach((_, index) => { - const pageNumber = index + 1; + if (totalPages < 1) { + return newFields; + } + for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) { if (pageNumber === field.page) { - return; + continue; } const newField: TLocalField = { @@ -241,7 +244,7 @@ export const useEditorFields = ({ append(newField); newFields.push(newField); - }); + } triggerFieldsUpdate(); return newFields; diff --git a/packages/lib/client-only/hooks/use-element-bounds.ts b/packages/lib/client-only/hooks/use-element-bounds.ts index 560accca6..53724697d 100644 --- a/packages/lib/client-only/hooks/use-element-bounds.ts +++ b/packages/lib/client-only/hooks/use-element-bounds.ts @@ -17,7 +17,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc : elementOrSelector; if (!$el) { - throw new Error('Element not found'); + return { top: 0, left: 0, width: 0, height: 0 }; } if (withScroll) { @@ -36,7 +36,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc useEffect(() => { setBounds(calculateBounds()); - }, []); + }, [calculateBounds]); useEffect(() => { const onResize = () => { @@ -48,7 +48,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc return () => { window.removeEventListener('resize', onResize); }; - }, []); + }, [calculateBounds]); useEffect(() => { const $el = @@ -69,7 +69,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc return () => { observer.disconnect(); }; - }, []); + }, [elementOrSelector, calculateBounds]); return bounds; }; diff --git a/packages/lib/client-only/hooks/use-field-page-coords.ts b/packages/lib/client-only/hooks/use-field-page-coords.ts index e212e7328..9d908ad9c 100644 --- a/packages/lib/client-only/hooks/use-field-page-coords.ts +++ b/packages/lib/client-only/hooks/use-field-page-coords.ts @@ -3,7 +3,10 @@ import { useCallback, useEffect, useState } from 'react'; import type { Field } from '@prisma/client'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { + PDF_VIEWER_CONTENT_SELECTOR, + PDF_VIEWER_PAGE_SELECTOR, +} from '@documenso/lib/constants/pdf-viewer'; export const useFieldPageCoords = ( field: Pick, @@ -57,23 +60,65 @@ export const useFieldPageCoords = ( }; }, [calculateCoords]); + // Watch for the page element to appear in the DOM (e.g. after a virtual list + // scroll) and recalculate coords. Also attach a ResizeObserver once the page + // element exists. useEffect(() => { - const $page = document.querySelector( - `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`, - ); + const pageSelector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`; - if (!$page) { - return; + let resizeObserver: ResizeObserver | null = null; + let observedElement: HTMLElement | null = null; + + const attachResizeObserver = ($page: HTMLElement) => { + if ($page === observedElement) { + return; + } + + resizeObserver?.disconnect(); + resizeObserver = new ResizeObserver(() => { + calculateCoords(); + }); + resizeObserver.observe($page); + observedElement = $page; + }; + + // Try to attach immediately if the page already exists. + const existingPage = document.querySelector(pageSelector); + + if (existingPage) { + attachResizeObserver(existingPage); } - const observer = new ResizeObserver(() => { + // Watch for DOM mutations to detect when the page element appears (e.g. + // after the virtual list scrolls to a new page and renders it). + // Scope to the PDF viewer content container to avoid firing on unrelated + // DOM changes elsewhere in the document. + const mutationObserver = new MutationObserver(() => { + const $page = document.querySelector(pageSelector); + + if (!$page) { + return; + } + + if ($page === observedElement) { + return; + } + calculateCoords(); + attachResizeObserver($page); }); - observer.observe($page); + const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body; + + mutationObserver.observe($container, { + childList: true, + subtree: true, + }); return () => { - observer.disconnect(); + mutationObserver.disconnect(); + resizeObserver?.disconnect(); + observedElement = null; }; }, [calculateCoords, field.page]); diff --git a/packages/lib/client-only/hooks/use-is-page-in-dom.ts b/packages/lib/client-only/hooks/use-is-page-in-dom.ts new file mode 100644 index 000000000..f871cfce6 --- /dev/null +++ b/packages/lib/client-only/hooks/use-is-page-in-dom.ts @@ -0,0 +1,39 @@ +import { useEffect, useState } from 'react'; + +import { + PDF_VIEWER_CONTENT_SELECTOR, + PDF_VIEWER_PAGE_SELECTOR, +} from '@documenso/lib/constants/pdf-viewer'; + +/** + * Returns whether the PDF page element for the given page number is currently + * present in the DOM. With virtual list rendering only pages near the viewport + * are mounted, so this hook lets consumers skip rendering when their page is + * virtualised away. + */ +export const useIsPageInDom = (pageNumber: number) => { + const [isPageInDom, setIsPageInDom] = useState(false); + + useEffect(() => { + const selector = `${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pageNumber}"]`; + + setIsPageInDom(document.querySelector(selector) !== null); + + const observer = new MutationObserver(() => { + setIsPageInDom(document.querySelector(selector) !== null); + }); + + const $container = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR) ?? document.body; + + observer.observe($container, { + childList: true, + subtree: true, + }); + + return () => { + observer.disconnect(); + }; + }, [pageNumber]); + + return isPageInDom; +}; diff --git a/packages/lib/client-only/hooks/use-page-renderer.ts b/packages/lib/client-only/hooks/use-page-renderer.ts index de4054f86..f677b637b 100644 --- a/packages/lib/client-only/hooks/use-page-renderer.ts +++ b/packages/lib/client-only/hooks/use-page-renderer.ts @@ -1,135 +1,85 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import Konva from 'konva'; -import type { RenderParameters } from 'pdfjs-dist/types/src/display/api'; -import { usePageContext } from 'react-pdf'; + +import { type PageRenderData } from '../providers/envelope-render-provider'; type RenderFunction = (props: { stage: Konva.Stage; pageLayer: Konva.Layer }) => void; -export function usePageRenderer(renderFunction: RenderFunction) { - const pageContext = usePageContext(); +export const usePageRenderer = (renderFunction: RenderFunction, pageData: PageRenderData) => { + const { pageWidth, pageHeight, scale, imageLoadingState } = pageData; - if (!pageContext) { - throw new Error('Unable to find Page context.'); - } - - const { page, rotate, scale } = pageContext; - - if (!page) { - throw new Error('Attempted to render page canvas, but no page was specified.'); - } - - const canvasElement = useRef(null); const konvaContainer = useRef(null); const stage = useRef(null); const pageLayer = useRef(null); - const [renderError, setRenderError] = useState(false); - /** * The raw viewport with no scaling. Basically the actual PDF size. */ const unscaledViewport = useMemo( - () => page.getViewport({ scale: 1, rotation: rotate }), - [page, rotate, scale], + () => ({ + scale: 1, + width: pageWidth, + height: pageHeight, + }), + [pageWidth, pageHeight], ); /** * The viewport scaled according to page width. */ const scaledViewport = useMemo( - () => page.getViewport({ scale, rotation: rotate }), - [page, rotate, scale], + () => ({ + scale, + width: pageWidth * scale, + height: pageHeight * scale, + }), + [pageWidth, pageHeight, scale], ); - /** - * Viewport with the device pixel ratio applied so we can render the PDF - * in a higher resolution. - */ - const renderViewport = useMemo( - () => page.getViewport({ scale: scale * window.devicePixelRatio, rotation: rotate }), - [page, rotate, scale], - ); + useEffect(() => { + const { current: container } = konvaContainer; - /** - * Render the PDF and create the scaled Konva stage. - */ - useEffect( - function drawPageOnCanvas() { - if (!page) { - return; - } + if (!container || imageLoadingState !== 'loaded') { + return; + } - const { current: canvas } = canvasElement; - const { current: kContainer } = konvaContainer; + stage.current = new Konva.Stage({ + container, + width: scaledViewport.width, + height: scaledViewport.height, + scale: { + x: scale, + y: scale, + }, + }); - if (!canvas || !kContainer) { - return; - } + // Create the main layer for interactive elements. + pageLayer.current = new Konva.Layer(); - canvas.width = renderViewport.width; - canvas.height = renderViewport.height; + stage.current.add(pageLayer.current); - canvas.style.width = `${Math.floor(scaledViewport.width)}px`; - canvas.style.height = `${Math.floor(scaledViewport.height)}px`; + renderFunction({ + stage: stage.current, + pageLayer: pageLayer.current, + }); - const renderContext: RenderParameters = { - canvas, - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - canvasContext: canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D, - viewport: renderViewport, - }; + void document.fonts.ready.then(function () { + pageLayer.current?.batchDraw(); + }); - const cancellable = page.render(renderContext); - const runningTask = cancellable; - - cancellable.promise.catch(() => { - // Intentionally empty - }); - - void cancellable.promise.then(() => { - stage.current = new Konva.Stage({ - container: kContainer, - width: scaledViewport.width, - height: scaledViewport.height, - scale: { - x: scale, - y: scale, - }, - }); - - // Create the main layer for interactive elements. - pageLayer.current = new Konva.Layer(); - - stage.current.add(pageLayer.current); - - renderFunction({ - stage: stage.current, - pageLayer: pageLayer.current, - }); - - void document.fonts.ready.then(function () { - pageLayer.current?.batchDraw(); - }); - }); - - return () => { - runningTask.cancel(); - }; - }, - [page, scaledViewport], - ); + return () => { + stage.current?.destroy(); + stage.current = null; + }; + }, [imageLoadingState, scaledViewport]); return { - canvasElement, konvaContainer, stage, pageLayer, unscaledViewport, scaledViewport, - pageContext, - renderError, - setRenderError, }; -} +}; diff --git a/packages/lib/client-only/providers/envelope-render-provider.tsx b/packages/lib/client-only/providers/envelope-render-provider.tsx index d9fb64cb6..48fddc567 100644 --- a/packages/lib/client-only/providers/envelope-render-provider.tsx +++ b/packages/lib/client-only/providers/envelope-render-provider.tsx @@ -1,23 +1,26 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React from 'react'; -import type { Field, Recipient } from '@prisma/client'; +import { type Field, type Recipient } from '@prisma/client'; +import type { DocumentDataVersion } from '@documenso/lib/types/document'; +import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; import type { TRecipientColor } from '@documenso/ui/lib/recipient-colors'; import { AVAILABLE_RECIPIENT_COLORS } from '@documenso/ui/lib/recipient-colors'; import type { TEnvelope } from '../../types/envelope'; import type { FieldRenderMode } from '../../universal/field-renderer/render-field'; -import { getEnvelopeItemPdfUrl } from '../../utils/envelope-download'; -type FileData = - | { - status: 'loading' | 'error'; - } - | { - file: Uint8Array; - status: 'loaded'; - }; +export type PageRenderData = { + scale: number; + pageIndex: number; + pageNumber: number; + pageWidth: number; + pageHeight: number; + imageLoadingState: ImageLoadingState; +}; + +export type ImageLoadingState = 'loading' | 'loaded' | 'error'; type EnvelopeRenderOverrideSettings = { mode?: FieldRenderMode; @@ -25,10 +28,22 @@ type EnvelopeRenderOverrideSettings = { showRecipientSigningStatus?: boolean; }; -type EnvelopeRenderItem = TEnvelope['envelopeItems'][number]; +type EnvelopeRenderItem = { + id: string; + title: string; + order: number; + envelopeId: string; + + /** + * The PDF data to render. + * + * If it's a string we assume it's a URL to the PDF file. + */ + data: Uint8Array | string; +}; type EnvelopeRenderProviderValue = { - getPdfBuffer: (envelopeItemId: string) => FileData | null; + version: DocumentDataVersion; envelopeItems: EnvelopeRenderItem[]; envelopeStatus: TEnvelope['status']; envelopeType: TEnvelope['type']; @@ -46,7 +61,26 @@ type EnvelopeRenderProviderValue = { interface EnvelopeRenderProviderProps { children: React.ReactNode; - envelope: Pick; + /** + * The envelope item version to render. + */ + version: DocumentDataVersion; + + envelope: Pick; + + /** + * The envelope items to render. + * + * If data is optional then we build the URL based of the IDs. + */ + envelopeItems: { + id: string; + title: string; + order: number; + envelopeId: string; + documentDataId: string; + data?: Uint8Array | string; + }[]; /** * Optional fields which are passed down to renderers for custom rendering needs. @@ -70,6 +104,13 @@ interface EnvelopeRenderProviderProps { */ token: string | undefined; + /** + * The presign token to access the envelope. + * + * If not provided, it will be assumed that the current user can access the document. + */ + presignToken?: string | undefined; + /** * Custom override settings for generic page renderers. */ @@ -89,81 +130,51 @@ export const useCurrentEnvelopeRender = () => { }; /** - * Manages fetching and storing PDF files to render on the client. + * Manages fetching the data required to render an envelope and it's items. */ export const EnvelopeRenderProvider = ({ children, envelope, + envelopeItems: envelopeItemsFromProps, fields, token, + presignToken, recipients = [], + version, overrideSettings, }: EnvelopeRenderProviderProps) => { - // Indexed by documentDataId. - const [files, setFiles] = useState>({}); - - const [currentItem, setCurrentItem] = useState(null); - const [renderError, setRenderError] = useState(false); const envelopeItems = useMemo( - () => envelope.envelopeItems.sort((a, b) => a.order - b.order), - [envelope.envelopeItems], + () => + [...envelopeItemsFromProps] + .sort((a, b) => a.order - b.order) + .map((item) => { + const pdfUrl = getDocumentDataUrl({ + envelopeId: envelope.id, + envelopeItemId: item.id, + documentDataId: item.documentDataId, + version, + token, + presignToken, + }); + + const data = item.data || pdfUrl; + + return { + ...item, + data, + }; + }), + [envelopeItemsFromProps, envelope.id, token, version, presignToken], ); - const loadEnvelopeItemPdfFile = async (envelopeItem: EnvelopeRenderItem) => { - if (files[envelopeItem.id]?.status === 'loading') { - return; - } - - if (!files[envelopeItem.id]) { - setFiles((prev) => ({ - ...prev, - [envelopeItem.id]: { - status: 'loading', - }, - })); - } - - try { - const downloadUrl = getEnvelopeItemPdfUrl({ - type: 'view', - envelopeItem: envelopeItem, - token, - }); - - const blob = await fetch(downloadUrl).then(async (res) => await res.blob()); - - const file = await blob.arrayBuffer(); - - setFiles((prev) => ({ - ...prev, - [envelopeItem.id]: { - file: new Uint8Array(file), - status: 'loaded', - }, - })); - } catch (error) { - console.error(error); - - setFiles((prev) => ({ - ...prev, - [envelopeItem.id]: { - status: 'error', - }, - })); - } - }; - - const getPdfBuffer = useCallback( - (envelopeItemId: string) => { - return files[envelopeItemId] || null; - }, - [files], + const [currentItem, setCurrentItem] = useState( + envelopeItems[0] ?? null, ); const setCurrentEnvelopeItem = (envelopeItemId: string) => { - const foundItem = envelope.envelopeItems.find((item) => item.id === envelopeItemId); + const foundItem = envelopeItems.find((item) => item.id === envelopeItemId); setCurrentItem(foundItem ?? null); }; @@ -179,15 +190,6 @@ export const EnvelopeRenderProvider = ({ } }, [currentItem, envelopeItems]); - // Look for any missing pdf files and load them. - useEffect(() => { - const missingFiles = envelope.envelopeItems.filter((item) => !files[item.id]); - - for (const item of missingFiles) { - void loadEnvelopeItemPdfFile(item); - } - }, [envelope.envelopeItems]); - const recipientIds = useMemo( () => recipients.map((recipient) => recipient.id).sort(), [recipients], @@ -207,7 +209,7 @@ export const EnvelopeRenderProvider = ({ return ( ; diff --git a/packages/lib/constants/pdf-viewer.ts b/packages/lib/constants/pdf-viewer.ts index 54c9c5d5a..9c8c0e18b 100644 --- a/packages/lib/constants/pdf-viewer.ts +++ b/packages/lib/constants/pdf-viewer.ts @@ -1,2 +1,19 @@ -export const PDF_VIEWER_CONTAINER_SELECTOR = '.react-pdf__Document'; +// Keep these two constants in sync. export const PDF_VIEWER_PAGE_SELECTOR = '.react-pdf__Page'; +export const PDF_VIEWER_PAGE_CLASSNAME = 'react-pdf__Page z-0'; + +export const PDF_VIEWER_CONTENT_SELECTOR = '[data-pdf-content]'; + +export const getPdfPagesCount = () => { + const pageCountAttr = document + .querySelector(PDF_VIEWER_CONTENT_SELECTOR) + ?.getAttribute('data-page-count'); + + const totalPages = Number(pageCountAttr); + + if (!Number.isInteger(totalPages) || totalPages < 1) { + return 0; + } + + return totalPages; +}; diff --git a/packages/lib/jobs/definitions/internal/seal-document.handler.ts b/packages/lib/jobs/definitions/internal/seal-document.handler.ts index 786de3a9c..5d44b7147 100644 --- a/packages/lib/jobs/definitions/internal/seal-document.handler.ts +++ b/packages/lib/jobs/definitions/internal/seal-document.handler.ts @@ -285,18 +285,13 @@ export const run = async ({ await prisma.$transaction(async (tx) => { for (const { oldDocumentDataId, newDocumentDataId } of newDocumentData) { - const newData = await tx.documentData.findFirstOrThrow({ + await tx.envelopeItem.update({ where: { - id: newDocumentDataId, - }, - }); - - await tx.documentData.update({ - where: { - id: oldDocumentDataId, + envelopeId: envelope.id, + documentDataId: oldDocumentDataId, }, data: { - data: newData.data, + documentDataId: newDocumentDataId, }, }); } @@ -496,11 +491,14 @@ const decorateAndSignPdf = async ({ // Add suffix based on document status const suffix = isRejected ? '_rejected.pdf' : '_signed.pdf'; - const newDocumentData = await putPdfFileServerSide({ - name: `${name}${suffix}`, - type: 'application/pdf', - arrayBuffer: async () => Promise.resolve(pdfBytes), - }); + const newDocumentData = await putPdfFileServerSide( + { + name: `${name}${suffix}`, + type: 'application/pdf', + arrayBuffer: async () => Promise.resolve(pdfBytes), + }, + envelopeItem.documentData.initialData, + ); return { oldDocumentDataId: envelopeItem.documentData.id, diff --git a/packages/lib/package.json b/packages/lib/package.json index 4421d71ce..a81488699 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -55,7 +55,6 @@ "posthog-js": "^1.297.2", "posthog-node": "4.18.0", "react": "^18", - "react-pdf": "^10.3.0", "remeda": "^2.32.0", "sharp": "0.34.5", "skia-canvas": "^3.0.8", diff --git a/packages/lib/server-only/document-data/create-document-data.ts b/packages/lib/server-only/document-data/create-document-data.ts index 9cf2d7979..62757abcb 100644 --- a/packages/lib/server-only/document-data/create-document-data.ts +++ b/packages/lib/server-only/document-data/create-document-data.ts @@ -5,14 +5,25 @@ import { prisma } from '@documenso/prisma'; export type CreateDocumentDataOptions = { type: DocumentDataType; data: string; + + /** + * The initial data that was used to create the document data. + * + * If not provided, the current data will be used. + */ + initialData?: string; }; -export const createDocumentData = async ({ type, data }: CreateDocumentDataOptions) => { +export const createDocumentData = async ({ + type, + data, + initialData, +}: CreateDocumentDataOptions) => { return await prisma.documentData.create({ data: { type, data, - initialData: data, + initialData: initialData || data, }, }); }; diff --git a/packages/lib/server-only/document/get-document-by-token.ts b/packages/lib/server-only/document/get-document-by-token.ts index 47b009a69..35793b9ef 100644 --- a/packages/lib/server-only/document/get-document-by-token.ts +++ b/packages/lib/server-only/document/get-document-by-token.ts @@ -96,6 +96,7 @@ export const getDocumentAndSenderByToken = async ({ title: true, order: true, envelopeId: true, + documentDataId: true, documentData: true, }, }, diff --git a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts index 6141b33c2..c0a3fd868 100644 --- a/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts +++ b/packages/lib/server-only/envelope/get-envelope-for-recipient-signing.ts @@ -80,6 +80,7 @@ export const ZEnvelopeForSigningResponse = z.object({ id: true, title: true, order: true, + documentDataId: true, }).array(), team: TeamSchema.pick({ diff --git a/packages/lib/server-only/template/get-template-by-direct-link-token.ts b/packages/lib/server-only/template/get-template-by-direct-link-token.ts index 6ede281f8..196ea3b9b 100644 --- a/packages/lib/server-only/template/get-template-by-direct-link-token.ts +++ b/packages/lib/server-only/template/get-template-by-direct-link-token.ts @@ -90,6 +90,7 @@ export const getTemplateByDirectLinkToken = async ({ envelopeItems: envelope.envelopeItems.map((item) => ({ id: item.id, envelopeId: item.envelopeId, + documentDataId: item.documentDataId, })), }; }; diff --git a/packages/lib/types/document.ts b/packages/lib/types/document.ts index 92daff749..125cae519 100644 --- a/packages/lib/types/document.ts +++ b/packages/lib/types/document.ts @@ -181,3 +181,5 @@ export const ZDocumentManySchema = LegacyDocumentSchema.pick({ }); export type TDocumentMany = z.infer; + +export type DocumentDataVersion = 'initial' | 'current'; diff --git a/packages/lib/types/envelope.ts b/packages/lib/types/envelope.ts index f3ea46f15..806ee9773 100644 --- a/packages/lib/types/envelope.ts +++ b/packages/lib/types/envelope.ts @@ -61,6 +61,7 @@ export const ZEnvelopeSchema = EnvelopeSchema.pick({ fields: ZEnvelopeFieldSchema.array(), envelopeItems: EnvelopeItemSchema.pick({ envelopeId: true, + documentDataId: true, id: true, title: true, order: true, diff --git a/packages/lib/universal/upload/put-file.server.ts b/packages/lib/universal/upload/put-file.server.ts index 5d7e0fe26..f9950d3f8 100644 --- a/packages/lib/universal/upload/put-file.server.ts +++ b/packages/lib/universal/upload/put-file.server.ts @@ -20,7 +20,7 @@ type File = { * Uploads a document file to the appropriate storage location and creates * a document data record. */ -export const putPdfFileServerSide = async (file: File) => { +export const putPdfFileServerSide = async (file: File, initialData?: string) => { const isEncryptedDocumentsAllowed = false; // Was feature flag. const arrayBuffer = await file.arrayBuffer(); @@ -41,7 +41,7 @@ export const putPdfFileServerSide = async (file: File) => { const { type, data } = await putFileServerSide(file); - return await createDocumentData({ type, data }); + return await createDocumentData({ type, data, initialData }); }; /** diff --git a/packages/lib/utils/envelope-download.ts b/packages/lib/utils/envelope-download.ts index 82e1dd2cd..949ebbc82 100644 --- a/packages/lib/utils/envelope-download.ts +++ b/packages/lib/utils/envelope-download.ts @@ -1,5 +1,7 @@ import type { EnvelopeItem } from '@prisma/client'; +import type { DocumentDataVersion } from '@documenso/lib/types/document'; + import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app'; export type EnvelopeItemPdfUrlOptions = @@ -34,3 +36,39 @@ export const getEnvelopeItemPdfUrl = (options: EnvelopeItemPdfUrlOptions) => { ? `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/envelopeItem/${id}${presignToken ? `?presignToken=${presignToken}` : ''}` : `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/envelope/${envelopeId}/envelopeItem/${id}${presignToken ? `?token=${presignToken}` : ''}`; }; + +export type DocumentDataUrlOptions = { + envelopeId: string; + envelopeItemId: string; + documentDataId: string; + token: string | undefined; + presignToken?: string | undefined; + version: DocumentDataVersion; +}; + +/** + * The difference between this and `getEnvelopeItemPdfUrl` is that this will + * hard cache since we add the `documentDataId` to the URL. + * + * Since `documentDataId` should change when the document is changed/signed, this is a + * good way to cache an envelope item by. + */ +export const getDocumentDataUrl = (options: DocumentDataUrlOptions) => { + const { envelopeId, envelopeItemId, documentDataId, token, presignToken, version } = options; + + const partialUrl = `envelope/${envelopeId}/envelopeItem/${envelopeItemId}/dataId/${documentDataId}/${version}/item.pdf`; + + // Recipient token endpoint. + if (token) { + return `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/token/${token}/${partialUrl}`; + } + + // Endpoint authenticated by session or presigned token. + const baseUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/files/${partialUrl}`; + + if (presignToken) { + return `${baseUrl}?presignToken=${presignToken}`; + } + + return baseUrl; +}; diff --git a/packages/lib/utils/fields.ts b/packages/lib/utils/fields.ts index ec26192eb..fe2e1bf6e 100644 --- a/packages/lib/utils/fields.ts +++ b/packages/lib/utils/fields.ts @@ -2,6 +2,8 @@ import type { I18n } from '@lingui/core'; import { msg } from '@lingui/core/macro'; import { type Envelope, type Field, FieldType } from '@prisma/client'; +import { PDF_VIEWER_CONTENT_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; + import { extractLegacyIds } from '../universal/id'; /** @@ -23,25 +25,44 @@ export const sortFieldsByPosition = (fields: Field[]): Field[] => { */ export const validateFieldsInserted = (fields: Field[]): boolean => { const fieldCardElements = document.getElementsByClassName('field-card-container'); + const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR); - // Attach validate attribute on all fields. + const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted)); + + // All fields are inserted — clear the validation signal. + if (uninsertedFields.length === 0) { + pdfContent?.removeAttribute('data-validate-fields'); + return true; + } + + // Attach validate attribute on all fields currently in the DOM. Array.from(fieldCardElements).forEach((element) => { element.setAttribute('data-validate', 'true'); }); - const uninsertedFields = sortFieldsByPosition(fields.filter((field) => !field.inserted)); + // Also set a signal on the PDF viewer container so that field elements that + // mount later (e.g. after the virtual list scrolls to a new page) can pick + // up the validation state. + pdfContent?.setAttribute('data-validate-fields', 'true'); const firstUninsertedField = uninsertedFields[0]; - const firstUninsertedFieldElement = - firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`); + if (firstUninsertedField) { + // Try direct element scroll first (works if the field's page is currently rendered). + const firstUninsertedFieldElement = document.getElementById(`field-${firstUninsertedField.id}`); - if (firstUninsertedFieldElement) { - firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); - return false; + if (firstUninsertedFieldElement) { + firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } else { + // Field not in DOM (page virtualized away) — signal the PDF viewer to + // scroll to the correct page via the data attribute. + if (pdfContent) { + pdfContent.setAttribute('data-scroll-to-page', String(firstUninsertedField.page)); + } + } } - return uninsertedFields.length === 0; + return false; }; export const validateFieldsUninserted = (): boolean => { diff --git a/packages/lib/utils/templates.ts b/packages/lib/utils/templates.ts index 5e22474e2..7e69d407b 100644 --- a/packages/lib/utils/templates.ts +++ b/packages/lib/utils/templates.ts @@ -51,7 +51,7 @@ export const mapEnvelopeToTemplateLite = (envelope: Envelope): TTemplateLite => return { id: legacyTemplateId, - envelopeId: envelope.secondaryId, + envelopeId: envelope.id, type: envelope.templateType, visibility: envelope.visibility, externalId: envelope.externalId, diff --git a/packages/trpc/server/envelope-router/create-envelope-items.types.ts b/packages/trpc/server/envelope-router/create-envelope-items.types.ts index 4bcfa7f90..9bd42560c 100644 --- a/packages/trpc/server/envelope-router/create-envelope-items.types.ts +++ b/packages/trpc/server/envelope-router/create-envelope-items.types.ts @@ -33,6 +33,7 @@ export const ZCreateEnvelopeItemsResponseSchema = z.object({ title: true, envelopeId: true, order: true, + documentDataId: true, }).array(), }); diff --git a/packages/ui/components/document/document-read-only-fields.tsx b/packages/ui/components/document/document-read-only-fields.tsx index 5d3cb2539..dde70f32a 100644 --- a/packages/ui/components/document/document-read-only-fields.tsx +++ b/packages/ui/components/document/document-read-only-fields.tsx @@ -98,10 +98,8 @@ export const DocumentReadOnlyFields = ({ setHiddenFieldIds((prev) => ({ ...prev, [fieldId]: true })); }; - const highestPageNumber = Math.max(...fields.map((field) => field.page)); - return ( - + {fields.map( (field) => !hiddenFieldIds[field.secondaryId] && ( @@ -163,7 +161,7 @@ export const DocumentReadOnlyFields = ({

-

+

{getRecipientDisplayText(field.recipient)}

diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index 2886230b3..963a96fd3 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -5,7 +5,11 @@ import { createPortal } from 'react-dom'; import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds'; import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom'; +import { + PDF_VIEWER_CONTENT_SELECTOR, + PDF_VIEWER_PAGE_SELECTOR, +} from '@documenso/lib/constants/pdf-viewer'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import type { RecipientColorStyles } from '../../lib/recipient-colors'; @@ -81,6 +85,8 @@ export function FieldRootContainer({ readonly, }: FieldRootContainerProps) { const [isValidating, setIsValidating] = useState(false); + const isPageInDom = useIsPageInDom(field.page); + const ref = React.useRef(null); useEffect(() => { @@ -88,6 +94,21 @@ export function FieldRootContainer({ return; } + // Check the validation signal on the PDF viewer container. When a field + // mounts after the virtual list scrolls to its page, the per-element + // `data-validate` attribute will not have been set yet. The signal on the + // `[data-pdf-content]` container bridges this gap so newly-rendered fields + // pick up the validation state immediately. + const pdfContent = document.querySelector(PDF_VIEWER_CONTENT_SELECTOR); + + if ( + pdfContent?.getAttribute('data-validate-fields') === 'true' && + isFieldUnsignedAndRequired(field) + ) { + ref.current.setAttribute('data-validate', 'true'); + setIsValidating(true); + } + const observer = new MutationObserver((_mutations) => { if (ref.current) { setIsValidating(ref.current.getAttribute('data-validate') === 'true'); @@ -101,7 +122,11 @@ export function FieldRootContainer({ return () => { observer.disconnect(); }; - }, []); + }, [isPageInDom]); + + if (!isPageInDom) { + return null; + } return ( diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx deleted file mode 100644 index 5cf34870d..000000000 --- a/packages/ui/components/pdf-viewer/pdf-viewer-konva-lazy.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import React, { Suspense, lazy } from 'react'; - -import { Trans } from '@lingui/react/macro'; -import { type PDFDocumentProxy } from 'pdfjs-dist'; - -import type { PdfViewerRendererMode } from './pdf-viewer-konva'; - -export type LoadedPDFDocument = PDFDocumentProxy; - -export type PDFViewerProps = { - className?: string; - onDocumentLoad?: () => void; - renderer: PdfViewerRendererMode; - [key: string]: unknown; -} & Omit, 'onPageClick'>; - -const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva')); - -export const PDFViewerKonvaLazy = (props: PDFViewerProps) => { - return ( - - Loading... -
- } - > - - - ); -}; - -export default PDFViewerKonvaLazy; diff --git a/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx b/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx deleted file mode 100644 index 098fd5876..000000000 --- a/packages/ui/components/pdf-viewer/pdf-viewer-konva.tsx +++ /dev/null @@ -1,213 +0,0 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; - -import type { MessageDescriptor } from '@lingui/core'; -import { msg } from '@lingui/core/macro'; -import { Trans, useLingui } from '@lingui/react/macro'; -import Konva from 'konva'; -import { Loader } from 'lucide-react'; -import { type PDFDocumentProxy } from 'pdfjs-dist'; -import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; - -import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider'; -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { cn } from '@documenso/ui/lib/utils'; -import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; - -export type LoadedPDFDocument = PDFDocumentProxy; - -/** - * This imports the worker from the `pdfjs-dist` package. - */ -pdfjs.GlobalWorkerOptions.workerSrc = new URL( - 'pdfjs-dist/legacy/build/pdf.worker.min.mjs', - import.meta.url, -).toString(); - -const pdfViewerOptions = { - cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`, -}; - -const PDFLoader = () => ( - <> - - -

- Loading document... -

- -); - -export type PdfViewerRendererMode = 'editor' | 'preview' | 'signing'; - -const RendererErrorMessages: Record< - PdfViewerRendererMode, - { title: MessageDescriptor; description: MessageDescriptor } -> = { - editor: { - title: msg`Configuration Error`, - description: msg`There was an issue rendering some fields, please review the fields and try again.`, - }, - preview: { - title: msg`Configuration Error`, - description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`, - }, - signing: { - title: msg`Configuration Error`, - description: msg`Something went wrong while rendering the document, some fields may be missing or corrupted.`, - }, -}; - -export type PdfViewerKonvaProps = { - className?: string; - onDocumentLoad?: () => void; - customPageRenderer?: React.FunctionComponent; - renderer: PdfViewerRendererMode; - [key: string]: unknown; -} & Omit, 'onPageClick'>; - -export const PdfViewerKonva = ({ - className, - onDocumentLoad, - customPageRenderer, - renderer, - ...props -}: PdfViewerKonvaProps) => { - const { t } = useLingui(); - - const $el = useRef(null); - - const { getPdfBuffer, currentEnvelopeItem, renderError } = useCurrentEnvelopeRender(); - - const [width, setWidth] = useState(0); - const [numPages, setNumPages] = useState(0); - const [pdfError, setPdfError] = useState(false); - - const envelopeItemFile = useMemo(() => { - const data = getPdfBuffer(currentEnvelopeItem?.id || ''); - - if (!data || data.status !== 'loaded') { - return null; - } - - return { - data: new Uint8Array(data.file), - }; - }, [currentEnvelopeItem?.id, getPdfBuffer]); - - const onDocumentLoaded = useCallback( - (doc: PDFDocumentProxy) => { - setNumPages(doc.numPages); - }, - [onDocumentLoad], - ); - - useEffect(() => { - if ($el.current) { - const $current = $el.current; - - const { width } = $current.getBoundingClientRect(); - - setWidth(width); - - const onResize = () => { - const { width } = $current.getBoundingClientRect(); - setWidth(width); - }; - - window.addEventListener('resize', onResize); - - return () => { - window.removeEventListener('resize', onResize); - }; - } - }, []); - - return ( -
- {renderError && ( - - {t(RendererErrorMessages[renderer].title)} - {t(RendererErrorMessages[renderer].description)} - - )} - - {envelopeItemFile && Konva ? ( - onDocumentLoaded(d)} - // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. - // Therefore we add some additional custom error handling. - onSourceError={() => { - setPdfError(true); - }} - externalLinkTarget="_blank" - loading={ -
- {pdfError ? ( -
-

- Something went wrong while loading the document. -

-

- Please try again or contact our support. -

-
- ) : ( - - )} -
- } - error={ -
-
-

- Something went wrong while loading the document. -

-

- Please try again or contact our support. -

-
-
- } - options={pdfViewerOptions} - > - {Array(numPages) - .fill(null) - .map((_, i) => ( -
-
- ''} - renderMode={customPageRenderer ? 'custom' : 'canvas'} - customRenderer={customPageRenderer} - /> -
-

- - Page {i + 1} of {numPages} - -

-
- ))} -
- ) : ( -
- -
- )} -
- ); -}; - -export default PdfViewerKonva; diff --git a/packages/ui/package.json b/packages/ui/package.json index 8c8f28c65..42595248f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -66,14 +66,13 @@ "framer-motion": "^12.23.24", "lucide-react": "^0.554.0", "luxon": "^3.7.2", - "perfect-freehand": "^1.2.2", "pdfjs-dist": "5.4.296", + "perfect-freehand": "^1.2.2", "react": "^18", "react-colorful": "^5.6.1", "react-day-picker": "^8.10.1", "react-dom": "^18", "react-hook-form": "^7.66.1", - "react-pdf": "^10.3.0", "react-rnd": "^10.5.2", "remeda": "^2.32.0", "tailwind-merge": "^1.14.0", diff --git a/packages/ui/primitives/document-flow/add-fields.tsx b/packages/ui/primitives/document-flow/add-fields.tsx index 80c0e864b..9cbd9ac6f 100644 --- a/packages/ui/primitives/document-flow/add-fields.tsx +++ b/packages/ui/primitives/document-flow/add-fields.tsx @@ -23,7 +23,7 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer'; import { type TFieldMetaSchema as FieldMeta, ZFieldMetaSchema, @@ -431,13 +431,15 @@ export const AddFieldsFormPartial = ({ } if (duplicateAll) { - const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR)); + const totalPages = getPdfPagesCount(); - pages.forEach((_, index) => { - const pageNumber = index + 1; + if (totalPages < 1) { + return; + } + for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) { if (pageNumber === lastActiveField.pageNumber) { - return; + continue; } const newField: TAddFieldsFormSchema['fields'][0] = { @@ -450,7 +452,7 @@ export const AddFieldsFormPartial = ({ }; append(newField); - }); + } return; } diff --git a/packages/ui/primitives/document-flow/field-item.tsx b/packages/ui/primitives/document-flow/field-item.tsx index 0cb271ae9..1f3156b38 100644 --- a/packages/ui/primitives/document-flow/field-item.tsx +++ b/packages/ui/primitives/document-flow/field-item.tsx @@ -10,6 +10,7 @@ import { Rnd } from 'react-rnd'; import { useSearchParams } from 'react-router'; import { useElementBounds } from '@documenso/lib/client-only/hooks/use-element-bounds'; +import { useIsPageInDom } from '@documenso/lib/client-only/hooks/use-is-page-in-dom'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { ZCheckboxFieldMeta, ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; @@ -50,7 +51,17 @@ export type FieldItemProps = { /** * The item when editing fields?? */ -export const FieldItem = ({ +export const FieldItem = (props: FieldItemProps) => { + const isPageInDom = useIsPageInDom(props.field.pageNumber); + + if (!isPageInDom) { + return null; + } + + return ; +}; + +const FieldItemInner = ({ fieldClassName, field, passive, diff --git a/packages/ui/primitives/pdf-viewer/base.client.tsx b/packages/ui/primitives/pdf-viewer/base.client.tsx deleted file mode 100644 index 496ea706d..000000000 --- a/packages/ui/primitives/pdf-viewer/base.client.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { - type LoadedPDFDocument, - type OnPDFViewerPageClick, - PDFViewer, - type PDFViewerProps, -} from './base'; - -export { PDFViewer, type LoadedPDFDocument, type OnPDFViewerPageClick, type PDFViewerProps }; - -export default PDFViewer; diff --git a/packages/ui/primitives/pdf-viewer/base.tsx b/packages/ui/primitives/pdf-viewer/base.tsx deleted file mode 100644 index 4b150421b..000000000 --- a/packages/ui/primitives/pdf-viewer/base.tsx +++ /dev/null @@ -1,290 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; - -import { msg } from '@lingui/core/macro'; -import { useLingui } from '@lingui/react'; -import { Trans } from '@lingui/react/macro'; -import type { EnvelopeItem } from '@prisma/client'; -import { base64 } from '@scure/base'; -import { Loader } from 'lucide-react'; -import { type PDFDocumentProxy } from 'pdfjs-dist'; -import { Document as PDFDocument, Page as PDFPage, pdfjs } from 'react-pdf'; - -import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; -import { getEnvelopeItemPdfUrl } from '@documenso/lib/utils/envelope-download'; - -import { cn } from '../../lib/utils'; -import { useToast } from '../use-toast'; - -export type LoadedPDFDocument = PDFDocumentProxy; - -/** - * This imports the worker from the `pdfjs-dist` package. - * Wrapped in typeof window check to prevent SSR evaluation. - */ -if (typeof window !== 'undefined') { - pdfjs.GlobalWorkerOptions.workerSrc = new URL( - 'pdfjs-dist/legacy/build/pdf.worker.min.mjs', - import.meta.url, - ).toString(); -} - -const pdfViewerOptions = { - cMapUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/static/cmaps/`, -}; - -export type OnPDFViewerPageClick = (_event: { - pageNumber: number; - numPages: number; - originalEvent: React.MouseEvent; - pageHeight: number; - pageWidth: number; - pageX: number; - pageY: number; -}) => void | Promise; - -const PDFLoader = () => ( - <> - - -

- Loading document... -

- -); - -export type PDFViewerProps = { - className?: string; - envelopeItem: Pick; - token: string | undefined; - presignToken?: string | undefined; - version: 'original' | 'signed'; - onDocumentLoad?: (_doc: LoadedPDFDocument) => void; - onPageClick?: OnPDFViewerPageClick; - overrideData?: string; - customPageRenderer?: React.FunctionComponent; - [key: string]: unknown; -} & Omit, 'onPageClick'>; - -export const PDFViewer = ({ - className, - envelopeItem, - token, - presignToken, - version, - onDocumentLoad, - onPageClick, - overrideData, - customPageRenderer, - ...props -}: PDFViewerProps) => { - const { _ } = useLingui(); - const { toast } = useToast(); - - const $el = useRef(null); - - const [isDocumentBytesLoading, setIsDocumentBytesLoading] = useState(false); - const [documentBytes, setDocumentBytes] = useState( - overrideData ? base64.decode(overrideData) : null, - ); - - const [width, setWidth] = useState(0); - const [numPages, setNumPages] = useState(0); - const [pdfError, setPdfError] = useState(false); - - const isLoading = isDocumentBytesLoading || !documentBytes; - - const envelopeItemFile = useMemo(() => { - if (!documentBytes) { - return null; - } - - return { - data: documentBytes, - }; - }, [documentBytes]); - - const onDocumentLoaded = (doc: LoadedPDFDocument) => { - setNumPages(doc.numPages); - onDocumentLoad?.(doc); - }; - - const onDocumentPageClick = ( - event: React.MouseEvent, - pageNumber: number, - ) => { - const $el = event.target instanceof HTMLElement ? event.target : null; - - if (!$el) { - return; - } - - const $page = $el.closest(PDF_VIEWER_PAGE_SELECTOR); - - if (!$page) { - return; - } - - const { height, width, top, left } = $page.getBoundingClientRect(); - - const pageX = event.clientX - left; - const pageY = event.clientY - top; - - if (onPageClick) { - void onPageClick({ - pageNumber, - numPages, - originalEvent: event, - pageHeight: height, - pageWidth: width, - pageX, - pageY, - }); - } - }; - - useEffect(() => { - if ($el.current) { - const $current = $el.current; - - const { width } = $current.getBoundingClientRect(); - - setWidth(width); - - const onResize = () => { - const { width } = $current.getBoundingClientRect(); - - setWidth(width); - }; - - window.addEventListener('resize', onResize); - - return () => { - window.removeEventListener('resize', onResize); - }; - } - }, []); - - useEffect(() => { - if (overrideData) { - const bytes = base64.decode(overrideData); - - setDocumentBytes(bytes); - return; - } - - const fetchDocumentBytes = async () => { - try { - setIsDocumentBytesLoading(true); - - const documentUrl = getEnvelopeItemPdfUrl({ - type: 'view', - envelopeItem: envelopeItem, - token, - presignToken, - }); - - const bytes = await fetch(documentUrl).then(async (res) => await res.arrayBuffer()); - - setDocumentBytes(new Uint8Array(bytes)); - - setIsDocumentBytesLoading(false); - } catch (err) { - console.error(err); - - toast({ - title: _(msg`Error`), - description: _(msg`An error occurred while loading the document.`), - variant: 'destructive', - }); - } - }; - - void fetchDocumentBytes(); - }, [envelopeItem.envelopeId, envelopeItem.id, token, version, toast, overrideData]); - - return ( -
- {isLoading ? ( -
- -
- ) : ( - <> - onDocumentLoaded(d)} - // Uploading a invalid document causes an error which doesn't appear to be handled by the `error` prop. - // Therefore we add some additional custom error handling. - onSourceError={() => { - setPdfError(true); - }} - externalLinkTarget="_blank" - loading={ -
- {pdfError ? ( -
-

- Something went wrong while loading the document. -

-

- Please try again or contact our support. -

-
- ) : ( - - )} -
- } - error={ -
-
-

- Something went wrong while loading the document. -

-

- Please try again or contact our support. -

-
-
- } - options={pdfViewerOptions} - > - {Array(numPages) - .fill(null) - .map((_, i) => ( -
-
- ''} - renderMode={customPageRenderer ? 'custom' : 'canvas'} - customRenderer={customPageRenderer} - onClick={(e) => onDocumentPageClick(e, i + 1)} - /> -
-

- - Page {i + 1} of {numPages} - -

-
- ))} -
- - )} -
- ); -}; - -export default PDFViewer; diff --git a/packages/ui/primitives/pdf-viewer/index.ts b/packages/ui/primitives/pdf-viewer/index.ts deleted file mode 100644 index 8a185aaec..000000000 --- a/packages/ui/primitives/pdf-viewer/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './base'; diff --git a/packages/ui/primitives/pdf-viewer/lazy.tsx b/packages/ui/primitives/pdf-viewer/lazy.tsx deleted file mode 100644 index 7f1bdc1ee..000000000 --- a/packages/ui/primitives/pdf-viewer/lazy.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { ClientOnly } from '../../components/client-only'; - -import { Trans } from '@lingui/react/macro'; - -import { PDFViewer, type PDFViewerProps } from './base.client'; - -export const PDFViewerLazy = (props: PDFViewerProps) => { - return ( - - Loading... -
- } - > - {() => } - - ); -}; diff --git a/packages/ui/primitives/template-flow/add-template-fields.tsx b/packages/ui/primitives/template-flow/add-template-fields.tsx index 3a3217fe7..cf5a05b50 100644 --- a/packages/ui/primitives/template-flow/add-template-fields.tsx +++ b/packages/ui/primitives/template-flow/add-template-fields.tsx @@ -24,7 +24,7 @@ import { useHotkeys } from 'react-hotkeys-hook'; import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect'; import { useAutoSave } from '@documenso/lib/client-only/hooks/use-autosave'; import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element'; -import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; +import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/constants/pdf-viewer'; import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; import { @@ -188,13 +188,15 @@ export const AddTemplateFieldsFormPartial = ({ } if (duplicateAll) { - const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR)); + const totalPages = getPdfPagesCount(); - pages.forEach((_, index) => { - const pageNumber = index + 1; + if (totalPages < 1) { + return; + } + for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) { if (pageNumber === lastActiveField.pageNumber) { - return; + continue; } const newField: TAddTemplateFieldsFormSchema['fields'][0] = { @@ -208,7 +210,7 @@ export const AddTemplateFieldsFormPartial = ({ }; append(newField); - }); + } void handleAutoSave(); return; From c63b4ca3cc92f773c47eb07c32bc13897c99f5cb Mon Sep 17 00:00:00 2001 From: Konrad <11725227+mKoonrad@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:05:03 +0100 Subject: [PATCH 018/110] fix(i18n): mark dropdown and radio placeholder for translation (#2537) --- .../forms/editor/editor-field-dropdown-form.tsx | 10 +++++----- .../forms/editor/editor-field-radio-form.tsx | 2 +- .../field-items-advanced-settings/dropdown-field.tsx | 12 ++++++------ .../field-items-advanced-settings/radio-field.tsx | 4 ++-- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/remix/app/components/forms/editor/editor-field-dropdown-form.tsx b/apps/remix/app/components/forms/editor/editor-field-dropdown-form.tsx index df95dcd07..ed4ae9ee9 100644 --- a/apps/remix/app/components/forms/editor/editor-field-dropdown-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-dropdown-form.tsx @@ -96,7 +96,7 @@ export const EditorFieldDropdownForm = ({ mode: 'onChange', defaultValues: { defaultValue: value.defaultValue, - values: value.values || [{ value: 'Option 1' }], + values: [{ value: t`Option 1` }], required: value.required || false, readOnly: value.readOnly || false, fontSize: value.fontSize || DEFAULT_FIELD_FONT_SIZE, @@ -110,13 +110,13 @@ export const EditorFieldDropdownForm = ({ const addValue = () => { const currentValues = form.getValues('values') || []; - let newValue = 'New option'; + let newValue = t`New option`; // Iterate to create a unique value for (let i = 0; i < currentValues.length; i++) { - newValue = `New option ${i + 1}`; - if (currentValues.some((item) => item.value === `New option ${i + 1}`)) { - newValue = `New option ${i + 1}`; + newValue = t`New option ${i + 1}`; + if (currentValues.some((item) => item.value === t`New option ${i + 1}`)) { + newValue = t`New option ${i + 1}`; } else { break; } diff --git a/apps/remix/app/components/forms/editor/editor-field-radio-form.tsx b/apps/remix/app/components/forms/editor/editor-field-radio-form.tsx index c2ea00716..d6d20bada 100644 --- a/apps/remix/app/components/forms/editor/editor-field-radio-form.tsx +++ b/apps/remix/app/components/forms/editor/editor-field-radio-form.tsx @@ -79,7 +79,7 @@ export const EditorFieldRadioForm = ({ mode: 'onChange', defaultValues: { label: value.label || '', - values: value.values || [{ id: 1, checked: false, value: 'Default value' }], + values: value.values || [{ id: 1, checked: false, value: t`Default value` }], required: value.required || false, readOnly: value.readOnly || false, direction: value.direction || 'vertical', diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx index e6b78a729..da0e6f825 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/dropdown-field.tsx @@ -36,14 +36,14 @@ export const DropdownFieldAdvancedSettings = ({ const { _ } = useLingui(); const [showValidation, setShowValidation] = useState(false); - const [values, setValues] = useState(fieldState.values ?? [{ value: 'Option 1' }]); + const [values, setValues] = useState(fieldState.values ?? [{ value: _(msg`Option 1`) }]); const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false); const [required, setRequired] = useState(fieldState.required ?? false); - const [defaultValue, setDefaultValue] = useState(fieldState.defaultValue ?? 'Option 1'); + const [defaultValue, setDefaultValue] = useState(fieldState.defaultValue ?? _(msg`Option 1`)); const addValue = () => { - setValues([...values, { value: 'New option' }]); - handleFieldChange('values', [...values, { value: 'New option' }]); + setValues([...values, { value: _(msg`New option`) }]); + handleFieldChange('values', [...values, { value: _(msg`New option`) }]); }; const removeValue = (index: number) => { @@ -90,11 +90,11 @@ export const DropdownFieldAdvancedSettings = ({ }, [values]); useEffect(() => { - setValues(fieldState.values ?? [{ value: 'Option 1' }]); + setValues(fieldState.values ?? [{ value: _(msg`Option 1`) }]); }, [fieldState.values]); useEffect(() => { - setDefaultValue(fieldState.defaultValue ?? 'Option 1'); + setDefaultValue(fieldState.defaultValue ?? _(msg`Option 1`)); }, [fieldState.defaultValue]); return ( diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx index 49dc8eba7..e4bc9f073 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx @@ -31,7 +31,7 @@ export const RadioFieldAdvancedSettings = ({ const [showValidation, setShowValidation] = useState(false); const [values, setValues] = useState( - fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }], + fieldState.values ?? [{ id: 1, checked: false, value: _(msg`Default value`) }], ); const [readOnly, setReadOnly] = useState(fieldState.readOnly ?? false); const [required, setRequired] = useState(fieldState.required ?? false); @@ -99,7 +99,7 @@ export const RadioFieldAdvancedSettings = ({ }; useEffect(() => { - setValues(fieldState.values ?? [{ id: 1, checked: false, value: 'Default value' }]); + setValues(fieldState.values ?? [{ id: 1, checked: false, value: _(msg`Default value`) }]); }, [fieldState.values]); useEffect(() => { From 7e2cbe46c0b2b4241eb8cc4a03461924247b69a2 Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Fri, 6 Mar 2026 02:30:31 +0000 Subject: [PATCH 019/110] fix: show current month data and add caching (#2573) ### Summary - Add Cache-Control headers to all route responses (1h s-maxage, 2h stale-while-revalidate) - Append current month to chart data so graphs stay up-to-date (cumulative carries forward, else zero) - Remove `.limit(12)` from growth queries for full history - Pass isCumulative flag through addZeroMonth - Deduplicate TransformedData type, remove transformRepoStats --- .../app/community/total-forks/route.ts | 1 + .../app/community/total-issues/route.ts | 1 + .../app/community/total-prs/route.ts | 1 + .../app/community/total-stars/route.ts | 1 + apps/openpage-api/app/github/forks/route.ts | 1 + apps/openpage-api/app/github/issues/route.ts | 1 + apps/openpage-api/app/github/prs/route.ts | 1 + apps/openpage-api/app/github/stars/route.ts | 1 + .../app/growth/completed-documents/route.ts | 1 + .../app/growth/new-users/route.ts | 1 + .../app/growth/signer-conversion/route.ts | 1 + .../growth/total-completed-documents/route.ts | 1 + .../app/growth/total-customers/route.ts | 1 + .../growth/total-signer-conversion/route.ts | 1 + .../app/growth/total-users/route.ts | 1 + apps/openpage-api/lib/add-zero-month.ts | 55 +++++++++---------- .../growth/get-monthly-completed-document.ts | 5 +- .../lib/growth/get-signer-conversion.ts | 2 +- .../lib/growth/get-user-monthly-growth.ts | 5 +- apps/openpage-api/lib/transform-data.ts | 17 +----- 20 files changed, 50 insertions(+), 49 deletions(-) diff --git a/apps/openpage-api/app/community/total-forks/route.ts b/apps/openpage-api/app/community/total-forks/route.ts index 330ddc587..2c78d087c 100644 --- a/apps/openpage-api/app/community/total-forks/route.ts +++ b/apps/openpage-api/app/community/total-forks/route.ts @@ -12,6 +12,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/community/total-issues/route.ts b/apps/openpage-api/app/community/total-issues/route.ts index 386e766cb..eaa633cb2 100644 --- a/apps/openpage-api/app/community/total-issues/route.ts +++ b/apps/openpage-api/app/community/total-issues/route.ts @@ -12,6 +12,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/community/total-prs/route.ts b/apps/openpage-api/app/community/total-prs/route.ts index 4bffd1702..b0174e082 100644 --- a/apps/openpage-api/app/community/total-prs/route.ts +++ b/apps/openpage-api/app/community/total-prs/route.ts @@ -12,6 +12,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/community/total-stars/route.ts b/apps/openpage-api/app/community/total-stars/route.ts index 60e8b431e..ac76b6e12 100644 --- a/apps/openpage-api/app/community/total-stars/route.ts +++ b/apps/openpage-api/app/community/total-stars/route.ts @@ -12,6 +12,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/github/forks/route.ts b/apps/openpage-api/app/github/forks/route.ts index f512dd57d..331a0f644 100644 --- a/apps/openpage-api/app/github/forks/route.ts +++ b/apps/openpage-api/app/github/forks/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/github/issues/route.ts b/apps/openpage-api/app/github/issues/route.ts index eccc1bd32..5047e3135 100644 --- a/apps/openpage-api/app/github/issues/route.ts +++ b/apps/openpage-api/app/github/issues/route.ts @@ -12,6 +12,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/github/prs/route.ts b/apps/openpage-api/app/github/prs/route.ts index 7516e6986..83a31cd2f 100644 --- a/apps/openpage-api/app/github/prs/route.ts +++ b/apps/openpage-api/app/github/prs/route.ts @@ -12,6 +12,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/github/stars/route.ts b/apps/openpage-api/app/github/stars/route.ts index 79c03f0cd..860533282 100644 --- a/apps/openpage-api/app/github/stars/route.ts +++ b/apps/openpage-api/app/github/stars/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/growth/completed-documents/route.ts b/apps/openpage-api/app/growth/completed-documents/route.ts index 0887d39ec..85ad83d08 100644 --- a/apps/openpage-api/app/growth/completed-documents/route.ts +++ b/apps/openpage-api/app/growth/completed-documents/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/growth/new-users/route.ts b/apps/openpage-api/app/growth/new-users/route.ts index 460adf9da..359fa6556 100644 --- a/apps/openpage-api/app/growth/new-users/route.ts +++ b/apps/openpage-api/app/growth/new-users/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/growth/signer-conversion/route.ts b/apps/openpage-api/app/growth/signer-conversion/route.ts index 8be97ded5..3ed45c192 100644 --- a/apps/openpage-api/app/growth/signer-conversion/route.ts +++ b/apps/openpage-api/app/growth/signer-conversion/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/growth/total-completed-documents/route.ts b/apps/openpage-api/app/growth/total-completed-documents/route.ts index 29bd0c9ce..d23967563 100644 --- a/apps/openpage-api/app/growth/total-completed-documents/route.ts +++ b/apps/openpage-api/app/growth/total-completed-documents/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/growth/total-customers/route.ts b/apps/openpage-api/app/growth/total-customers/route.ts index c403ac311..ab1658859 100644 --- a/apps/openpage-api/app/growth/total-customers/route.ts +++ b/apps/openpage-api/app/growth/total-customers/route.ts @@ -16,6 +16,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/growth/total-signer-conversion/route.ts b/apps/openpage-api/app/growth/total-signer-conversion/route.ts index d620ee4e6..a10014161 100644 --- a/apps/openpage-api/app/growth/total-signer-conversion/route.ts +++ b/apps/openpage-api/app/growth/total-signer-conversion/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/app/growth/total-users/route.ts b/apps/openpage-api/app/growth/total-users/route.ts index bd34055ef..ba4e8ee37 100644 --- a/apps/openpage-api/app/growth/total-users/route.ts +++ b/apps/openpage-api/app/growth/total-users/route.ts @@ -10,6 +10,7 @@ export async function GET(request: Request) { status: 200, headers: { 'content-type': 'application/json', + 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=7200', }, }), ); diff --git a/apps/openpage-api/lib/add-zero-month.ts b/apps/openpage-api/lib/add-zero-month.ts index eb5914599..91e0d89c7 100644 --- a/apps/openpage-api/lib/add-zero-month.ts +++ b/apps/openpage-api/lib/add-zero-month.ts @@ -1,15 +1,20 @@ import { DateTime } from 'luxon'; -export interface TransformedData { +export type TransformedData = { labels: string[]; datasets: Array<{ label: string; data: number[]; }>; -} +}; -export function addZeroMonth(transformedData: TransformedData): TransformedData { - const result = { +const FORMAT = 'MMM yyyy'; + +export const addZeroMonth = ( + transformedData: TransformedData, + isCumulative = false, +): TransformedData => { + const result: TransformedData = { labels: [...transformedData.labels], datasets: transformedData.datasets.map((dataset) => ({ label: dataset.label, @@ -21,34 +26,28 @@ export function addZeroMonth(transformedData: TransformedData): TransformedData return result; } - if (result.datasets.every((dataset) => dataset.data[0] === 0)) { - return result; - } - - try { - let firstMonth = DateTime.fromFormat(result.labels[0], 'MMM yyyy'); + if (!result.datasets.every((dataset) => dataset.data[0] === 0)) { + const firstMonth = DateTime.fromFormat(result.labels[0], FORMAT); if (!firstMonth.isValid) { - const formats = ['MMM yyyy', 'MMMM yyyy', 'MM/yyyy', 'yyyy-MM']; - - for (const format of formats) { - firstMonth = DateTime.fromFormat(result.labels[0], format); - if (firstMonth.isValid) break; - } - - if (!firstMonth.isValid) { - console.warn(`Could not parse date: "${result.labels[0]}"`); - return transformedData; - } + console.warn(`Could not parse date: "${result.labels[0]}"`); + return transformedData; } - const zeroMonth = firstMonth.minus({ months: 1 }).toFormat('MMM yyyy'); - result.labels.unshift(zeroMonth); + result.labels.unshift(firstMonth.minus({ months: 1 }).toFormat(FORMAT)); result.datasets.forEach((dataset) => { dataset.data.unshift(0); }); - - return result; - } catch (error) { - return transformedData; } -} + + const now = DateTime.now().startOf('month'); + const lastMonth = DateTime.fromFormat(result.labels[result.labels.length - 1], FORMAT); + + if (lastMonth.isValid && lastMonth.startOf('month') < now) { + result.labels.push(now.toFormat(FORMAT)); + result.datasets.forEach((dataset) => { + dataset.data.push(isCumulative ? dataset.data[dataset.data.length - 1] : 0); + }); + } + + return result; +}; diff --git a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts index 808d7259d..b704c04eb 100644 --- a/apps/openpage-api/lib/growth/get-monthly-completed-document.ts +++ b/apps/openpage-api/lib/growth/get-monthly-completed-document.ts @@ -21,8 +21,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' .where(() => sql`"Envelope"."status" = ${DocumentStatus.COMPLETED}::"DocumentStatus"`) .where(() => sql`"Envelope"."type" = ${EnvelopeType.DOCUMENT}::"EnvelopeType"`) .groupBy('month') - .orderBy('month', 'desc') - .limit(12); + .orderBy('month', 'desc'); const result = await qb.execute(); @@ -38,7 +37,7 @@ export const getCompletedDocumentsMonthly = async (type: 'count' | 'cumulative' ], }; - return addZeroMonth(transformedData); + return addZeroMonth(transformedData, type === 'cumulative'); }; export type GetCompletedDocumentsMonthlyResult = Awaited< diff --git a/apps/openpage-api/lib/growth/get-signer-conversion.ts b/apps/openpage-api/lib/growth/get-signer-conversion.ts index c70600179..8d25e3a14 100644 --- a/apps/openpage-api/lib/growth/get-signer-conversion.ts +++ b/apps/openpage-api/lib/growth/get-signer-conversion.ts @@ -36,7 +36,7 @@ export const getSignerConversionMonthly = async (type: 'count' | 'cumulative' = ], }; - return addZeroMonth(transformedData); + return addZeroMonth(transformedData, type === 'cumulative'); }; export type GetSignerConversionMonthlyResult = Awaited< diff --git a/apps/openpage-api/lib/growth/get-user-monthly-growth.ts b/apps/openpage-api/lib/growth/get-user-monthly-growth.ts index 9eba7311f..ae428813c 100644 --- a/apps/openpage-api/lib/growth/get-user-monthly-growth.ts +++ b/apps/openpage-api/lib/growth/get-user-monthly-growth.ts @@ -17,8 +17,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count .as('cume_count'), ]) .groupBy('month') - .orderBy('month', 'desc') - .limit(12); + .orderBy('month', 'desc'); const result = await qb.execute(); @@ -34,7 +33,7 @@ export const getUserMonthlyGrowth = async (type: 'count' | 'cumulative' = 'count ], }; - return addZeroMonth(transformedData); + return addZeroMonth(transformedData, type === 'cumulative'); }; export type GetUserMonthlyGrowthResult = Awaited>; diff --git a/apps/openpage-api/lib/transform-data.ts b/apps/openpage-api/lib/transform-data.ts index 079ed4f6e..801dd4281 100644 --- a/apps/openpage-api/lib/transform-data.ts +++ b/apps/openpage-api/lib/transform-data.ts @@ -1,6 +1,6 @@ import { DateTime } from 'luxon'; -import { addZeroMonth } from './add-zero-month'; +import { type TransformedData, addZeroMonth } from './add-zero-month'; type MetricKeys = { stars: number; @@ -14,14 +14,6 @@ type DataEntry = { [key: string]: MetricKeys; }; -type TransformData = { - labels: string[]; - datasets: { - label: string; - data: number[]; - }[]; -}; - type MetricKey = keyof MetricKeys; const FRIENDLY_METRIC_NAMES: { [key in MetricKey]: string } = { @@ -38,7 +30,7 @@ export function transformData({ }: { data: DataEntry; metric: MetricKey; -}): TransformData { +}): TransformedData { try { if (!data || Object.keys(data).length === 0) { return { @@ -103,7 +95,7 @@ export function transformData({ ], }; - return addZeroMonth(transformedData); + return addZeroMonth(transformedData, true); } catch (error) { return { labels: [], @@ -111,6 +103,3 @@ export function transformData({ }; } } - -// To be on the safer side -export const transformRepoStats = transformData; From 7ea664214ab25e064443c6eed31016fafdd78147 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 6 Mar 2026 14:11:27 +1100 Subject: [PATCH 020/110] feat: add embedded envelopes (#2564) ## Description Add envelopes V2 embedded support --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../skills/envelope-editor-v2-e2e/SKILL.md | 371 ++++++++ .../dialogs/envelope-delete-dialog.tsx | 215 +++++ .../dialogs/envelope-distribute-dialog.tsx | 14 +- .../embed/authoring/configure-fields-view.tsx | 4 +- .../embed-direct-template-client-page.tsx | 12 +- .../embed/embed-document-signing-page-v1.tsx | 12 +- .../embed/embed-document-signing-page-v2.tsx | 2 +- .../multi-sign-document-signing-view.tsx | 6 +- .../editor/editor-field-checkbox-form.tsx | 27 +- .../editor/editor-field-dropdown-form.tsx | 10 +- .../editor-field-generic-field-forms.tsx | 15 +- .../forms/editor/editor-field-number-form.tsx | 21 +- .../forms/editor/editor-field-radio-form.tsx | 17 +- .../forms/editor/editor-field-text-form.tsx | 10 +- .../direct-template/direct-template-page.tsx | 6 +- .../document-signing-page-view-v1.tsx | 10 +- .../document/document-attachments-popover.tsx | 21 +- .../document/document-certificate-qr-view.tsx | 12 +- .../general/document/document-edit-form.tsx | 8 +- .../embedded-editor-attachment-popover.tsx | 215 +++++ .../envelope-editor-fields-page.tsx | 74 +- .../envelope-editor-header.tsx | 153 +++- .../envelope-editor-recipient-form.tsx | 774 ++++++++-------- ...elope-editor-renderer-provider-wrapper.tsx | 28 + .../envelope-editor-settings-dialog.tsx | 801 ++++++++-------- .../envelope-editor-title-input.tsx | 5 +- .../envelope-editor-upload-page.tsx | 260 ++++-- .../envelope-editor/envelope-editor.tsx | 678 +++++++++----- .../pdf-viewer/envelope-pdf-viewer.tsx | 2 +- .../general/pdf-viewer/pdf-viewer.tsx | 18 +- .../general/template/template-edit-form.tsx | 8 +- .../t.$teamUrl+/documents.$id._index.tsx | 22 +- .../t.$teamUrl+/documents.$id.edit.tsx | 19 +- .../t.$teamUrl+/templates.$id._index.tsx | 10 +- apps/remix/app/routes/embed+/playground.tsx | 656 ++++++++++++++ .../routes/embed+/v1+/authoring+/_layout.tsx | 2 +- .../embed+/v1+/authoring+/document.create.tsx | 8 +- .../v1+/authoring+/document.edit.$id.tsx | 8 +- .../embed+/v1+/authoring+/template.create.tsx | 8 +- .../v1+/authoring+/template.edit.$id.tsx | 8 +- .../routes/embed+/v1+/multisign+/_index.tsx | 6 +- .../routes/embed+/v2+/authoring+/_layout.tsx | 181 ++++ .../v2+/authoring+/envelope.create._index.tsx | 434 +++++++++ .../v2+/authoring+/envelope.edit.$id.tsx | 371 ++++++++ apps/remix/app/utils/css-vars.ts | 36 +- .../envelope-actions.spec.ts | 442 +++++++++ .../envelope-attachments.spec.ts | 329 +++++++ .../envelope-editor-embedded-css.spec.ts | 194 ++++ .../envelope-fields.spec.ts | 852 ++++++++++++++++++ .../envelope-editor-v2/envelope-items.spec.ts | 316 +++++++ .../envelope-recipients.spec.ts | 277 ++++++ .../envelope-settings.spec.ts | 411 +++++++++ .../app-tests/e2e/fixtures/envelope-editor.ts | 429 +++++++++ packages/app-tests/e2e/fixtures/konva.ts | 22 + .../ee/server-only/limits/provider/client.tsx | 15 + .../client-only/hooks/use-editor-fields.ts | 5 +- .../hooks/use-editor-recipients.ts | 7 +- .../client-only/hooks/use-page-renderer.ts | 3 +- .../providers/envelope-editor-provider.tsx | 526 ++++++++--- .../providers/envelope-render-provider.tsx | 12 +- .../verify-embedding-presign-token.ts | 16 +- .../envelope-item/create-envelope-items.ts | 181 ++++ .../envelope-item/delete-envelope-item.ts | 67 ++ .../envelope-item/update-envelope-items.ts | 40 + .../server-only/envelope/create-envelope.ts | 16 +- .../envelope/get-editor-envelope-by-id.ts | 110 +++ .../recipient/set-template-recipients.ts | 30 +- packages/lib/types/css-vars.ts | 37 + .../lib}/types/embed-authoring-base-schema.ts | 0 .../lib}/types/embed-base-schemas.ts | 2 +- .../types/embed-direct-template-schema.ts | 0 .../lib}/types/embed-document-sign-schema.ts | 0 .../types/embed-multisign-document-schema.ts | 0 packages/lib/types/envelope-editor.ts | 316 +++++++ packages/lib/types/field-meta.ts | 2 +- packages/lib/types/recipient.ts | 8 +- packages/lib/utils/embed-config.ts | 148 +++ packages/lib/utils/envelope-download.ts | 15 + packages/tailwind-config/index.cjs | 1 + .../trpc/server/embedding-router/_router.ts | 4 + .../create-embedding-envelope.ts | 41 + .../create-embedding-envelope.types.ts | 8 + .../create-embedding-presign-token.types.ts | 4 +- .../update-embedding-envelope.ts | 428 +++++++++ .../update-embedding-envelope.types.ts | 111 +++ .../envelope-router/create-envelope-items.ts | 147 +-- .../server/envelope-router/create-envelope.ts | 309 ++++--- .../envelope-router/delete-envelope-item.ts | 51 +- .../envelope-router/get-editor-envelope.ts | 31 + .../get-editor-envelope.types.ts | 12 + .../trpc/server/envelope-router/router.ts | 4 + .../set-envelope-recipients.types.ts | 20 +- .../envelope-router/update-envelope-items.ts | 30 +- .../recipient/recipient-role-select.tsx | 176 ++-- .../add-template-placeholder-recipients.tsx | 16 +- packages/ui/styles/theme.css | 4 + 96 files changed, 9887 insertions(+), 1916 deletions(-) create mode 100644 .opencode/skills/envelope-editor-v2-e2e/SKILL.md create mode 100644 apps/remix/app/components/dialogs/envelope-delete-dialog.tsx create mode 100644 apps/remix/app/components/general/document/embedded-editor-attachment-popover.tsx create mode 100644 apps/remix/app/components/general/envelope-editor/envelope-editor-renderer-provider-wrapper.tsx create mode 100644 apps/remix/app/routes/embed+/playground.tsx create mode 100644 apps/remix/app/routes/embed+/v2+/authoring+/_layout.tsx create mode 100644 apps/remix/app/routes/embed+/v2+/authoring+/envelope.create._index.tsx create mode 100644 apps/remix/app/routes/embed+/v2+/authoring+/envelope.edit.$id.tsx create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-actions.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-attachments.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-editor-embedded-css.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-fields.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-items.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipients.spec.ts create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-settings.spec.ts create mode 100644 packages/app-tests/e2e/fixtures/envelope-editor.ts create mode 100644 packages/app-tests/e2e/fixtures/konva.ts create mode 100644 packages/lib/server-only/envelope-item/create-envelope-items.ts create mode 100644 packages/lib/server-only/envelope-item/delete-envelope-item.ts create mode 100644 packages/lib/server-only/envelope-item/update-envelope-items.ts create mode 100644 packages/lib/server-only/envelope/get-editor-envelope-by-id.ts create mode 100644 packages/lib/types/css-vars.ts rename {apps/remix/app => packages/lib}/types/embed-authoring-base-schema.ts (100%) rename {apps/remix/app => packages/lib}/types/embed-base-schemas.ts (84%) rename {apps/remix/app => packages/lib}/types/embed-direct-template-schema.ts (100%) rename {apps/remix/app => packages/lib}/types/embed-document-sign-schema.ts (100%) rename {apps/remix/app => packages/lib}/types/embed-multisign-document-schema.ts (100%) create mode 100644 packages/lib/types/envelope-editor.ts create mode 100644 packages/lib/utils/embed-config.ts create mode 100644 packages/trpc/server/embedding-router/create-embedding-envelope.ts create mode 100644 packages/trpc/server/embedding-router/create-embedding-envelope.types.ts create mode 100644 packages/trpc/server/embedding-router/update-embedding-envelope.ts create mode 100644 packages/trpc/server/embedding-router/update-embedding-envelope.types.ts create mode 100644 packages/trpc/server/envelope-router/get-editor-envelope.ts create mode 100644 packages/trpc/server/envelope-router/get-editor-envelope.types.ts diff --git a/.opencode/skills/envelope-editor-v2-e2e/SKILL.md b/.opencode/skills/envelope-editor-v2-e2e/SKILL.md new file mode 100644 index 000000000..0ac584a76 --- /dev/null +++ b/.opencode/skills/envelope-editor-v2-e2e/SKILL.md @@ -0,0 +1,371 @@ +--- +name: envelope-editor-v2-e2e +description: Writing and maintaining Playwright E2E tests for the Envelope Editor V2. Use when the user needs to create, modify, debug, or extend E2E tests in packages/app-tests/e2e/envelope-editor-v2/. Triggers include requests to "write an e2e test", "add a test for the envelope editor", "test envelope settings/recipients/fields/items/attachments", "fix a failing envelope test", or any task involving Playwright tests for the envelope editor feature. +--- + +# Envelope Editor V2 E2E Tests + +## Overview + +The Envelope Editor V2 E2E test suite lives in `packages/app-tests/e2e/envelope-editor-v2/`. Each test file covers a distinct feature area of the envelope editor and follows a strict architectural pattern that tests the **same flow** across four surfaces: + +1. **Document** (`documents/`) - Native document editor +2. **Template** (`templates/`) - Native template editor +3. **Embedded Create** (`/embed/v2/authoring/envelope/create`) - Embedded editor creating a new envelope +4. **Embedded Edit** (`/embed/v2/authoring/envelope/edit/`) - Embedded editor updating an existing envelope + +## Project Structure + +``` +packages/app-tests/ + e2e/ + envelope-editor-v2/ + envelope-attachments.spec.ts # Attachment CRUD + envelope-fields.spec.ts # Field placement on PDF canvas + envelope-items.spec.ts # PDF document item CRUD + envelope-recipients.spec.ts # Recipient management + envelope-settings.spec.ts # Settings dialog + fixtures/ + authentication.ts # apiSignin, apiSignout + documents.ts # Document tab helpers + envelope-editor.ts # Core fixture: surface openers + locator/action helpers + generic.ts # Toast assertions, text visibility + signature.ts # Signature pad helpers + playwright.config.ts # Test configuration +``` + +## Core Abstraction: `TEnvelopeEditorSurface` + +Every test revolves around the `TEnvelopeEditorSurface` type from `fixtures/envelope-editor.ts`. This is the central abstraction that normalizes differences between the four surfaces: + +```typescript +type TEnvelopeEditorSurface = { + root: Page; // The Playwright page + isEmbedded: boolean; // true for embed surfaces + envelopeId?: string; // Set for document/template/embed-edit, undefined for embed-create + envelopeType: 'DOCUMENT' | 'TEMPLATE'; + userId: number; // Seeded user ID + userEmail: string; // Seeded user email + userName: string; // Seeded user name + teamId: number; // Seeded team ID +}; +``` + +### Surface Openers (from `fixtures/envelope-editor.ts`) + +```typescript +// Native surfaces - seed user + document/template, sign in, navigate +const surface = await openDocumentEnvelopeEditor(page); +const surface = await openTemplateEnvelopeEditor(page); + +// Embedded surfaces - seed user, create API token, get presign token, navigate +const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT' | 'TEMPLATE', + mode?: 'create' | 'edit', // default: 'create' + tokenNamePrefix?: string, // for unique API token names + externalId?: string, // optional external ID in hash + features?: EmbeddedEditorConfig, // feature flags +}); +``` + +## Test Architecture Pattern + +Every test file follows this structure, with four `test.describe` blocks grouping tests by editor surface: + +### 1. Imports + +```typescript +import { type Page, expect, test } from '@playwright/test'; +// Prisma enums if needed for DB assertions +import { SomePrismaEnum } from '@prisma/client'; + +import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; + +import { + type TEnvelopeEditorSurface, // Import needed helpers from the fixture + openDocumentEnvelopeEditor, + openEmbeddedEnvelopeEditor, + openTemplateEnvelopeEditor, + persistEmbeddedEnvelope, // ... other helpers +} from '../fixtures/envelope-editor'; +import { expectToastTextToBeVisible } from '../fixtures/generic'; +``` + +### 2. Type definitions and constants + +```typescript +type FlowResult = { + externalId: string; + // ... other data needed for DB assertions +}; + +const TEST_VALUES = { + // Centralized test data constants +}; +``` + +### 3. Local helper functions + +```typescript +// Common: open settings and set external ID for DB lookup +const openSettingsDialog = async (root: Page) => { + await getEnvelopeEditorSettingsTrigger(root).click(); + await expect(root.getByRole('heading', { name: 'Document Settings' })).toBeVisible(); +}; + +const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: string) => { + await openSettingsDialog(surface.root); + await surface.root.locator('input[name="externalId"]').fill(externalId); + await surface.root.getByRole('button', { name: 'Update' }).click(); + + if (!surface.isEmbedded) { + await expectToastTextToBeVisible(surface.root, 'Envelope updated'); + } +}; +``` + +### 4. The flow function + +A single `runXxxFlow` function that works across ALL surfaces. It handles embedded vs non-embedded differences internally: + +```typescript +const runMyFeatureFlow = async (surface: TEnvelopeEditorSurface): Promise => { + const externalId = `e2e-feature-${nanoid()}`; + + // For embedded create, may need to add a PDF first + if (surface.isEmbedded && !surface.envelopeId) { + await addEnvelopeItemPdf(surface.root, 'embedded-feature.pdf'); + } + + await updateExternalId(surface, externalId); + + // Handle embedded vs native differences + if (surface.isEmbedded) { + // No "Add Myself" button in embedded mode + await setRecipientEmail(surface.root, 0, 'embedded@example.com'); + } else { + await clickAddMyselfButton(surface.root); + } + + // ... perform feature-specific actions ... + + // Navigate away and back to verify UI persistence + await clickEnvelopeEditorStep(surface.root, 'addFields'); + await clickEnvelopeEditorStep(surface.root, 'upload'); + + // ... assert UI state after navigation ... + + return { externalId /* ... */ }; +}; +``` + +### 5. Database assertion function + +Uses Prisma directly to verify data was persisted correctly: + +```typescript +const assertFeaturePersistedInDatabase = async ({ + surface, + externalId, + // ... expected values +}: { + surface: TEnvelopeEditorSurface; + externalId: string; + // ... +}) => { + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { + externalId, + userId: surface.userId, + teamId: surface.teamId, + type: surface.envelopeType, + }, + include: { + // Include related data as needed + documentMeta: true, + recipients: true, + fields: true, + envelopeAttachments: true, + }, + orderBy: { createdAt: 'desc' }, + }); + + // Assert expected values + expect(envelope.someField).toBe(expectedValue); +}; +``` + +### 6. The four `test.describe` blocks + +Tests are organized into four `test.describe` blocks, one per editor surface. Each describe block contains the tests relevant to that surface. This structure allows adding multiple tests per surface while keeping them grouped: + +```typescript +test.describe('document editor', () => { + test('description of what is tested', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + const result = await runMyFeatureFlow(surface); + + await assertFeaturePersistedInDatabase({ + surface, + ...result, + }); + }); + + // Additional document-editor-specific tests here... +}); + +test.describe('template editor', () => { + test('description of what is tested', async ({ page }) => { + const surface = await openTemplateEnvelopeEditor(page); + const result = await runMyFeatureFlow(surface); + + await assertFeaturePersistedInDatabase({ + surface, + ...result, + }); + }); + + // Additional template-editor-specific tests here... +}); + +test.describe('embedded create', () => { + test('description of what is tested', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'DOCUMENT', + tokenNamePrefix: 'e2e-embed-feature', + }); + + const result = await runMyFeatureFlow(surface); + + // IMPORTANT: Must persist before DB assertions for embedded + await persistEmbeddedEnvelope(surface); + + await assertFeaturePersistedInDatabase({ + surface, + ...result, + }); + }); + + // Additional embedded-create-specific tests here... +}); + +test.describe('embedded edit', () => { + test('description of what is tested', async ({ page }) => { + const surface = await openEmbeddedEnvelopeEditor(page, { + envelopeType: 'TEMPLATE', + mode: 'edit', + tokenNamePrefix: 'e2e-embed-feature', + }); + + const result = await runMyFeatureFlow(surface); + + // IMPORTANT: Must persist before DB assertions for embedded + await persistEmbeddedEnvelope(surface); + + await assertFeaturePersistedInDatabase({ + surface, + ...result, + }); + }); + + // Additional embedded-edit-specific tests here... +}); +``` + +When a test only applies to specific surfaces (e.g., a document-only action like "send document"), only include it in the relevant describe block(s). Not every describe block needs the same tests -- the structure groups tests by surface, not by requiring symmetry. + +## Key Differences Between Surfaces + +| Behavior | Document/Template | Embedded Create | Embedded Edit | +| -------------------------- | -------------------------- | ----------------------------------------- | ----------------------------------------- | +| User seeding | Seed + sign in | Seed + API token | Seed + API token + seed envelope | +| "Add Myself" button | Available | Not available | Not available | +| Toast on settings update | Yes (`'Envelope updated'`) | No | No | +| PDF already attached | Yes (1 item) | No (0 items, must upload) | Yes (1 item) | +| Delete confirmation dialog | Yes (`'Delete'` button) | No (immediate) | No (immediate) | +| DB persistence timing | Immediate (autosaved) | After `persistEmbeddedEnvelope()` | After `persistEmbeddedEnvelope()` | +| Persist button label | N/A | `'Create Document'` / `'Create Template'` | `'Update Document'` / `'Update Template'` | + +## Available Fixture Helpers + +### From `fixtures/envelope-editor.ts` + +**Locator helpers** (return Playwright Locators): + +- `getEnvelopeEditorSettingsTrigger(root)` - Settings gear button +- `getEnvelopeItemTitleInputs(root)` - Title inputs for envelope items +- `getEnvelopeItemDragHandles(root)` - Drag handles for reordering items +- `getEnvelopeItemRemoveButtons(root)` - Remove buttons for items +- `getEnvelopeItemDropzoneInput(root)` - File input for PDF upload +- `getRecipientEmailInputs(root)` - Email inputs for recipients +- `getRecipientNameInputs(root)` - Name inputs for recipients +- `getRecipientRows(root)` - Full recipient row fieldsets +- `getRecipientRemoveButtons(root)` - Remove buttons for recipients +- `getSigningOrderInputs(root)` - Signing order number inputs + +**Action helpers**: + +- `addEnvelopeItemPdf(root, fileName?)` - Upload a PDF to the dropzone +- `clickEnvelopeEditorStep(root, stepId)` - Navigate to a step: `'upload'`, `'addFields'`, `'preview'` +- `clickAddMyselfButton(root)` - Click "Add Myself" (native only) +- `clickAddSignerButton(root)` - Click "Add Signer" +- `setRecipientEmail(root, index, email)` - Fill recipient email +- `setRecipientName(root, index, name)` - Fill recipient name +- `setRecipientRole(root, index, roleLabel)` - Set role via combobox +- `assertRecipientRole(root, index, roleLabel)` - Assert role value +- `toggleSigningOrder(root, enabled)` - Toggle signing order switch +- `toggleAllowDictateSigners(root, enabled)` - Toggle dictate signers switch +- `setSigningOrderValue(root, index, value)` - Set signing order number +- `persistEmbeddedEnvelope(surface)` - Click Create/Update button for embedded flows + +### From `fixtures/generic.ts` + +- `expectTextToBeVisible(page, text)` - Assert text visible on page +- `expectTextToNotBeVisible(page, text)` - Assert text not visible +- `expectToastTextToBeVisible(page, text)` - Assert toast message visible + +## External ID Pattern + +Every test uses an `externalId` (e.g., `e2e-feature-${nanoid()}`) set via the settings dialog. This unique ID is then used in Prisma queries to reliably locate the envelope in the database for assertions. This is critical because multiple tests run in parallel. + +## Running Tests + +```bash +# Run all envelope editor tests +npm run test:dev -w @documenso/app-tests -- --grep "Envelope Editor V2" + +# Run a specific test file +npm run test:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/envelope-recipients.spec.ts + +# Run with UI +npm run test-ui:dev -w @documenso/app-tests -- e2e/envelope-editor-v2/ + +# Run specific test by name +npm run test:dev -w @documenso/app-tests -- --grep "documents/: add myself" +``` + +## Checklist When Writing a New Test + +1. Create the spec file in `packages/app-tests/e2e/envelope-editor-v2/` +2. Import `TEnvelopeEditorSurface` and the three opener functions +3. Import `persistEmbeddedEnvelope` if you need DB assertions for embedded flows +4. Define a `FlowResult` type for data passed between flow and assertion +5. Define `TEST_VALUES` constants for test data +6. Write `updateExternalId` helper (or reuse the pattern) +7. Write the `runXxxFlow` function handling embedded vs native differences +8. Write the `assertXxxPersistedInDatabase` function using Prisma +9. Create four `test.describe` blocks: `'document editor'`, `'template editor'`, `'embedded create'`, `'embedded edit'` +10. Place tests inside the appropriate describe block for each surface +11. For embedded create tests, add a PDF via `addEnvelopeItemPdf` before the flow +12. For embedded tests, call `persistEmbeddedEnvelope(surface)` before DB assertions +13. Use `surface.isEmbedded` to branch on behavioral differences (toasts, "Add Myself", etc.) + +## Common Pitfalls + +- **Missing `persistEmbeddedEnvelope`**: Embedded flows don't autosave. You MUST call this before any DB assertions. +- **PDF required for embedded create**: Embedded create starts with 0 items. Upload a PDF before navigating to fields. +- **Toast assertions in embedded**: Don't assert toasts for settings updates in embedded mode (they don't appear). +- **Parallel test isolation**: Always use a unique `externalId` via `nanoid()` so parallel tests don't collide. +- **Navigation verification**: Navigate away from and back to the current step to verify UI state persistence (the editor may re-render). +- **Delete confirmation**: Native surfaces show a confirmation dialog for item deletion; embedded surfaces delete immediately. diff --git a/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx b/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx new file mode 100644 index 000000000..2975643f6 --- /dev/null +++ b/apps/remix/app/components/dialogs/envelope-delete-dialog.tsx @@ -0,0 +1,215 @@ +import { useEffect, useState } from 'react'; + +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react/macro'; +import { Trans } from '@lingui/react/macro'; +import { DocumentStatus, EnvelopeType } from '@prisma/client'; +import { P, match } from 'ts-pattern'; + +import { useLimits } from '@documenso/ee/server-only/limits/provider/client'; +import { trpc as trpcReact } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +type EnvelopeDeleteDialogProps = { + id: string; + type: EnvelopeType; + trigger?: React.ReactNode; + onDelete?: () => Promise | void; + status: DocumentStatus; + title: string; + canManageDocument: boolean; +}; + +export const EnvelopeDeleteDialog = ({ + id, + type, + trigger, + onDelete, + status, + title, + canManageDocument, +}: EnvelopeDeleteDialogProps) => { + const { toast } = useToast(); + const { refreshLimits } = useLimits(); + const { t } = useLingui(); + + const deleteMessage = msg`delete`; + + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT); + + const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({ + onSuccess: async () => { + void refreshLimits(); + + toast({ + title: t`Document deleted`, + description: t`"${title}" has been successfully deleted`, + duration: 5000, + }); + + await onDelete?.(); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`Something went wrong`, + description: t`This document could not be deleted at this time. Please try again.`, + variant: 'destructive', + duration: 7500, + }); + }, + }); + + useEffect(() => { + if (open) { + setInputValue(''); + setIsDeleteEnabled(status === DocumentStatus.DRAFT); + } + }, [open, status]); + + const onInputChange = (event: React.ChangeEvent) => { + setInputValue(event.target.value); + setIsDeleteEnabled(event.target.value === t(deleteMessage)); + }; + + return ( + !isPending && setOpen(value)}> + {trigger} + + + + + Are you sure? + + + + {canManageDocument ? ( + + You are about to delete "{title}" + + ) : ( + + You are about to hide "{title}" + + )} + + + + {canManageDocument ? ( + + {match(status) + .with(DocumentStatus.DRAFT, () => ( + + {type === EnvelopeType.DOCUMENT ? ( + + Please note that this action is irreversible. Once confirmed, + this document will be permanently deleted. + + ) : ( + + Please note that this action is irreversible. Once confirmed, + this template will be permanently deleted. + + )} + + )) + .with(DocumentStatus.PENDING, () => ( + +

+ + Please note that this action is irreversible. + +

+ +

+ Once confirmed, the following will occur: +

+ +
    +
  • + Document will be permanently deleted +
  • +
  • + Document signing process will be cancelled +
  • +
  • + All inserted signatures will be voided +
  • +
  • + All recipients will be notified +
  • +
+
+ )) + .with(P.union(DocumentStatus.COMPLETED, DocumentStatus.REJECTED), () => ( + +

+ By deleting this document, the following will occur: +

+ +
    +
  • + The document will be hidden from your account +
  • +
  • + Recipients will still retain their copy of the document +
  • +
+
+ )) + .exhaustive()} +
+ ) : ( + + + Please contact support if you would like to revert this action. + + + )} + + {status !== DocumentStatus.DRAFT && canManageDocument && ( + + )} + + + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx index a1943eb07..93e59ec67 100644 --- a/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx +++ b/apps/remix/app/components/dialogs/envelope-distribute-dialog.tsx @@ -13,6 +13,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 { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients'; import { trpc, trpc as trpcReact } from '@documenso/trpc/react'; @@ -116,10 +117,15 @@ export const EnvelopeDistributeDialog = ({ } = form; const { data: emailData, isLoading: isLoadingEmails } = - trpc.enterprise.organisation.email.find.useQuery({ - organisationId: organisation.id, - perPage: 100, - }); + trpc.enterprise.organisation.email.find.useQuery( + { + organisationId: organisation.id, + perPage: 100, + }, + { + ...DO_NOT_INVALIDATE_QUERY_ON_MUTATION, + }, + ); const emails = emailData?.data || []; diff --git a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx index 1a4bee33c..f651cd981 100644 --- a/apps/remix/app/components/embed/authoring/configure-fields-view.tsx +++ b/apps/remix/app/components/embed/authoring/configure-fields-view.tsx @@ -15,7 +15,7 @@ import { PDF_VIEWER_PAGE_SELECTOR, getPdfPagesCount } from '@documenso/lib/const import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { nanoid } from '@documenso/lib/universal/id'; import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers'; -import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; +import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; import { useRecipientColors } from '@documenso/ui/lib/recipient-colors'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; @@ -87,7 +87,7 @@ export const ConfigureFieldsView = ({ const normalizedDocumentData = useMemo(() => { if (envelopeItem) { - return getDocumentDataUrl({ + return getDocumentDataUrlForPdfViewer({ envelopeId: envelopeItem.envelopeId, envelopeItemId: envelopeItem.id, documentDataId: envelopeItem.documentDataId, diff --git a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx index 6b02a3b03..0add3413d 100644 --- a/apps/remix/app/components/embed/embed-direct-template-client-page.tsx +++ b/apps/remix/app/components/embed/embed-direct-template-client-page.tsx @@ -19,11 +19,12 @@ import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn' import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema'; import { isFieldUnsignedAndRequired, isRequiredField, } from '@documenso/lib/utils/advanced-fields-helpers'; -import { getDocumentDataUrl } from '@documenso/lib/utils/envelope-download'; +import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field'; import { trpc } from '@documenso/trpc/react'; @@ -41,7 +42,6 @@ import { useToast } from '@documenso/ui/primitives/use-toast'; import { BrandingLogo } from '~/components/general/branding-logo'; import { PDFViewer } from '~/components/general/pdf-viewer/pdf-viewer'; -import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema'; import { injectCss } from '~/utils/css-vars'; import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form'; @@ -341,10 +341,10 @@ export const EmbedDirectTemplateClientPage = ({ {/* Viewer */}
- + @@ -260,7 +266,10 @@ export const EditorFieldCheckboxForm = ({ void form.trigger(); }} > - + @@ -295,7 +304,7 @@ export const EditorFieldCheckboxForm = ({ Checkbox values

-
@@ -310,7 +319,8 @@ export const EditorFieldCheckboxForm = ({ @@ -325,7 +335,11 @@ export const EditorFieldCheckboxForm = ({ render={({ field }) => ( - + )} @@ -333,6 +347,7 @@ export const EditorFieldCheckboxForm = ({
@@ -229,7 +232,7 @@ export const EditorFieldDropdownForm = ({ render={({ field }) => ( - + @@ -238,6 +241,7 @@ export const EditorFieldDropdownForm = ({