Merge branch 'main' into fix/dev-hmr-full-reload

This commit is contained in:
Ephraim Duncan
2026-05-07 12:40:28 +00:00
committed by GitHub
68 changed files with 2289 additions and 409 deletions
@@ -0,0 +1,94 @@
---
date: 2026-04-22
title: Partial Signed Pdf Download
---
## Summary
Let team members fetch a PDF with all currently-inserted fields burned in while the envelope is still in `PENDING` status. Today the only available bytes for a pending envelope are the original (no fields) - the sealed PDF only materialises after the last recipient signs and the `seal-document` job runs.
Exposed in two places:
- v2 API: `GET /api/v2/envelope/item/{envelopeItemId}/download?version=pending` (API-token auth)
- UI: a `Partial` button in the existing `EnvelopeDownloadDialog`, alongside `Original`. Replaces the `Signed` slot when the envelope is `PENDING`. Backed by the existing session-authed file route `GET /api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending`.
## Scope
- v2 API only (no v1).
- `internalVersion === 2` envelopes only. Legacy v1 returns 400 `ENVELOPE_LEGACY`.
- Team-side / owner only. No recipient-token download path - recipients have the in-app overlay viewer for verification, and a downloadable half-signed PDF is a leak vector for partially-executed contracts. Enforced both at the server (the recipient-token file route does not accept `pending`) and at the UI (the dialog hides the Partial button when a recipient token is set).
- No PKI signature, no certificate page, no audit log appendix - the response is explicitly not a final executed document.
- No watermark or banner text. The filename suffix (`_pending.pdf`), the `Cache-Control: no-store, private` header, and the absence of a PKI signature are sufficient to signal draft status.
## Behaviour
API response matrix (both `/api/v2/envelope/item/{id}/download?version=pending` and the UI-facing `/api/files/envelope/{envelopeId}/envelopeItem/{id}/download/pending`):
| Envelope status | Response |
|---|---|
| `PENDING` (v2) | 200, PDF with currently-inserted fields burned in |
| `PENDING` (v1) | 400 `ENVELOPE_LEGACY` |
| `DRAFT` | 400 `ENVELOPE_DRAFT` |
| `COMPLETED` | 400 `ENVELOPE_COMPLETED` |
| `REJECTED` | 400 `ENVELOPE_REJECTED` |
All v1-vs-v2 / status-mismatch errors are 4xx so callers can cleanly separate them from real server failures (5xx). Specifically v1 PENDING returns 400 not 501: 5xx is reserved for actual server problems, while "this envelope can't satisfy this request shape" is a client-addressable condition.
Filename: `{title}_pending.pdf`.
ETag is content-addressed over `sha256(envelope.status + sorted((field.id, field.customText, field.signature?.id, field.signature?.created) for inserted===true fields))`. Returns 304 on `If-None-Match` match.
No persistent caching. Generated on-demand per request when ETag misses.
Error response shape (envelope item v2 download route and the team-side file route): preserves the existing `{ error: <message> }` field for backwards compatibility and adds `code: <APP_ERROR_CODE>` as a new field for callers that want to branch on it. The document download route (`/document/{documentId}/download`) is untouched.
## UI
`apps/remix/app/components/dialogs/envelope-download-dialog.tsx`:
- The dialog shows `Original` plus one of:
- `Signed` when status is `COMPLETED` (existing behaviour)
- `Partial` when status is `PENDING`, there is no recipient token, and the envelope is not legacy (`!isLegacy`)
- nothing otherwise
- New optional prop `isLegacy?: boolean`. Only consulted to gate the `Partial` button, so callers whose status can never be `PENDING` (DRAFT/COMPLETED/REJECTED hardcoded, or `isComplete: true` matchers) and callers that always set a recipient token can omit it. Three call sites pass it (`isLegacy={envelope.internalVersion === 1}`): `documents-table-action-dropdown.tsx`, `envelope-editor.tsx`, `document-page-view-dropdown.tsx`. The other eight callers were left alone.
Trade-off: a future team-side dialog usage where status could be PENDING but the dev forgets `isLegacy` will silently not render the Partial button. The status gate prevents an actively broken click; missing button is discoverable in testing. Required-prop alternative was rejected because eight of eleven call sites would carry a meaningless value.
## Files
Server:
- `apps/remix/server/api/download/download.types.ts` - added `'pending'` to the `version` enum; split the validator into `param` (envelopeItemId) + `query` (version). The original wiring as a path-param validator was a pre-existing bug: requests like `?version=original` were silently returning the signed PDF since `version` actually arrives as a query string. Fixed as a side effect.
- `packages/trpc/server/envelope-router/download-envelope-item.types.ts` - mirrored the enum change in the OpenAPI schema.
- `apps/remix/server/api/download/download.ts` - the envelope item v2 route now fetches envelope recipients alongside the envelope, branches on `version` when calling the helper, and emits AppError responses as `{ error, code }` consistently across all status codes.
- `apps/remix/server/api/files/files.types.ts` - added `'pending'` to the team-side download schema only. The recipient-token download schema is untouched, so `/api/files/token/.../download/pending` is rejected by the schema validator.
- `apps/remix/server/api/files/files.ts` - the team-side download handler fetches envelope recipients and dispatches the `pending` branch through the same `handleEnvelopeItemFileRequest` helper. Wrapped in a try/catch that returns `{ error, code }` for AppErrors.
- `apps/remix/server/api/files/files.helpers.ts` - `handleEnvelopeItemFileRequest` is now a single entry point taking a discriminated-union options type. The static-file flow (`signed`/`original`) and the on-demand pending flow are private helpers in the same module.
- `packages/lib/server-only/pdf/generate-partial-signed-pdf.ts` (new) - small orchestrator that loads the original PDF, groups inserted fields by page, calls the existing `insertFieldInPDFV2` overlay helper for each page, flattens, and saves.
- `packages/lib/errors/app-error.ts` - added `ENVELOPE_DRAFT`, `ENVELOPE_COMPLETED`, `ENVELOPE_REJECTED`, `ENVELOPE_LEGACY` codes, all mapped to 400. The legacy-envelope case deliberately returns 4xx rather than 501 to keep "this resource can't satisfy this operation" distinct from real 5xx server failures in caller logs/metrics.
Client:
- `packages/lib/utils/envelope-download.ts` - `EnvelopeItemPdfUrlOptions` download variant now allows `'pending'` as a version. The recipient-token URL builder will produce a URL the server rejects, but the dialog gates on no-token at the call site.
- `packages/lib/client-only/download-pdf.ts` - `DocumentVersion` extended; filename suffix logic moved into a small switch (`_signed.pdf`, `_pending.pdf`, `.pdf`).
- `apps/remix/app/components/dialogs/envelope-download-dialog.tsx` - secondary download derivation with the new `Partial` branch, optional `isLegacy` prop.
- `apps/remix/app/components/tables/documents-table-action-dropdown.tsx`, `apps/remix/app/components/general/envelope-editor/envelope-editor.tsx`, `apps/remix/app/components/general/document/document-page-view-dropdown.tsx` - pass `isLegacy={envelope.internalVersion === 1}` (or `row.internalVersion === 1`) to the dialog.
## Verification
1. E2E (`packages/app-tests/e2e/api/v2/partial-signed-pdf-download.spec.ts`):
- Pending envelope, recipient 1 signs, API token download with `?version=pending` returns 200 + PDF; subsequent call with `If-None-Match: <etag>` returns 304; after recipient 2 completes the envelope flips to `COMPLETED` and the same call returns 400 `ENVELOPE_COMPLETED`; `?version=signed` then succeeds.
- Draft envelope returns 400 `ENVELOPE_DRAFT`.
- `internalVersion === 1` pending envelope returns 400 `ENVELOPE_LEGACY`.
2. `npx tsc --noEmit -p apps/remix/tsconfig.json` and `npm run lint`.
3. Manual: open the Documents table or envelope editor on a PENDING envelope (v2), open the download dialog, confirm `Partial` appears alongside `Original` and produces a `_pending.pdf` with current fields burned in. Same dialog on a COMPLETED envelope shows `Signed`. Same dialog on a v1 PENDING envelope shows neither (status gate would show Partial, but the `isLegacy` flag filters it out).
## Out of Scope / Follow-ups
- Recipient-token download path (API and UI) - decided against. Revisit if there is concrete demand and a story for limiting the leak vector.
- v1 API parity / v1 partial rendering - not building. Implementing partial for v1 would require porting `legacy_insertFieldInPDF` / `insertFieldInPDFV1` into a partial-only flow, which is code with no long-term home as v1 is being phased out.
- Document download route (`/document/{documentId}/download`) - untouched. Same error shape and validator wiring as before. Consider normalising to the same `{ error, code }` shape in a follow-up if any caller wants to branch on `code` from that route.
- Persistent caching layer / job-queue generation - revisit if p95 latency on large PDFs becomes an issue.
- Specific toast for `ENVELOPE_LEGACY` in the dialog - currently the catch-all "Something went wrong" handles it. Worth a polish if v1 PENDING envelopes are common in your data and we see complaints. (Note: with the `isLegacy` gate at the UI, the error is unreachable from the dialog itself; the API can still surface it for direct callers.)
@@ -102,17 +102,18 @@ const EnvelopeEditor = ({ presignToken, envelopeId }) => {
### All V2 Authoring Components
| Prop | Type | Required | Description |
| ------------------ | --------- | -------- | -------------------------------------------------------- |
| `presignToken` | `string` | Yes | Authentication token from your backend |
| `externalId` | `string` | No | Your reference ID to link with the envelope |
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
| `css` | `string` | No | Custom CSS string (Platform Plan) |
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
| `className` | `string` | No | CSS class for the iframe |
| `features` | `object` | No | Feature toggles for the authoring experience |
| Prop | Type | Required | Description |
| ---------------- | --------- | -------- | -------------------------------------------------------- |
| `presignToken` | `string` | Yes | Authentication token from your backend |
| `externalId` | `string` | No | Your reference ID to link with the envelope |
| `host` | `string` | No | Custom host URL. Defaults to `https://app.documenso.com` |
| `css` | `string` | No | Custom CSS string (Platform Plan) |
| `cssVars` | `object` | No | [CSS variable](/docs/developers/embedding/css-variables) overrides (Platform Plan) |
| `darkModeDisabled` | `boolean` | No | Disable dark mode (Platform Plan) |
| `language` | `string` | No | Set the UI language. See [Supported Languages](https://github.com/documenso/documenso/tree/main/packages/lib/constants/locales.ts) |
| `className` | `string` | No | CSS class for the iframe |
| `user` | `object` | No | Current user info. When provided, enables the "Add Myself" button in the recipients list. Object with optional `email` and `name` fields |
| `features` | `object` | No | Feature toggles for the authoring experience |
### Create Component Only
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -24,6 +24,19 @@ type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' |
type EnvelopeDownloadDialogProps = {
envelopeId: string;
envelopeStatus: DocumentStatus;
/**
* Whether the envelope is a legacy (v1) envelope. Only consulted to gate the
* partial-download variant: legacy envelopes use a different field-rendering
* pipeline that the partial PDF helper does not implement, so the Partial
* button is hidden for them.
*
* Optional: omit it on call sites where the status can never be PENDING (DRAFT,
* COMPLETED, REJECTED) or when a recipient token is set, since the Partial button
* is also gated on those. Pass it from team-side call sites that can render the
* dialog for a PENDING envelope.
*/
isLegacy?: boolean;
envelopeItems?: EnvelopeItemToDownload[];
/**
@@ -38,6 +51,7 @@ type EnvelopeDownloadDialogProps = {
export const EnvelopeDownloadDialog = ({
envelopeId,
envelopeStatus,
isLegacy,
envelopeItems: initialEnvelopeItems,
token,
trigger,
@@ -51,8 +65,36 @@ export const EnvelopeDownloadDialog = ({
[envelopeItemIdAndVersion: string]: boolean;
}>({});
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
`${envelopeItemId}-${version}`;
const generateDownloadKey = (
envelopeItemId: string,
version: 'original' | 'signed' | 'pending',
) => `${envelopeItemId}-${version}`;
// The dialog shows the original document alongside one of:
// - "Signed" (when the envelope is COMPLETED)
// - "Partial" (when the envelope is PENDING, not legacy, and we are on the
// team/owner side; recipients are intentionally not offered this since the
// partial PDF carries no PKI signature and would create a leak vector for
// half-executed contracts; legacy envelopes use a different rendering
// pipeline that the partial-download helper does not implement)
// - nothing (DRAFT, REJECTED, PENDING with recipient token, or legacy PENDING)
const secondaryDownload = useMemo<{ version: 'signed' | 'pending'; label: string } | null>(() => {
if (envelopeStatus === DocumentStatus.COMPLETED) {
return {
version: 'signed',
label: t({ message: 'Signed', context: 'Signed document (adjective)' }),
};
}
if (envelopeStatus === DocumentStatus.PENDING && !token && !isLegacy) {
return {
version: 'pending',
label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }),
};
}
return null;
}, [envelopeStatus, isLegacy, token, t]);
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpc.envelope.item.getManyByToken.useQuery(
@@ -70,7 +112,7 @@ export const EnvelopeDownloadDialog = ({
const onDownload = async (
envelopeItem: EnvelopeItemToDownload,
version: 'original' | 'signed',
version: 'original' | 'signed' | 'pending',
) => {
const { id: envelopeItemId } = envelopeItem;
@@ -132,7 +174,7 @@ export const EnvelopeDownloadDialog = ({
{Array.from({ length: 1 }).map((_, index) => (
<div
key={index}
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
className="flex items-center gap-2 rounded-lg border border-border bg-card p-4"
>
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
@@ -149,20 +191,20 @@ export const EnvelopeDownloadDialog = ({
envelopeItems.map((item) => (
<div
key={item.id}
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
>
<div className="flex-shrink-0">
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
<FileTextIcon className="text-primary h-5 w-5" />
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
<FileTextIcon className="h-5 w-5 text-primary" />
</div>
</div>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="text-foreground truncate text-sm font-medium" title={item.title}>
<h4 className="truncate text-sm font-medium text-foreground" title={item.title}>
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<p className="mt-0.5 text-xs text-muted-foreground">
<Trans>PDF Document</Trans>
</p>
</div>
@@ -181,18 +223,20 @@ export const EnvelopeDownloadDialog = ({
<Trans context="Original document (adjective)">Original</Trans>
</Button>
{envelopeStatus === DocumentStatus.COMPLETED && (
{secondaryDownload && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, 'signed')}
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
onClick={async () => onDownload(item, secondaryDownload.version)}
loading={
isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]
}
>
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans context="Signed document (adjective)">Signed</Trans>
{!isDownloadingState[
generateDownloadKey(item.id, secondaryDownload.version)
] && <DownloadIcon className="mr-2 h-4 w-4" />}
{secondaryDownload.label}
</Button>
)}
</div>
@@ -34,7 +34,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -44,6 +43,11 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
type OrganisationMemberOption,
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
export type OrganisationGroupCreateDialogProps = {
trigger?: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
@@ -64,6 +68,7 @@ export const OrganisationGroupCreateDialog = ({
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>([]);
const organisation = useCurrentOrganisation();
const form = useForm({
@@ -77,13 +82,6 @@ export const OrganisationGroupCreateDialog = ({
const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation();
const { data: membersFindResult, isLoading: isLoadingMembers } =
trpc.organisation.member.find.useQuery({
organisationId: organisation.id,
});
const members = membersFindResult?.data ?? [];
const onFormSubmit = async ({
name,
organisationRole,
@@ -119,6 +117,7 @@ export const OrganisationGroupCreateDialog = ({
useEffect(() => {
form.reset();
setSelectedMembers([]);
}, [open, form]);
return (
@@ -178,7 +177,7 @@ export const OrganisationGroupCreateDialog = ({
</FormLabel>
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground w-full">
<SelectTrigger className="w-full text-muted-foreground">
<SelectValue />
</SelectTrigger>
@@ -209,16 +208,15 @@ export const OrganisationGroupCreateDialog = ({
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={members.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={isLoadingMembers}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select members`}
<OrganisationMembersMultiSelectCombobox
organisationId={organisation.id}
selectedMembers={selectedMembers}
onChange={(members) => {
setSelectedMembers(members);
field.onChange(members.map((member) => member.id));
}}
className="w-full bg-background"
dataTestId="group-members-picker"
/>
</FormControl>
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
@@ -31,7 +31,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -41,6 +40,10 @@ import {
} from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import {
type OrganisationGroupOption,
OrganisationGroupsMultiSelectCombobox,
} from '~/components/general/organisation-groups-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
export type TeamGroupCreateDialogProps = Omit<DialogPrimitive.DialogProps, 'children'>;
@@ -59,6 +62,7 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => {
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT');
const [selectedGroups, setSelectedGroups] = useState<OrganisationGroupOption[]>([]);
const { t } = useLingui();
const { toast } = useToast();
@@ -74,26 +78,6 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation();
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
organisationId: team.organisationId,
perPage: 100, // Won't really work if they somehow have more than 100 groups.
types: [OrganisationGroupType.CUSTOM],
});
const teamGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
perPage: 100, // Won't really work if they somehow have more than 100 groups.
});
const avaliableOrganisationGroups = useMemo(() => {
const organisationGroups = organisationGroupQuery.data?.data ?? [];
const teamGroups = teamGroupQuery.data?.data ?? [];
return organisationGroups.filter(
(group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id),
);
}, [organisationGroupQuery, teamGroupQuery]);
const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => {
try {
await createTeamGroups({
@@ -121,6 +105,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
if (!open) {
form.reset();
setStep('SELECT');
setSelectedGroups([]);
}
}, [open, form]);
@@ -178,28 +163,24 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={avaliableOrganisationGroups.map((group) => ({
label: group.name ?? group.organisationRole,
value: group.id,
}))}
loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading}
selectedValues={field.value.map(
({ organisationGroupId }) => organisationGroupId,
)}
onChange={(value) => {
<OrganisationGroupsMultiSelectCombobox
organisationId={team.organisationId}
selectedGroups={selectedGroups}
excludeTeamId={team.id}
onChange={(groups) => {
setSelectedGroups(groups);
field.onChange(
value.map((organisationGroupId) => ({
organisationGroupId,
groups.map((group) => ({
organisationGroupId: group.id,
teamRole:
field.value.find(
(value) => value.organisationGroupId === organisationGroupId,
(value) => value.organisationGroupId === group.id,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="bg-background w-full"
emptySelectionPlaceholder={t`Select groups`}
className="w-full bg-background"
dataTestId="team-groups-picker"
/>
</FormControl>
@@ -243,9 +224,8 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
readOnly
className="bg-background"
value={
avaliableOrganisationGroups.find(
({ id }) => id === group.organisationGroupId,
)?.name || t`Untitled Group`
selectedGroups.find(({ id }) => id === group.organisationGroupId)
?.name || t`Untitled Group`
}
/>
</div>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -36,7 +36,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -48,6 +47,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
import {
type OrganisationMemberOption,
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { useCurrentTeam } from '~/providers/team';
export type TeamMemberCreateDialogProps = {
@@ -69,6 +72,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
const [open, setOpen] = useState(false);
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>([]);
const prevInviteDialogOpenRef = useRef(false);
const { t } = useLingui();
@@ -92,25 +96,16 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation();
const organisationMemberQuery = trpc.organisation.member.find.useQuery({
// Lightweight count-only query for org members not already on this team.
// Powers the "no available members" empty state.
const availableMemberCountQuery = trpc.organisation.member.find.useQuery({
organisationId: team.organisationId,
perPage: 1,
excludeTeamId: team.id,
});
const teamMemberQuery = trpc.team.member.find.useQuery({
teamId: team.id,
});
const avaliableOrganisationMembers = useMemo(() => {
const organisationMembers = organisationMemberQuery.data?.data ?? [];
const teamMembers = teamMemberQuery.data?.data ?? [];
return organisationMembers.filter(
(member) => !teamMembers.some((teamMember) => teamMember.id === member.id),
);
}, [organisationMemberQuery, teamMemberQuery]);
const hasNoAvailableMembers =
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
!availableMemberCountQuery.isLoading && (availableMemberCountQuery.data?.count ?? 0) === 0;
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
if (members.length === 0) {
@@ -159,6 +154,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
form.reset();
setStep('SELECT');
setInviteDialogOpen(false);
setSelectedMembers([]);
}
}, [open, form]);
@@ -296,29 +292,24 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
)}
</div>
) : (
<MultiSelectCombobox
options={avaliableOrganisationMembers.map((member) => ({
label: member.name,
value: member.id,
}))}
loading={organisationMemberQuery.isLoading}
selectedValues={field.value.map(
(member) => member.organisationMemberId,
)}
onChange={(value) => {
<OrganisationMembersMultiSelectCombobox
organisationId={team.organisationId}
selectedMembers={selectedMembers}
excludeTeamId={team.id}
onChange={(members) => {
setSelectedMembers(members);
field.onChange(
value.map((organisationMemberId) => ({
organisationMemberId,
members.map((member) => ({
organisationMemberId: member.id,
teamRole:
field.value.find(
(member) =>
member.organisationMemberId === organisationMemberId,
(entry) => entry.organisationMemberId === member.id,
)?.teamRole || TeamMemberRole.MEMBER,
})),
);
}}
className="w-full bg-background"
emptySelectionPlaceholder={t`Select members`}
dataTestId="team-members-picker"
/>
)}
</FormControl>
@@ -394,9 +385,8 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
readOnly
className="bg-background"
value={
organisationMemberQuery.data?.data.find(
({ id }) => id === member.organisationMemberId,
)?.name || ''
selectedMembers.find(({ id }) => id === member.organisationMemberId)
?.name || ''
}
/>
</div>
@@ -1,7 +1,25 @@
import { Trans } from '@lingui/react/macro';
import { Link } from 'react-router';
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
export const EmbedPaywall = () => {
return (
<div>
<h1>Paywall</h1>
<div className="flex min-h-screen items-center justify-center p-4">
<div className="text-center text-muted-foreground">
<p className="text-lg font-semibold">
<Trans>This feature is not available on your current plan</Trans>
</p>
<p className="mt-2 text-sm">
<Trans>
Please contact{' '}
<Link to={`mailto:${SUPPORT_EMAIL}`} target="_blank">
support
</Link>{' '}
if you have any questions.
</Trans>
</p>
</div>
</div>
);
};
+27 -2
View File
@@ -106,6 +106,7 @@ export const SignInForm = ({
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const twoFactorTurnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
@@ -234,6 +235,11 @@ export const SignInForm = ({
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
setIsTwoFactorAuthenticationDialogOpen(true);
// Turnstile tokens are single-use. Clear the consumed one so the
// dialog's fresh widget mounts cleanly and the dialog can't be
// submitted with the stale token before a new one is issued.
setCaptchaToken(null);
return;
}
@@ -393,7 +399,7 @@ export const SignInForm = ({
)}
/>
{turnstileSiteKey && (
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
@@ -545,6 +551,21 @@ export const SignInForm = ({
/>
)}
{turnstileSiteKey && (
<div className="mt-4">
<Turnstile
ref={twoFactorTurnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
}}
/>
</div>
)}
<DialogFooter className="mt-4">
<Button
type="button"
@@ -558,7 +579,11 @@ export const SignInForm = ({
)}
</Button>
<Button type="submit" loading={isSubmitting}>
<Button
type="submit"
loading={isSubmitting}
disabled={Boolean(turnstileSiteKey) && !captchaToken}
>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</DialogFooter>
@@ -104,7 +104,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
token={recipient?.token}
isLegacy={envelope.internalVersion === 1}
token={canManageDocument ? undefined : recipient?.token}
envelopeItems={envelope.envelopeItems}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
@@ -208,8 +208,14 @@ export const EnvelopeEditorRecipientForm = () => {
envelope.fields.filter((field) => field.recipientId === signer.id).length === 0,
);
const currentEditorEmail = isEmbedded ? editorConfig.embedded?.user?.email : user?.email;
const currentEditorName = isEmbedded ? editorConfig.embedded?.user?.name : user?.name;
const hasCurrentEditorInfo = Boolean(currentEditorEmail || currentEditorName);
const isUserAlreadyARecipient = watchedSigners.some(
(signer) => signer.email.toLowerCase() === user?.email?.toLowerCase(),
(signer) => signer.email.toLowerCase() === currentEditorEmail?.toLowerCase(),
);
const hasDocumentBeenSent = recipients.some(
@@ -344,11 +350,11 @@ export const EnvelopeEditorRecipientForm = () => {
const onAddSelfSigner = () => {
if (emptySignerIndex !== -1) {
setValue(`signers.${emptySignerIndex}.name`, user?.name ?? '', {
setValue(`signers.${emptySignerIndex}.name`, currentEditorName ?? '', {
shouldValidate: true,
shouldDirty: true,
});
setValue(`signers.${emptySignerIndex}.email`, user?.email ?? '', {
setValue(`signers.${emptySignerIndex}.email`, currentEditorEmail ?? '', {
shouldValidate: true,
shouldDirty: true,
});
@@ -358,8 +364,8 @@ export const EnvelopeEditorRecipientForm = () => {
appendSigner(
{
formId: nanoid(12),
name: user?.name ?? '',
email: user?.email ?? '',
name: currentEditorName ?? '',
email: currentEditorEmail ?? '',
role: RecipientRole.SIGNER,
actionAuth: [],
signingOrder:
@@ -635,7 +641,7 @@ export const EnvelopeEditorRecipientForm = () => {
</Tooltip>
)}
{!isEmbedded && (
{(!isEmbedded || hasCurrentEditorInfo) && (
<Button
variant="outline"
className="flex flex-row items-center"
@@ -495,6 +495,7 @@ export const EnvelopeEditor = () => {
<EnvelopeDownloadDialog
envelopeId={envelope.id}
envelopeStatus={envelope.status}
isLegacy={envelope.internalVersion === 1}
envelopeItems={envelope.envelopeItems}
trigger={
<Button
@@ -0,0 +1,115 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationGroupType } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
export type OrganisationGroupOption = {
/** Organisation group ID. */
id: string;
name: string;
};
type OrganisationGroupsMultiSelectComboboxProps = {
organisationId: string;
/**
* Currently selected groups. Must include name so chips render with
* proper labels even before the first server search returns results.
*/
selectedGroups: OrganisationGroupOption[];
onChange: (groups: OrganisationGroupOption[]) => void;
/**
* If set, organisation groups already attached to this team are filtered
* out of the search results server-side. Used by "add groups to team" flows.
*/
excludeTeamId?: number;
/**
* Restrict search to specific group types. Defaults to CUSTOM groups only,
* matching how groups are managed in the organisation settings UI.
*/
types?: OrganisationGroupType[];
/** Number of groups to fetch per search call. Defaults to the schema cap (100). */
perPage?: number;
className?: string;
dataTestId?: string;
};
const toOption = (group: OrganisationGroupOption): Option => ({
value: group.id,
label: group.name,
groupName: group.name,
});
const fromOption = (option: Option): OrganisationGroupOption => ({
id: option.value,
name: typeof option.groupName === 'string' ? option.groupName : option.label,
});
/**
* Searchable multi-select combobox for picking organisation groups,
* backed by `trpc.organisation.group.find` with server-side search.
*
* Renders selected groups as chips and supports an unbounded number of
* organisation groups (paged out via debounced server queries).
*/
export const OrganisationGroupsMultiSelectCombobox = ({
organisationId,
selectedGroups,
onChange,
excludeTeamId,
types = [OrganisationGroupType.CUSTOM],
perPage = 100,
className,
dataTestId,
}: OrganisationGroupsMultiSelectComboboxProps) => {
const { _ } = useLingui();
const utils = trpc.useUtils();
const handleSearch = async (query: string): Promise<Option[]> => {
const result = await utils.organisation.group.find.fetch({
organisationId,
query,
page: 1,
perPage,
types,
excludeTeamId,
});
return result.data.map((group) =>
toOption({
id: group.id,
name: group.name ?? '',
}),
);
};
return (
<MultiSelect
className={className}
data-testid={dataTestId}
commandProps={{ label: _(msg`Select groups`) }}
inputProps={{ 'aria-label': _(msg`Select groups`) }}
placeholder={_(msg`Search groups by name`)}
value={selectedGroups.map(toOption)}
onChange={(options) => onChange(options.map(fromOption))}
onSearch={handleSearch}
triggerSearchOnFocus
hideClearAllButton
hidePlaceholderWhenSelected
delay={300}
loadingIndicator={
<p className="py-4 text-center text-sm text-muted-foreground">
<Trans>Loading...</Trans>
</p>
}
emptyIndicator={
<p className="py-4 text-center text-sm text-muted-foreground">
<Trans>No groups found</Trans>
</p>
}
/>
);
};
@@ -0,0 +1,112 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
export type OrganisationMemberOption = {
/** Organisation member ID. */
id: string;
name: string;
email: string;
};
type OrganisationMembersMultiSelectComboboxProps = {
organisationId: string;
/**
* Currently selected members. Must include name/email so chips render with
* proper labels even before the first server search returns results.
*/
selectedMembers: OrganisationMemberOption[];
onChange: (members: OrganisationMemberOption[]) => void;
/**
* If set, organisation members already on this team are filtered out of the
* search results server-side. Used by "add members to team" flows.
*/
excludeTeamId?: number;
/** Number of members to fetch per search call. Defaults to the schema cap (100). */
perPage?: number;
className?: string;
dataTestId?: string;
};
const toOption = (member: OrganisationMemberOption): Option => ({
value: member.id,
label: member.name ? `${member.name} (${member.email})` : member.email,
// Stash these so we can reconstruct OrganisationMemberOption on change.
email: member.email,
name: member.name,
});
const fromOption = (option: Option): OrganisationMemberOption => ({
id: option.value,
name: typeof option.name === 'string' ? option.name : '',
email: typeof option.email === 'string' ? option.email : '',
});
/**
* Searchable multi-select combobox for picking organisation members,
* backed by `trpc.organisation.member.find` with server-side search.
*
* Renders selected members as chips and supports an unbounded number of
* organisation members (paged out via debounced server queries).
*/
export const OrganisationMembersMultiSelectCombobox = ({
organisationId,
selectedMembers,
onChange,
excludeTeamId,
perPage = 100,
className,
dataTestId,
}: OrganisationMembersMultiSelectComboboxProps) => {
const { _ } = useLingui();
const utils = trpc.useUtils();
const handleSearch = async (query: string): Promise<Option[]> => {
const result = await utils.organisation.member.find.fetch({
organisationId,
query,
page: 1,
perPage,
excludeTeamId,
});
return result.data.map((member) =>
toOption({
id: member.id,
name: member.name,
email: member.email,
}),
);
};
return (
<MultiSelect
className={className}
data-testid={dataTestId}
commandProps={{ label: _(msg`Select members`) }}
inputProps={{ 'aria-label': _(msg`Select members`) }}
placeholder={_(msg`Search members by name or email`)}
value={selectedMembers.map(toOption)}
onChange={(options) => onChange(options.map(fromOption))}
onSearch={handleSearch}
triggerSearchOnFocus
hideClearAllButton
hidePlaceholderWhenSelected
delay={300}
loadingIndicator={
<p className="py-4 text-center text-sm text-muted-foreground">
<Trans>Loading...</Trans>
</p>
}
emptyIndicator={
<p className="py-4 text-center text-sm text-muted-foreground">
<Trans>No members found</Trans>
</p>
}
/>
);
};
@@ -152,7 +152,8 @@ export const DocumentsTableActionDropdown = ({
<EnvelopeDownloadDialog
envelopeId={row.envelopeId}
envelopeStatus={row.status}
token={recipient?.token}
isLegacy={row.internalVersion === 1}
token={canManageDocument ? undefined : recipient?.token}
trigger={
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
<div>
@@ -1,4 +1,4 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -17,7 +17,6 @@ import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translation
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
import type { TFindOrganisationMembersResponse } from '@documenso/trpc/server/organisation-router/find-organisation-members.types';
import { Button } from '@documenso/ui/primitives/button';
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import {
@@ -30,7 +29,6 @@ import {
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
import {
Select,
SelectContent,
@@ -42,6 +40,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import {
type OrganisationMemberOption,
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
import { SettingsHeader } from '~/components/general/settings-header';
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
@@ -53,10 +55,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
const groupId = params.id;
const { data: members, isLoading: isLoadingMembers } = trpc.organisation.member.find.useQuery({
organisationId: organisation.id,
});
const { data: groupData, isLoading: isLoadingGroup } = trpc.organisation.group.find.useQuery(
{
organisationId: organisation.id,
@@ -72,10 +70,10 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
const group = groupData?.data.find((g) => g.id === groupId);
if (isLoadingGroup || isLoadingMembers) {
if (isLoadingGroup) {
return (
<div className="flex items-center justify-center rounded-lg py-32">
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@@ -121,7 +119,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
/>
</SettingsHeader>
<OrganisationGroupForm group={group} organisationMembers={members?.data || []} />
<OrganisationGroupForm group={group} />
</div>
);
}
@@ -136,10 +134,9 @@ type TUpdateOrganisationGroupFormSchema = z.infer<typeof ZUpdateOrganisationGrou
type OrganisationGroupFormOptions = {
group: TFindOrganisationGroupsResponse['data'][number];
organisationMembers: TFindOrganisationMembersResponse['data'];
};
const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroupFormOptions) => {
const OrganisationGroupForm = ({ group }: OrganisationGroupFormOptions) => {
const { toast } = useToast();
const { t } = useLingui();
@@ -147,6 +144,16 @@ const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroup
const { mutateAsync: updateOrganisationGroup } = trpc.organisation.group.update.useMutation();
// Track full member details (name/email) keyed by id so chip labels render
// correctly even after the form has been mounted for a while.
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>(() =>
group.members.map((member) => ({
id: member.id,
name: member.name,
email: member.email,
})),
);
const form = useForm<TUpdateOrganisationGroupFormSchema>({
resolver: zodResolver(ZUpdateOrganisationGroupFormSchema),
defaultValues: {
@@ -258,15 +265,15 @@ const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroup
<Trans>Members</Trans>
</FormLabel>
<FormControl>
<MultiSelectCombobox
options={organisationMembers.map((member) => ({
label: member.name || member.email,
value: member.id,
}))}
selectedValues={field.value}
onChange={field.onChange}
<OrganisationMembersMultiSelectCombobox
organisationId={organisation.id}
selectedMembers={selectedMembers}
onChange={(members) => {
setSelectedMembers(members);
field.onChange(members.map((member) => member.id));
}}
className="w-full"
emptySelectionPlaceholder={t`Select members`}
dataTestId="group-members-picker"
/>
</FormControl>
<FormDescription>
@@ -51,6 +51,7 @@ const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema
clientId: true,
autoProvisionUsers: true,
defaultOrganisationRole: true,
allowPersonalOrganisations: true,
})
.extend({
clientSecret: z.string().nullable(),
@@ -120,6 +121,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
allowedDomains: authenticationPortal.allowedDomains.join(' '),
allowPersonalOrganisations: authenticationPortal.allowPersonalOrganisations,
},
});
@@ -161,6 +163,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
autoProvisionUsers: values.autoProvisionUsers,
defaultOrganisationRole: values.defaultOrganisationRole,
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
allowPersonalOrganisations: values.allowPersonalOrganisations,
},
});
@@ -390,6 +393,30 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
)}
/> */}
<FormField
control={form.control}
name="allowPersonalOrganisations"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
<div className="space-y-0.5">
<FormLabel>
<Trans>Allow Personal Organisations</Trans>
</FormLabel>
<p className="text-sm text-muted-foreground">
<Trans>
When enabled, users signing in via SSO for the first time will also receive
their own personal organisation.
</Trans>
</p>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="enabled"
@@ -298,6 +298,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
mode: 'create' as const,
onCreate: async (envelope: Omit<TEditorEnvelope, 'id'>) => createEmbeddedEnvelope(envelope),
customBrandingLogo: Boolean(teamSettings.brandingEnabled && teamSettings.brandingLogo),
user: embedAuthoringOptions.user,
}),
[token],
);
@@ -314,6 +314,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
mode: 'edit' as const,
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
brandingLogo,
user: embedAuthoringOptions.user,
}),
[token],
);
+1 -1
View File
@@ -106,5 +106,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "2.9.1"
"version": "2.10.1"
}
+34 -9
View File
@@ -13,6 +13,7 @@ import { handleEnvelopeItemFileRequest } from '../files/files.helpers';
import {
ZDownloadDocumentRequestParamsSchema,
ZDownloadEnvelopeItemRequestParamsSchema,
ZDownloadEnvelopeItemRequestQuerySchema,
} from './download.types';
export const downloadRoute = new Hono<HonoEnv>()
@@ -23,11 +24,13 @@ export const downloadRoute = new Hono<HonoEnv>()
.get(
'/envelope/item/:envelopeItemId/download',
sValidator('param', ZDownloadEnvelopeItemRequestParamsSchema),
sValidator('query', ZDownloadEnvelopeItemRequestQuerySchema),
async (c) => {
const logger = c.get('logger');
try {
const { envelopeItemId, version } = c.req.valid('param');
const { envelopeItemId } = c.req.valid('param');
const { version } = c.req.valid('query');
const authorizationHeader = c.req.header('authorization');
// Support for both "Authorization: Bearer api_xxx" and "Authorization: api_xxx"
@@ -65,7 +68,16 @@ export const downloadRoute = new Hono<HonoEnv>()
},
},
include: {
envelope: true,
envelope: {
include: {
recipients: {
select: {
role: true,
signingStatus: true,
},
},
},
},
documentData: true,
},
});
@@ -78,23 +90,36 @@ export const downloadRoute = new Hono<HonoEnv>()
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
const baseOptions = {
title: envelopeItem.title,
status: envelopeItem.envelope.status,
documentData: envelopeItem.documentData,
version: version || 'signed',
isDownload: true,
context: c,
} as const;
if (version === 'pending') {
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
envelopeItemId: envelopeItem.id,
envelope: envelopeItem.envelope,
});
}
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
status: envelopeItem.envelope.status,
});
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
if (error.code === AppErrorCode.UNAUTHORIZED) {
return c.json({ error: error.message }, 401);
}
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: error.message }, 400);
// Preserve the existing `{ error }` shape for backwards compatibility;
// `code` is added as a new field for callers that want to branch on it.
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
@@ -2,12 +2,15 @@ import { z } from 'zod';
export const ZDownloadEnvelopeItemRequestParamsSchema = z.object({
envelopeItemId: z.string().describe('The ID of the envelope item to download.'),
});
export const ZDownloadEnvelopeItemRequestQuerySchema = z.object({
version: z
.enum(['original', 'signed'])
.enum(['original', 'signed', 'pending'])
.optional()
.default('signed')
.describe(
'The version of the envelope item to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
'The version of the envelope item to download. "signed" returns the completed document with all signatures and the audit trail, "original" returns the original uploaded document, "pending" returns the original document with currently-inserted fields burned in (only valid while the envelope is in PENDING status; not a final executed document).',
),
});
@@ -15,6 +18,10 @@ export type TDownloadEnvelopeItemRequestParams = z.infer<
typeof ZDownloadEnvelopeItemRequestParamsSchema
>;
export type TDownloadEnvelopeItemRequestQuery = z.infer<
typeof ZDownloadEnvelopeItemRequestQuerySchema
>;
export const ZDownloadDocumentRequestParamsSchema = z.object({
documentId: z.coerce.number().describe('The ID of the document to download.'),
version: z
+167 -13
View File
@@ -2,12 +2,17 @@ import {
type DocumentDataType,
DocumentStatus,
type EnvelopeType,
type RecipientRole,
type SigningStatus,
type TemplateType,
} from '@prisma/client';
import { EnvelopeType as EnvelopeTypeEnum, TemplateType as TemplateTypeEnum } from '@prisma/client';
import contentDisposition from 'content-disposition';
import { type Context } from 'hono';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { generatePartialSignedPdf } from '@documenso/lib/server-only/pdf/generate-partial-signed-pdf';
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
import { sha256 } from '@documenso/lib/universal/crypto';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
@@ -15,30 +20,75 @@ import { prisma } from '@documenso/prisma';
import type { HonoEnv } from '../../router';
type HandleEnvelopeItemFileRequestOptions = {
title: string;
type DocumentDataInput = {
type: DocumentDataType;
data: string;
initialData: string;
};
type EnvelopeForPendingDownload = {
id: string;
status: DocumentStatus;
documentData: {
type: DocumentDataType;
data: string;
initialData: string;
};
version: 'signed' | 'original';
isDownload: boolean;
context: Context<HonoEnv>;
internalVersion: number;
recipients: Array<{
role: RecipientRole;
signingStatus: SigningStatus;
}>;
};
/**
* Helper function to handle envelope item file requests (both view and download)
* Options shape varies by `version`:
* - `signed` / `original`: serves stored bytes; only needs envelope `status` for cache headers.
* - `pending`: generates a fresh PDF with currently-inserted fields burned in; needs the
* full envelope (id, status, internalVersion, recipients) plus envelopeItemId to query fields.
*/
export const handleEnvelopeItemFileRequest = async ({
type HandleEnvelopeItemFileRequestOptions = {
title: string;
documentData: DocumentDataInput;
isDownload: boolean;
context: Context<HonoEnv>;
} & (
| {
version: 'signed' | 'original';
status: DocumentStatus;
}
| {
version: 'pending';
envelopeItemId: string;
envelope: EnvelopeForPendingDownload;
}
);
/**
* Single entry point for envelope item file requests (view and download).
*
* Dispatches on `version`:
* - `signed` / `original`: returns the stored PDF bytes as-is.
* - `pending`: generates an on-demand PDF with all currently-inserted fields burned in.
*/
export const handleEnvelopeItemFileRequest = async (
options: HandleEnvelopeItemFileRequestOptions,
) => {
if (options.version === 'pending') {
return handlePendingFileRequest(options);
}
return handleStaticFileRequest(options);
};
type StaticFileRequestOptions = Extract<
HandleEnvelopeItemFileRequestOptions,
{ version: 'signed' | 'original' }
>;
const handleStaticFileRequest = async ({
title,
status,
documentData,
version,
isDownload,
context: c,
}: HandleEnvelopeItemFileRequestOptions) => {
}: StaticFileRequestOptions) => {
const documentDataToUse = version === 'signed' ? documentData.data : documentData.initialData;
const etag = Buffer.from(sha256(documentDataToUse)).toString('hex');
@@ -88,6 +138,110 @@ export const handleEnvelopeItemFileRequest = async ({
return c.body(file);
};
type PendingFileRequestOptions = Extract<
HandleEnvelopeItemFileRequestOptions,
{ version: 'pending' }
>;
const handlePendingFileRequest = async ({
title,
envelopeItemId,
envelope,
documentData,
context: c,
}: PendingFileRequestOptions) => {
if (envelope.status !== DocumentStatus.PENDING) {
const errorCode = match(envelope.status)
.with(DocumentStatus.DRAFT, () => AppErrorCode.ENVELOPE_DRAFT)
.with(DocumentStatus.COMPLETED, () => AppErrorCode.ENVELOPE_COMPLETED)
.with(DocumentStatus.REJECTED, () => AppErrorCode.ENVELOPE_REJECTED)
.otherwise(() => AppErrorCode.INVALID_REQUEST);
throw new AppError(errorCode, {
message: `Envelope ${envelope.id} must be pending to download a partially signed PDF`,
statusCode: 400,
});
}
if (envelope.internalVersion !== 2) {
throw new AppError(AppErrorCode.ENVELOPE_LEGACY, {
message: `Envelope ${envelope.id} is a legacy envelope and does not support partially signed PDF downloads`,
statusCode: 400,
});
}
const fields = await prisma.field.findMany({
where: {
envelopeItemId,
inserted: true,
},
include: {
signature: true,
},
orderBy: {
id: 'asc',
},
});
const etag = Buffer.from(
sha256(
JSON.stringify({
envelopeStatus: envelope.status,
fields: fields.map((field) => ({
id: field.id,
customText: field.customText,
signatureId: field.signature?.id ?? null,
signatureCreated: field.signature?.created ?? null,
})),
}),
),
).toString('hex');
if (c.req.header('If-None-Match') === etag) {
c.header('ETag', etag);
c.header('Cache-Control', 'no-store, private');
return c.body(null, 304);
}
const file = await getFileServerSide({
type: documentData.type,
data: documentData.initialData,
}).catch((error) => {
console.error(error);
return null;
});
if (!file) {
return c.json({ error: 'File not found' }, 404);
}
const pdf = await generatePartialSignedPdf({
pdfData: file,
fields,
});
c.get('logger').info({
source: 'pendingPdfDownload',
envelopeId: envelope.id,
envelopeItemId,
insertedFieldCount: fields.length,
etag,
});
c.header('Content-Type', 'application/pdf');
c.header('Cache-Control', 'no-store, private');
c.header('ETag', etag);
const baseTitle = title.replace(/\.pdf$/i, '');
const filename = `${baseTitle}_pending.pdf`;
c.header('Content-Disposition', contentDisposition(filename));
return c.body(pdf);
};
type CheckEnvelopeFileAccessOptions = {
userId: number;
teamId: number;
+87 -52
View File
@@ -150,66 +150,101 @@ export const filesRoute = new Hono<HonoEnv>()
'/envelope/:envelopeId/envelopeItem/:envelopeItemId/download/:version?',
sValidator('param', ZGetEnvelopeItemFileDownloadRequestParamsSchema),
async (c) => {
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
const logger = c.get('logger');
const session = await getOptionalSession(c);
try {
const { envelopeId, envelopeItemId, version } = c.req.valid('param');
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const session = await getOptionalSession(c);
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
if (!session.user) {
return c.json({ error: 'Unauthorized' }, 401);
}
const envelope = await prisma.envelope.findFirst({
where: {
id: envelopeId,
},
include: {
envelopeItems: {
where: {
id: envelopeItemId,
},
include: {
documentData: true,
},
},
include: {
documentData: true,
recipients: {
select: {
role: true,
signingStatus: true,
},
},
},
},
});
});
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
if (!envelope) {
return c.json({ error: 'Envelope not found' }, 404);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const hasDownloadAccess = await checkEnvelopeFileAccess({
userId: session.user.id,
teamId: envelope.teamId,
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!hasDownloadAccess) {
return c.json(
{
error: 'User does not have access to the team that this envelope is associated with',
},
403,
);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
const baseOptions = {
title: envelopeItem.title,
documentData: envelopeItem.documentData,
isDownload: true,
context: c,
} as const;
if (version === 'pending') {
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
envelopeItemId: envelopeItem.id,
envelope,
});
}
return await handleEnvelopeItemFileRequest({
...baseOptions,
version,
status: envelope.status,
});
} catch (error) {
logger.error(error);
if (error instanceof AppError) {
const { status, body } = AppError.toRestAPIError(error);
return c.json({ error: body.message, code: error.code }, status);
}
return c.json({ error: 'Internal server error' }, 500);
}
const [envelopeItem] = envelope.envelopeItems;
if (!envelopeItem) {
return c.json({ error: 'Envelope item not found' }, 404);
}
const hasDownloadAccess = await checkEnvelopeFileAccess({
userId: session.user.id,
teamId: envelope.teamId,
envelopeType: envelope.type,
templateType: envelope.templateType,
});
if (!hasDownloadAccess) {
return c.json(
{ error: 'User does not have access to the team that this envelope is associated with' },
403,
);
}
if (!envelopeItem.documentData) {
return c.json({ error: 'Document data not found' }, 404);
}
return await handleEnvelopeItemFileRequest({
title: envelopeItem.title,
status: envelope.status,
documentData: envelopeItem.documentData,
version,
isDownload: true,
context: c,
});
},
)
.get(
+1 -1
View File
@@ -56,7 +56,7 @@ export type TGetEnvelopeItemFileTokenRequestParams = z.infer<
export const ZGetEnvelopeItemFileDownloadRequestParamsSchema = z.object({
envelopeId: z.string().min(1),
envelopeItemId: z.string().min(1),
version: z.enum(['signed', 'original']).default('signed'),
version: z.enum(['signed', 'original', 'pending']).default('signed'),
});
export type TGetEnvelopeItemFileDownloadRequestParams = z.infer<
+36 -24
View File
@@ -19,23 +19,32 @@ const NON_PAGE_PATH_REGEX = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/appl
const EMBED_PATH_REGEX = /^\/embed(\/|\.data|$)/;
/**
* Auth pages reachable from inside an embed iframe during the
* Non-`/embed` page routes that customers iframe directly, plus the auth
* pages reachable from inside an embed iframe during the
* reauth-as-different-account flow.
*
* Signing routes (`/sign/:token`, `/d/:token`):
* Some customer integrations embed these URLs directly (without going
* through `EmbedSignDocument`). Without `frame-ancestors *` here, those
* integrations break with a "refused to connect" iframe error.
*
* Auth routes (`/signin`, `/forgot-password`, `/check-email`,
* `/unverified-account`):
* `apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx`
* does `window.location.href = '/signin?...'` inside the iframe when the
* user needs to sign out and sign back in as a different account, and
* `<SignInForm>` links/navigates to `/forgot-password`, `/check-email`, and
* `/unverified-account` from there.
*
* Without `frame-ancestors *` on these routes, the customer's iframe gets
* blocked the moment the user clicks "Login" in the reauth dialog.
* `/unverified-account` from there. Without `frame-ancestors *` on these
* routes, the customer's iframe gets blocked the moment the user clicks
* "Login" in the reauth dialog.
*
* These routes still get the strict nonced `script-src`/`style-src-elem`
* policy — only `frame-ancestors` is relaxed.
* policy — only `frame-ancestors` is relaxed. The `(\/|\.data|$)` tail
* keeps `/sign` from matching `/signin`/`/signup` and `/d` from matching
* `/dashboard`.
*/
const AUTH_FRAMEABLE_PATH_REGEX =
/^\/(signin|forgot-password|check-email|unverified-account)(\/|\.data|$)/;
const FRAMEABLE_PATH_REGEX =
/^\/(signin|forgot-password|check-email|unverified-account|sign|d)(\/|\.data|$)/;
/**
* Hono context variable name where the per-request CSP nonce is stashed.
@@ -59,7 +68,7 @@ const generateNonce = () => {
return btoa(binary);
};
type CspPathKind = 'embed' | 'auth' | 'default';
type CspPathKind = 'embed' | 'frameable' | 'default';
const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) => {
// `'self'` is included alongside `'strict-dynamic'` as a fallback for
@@ -87,18 +96,18 @@ const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) =
// Embeds inject customer-supplied CSS via runtime-created `<style>`
// elements (see apps/remix/app/utils/css-vars.ts). Nonce-stamping those
// would be brittle for white-label customers, so we accept
// `'unsafe-inline'` on the embed scope only. Auth pages do NOT load
// customer CSS and keep the strict nonced policy.
// `'unsafe-inline'` on the embed scope only. Frameable (auth/signing)
// pages do NOT load customer CSS and keep the strict nonced policy.
if (kind === 'embed') {
directives.push(`style-src-elem 'self' 'unsafe-inline'`);
} else {
directives.push(`style-src-elem 'self' 'nonce-${nonce}'`);
}
// Embed and auth routes are both reachable from inside a customer's
// iframe and therefore need `frame-ancestors *`. Every other page gets
// clickjacking protection.
if (kind === 'embed' || kind === 'auth') {
// Embed, signing, and auth routes are all reachable from inside a
// customer's iframe and therefore need `frame-ancestors *`. Every other
// page gets clickjacking protection.
if (kind === 'embed' || kind === 'frameable') {
directives.push(`frame-ancestors *`);
} else {
directives.push(`frame-ancestors 'self'`);
@@ -112,8 +121,8 @@ const classifyPath = (path: string): CspPathKind => {
return 'embed';
}
if (AUTH_FRAMEABLE_PATH_REGEX.test(path)) {
return 'auth';
if (FRAMEABLE_PATH_REGEX.test(path)) {
return 'frameable';
}
return 'default';
@@ -130,13 +139,16 @@ const classifyPath = (path: string): CspPathKind => {
* Router for `<ServerRouter nonce>` and `<Scripts nonce>` etc.
*
* Path-aware classification:
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'` style-src-elem
* (white-label CSS injection), strict nonced script-src.
* - `auth` — wildcard `frame-ancestors` only; needed because the embed
* reauth flow redirects the iframe to `/signin` etc. Strict
* nonced script-src and style-src-elem otherwise.
* - default — strict nonced script-src and style-src-elem,
* `frame-ancestors 'self'` for clickjacking protection.
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'`
* style-src-elem (white-label CSS injection), strict
* nonced script-src.
* - `frameable` — wildcard `frame-ancestors` only; needed because the
* embed reauth flow redirects the iframe to `/signin` etc,
* and because some customers iframe `/sign/:token` and
* `/d/:token` directly without using `EmbedSignDocument`.
* Strict nonced script-src and style-src-elem otherwise.
* - default — strict nonced script-src and style-src-elem,
* `frame-ancestors 'self'` for clickjacking protection.
*/
export const securityHeadersMiddleware = createMiddleware<HonoEnv>(async (c, next) => {
const nonce = generateNonce();
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "2.9.1",
"version": "2.10.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "2.9.1",
"version": "2.10.1",
"hasInstallScript": true,
"workspaces": [
"apps/*",
@@ -407,7 +407,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "2.9.1",
"version": "2.10.1",
"dependencies": {
"@cantoo/pdf-lib": "^2.5.3",
"@documenso/api": "*",
+1 -1
View File
@@ -5,7 +5,7 @@
"apps/*",
"packages/*"
],
"version": "2.9.1",
"version": "2.10.1",
"scripts": {
"postinstall": "patch-package",
"build": "turbo run build",
@@ -0,0 +1,251 @@
import { PDF } from '@libpdf/core';
import type { APIRequestContext } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { DocumentStatus, FieldType, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
import { prisma } from '@documenso/prisma';
import { apiSeedDraftDocument, apiSeedPendingDocument } from '../../fixtures/api-seeds';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
const trpcMutation = async (
request: APIRequestContext,
procedure: string,
input: Record<string, unknown>,
) => {
const res = await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: {
'content-type': 'application/json',
},
data: JSON.stringify({ json: input }),
});
expect(res.ok(), `${procedure} failed: ${await res.text()}`).toBeTruthy();
};
const getPdfBytes = async (response: Awaited<ReturnType<APIRequestContext['get']>>) => {
const body = await response.body();
expect(body.subarray(0, 5).toString()).toBe('%PDF-');
return new Uint8Array(body);
};
const signAndCompleteRecipient = async ({
request,
token,
documentId,
fieldId,
}: {
request: APIRequestContext;
token: string;
documentId: number;
fieldId: number;
}) => {
await trpcMutation(request, 'envelope.field.sign', {
token,
fieldId,
fieldValue: {
type: FieldType.SIGNATURE,
value: 'Signature',
},
});
await trpcMutation(request, 'recipient.completeDocumentWithToken', {
token,
documentId,
});
};
test.describe('API V2 partial signed PDF downloads', () => {
test('returns a PDF with inserted fields, supports ETag, and rejects after completion', async ({
request,
}) => {
const { envelope, token, distributeResult } = await apiSeedPendingDocument(request, {
recipients: [
{ email: 'partial-signer-1@test.documenso.com', name: 'Partial Signer 1' },
{ email: 'partial-signer-2@test.documenso.com', name: 'Partial Signer 2' },
],
fieldsPerRecipient: [
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 15, height: 5 }],
[
{
type: FieldType.SIGNATURE,
page: 1,
positionX: 5,
positionY: 15,
width: 15,
height: 5,
},
],
],
});
const [recipientOne, recipientTwo] = distributeResult.recipients;
const documentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
const envelopeItem = envelope.envelopeItems[0];
const recipientOneField = envelope.fields.find(
(field) => field.recipientId === recipientOne.id && field.type === FieldType.SIGNATURE,
);
const recipientTwoField = envelope.fields.find(
(field) => field.recipientId === recipientTwo.id && field.type === FieldType.SIGNATURE,
);
if (!recipientOneField || !recipientTwoField) {
throw new Error('Expected signature fields not found');
}
await signAndCompleteRecipient({
request,
token: recipientOne.token,
documentId,
fieldId: recipientOneField.id,
});
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: envelope.id,
},
include: {
recipients: true,
},
});
expect(dbEnvelope.status).toBe(DocumentStatus.PENDING);
expect(
dbEnvelope.recipients.find((recipient) => recipient.id === recipientOne.id)?.signingStatus,
).toBe(SigningStatus.SIGNED);
}).toPass();
const downloadUrl = `${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=pending`;
const pendingResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(pendingResponse.status()).toBe(200);
expect(pendingResponse.headers()['content-type']).toContain('application/pdf');
expect(pendingResponse.headers()['cache-control']).toBe('no-store, private');
expect(pendingResponse.headers()['content-disposition']).toContain('_pending.pdf');
const etag = pendingResponse.headers().etag;
expect(etag).toBeTruthy();
const pendingPdfBytes = await getPdfBytes(pendingResponse);
const pendingPdf = await PDF.load(pendingPdfBytes);
const originalEnvelopeItem = await prisma.envelopeItem.findUniqueOrThrow({
where: {
id: envelopeItem.id,
},
include: {
documentData: true,
},
});
const originalPdfBytes = await getFileServerSide({
type: originalEnvelopeItem.documentData.type,
data: originalEnvelopeItem.documentData.initialData,
});
const originalPdf = await PDF.load(new Uint8Array(originalPdfBytes));
// Pending PDF should have the same page count as the original (no cert/audit pages).
expect(pendingPdf.getPageCount()).toBe(originalPdf.getPageCount());
const cachedResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
'If-None-Match': etag,
},
});
expect(cachedResponse.status()).toBe(304);
await signAndCompleteRecipient({
request,
token: recipientTwo.token,
documentId,
fieldId: recipientTwoField.id,
});
await expect(async () => {
const dbEnvelope = await prisma.envelope.findUniqueOrThrow({
where: {
id: envelope.id,
},
});
expect(dbEnvelope.status).toBe(DocumentStatus.COMPLETED);
}).toPass({ timeout: 15_000 });
const completedResponse = await request.get(downloadUrl, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const completedError = await completedResponse.json();
expect(completedResponse.status()).toBe(400);
expect(completedError.code).toBe('ENVELOPE_COMPLETED');
const signedResponse = await request.get(
`${API_BASE_URL}/envelope/item/${envelopeItem.id}/download?version=signed`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
expect(signedResponse.status()).toBe(200);
await getPdfBytes(signedResponse);
});
test('rejects draft and legacy pending envelopes', async ({ request }) => {
const draft = await apiSeedDraftDocument(request, {
recipients: [{ email: 'partial-draft@test.documenso.com', name: 'Draft Signer' }],
});
const draftResponse = await request.get(
`${API_BASE_URL}/envelope/item/${draft.envelope.envelopeItems[0].id}/download?version=pending`,
{
headers: {
Authorization: `Bearer ${draft.token}`,
},
},
);
const draftError = await draftResponse.json();
expect(draftResponse.status()).toBe(400);
expect(draftError.code).toBe('ENVELOPE_DRAFT');
const legacy = await apiSeedPendingDocument(request);
await prisma.envelope.update({
where: {
id: legacy.envelope.id,
},
data: {
internalVersion: 1,
},
});
const legacyResponse = await request.get(
`${API_BASE_URL}/envelope/item/${legacy.envelope.envelopeItems[0].id}/download?version=pending`,
{
headers: {
Authorization: `Bearer ${legacy.token}`,
},
},
);
const legacyError = await legacyResponse.json();
expect(legacyResponse.status()).toBe(400);
expect(legacyError.code).toBe('ENVELOPE_LEGACY');
});
});
@@ -0,0 +1,172 @@
import { type APIRequestContext, expect, test } from '@playwright/test';
import { FieldType, SigningStatus } from '@prisma/client';
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
import { prisma } from '@documenso/prisma';
import { apiSeedPendingDocument } from '../fixtures/api-seeds';
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
type SeededEnvelopes = {
assistantToken: string;
otherEnvelopeFieldId: number;
};
/**
* Seeds two unrelated pending envelopes:
* - Envelope A has an ASSISTANT (with a token) plus a SIGNER.
* - Envelope B is owned by a different user and has a SIGNER with a TEXT field.
*
* Returns the assistant's token from envelope A and the TEXT field id from
* envelope B so callers can exercise signing routes across envelopes.
*/
const seedTwoPendingEnvelopes = async (request: APIRequestContext): Promise<SeededEnvelopes> => {
const envelopeA = await apiSeedPendingDocument(request, {
title: '[TEST] Envelope A',
recipients: [
{
email: `assistant-${Date.now()}@documenso.com`,
name: 'Assistant',
role: 'ASSISTANT',
signingOrder: 1,
},
{
email: `signer-a-${Date.now()}@documenso.com`,
name: 'Signer A',
role: 'SIGNER',
signingOrder: 2,
},
],
fieldsPerRecipient: [
[],
// SIGNER needs a SIGNATURE field so distribution succeeds.
[{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 }],
],
});
const assistant = envelopeA.distributeResult.recipients.find((r) => r.role === 'ASSISTANT');
if (!assistant) {
throw new Error('Assistant recipient not found in envelope A');
}
const envelopeB = await apiSeedPendingDocument(request, {
title: '[TEST] Envelope B',
recipients: [
{
email: `signer-b-${Date.now()}@documenso.com`,
name: 'Signer B',
role: 'SIGNER',
signingOrder: 1,
},
],
// A TEXT field is used as the cross-envelope target. The V2 route has a
// separate guard that blocks assistants from signing SIGNATURE fields,
// which would mask whether the recipient lookup itself was scoped.
fieldsPerRecipient: [
[
{ type: FieldType.SIGNATURE, page: 1, positionX: 5, positionY: 5, width: 5, height: 5 },
{ type: FieldType.TEXT, page: 1, positionX: 5, positionY: 15, width: 5, height: 5 },
],
],
});
const otherEnvelope = await prisma.envelope.findUniqueOrThrow({
where: { id: envelopeB.envelope.id },
include: { fields: true },
});
const textField = otherEnvelope.fields.find((f) => f.type === FieldType.TEXT);
if (!textField) {
throw new Error('TEXT field not found in envelope B');
}
return {
assistantToken: assistant.token,
otherEnvelopeFieldId: textField.id,
};
};
const trpcMutation = async (
request: APIRequestContext,
procedure: string,
input: Record<string, unknown>,
) => {
return await request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
headers: { 'content-type': 'application/json' },
data: JSON.stringify({ json: input }),
});
};
test.describe('[ASSISTANT_SIGNING_AUTH]: cross-envelope field access', () => {
test('envelope.field.sign (V2) rejects fieldId from another envelope', async ({ request }) => {
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
const res = await trpcMutation(request, 'envelope.field.sign', {
token: assistantToken,
fieldId: otherEnvelopeFieldId,
fieldValue: { type: FieldType.TEXT, value: 'TEXT' },
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: otherEnvelopeFieldId },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe('');
});
test('field.signFieldWithToken (V1) rejects fieldId from another envelope', async ({
request,
}) => {
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
const res = await trpcMutation(request, 'field.signFieldWithToken', {
token: assistantToken,
fieldId: otherEnvelopeFieldId,
value: 'TEXT',
isBase64: false,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: otherEnvelopeFieldId },
});
expect(fieldAfter.inserted).toBe(false);
expect(fieldAfter.customText).toBe('');
});
test('field.removeSignedFieldWithToken (V1) rejects fieldId from another envelope', async ({
request,
}) => {
const { assistantToken, otherEnvelopeFieldId } = await seedTwoPendingEnvelopes(request);
// Pre-insert the field so a successful (incorrect) uninsert is detectable.
await prisma.field.update({
where: { id: otherEnvelopeFieldId },
data: { inserted: true, customText: 'pre-existing-value' },
});
const res = await trpcMutation(request, 'field.removeSignedFieldWithToken', {
token: assistantToken,
fieldId: otherEnvelopeFieldId,
});
expect(res.ok()).toBeFalsy();
const fieldAfter = await prisma.field.findUniqueOrThrow({
where: { id: otherEnvelopeFieldId },
include: { recipient: true },
});
expect(fieldAfter.inserted).toBe(true);
expect(fieldAfter.customText).toBe('pre-existing-value');
expect(fieldAfter.recipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
});
});
@@ -312,10 +312,12 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member1' }).click();
await page.getByRole('option', { name: 'Member2' }).click();
await page.getByRole('option', { name: 'Member3' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
@@ -338,8 +340,12 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_A');
await page.getByRole('combobox').filter({ hasText: 'Organisation Admin' }).click();
await page.getByRole('option', { name: 'Organisation Member' }).click();
await page.getByRole('combobox').filter({ hasText: 'Member1, Member2, Member3' }).click();
await page.getByRole('option', { name: 'Member3' }).click();
// Remove Member3 by clicking the X on its chip in the multiselect.
await page
.getByTestId('group-members-picker')
.locator('div', { hasText: /^Member3/ })
.getByRole('button', { name: 'Remove' })
.click();
await page.getByRole('button', { name: 'Update' }).click();
await expect(page.getByText('Group has been updated successfully').first()).toBeVisible();
@@ -348,10 +354,12 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Create a custom member group with the 3 admins to check that they still get the ADMIN roles.
await page.getByRole('button', { name: 'Create group' }).click();
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_ADMINS');
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Admin1' }).click();
await page.getByRole('option', { name: 'Admin2' }).click();
await page.getByRole('option', { name: 'Admin3' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
@@ -374,17 +382,21 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
await page.getByRole('textbox', { name: 'Group Name *' }).fill('CUSTOM_GROUP_B');
await page.getByRole('combobox').filter({ hasText: 'Organisation Member' }).click();
await page.getByRole('option', { name: 'Organisation Admin' }).click();
await page.getByRole('combobox').filter({ hasText: 'Select members' }).click();
await page.getByTestId('group-members-picker').click();
await page.getByRole('option', { name: 'Member4' }).click();
await page.getByRole('option', { name: 'Member5' }).click();
// Close the multiselect dropdown so it doesn't overlap the submit button.
await page.getByRole('heading', { name: 'Create group' }).click();
await page.getByTestId('dialog-create-organisation-button').click();
await expect(page.getByText('Group has been created.').first()).toBeVisible();
// Assign CUSTOM_GROUP_A to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
await page.getByRole('button', { name: 'Add groups' }).click();
await page.getByRole('combobox').click();
await page.getByTestId('team-groups-picker').click();
await page.getByRole('option', { name: 'CUSTOM_GROUP_A', exact: true }).click();
// Close the multiselect dropdown so it doesn't overlap the Next button.
await page.getByRole('heading', { name: 'Add groups' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
@@ -394,8 +406,10 @@ test('[ORGANISATIONS]: manage groups and members', async ({ page }) => {
// Assign CUSTOM_GROUP_B to TeamA
await page.goto(`/t/${teamA}/settings/groups`);
await page.getByRole('button', { name: 'Add groups' }).click();
await page.getByRole('combobox').click();
await page.getByTestId('team-groups-picker').click();
await page.getByRole('option', { name: 'CUSTOM_GROUP_B', exact: true }).click();
// Close the multiselect dropdown so it doesn't overlap the Next button.
await page.getByRole('heading', { name: 'Add groups' }).click();
await page.getByRole('button', { name: 'Next' }).click();
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Manager' }).click();
@@ -76,7 +76,10 @@ export const handleOAuthOrganisationCallbackUrl = async (
},
});
await onCreateUserHook(userToLink).catch((err) => {
await onCreateUserHook(userToLink, {
skipPersonalOrganisation:
!organisation.organisationAuthenticationPortal.allowPersonalOrganisations,
}).catch((err) => {
// Todo: (RR7) Add logging.
console.error(err);
});
+16 -3
View File
@@ -3,7 +3,7 @@ import type { EnvelopeItem } from '@prisma/client';
import { getEnvelopeItemPdfUrl } from '../utils/envelope-download';
import { downloadFile } from './download-file';
type DocumentVersion = 'original' | 'signed';
type DocumentVersion = 'original' | 'signed' | 'pending';
type DownloadPDFProps = {
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
@@ -14,10 +14,24 @@ type DownloadPDFProps = {
* Specifies which version of the document to download.
* 'signed': Downloads the signed version (default).
* 'original': Downloads the original version.
* 'pending': Downloads the original document with currently-inserted fields burned in.
* Only valid while the envelope is in PENDING status. Not supported via
* recipient token.
*/
version?: DocumentVersion;
};
const versionToFilenameSuffix = (version: DocumentVersion): string => {
switch (version) {
case 'signed':
return '_signed.pdf';
case 'pending':
return '_pending.pdf';
case 'original':
return '.pdf';
}
};
export const downloadPDF = async ({
envelopeItem,
token,
@@ -34,10 +48,9 @@ export const downloadPDF = async ({
const blob = await fetch(downloadUrl).then(async (res) => await res.blob());
const baseTitle = (fileName ?? 'document').replace(/\.pdf$/, '');
const suffix = version === 'signed' ? '_signed.pdf' : '.pdf';
downloadFile({
filename: `${baseTitle}${suffix}`,
filename: `${baseTitle}${versionToFilenameSuffix(version)}`,
data: blob,
});
};
+18
View File
@@ -8,12 +8,15 @@ export const VALID_DATE_FORMAT_VALUES = [
DEFAULT_DOCUMENT_DATE_FORMAT,
'yyyy-MM-dd',
'dd/MM/yyyy',
'dd-MM-yyyy',
'MM/dd/yyyy',
'yy-MM-dd',
'MMMM dd, yyyy',
'EEEE, MMMM dd, yyyy',
'dd/MM/yyyy hh:mm a',
'dd/MM/yyyy HH:mm',
'dd-MM-yyyy hh:mm a',
'dd-MM-yyyy HH:mm',
'MM/dd/yyyy hh:mm a',
'MM/dd/yyyy HH:mm',
'dd.MM.yyyy',
@@ -52,6 +55,16 @@ export const DATE_FORMATS = [
label: 'DD/MM/YYYY HH:mm AM/PM',
value: 'dd/MM/yyyy hh:mm a',
},
{
key: 'DDMMYYYY_DASH_TIME',
label: 'DD-MM-YYYY HH:mm',
value: 'dd-MM-yyyy HH:mm',
},
{
key: 'DDMMYYYY_DASH_TIME_12H',
label: 'DD-MM-YYYY HH:mm AM/PM',
value: 'dd-MM-yyyy hh:mm a',
},
{
key: 'MMDDYYYY_TIME',
label: 'MM/DD/YYYY HH:mm',
@@ -117,6 +130,11 @@ export const DATE_FORMATS = [
label: 'DD/MM/YYYY',
value: 'dd/MM/yyyy',
},
{
key: 'DDMMYYYY_DASH',
label: 'DD-MM-YYYY',
value: 'dd-MM-yyyy',
},
{
key: 'MMDDYYYY',
label: 'MM/DD/YYYY',
+24 -2
View File
@@ -12,15 +12,21 @@ export enum AppErrorCode {
'RECIPIENT_EXPIRED' = 'RECIPIENT_EXPIRED',
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_IMPLEMENTED' = 'NOT_IMPLEMENTED',
'NOT_SETUP' = 'NOT_SETUP',
'INVALID_CAPTCHA' = 'INVALID_CAPTCHA',
'UNAUTHORIZED' = 'UNAUTHORIZED',
'FORBIDDEN' = 'FORBIDDEN',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
'RETRY_EXCEPTION' = 'RETRY_EXCEPTION',
'SCHEMA_FAILED' = 'SCHEMA_FAILED',
'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS',
'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED',
'WEBHOOK_INVALID_REQUEST' = 'WEBHOOK_INVALID_REQUEST',
'ENVELOPE_DRAFT' = 'ENVELOPE_DRAFT',
'ENVELOPE_COMPLETED' = 'ENVELOPE_COMPLETED',
'ENVELOPE_REJECTED' = 'ENVELOPE_REJECTED',
'ENVELOPE_LEGACY' = 'ENVELOPE_LEGACY',
}
export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string; status: number }> =
@@ -32,13 +38,19 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record<string, { code: string;
[AppErrorCode.INVALID_REQUEST]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.INVALID_CAPTCHA]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.NOT_FOUND]: { code: 'NOT_FOUND', status: 404 },
[AppErrorCode.NOT_IMPLEMENTED]: { code: 'INTERNAL_SERVER_ERROR', status: 501 },
[AppErrorCode.NOT_SETUP]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.UNAUTHORIZED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.FORBIDDEN]: { code: 'FORBIDDEN', status: 403 },
[AppErrorCode.UNKNOWN_ERROR]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.RETRY_EXCEPTION]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.SCHEMA_FAILED]: { code: 'INTERNAL_SERVER_ERROR', status: 500 },
[AppErrorCode.TOO_MANY_REQUESTS]: { code: 'TOO_MANY_REQUESTS', status: 429 },
[AppErrorCode.TWO_FACTOR_AUTH_FAILED]: { code: 'UNAUTHORIZED', status: 401 },
[AppErrorCode.ENVELOPE_DRAFT]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_COMPLETED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_REJECTED]: { code: 'BAD_REQUEST', status: 400 },
[AppErrorCode.ENVELOPE_LEGACY]: { code: 'BAD_REQUEST', status: 400 },
};
export const ZAppErrorJsonSchema = z.object({
@@ -216,15 +228,25 @@ export class AppError extends Error {
}
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
status: 400 | 401 | 403 | 404 | 500 | 501;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
AppErrorCode.ENVELOPE_DRAFT,
AppErrorCode.ENVELOPE_COMPLETED,
AppErrorCode.ENVELOPE_REJECTED,
AppErrorCode.ENVELOPE_LEGACY,
() => 400 as const,
)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.FORBIDDEN, () => 403 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.with(AppErrorCode.NOT_IMPLEMENTED, () => 501 as const)
.otherwise(() => 500 as const);
return {
@@ -35,6 +35,7 @@ export const getFieldsForToken = async ({ token }: GetFieldsForTokenOptions) =>
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
},
envelope: {
id: recipient.envelopeId,
@@ -38,6 +38,7 @@ export const removeSignedFieldWithToken = async ({
signingStatus: {
not: SigningStatus.SIGNED,
},
envelopeId: recipient.envelopeId,
}),
},
},
@@ -78,6 +78,7 @@ export const signFieldWithToken = async ({
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
}),
},
},
@@ -0,0 +1,80 @@
import { PDF } from '@libpdf/core';
import { groupBy } from 'remeda';
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
import { insertFieldInPDFV2 } from './insert-field-in-pdf-v2';
type GeneratePartialSignedPdfOptions = {
pdfData: Uint8Array;
fields: FieldWithSignature[];
};
/**
* Generates a PDF with all currently-inserted fields burned in. Used to serve
* partially signed envelopes during the `PENDING` window before the seal job
* has had a chance to produce the final sealed PDF.
*
* No PKI signature, no certificate page, no audit log appendix - this is a
* preview of the in-progress envelope, not a final executed document.
*/
export const generatePartialSignedPdf = async ({
pdfData,
fields,
}: GeneratePartialSignedPdfOptions) => {
const pdfDoc = await PDF.load(pdfData);
pdfDoc.flattenAll();
pdfDoc.upgradeVersion('1.7');
const fieldsGroupedByPage = groupBy(fields, (field) => field.page);
for (const [pageNumber, pageFields] of Object.entries(fieldsGroupedByPage)) {
const page = pdfDoc.getPage(Number(pageNumber) - 1);
if (!page) {
throw new Error(`Page ${pageNumber} does not exist`);
}
const pageWidth = page.width;
const pageHeight = page.height;
const overlayBytes = await insertFieldInPDFV2({
pageWidth,
pageHeight,
fields: pageFields,
});
const overlayPdf = await PDF.load(overlayBytes);
const embeddedPage = await pdfDoc.embedPage(overlayPdf, 0);
let translateX = 0;
let translateY = 0;
switch (page.rotation) {
case 90:
translateX = pageHeight;
translateY = 0;
break;
case 180:
translateX = pageWidth;
translateY = pageHeight;
break;
case 270:
translateX = 0;
translateY = pageWidth;
break;
}
page.drawPage(embeddedPage, {
x: translateX,
y: translateY,
rotate: {
angle: page.rotation,
},
});
}
pdfDoc.flattenAll();
return await pdfDoc.save({ useXRefStream: true });
};
+16 -2
View File
@@ -60,13 +60,27 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
return user;
};
export type OnCreateUserHookOptions = {
/**
* When true, do not create a "Personal Organisation" for the new user.
* Used by the Organisation SSO signup path, where the user is intended
* to operate inside the SSO organisation rather than a personal space.
*
* Defaults to false — preserves the historical behaviour of creating a
* personal organisation for every new user.
*/
skipPersonalOrganisation?: boolean;
};
/**
* Should be run after a user is created, example during email password signup or google sign in.
*
* @returns User
*/
export const onCreateUserHook = async (user: User) => {
await createPersonalOrganisation({ userId: user.id });
export const onCreateUserHook = async (user: User, options: OnCreateUserHookOptions = {}) => {
if (!options.skipPersonalOrganisation) {
await createPersonalOrganisation({ userId: user.id });
}
return user;
};
+53 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:34\n"
"PO-Revision-Date: 2026-05-07 05:08\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1542,6 +1542,10 @@ msgstr "Erlaube allen Organisationsmitgliedern den Zugriff auf dieses Team"
msgid "Allow document recipients to reply directly to this email address"
msgstr "Erlauben Sie den Dokumentempfängern, direkt an diese E-Mail-Adresse zu antworten"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "Persönliche Organisationen erlauben"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "Nicht übertragen (alle Dokumente löschen)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "Umschlag verteilt"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "Umschlag-ID"
@@ -6329,6 +6338,8 @@ msgstr "Lade Vorschläge ..."
#: 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "Wird geladen..."
@@ -6905,10 +6916,18 @@ msgstr "Noch keine Ordner."
msgid "No further action is required from you at this time."
msgstr "Es sind derzeit keine weiteren Maßnahmen Ihrerseits erforderlich."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "Keine Gruppen gefunden"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Keine Lizenz konfiguriert"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "Keine Mitglieder gefunden"
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt"
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "Bezahlt"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "Teilweise unterzeichnet"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "Bitte bestätige deine E-Mail"
msgid "Please confirm your email address"
msgstr "Bitte bestätige deine E-Mail-Adresse"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "Bitte kontaktieren Sie <0>den Support</0>, wenn Sie Fragen haben."
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "Bitte kontaktieren Sie den Support, wenn Sie diese Aktion rückgängig machen möchten."
@@ -7694,8 +7722,6 @@ msgstr "Bitte geben Sie einen aussagekräftigen Namen für Ihr Token ein. Dies w
msgid "Please enter a number"
msgstr "Bitte gib eine Zahl ein"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Bitte geben Sie einen gültigen Namen ein."
@@ -8761,10 +8787,18 @@ msgstr "Dokumente suchen..."
msgid "Search folders..."
msgstr "Ordner durchsuchen..."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "Gruppen nach Namen suchen"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "Sprachen suchen..."
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "Mitglieder nach Namen oder E-Mail suchen"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "Geheim"
@@ -8884,7 +8918,8 @@ msgstr "Standardrolle auswählen"
msgid "Select direction"
msgstr "Richtung auswählen"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "Gruppen auswählen"
@@ -8896,9 +8931,8 @@ msgstr "Mitgliedsgruppen auswählen, die dem Team hinzugefügt werden sollen."
msgid "Select groups to add to this team"
msgstr "Wählen Sie Gruppen aus, die diesem Team hinzugefügt werden sollen"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "Mitglieder auswählen"
@@ -10777,6 +10811,10 @@ msgstr "Dieses Kuvert kann momentan nicht verteilt werden. Bitte versuchen Sie e
msgid "This envelope could not be resent at this time. Please try again."
msgstr "Dieses Kuvert konnte derzeit nicht erneut gesendet werden. Bitte versuchen Sie es erneut."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "Diese Funktion ist in Ihrem aktuellen Tarif nicht verfügbar."
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "Dieses Feld kann nicht geändert oder gelöscht werden. Wenn Sie den direkten Link dieser Vorlage teilen oder zu Ihrem öffentlichen Profil hinzufügen, kann jeder, der darauf zugreift, seinen Namen und seine E-Mail-Adresse eingeben und die ihm zugewiesenen Felder ausfüllen."
@@ -11737,10 +11775,6 @@ msgstr "Benutzerprofile sind hier!"
msgid "User settings"
msgstr "Benutzereinstellungen"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "Ein Benutzer mit dieser E-Mail existiert bereits. Bitte verwenden Sie eine andere E-Mail-Adresse."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12286,6 +12320,10 @@ msgstr "Wir konnten das Token nicht in Ihre Zwischenablage kopieren. Bitte versu
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "Wir konnten Ihren Wiederherstellungscode nicht in Ihre Zwischenablage kopieren. Bitte versuchen Sie es erneut."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "Wir konnten Ihr Konto nicht erstellen. Wenn Sie bereits ein Konto haben, versuchen Sie sich stattdessen anzumelden."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "Wir konnten Ihr Konto nicht erstellen. Bitte überprüfen Sie die von Ihnen angegebenen Informationen und versuchen Sie es erneut."
@@ -12481,6 +12519,10 @@ msgstr "Was Sie mit Teams machen können:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "Wenn aktiviert, können Unterzeichner auswählen, wer als nächster in der Reihenfolge unterzeichnen soll, anstatt der vorgegebenen Reihenfolge zu folgen."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "Wenn diese Option aktiviert ist, erhalten Benutzer, die sich zum ersten Mal über SSO anmelden, zusätzlich ihre eigene persönliche Organisation."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "Wenn Sie auf Fortfahren klicken, werden Sie aufgefordert, den ersten verfügbaren Authenticator auf Ihrem System hinzuzufügen."
+30 -6
View File
@@ -1537,6 +1537,10 @@ msgstr "Allow all organisation members to access this team"
msgid "Allow document recipients to reply directly to this email address"
msgstr "Allow document recipients to reply directly to this email address"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "Allow Personal Organisations"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4310,6 +4314,7 @@ msgstr "Don't transfer (Delete all documents)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4917,6 +4922,10 @@ msgstr "Envelope distributed"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "Envelope ID"
@@ -7418,6 +7427,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "Paid"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "Partial"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7673,6 +7687,10 @@ msgstr "Please confirm your email"
msgid "Please confirm your email address"
msgstr "Please confirm your email address"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "Please contact <0>support</0> if you have any questions."
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "Please contact support if you would like to revert this action."
@@ -7689,8 +7707,6 @@ msgstr "Please enter a meaningful name for your token. This will help you identi
msgid "Please enter a number"
msgstr "Please enter a number"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Please enter a valid name."
@@ -10772,6 +10788,10 @@ msgstr "This envelope could not be distributed at this time. Please try again."
msgid "This envelope could not be resent at this time. Please try again."
msgstr "This envelope could not be resent at this time. Please try again."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "This feature is not available on your current plan"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
@@ -11732,10 +11752,6 @@ msgstr "User profiles are here!"
msgid "User settings"
msgstr "User settings"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "User with this email already exists. Please use a different email address."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12281,6 +12297,10 @@ msgstr "We were unable to copy the token to your clipboard. Please try again."
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "We were unable to copy your recovery code to your clipboard. Please try again."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "We were unable to create your account. If you already have an account, try signing in instead."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "We were unable to create your account. Please review the information you provided and try again."
@@ -12476,6 +12496,10 @@ msgstr "What you can do with teams:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "When you click continue, you will be prompted to add the first available authenticator on your system."
+53 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:34\n"
"PO-Revision-Date: 2026-05-07 05:08\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1542,6 +1542,10 @@ msgstr "Permitir a todos los miembros de la organización acceder a este equipo"
msgid "Allow document recipients to reply directly to this email address"
msgstr "Permitir que los destinatarios del documento respondan directamente a esta dirección de correo electrónico"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "Permitir organizaciones personales"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "No transferir (Eliminar todos los documentos)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "Sobre distribuido"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "ID de Sobre"
@@ -6329,6 +6338,8 @@ msgstr "Cargando sugerencias..."
#: 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "Cargando..."
@@ -6905,10 +6916,18 @@ msgstr "Aún no hay carpetas."
msgid "No further action is required from you at this time."
msgstr "No further action is required from you at this time."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "No se encontraron grupos"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Licencia no configurada"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "No se encontraron miembros"
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "No hay miembros seleccionados"
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "De pago"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "Parcial"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "Por favor confirma tu correo electrónico"
msgid "Please confirm your email address"
msgstr "Por favor confirma tu dirección de correo electrónico"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "Comunícate con el <0>soporte</0> si tienes alguna pregunta."
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "Por favor, contacta al soporte si deseas revertir esta acción."
@@ -7694,8 +7722,6 @@ msgstr "Por favor, ingresa un nombre significativo para tu token. Esto te ayudar
msgid "Please enter a number"
msgstr "Por favor ingresa un número"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Por favor, introduce un nombre válido."
@@ -8761,10 +8787,18 @@ msgstr "Buscar documentos..."
msgid "Search folders..."
msgstr "Buscar carpetas..."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "Buscar grupos por nombre"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "Buscar idiomas..."
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "Buscar miembros por nombre o correo electrónico"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "Secreto"
@@ -8884,7 +8918,8 @@ msgstr "Seleccione el rol predeterminado"
msgid "Select direction"
msgstr "Seleccione dirección"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "Seleccionar grupos"
@@ -8896,9 +8931,8 @@ msgstr "Seleccionar grupos de miembros para añadir al equipo."
msgid "Select groups to add to this team"
msgstr "Seleccionar grupos para añadir a este equipo"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "Seleccione miembros"
@@ -10777,6 +10811,10 @@ msgstr "Este sobre no se pudo distribuir en este momento. Por favor, inténtelo
msgid "This envelope could not be resent at this time. Please try again."
msgstr "Este sobre no se pudo reenviar en este momento. Por favor, inténtelo de nuevo."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "Esta función no está disponible en tu plan actual"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "Este campo no se puede modificar ni eliminar. Cuando comparta el enlace directo de esta plantilla o lo agregue a su perfil público, cualquiera que acceda podrá ingresar su nombre y correo electrónico, y completar los campos que se le hayan asignado."
@@ -11737,10 +11775,6 @@ msgstr "¡Los perfiles de usuario están aquí!"
msgid "User settings"
msgstr "Configuraciones del usuario"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "Un usuario con este correo electrónico ya existe. Por favor, use una dirección de correo diferente."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12286,6 +12320,10 @@ msgstr "No pudimos copiar el token en tu portapapeles. Por favor, inténtalo de
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "No pudimos copiar tu código de recuperación en tu portapapeles. Por favor, inténtalo de nuevo."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "No hemos podido crear tu cuenta. Si ya tienes una, intenta iniciar sesión en su lugar."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "No pudimos crear su cuenta. Revise la información que proporcionó e inténtelo de nuevo."
@@ -12481,6 +12519,10 @@ msgstr "Lo que puedes hacer con los equipos:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "Cuando está habilitado, los firmantes pueden elegir quién debe firmar a continuación en la secuencia en lugar de seguir el orden predefinido."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "Cuando esté habilitado, los usuarios que inicien sesión mediante SSO por primera vez también recibirán su propia organización personal."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "Cuando haces clic en continuar, se te pedirá que añadas el primer autenticador disponible en tu sistema."
+53 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: fr\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:34\n"
"PO-Revision-Date: 2026-05-07 05:08\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
@@ -1542,6 +1542,10 @@ msgstr "Permettre à tous les membres de l'organisation d'accéder à cette équ
msgid "Allow document recipients to reply directly to this email address"
msgstr "Autoriser les destinataires du document à répondre directement à cette adresse e-mail"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "Autoriser les organisations personnelles"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "Ne pas transférer (supprimer tous les documents)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "Enveloppe distribuée"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "ID de l'enveloppe"
@@ -6329,6 +6338,8 @@ msgstr "Chargement des 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "Chargement..."
@@ -6905,10 +6916,18 @@ msgstr "Aucun dossier pour le moment."
msgid "No further action is required from you at this time."
msgstr "Aucune autre action n'est requise de votre part pour le moment."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "Aucun groupe trouvé"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Aucune licence configurée"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "Aucun membre trouvé"
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "Aucun membre sélectionné"
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "Payé"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "Partiel"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "Veuillez confirmer votre email"
msgid "Please confirm your email address"
msgstr "Veuillez confirmer votre adresse email"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "Veuillez contacter <0>lassistance</0> si vous avez des questions."
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "Veuillez contacter le support si vous souhaitez annuler cette action."
@@ -7694,8 +7722,6 @@ msgstr "Veuillez entrer un nom significatif pour votre token. Cela vous aidera
msgid "Please enter a number"
msgstr "Veuillez entrer un nombre"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Veuiillez entrer un nom valide."
@@ -8761,10 +8787,18 @@ msgstr "Rechercher des documents..."
msgid "Search folders..."
msgstr "Rechercher dans les dossiers..."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "Rechercher des groupes par nom"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "Rechercher des langues..."
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "Rechercher des membres par nom ou adresse e-mail"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "Secret"
@@ -8884,7 +8918,8 @@ msgstr "Sélectionner le rôle par défaut"
msgid "Select direction"
msgstr "Sélectionner la direction"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "Sélectionnez des groupes"
@@ -8896,9 +8931,8 @@ msgstr "Sélectionnez des groupes de membres à ajouter à l'équipe."
msgid "Select groups to add to this team"
msgstr "Sélectionnez des groupes à ajouter à cette équipe"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "Sélectionnez des membres"
@@ -10777,6 +10811,10 @@ msgstr "Cette enveloppe n'a pas pu être distribuée pour le moment. Veuillez r
msgid "This envelope could not be resent at this time. Please try again."
msgstr "Cette enveloppe n'a pas pu être renvoyée pour le moment. Veuillez réessayer."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "Cette fonctionnalité nest pas disponible avec votre offre actuelle"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "Ce champ ne peut pas être modifié ou supprimé. Lorsque vous partagez le lien direct de ce modèle ou l'ajoutez à votre profil public, toute personne qui y accède peut saisir son nom et son email, et remplir les champs qui lui sont attribués."
@@ -11737,10 +11775,6 @@ msgstr "Les profils des utilisateurs sont ici !"
msgid "User settings"
msgstr "Paramètres de l'utilisateur"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "Un utilisateur avec cet e-mail existe déjà. Veuillez utiliser une adresse e-mail différente."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12286,6 +12320,10 @@ msgstr "Nous n'avons pas pu copier le token dans votre presse-papiers. Veuillez
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "Nous n'avons pas pu copier votre code de récupération dans votre presse-papiers. Veuillez réessayer."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "Nous navons pas pu créer votre compte. Si vous avez déjà un compte, essayez plutôt de vous connecter."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "Nous n'avons pas pu créer votre compte. Veuillez vérifier les informations que vous avez fournies et réessayer."
@@ -12481,6 +12519,10 @@ msgstr "Ce que vous pouvez faire avec les équipes :"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "Lorsqu'il est activé, les signataires peuvent choisir qui doit signer ensuite au lieu de suivre l'ordre prédéfini."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "Lorsque cette option est activée, les utilisateurs qui se connectent via SSO pour la première fois reçoivent également leur propre organisation personnelle."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "Lorsque vous cliquez sur continuer, vous serez invité à ajouter le premier authentificateur disponible sur votre système."
+53 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: it\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:34\n"
"PO-Revision-Date: 2026-05-07 05:08\n"
"Last-Translator: \n"
"Language-Team: Italian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1542,6 +1542,10 @@ msgstr "Consenti a tutti i membri dell'organizzazione di accedere a questo team"
msgid "Allow document recipients to reply directly to this email address"
msgstr "Consenti ai destinatari del documento di rispondere direttamente a questo indirizzo email"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "Consenti organizzazioni personali"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "Non trasferire (elimina tutti i documenti)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "Busta distribuita"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "ID Busta"
@@ -6329,6 +6338,8 @@ msgstr "Caricamento suggerimenti..."
#: 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "Caricamento in corso..."
@@ -6905,10 +6916,18 @@ msgstr "Nessuna cartella ancora."
msgid "No further action is required from you at this time."
msgstr "Non sono richieste ulteriori azioni da parte tua in questo momento."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "Nessun gruppo trovato"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Nessuna licenza configurata"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "Nessun membro trovato"
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "Nessun membro selezionato"
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "A pagamento"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "Parzialmente firmato"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "Per favore conferma la tua email"
msgid "Please confirm your email address"
msgstr "Per favore conferma il tuo indirizzo email"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "Contatta il <0>supporto</0> se hai domande."
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "Si prega di contattare il supporto se si desidera annullare questa azione."
@@ -7694,8 +7722,6 @@ msgstr "Si prega di inserire un nome significativo per il proprio token. Questo
msgid "Please enter a number"
msgstr "Inserisci un numero"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Per favore inserisci un nome valido."
@@ -8761,10 +8787,18 @@ msgstr "Cerca documenti..."
msgid "Search folders..."
msgstr "Cerca cartelle..."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "Cerca gruppi per nome"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "Cerca lingue..."
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "Cerca membri per nome o email"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "Segreto"
@@ -8884,7 +8918,8 @@ msgstr "Seleziona ruolo predefinito"
msgid "Select direction"
msgstr "Seleziona la direzione"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "Seleziona gruppi"
@@ -8896,9 +8931,8 @@ msgstr "Seleziona i gruppi di membri da aggiungere al team."
msgid "Select groups to add to this team"
msgstr "Seleziona i gruppi da aggiungere a questo team"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "Seleziona membri"
@@ -10777,6 +10811,10 @@ msgstr "Questa busta non può essere distribuita in questo momento. Per favore r
msgid "This envelope could not be resent at this time. Please try again."
msgstr "Questa busta non può essere reinviata in questo momento. Per favore riprova."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "Questa funzionalità non è disponibile nel tuo piano attuale"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "Questo campo non può essere modificato o eliminato. Quando condividi il link diretto di questo modello o lo aggiungi al tuo profilo pubblico, chiunque vi acceda può inserire il proprio nome e email, e compilare i campi assegnati."
@@ -11737,10 +11775,6 @@ msgstr "I profili utente sono qui!"
msgid "User settings"
msgstr "Impostazioni utente"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "Un utente con questo email esiste già. Si prega di utilizzare un indirizzo email diverso."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12286,6 +12320,10 @@ msgstr "Non siamo riusciti a copiare il token negli appunti. Si prega di riprova
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "Non siamo riusciti a copiare il tuo codice di recupero negli appunti. Si prega di riprovare."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "Non siamo riusciti a creare il tuo account. Se hai già un account, prova ad accedere invece."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "Non siamo riusciti a creare il tuo account. Si prega di rivedere le informazioni fornite e riprovare."
@@ -12481,6 +12519,10 @@ msgstr "Cosa puoi fare con i team:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "Quando abilitato, i firmatari possono scegliere chi deve firmare successivamente nella sequenza invece di seguire l'ordine predefinito."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "Quando è abilitata, gli utenti che eseguono laccesso tramite SSO per la prima volta riceveranno anche una propria organizzazione personale."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "Quando fai clic su continua, ti verrà chiesto di aggiungere il primo autenticatore disponibile sul tuo sistema."
+53 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ja\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:34\n"
"PO-Revision-Date: 2026-05-07 05:08\n"
"Last-Translator: \n"
"Language-Team: Japanese\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1542,6 +1542,10 @@ msgstr "すべての組織メンバーがこのチームにアクセスできる
msgid "Allow document recipients to reply directly to this email address"
msgstr "ドキュメントの受信者が、このメールアドレスに直接返信できるようにします。"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "個人用組織を許可する"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "転送しない(すべてのドキュメントを削除)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "封筒を送信しました"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "封筒ID"
@@ -6329,6 +6338,8 @@ msgstr "候補を読み込み中..."
#: 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "読み込み中..."
@@ -6905,10 +6916,18 @@ msgstr "まだフォルダーがありません。"
msgid "No further action is required from you at this time."
msgstr "現在、お客様が行う必要のある操作はありません。"
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "グループが見つかりません"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "ライセンスが設定されていません"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "メンバーが見つかりません"
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "メンバーが選択されていません"
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "有料"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "一部署名済み"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "メールアドレスを確認してください"
msgid "Please confirm your email address"
msgstr "メールアドレスを確認してください"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "ご不明な点がありましたら、<0>サポート</0>までお問い合わせください。"
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "この操作を元に戻したい場合は、サポートまでお問い合わせください。"
@@ -7694,8 +7722,6 @@ msgstr "トークンの用途が分かる名前を入力してください。後
msgid "Please enter a number"
msgstr "数値を入力してください"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "有効な名前を入力してください。"
@@ -8761,10 +8787,18 @@ msgstr "文書を検索..."
msgid "Search folders..."
msgstr "フォルダを検索…"
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "グループ名で検索"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "言語を検索…"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "名前またはメールアドレスでメンバーを検索"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "シークレット"
@@ -8884,7 +8918,8 @@ msgstr "デフォルトロールを選択"
msgid "Select direction"
msgstr "方向を選択"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "グループを選択"
@@ -8896,9 +8931,8 @@ msgstr "チームに追加するメンバーグループを選択します。"
msgid "Select groups to add to this team"
msgstr "このチームに追加するグループを選択"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "メンバーを選択"
@@ -10777,6 +10811,10 @@ msgstr "この封筒は現在送信できません。もう一度お試しくだ
msgid "This envelope could not be resent at this time. Please try again."
msgstr "この封筒は現在再送信できません。もう一度お試しください。"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "この機能は現在ご利用中のプランではご利用いただけません"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "このフィールドは変更も削除もできません。このテンプレートのダイレクトリンクを共有するか、公開プロフィールに追加すると、アクセスした人は自分の名前とメールアドレスを入力し、割り当てられたフィールドに入力できます。"
@@ -11737,10 +11775,6 @@ msgstr "ユーザープロフィール機能が登場しました!"
msgid "User settings"
msgstr "ユーザー設定"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "このメールアドレスのユーザーはすでに存在します。別のメールアドレスを使用してください。"
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12286,6 +12320,10 @@ msgstr "トークンをクリップボードにコピーできませんでした
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "リカバリーコードをクリップボードにコピーできませんでした。もう一度お試しください。"
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "アカウントを作成できませんでした。すでにアカウントをお持ちの場合は、代わりにサインインしてください。"
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "アカウントを作成できませんでした。入力内容を確認のうえ、もう一度お試しください。"
@@ -12481,6 +12519,10 @@ msgstr "チームでできること:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "有効にすると、定義済みの順序ではなく、署名者が次に署名する人を順次選択できるようになります。"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "有効にすると、SSO で初めてサインインするユーザーには、個人用の組織も自動的に作成されます。"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "続行をクリックすると、システム上で最初に利用可能な認証手段の追加を求められます。"
+53 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: ko\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:34\n"
"PO-Revision-Date: 2026-05-07 05:08\n"
"Last-Translator: \n"
"Language-Team: Korean\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1542,6 +1542,10 @@ msgstr "모든 조직 구성원이 이 팀에 접근할 수 있도록 허용"
msgid "Allow document recipients to reply directly to this email address"
msgstr "문서 수신자가 이 이메일 주소로 직접 회신할 수 있도록 허용합니다."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "개인 조직 허용"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "전송하지 않음(모든 문서 삭제)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "봉투가 배포되었습니다"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "봉투 ID"
@@ -6329,6 +6338,8 @@ msgstr "제안 불러오는 중..."
#: 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "로딩 중..."
@@ -6905,10 +6916,18 @@ msgstr "아직 폴더가 없습니다."
msgid "No further action is required from you at this time."
msgstr "현재 추가로 수행해야 할 작업은 없습니다."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "그룹을 찾을 수 없습니다."
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "라이선스가 구성되지 않았습니다"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "구성원을 찾을 수 없습니다."
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "선택된 구성원이 없습니다."
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "유료"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "부분 완료"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "이메일을 확인해 주세요."
msgid "Please confirm your email address"
msgstr "이메일 주소를 확인해 주세요."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "궁금한 점이 있으시면 <0>지원팀</0>에 문의해 주세요."
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "이 작업을 되돌리려면 지원팀에 문의해 주세요."
@@ -7694,8 +7722,6 @@ msgstr "토큰을 나중에 식별할 수 있도록 의미 있는 이름을 입
msgid "Please enter a number"
msgstr "숫자를 입력하세요"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "올바른 이름을 입력해 주세요."
@@ -8761,10 +8787,18 @@ msgstr "문서 검색..."
msgid "Search folders..."
msgstr "폴더 검색..."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "그룹 이름으로 검색"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "언어 검색..."
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "이름 또는 이메일로 구성원 검색"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "시크릿"
@@ -8884,7 +8918,8 @@ msgstr "기본 역할 선택"
msgid "Select direction"
msgstr "방향 선택"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "그룹 선택"
@@ -8896,9 +8931,8 @@ msgstr "이 팀에 추가할 구성원 그룹을 선택하세요."
msgid "Select groups to add to this team"
msgstr "이 팀에 추가할 그룹을 선택하세요."
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "구성원 선택"
@@ -10777,6 +10811,10 @@ msgstr "현재 이 봉투를 배포할 수 없습니다. 다시 시도해 주세
msgid "This envelope could not be resent at this time. Please try again."
msgstr "현재 이 봉투를 다시 보낼 수 없습니다. 다시 시도해 주세요."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "현재 요금제에서는 이 기능을 이용할 수 없습니다."
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "이 필드는 수정하거나 삭제할 수 없습니다. 이 템플릿의 다이렉트 링크를 공유하거나 공개 프로필에 추가하면, 누구든지 링크에 접근해 이름과 이메일을 입력하고 본인에게 할당된 필드를 작성할 수 있습니다."
@@ -11737,10 +11775,6 @@ msgstr "사용자 프로필이 추가되었습니다!"
msgid "User settings"
msgstr "사용자 설정"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "이 이메일을 사용하는 사용자가 이미 있습니다. 다른 이메일 주소를 사용해 주세요."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12286,6 +12320,10 @@ msgstr "토큰을 클립보드에 복사하지 못했습니다. 다시 시도해
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "복구 코드를 클립보드에 복사하지 못했습니다. 다시 시도해 주세요."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "계정을 생성할 수 없습니다. 이미 계정을 보유하고 있다면 대신 로그인해 주세요."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "계정을 생성하지 못했습니다. 입력하신 정보를 확인한 뒤 다시 시도해 주세요."
@@ -12481,6 +12519,10 @@ msgstr "팀으로 할 수 있는 일:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "활성화하면, 미리 정의된 순서 대신 서명자가 다음 서명자를 직접 선택할 수 있습니다."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "활성화하면, 처음으로 SSO를 통해 로그인하는 사용자는 자신의 개인 조직도 함께 생성됩니다."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "계속을 클릭하면 시스템에서 사용 가능한 첫 번째 인증 수단을 추가하라는 안내가 표시됩니다."
+53 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: nl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:34\n"
"PO-Revision-Date: 2026-05-07 05:08\n"
"Last-Translator: \n"
"Language-Team: Dutch\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -1542,6 +1542,10 @@ msgstr "Alle organisatieleden toestaan toegang te hebben tot dit team"
msgid "Allow document recipients to reply directly to this email address"
msgstr "Sta documentontvangers toe direct op dit e-mailadres te antwoorden."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "Persoonlijke organisaties toestaan"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "Niet overdragen (alle documenten verwijderen)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "Envelope gedistribueerd"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "Envelope-ID"
@@ -6329,6 +6338,8 @@ msgstr "Suggesties laden..."
#: 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "Laden..."
@@ -6905,10 +6916,18 @@ msgstr "Nog geen mappen."
msgid "No further action is required from you at this time."
msgstr "Er is op dit moment geen verdere actie van jou vereist."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "Geen groepen gevonden"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Geen licentie geconfigureerd"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "Geen leden gevonden"
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "Geen leden geselecteerd"
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "Betaald"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "Gedeeltelijk"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "Bevestig je e-mailadres"
msgid "Please confirm your email address"
msgstr "Bevestig je e-mailadres"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "Neem contact op met <0>support</0> als je vragen hebt."
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "Neem contact op met support als je deze actie ongedaan wilt maken."
@@ -7694,8 +7722,6 @@ msgstr "Voer een betekenisvolle naam in voor je token. Hiermee kun je het later
msgid "Please enter a number"
msgstr "Voer een nummer in"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Voer een geldige naam in."
@@ -8761,10 +8787,18 @@ msgstr "Documenten zoeken..."
msgid "Search folders..."
msgstr "Mappen zoeken..."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "Zoek groepen op naam"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "Talen zoeken..."
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "Zoek leden op naam of e-mail"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "Geheim"
@@ -8884,7 +8918,8 @@ msgstr "Selecteer standaardrol"
msgid "Select direction"
msgstr "Selecteer richting"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "Selecteer groepen"
@@ -8896,9 +8931,8 @@ msgstr "Selecteer groepen leden om aan het team toe te voegen."
msgid "Select groups to add to this team"
msgstr "Selecteer groepen om aan dit team toe te voegen"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "Selecteer leden"
@@ -10777,6 +10811,10 @@ msgstr "Deze envelop kon op dit moment niet worden verzonden. Probeer het opnieu
msgid "This envelope could not be resent at this time. Please try again."
msgstr "Deze envelop kon op dit moment niet opnieuw worden verzonden. Probeer het opnieuw."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "Deze functie is niet beschikbaar in je huidige abonnement."
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "Dit veld kan niet worden gewijzigd of verwijderd. Wanneer je de directe link van deze sjabloon deelt of aan je openbare profiel toevoegt, kan iedereen die toegang heeft zijn naam en e-mailadres invoeren en de aan hem toegewezen velden invullen."
@@ -11737,10 +11775,6 @@ msgstr "Gebruikersprofielen zijn er!"
msgid "User settings"
msgstr "Gebruikersinstellingen"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "Er bestaat al een gebruiker met dit e-mailadres. Gebruik een ander e-mailadres."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12286,6 +12320,10 @@ msgstr "We konden het token niet naar je klembord kopiëren. Probeer het opnieuw
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "We konden je herstelcode niet naar je klembord kopiëren. Probeer het opnieuw."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "We konden je account niet aanmaken. Als je al een account hebt, probeer dan in te loggen."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "We konden je account niet aanmaken. Controleer de door jou ingevoerde gegevens en probeer het opnieuw."
@@ -12481,6 +12519,10 @@ msgstr "Wat je met teams kunt doen:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "Wanneer dit is ingeschakeld, kunnen ondertekenaars zelf kiezen wie de volgende in de volgorde moet ondertekenen in plaats van de vooraf gedefinieerde volgorde te volgen."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "Wanneer ingeschakeld, krijgen gebruikers die zich voor het eerst aanmelden via SSO ook hun eigen persoonlijke organisatie."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "Wanneer je op Doorgaan klikt, wordt je gevraagd de eerste beschikbare authenticator op je systeem toe te voegen."
+54 -12
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: pl\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-23 18:23\n"
"PO-Revision-Date: 2026-05-07 05:08\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"
@@ -1542,6 +1542,10 @@ msgstr "Zezwalaj wszystkim użytkownikom organizacji na dostęp do tego zespołu
msgid "Allow document recipients to reply directly to this email address"
msgstr "Odpowiadanie odbiorcom dokumentu bezpośrednio na ten adres e-mail"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "Zezwól na osobiste organizacje"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "Nie przenoś (usuń wszystkie dokumenty)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "Wysłano kopertę"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "Identyfikator koperty"
@@ -6329,6 +6338,8 @@ msgstr "Ładowanie sugestii..."
#: 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "Ładowanie..."
@@ -6905,10 +6916,18 @@ msgstr "Brak folderów."
msgid "No further action is required from you at this time."
msgstr "Nie musisz nic więcej robić."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "Nie znaleziono grup"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "Brak skonfigurowanej licencji"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "Nie znaleziono członków"
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "Nie wybrano żadnych użytkowników"
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "Opłacona"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "Częściowy"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "Potwierdź adres e-mail"
msgid "Please confirm your email address"
msgstr "Potwierdź adres e-mail"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "Jeśli masz jakieś pytania, skontaktuj się z <0>pomocą techniczną</0>."
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "Skontaktuj się z pomocą techniczną, jeśli chcesz cofnąć tę akcję."
@@ -7694,8 +7722,6 @@ msgstr "Wpisz nazwę tokena. Pomoże to później w jego identyfikacji."
msgid "Please enter a number"
msgstr "Wpisz liczbę"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Wpisz prawidłową nazwę."
@@ -8761,10 +8787,18 @@ msgstr "Szukaj dokumentów..."
msgid "Search folders..."
msgstr "Szukaj folderów..."
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "Wyszukaj grupy po nazwie"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "Szukaj języków..."
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "Wyszukaj członków po imieniu, nazwisku lub e-mailu"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "Sekret"
@@ -8884,7 +8918,8 @@ msgstr "Wybierz domyślną rolę"
msgid "Select direction"
msgstr "Wybierz kierunek"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "Wybierz grupy"
@@ -8896,9 +8931,8 @@ msgstr "Wybierz grupy użytkowników do dodania do zespołu."
msgid "Select groups to add to this team"
msgstr "Wybierz grupy, które chcesz dodać do zespołu"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "Wybierz użytkowników"
@@ -10777,6 +10811,10 @@ msgstr "Nie można wysłać koperty. Spróbuj ponownie."
msgid "This envelope could not be resent at this time. Please try again."
msgstr "Nie można ponownie wysłać koperty. Spróbuj ponownie."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "Ta funkcja nie jest dostępna w Twoim obecnym planie."
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "Pole nie może być modyfikowane ani usuwane. Gdy udostępnisz bezpośredni link do szablonu lub dodasz go do profilu publicznego, każdy, kto uzyska do niego dostęp, będzie mógł wpisać swoją nazwę oraz adres e-mail i wypełnić przypisane pola."
@@ -11737,10 +11775,6 @@ msgstr "Profile użytkowników są tutaj!"
msgid "User settings"
msgstr "Ustawienia użytkownika"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "Użytkownik z tym adresem e-mail już istnieje. Użyj innego adresu."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12068,7 +12102,7 @@ msgstr "Nie mogliśmy włączyć funkcji AI. Spróbuj ponownie."
#: apps/remix/app/components/dialogs/admin-organisation-member-delete-dialog.tsx
#: apps/remix/app/components/dialogs/admin-team-member-delete-dialog.tsx
msgid "We couldn't remove this member. Please try again later."
msgstr "Nie mogliśmy usunąć tego użytkownika. Spróbuj ponownie później."
msgstr "Nie mogliśmy usunąć użytkownika. Spróbuj ponownie później."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
msgid "We couldn't update the group. Please try again."
@@ -12286,6 +12320,10 @@ msgstr "Nie mogliśmy skopiować tokena do schowka. Spróbuj ponownie."
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "Nie mogliśmy skopiować kodu odzyskiwania do schowka. Spróbuj ponownie."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "Nie mogliśmy utworzyć konta. Jeśli masz już konto, zaloguj się."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "Nie mogliśmy utworzyć konta. Sprawdź dane i spróbuj ponownie."
@@ -12481,6 +12519,10 @@ msgstr "Dzięki zespołom możesz:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "Podpisujący mogą wybrać, kto powinien podpisać jako następny w kolejności, zamiast postępować zgodnie z wcześniej ustaloną kolejnością."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "Po włączeniu tej opcji użytkownicy logujący się po raz pierwszy za pomocą SSO otrzymają również własną osobistą organizację."
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "Po kliknięciu przycisku „Kontynuuj”, zostaniesz poproszony o dodanie pierwszego klucza dostępu."
+30 -6
View File
@@ -1537,6 +1537,10 @@ msgstr "Permitir que todos os membros da organização acessem esta equipe"
msgid "Allow document recipients to reply directly to this email address"
msgstr "Permitir que os destinatários do documento respondam diretamente a este endereço de e-mail"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr ""
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4310,6 +4314,7 @@ msgstr ""
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4917,6 +4922,10 @@ msgstr "Envelope distribuído"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "ID do Envelope"
@@ -7418,6 +7427,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "Pago"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr ""
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7673,6 +7687,10 @@ msgstr "Por favor, confirme seu e-mail"
msgid "Please confirm your email address"
msgstr "Por favor, confirme seu endereço de e-mail"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr ""
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "Por favor, entre em contato com o suporte se desejar reverter esta ação."
@@ -7689,8 +7707,6 @@ msgstr "Por favor, insira um nome significativo para seu token. Isso ajudará vo
msgid "Please enter a number"
msgstr ""
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "Por favor, insira um nome válido."
@@ -10772,6 +10788,10 @@ msgstr "Este envelope não pôde ser distribuído neste momento. Por favor, tent
msgid "This envelope could not be resent at this time. Please try again."
msgstr "Este envelope não pôde ser reenviado neste momento. Por favor, tente novamente."
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr ""
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "Este campo não pode ser modificado ou excluído. Quando você compartilha o link direto deste modelo ou o adiciona ao seu perfil público, qualquer pessoa que o acesse pode inserir seu nome e e-mail e preencher os campos atribuídos a ela."
@@ -11732,10 +11752,6 @@ msgstr "Perfis de usuário chegaram!"
msgid "User settings"
msgstr "Configurações do usuário"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "Já existe um usuário com este e-mail. Por favor, use um endereço de e-mail diferente."
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12281,6 +12297,10 @@ msgstr "Não conseguimos copiar o token para sua área de transferência. Por fa
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "Não conseguimos copiar seu código de recuperação para sua área de transferência. Por favor, tente novamente."
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr ""
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "Não conseguimos criar sua conta. Por favor, revise as informações fornecidas e tente novamente."
@@ -12476,6 +12496,10 @@ msgstr "O que você pode fazer com equipes:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "Quando ativado, os signatários podem escolher quem deve assinar em seguida na sequência, em vez de seguir a ordem predefinida."
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr ""
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "Ao clicar em continuar, você será solicitado a adicionar o primeiro autenticador disponível em seu sistema."
+53 -11
View File
@@ -8,7 +8,7 @@ msgstr ""
"Language: zh\n"
"Project-Id-Version: documenso-app\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-04-22 14:34\n"
"PO-Revision-Date: 2026-05-07 05:08\n"
"Last-Translator: \n"
"Language-Team: Chinese Simplified\n"
"Plural-Forms: nplurals=1; plural=0;\n"
@@ -1542,6 +1542,10 @@ msgstr "允许所有组织成员访问此团队"
msgid "Allow document recipients to reply directly to this email address"
msgstr "允许文档收件人直接回复到此邮箱地址"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "Allow Personal Organisations"
msgstr "允许个人组织"
#: apps/remix/app/components/embed/authoring/configure-document-recipients.tsx
#: apps/remix/app/components/general/envelope-editor/envelope-editor-recipient-form.tsx
#: packages/ui/primitives/document-flow/add-signers.tsx
@@ -4315,6 +4319,7 @@ msgstr "不要转移(删除所有文档)"
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/inbox-table.tsx
#: apps/remix/app/components/tables/organisation-billing-invoices-table.tsx
#: apps/remix/app/components/tables/templates-table-action-dropdown.tsx
#: apps/remix/app/routes/_recipient+/sign.$token+/complete.tsx
#: packages/email/template-components/template-document-completed.tsx
msgid "Download"
@@ -4922,6 +4927,10 @@ msgstr "信封已分发"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-audit-logs.ts
#: packages/lib/server-only/pdf/render-certificate.ts
#: packages/lib/server-only/pdf/render-certificate.ts
msgid "Envelope ID"
msgstr "信封 ID"
@@ -6329,6 +6338,8 @@ msgstr "正在加载建议…"
#: 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/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/routes/_authenticated+/admin+/email-domains.$id.tsx
msgid "Loading..."
msgstr "正在加载..."
@@ -6905,10 +6916,18 @@ msgstr "还没有文件夹。"
msgid "No further action is required from you at this time."
msgstr "目前您无需再执行任何操作。"
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "No groups found"
msgstr "未找到群组"
#: apps/remix/app/components/general/admin-license-card.tsx
msgid "No License Configured"
msgstr "未配置许可证"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "No members found"
msgstr "未找到成员"
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "No members selected"
msgstr "未选择任何成员"
@@ -7423,6 +7442,11 @@ msgctxt "Subscription status"
msgid "Paid"
msgstr "已付费"
#: apps/remix/app/components/dialogs/envelope-download-dialog.tsx
msgctxt "Partially signed document (adjective)"
msgid "Partial"
msgstr "部分签署"
#: apps/remix/app/components/forms/signin.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-dialog.tsx
#: apps/remix/app/components/general/document-signing/document-signing-auth-passkey.tsx
@@ -7678,6 +7702,10 @@ msgstr "请确认您的邮箱"
msgid "Please confirm your email address"
msgstr "请确认您的邮箱地址"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "Please contact <0>support</0> if you have any questions."
msgstr "如果您有任何问题,请联系<0>支持</0>。"
#: apps/remix/app/components/dialogs/envelope-delete-dialog.tsx
msgid "Please contact support if you would like to revert this action."
msgstr "如需撤销此操作,请联系支持。"
@@ -7694,8 +7722,6 @@ msgstr "请输入一个有意义的令牌名称,以便日后识别。"
msgid "Please enter a number"
msgstr "请输入一个数字"
#: apps/remix/app/components/forms/profile.tsx
#: apps/remix/app/components/forms/signup.tsx
#: apps/remix/app/components/general/claim-account.tsx
msgid "Please enter a valid name."
msgstr "请输入有效姓名。"
@@ -8761,10 +8787,18 @@ msgstr "搜索文档..."
msgid "Search folders..."
msgstr "搜索文件夹…"
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Search groups by name"
msgstr "按名称搜索群组"
#: packages/ui/components/common/language-switcher-dialog.tsx
msgid "Search languages..."
msgstr "搜索语言…"
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Search members by name or email"
msgstr "按姓名或邮箱搜索成员"
#: apps/remix/app/components/dialogs/webhook-create-dialog.tsx
msgid "Secret"
msgstr "密钥"
@@ -8884,7 +8918,8 @@ msgstr "选择默认角色"
msgid "Select direction"
msgstr "选择方向"
#: apps/remix/app/components/dialogs/team-group-create-dialog.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-groups-multiselect-combobox.tsx
msgid "Select groups"
msgstr "选择组"
@@ -8896,9 +8931,8 @@ msgstr "选择要添加到团队的成员组。"
msgid "Select groups to add to this team"
msgstr "选择要添加到此团队的组"
#: apps/remix/app/components/dialogs/organisation-group-create-dialog.tsx
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.groups.$id.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
#: apps/remix/app/components/general/organisation-members-multiselect-combobox.tsx
msgid "Select members"
msgstr "选择成员"
@@ -10777,6 +10811,10 @@ msgstr "当前无法分发此信封。请重试。"
msgid "This envelope could not be resent at this time. Please try again."
msgstr "当前无法重新发送此信封。请重试。"
#: apps/remix/app/components/embed/embed-paywall.tsx
msgid "This feature is not available on your current plan"
msgstr "此功能在您当前的方案中不可用"
#: packages/ui/primitives/template-flow/add-template-placeholder-recipients.tsx
msgid "This field cannot be modified or deleted. When you share this template's direct link or add it to your public profile, anyone who accesses it can input their name and email, and fill in the fields assigned to them."
msgstr "此字段无法修改或删除。将此模板的直链分享出去或添加到您的公开主页后,任何访问该链接的人都可以输入自己的姓名和邮箱,并填写分配给 TA 的字段。"
@@ -11737,10 +11775,6 @@ msgstr "用户资料来了!"
msgid "User settings"
msgstr "用户设置"
#: apps/remix/app/components/forms/signup.tsx
msgid "User with this email already exists. Please use a different email address."
msgstr "使用该邮箱的用户已存在。请使用其他邮箱地址。"
#: apps/remix/app/components/tables/organisation-insights-table.tsx
#: apps/remix/app/routes/_authenticated+/admin+/_layout.tsx
msgid "Users"
@@ -12286,6 +12320,10 @@ msgstr "无法将令牌复制到剪贴板。请重试。"
msgid "We were unable to copy your recovery code to your clipboard. Please try again."
msgstr "无法将恢复代码复制到剪贴板。请重试。"
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. If you already have an account, try signing in instead."
msgstr "我们无法创建你的账户。如果你已经有账户,请尝试改为登录。"
#: apps/remix/app/components/forms/signup.tsx
msgid "We were unable to create your account. Please review the information you provided and try again."
msgstr "我们未能创建您的账户。请检查您提供的信息后重试。"
@@ -12481,6 +12519,10 @@ msgstr "通过团队您可以完成以下工作:"
msgid "When enabled, signers can choose who should sign next in the sequence instead of following the predefined order."
msgstr "启用后,签署人可以选择下一位签署人,而不是遵循预定义顺序。"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.sso.tsx
msgid "When enabled, users signing in via SSO for the first time will also receive their own personal organisation."
msgstr "启用后,首次通过 SSO 登录的用户也会获得各自的个人组织。"
#: apps/remix/app/components/dialogs/passkey-create-dialog.tsx
msgid "When you click continue, you will be prompted to add the first available authenticator on your system."
msgstr "点击继续后,系统会提示你添加设备上第一个可用的验证器。"
+16
View File
@@ -220,11 +220,23 @@ export const ZEmbedCreateEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
type: z.nativeEnum(EnvelopeType),
folderId: z.string().optional(),
user: z
.object({
email: z.string().email().optional(),
name: z.string().optional(),
})
.optional(),
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
});
export const ZEmbedEditEnvelopeAuthoringSchema = ZBaseEmbedDataSchema.extend({
externalId: z.string().optional(),
user: z
.object({
email: z.string().email().optional(),
name: z.string().optional(),
})
.optional(),
features: z.object({}).passthrough().optional().default(DEFAULT_EMBEDDED_EDITOR_CONFIG),
});
@@ -323,5 +335,9 @@ export type EnvelopeEditorConfig = TEnvelopeEditorSettings & {
onCreate?: (envelope: Omit<TEditorEnvelope, 'id'>) => void;
onUpdate?: (envelope: TEditorEnvelope) => void;
customBrandingLogo?: boolean;
user?: {
email?: string;
name?: string;
};
};
};
+5 -1
View File
@@ -4,12 +4,16 @@ import type { DocumentDataVersion } from '@documenso/lib/types/document';
import { NEXT_PUBLIC_WEBAPP_URL } from '../constants/app';
/**
* `pending` is only supported when there is no recipient token (team/owner-side downloads
* via the session-authed file route). The recipient-token route does not accept `pending`.
*/
export type EnvelopeItemPdfUrlOptions =
| {
type: 'download';
envelopeItem: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
token: string | undefined;
version: 'original' | 'signed';
version: 'original' | 'signed' | 'pending';
presignToken?: undefined;
}
| {
@@ -0,0 +1,10 @@
-- AlterTable
-- Add the column with a temporary default of `true` so that all existing rows
-- (representing organisations created before this feature) are backfilled to
-- `true` — preserving the historical behaviour of creating personal
-- organisations for SSO-provisioned users.
ALTER TABLE "OrganisationAuthenticationPortal" ADD COLUMN "allowPersonalOrganisations" BOOLEAN NOT NULL DEFAULT true;
-- Switch the column default to `false` so that any organisations created from
-- now on opt out of personal-organisation creation by default.
ALTER TABLE "OrganisationAuthenticationPortal" ALTER COLUMN "allowPersonalOrganisations" SET DEFAULT false;
+4 -3
View File
@@ -1100,9 +1100,10 @@ model OrganisationAuthenticationPortal {
clientSecret String @default("")
wellKnownUrl String @default("")
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
autoProvisionUsers Boolean @default(true)
allowedDomains String[] @default([])
defaultOrganisationRole OrganisationMemberRole @default(MEMBER)
autoProvisionUsers Boolean @default(true)
allowedDomains String[] @default([])
allowPersonalOrganisations Boolean @default(false)
}
model Counter {
+69
View File
@@ -0,0 +1,69 @@
import { TeamMemberRole } from '@prisma/client';
import { createTeamMembers } from '@documenso/trpc/server/team-router/create-team-members';
import { prisma } from '..';
import { seedTeam } from './teams';
/**
* One-off seed script: creates a team with a large number of members.
*
* Run via:
* npm run with:env -- tsx packages/prisma/seed/large-team-seed.ts
*
* Produces:
* - 1 owner
* - ORG_MEMBER_COUNT organisation members
* - TEAM_MEMBER_COUNT of those org members are also added to the team's role group
*/
const ORG_MEMBER_COUNT = 200;
const TEAM_MEMBER_COUNT = 50;
const seedLargeTeam = async () => {
console.log(`[SEEDING]: Creating team with ${ORG_MEMBER_COUNT} organisation members...`);
const { owner, team, organisation } = await seedTeam({
createTeamMembers: ORG_MEMBER_COUNT,
});
// Exclude the owner — they're already a team member by default.
const nonOwnerOrgMembers = organisation.members.filter((member) => member.userId !== owner.id);
const membersToAttachToTeam = nonOwnerOrgMembers.slice(0, TEAM_MEMBER_COUNT);
console.log(
`[SEEDING]: Attaching ${membersToAttachToTeam.length} org members to the team's role group...`,
);
await createTeamMembers({
userId: owner.id,
teamId: team.id,
membersToCreate: membersToAttachToTeam.map((member) => ({
organisationMemberId: member.id,
teamRole: TeamMemberRole.MEMBER,
})),
});
console.log(`[SEEDING]: Done.`);
console.log(` Owner email: ${owner.email}`);
console.log(` Owner password: password`);
console.log(` Organisation: ${organisation.url} (id ${organisation.id})`);
console.log(` Team URL: ${team.url} (id ${team.id})`);
console.log(` Org members: ${ORG_MEMBER_COUNT}`);
console.log(` Team-group members: ${membersToAttachToTeam.length}`);
};
const main = async () => {
try {
await seedLargeTeam();
} catch (err) {
console.error('[SEEDING]: Failed to seed large team.');
console.error(err);
process.exitCode = 1;
} finally {
await prisma.$disconnect();
}
};
void main();
@@ -46,7 +46,8 @@ export const createEmbeddingPresignTokenRoute = procedure
if (!organisationClaim.flags.embedAuthoring) {
throw new AppError(AppErrorCode.UNAUTHORIZED, {
message: 'You do not have permission to create embedding presign tokens',
message:
'Embedded Authoring is not included in your current plan. Please contact support.',
});
}
}
@@ -52,6 +52,7 @@ export const getOrganisationAuthenticationPortal = async ({
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
allowPersonalOrganisations: true,
clientSecret: true,
},
},
@@ -79,6 +80,7 @@ export const getOrganisationAuthenticationPortal = async ({
wellKnownUrl: portal.wellKnownUrl,
autoProvisionUsers: portal.autoProvisionUsers,
allowedDomains: portal.allowedDomains,
allowPersonalOrganisations: portal.allowPersonalOrganisations,
clientSecretProvided: Boolean(portal.clientSecret),
};
};
@@ -14,6 +14,7 @@ export const ZGetOrganisationAuthenticationPortalResponseSchema =
wellKnownUrl: true,
autoProvisionUsers: true,
allowedDomains: true,
allowPersonalOrganisations: true,
}).extend({
/**
* Whether we have the client secret in the database.
@@ -61,6 +61,7 @@ export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedur
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
allowPersonalOrganisations,
} = data;
if (
@@ -104,6 +105,7 @@ export const updateOrganisationAuthenticationPortalRoute = authenticatedProcedur
wellKnownUrl,
autoProvisionUsers,
allowedDomains,
allowPersonalOrganisations,
},
});
});
@@ -14,6 +14,7 @@ export const ZUpdateOrganisationAuthenticationPortalRequestSchema = z.object({
wellKnownUrl: z.union([z.string().url(), z.literal('')]),
autoProvisionUsers: z.boolean(),
allowedDomains: z.array(z.string().regex(domainRegex)),
allowPersonalOrganisations: z.boolean(),
}),
});
@@ -18,9 +18,9 @@ export const downloadEnvelopeItemMeta: TrpcRouteMeta = {
export const ZDownloadEnvelopeItemRequestSchema = z.object({
envelopeItemId: z.string().describe('The ID of the envelope item to download.'),
version: z
.enum(['original', 'signed'])
.enum(['original', 'signed', 'pending'])
.describe(
'The version of the envelope item to download. "signed" returns the completed document with signatures, "original" returns the original uploaded document.',
'The version of the envelope item to download. "signed" returns the completed document with all signatures and the audit trail, "original" returns the original uploaded document, "pending" returns the original document with currently-inserted fields burned in (only valid while the envelope is in PENDING status; not a final executed document).',
)
.default('signed'),
});
@@ -51,6 +51,7 @@ export const signEnvelopeFieldRoute = procedure
signingOrder: {
gte: recipient.signingOrder ?? 0,
},
envelopeId: recipient.envelopeId,
}
: {
id: recipient.id,
@@ -17,8 +17,16 @@ export const findOrganisationGroupsRoute = authenticatedProcedure
.input(ZFindOrganisationGroupsRequestSchema)
.output(ZFindOrganisationGroupsResponseSchema)
.query(async ({ input, ctx }) => {
const { organisationId, types, query, page, perPage, organisationGroupId, organisationRoles } =
input;
const {
organisationId,
types,
query,
page,
perPage,
organisationGroupId,
organisationRoles,
excludeTeamId,
} = input;
const { user } = ctx;
ctx.logger.info({
@@ -36,6 +44,7 @@ export const findOrganisationGroupsRoute = authenticatedProcedure
query,
page,
perPage,
excludeTeamId,
});
});
@@ -48,6 +57,7 @@ type FindOrganisationGroupsOptions = {
query?: string;
page?: number;
perPage?: number;
excludeTeamId?: number;
};
export const findOrganisationGroups = async ({
@@ -59,6 +69,7 @@ export const findOrganisationGroups = async ({
query,
page = 1,
perPage = 10,
excludeTeamId,
}: FindOrganisationGroupsOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
@@ -92,6 +103,14 @@ export const findOrganisationGroups = async ({
};
}
// Exclude organisation groups that already have a team-group entry pointing
// at the given team — i.e. they're already attached.
if (excludeTeamId !== undefined) {
whereClause.teamGroups = {
none: { teamId: excludeTeamId },
};
}
const [data, count] = await Promise.all([
prisma.organisationGroup.findMany({
where: whereClause,
@@ -19,6 +19,12 @@ export const ZFindOrganisationGroupsRequestSchema = ZFindSearchParamsSchema.exte
organisationGroupId: z.string().optional(),
organisationRoles: z.nativeEnum(OrganisationMemberRole).array().optional(),
types: z.nativeEnum(OrganisationGroupType).array().optional(),
/**
* Exclude organisation groups that are already attached to the given team.
* Useful for "add groups to team" pickers so that groups already on the
* team don't appear in the dropdown.
*/
excludeTeamId: z.number().optional(),
});
export const ZFindOrganisationGroupsResponseSchema = ZFindResultResponse.extend({
@@ -28,6 +28,7 @@ export const findOrganisationMembersRoute = authenticatedProcedure
query: input.query,
page: input.page,
perPage: input.perPage,
excludeTeamId: input.excludeTeamId,
});
return {
@@ -55,6 +56,7 @@ type FindOrganisationMembersOptions = {
query?: string;
page?: number;
perPage?: number;
excludeTeamId?: number;
};
export const findOrganisationMembers = async ({
@@ -63,6 +65,7 @@ export const findOrganisationMembers = async ({
query,
page = 1,
perPage = 10,
excludeTeamId,
}: FindOrganisationMembersOptions) => {
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({ organisationId, userId }),
@@ -95,6 +98,21 @@ export const findOrganisationMembers = async ({
};
}
// Exclude organisation members who are already part of the given team —
// i.e. they belong to an organisation group that has a team-group entry
// pointing at the team.
if (excludeTeamId !== undefined) {
whereClause.organisationGroupMembers = {
none: {
group: {
teamGroups: {
some: { teamId: excludeTeamId },
},
},
},
};
}
const [data, count] = await Promise.all([
prisma.organisationMember.findMany({
where: whereClause,
@@ -17,6 +17,12 @@ import { OrganisationMemberSchema } from '@documenso/prisma/generated/zod/modelS
export const ZFindOrganisationMembersRequestSchema = ZFindSearchParamsSchema.extend({
organisationId: z.string(),
/**
* Exclude organisation members who are already members of the given team.
* Useful for "add members to team" pickers so that members already on the
* team don't appear in the dropdown.
*/
excludeTeamId: z.number().optional(),
});
export const ZFindOrganisationMembersResponseSchema = ZFindResultResponse.extend({
@@ -65,6 +65,8 @@ export const createTeamGroupsRoute = authenticatedProcedure
},
});
// Hard validation — these failures indicate programming or authorisation
// errors and should reject the whole request.
const isValid = groups.every((group) => {
const organisationGroup = team.organisation.groups.find(
({ id }) => id === group.organisationGroupId,
@@ -84,11 +86,6 @@ export const createTeamGroupsRoute = authenticatedProcedure
return false;
}
// Check that the group is not already added to the team.
if (organisationGroup.teamGroups.some((teamGroup) => teamGroup.teamId === teamId)) {
return false;
}
// Check that the user has permission to add the group to the team.
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, group.teamRole)) {
return false;
@@ -103,8 +100,23 @@ export const createTeamGroupsRoute = authenticatedProcedure
});
}
// Silently drop groups already attached to the team. Makes the create
// idempotent for the common race where a group was added between the
// picker fetch and the submit.
const filteredGroups = groups.filter((group) => {
const organisationGroup = team.organisation.groups.find(
({ id }) => id === group.organisationGroupId,
);
return !organisationGroup?.teamGroups.some((teamGroup) => teamGroup.teamId === teamId);
});
if (filteredGroups.length === 0) {
return;
}
await prisma.teamGroup.createMany({
data: groups.map((group) => ({
data: filteredGroups.map((group) => ({
id: generateDatabaseId('team_group'),
teamId,
organisationGroupId: group.organisationGroupId,
@@ -146,15 +146,55 @@ export const createTeamMembers = async ({
});
}
const teamRoleGroupId = (role: TeamMemberRole) =>
match(role)
.with(TeamMemberRole.MEMBER, () => teamMemberGroup.organisationGroupId)
.with(TeamMemberRole.MANAGER, () => teamManagerGroup.organisationGroupId)
.with(TeamMemberRole.ADMIN, () => teamAdminGroup.organisationGroupId)
.exhaustive();
// Silently drop additions that would duplicate an existing membership in
// the same internal-team role group (the only case that would hit the
// (organisationMemberId, groupId) unique constraint). Members who are
// implicitly part of the team via an INTERNAL_ORGANISATION group are NOT
// dropped, so this still allows assigning an explicit team role on top
// of the inherited org-level membership.
const existingTeamGroupMemberships = await prisma.organisationGroupMember.findMany({
where: {
organisationMemberId: {
in: membersToCreate.map((member) => member.organisationMemberId),
},
groupId: {
in: [
teamMemberGroup.organisationGroupId,
teamManagerGroup.organisationGroupId,
teamAdminGroup.organisationGroupId,
],
},
},
select: { organisationMemberId: true, groupId: true },
});
const existingPairs = new Set(
existingTeamGroupMemberships.map(
({ organisationMemberId, groupId }) => `${organisationMemberId}:${groupId}`,
),
);
const filteredMembersToCreate = membersToCreate.filter(
(member) =>
!existingPairs.has(`${member.organisationMemberId}:${teamRoleGroupId(member.teamRole)}`),
);
if (filteredMembersToCreate.length === 0) {
return;
}
await prisma.organisationGroupMember.createMany({
data: membersToCreate.map((member) => ({
data: filteredMembersToCreate.map((member) => ({
id: generateDatabaseId('group_member'),
organisationMemberId: member.organisationMemberId,
groupId: match(member.teamRole)
.with(TeamMemberRole.MEMBER, () => teamMemberGroup.organisationGroupId)
.with(TeamMemberRole.MANAGER, () => teamManagerGroup.organisationGroupId)
.with(TeamMemberRole.ADMIN, () => teamAdminGroup.organisationGroupId)
.exhaustive(),
groupId: teamRoleGroupId(member.teamRole),
})),
});
};