mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 01:15:49 +10:00
Merge branch 'main' into feat/signing-required-field-colors
This commit is contained in:
@@ -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