Merge branch 'main' into feat/signing-required-field-colors

This commit is contained in:
Ephraim Duncan
2026-05-07 12:41:01 +00:00
committed by GitHub
113 changed files with 5287 additions and 1584 deletions
@@ -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>
);
};
+2 -4
View File
@@ -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 }),
});
+27 -2
View File
@@ -106,6 +106,7 @@ export const SignInForm = ({
const turnstileSiteKey = env('NEXT_PUBLIC_TURNSTILE_SITE_KEY');
const turnstileRef = useRef<TurnstileInstance>(null);
const twoFactorTurnstileRef = useRef<TurnstileInstance>(null);
const [captchaToken, setCaptchaToken] = useState<string | null>(null);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
@@ -234,6 +235,11 @@ export const SignInForm = ({
if (error.code === 'TWO_FACTOR_MISSING_CREDENTIALS') {
setIsTwoFactorAuthenticationDialogOpen(true);
// Turnstile tokens are single-use. Clear the consumed one so the
// dialog's fresh widget mounts cleanly and the dialog can't be
// submitted with the stale token before a new one is issued.
setCaptchaToken(null);
return;
}
@@ -393,7 +399,7 @@ export const SignInForm = ({
)}
/>
{turnstileSiteKey && (
{turnstileSiteKey && !isTwoFactorAuthenticationDialogOpen && (
<Turnstile
ref={turnstileRef}
siteKey={turnstileSiteKey}
@@ -545,6 +551,21 @@ export const SignInForm = ({
/>
)}
{turnstileSiteKey && (
<div className="mt-4">
<Turnstile
ref={twoFactorTurnstileRef}
siteKey={turnstileSiteKey}
onSuccess={setCaptchaToken}
onExpire={() => setCaptchaToken(null)}
options={{
size: 'flexible',
appearance: 'interaction-only',
}}
/>
</div>
)}
<DialogFooter className="mt-4">
<Button
type="button"
@@ -558,7 +579,11 @@ export const SignInForm = ({
)}
</Button>
<Button type="submit" loading={isSubmitting}>
<Button
type="submit"
loading={isSubmitting}
disabled={Boolean(turnstileSiteKey) && !captchaToken}
>
{isSubmitting ? <Trans>Signing in...</Trans> : <Trans>Sign In</Trans>}
</Button>
</DialogFooter>
+3 -5
View File
@@ -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" />
@@ -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
@@ -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>