mirror of
https://github.com/documenso/documenso.git
synced 2026-07-23 00:13:46 +10:00
Merge branch 'main' into fix/dev-hmr-full-reload
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
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
@@ -24,6 +24,19 @@ type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' |
|
||||
type EnvelopeDownloadDialogProps = {
|
||||
envelopeId: string;
|
||||
envelopeStatus: DocumentStatus;
|
||||
|
||||
/**
|
||||
* Whether the envelope is a legacy (v1) envelope. Only consulted to gate the
|
||||
* partial-download variant: legacy envelopes use a different field-rendering
|
||||
* pipeline that the partial PDF helper does not implement, so the Partial
|
||||
* button is hidden for them.
|
||||
*
|
||||
* Optional: omit it on call sites where the status can never be PENDING (DRAFT,
|
||||
* COMPLETED, REJECTED) or when a recipient token is set, since the Partial button
|
||||
* is also gated on those. Pass it from team-side call sites that can render the
|
||||
* dialog for a PENDING envelope.
|
||||
*/
|
||||
isLegacy?: boolean;
|
||||
envelopeItems?: EnvelopeItemToDownload[];
|
||||
|
||||
/**
|
||||
@@ -38,6 +51,7 @@ type EnvelopeDownloadDialogProps = {
|
||||
export const EnvelopeDownloadDialog = ({
|
||||
envelopeId,
|
||||
envelopeStatus,
|
||||
isLegacy,
|
||||
envelopeItems: initialEnvelopeItems,
|
||||
token,
|
||||
trigger,
|
||||
@@ -51,8 +65,36 @@ export const EnvelopeDownloadDialog = ({
|
||||
[envelopeItemIdAndVersion: string]: boolean;
|
||||
}>({});
|
||||
|
||||
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
|
||||
`${envelopeItemId}-${version}`;
|
||||
const generateDownloadKey = (
|
||||
envelopeItemId: string,
|
||||
version: 'original' | 'signed' | 'pending',
|
||||
) => `${envelopeItemId}-${version}`;
|
||||
|
||||
// The dialog shows the original document alongside one of:
|
||||
// - "Signed" (when the envelope is COMPLETED)
|
||||
// - "Partial" (when the envelope is PENDING, not legacy, and we are on the
|
||||
// team/owner side; recipients are intentionally not offered this since the
|
||||
// partial PDF carries no PKI signature and would create a leak vector for
|
||||
// half-executed contracts; legacy envelopes use a different rendering
|
||||
// pipeline that the partial-download helper does not implement)
|
||||
// - nothing (DRAFT, REJECTED, PENDING with recipient token, or legacy PENDING)
|
||||
const secondaryDownload = useMemo<{ version: 'signed' | 'pending'; label: string } | null>(() => {
|
||||
if (envelopeStatus === DocumentStatus.COMPLETED) {
|
||||
return {
|
||||
version: 'signed',
|
||||
label: t({ message: 'Signed', context: 'Signed document (adjective)' }),
|
||||
};
|
||||
}
|
||||
|
||||
if (envelopeStatus === DocumentStatus.PENDING && !token && !isLegacy) {
|
||||
return {
|
||||
version: 'pending',
|
||||
label: t({ message: 'Partial', context: 'Partially signed document (adjective)' }),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [envelopeStatus, isLegacy, token, t]);
|
||||
|
||||
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
|
||||
trpc.envelope.item.getManyByToken.useQuery(
|
||||
@@ -70,7 +112,7 @@ export const EnvelopeDownloadDialog = ({
|
||||
|
||||
const onDownload = async (
|
||||
envelopeItem: EnvelopeItemToDownload,
|
||||
version: 'original' | 'signed',
|
||||
version: 'original' | 'signed' | 'pending',
|
||||
) => {
|
||||
const { id: envelopeItemId } = envelopeItem;
|
||||
|
||||
@@ -132,7 +174,7 @@ export const EnvelopeDownloadDialog = ({
|
||||
{Array.from({ length: 1 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-card p-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 flex-shrink-0 rounded-lg" />
|
||||
|
||||
@@ -149,20 +191,20 @@ export const EnvelopeDownloadDialog = ({
|
||||
envelopeItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="border-border bg-card hover:bg-accent/50 flex items-center gap-4 rounded-lg border p-4 transition-colors"
|
||||
className="flex items-center gap-4 rounded-lg border border-border bg-card p-4 transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<FileTextIcon className="text-primary h-5 w-5" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<FileTextIcon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Todo: Envelopes - Fix overflow */}
|
||||
<h4 className="text-foreground truncate text-sm font-medium" title={item.title}>
|
||||
<h4 className="truncate text-sm font-medium text-foreground" title={item.title}>
|
||||
{item.title}
|
||||
</h4>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<Trans>PDF Document</Trans>
|
||||
</p>
|
||||
</div>
|
||||
@@ -181,18 +223,20 @@ export const EnvelopeDownloadDialog = ({
|
||||
<Trans context="Original document (adjective)">Original</Trans>
|
||||
</Button>
|
||||
|
||||
{envelopeStatus === DocumentStatus.COMPLETED && (
|
||||
{secondaryDownload && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={async () => onDownload(item, 'signed')}
|
||||
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
|
||||
onClick={async () => onDownload(item, secondaryDownload.version)}
|
||||
loading={
|
||||
isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]
|
||||
}
|
||||
>
|
||||
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<Trans context="Signed document (adjective)">Signed</Trans>
|
||||
{!isDownloadingState[
|
||||
generateDownloadKey(item.id, secondaryDownload.version)
|
||||
] && <DownloadIcon className="mr-2 h-4 w-4" />}
|
||||
{secondaryDownload.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -44,6 +43,11 @@ import {
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import {
|
||||
type OrganisationMemberOption,
|
||||
OrganisationMembersMultiSelectCombobox,
|
||||
} from '~/components/general/organisation-members-multiselect-combobox';
|
||||
|
||||
export type OrganisationGroupCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
@@ -64,6 +68,7 @@ export const OrganisationGroupCreateDialog = ({
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>([]);
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const form = useForm({
|
||||
@@ -77,13 +82,6 @@ export const OrganisationGroupCreateDialog = ({
|
||||
|
||||
const { mutateAsync: createOrganisationGroup } = trpc.organisation.group.create.useMutation();
|
||||
|
||||
const { data: membersFindResult, isLoading: isLoadingMembers } =
|
||||
trpc.organisation.member.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const members = membersFindResult?.data ?? [];
|
||||
|
||||
const onFormSubmit = async ({
|
||||
name,
|
||||
organisationRole,
|
||||
@@ -119,6 +117,7 @@ export const OrganisationGroupCreateDialog = ({
|
||||
|
||||
useEffect(() => {
|
||||
form.reset();
|
||||
setSelectedMembers([]);
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
@@ -178,7 +177,7 @@ export const OrganisationGroupCreateDialog = ({
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="text-muted-foreground w-full">
|
||||
<SelectTrigger className="w-full text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -209,16 +208,15 @@ export const OrganisationGroupCreateDialog = ({
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={members.map((member) => ({
|
||||
label: member.name,
|
||||
value: member.id,
|
||||
}))}
|
||||
loading={isLoadingMembers}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
className="bg-background w-full"
|
||||
emptySelectionPlaceholder={t`Select members`}
|
||||
<OrganisationMembersMultiSelectCombobox
|
||||
organisationId={organisation.id}
|
||||
selectedMembers={selectedMembers}
|
||||
onChange={(members) => {
|
||||
setSelectedMembers(members);
|
||||
field.onChange(members.map((member) => member.id));
|
||||
}}
|
||||
className="w-full bg-background"
|
||||
dataTestId="group-members-picker"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { match } from 'ts-pattern';
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -41,6 +40,10 @@ import {
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import {
|
||||
type OrganisationGroupOption,
|
||||
OrganisationGroupsMultiSelectCombobox,
|
||||
} from '~/components/general/organisation-groups-multiselect-combobox';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TeamGroupCreateDialogProps = Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
@@ -59,6 +62,7 @@ type TAddTeamMembersFormSchema = z.infer<typeof ZAddTeamMembersFormSchema>;
|
||||
export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<'SELECT' | 'ROLES'>('SELECT');
|
||||
const [selectedGroups, setSelectedGroups] = useState<OrganisationGroupOption[]>([]);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@@ -74,26 +78,6 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
||||
|
||||
const { mutateAsync: createTeamGroups } = trpc.team.group.createMany.useMutation();
|
||||
|
||||
const organisationGroupQuery = trpc.organisation.group.find.useQuery({
|
||||
organisationId: team.organisationId,
|
||||
perPage: 100, // Won't really work if they somehow have more than 100 groups.
|
||||
types: [OrganisationGroupType.CUSTOM],
|
||||
});
|
||||
|
||||
const teamGroupQuery = trpc.team.group.find.useQuery({
|
||||
teamId: team.id,
|
||||
perPage: 100, // Won't really work if they somehow have more than 100 groups.
|
||||
});
|
||||
|
||||
const avaliableOrganisationGroups = useMemo(() => {
|
||||
const organisationGroups = organisationGroupQuery.data?.data ?? [];
|
||||
const teamGroups = teamGroupQuery.data?.data ?? [];
|
||||
|
||||
return organisationGroups.filter(
|
||||
(group) => !teamGroups.some((teamGroup) => teamGroup.organisationGroupId === group.id),
|
||||
);
|
||||
}, [organisationGroupQuery, teamGroupQuery]);
|
||||
|
||||
const onFormSubmit = async ({ groups }: TAddTeamMembersFormSchema) => {
|
||||
try {
|
||||
await createTeamGroups({
|
||||
@@ -121,6 +105,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
||||
if (!open) {
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
setSelectedGroups([]);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
@@ -178,28 +163,24 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={avaliableOrganisationGroups.map((group) => ({
|
||||
label: group.name ?? group.organisationRole,
|
||||
value: group.id,
|
||||
}))}
|
||||
loading={organisationGroupQuery.isLoading || teamGroupQuery.isLoading}
|
||||
selectedValues={field.value.map(
|
||||
({ organisationGroupId }) => organisationGroupId,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
<OrganisationGroupsMultiSelectCombobox
|
||||
organisationId={team.organisationId}
|
||||
selectedGroups={selectedGroups}
|
||||
excludeTeamId={team.id}
|
||||
onChange={(groups) => {
|
||||
setSelectedGroups(groups);
|
||||
field.onChange(
|
||||
value.map((organisationGroupId) => ({
|
||||
organisationGroupId,
|
||||
groups.map((group) => ({
|
||||
organisationGroupId: group.id,
|
||||
teamRole:
|
||||
field.value.find(
|
||||
(value) => value.organisationGroupId === organisationGroupId,
|
||||
(value) => value.organisationGroupId === group.id,
|
||||
)?.teamRole || TeamMemberRole.MEMBER,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className="bg-background w-full"
|
||||
emptySelectionPlaceholder={t`Select groups`}
|
||||
className="w-full bg-background"
|
||||
dataTestId="team-groups-picker"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -243,9 +224,8 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
|
||||
readOnly
|
||||
className="bg-background"
|
||||
value={
|
||||
avaliableOrganisationGroups.find(
|
||||
({ id }) => id === group.organisationGroupId,
|
||||
)?.name || t`Untitled Group`
|
||||
selectedGroups.find(({ id }) => id === group.organisationGroupId)
|
||||
?.name || t`Untitled Group`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -48,6 +47,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitive
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
|
||||
import {
|
||||
type OrganisationMemberOption,
|
||||
OrganisationMembersMultiSelectCombobox,
|
||||
} from '~/components/general/organisation-members-multiselect-combobox';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type TeamMemberCreateDialogProps = {
|
||||
@@ -69,6 +72,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<'SELECT' | 'MEMBERS'>('SELECT');
|
||||
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
||||
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>([]);
|
||||
const prevInviteDialogOpenRef = useRef(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
@@ -92,25 +96,16 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
|
||||
const { mutateAsync: createTeamMembers } = trpc.team.member.createMany.useMutation();
|
||||
|
||||
const organisationMemberQuery = trpc.organisation.member.find.useQuery({
|
||||
// Lightweight count-only query for org members not already on this team.
|
||||
// Powers the "no available members" empty state.
|
||||
const availableMemberCountQuery = trpc.organisation.member.find.useQuery({
|
||||
organisationId: team.organisationId,
|
||||
perPage: 1,
|
||||
excludeTeamId: team.id,
|
||||
});
|
||||
|
||||
const teamMemberQuery = trpc.team.member.find.useQuery({
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const avaliableOrganisationMembers = useMemo(() => {
|
||||
const organisationMembers = organisationMemberQuery.data?.data ?? [];
|
||||
const teamMembers = teamMemberQuery.data?.data ?? [];
|
||||
|
||||
return organisationMembers.filter(
|
||||
(member) => !teamMembers.some((teamMember) => teamMember.id === member.id),
|
||||
);
|
||||
}, [organisationMemberQuery, teamMemberQuery]);
|
||||
|
||||
const hasNoAvailableMembers =
|
||||
!organisationMemberQuery.isLoading && avaliableOrganisationMembers.length === 0;
|
||||
!availableMemberCountQuery.isLoading && (availableMemberCountQuery.data?.count ?? 0) === 0;
|
||||
|
||||
const onFormSubmit = async ({ members }: TAddTeamMembersFormSchema) => {
|
||||
if (members.length === 0) {
|
||||
@@ -159,6 +154,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
form.reset();
|
||||
setStep('SELECT');
|
||||
setInviteDialogOpen(false);
|
||||
setSelectedMembers([]);
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
@@ -296,29 +292,24 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<MultiSelectCombobox
|
||||
options={avaliableOrganisationMembers.map((member) => ({
|
||||
label: member.name,
|
||||
value: member.id,
|
||||
}))}
|
||||
loading={organisationMemberQuery.isLoading}
|
||||
selectedValues={field.value.map(
|
||||
(member) => member.organisationMemberId,
|
||||
)}
|
||||
onChange={(value) => {
|
||||
<OrganisationMembersMultiSelectCombobox
|
||||
organisationId={team.organisationId}
|
||||
selectedMembers={selectedMembers}
|
||||
excludeTeamId={team.id}
|
||||
onChange={(members) => {
|
||||
setSelectedMembers(members);
|
||||
field.onChange(
|
||||
value.map((organisationMemberId) => ({
|
||||
organisationMemberId,
|
||||
members.map((member) => ({
|
||||
organisationMemberId: member.id,
|
||||
teamRole:
|
||||
field.value.find(
|
||||
(member) =>
|
||||
member.organisationMemberId === organisationMemberId,
|
||||
(entry) => entry.organisationMemberId === member.id,
|
||||
)?.teamRole || TeamMemberRole.MEMBER,
|
||||
})),
|
||||
);
|
||||
}}
|
||||
className="w-full bg-background"
|
||||
emptySelectionPlaceholder={t`Select members`}
|
||||
dataTestId="team-members-picker"
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
@@ -394,9 +385,8 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
readOnly
|
||||
className="bg-background"
|
||||
value={
|
||||
organisationMemberQuery.data?.data.find(
|
||||
({ id }) => id === member.organisationMemberId,
|
||||
)?.name || ''
|
||||
selectedMembers.find(({ id }) => id === member.organisationMemberId)
|
||||
?.name || ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,25 @@
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
|
||||
export const EmbedPaywall = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Paywall</h1>
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-lg font-semibold">
|
||||
<Trans>This feature is not available on your current plan</Trans>
|
||||
</p>
|
||||
<p className="mt-2 text-sm">
|
||||
<Trans>
|
||||
Please contact{' '}
|
||||
<Link to={`mailto:${SUPPORT_EMAIL}`} target="_blank">
|
||||
support
|
||||
</Link>{' '}
|
||||
if you have any questions.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -106,6 +106,7 @@ export const SignInForm = ({
|
||||
|
||||
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
|
||||
const turnstileRef = useRef<TurnstileInstance>(null);
|
||||
const twoFactorTurnstileRef = useRef<TurnstileInstance>(null);
|
||||
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
|
||||
|
||||
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||
@@ -234,6 +235,11 @@ export const SignInForm = ({
|
||||
|
||||
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
|
||||
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||
|
||||
// Turnstile tokens are single-use. Clear the consumed one so the
|
||||
// dialog's fresh widget mounts cleanly and the dialog can't be
|
||||
// submitted with the stale token before a new one is issued.
|
||||
setCaptchaToken(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,7 +399,7 @@ export const SignInForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
@@ -545,6 +551,21 @@ export const SignInForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<div className="mt-4">
|
||||
<Turnstile
|
||||
ref={twoFactorTurnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onExpire={() => setCaptchaToken(null)}
|
||||
options={{
|
||||
size: 'flexible',
|
||||
appearance: 'interaction-only',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -558,7 +579,11 @@ export const SignInForm = ({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={isSubmitting}
|
||||
disabled={Boolean(turnstileSiteKey) && !captchaToken}
|
||||
>
|
||||
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -104,7 +104,8 @@ export const DocumentPageViewDropdown = ({ envelope }: DocumentPageViewDropdownP
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
token={recipient?.token}
|
||||
isLegacy={envelope.internalVersion === 1}
|
||||
token={canManageDocument ? undefined : recipient?.token}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
|
||||
+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"
|
||||
|
||||
@@ -495,6 +495,7 @@ export const EnvelopeEditor = () => {
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={envelope.id}
|
||||
envelopeStatus={envelope.status}
|
||||
isLegacy={envelope.internalVersion === 1}
|
||||
envelopeItems={envelope.envelopeItems}
|
||||
trigger={
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { OrganisationGroupType } from '@prisma/client';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||
|
||||
export type OrganisationGroupOption = {
|
||||
/** Organisation group ID. */
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type OrganisationGroupsMultiSelectComboboxProps = {
|
||||
organisationId: string;
|
||||
/**
|
||||
* Currently selected groups. Must include name so chips render with
|
||||
* proper labels even before the first server search returns results.
|
||||
*/
|
||||
selectedGroups: OrganisationGroupOption[];
|
||||
onChange: (groups: OrganisationGroupOption[]) => void;
|
||||
/**
|
||||
* If set, organisation groups already attached to this team are filtered
|
||||
* out of the search results server-side. Used by "add groups to team" flows.
|
||||
*/
|
||||
excludeTeamId?: number;
|
||||
/**
|
||||
* Restrict search to specific group types. Defaults to CUSTOM groups only,
|
||||
* matching how groups are managed in the organisation settings UI.
|
||||
*/
|
||||
types?: OrganisationGroupType[];
|
||||
/** Number of groups to fetch per search call. Defaults to the schema cap (100). */
|
||||
perPage?: number;
|
||||
className?: string;
|
||||
dataTestId?: string;
|
||||
};
|
||||
|
||||
const toOption = (group: OrganisationGroupOption): Option => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
groupName: group.name,
|
||||
});
|
||||
|
||||
const fromOption = (option: Option): OrganisationGroupOption => ({
|
||||
id: option.value,
|
||||
name: typeof option.groupName === 'string' ? option.groupName : option.label,
|
||||
});
|
||||
|
||||
/**
|
||||
* Searchable multi-select combobox for picking organisation groups,
|
||||
* backed by `trpc.organisation.group.find` with server-side search.
|
||||
*
|
||||
* Renders selected groups as chips and supports an unbounded number of
|
||||
* organisation groups (paged out via debounced server queries).
|
||||
*/
|
||||
export const OrganisationGroupsMultiSelectCombobox = ({
|
||||
organisationId,
|
||||
selectedGroups,
|
||||
onChange,
|
||||
excludeTeamId,
|
||||
types = [OrganisationGroupType.CUSTOM],
|
||||
perPage = 100,
|
||||
className,
|
||||
dataTestId,
|
||||
}: OrganisationGroupsMultiSelectComboboxProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const handleSearch = async (query: string): Promise<Option[]> => {
|
||||
const result = await utils.organisation.group.find.fetch({
|
||||
organisationId,
|
||||
query,
|
||||
page: 1,
|
||||
perPage,
|
||||
types,
|
||||
excludeTeamId,
|
||||
});
|
||||
|
||||
return result.data.map((group) =>
|
||||
toOption({
|
||||
id: group.id,
|
||||
name: group.name ?? '',
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
className={className}
|
||||
data-testid={dataTestId}
|
||||
commandProps={{ label: _(msg`Select groups`) }}
|
||||
inputProps={{ 'aria-label': _(msg`Select groups`) }}
|
||||
placeholder={_(msg`Search groups by name`)}
|
||||
value={selectedGroups.map(toOption)}
|
||||
onChange={(options) => onChange(options.map(fromOption))}
|
||||
onSearch={handleSearch}
|
||||
triggerSearchOnFocus
|
||||
hideClearAllButton
|
||||
hidePlaceholderWhenSelected
|
||||
delay={300}
|
||||
loadingIndicator={
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>Loading...</Trans>
|
||||
</p>
|
||||
}
|
||||
emptyIndicator={
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No groups found</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { MultiSelect, type Option } from '@documenso/ui/primitives/multiselect';
|
||||
|
||||
export type OrganisationMemberOption = {
|
||||
/** Organisation member ID. */
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
type OrganisationMembersMultiSelectComboboxProps = {
|
||||
organisationId: string;
|
||||
/**
|
||||
* Currently selected members. Must include name/email so chips render with
|
||||
* proper labels even before the first server search returns results.
|
||||
*/
|
||||
selectedMembers: OrganisationMemberOption[];
|
||||
onChange: (members: OrganisationMemberOption[]) => void;
|
||||
/**
|
||||
* If set, organisation members already on this team are filtered out of the
|
||||
* search results server-side. Used by "add members to team" flows.
|
||||
*/
|
||||
excludeTeamId?: number;
|
||||
/** Number of members to fetch per search call. Defaults to the schema cap (100). */
|
||||
perPage?: number;
|
||||
className?: string;
|
||||
dataTestId?: string;
|
||||
};
|
||||
|
||||
const toOption = (member: OrganisationMemberOption): Option => ({
|
||||
value: member.id,
|
||||
label: member.name ? `${member.name} (${member.email})` : member.email,
|
||||
// Stash these so we can reconstruct OrganisationMemberOption on change.
|
||||
email: member.email,
|
||||
name: member.name,
|
||||
});
|
||||
|
||||
const fromOption = (option: Option): OrganisationMemberOption => ({
|
||||
id: option.value,
|
||||
name: typeof option.name === 'string' ? option.name : '',
|
||||
email: typeof option.email === 'string' ? option.email : '',
|
||||
});
|
||||
|
||||
/**
|
||||
* Searchable multi-select combobox for picking organisation members,
|
||||
* backed by `trpc.organisation.member.find` with server-side search.
|
||||
*
|
||||
* Renders selected members as chips and supports an unbounded number of
|
||||
* organisation members (paged out via debounced server queries).
|
||||
*/
|
||||
export const OrganisationMembersMultiSelectCombobox = ({
|
||||
organisationId,
|
||||
selectedMembers,
|
||||
onChange,
|
||||
excludeTeamId,
|
||||
perPage = 100,
|
||||
className,
|
||||
dataTestId,
|
||||
}: OrganisationMembersMultiSelectComboboxProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const handleSearch = async (query: string): Promise<Option[]> => {
|
||||
const result = await utils.organisation.member.find.fetch({
|
||||
organisationId,
|
||||
query,
|
||||
page: 1,
|
||||
perPage,
|
||||
excludeTeamId,
|
||||
});
|
||||
|
||||
return result.data.map((member) =>
|
||||
toOption({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<MultiSelect
|
||||
className={className}
|
||||
data-testid={dataTestId}
|
||||
commandProps={{ label: _(msg`Select members`) }}
|
||||
inputProps={{ 'aria-label': _(msg`Select members`) }}
|
||||
placeholder={_(msg`Search members by name or email`)}
|
||||
value={selectedMembers.map(toOption)}
|
||||
onChange={(options) => onChange(options.map(fromOption))}
|
||||
onSearch={handleSearch}
|
||||
triggerSearchOnFocus
|
||||
hideClearAllButton
|
||||
hidePlaceholderWhenSelected
|
||||
delay={300}
|
||||
loadingIndicator={
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>Loading...</Trans>
|
||||
</p>
|
||||
}
|
||||
emptyIndicator={
|
||||
<p className="py-4 text-center text-sm text-muted-foreground">
|
||||
<Trans>No members found</Trans>
|
||||
</p>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -152,7 +152,8 @@ export const DocumentsTableActionDropdown = ({
|
||||
<EnvelopeDownloadDialog
|
||||
envelopeId={row.envelopeId}
|
||||
envelopeStatus={row.status}
|
||||
token={recipient?.token}
|
||||
isLegacy={row.internalVersion === 1}
|
||||
token={canManageDocument ? undefined : recipient?.token}
|
||||
trigger={
|
||||
<DropdownMenuItem asChild onSelect={(e) => e.preventDefault()}>
|
||||
<div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -17,7 +17,6 @@ import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translation
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFindOrganisationGroupsResponse } from '@documenso/trpc/server/organisation-router/find-organisation-groups.types';
|
||||
import type { TFindOrganisationMembersResponse } from '@documenso/trpc/server/organisation-router/find-organisation-members.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import {
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { MultiSelectCombobox } from '@documenso/ui/primitives/multi-select-combobox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -42,6 +40,10 @@ import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { OrganisationGroupDeleteDialog } from '~/components/dialogs/organisation-group-delete-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import {
|
||||
type OrganisationMemberOption,
|
||||
OrganisationMembersMultiSelectCombobox,
|
||||
} from '~/components/general/organisation-members-multiselect-combobox';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||
@@ -53,10 +55,6 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
|
||||
const groupId = params.id;
|
||||
|
||||
const { data: members, isLoading: isLoadingMembers } = trpc.organisation.member.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const { data: groupData, isLoading: isLoadingGroup } = trpc.organisation.group.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
@@ -72,10 +70,10 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
|
||||
const group = groupData?.data.find((g) => g.id === groupId);
|
||||
|
||||
if (isLoadingGroup || isLoadingMembers) {
|
||||
if (isLoadingGroup) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,7 +119,7 @@ export default function OrganisationGroupSettingsPage({ params }: Route.Componen
|
||||
/>
|
||||
</SettingsHeader>
|
||||
|
||||
<OrganisationGroupForm group={group} organisationMembers={members?.data || []} />
|
||||
<OrganisationGroupForm group={group} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,10 +134,9 @@ type TUpdateOrganisationGroupFormSchema = z.infer<typeof ZUpdateOrganisationGrou
|
||||
|
||||
type OrganisationGroupFormOptions = {
|
||||
group: TFindOrganisationGroupsResponse['data'][number];
|
||||
organisationMembers: TFindOrganisationMembersResponse['data'];
|
||||
};
|
||||
|
||||
const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroupFormOptions) => {
|
||||
const OrganisationGroupForm = ({ group }: OrganisationGroupFormOptions) => {
|
||||
const { toast } = useToast();
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -147,6 +144,16 @@ const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroup
|
||||
|
||||
const { mutateAsync: updateOrganisationGroup } = trpc.organisation.group.update.useMutation();
|
||||
|
||||
// Track full member details (name/email) keyed by id so chip labels render
|
||||
// correctly even after the form has been mounted for a while.
|
||||
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>(() =>
|
||||
group.members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
})),
|
||||
);
|
||||
|
||||
const form = useForm<TUpdateOrganisationGroupFormSchema>({
|
||||
resolver: zodResolver(ZUpdateOrganisationGroupFormSchema),
|
||||
defaultValues: {
|
||||
@@ -258,15 +265,15 @@ const OrganisationGroupForm = ({ group, organisationMembers }: OrganisationGroup
|
||||
<Trans>Members</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<MultiSelectCombobox
|
||||
options={organisationMembers.map((member) => ({
|
||||
label: member.name || member.email,
|
||||
value: member.id,
|
||||
}))}
|
||||
selectedValues={field.value}
|
||||
onChange={field.onChange}
|
||||
<OrganisationMembersMultiSelectCombobox
|
||||
organisationId={organisation.id}
|
||||
selectedMembers={selectedMembers}
|
||||
onChange={(members) => {
|
||||
setSelectedMembers(members);
|
||||
field.onChange(members.map((member) => member.id));
|
||||
}}
|
||||
className="w-full"
|
||||
emptySelectionPlaceholder={t`Select members`}
|
||||
dataTestId="group-members-picker"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
|
||||
@@ -51,6 +51,7 @@ const ZProviderFormSchema = ZUpdateOrganisationAuthenticationPortalRequestSchema
|
||||
clientId: true,
|
||||
autoProvisionUsers: true,
|
||||
defaultOrganisationRole: true,
|
||||
allowPersonalOrganisations: true,
|
||||
})
|
||||
.extend({
|
||||
clientSecret: z.string().nullable(),
|
||||
@@ -120,6 +121,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
|
||||
autoProvisionUsers: authenticationPortal.autoProvisionUsers,
|
||||
defaultOrganisationRole: authenticationPortal.defaultOrganisationRole,
|
||||
allowedDomains: authenticationPortal.allowedDomains.join(' '),
|
||||
allowPersonalOrganisations: authenticationPortal.allowPersonalOrganisations,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -161,6 +163,7 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
|
||||
autoProvisionUsers: values.autoProvisionUsers,
|
||||
defaultOrganisationRole: values.defaultOrganisationRole,
|
||||
allowedDomains: values.allowedDomains.split(' ').filter(Boolean),
|
||||
allowPersonalOrganisations: values.allowPersonalOrganisations,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -390,6 +393,30 @@ const SSOProviderForm = ({ authenticationPortal }: SSOProviderFormProps) => {
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowPersonalOrganisations"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border px-4 py-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
<Trans>Allow Personal Organisations</Trans>
|
||||
</FormLabel>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
When enabled, users signing in via SSO for the first time will also receive
|
||||
their own personal organisation.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
|
||||
@@ -298,6 +298,7 @@ const EnvelopeCreatePage = ({ embedAuthoringOptions }: EnvelopeCreatePageProps)
|
||||
mode: 'create' as const,
|
||||
onCreate: async (envelope: Omit<TEditorEnvelope, 'id'>) => createEmbeddedEnvelope(envelope),
|
||||
customBrandingLogo: Boolean(teamSettings.brandingEnabled && teamSettings.brandingLogo),
|
||||
user: embedAuthoringOptions.user,
|
||||
}),
|
||||
[token],
|
||||
);
|
||||
|
||||
@@ -314,6 +314,7 @@ const EnvelopeEditPage = ({ embedAuthoringOptions }: EnvelopeEditPageProps) => {
|
||||
mode: 'edit' as const,
|
||||
onUpdate: async (envelope: TEditorEnvelope) => updateEmbeddedEnvelope(envelope),
|
||||
brandingLogo,
|
||||
user: embedAuthoringOptions.user,
|
||||
}),
|
||||
[token],
|
||||
);
|
||||
|
||||
@@ -106,5 +106,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.9.1"
|
||||
"version": "2.10.1"
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -19,23 +19,32 @@ const NON_PAGE_PATH_REGEX = /^(\/api\/|\/ingest\/|\/__manifest|\/assets\/|\/appl
|
||||
const EMBED_PATH_REGEX = /^\/embed(\/|\.data|$)/;
|
||||
|
||||
/**
|
||||
* Auth pages reachable from inside an embed iframe during the
|
||||
* Non-`/embed` page routes that customers iframe directly, plus the auth
|
||||
* pages reachable from inside an embed iframe during the
|
||||
* reauth-as-different-account flow.
|
||||
*
|
||||
* Signing routes (`/sign/:token`, `/d/:token`):
|
||||
* Some customer integrations embed these URLs directly (without going
|
||||
* through `EmbedSignDocument`). Without `frame-ancestors *` here, those
|
||||
* integrations break with a "refused to connect" iframe error.
|
||||
*
|
||||
* Auth routes (`/signin`, `/forgot-password`, `/check-email`,
|
||||
* `/unverified-account`):
|
||||
* `apps/remix/app/components/general/document-signing/document-signing-auth-account.tsx`
|
||||
* does `window.location.href = '/signin?...'` inside the iframe when the
|
||||
* user needs to sign out and sign back in as a different account, and
|
||||
* `<SignInForm>` links/navigates to `/forgot-password`, `/check-email`, and
|
||||
* `/unverified-account` from there.
|
||||
*
|
||||
* Without `frame-ancestors *` on these routes, the customer's iframe gets
|
||||
* blocked the moment the user clicks "Login" in the reauth dialog.
|
||||
* `/unverified-account` from there. Without `frame-ancestors *` on these
|
||||
* routes, the customer's iframe gets blocked the moment the user clicks
|
||||
* "Login" in the reauth dialog.
|
||||
*
|
||||
* These routes still get the strict nonced `script-src`/`style-src-elem`
|
||||
* policy — only `frame-ancestors` is relaxed.
|
||||
* policy — only `frame-ancestors` is relaxed. The `(\/|\.data|$)` tail
|
||||
* keeps `/sign` from matching `/signin`/`/signup` and `/d` from matching
|
||||
* `/dashboard`.
|
||||
*/
|
||||
const AUTH_FRAMEABLE_PATH_REGEX =
|
||||
/^\/(signin|forgot-password|check-email|unverified-account)(\/|\.data|$)/;
|
||||
const FRAMEABLE_PATH_REGEX =
|
||||
/^\/(signin|forgot-password|check-email|unverified-account|sign|d)(\/|\.data|$)/;
|
||||
|
||||
/**
|
||||
* Hono context variable name where the per-request CSP nonce is stashed.
|
||||
@@ -59,7 +68,7 @@ const generateNonce = () => {
|
||||
return btoa(binary);
|
||||
};
|
||||
|
||||
type CspPathKind = 'embed' | 'auth' | 'default';
|
||||
type CspPathKind = 'embed' | 'frameable' | 'default';
|
||||
|
||||
const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) => {
|
||||
// `'self'` is included alongside `'strict-dynamic'` as a fallback for
|
||||
@@ -87,18 +96,18 @@ const buildCspHeader = ({ nonce, kind }: { nonce: string; kind: CspPathKind }) =
|
||||
// Embeds inject customer-supplied CSS via runtime-created `<style>`
|
||||
// elements (see apps/remix/app/utils/css-vars.ts). Nonce-stamping those
|
||||
// would be brittle for white-label customers, so we accept
|
||||
// `'unsafe-inline'` on the embed scope only. Auth pages do NOT load
|
||||
// customer CSS and keep the strict nonced policy.
|
||||
// `'unsafe-inline'` on the embed scope only. Frameable (auth/signing)
|
||||
// pages do NOT load customer CSS and keep the strict nonced policy.
|
||||
if (kind === 'embed') {
|
||||
directives.push(`style-src-elem 'self' 'unsafe-inline'`);
|
||||
} else {
|
||||
directives.push(`style-src-elem 'self' 'nonce-${nonce}'`);
|
||||
}
|
||||
|
||||
// Embed and auth routes are both reachable from inside a customer's
|
||||
// iframe and therefore need `frame-ancestors *`. Every other page gets
|
||||
// clickjacking protection.
|
||||
if (kind === 'embed' || kind === 'auth') {
|
||||
// Embed, signing, and auth routes are all reachable from inside a
|
||||
// customer's iframe and therefore need `frame-ancestors *`. Every other
|
||||
// page gets clickjacking protection.
|
||||
if (kind === 'embed' || kind === 'frameable') {
|
||||
directives.push(`frame-ancestors *`);
|
||||
} else {
|
||||
directives.push(`frame-ancestors 'self'`);
|
||||
@@ -112,8 +121,8 @@ const classifyPath = (path: string): CspPathKind => {
|
||||
return 'embed';
|
||||
}
|
||||
|
||||
if (AUTH_FRAMEABLE_PATH_REGEX.test(path)) {
|
||||
return 'auth';
|
||||
if (FRAMEABLE_PATH_REGEX.test(path)) {
|
||||
return 'frameable';
|
||||
}
|
||||
|
||||
return 'default';
|
||||
@@ -130,13 +139,16 @@ const classifyPath = (path: string): CspPathKind => {
|
||||
* Router for `<ServerRouter nonce>` and `<Scripts nonce>` etc.
|
||||
*
|
||||
* Path-aware classification:
|
||||
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'` style-src-elem
|
||||
* (white-label CSS injection), strict nonced script-src.
|
||||
* - `auth` — wildcard `frame-ancestors` only; needed because the embed
|
||||
* reauth flow redirects the iframe to `/signin` etc. Strict
|
||||
* nonced script-src and style-src-elem otherwise.
|
||||
* - default — strict nonced script-src and style-src-elem,
|
||||
* `frame-ancestors 'self'` for clickjacking protection.
|
||||
* - `embed` — wildcard `frame-ancestors`, `'unsafe-inline'`
|
||||
* style-src-elem (white-label CSS injection), strict
|
||||
* nonced script-src.
|
||||
* - `frameable` — wildcard `frame-ancestors` only; needed because the
|
||||
* embed reauth flow redirects the iframe to `/signin` etc,
|
||||
* and because some customers iframe `/sign/:token` and
|
||||
* `/d/:token` directly without using `EmbedSignDocument`.
|
||||
* Strict nonced script-src and style-src-elem otherwise.
|
||||
* - default — strict nonced script-src and style-src-elem,
|
||||
* `frame-ancestors 'self'` for clickjacking protection.
|
||||
*/
|
||||
export const securityHeadersMiddleware = createMiddleware<HonoEnv>(async (c, next) => {
|
||||
const nonce = generateNonce();
|
||||
|
||||
Reference in New Issue
Block a user