mirror of
https://github.com/documenso/documenso.git
synced 2026-07-22 16:03:39 +10:00
Merge branch 'main' into feat/signing-required-field-colors
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -29,4 +29,9 @@ description: Advanced document features including PDF placeholders, AI detection
|
||||
description="Set a signing deadline so document links expire after a configurable period."
|
||||
href="/docs/users/documents/advanced/recipient-expiration"
|
||||
/>
|
||||
<Card
|
||||
title="Signing Reminders"
|
||||
description="Automatically email recipients who have not yet signed on a configurable schedule."
|
||||
href="/docs/users/documents/advanced/signing-reminders"
|
||||
/>
|
||||
</Cards>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"ai-detection",
|
||||
"default-recipients",
|
||||
"document-visibility",
|
||||
"recipient-expiration"
|
||||
"recipient-expiration",
|
||||
"signing-reminders"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
---
|
||||
title: Signing Reminders
|
||||
description: Automatically email recipients who have not yet signed on a configurable schedule.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
import { Step, Steps } from 'fumadocs-ui/components/steps';
|
||||
import { Tab, Tabs } from 'fumadocs-ui/components/tabs';
|
||||
|
||||
## Overview
|
||||
|
||||
Signing reminders automatically email recipients who have not completed their signing. You configure when the first reminder goes out and how often it repeats — Documenso handles the rest until the recipient signs or the document leaves a pending state.
|
||||
|
||||
This is useful when:
|
||||
|
||||
- You want recipients nudged without having to track them down manually
|
||||
- A deadline is approaching and you want to escalate gradually
|
||||
- You send a high volume of documents and cannot chase each one
|
||||
|
||||
Reminders are tracked **per recipient**, not per document. Each unsigned recipient is on their own schedule based on when they were emailed.
|
||||
|
||||
This is different from the **Resend** action covered in [Send Documents](/docs/users/documents/send), which is a one-off manual nudge. Reminders are scheduled and recurring.
|
||||
|
||||
## Default Behaviour
|
||||
|
||||
Every **newly created** organisation starts with reminders **enabled**:
|
||||
|
||||
- First reminder sent **5 days** after the recipient is emailed
|
||||
- Repeats **every 2 days** until the recipient signs
|
||||
|
||||
You can change this default at the organisation or team level, or override it per document.
|
||||
|
||||
<Callout type="info">
|
||||
Organisations created **before this feature shipped** have reminders left blank (no default) so
|
||||
recipients of in-flight or future documents are not unexpectedly sent reminders. To enable
|
||||
reminders for an existing organisation, configure **Default Signing Reminders** under
|
||||
**Organisation Settings > Preferences > Document**.
|
||||
</Callout>
|
||||
|
||||
## Settings Cascade
|
||||
|
||||
Reminder settings follow a three-level cascade: **Organisation → Team → Document**. Each level can override the one above it, or inherit from it.
|
||||
|
||||
<Tabs items={['Organisation', 'Team', 'Document']}>
|
||||
<Tab value="Organisation">
|
||||
|
||||
Sets the default for all teams in the organisation. Options are **Enabled** (set when the first reminder fires and how often it repeats) or **No reminders**.
|
||||
|
||||
To configure, navigate to **Organisation Settings > Preferences > Document** and find **Default Signing Reminders**.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Team">
|
||||
|
||||
Overrides the organisation default for documents created within this team. Options are **Enabled**, **No reminders**, or **Inherit from organisation**.
|
||||
|
||||
New teams default to **Inherit from organisation**.
|
||||
|
||||
To configure, navigate to **Team Settings > Preferences > Document** and find **Default Signing Reminders**.
|
||||
|
||||
</Tab>
|
||||
<Tab value="Document">
|
||||
|
||||
Overrides the team or organisation default for a single document. Options are **Enabled**, **No reminders**, or **Inherit from organisation**.
|
||||
|
||||
If you do not change reminders when editing a document, the team or organisation default applies.
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Set Reminders for a Document
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Open the document settings
|
||||
|
||||
In the document editor, open the **Settings** dialog and go to the **Reminders** tab.
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Choose a mode
|
||||
|
||||
- **Enabled** — Documenso sends reminders on the schedule you configure below
|
||||
- **No reminders** — no automatic reminders for this document
|
||||
- **Inherit from organisation** — use the team or organisation default
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Configure the schedule
|
||||
|
||||
When **Enabled**, set:
|
||||
|
||||
- **Send first reminder after** — a number and unit (days, weeks, or months) measured from when the recipient is first emailed
|
||||
- **Then repeat every** — either **Custom interval** (a number and unit) or **Don't repeat** (only one reminder is ever sent)
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Send the document
|
||||
|
||||
The first reminder is scheduled when the recipient receives the initial signing email. Subsequent reminders are scheduled from the time the previous reminder was sent.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="info">
|
||||
Editing reminder settings on a document that is already pending recalculates the next reminder for
|
||||
every unsigned recipient immediately.
|
||||
</Callout>
|
||||
|
||||
## Set a Default Reminder Schedule
|
||||
|
||||
{/* prettier-ignore */}
|
||||
<Steps>
|
||||
<Step>
|
||||
|
||||
### Navigate to document preferences
|
||||
|
||||
Go to **Organisation Settings > Preferences > Document** (or **Team Settings > Preferences > Document** for team-level overrides).
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Configure the default
|
||||
|
||||
Find **Default Signing Reminders** and choose:
|
||||
|
||||
- **Enabled** — set the first-reminder delay and repeat interval
|
||||
- **No reminders** — disable reminders by default
|
||||
- **Inherit from organisation** (team level only) — use whatever the organisation has configured
|
||||
|
||||
</Step>
|
||||
<Step>
|
||||
|
||||
### Save
|
||||
|
||||
Click **Save** to apply. New documents created after this change use the updated default. Documents already in flight are unaffected unless you edit their reminder settings directly.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## What Happens When a Reminder Fires
|
||||
|
||||
When a recipient's next reminder time arrives:
|
||||
|
||||
1. Documenso sends the reminder email — same template as the original signing request, but the subject and preview are prefixed with **"Reminder:"**.
|
||||
2. An audit log entry is created for the recipient (an `EMAIL_SENT` entry of type `REMINDER`).
|
||||
3. The `document.reminder.sent` webhook fires. See [webhook events](/docs/developers/webhooks/events).
|
||||
4. The next reminder is scheduled based on **Then repeat every**, or no further reminder is scheduled if you chose **Don't repeat**.
|
||||
|
||||
Reminders stop automatically when the recipient signs, declines, the recipient's signing deadline passes, or the document leaves the pending state (completed, rejected, or deleted).
|
||||
|
||||
## When Reminders Are Skipped
|
||||
|
||||
A configured reminder will not be sent in the following cases:
|
||||
|
||||
- The recipient is a **CC** — CCs are notified once and never reminded
|
||||
- The recipient's **signing deadline has passed** — see [Recipient Expiration](/docs/users/documents/advanced/recipient-expiration). Resending the document refreshes the deadline and resumes reminders.
|
||||
- The document uses **manual link distribution** — Documenso never emails recipients for these documents
|
||||
- The envelope's email settings have **signing request emails disabled**
|
||||
- The recipient has not yet been emailed (for example, an unreached step in a sequential workflow)
|
||||
|
||||
<Callout type="info">
|
||||
Reminders stop automatically **30 days after the recipient was first emailed**, regardless of the
|
||||
repeat interval. This hard cap prevents runaway reminder chains for recipients who never sign and
|
||||
have no expiration set. If you need a different stop condition, set a shorter repeat interval,
|
||||
use **Don't repeat**, or configure [recipient expiration](/docs/users/documents/advanced/recipient-expiration).
|
||||
</Callout>
|
||||
|
||||
<Callout type="info">
|
||||
Reminders are dispatched by a background sweep that runs every 15 minutes, so the actual send time
|
||||
may be up to ~15 minutes after the scheduled time.
|
||||
</Callout>
|
||||
|
||||
## Reminder Options Reference
|
||||
|
||||
| Setting | Options | Notes |
|
||||
| --------------------------- | ---------------------------------------- | -------------------------------------------------------------------- |
|
||||
| **Mode** | Enabled / No reminders / Inherit | Inherit is only available at the team and document levels |
|
||||
| **Send first reminder after** | 1+ days, weeks, or months | Measured from when the recipient receives the initial signing email. Reminders past 30 days from that moment are skipped. |
|
||||
| **Then repeat every** | Custom interval (1+ days/weeks/months) or Don't repeat | Custom interval keeps reminding until the recipient signs or the 30-day cap is reached |
|
||||
|
||||
The organisation default out of the box is **first reminder after 5 days, repeating every 2 days**, which falls well inside the 30-day cap.
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [Send Documents](/docs/users/documents/send) - Send documents and trigger a one-off resend
|
||||
- [Recipient Expiration](/docs/users/documents/advanced/recipient-expiration) - Set a hard signing deadline
|
||||
- [Document Preferences](/docs/users/organisations/preferences/document) - Configure default document settings
|
||||
- [Webhook Events](/docs/developers/webhooks/events) - Subscribe to `document.reminder.sent`
|
||||
@@ -33,6 +33,7 @@ To access the preferences, navigate to either the organisation or teams settings
|
||||
| **Include the Audit Logs** | Whether the audit logs are embedded in the document when downloaded. The audit logs are always available separately from the logs page. |
|
||||
| **Default Recipients** | Recipients that are automatically added to new documents. Can be overridden per document. |
|
||||
| **Default Envelope Expiration** | How long recipients have to sign before the signing link expires. See [recipient expiration](/docs/users/documents/advanced/recipient-expiration). |
|
||||
| **Default Signing Reminders** | When and how often to email recipients who have not yet signed. See [signing reminders](/docs/users/documents/advanced/signing-reminders). |
|
||||
| **Delegate Document Ownership** | Allow team API tokens to delegate document ownership to another team member. |
|
||||
| **AI Features** | Enable AI-powered features such as automatic recipient detection. Only shown if AI features are configured on the instance. |
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -114,7 +114,7 @@ export const TemplateBulkSendDialog = ({
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="outline">
|
||||
<Button variant="outline" className="shrink-0" size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -55,6 +55,7 @@ type TemplateDirectLinkDialogProps = {
|
||||
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
|
||||
recipients: TRecipientLite[];
|
||||
trigger?: React.ReactNode;
|
||||
triggerSizeVariant?: 'default' | 'sm' | 'lg';
|
||||
onCreateSuccess?: () => Promise<void> | void;
|
||||
onDeleteSuccess?: () => Promise<void> | void;
|
||||
};
|
||||
@@ -66,6 +67,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
directLink,
|
||||
recipients,
|
||||
trigger,
|
||||
triggerSizeVariant = 'sm',
|
||||
onCreateSuccess,
|
||||
onDeleteSuccess,
|
||||
}: TemplateDirectLinkDialogProps) => {
|
||||
@@ -208,7 +210,7 @@ export const TemplateDirectLinkDialog = ({
|
||||
<Dialog open={open} onOpenChange={(value) => !isLoading && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger || (
|
||||
<Button variant="outline" className="px-3">
|
||||
<Button variant="outline" className="shrink-0 px-3" size={triggerSizeVariant}>
|
||||
<LinkIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
|
||||
{directLink ? <Trans>Manage Direct Link</Trans> : <Trans>Create Direct Link</Trans>}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
@@ -23,10 +24,7 @@ import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signa
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export const ZProfileFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
name: ZNameSchema,
|
||||
signature: z.string().min(1, { message: msg`Signature Pad cannot be empty.`.id }),
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { z } from 'zod';
|
||||
import communityCardsImage from '@documenso/assets/images/community-cards.png';
|
||||
import { authClient } from '@documenso/auth/client';
|
||||
import { useAnalytics } from '@documenso/lib/client-only/hooks/use-analytics';
|
||||
import { ZNameSchema } from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { zEmail } from '@documenso/lib/utils/zod';
|
||||
@@ -39,10 +40,7 @@ import { UserProfileTimur } from '~/components/general/user-profile-timur';
|
||||
|
||||
export const ZSignUpFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, { message: msg`Please enter a valid name.`.id }),
|
||||
name: ZNameSchema,
|
||||
email: zEmail().min(1),
|
||||
password: ZPasswordSchema,
|
||||
signature: z.string().min(1, { message: msg`We need your signature to sign documents`.id }),
|
||||
@@ -60,7 +58,7 @@ export const ZSignUpFormSchema = z
|
||||
|
||||
export const SIGNUP_ERROR_MESSAGES: Record<string, MessageDescriptor> = {
|
||||
SIGNUP_DISABLED: msg`Signup is currently disabled or not available for your email domain.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`User with this email already exists. Please use a different email address.`,
|
||||
[AppErrorCode.ALREADY_EXISTS]: msg`We were unable to create your account. If you already have an account, try signing in instead.`,
|
||||
[AppErrorCode.INVALID_REQUEST]: msg`We were unable to create your account. Please review the information you provided and try again.`,
|
||||
};
|
||||
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -74,8 +74,8 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
return (
|
||||
<nav className="w-full border-b border-border bg-background px-4 py-3 md:px-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 flex-1 items-center space-x-4">
|
||||
{editorConfig.embedded?.customBrandingLogo ? (
|
||||
<img
|
||||
src={`/api/branding/logo/team/${envelope.teamId}`}
|
||||
@@ -87,9 +87,9 @@ export default function EnvelopeEditorHeader() {
|
||||
<BrandingLogo className="h-6 w-auto" />
|
||||
</Link>
|
||||
)}
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<Separator orientation="vertical" className="h-6 shrink-0" />
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex min-w-0 items-center space-x-2">
|
||||
<EnvelopeItemTitleInput
|
||||
dataTestId="envelope-title-input"
|
||||
disabled={!envelopeItemPermissions.canTitleBeChanged || !allowConfigureEnvelopeTitle}
|
||||
@@ -107,19 +107,19 @@ export default function EnvelopeEditorHeader() {
|
||||
{envelope.type === EnvelopeType.TEMPLATE && (
|
||||
<>
|
||||
{envelope.templateType === TemplateType.PRIVATE && (
|
||||
<Badge variant="secondary">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<LockIcon className="mr-2 h-4 w-4 text-blue-600 dark:text-blue-300" />
|
||||
<Trans>Private Template</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{envelope.templateType === TemplateType.ORGANISATION && (
|
||||
<Badge variant="orange">
|
||||
<Badge variant="orange" className="shrink-0">
|
||||
<Building2Icon className="mr-2 size-4" />
|
||||
<Trans>Organisation Template</Trans>
|
||||
</Badge>
|
||||
)}
|
||||
{envelope.templateType === TemplateType.PUBLIC && (
|
||||
<Badge variant="default">
|
||||
<Badge variant="default" className="shrink-0">
|
||||
<Globe2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Public Template</Trans>
|
||||
</Badge>
|
||||
@@ -127,7 +127,7 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
{envelope.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="py-1"
|
||||
className="shrink-0 py-1"
|
||||
token={envelope.directLink.token}
|
||||
enabled={envelope.directLink.enabled}
|
||||
/>
|
||||
@@ -138,22 +138,22 @@ export default function EnvelopeEditorHeader() {
|
||||
{envelope.type === EnvelopeType.DOCUMENT &&
|
||||
match(envelope.status)
|
||||
.with(DocumentStatus.DRAFT, () => (
|
||||
<Badge variant="warning">
|
||||
<Badge variant="warning" className="shrink-0">
|
||||
<Trans>Draft</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(DocumentStatus.PENDING, () => (
|
||||
<Badge variant="secondary">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(DocumentStatus.COMPLETED, () => (
|
||||
<Badge variant="default">
|
||||
<Badge variant="default" className="shrink-0">
|
||||
<Trans>Completed</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(DocumentStatus.REJECTED, () => (
|
||||
<Badge variant="destructive">
|
||||
<Badge variant="destructive" className="shrink-0">
|
||||
<Trans>Rejected</Trans>
|
||||
</Badge>
|
||||
))
|
||||
@@ -161,7 +161,7 @@ export default function EnvelopeEditorHeader() {
|
||||
|
||||
{autosaveError && (
|
||||
<>
|
||||
<Badge variant="destructive">
|
||||
<Badge variant="destructive" className="shrink-0">
|
||||
<AlertTriangleIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Sync failed, changes not saved</Trans>
|
||||
</Badge>
|
||||
@@ -171,7 +171,7 @@ export default function EnvelopeEditorHeader() {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
<Badge variant="destructive">
|
||||
<Badge variant="destructive" className="shrink-0">
|
||||
<RefreshCwIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Reload</Trans>
|
||||
</Badge>
|
||||
@@ -181,7 +181,7 @@ export default function EnvelopeEditorHeader() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex shrink-0 items-center space-x-2">
|
||||
{allowAttachments &&
|
||||
(isEmbedded ? (
|
||||
<EmbeddedEditorAttachmentPopover buttonSize="sm" />
|
||||
|
||||
+12
-6
@@ -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"
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
|
||||
const MIN_INPUT_WIDTH = 100;
|
||||
const INPUT_WIDTH_PADDING = 16;
|
||||
|
||||
export type EnvelopeItemTitleInputProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
@@ -27,11 +30,12 @@ export const EnvelopeItemTitleInput = ({
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
// Update input width based on content
|
||||
useEffect(() => {
|
||||
if (measureRef.current) {
|
||||
const width = measureRef.current.offsetWidth;
|
||||
setInputWidth(Math.max(width + 16, 100)); // Add padding and minimum width
|
||||
const nextInputWidth = Math.max(width + INPUT_WIDTH_PADDING, MIN_INPUT_WIDTH);
|
||||
|
||||
setInputWidth(nextInputWidth);
|
||||
}
|
||||
}, [envelopeItemTitle]);
|
||||
|
||||
@@ -55,7 +59,7 @@ export const EnvelopeItemTitleInput = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative min-w-0 max-w-full shrink">
|
||||
{/* Hidden span to measure text width */}
|
||||
<span
|
||||
ref={measureRef}
|
||||
@@ -73,9 +77,9 @@ export const EnvelopeItemTitleInput = ({
|
||||
value={envelopeItemTitle}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
style={{ width: `${inputWidth}px` }}
|
||||
style={{ width: `${inputWidth}px`, maxWidth: '100%' }}
|
||||
className={cn(
|
||||
'rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
|
||||
'max-w-full rounded-sm border-0 bg-transparent p-1 text-sm font-medium text-foreground outline-none hover:outline hover:outline-1 hover:outline-muted-foreground focus:outline focus:outline-1 focus:outline-muted-foreground',
|
||||
className,
|
||||
{
|
||||
'outline-red-500': isError,
|
||||
|
||||
@@ -525,7 +525,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
snapshot.isDragging ? 'shadow-md' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex min-w-0 items-center space-x-3">
|
||||
{uploadConfig?.allowConfigureOrder && (
|
||||
<div
|
||||
{...provided.dragHandleProps}
|
||||
@@ -536,7 +536,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
{localFile.envelopeItemId !== null ? (
|
||||
<EnvelopeItemTitleInput
|
||||
disabled={
|
||||
@@ -564,7 +564,7 @@ export const EnvelopeEditorUploadPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex shrink-0 items-center space-x-2">
|
||||
{localFile.isUploading && (
|
||||
<div className="flex h-6 w-10 items-center justify-center">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -495,6 +495,7 @@ export const EnvelopeEditor = () => {
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
isLegacy={envelope.internalVersion === 1}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
trigger={
|
||||
<Button
|
||||
|
||||
+17
-5
@@ -25,12 +25,17 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
envelopeStatus,
|
||||
currentEnvelopeItem,
|
||||
fields,
|
||||
signatures,
|
||||
recipients,
|
||||
getRecipientColorKey,
|
||||
setRenderError,
|
||||
overrideSettings,
|
||||
} = useCurrentEnvelopeRender();
|
||||
|
||||
const signaturesByFieldId = useMemo(() => {
|
||||
return new Map(signatures.map((signature) => [signature.fieldId, signature]));
|
||||
}, [signatures]);
|
||||
|
||||
const { stage, pageLayer, konvaContainer, unscaledViewport } = usePageRenderer(
|
||||
({ stage, pageLayer }) => {
|
||||
createPageCanvas(stage, pageLayer);
|
||||
@@ -80,6 +85,16 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
|
||||
const fieldTranslations = getClientSideFieldTranslations(i18n);
|
||||
|
||||
// Look up an inserted signature for this field. If we don't have one (e.g.
|
||||
// the signatures haven't been loaded, or the field hasn't been signed yet)
|
||||
// fall back to a placeholder so the field still renders something.
|
||||
const insertedSignature = signaturesByFieldId.get(field.id);
|
||||
|
||||
const signature = insertedSignature ?? {
|
||||
signatureImageAsBase64: '',
|
||||
typedSignature: fieldTranslations.SIGNATURE,
|
||||
};
|
||||
|
||||
renderField({
|
||||
scale,
|
||||
pageLayer: pageLayer.current,
|
||||
@@ -91,10 +106,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
positionX: Number(field.positionX),
|
||||
positionY: Number(field.positionY),
|
||||
fieldMeta: field.fieldMeta,
|
||||
signature: {
|
||||
signatureImageAsBase64: '',
|
||||
typedSignature: fieldTranslations.SIGNATURE,
|
||||
},
|
||||
signature,
|
||||
},
|
||||
translations: fieldTranslations,
|
||||
pageWidth: unscaledViewport.width,
|
||||
@@ -150,7 +162,7 @@ export const EnvelopeGenericPageRenderer = ({ pageData }: { pageData: PageRender
|
||||
});
|
||||
|
||||
pageLayer.current.batchDraw();
|
||||
}, [localPageFields]);
|
||||
}, [localPageFields, signaturesByFieldId]);
|
||||
|
||||
if (!currentEnvelopeItem) {
|
||||
return null;
|
||||
|
||||
@@ -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,14 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import {
|
||||
DocumentStatus,
|
||||
EnvelopeType,
|
||||
type Recipient,
|
||||
type TemplateDirectLink,
|
||||
} from '@prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, type TemplateDirectLink } from '@prisma/client';
|
||||
import {
|
||||
Copy,
|
||||
Download,
|
||||
Edit,
|
||||
FolderIcon,
|
||||
MoreHorizontal,
|
||||
@@ -30,6 +26,7 @@ import {
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
|
||||
import { EnvelopeDeleteDialog } from '../dialogs/envelope-delete-dialog';
|
||||
import { EnvelopeDownloadDialog } from '../dialogs/envelope-download-dialog';
|
||||
import { EnvelopeDuplicateDialog } from '../dialogs/envelope-duplicate-dialog';
|
||||
import { EnvelopeRenameDialog } from '../dialogs/envelope-rename-dialog';
|
||||
import { TemplateBulkSendDialog } from '../dialogs/template-bulk-send-dialog';
|
||||
@@ -77,87 +74,94 @@ export const TemplatesTableActionDropdown = ({
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>Action</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem disabled={!canMutate} asChild>
|
||||
<Link to={formatPath}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canMutate && (
|
||||
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Rename</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{canMutate && (
|
||||
<EnvelopeDuplicateDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canMutate && (
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
directLink={row.directLink}
|
||||
trigger={
|
||||
<div
|
||||
data-testid="template-direct-link"
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Share2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Direct link</Trans>
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeStatus={DocumentStatus.DRAFT}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Trans>Download</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem disabled={!canMutate} onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
{canMutate && (
|
||||
<TemplateBulkSendDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
trigger={
|
||||
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={formatPath}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
<Trans>Edit</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{canMutate && (
|
||||
<EnvelopeDeleteDialog
|
||||
id={row.envelopeId}
|
||||
type={EnvelopeType.TEMPLATE}
|
||||
status={DocumentStatus.DRAFT}
|
||||
title={row.title}
|
||||
canManageDocument={canMutate}
|
||||
onDelete={onDelete}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
<DropdownMenuItem onClick={() => setRenameDialogOpen(true)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
<Trans>Rename</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<EnvelopeDuplicateDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeType={EnvelopeType.TEMPLATE}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
<Trans>Duplicate</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
directLink={row.directLink}
|
||||
trigger={
|
||||
<div
|
||||
data-testid="template-direct-link"
|
||||
className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Share2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Direct link</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<DropdownMenuItem onClick={() => setMoveToFolderDialogOpen(true)}>
|
||||
<FolderIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Move to Folder</Trans>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<TemplateBulkSendDialog
|
||||
templateId={row.id}
|
||||
recipients={row.recipients}
|
||||
trigger={
|
||||
<div className="relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
<Trans>Bulk Send via CSV</Trans>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<EnvelopeDeleteDialog
|
||||
id={row.envelopeId}
|
||||
type={EnvelopeType.TEMPLATE}
|
||||
status={DocumentStatus.DRAFT}
|
||||
title={row.title}
|
||||
canManageDocument={canMutate}
|
||||
onDelete={onDelete}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export default async function handleRequest(
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext,
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
let language = await langCookie.parse(request.headers.get('cookie') ?? '');
|
||||
|
||||
@@ -30,6 +30,12 @@ export default async function handleRequest(
|
||||
|
||||
await dynamicActivate(language);
|
||||
|
||||
// Threaded into ServerRouter so React Router applies the nonce to the
|
||||
// scripts it injects (route manifest, hydration data, module preloads).
|
||||
// The same nonce is also exposed to the React tree via the root loader so
|
||||
// our own inline scripts/styles can carry it.
|
||||
const nonce = loadContext.nonce || undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
@@ -41,9 +47,10 @@ export default async function handleRequest(
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<ServerRouter context={routerContext} url={request.url} />
|
||||
<ServerRouter context={routerContext} url={request.url} nonce={nonce} />
|
||||
</I18nProvider>,
|
||||
{
|
||||
nonce,
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
|
||||
+21
-8
@@ -27,6 +27,7 @@ import { GenericErrorLayout } from './components/general/generic-error-layout';
|
||||
import { langCookie } from './storage/lang-cookie.server';
|
||||
import { themeSessionResolver } from './storage/theme-session.server';
|
||||
import { appMetaTags } from './utils/meta';
|
||||
import { nonce } from './utils/nonce';
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
@@ -41,7 +42,7 @@ export function meta() {
|
||||
*/
|
||||
export const shouldRevalidate = () => false;
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
export async function loader({ context, request }: Route.LoaderArgs) {
|
||||
const session = await getOptionalSession(request);
|
||||
|
||||
const { getTheme } = await themeSessionResolver(request);
|
||||
@@ -67,6 +68,10 @@ export async function loader({ request }: Route.LoaderArgs) {
|
||||
lang,
|
||||
theme: getTheme(),
|
||||
disableAnimations,
|
||||
// Surface the per-request CSP nonce produced by `securityHeadersMiddleware` so all
|
||||
// SSR-rendered <script>/<style> elements in this layout (and child
|
||||
// routes that need it) can carry the matching nonce attribute.
|
||||
nonce: context.nonce,
|
||||
session: session.isAuthenticated
|
||||
? {
|
||||
user: session.user,
|
||||
@@ -95,8 +100,14 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
const { publicEnv, session, lang, disableAnimations, ...data } =
|
||||
useLoaderData<typeof loader>() || {};
|
||||
const {
|
||||
publicEnv,
|
||||
session,
|
||||
lang,
|
||||
disableAnimations,
|
||||
nonce: cspNonce,
|
||||
...data
|
||||
} = useLoaderData<typeof loader>() || {};
|
||||
|
||||
const [theme] = useTheme();
|
||||
|
||||
@@ -111,12 +122,13 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="google" content="notranslate" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<Links nonce={nonce(cspNonce)} />
|
||||
<meta name="google" content="notranslate" />
|
||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} />
|
||||
<PreventFlashOnWrongTheme ssrTheme={Boolean(data.theme)} nonce={nonce(cspNonce)} />
|
||||
|
||||
{disableAnimations && (
|
||||
<style
|
||||
nonce={nonce(cspNonce)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `*, *::before, *::after { animation: none !important; transition: none !important; }`,
|
||||
}}
|
||||
@@ -124,7 +136,7 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
)}
|
||||
|
||||
{/* Fix: https://stackoverflow.com/questions/21147149/flash-of-unstyled-content-fouc-in-firefox-only-is-ff-slow-renderer */}
|
||||
<script>0</script>
|
||||
<script nonce={nonce(cspNonce)}>0</script>
|
||||
</head>
|
||||
<body>
|
||||
{/* Global license banner currently disabled. Need to wait until after a few releases. */}
|
||||
@@ -152,13 +164,14 @@ export function LayoutContent({ children }: { children: React.ReactNode }) {
|
||||
</NuqsAdapter>
|
||||
|
||||
<script
|
||||
nonce={nonce(cspNonce)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.__ENV__ = ${JSON.stringify(publicEnv)}`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ScrollRestoration nonce={nonce(cspNonce)} />
|
||||
<Scripts nonce={nonce(cspNonce)} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -62,6 +62,17 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const { data: fieldSignatures } = trpc.envelope.field.getSignatures.useQuery(
|
||||
{
|
||||
envelopeId: params.id,
|
||||
},
|
||||
{
|
||||
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
|
||||
enabled:
|
||||
envelope && envelope.internalVersion === 2 && envelope.status === DocumentStatus.PENDING,
|
||||
},
|
||||
);
|
||||
|
||||
if (isLoadingEnvelope) {
|
||||
return (
|
||||
<div className="flex w-screen flex-col items-center justify-center gap-2 py-64 text-foreground">
|
||||
@@ -108,12 +119,9 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
<Trans>Documents</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={envelope.title}
|
||||
>
|
||||
<div className="flex flex-row justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="mt-4 block text-2xl font-semibold md:text-3xl" title={envelope.title}>
|
||||
{envelope.title}
|
||||
</h1>
|
||||
|
||||
@@ -162,6 +170,7 @@ export default function DocumentPage({ params }: Route.ComponentProps) {
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
token={undefined}
|
||||
fields={envelope.fields}
|
||||
signatures={fieldSignatures}
|
||||
recipients={envelope.recipients}
|
||||
overrideSettings={{
|
||||
showRecipientSigningStatus: true,
|
||||
|
||||
@@ -121,44 +121,23 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<div className="mx-auto -mt-4 w-full max-w-screen-xl px-4 md:px-8">
|
||||
<Link to={templateRootPath} className="flex items-center text-documenso-700 hover:opacity-80">
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Templates</Trans>
|
||||
</Link>
|
||||
<div className="flex flex-row justify-between">
|
||||
<Link
|
||||
to={templateRootPath}
|
||||
className="flex items-center text-documenso-700 hover:opacity-80"
|
||||
>
|
||||
<ChevronLeft className="mr-2 inline-block h-5 w-5" />
|
||||
<Trans>Templates</Trans>
|
||||
</Link>
|
||||
|
||||
<div className="flex flex-row justify-between truncate">
|
||||
<div>
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
title={envelope.title}
|
||||
>
|
||||
{envelope.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center">
|
||||
<TemplateType
|
||||
inheritColor
|
||||
className="text-muted-foreground"
|
||||
type={envelope.templateType}
|
||||
/>
|
||||
|
||||
{envelope.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-4"
|
||||
token={envelope.directLink.token}
|
||||
enabled={envelope.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-row space-x-4 sm:mt-0 sm:self-end">
|
||||
<div className="flex shrink-0 flex-row space-x-4">
|
||||
{isOwnTeamTemplate && (
|
||||
<>
|
||||
<TemplateDirectLinkDialog
|
||||
templateId={mapSecondaryIdToTemplateId(envelope.secondaryId)}
|
||||
directLink={envelope.directLink}
|
||||
recipients={envelope.recipients}
|
||||
triggerSizeVariant="sm"
|
||||
/>
|
||||
|
||||
<TemplateBulkSendDialog
|
||||
@@ -166,7 +145,7 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
recipients={envelope.recipients}
|
||||
/>
|
||||
|
||||
<Button className="w-full" asChild>
|
||||
<Button asChild size="sm">
|
||||
<Link to={`${templateRootPath}/${envelope.id}/edit`}>
|
||||
<LucideEdit className="mr-1.5 h-3.5 w-3.5" />
|
||||
<Trans>Edit Template</Trans>
|
||||
@@ -177,6 +156,28 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<h1 className="mt-4 block text-2xl font-semibold md:text-3xl" title={envelope.title}>
|
||||
{envelope.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex items-center">
|
||||
<TemplateType
|
||||
inheritColor
|
||||
className="text-muted-foreground"
|
||||
type={envelope.templateType}
|
||||
/>
|
||||
|
||||
{envelope.directLink?.token && (
|
||||
<TemplateDirectLinkBadge
|
||||
className="ml-4"
|
||||
token={envelope.directLink.token}
|
||||
enabled={envelope.directLink.enabled}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid w-full grid-cols-12 gap-8">
|
||||
{envelope.internalVersion === 2 ? (
|
||||
<div className="relative col-span-12 lg:col-span-6 xl:col-span-7">
|
||||
@@ -247,20 +248,18 @@ export default function TemplatePage({ params }: Route.ComponentProps) {
|
||||
<Trans>Template</Trans>
|
||||
</h3>
|
||||
|
||||
{isOwnTeamTemplate && (
|
||||
<div>
|
||||
<TemplatesTableActionDropdown
|
||||
row={{
|
||||
...envelope,
|
||||
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
}}
|
||||
teamId={team?.id}
|
||||
templateRootPath={templateRootPath}
|
||||
onDelete={async () => navigate(templateRootPath)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<TemplatesTableActionDropdown
|
||||
row={{
|
||||
...envelope,
|
||||
id: mapSecondaryIdToTemplateId(envelope.secondaryId),
|
||||
envelopeId: envelope.id,
|
||||
}}
|
||||
teamId={team?.id}
|
||||
templateRootPath={templateRootPath}
|
||||
onDelete={async () => navigate(templateRootPath)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 px-4 text-sm text-muted-foreground">
|
||||
|
||||
@@ -17,19 +17,14 @@ import { EmbedRecipientExpired } from '~/components/embed/embed-recipient-expire
|
||||
|
||||
import type { Route } from './+types/_layout';
|
||||
|
||||
// Todo: (RR7) Test
|
||||
export function headers({ loaderHeaders }: Route.HeadersArgs) {
|
||||
const origin = loaderHeaders.get('Origin') ?? '*';
|
||||
|
||||
// Allow third parties to iframe the document.
|
||||
return {
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
'Content-Security-Policy': `frame-ancestors ${origin}`,
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
};
|
||||
}
|
||||
// Note: CSP (`frame-ancestors *`), `Referrer-Policy`, and
|
||||
// `X-Content-Type-Options` are now emitted globally by
|
||||
// `securityHeadersMiddleware` for any path under `/embed`. See
|
||||
// `apps/remix/server/security-headers.ts`.
|
||||
//
|
||||
// The previous `Access-Control-Allow-*` headers here only ever applied to
|
||||
// HTML page renders, where CORS preflight does not apply, so they were a
|
||||
// no-op and have been dropped along with the rest of `headers()`.
|
||||
|
||||
export function loader() {
|
||||
// SSR env variables.
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Returns the supplied CSP nonce only when rendering on the server.
|
||||
*
|
||||
* Browsers strip the `nonce` attribute from `getAttribute()` after CSP
|
||||
* processing for security (so reflected XSS can't read the nonce back out
|
||||
* of the DOM), but React 18's hydration reads via `getAttribute` and warns
|
||||
* about a mismatch when the JSX prop is non-empty:
|
||||
*
|
||||
* Prop `nonce` did not match. Server: "" Client: "abc..."
|
||||
*
|
||||
* Returning `undefined` on the client makes React treat the prop as
|
||||
* "no attribute" — `shouldRemoveAttribute` short-circuits for nullish
|
||||
* values (see `react-dom/cjs/react-dom.development.js` `shouldRemoveAttribute`),
|
||||
* and the hydration prop-diff branch is skipped entirely.
|
||||
*
|
||||
* The nonce only matters at the moment the script/style is parsed by the
|
||||
* browser. After that it's an inert attribute, so dropping it on the
|
||||
* client has no functional impact. Subsequent dynamically-injected
|
||||
* scripts inherit trust via `'strict-dynamic'`.
|
||||
*/
|
||||
export const nonce = (value: string | undefined): string | undefined =>
|
||||
typeof window === 'undefined' ? value : '';
|
||||
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.9.0"
|
||||
"version": "2.10.1"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import type { AppLoadContext } from 'react-router';
|
||||
|
||||
import type { HonoEnv } from './router';
|
||||
import { CSP_NONCE_KEY } from './security-headers';
|
||||
|
||||
/**
|
||||
* Augment React Router's `AppLoadContext` so loaders, actions, and
|
||||
* `entry.server` can access fields by name without casts.
|
||||
*/
|
||||
declare module 'react-router' {
|
||||
interface AppLoadContext {
|
||||
/**
|
||||
* Per-request CSP nonce. Populated by `securityHeadersMiddleware` and surfaced here
|
||||
* so it can be threaded into `<ServerRouter nonce>` and root loader
|
||||
* data, which then feeds `<Scripts>`, `<Links>`, etc.
|
||||
*/
|
||||
nonce: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the React Router `AppLoadContext` for both dev (vite plugin) and
|
||||
* production (`hono-react-router-adapter/node`).
|
||||
*
|
||||
* The Hono context isn't passed directly by the adapter, so we read it via
|
||||
* `hono/context-storage`, which is enabled in `server/router.ts`.
|
||||
*/
|
||||
export const getLoadContext = (): AppLoadContext => {
|
||||
const nonce = getContext<HonoEnv>().var[CSP_NONCE_KEY] ?? '';
|
||||
|
||||
return { nonce };
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import { serve } from '@hono/node-server';
|
||||
import { serveStatic } from '@hono/node-server/serve-static';
|
||||
import handle from 'hono-react-router-adapter/node';
|
||||
|
||||
import { getLoadContext } from './hono/server/load-context.js';
|
||||
import server from './hono/server/router.js';
|
||||
import * as build from './index.js';
|
||||
|
||||
@@ -28,7 +29,7 @@ server.use(
|
||||
}),
|
||||
);
|
||||
|
||||
const handler = handle(build, server);
|
||||
const handler = handle(build, server, { getLoadContext });
|
||||
|
||||
const port = parseInt(process.env.PORT || '3000', 10);
|
||||
|
||||
|
||||
@@ -29,13 +29,20 @@ import { downloadRoute } from './api/download/download';
|
||||
import { filesRoute } from './api/files/files';
|
||||
import { type AppContext, appContext } from './context';
|
||||
import { appMiddleware } from './middleware';
|
||||
import { securityHeadersMiddleware } from './security-headers';
|
||||
import { openApiTrpcServerHandler } from './trpc/hono-trpc-open-api';
|
||||
import { reactRouterTrpcServer } from './trpc/hono-trpc-remix';
|
||||
|
||||
// Re-export so the rollup build (entry: server/router.ts) bundles
|
||||
// load-context.ts. server/main.js imports getLoadContext from the rolled-up
|
||||
// output to wire it into the React Router adapter.
|
||||
export { getLoadContext } from './load-context';
|
||||
|
||||
export interface HonoEnv {
|
||||
Variables: RequestIdVariables & {
|
||||
context: AppContext;
|
||||
logger: Logger;
|
||||
cspNonce: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +63,15 @@ const fileRateLimitMiddleware = createRateLimitMiddleware(fileUploadRateLimit);
|
||||
app.use(contextStorage());
|
||||
app.use(appContext);
|
||||
|
||||
/**
|
||||
* Emit response security headers (CSP with per-request nonce, plus
|
||||
* Referrer-Policy and X-Content-Type-Options on embed routes). Must run
|
||||
* after `contextStorage()` so the nonce is readable via `getContext()` from
|
||||
* `getLoadContext`, and before the React Router handler so the response
|
||||
* carries the header.
|
||||
*/
|
||||
app.use(securityHeadersMiddleware);
|
||||
|
||||
/**
|
||||
* RR7 app middleware.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createMiddleware } from 'hono/factory';
|
||||
|
||||
import type { HonoEnv } from './router';
|
||||
|
||||
/**
|
||||
* Paths that never render HTML and therefore do not need security headers.
|
||||
*
|
||||
* Browsers ignore CSP and friends on non-document responses, so we skip
|
||||
* them to keep API/manifest/asset responses clean.
|
||||
*/
|
||||
const NON_PAGE_PATH_REGEX = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/apple-.*|\/favicon.*)/;
|
||||
|
||||
/**
|
||||
* Embed routes serve our white-label embed UI. Customers iframe these from
|
||||
* arbitrary origins, so `frame-ancestors` must be wildcard, and customer-
|
||||
* supplied CSS is injected at runtime as `<style>` elements which means
|
||||
* `style-src-elem` cannot be nonce-restricted on these routes.
|
||||
*/
|
||||
const EMBED_PATH_REGEX = /^\/embed(\/|\.data|$)/;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* These routes still get the strict nonced `script-src`/`style-src-elem`
|
||||
* policy — only `frame-ancestors` is relaxed. The `(\/|\.data|$)` tail
|
||||
* keeps `/sign` from matching `/signin`/`/signup` and `/d` from matching
|
||||
* `/dashboard`.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* Read by `getLoadContext` (server/load-context.ts) so the nonce can be
|
||||
* threaded into React Router's `<ServerRouter nonce>` and surfaced in the
|
||||
* root loader for use by `<Scripts>`, `<Links>`, etc.
|
||||
*/
|
||||
export const CSP_NONCE_KEY = 'cspNonce' as const;
|
||||
|
||||
const generateNonce = () => {
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
|
||||
let binary = '';
|
||||
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
binary += String.fromCharCode(buf[i]);
|
||||
}
|
||||
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
type CspPathKind = 'embed' | 'frameable' | 'default';
|
||||
|
||||
const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) => {
|
||||
// `'self'` is included alongside `'strict-dynamic'` as a fallback for
|
||||
// browsers that don't understand `'strict-dynamic'`. Modern browsers
|
||||
// ignore `'self'` (and other host/scheme sources) when `'strict-dynamic'`
|
||||
// is present.
|
||||
const directives = [
|
||||
`base-uri 'self'`,
|
||||
`object-src 'none'`,
|
||||
`form-action 'self'`,
|
||||
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
|
||||
// PDF.js (apps/remix/app/components/general/pdf-viewer/pdf-viewer.tsx)
|
||||
// creates a Web Worker via `new Worker(url)`. `'strict-dynamic'` does
|
||||
// not reliably propagate to worker creation across browsers, and
|
||||
// without `worker-src` the browser falls back to `script-src` which
|
||||
// would block the worker. `blob:` covers libs that inline workers.
|
||||
`worker-src 'self' blob:`,
|
||||
// Inline `style=""` attributes cannot be nonced or hashed (CSP3 has no
|
||||
// mechanism for it), and React inline styles, framer-motion, react-rnd,
|
||||
// konva, etc. all rely on them. `'unsafe-inline'` for attributes is
|
||||
// industry standard and does not weaken `style-src-elem`.
|
||||
`style-src-attr 'unsafe-inline'`,
|
||||
];
|
||||
|
||||
// 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. 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, 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'`);
|
||||
}
|
||||
|
||||
return directives.join('; ');
|
||||
};
|
||||
|
||||
const classifyPath = (path: string): CspPathKind => {
|
||||
if (EMBED_PATH_REGEX.test(path)) {
|
||||
return 'embed';
|
||||
}
|
||||
|
||||
if (FRAMEABLE_PATH_REGEX.test(path)) {
|
||||
return 'frameable';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
};
|
||||
|
||||
/**
|
||||
* Owns response security headers for page responses:
|
||||
* `Content-Security-Policy`, plus `Referrer-Policy` and
|
||||
* `X-Content-Type-Options` on embed routes (preserved from the per-route
|
||||
* `headers()` export this middleware replaces).
|
||||
*
|
||||
* Generates a per-request CSP nonce and stashes it on the Hono context so
|
||||
* `getLoadContext` (server/load-context.ts) can thread it into React
|
||||
* 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.
|
||||
* - `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();
|
||||
|
||||
c.set(CSP_NONCE_KEY, nonce);
|
||||
|
||||
await next();
|
||||
|
||||
const path = c.req.path;
|
||||
|
||||
if (NON_PAGE_PATH_REGEX.test(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = classifyPath(path);
|
||||
|
||||
c.res.headers.set('Content-Security-Policy', buildCspHeader({ nonce, kind }));
|
||||
|
||||
// Preserved from the per-route `headers()` export in
|
||||
// apps/remix/app/routes/embed+/_v0+/_layout.tsx, which has been removed.
|
||||
if (kind === 'embed') {
|
||||
if (!c.res.headers.has('Referrer-Policy')) {
|
||||
c.res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
}
|
||||
|
||||
if (!c.res.headers.has('X-Content-Type-Options')) {
|
||||
c.res.headers.set('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -47,6 +47,10 @@ export default defineConfig({
|
||||
tsconfigPaths(),
|
||||
serverAdapter({
|
||||
entry: 'server/router.ts',
|
||||
getLoadContext: async () => {
|
||||
const { getLoadContext } = await import('./server/load-context');
|
||||
return getLoadContext();
|
||||
},
|
||||
exclude: [
|
||||
// Spread the defaults but replace the /.css$/ rule so that Bull
|
||||
// Board's static CSS at /api/jobs/board/static/** passes through to Hono.
|
||||
|
||||
Reference in New Issue
Block a user