chore: merge main, resolve biome formatting conflicts

Merge origin/main into feat/external-2fa-codes. Resolve formatting
conflicts caused by biome rollout; preserve both feature streams:
PR's external 2FA token + signing-session 2FA proof additions plus
main's RateLimit/RecipientExpired/signingReminders/date-auto-insert.

In complete-document-with-token.ts, drop the duplicate early
field-fetching block introduced when main moved that logic later
with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH
check using derivedRecipientActionAuth.
This commit is contained in:
ephraimduncan
2026-05-12 11:46:11 +00:00
parent 9194884fbe
commit 138d663c25
1959 changed files with 93488 additions and 47038 deletions
+18 -18
View File
@@ -1,9 +1,9 @@
@import '@documenso/ui/styles/theme.css';
@import "@documenso/ui/styles/theme.css";
/* Inter Variable Fonts */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-variablefont_opsz,wght.ttf') format('truetype-variations');
font-family: "Inter";
src: url("/fonts/inter-variablefont_opsz,wght.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
@@ -11,8 +11,8 @@
/* Inter Italic Variable Fonts */
@font-face {
font-family: 'Inter';
src: url('/fonts/inter-italic-variablefont_opsz,wght.ttf') format('truetype-variations');
font-family: "Inter";
src: url("/fonts/inter-italic-variablefont_opsz,wght.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: italic;
font-display: swap;
@@ -20,16 +20,16 @@
/* Caveat Variable Font */
@font-face {
font-family: 'Caveat';
src: url('/fonts/caveat-variablefont_wght.ttf') format('truetype-variations');
font-family: "Caveat";
src: url("/fonts/caveat-variablefont_wght.ttf") format("truetype-variations");
font-weight: 400 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Noto Sans';
src: url('/fonts/noto-sans.ttf') format('truetype-variations');
font-family: "Noto Sans";
src: url("/fonts/noto-sans.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
@@ -37,8 +37,8 @@
/* Korean noto sans */
@font-face {
font-family: 'Noto Sans Korean';
src: url('/fonts/noto-sans-korean.ttf') format('truetype-variations');
font-family: "Noto Sans Korean";
src: url("/fonts/noto-sans-korean.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
@@ -46,8 +46,8 @@
/* Japanese noto sans */
@font-face {
font-family: 'Noto Sans Japanese';
src: url('/fonts/noto-sans-japanese.ttf') format('truetype-variations');
font-family: "Noto Sans Japanese";
src: url("/fonts/noto-sans-japanese.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
@@ -55,8 +55,8 @@
/* Chinese noto sans */
@font-face {
font-family: 'Noto Sans Chinese';
src: url('/fonts/noto-sans-chinese.ttf') format('truetype-variations');
font-family: "Noto Sans Chinese";
src: url("/fonts/noto-sans-chinese.ttf") format("truetype-variations");
font-weight: 100 900;
font-style: normal;
font-display: swap;
@@ -64,8 +64,8 @@
@layer base {
:root {
--font-sans: 'Inter';
--font-signature: 'Caveat';
--font-noto: 'Noto Sans', 'Noto Sans Korean', 'Noto Sans Japanese', 'Noto Sans Chinese';
--font-sans: "Inter";
--font-signature: "Caveat";
--font-noto: "Noto Sans", "Noto Sans Korean", "Noto Sans Japanese", "Noto Sans Chinese";
}
}
@@ -1,9 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { authClient } from '@documenso/auth/client';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { trpc } from '@documenso/trpc/react';
@@ -21,6 +15,10 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type AccountDeleteDialogProps = {
className?: string;
@@ -36,8 +34,7 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
const [enteredEmail, setEnteredEmail] = useState<string>('');
const { mutateAsync: deleteAccount, isPending: isDeletingAccount } =
trpc.profile.deleteAccount.useMutation();
const { mutateAsync: deleteAccount, isPending: isDeletingAccount } = trpc.profile.deleteAccount.useMutation();
const onDeleteAccount = async () => {
try {
@@ -63,18 +60,15 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
<div>
<AlertTitle>
<Trans>Delete Account</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Delete your account and all its contents, including completed documents. This action
is irreversible and will cancel your subscription, so proceed with caution.
Delete your account and all its contents, including completed documents. This action is irreversible and
will cancel your subscription, so proceed with caution.
</Trans>
</AlertDescription>
</div>
@@ -109,10 +103,8 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
<DialogDescription>
<Trans>
Documenso will delete{' '}
<span className="font-semibold">all of your documents</span>, along with all of
your completed documents, signatures, and all other resources belonging to your
Account.
Documenso will delete <span className="font-semibold">all of your documents</span>, along with all
of your completed documents, signatures, and all other resources belonging to your Account.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -121,9 +113,7 @@ export const AccountDeleteDialog = ({ className }: AccountDeleteDialogProps) =>
<div>
<Label>
<Trans>
Please type{' '}
<span className="text-muted-foreground font-semibold">{user.email}</span> to
confirm.
Please type <span className="font-semibold text-muted-foreground">{user.email}</span> to confirm.
</Trans>
</Label>
@@ -1,10 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -19,6 +12,11 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useNavigate } from 'react-router';
export type AdminDocumentDeleteDialogProps = {
envelopeId: string;
@@ -32,8 +30,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
const [reason, setReason] = useState('');
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } =
trpc.admin.document.delete.useMutation();
const { mutateAsync: deleteDocument, isPending: isDeletingDocument } = trpc.admin.document.delete.useMutation();
const handleDeleteDocument = async () => {
try {
@@ -64,18 +61,13 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
return (
<div>
<div>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
<div>
<AlertTitle>
<Trans>Delete Document</Trans>
</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Delete the document. This action is irreversible so proceed with caution.
</Trans>
<Trans>Delete the document. This action is irreversible so proceed with caution.</Trans>
</AlertDescription>
</div>
@@ -105,12 +97,7 @@ export const AdminDocumentDeleteDialog = ({ envelopeId }: AdminDocumentDeleteDia
<Trans>To confirm, please enter the reason</Trans>
</DialogDescription>
<Input
className="mt-2"
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
/>
<Input className="mt-2" type="text" value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
<DialogFooter>
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateAdminOrganisationRequestSchema } from '@documenso/trpc/server/admin-router/create-admin-organisation.types';
@@ -22,16 +12,16 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import type { z } from 'zod';
export type OrganisationCreateDialogProps = {
trigger?: React.ReactNode;
@@ -44,11 +34,7 @@ const ZCreateAdminOrganisationFormSchema = ZCreateAdminOrganisationRequestSchema
type TCreateOrganisationFormSchema = z.infer<typeof ZCreateAdminOrganisationFormSchema>;
export const AdminOrganisationCreateDialog = ({
trigger,
ownerUserId,
...props
}: OrganisationCreateDialogProps) => {
export const AdminOrganisationCreateDialog = ({ trigger, ownerUserId, ...props }: OrganisationCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -101,11 +87,7 @@ export const AdminOrganisationCreateDialog = ({
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -127,10 +109,7 @@ export const AdminOrganisationCreateDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -149,10 +128,7 @@ export const AdminOrganisationCreateDialog = ({
<Alert variant="neutral">
<AlertDescription className="mt-0">
<Trans>
You will need to configure any claims or subscription after creating this
organisation
</Trans>
<Trans>You will need to configure any claims or subscription after creating this organisation</Trans>
</AlertDescription>
</Alert>
@@ -0,0 +1,133 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useNavigate } from 'react-router';
export type AdminOrganisationMemberDeleteDialogProps = {
organisationId: string;
organisationName: string;
organisationMemberId: string;
organisationMemberName: string;
organisationMemberEmail: string;
};
export const AdminOrganisationMemberDeleteDialog = ({
organisationId,
organisationName,
organisationMemberId,
organisationMemberName,
organisationMemberEmail,
}: AdminOrganisationMemberDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { mutateAsync: deleteOrganisationMember, isPending } = trpc.admin.organisationMember.delete.useMutation({
onSuccess: async () => {
toast({
title: _(msg`Success`),
description: _(msg`Member has been removed from the organisation.`),
duration: 5000,
});
setOpen(false);
// Refresh the page to show updated data
await navigate(0);
},
onError: (err) => {
const error = AppError.parseError(err);
console.error(error);
toast({
title: _(msg`An error occurred`),
description: _(msg`We couldn't remove this member. Please try again later.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Remove</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Remove Organisation Member</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
<Trans>
You are about to remove the following user from the organisation{' '}
<span className="font-semibold">{organisationName}</span>:
</Trans>
</DialogDescription>
<Alert className="mt-4" variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={organisationMemberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{organisationMemberName}</span>}
secondaryText={organisationMemberEmail}
/>
</Alert>
</div>
<fieldset disabled={isPending}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () =>
deleteOrganisationMember({
organisationId,
organisationMemberId,
})
}
>
<Trans>Remove member</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -1,15 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { getHighestOrganisationRoleInGroup } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import type { TGetAdminOrganisationResponse } from '@documenso/trpc/server/admin-router/get-admin-organisation.types';
@@ -23,22 +11,18 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
export type AdminOrganisationMemberUpdateDialogProps = {
trigger?: React.ReactNode;
@@ -69,9 +53,7 @@ export const AdminOrganisationMemberUpdateDialog = ({
// Determine the current role value for the form
const currentRoleValue = isOwner
? 'OWNER'
: getHighestOrganisationRoleInGroup(
organisationMember.organisationGroupMembers.map((ogm) => ogm.group),
);
: getHighestOrganisationRoleInGroup(organisationMember.organisationGroupMembers.map((ogm) => ogm.group));
const organisationMemberName = organisationMember.user.name ?? organisationMember.user.email;
const form = useForm<ZUpdateOrganisationMemberSchema>({
@@ -81,8 +63,7 @@ export const AdminOrganisationMemberUpdateDialog = ({
},
});
const { mutateAsync: updateOrganisationMemberRole } =
trpc.admin.organisationMember.updateRole.useMutation();
const { mutateAsync: updateOrganisationMemberRole } = trpc.admin.organisationMember.updateRole.useMutation();
const onFormSubmit = async ({ role }: ZUpdateOrganisationMemberSchema) => {
try {
@@ -135,11 +116,7 @@ export const AdminOrganisationMemberUpdateDialog = ({
}, [open, currentRoleValue, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -156,8 +133,7 @@ export const AdminOrganisationMemberUpdateDialog = ({
<DialogDescription className="mt-4">
<Trans>
You are currently updating <span className="font-bold">{organisationMemberName}</span>
.
You are currently updating <span className="font-bold">{organisationMemberName}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -0,0 +1,181 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react';
export type AdminSwapSubscriptionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
sourceOrganisationId: string;
sourceOrganisationName: string;
userId: number;
};
export const AdminSwapSubscriptionDialog = ({
open,
onOpenChange,
sourceOrganisationId,
sourceOrganisationName,
userId,
}: AdminSwapSubscriptionDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [selectedOrgId, setSelectedOrgId] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { data: orgsData } = trpc.admin.organisation.find.useQuery(
{
ownerUserId: userId,
perPage: 100,
},
{
enabled: open,
},
);
const trpcUtils = trpc.useUtils();
const eligibleOrgs = useMemo(() => {
if (!orgsData?.data) {
return [];
}
return orgsData.data.filter((org) => {
if (org.id === sourceOrganisationId) {
return false;
}
const hasActiveSubscription =
org.subscription && (org.subscription.status === 'ACTIVE' || org.subscription.status === 'PAST_DUE');
return !hasActiveSubscription;
});
}, [orgsData, sourceOrganisationId]);
const selectedOrg = eligibleOrgs.find((org) => org.id === selectedOrgId);
const { mutateAsync: swapSubscription } = trpc.admin.organisation.swapSubscription.useMutation();
const onSubmit = async () => {
if (!selectedOrgId) {
return;
}
setIsSubmitting(true);
try {
await swapSubscription({
sourceOrganisationId,
targetOrganisationId: selectedOrgId,
});
await trpcUtils.admin.organisation.find.invalidate();
await trpcUtils.admin.organisation.get.invalidate();
onOpenChange(false);
toast({
title: t`Success`,
description: t`Subscription moved successfully`,
duration: 5000,
});
} catch (err) {
const error = AppError.parseError(err);
console.error(error);
toast({
title: t`Error`,
description: t`Failed to move subscription. Please try again.`,
variant: 'destructive',
});
} finally {
setIsSubmitting(false);
}
};
useEffect(() => {
if (!open) {
setSelectedOrgId('');
}
}, [open]);
return (
<Dialog open={open} onOpenChange={(value) => !isSubmitting && onOpenChange(value)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>
<Trans>Move Subscription</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Move the subscription from "{sourceOrganisationName}" to another organisation owned by this user.
</Trans>
</DialogDescription>
</DialogHeader>
<fieldset className="flex flex-col space-y-4" disabled={isSubmitting}>
<div className="flex flex-col gap-2">
<label className="font-medium text-sm">
<Trans>Target Organisation</Trans>
</label>
<Select value={selectedOrgId} onValueChange={setSelectedOrgId}>
<SelectTrigger>
<SelectValue placeholder={t`Select an organisation`} />
</SelectTrigger>
<SelectContent>
{eligibleOrgs.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name} ({org.url})
</SelectItem>
))}
</SelectContent>
</Select>
{eligibleOrgs.length === 0 && orgsData && (
<p className="text-muted-foreground text-sm">
<Trans>No eligible organisations found. The target must be on the free plan.</Trans>
</p>
)}
</div>
{selectedOrg && (
<Alert variant="warning">
<AlertDescription className="mt-0">
<Trans>
This will move the subscription from "{sourceOrganisationName}" to "{selectedOrg.name}". The source
organisation will be reset to the free plan.
</Trans>
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button type="button" onClick={onSubmit} disabled={!selectedOrgId} loading={isSubmitting}>
<Trans>Move Subscription</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,128 @@
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useNavigate } from 'react-router';
export type AdminTeamMemberDeleteDialogProps = {
teamId: number;
teamName: string;
memberId: string;
memberName: string;
memberEmail: string;
};
export const AdminTeamMemberDeleteDialog = ({
teamId,
teamName,
memberId,
memberName,
memberEmail,
}: AdminTeamMemberDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const { mutateAsync: deleteTeamMember, isPending } = trpc.admin.teamMember.delete.useMutation({
onSuccess: async () => {
toast({
title: _(msg`Success`),
description: _(msg`Member has been removed from the team.`),
duration: 5000,
});
setOpen(false);
// Refresh the page to show updated data
await navigate(0);
},
onError: (err) => {
const error = AppError.parseError(err);
console.error(error);
toast({
title: _(msg`An error occurred`),
description: _(msg`We couldn't remove this member. Please try again later.`),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Remove</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Remove Team Member</Trans>
</DialogTitle>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>This action is not reversible. Please be certain.</Trans>
</AlertDescription>
</Alert>
</DialogHeader>
<div>
<DialogDescription>
<Trans>
You are about to remove the following user from the team <span className="font-semibold">{teamName}</span>
:
</Trans>
</DialogDescription>
<Alert className="mt-4" variant="neutral" padding="tight">
<AvatarWithText
avatarClass="h-12 w-12"
avatarFallback={memberName.slice(0, 1).toUpperCase()}
primaryText={<span className="font-semibold">{memberName}</span>}
secondaryText={memberEmail}
/>
</Alert>
</div>
<fieldset disabled={isPending}>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
variant="destructive"
loading={isPending}
onClick={async () => deleteTeamMember({ teamId, memberId })}
>
<Trans>Remove member</Trans>
</Button>
</DialogFooter>
</fieldset>
</DialogContent>
</Dialog>
);
};
@@ -1,11 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
@@ -22,6 +14,12 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
export type AdminUserDeleteDialogProps = {
className?: string;
@@ -34,8 +32,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
const navigate = useNavigate();
const [email, setEmail] = useState('');
const { mutateAsync: deleteUser, isPending: isDeletingUser } =
trpc.admin.user.delete.useMutation();
const { mutateAsync: deleteUser, isPending: isDeletingUser } = trpc.admin.user.delete.useMutation();
const onDeleteAccount = async () => {
try {
@@ -69,16 +66,13 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
<div>
<AlertTitle>Delete Account</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Delete the users account and all its contents. This action is irreversible and will
cancel their subscription, so proceed with caution.
Delete the users account and all its contents. This action is irreversible and will cancel their
subscription, so proceed with caution.
</Trans>
</AlertDescription>
</div>
@@ -111,12 +105,7 @@ export const AdminUserDeleteDialog = ({ className, user }: AdminUserDeleteDialog
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input className="mt-2" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<DialogFooter>
@@ -1,10 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
@@ -21,23 +14,24 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { match } from 'ts-pattern';
export type AdminUserDisableDialogProps = {
className?: string;
userToDisable: TGetUserResponse;
};
export const AdminUserDisableDialog = ({
className,
userToDisable,
}: AdminUserDisableDialogProps) => {
export const AdminUserDisableDialog = ({ className, userToDisable }: AdminUserDisableDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [email, setEmail] = useState('');
const { mutateAsync: disableUser, isPending: isDisablingUser } =
trpc.admin.user.disable.useMutation();
const { mutateAsync: disableUser, isPending: isDisablingUser } = trpc.admin.user.disable.useMutation();
const onDisableAccount = async () => {
try {
@@ -69,16 +63,13 @@ export const AdminUserDisableDialog = ({
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
<div>
<AlertTitle>Disable Account</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Disabling the user results in the user not being able to use the account. It also
disables all the related contents such as subscription, webhooks, teams, and API keys.
Disabling the user results in the user not being able to use the account. It also disables all the related
contents such as subscription, webhooks, teams, and API keys.
</Trans>
</AlertDescription>
</div>
@@ -100,9 +91,8 @@ export const AdminUserDisableDialog = ({
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is reversible, but please be careful as the account may be
affected permanently (e.g. their settings and contents not being restored
properly).
This action is reversible, but please be careful as the account may be affected permanently (e.g.
their settings and contents not being restored properly).
</Trans>
</AlertDescription>
</Alert>
@@ -116,12 +106,7 @@ export const AdminUserDisableDialog = ({
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input className="mt-2" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<DialogFooter>
@@ -1,10 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
@@ -21,6 +14,11 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { match } from 'ts-pattern';
export type AdminUserEnableDialogProps = {
className?: string;
@@ -33,8 +31,7 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
const [email, setEmail] = useState('');
const { mutateAsync: enableUser, isPending: isEnablingUser } =
trpc.admin.user.enable.useMutation();
const { mutateAsync: enableUser, isPending: isEnablingUser } = trpc.admin.user.enable.useMutation();
const onEnableAccount = async () => {
try {
@@ -66,16 +63,13 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
<div>
<AlertTitle>Enable Account</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Enabling the account results in the user being able to use the account again, and all
the related features such as webhooks, teams, and API keys for example.
Enabling the account results in the user being able to use the account again, and all the related features
such as webhooks, teams, and API keys for example.
</Trans>
</AlertDescription>
</div>
@@ -103,20 +97,11 @@ export const AdminUserEnableDialog = ({ className, userToEnable }: AdminUserEnab
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input className="mt-2" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<DialogFooter>
<Button
onClick={onEnableAccount}
loading={isEnablingUser}
disabled={email !== userToEnable.email}
>
<Button onClick={onEnableAccount} loading={isEnablingUser} disabled={email !== userToEnable.email}>
<Trans>Enable account</Trans>
</Button>
</DialogFooter>
@@ -1,11 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TGetUserResponse } from '@documenso/trpc/server/admin-router/get-user.types';
@@ -22,24 +14,26 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
export type AdminUserResetTwoFactorDialogProps = {
className?: string;
user: TGetUserResponse;
};
export const AdminUserResetTwoFactorDialog = ({
className,
user,
}: AdminUserResetTwoFactorDialogProps) => {
export const AdminUserResetTwoFactorDialog = ({ className, user }: AdminUserResetTwoFactorDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [email, setEmail] = useState('');
const [open, setOpen] = useState(false);
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
trpc.admin.user.resetTwoFactor.useMutation();
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } = trpc.admin.user.resetTwoFactor.useMutation();
const onResetTwoFactor = async () => {
try {
@@ -64,9 +58,7 @@ export const AdminUserResetTwoFactorDialog = ({
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(
() => msg`An error occurred while resetting two factor authentication for the user.`,
);
.otherwise(() => msg`An error occurred while resetting two factor authentication for the user.`);
toast({
title: _(msg`Error`),
@@ -87,16 +79,13 @@ export const AdminUserResetTwoFactorDialog = ({
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<Alert className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row" variant="neutral">
<div>
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will
disable two factor authentication for the user.
Reset the users two factor authentication. This action is irreversible and will disable two factor
authentication for the user.
</Trans>
</AlertDescription>
</div>
@@ -119,8 +108,7 @@ export const AdminUserResetTwoFactorDialog = ({
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before
proceeding.
This action is irreversible. Please ensure you have informed the user before proceeding.
</Trans>
</AlertDescription>
</Alert>
@@ -132,12 +120,7 @@ export const AdminUserResetTwoFactorDialog = ({
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Input className="mt-2" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<DialogFooter>
@@ -1,20 +1,11 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useState } from 'react';
import { useCurrentTeam } from '~/providers/team';
@@ -24,11 +15,7 @@ type AiFeaturesEnableDialogProps = {
onEnabled: () => void;
};
export const AiFeaturesEnableDialog = ({
open,
onOpenChange,
onEnabled,
}: AiFeaturesEnableDialogProps) => {
export const AiFeaturesEnableDialog = ({ open, onOpenChange, onEnabled }: AiFeaturesEnableDialogProps) => {
const { t } = useLingui();
const team = useCurrentTeam();
@@ -71,11 +58,7 @@ export const AiFeaturesEnableDialog = ({
onOpenChange(false);
} catch (err) {
console.error('Failed to enable AI features', err);
setError(
err instanceof Error
? err.message
: t`We couldn't enable AI features right now. Please try again.`,
);
setError(err instanceof Error ? err.message : t`We couldn't enable AI features right now. Please try again.`);
}
};
@@ -89,39 +72,38 @@ export const AiFeaturesEnableDialog = ({
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>
Turn on AI detection to automatically find recipients and fields in your documents. AI
providers do not retain your data for training.
Turn on AI detection to automatically find recipients and fields in your documents. AI providers do not
retain your data for training.
</Trans>
</p>
<Alert variant="neutral">
<AlertDescription>
<Trans>
Your document content will be sent securely to our AI provider solely for detection
and will not be stored or used for training.
Your document content will be sent securely to our AI provider solely for detection and will not be
stored or used for training.
</Trans>
</AlertDescription>
</Alert>
{canEnableAiFeatures ? (
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>
You're an admin. You can enable AI features for this team right away. Everyone on
the team will see AI detection once enabled.
You're an admin. You can enable AI features for this team right away. Everyone on the team will see AI
detection once enabled.
</Trans>
</p>
) : (
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>
AI features are disabled for your team. Please ask your team owner or organisation
owner to enable them.
AI features are disabled for your team. Please ask your team owner or organisation owner to enable them.
</Trans>
</p>
)}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{error ? <p className="text-destructive text-sm">{error}</p> : null}
</div>
<DialogFooter>
@@ -1,29 +1,17 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { Textarea } from '@documenso/ui/primitives/textarea';
import {
AiApiError,
type DetectFieldsProgressEvent,
detectFields,
} from '../../../server/api/ai/detect-fields.client';
import { AiApiError, type DetectFieldsProgressEvent, detectFields } from '../../../server/api/ai/detect-fields.client';
import { AnimatedDocumentScanner } from '../general/animated-document-scanner';
type DialogState = 'PROMPT' | 'PROCESSING' | 'REVIEW' | 'ERROR' | 'RATE_LIMITED';
@@ -171,20 +159,17 @@ export const AiFieldDetectionDialog = ({
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>
We'll scan your document to find form fields like signature lines, text inputs,
checkboxes, and more. Detected fields will be suggested for you to review.
We'll scan your document to find form fields like signature lines, text inputs, checkboxes, and more.
Detected fields will be suggested for you to review.
</Trans>
</p>
<Alert className="flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>
Your document is processed securely using AI services that don't retain your
data.
</Trans>
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
</AlertDescription>
</Alert>
@@ -200,7 +185,7 @@ export const AiFieldDetectionDialog = ({
rows={2}
className="resize-none"
/>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
<Trans>Help the AI assign fields to the right recipients.</Trans>
</p>
</div>
@@ -231,7 +216,7 @@ export const AiFieldDetectionDialog = ({
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<p className="mt-2 text-muted-foreground/60 text-xs">
<Plural
value={progress.fieldsDetected}
one={
@@ -248,7 +233,7 @@ export const AiFieldDetectionDialog = ({
</p>
)}
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
<p className="mt-2 max-w-[40ch] text-center text-muted-foreground/60 text-xs">
<Trans>This can take a minute or two depending on the size of your document.</Trans>
</p>
@@ -278,16 +263,16 @@ export const AiFieldDetectionDialog = ({
{detectedFields.length === 0 ? (
<div className="flex flex-col items-center py-8">
<FormInputIcon className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-center text-sm text-muted-foreground">
<p className="mt-4 text-center text-muted-foreground text-sm">
<Trans>No fields were detected in your document.</Trans>
</p>
<p className="mt-1 text-center text-xs text-muted-foreground/70">
<p className="mt-1 text-center text-muted-foreground/70 text-xs">
<Trans>You can add fields manually in the editor.</Trans>
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Plural
value={detectedFields.length}
one="We found # field in your document."
@@ -299,7 +284,7 @@ export const AiFieldDetectionDialog = ({
{fieldCountsByType.map(([type, count]) => (
<li key={type} className="flex items-center justify-between px-4 py-3">
<span className="text-sm">{_(FIELD_TYPE_LABELS[type]) || type}</span>
<span className="text-sm font-medium text-muted-foreground">{count}</span>
<span className="font-medium text-muted-foreground text-sm">{count}</span>
</li>
))}
</ul>
@@ -314,7 +299,7 @@ export const AiFieldDetectionDialog = ({
{detectedFields.length > 0 && (
<Button type="button" onClick={onAddFields}>
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
<CheckIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Add fields</Trans>
</Button>
)}
@@ -331,11 +316,11 @@ export const AiFieldDetectionDialog = ({
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>Something went wrong while detecting fields.</Trans>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
{error && <p className="mt-2 text-destructive text-sm">{error}</p>}
</div>
<DialogFooter>
@@ -358,10 +343,8 @@ export const AiFieldDetectionDialog = ({
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>
You've made too many detection requests. Please wait a minute before trying again.
</Trans>
<p className="text-muted-foreground text-sm">
<Trans>You've made too many detection requests. Please wait a minute before trying again.</Trans>
</p>
</div>
@@ -1,22 +1,14 @@
import { useCallback, useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import type { TDetectedRecipientSchema } from '@documenso/lib/server-only/ai/envelope/detect-recipients/schema';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { CheckIcon, ShieldCheckIcon, UserIcon, XIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
AiApiError,
@@ -146,20 +138,17 @@ export const AiRecipientDetectionDialog = ({
</DialogHeader>
<div>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>
We'll scan your document to find signature fields and identify who needs to sign.
Detected recipients will be suggested for you to review.
We'll scan your document to find signature fields and identify who needs to sign. Detected recipients
will be suggested for you to review.
</Trans>
</p>
<Alert className="mt-4 flex items-center gap-2 space-y-0" variant="neutral">
<ShieldCheckIcon className="h-5 w-5 stroke-green-600" />
<AlertDescription className="mt-0">
<Trans>
Your document is processed securely using AI services that don't retain your
data.
</Trans>
<Trans>Your document is processed securely using AI services that don't retain your data.</Trans>
</AlertDescription>
</Alert>
</div>
@@ -189,7 +178,7 @@ export const AiRecipientDetectionDialog = ({
<p className="mt-8 text-muted-foreground">{_(PROCESSING_MESSAGES[messageIndex])}</p>
{progress && (
<p className="mt-2 text-xs text-muted-foreground/60">
<p className="mt-2 text-muted-foreground/60 text-xs">
<Plural
value={progress.recipientsDetected}
one={
@@ -206,7 +195,7 @@ export const AiRecipientDetectionDialog = ({
</p>
)}
<p className="mt-2 max-w-[40ch] text-center text-xs text-muted-foreground/60">
<p className="mt-2 max-w-[40ch] text-center text-muted-foreground/60 text-xs">
<Trans>This can take a minute or two depending on the size of your document.</Trans>
</p>
@@ -236,16 +225,16 @@ export const AiRecipientDetectionDialog = ({
{detectedRecipients.length === 0 ? (
<div className="flex flex-col items-center py-8">
<UserIcon className="h-12 w-12 text-muted-foreground/50" />
<p className="mt-4 text-center text-sm text-muted-foreground">
<p className="mt-4 text-center text-muted-foreground text-sm">
<Trans>No recipients were detected in your document.</Trans>
</p>
<p className="mt-1 text-center text-xs text-muted-foreground/70">
<p className="mt-1 text-center text-muted-foreground/70 text-xs">
<Trans>You can add recipients manually in the editor.</Trans>
</p>
</div>
) : (
<>
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Plural
value={detectedRecipients.length}
one="We found # recipient in your document."
@@ -265,13 +254,13 @@ export const AiRecipientDetectionDialog = ({
: '?'
}
primaryText={
<p className="text-sm font-medium text-foreground">
<p className="font-medium text-foreground text-sm">
{recipient.name || _(msg`Unknown name`)}
</p>
}
secondaryText={
<div className="text-xs text-muted-foreground">
<p className="italic text-muted-foreground/70">
<div className="text-muted-foreground text-xs">
<p className="text-muted-foreground/70 italic">
{recipient.email || _(msg`No email detected`)}
</p>
<p>{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}</p>
@@ -304,7 +293,7 @@ export const AiRecipientDetectionDialog = ({
{detectedRecipients.length > 0 && (
<Button type="button" onClick={onAddRecipients}>
<CheckIcon className="-ml-1 mr-2 h-4 w-4" />
<CheckIcon className="mr-2 -ml-1 h-4 w-4" />
<Trans>Add recipients</Trans>
</Button>
)}
@@ -321,11 +310,11 @@ export const AiRecipientDetectionDialog = ({
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>Something went wrong while detecting recipients.</Trans>
</p>
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
{error && <p className="mt-2 text-destructive text-sm">{error}</p>}
</div>
<DialogFooter>
@@ -349,10 +338,8 @@ export const AiRecipientDetectionDialog = ({
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
<Trans>
You've made too many detection requests. Please wait a minute before trying again.
</Trans>
<p className="text-muted-foreground text-sm">
<Trans>You've made too many detection requests. Please wait a minute before trying again.</Trans>
</p>
</div>
@@ -1,10 +1,4 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -14,15 +8,13 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
@@ -43,7 +35,7 @@ type ConfirmationDialogProps = {
const ZNextSignerFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
});
type TNextSignerFormSchema = z.infer<typeof ZNextSignerFormSchema>;
@@ -103,9 +95,8 @@ export function AssistantConfirmationDialog({
</DialogTitle>
<DialogDescription>
<Trans>
Are you sure you want to complete the document? This action cannot be undone.
Please ensure that you have completed prefilling all relevant fields before
proceeding.
Are you sure you want to complete the document? This action cannot be undone. Please ensure that you
have completed prefilling all relevant fields before proceeding.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -116,9 +107,11 @@ export function AssistantConfirmationDialog({
{!isEditingNextSigner && (
<div>
<p className="text-muted-foreground text-sm">
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
<Trans>
The next recipient to sign this document will be{' '}
<span className="font-semibold">{form.watch('name')}</span> (
<span className="font-semibold">{form.watch('email')}</span>).
</Trans>
</p>
<Button
@@ -144,11 +137,7 @@ export function AssistantConfirmationDialog({
<Trans>Name</Trans>
</FormLabel>
<FormControl>
<Input
{...field}
className="mt-2"
placeholder={t`Enter the next signer's name`}
/>
<Input {...field} className="mt-2" placeholder={t`Enter the next signer's name`} />
</FormControl>
<FormMessage />
@@ -1,8 +1,3 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { z } from 'zod';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { generateDefaultSubscriptionClaim } from '@documenso/lib/utils/organisations-claims';
import { trpc } from '@documenso/trpc/react';
@@ -18,6 +13,9 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import type { z } from 'zod';
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
@@ -57,7 +55,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Create Subscription Claim</Trans>
@@ -75,12 +73,7 @@ export const ClaimCreateDialog = ({ licenseFlags }: ClaimCreateDialogProps) => {
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
@@ -1,7 +1,3 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -15,6 +11,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
export type ClaimDeleteDialogProps = {
claimId: string;
@@ -23,12 +21,7 @@ export type ClaimDeleteDialogProps = {
trigger: React.ReactNode;
};
export const ClaimDeleteDialog = ({
claimId,
claimName,
claimLocked,
trigger,
}: ClaimDeleteDialogProps) => {
export const ClaimDeleteDialog = ({ claimId, claimName, claimLocked, trigger }: ClaimDeleteDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
@@ -1,7 +1,3 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TLicenseClaim } from '@documenso/lib/types/license';
import { trpc } from '@documenso/trpc/react';
import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types';
@@ -16,6 +12,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { SubscriptionClaimForm } from '../forms/subscription-claim-form';
@@ -53,7 +51,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
{trigger}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogContent className="scrollbar-hidden max-h-[90vh] overflow-y-auto sm:max-w-md">
<DialogHeader>
<DialogTitle>
<Trans>Update Subscription Claim</Trans>
@@ -74,12 +72,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD
licenseFlags={licenseFlags}
formSubmitTrigger={
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
@@ -1,133 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useNavigate } from 'react-router';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
type DocumentDuplicateDialogProps = {
id: string;
token?: string;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const DocumentDuplicateDialog = ({
id,
token,
open,
onOpenChange,
}: DocumentDuplicateDialogProps) => {
const navigate = useNavigate();
const { toast } = useToast();
const { _ } = useLingui();
const team = useCurrentTeam();
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpcReact.envelope.item.getManyByToken.useQuery(
{
envelopeId: id,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const documentsPath = formatDocumentsPath(team.url);
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpcReact.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: _(msg`Document Duplicated`),
description: _(msg`Your document has been successfully duplicated.`),
duration: 5000,
});
await navigate(`${documentsPath}/${id}/edit`);
onOpenChange(false);
},
});
const onDuplicate = async () => {
try {
await duplicateEnvelope({ envelopeId: id });
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be duplicated at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isDuplicating && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Duplicate</Trans>
</DialogTitle>
</DialogHeader>
{isLoadingEnvelopeItems || !envelopeItems || envelopeItems.length === 0 ? (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">
<h1 className="mt-4 grow-0 truncate text-2xl font-semibold md:text-3xl">
<Trans>Loading Document...</Trans>
</h1>
</div>
) : (
<div className="p-2 [&>div]:h-[50vh] [&>div]:overflow-y-scroll">
<PDFViewerLazy
key={envelopeItems[0].id}
envelopeItem={envelopeItems[0]}
token={undefined}
version="original"
/>
</div>
)}
<DialogFooter>
<div className="flex w-full flex-1 flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
className="flex-1"
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={isDuplicating}
loading={isDuplicating}
onClick={onDuplicate}
className="flex-1"
>
<Trans>Duplicate</Trans>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,15 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
@@ -23,16 +11,19 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -165,7 +156,7 @@ export const DocumentMoveToFolderDialog = ({
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
@@ -219,7 +210,7 @@ export const DocumentMoveToFolderDialog = ({
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
@@ -239,9 +230,7 @@ export const DocumentMoveToFolderDialog = ({
<Button
type="submit"
disabled={
isFoldersLoading || form.formState.isSubmitting || currentFolderId === null
}
disabled={isFoldersLoading || form.formState.isSubmitting || currentFolderId === null}
>
<Trans>Move</Trans>
</Button>
@@ -1,16 +1,6 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient, SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import type { Document } from '@documenso/prisma/types/document-legacy-schema';
import { trpc as trpcReact } from '@documenso/trpc/react';
@@ -27,14 +17,17 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { DropdownMenuItem } from '@documenso/ui/primitives/dropdown-menu';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { SigningStatus, type Team, type User } from '@prisma/client';
import { History } from 'lucide-react';
import { useState } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import * as z from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -45,10 +38,10 @@ const FORM_ID = 'resend-email';
export type DocumentResendDialogProps = {
document: Pick<Document, 'id' | 'userId' | 'teamId' | 'status'> & {
user: Pick<User, 'id' | 'name' | 'email'>;
recipients: Recipient[];
recipients: TRecipientLite[];
team: Pick<Team, 'id' | 'url'> | null;
};
recipients: Recipient[];
recipients: TRecipientLite[];
};
export const ZResendDocumentFormSchema = z.object({
@@ -141,10 +134,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
render={({ field: { value, onChange } }) => (
<>
{recipients.map((recipient) => (
<FormItem
key={recipient.id}
className="flex flex-row items-center justify-between gap-x-3"
>
<FormItem key={recipient.id} className="flex flex-row items-center justify-between gap-x-3">
<FormLabel
className={cn('my-2 flex items-center gap-2 font-normal', {
'opacity-50': !value.includes(recipient.id),
@@ -183,7 +173,7 @@ export const DocumentResendDialog = ({ document, recipients }: DocumentResendDia
<DialogClose asChild>
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 flex-1 bg-black/5 hover:bg-black/10"
className="flex-1 bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
variant="secondary"
disabled={isSubmitting}
>
@@ -1,72 +1,84 @@
import { useEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus } from '@prisma/client';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType } from '@prisma/client';
import { useEffect, useState } from 'react';
import { match, P } from 'ts-pattern';
type DocumentDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
type EnvelopeDeleteDialogProps = {
id: string;
type: EnvelopeType;
trigger?: React.ReactNode;
onDelete?: () => Promise<void> | void;
status: DocumentStatus;
documentTitle: string;
title: string;
canManageDocument: boolean;
};
export const DocumentDeleteDialog = ({
export const EnvelopeDeleteDialog = ({
id,
open,
onOpenChange,
type,
trigger,
onDelete,
status,
documentTitle,
title,
canManageDocument,
}: DocumentDeleteDialogProps) => {
}: EnvelopeDeleteDialogProps) => {
const { toast } = useToast();
const { refreshLimits } = useLimits();
const { _ } = useLingui();
const { t } = useLingui();
const deleteMessage = msg`delete`;
const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [isDeleteEnabled, setIsDeleteEnabled] = useState(status === DocumentStatus.DRAFT);
const { mutateAsync: deleteDocument, isPending } = trpcReact.document.delete.useMutation({
const isDocument = type === EnvelopeType.DOCUMENT;
const { mutateAsync: deleteEnvelope, isPending } = trpcReact.envelope.delete.useMutation({
onSuccess: async () => {
void refreshLimits();
toast({
title: _(msg`Document deleted`),
description: _(msg`"${documentTitle}" has been successfully deleted`),
title: canManageDocument
? isDocument
? t`Document deleted`
: t`Template deleted`
: isDocument
? t`Document hidden`
: t`Template hidden`,
description: canManageDocument
? t`"${title}" has been successfully deleted`
: t`"${title}" has been successfully hidden`,
duration: 5000,
});
await onDelete?.();
onOpenChange(false);
setOpen(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This document could not be deleted at this time. Please try again.`),
title: t`Something went wrong`,
description: isDocument
? t`This document could not be deleted at this time. Please try again.`
: t`This template could not be deleted at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
@@ -82,11 +94,13 @@ export const DocumentDeleteDialog = ({
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
setIsDeleteEnabled(event.target.value === t(deleteMessage));
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<Dialog open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -96,11 +110,11 @@ export const DocumentDeleteDialog = ({
<DialogDescription>
{canManageDocument ? (
<Trans>
You are about to delete <strong>"{documentTitle}"</strong>
You are about to delete <strong>"{title}"</strong>
</Trans>
) : (
<Trans>
You are about to hide <strong>"{documentTitle}"</strong>
You are about to hide <strong>"{title}"</strong>
</Trans>
)}
</DialogDescription>
@@ -111,10 +125,17 @@ export const DocumentDeleteDialog = ({
{match(status)
.with(DocumentStatus.DRAFT, () => (
<AlertDescription>
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed,
this document will be permanently deleted.
</Trans>
{type === EnvelopeType.DOCUMENT ? (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed, this document will
be permanently deleted.
</Trans>
) : (
<Trans>
Please note that this action is <strong>irreversible</strong>. Once confirmed, this template will
be permanently deleted.
</Trans>
)}
</AlertDescription>
))
.with(DocumentStatus.PENDING, () => (
@@ -176,23 +197,25 @@ export const DocumentDeleteDialog = ({
type="text"
value={inputValue}
onChange={onInputChange}
placeholder={_(msg`Please type ${`'${_(deleteMessage)}'`} to confirm`)}
placeholder={t`Please type ${`'${t(deleteMessage)}'`} to confirm`}
/>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
loading={isPending}
onClick={() => void deleteDocument({ documentId: id })}
onClick={() => void deleteEnvelope({ envelopeId: id })}
disabled={!isDeleteEnabled && canManageDocument}
variant="destructive"
>
{canManageDocument ? _(msg`Delete`) : _(msg`Hide`)}
{canManageDocument ? <Trans>Delete</Trans> : <Trans>Hide</Trans>}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,20 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
import { getRecipientsWithMissingFields } from '@documenso/lib/utils/recipients';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc, trpc as trpcReact } from '@documenso/trpc/react';
import { DocumentSendEmailMessageHelper } from '@documenso/ui/components/document/document-send-email-message-helper';
import { cn } from '@documenso/ui/lib/utils';
@@ -30,27 +19,24 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentStatus, EnvelopeType } from '@prisma/client';
import { AnimatePresence, motion } from 'framer-motion';
import { InfoIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
export type EnvelopeDistributeDialogProps = {
onDistribute?: () => Promise<void>;
@@ -61,16 +47,10 @@ export type EnvelopeDistributeDialogProps = {
export const ZEnvelopeDistributeFormSchema = z.object({
meta: z.object({
emailId: z.string().nullable(),
emailReplyTo: z.preprocess(
(val) => (val === '' ? undefined : val),
z.string().email().optional(),
),
emailReplyTo: z.preprocess((val) => (val === '' ? undefined : val), zEmail().optional()),
subject: z.string(),
message: z.string(),
distributionMethod: z
.nativeEnum(DocumentDistributionMethod)
.optional()
.default(DocumentDistributionMethod.EMAIL),
distributionMethod: z.nativeEnum(DocumentDistributionMethod).optional().default(DocumentDistributionMethod.EMAIL),
}),
});
@@ -101,8 +81,7 @@ export const EnvelopeDistributeDialog = ({
emailReplyTo: envelope.documentMeta?.emailReplyTo || undefined,
subject: envelope.documentMeta?.subject ?? '',
message: envelope.documentMeta?.message ?? '',
distributionMethod:
envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
distributionMethod: envelope.documentMeta?.distributionMethod || DocumentDistributionMethod.EMAIL,
},
},
resolver: zodResolver(ZEnvelopeDistributeFormSchema),
@@ -115,11 +94,15 @@ export const EnvelopeDistributeDialog = ({
formState: { isSubmitting },
} = form;
const { data: emailData, isLoading: isLoadingEmails } =
trpc.enterprise.organisation.email.find.useQuery({
const { data: emailData, isLoading: isLoadingEmails } = trpc.enterprise.organisation.email.find.useQuery(
{
organisationId: organisation.id,
perPage: 100,
});
},
{
...DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
},
);
const emails = emailData?.data || [];
@@ -149,9 +132,7 @@ export const EnvelopeDistributeDialog = ({
recipientAuth: recipient.authOptions,
});
return (
(auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email
);
return (auth.recipientAccessAuthRequired || auth.recipientActionAuthRequired) && !recipient.email;
});
}, [recipientsWithIndex, envelope.authOptions]);
@@ -258,10 +239,10 @@ export const EnvelopeDistributeDialog = ({
>
<TabsList className="w-full">
<TabsTrigger className="w-full" value={DocumentDistributionMethod.EMAIL}>
Email
<Trans>Email</Trans>
</TabsTrigger>
<TabsTrigger className="w-full" value={DocumentDistributionMethod.NONE}>
None
<Trans>None</Trans>
</TabsTrigger>
</TabsList>
</Tabs>
@@ -306,14 +287,9 @@ export const EnvelopeDistributeDialog = ({
<Select
{...field}
value={field.value === null ? '-1' : field.value}
onValueChange={(value) =>
field.onChange(value === '-1' ? null : value)
}
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
>
<SelectTrigger
loading={isLoadingEmails}
className="bg-background"
>
<SelectTrigger loading={isLoadingEmails} className="bg-background">
<SelectValue />
</SelectTrigger>
@@ -342,8 +318,7 @@ export const EnvelopeDistributeDialog = ({
<FormItem>
<FormLabel>
<Trans>
Reply To Email{' '}
<span className="text-muted-foreground">(Optional)</span>
Reply To Email <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
@@ -363,8 +338,7 @@ export const EnvelopeDistributeDialog = ({
<FormItem>
<FormLabel>
<Trans>
Subject{' '}
<span className="text-muted-foreground">(Optional)</span>
Subject <span className="text-muted-foreground">(Optional)</span>
</Trans>
</FormLabel>
@@ -383,8 +357,7 @@ export const EnvelopeDistributeDialog = ({
<FormItem>
<FormLabel className="flex flex-row items-center">
<Trans>
Message{' '}
<span className="text-muted-foreground">(Optional)</span>
Message <span className="text-muted-foreground">(Optional)</span>
</Trans>
<Tooltip>
<TooltipTrigger type="button">
@@ -418,15 +391,15 @@ export const EnvelopeDistributeDialog = ({
exit={{ opacity: 0, transition: { duration: 0.15 } }}
className="min-h-60 rounded-lg border"
>
<div className="py-24 text-center text-sm text-muted-foreground">
<div className="py-24 text-center text-muted-foreground text-sm">
<p>
<Trans>We won't send anything to notify recipients.</Trans>
</p>
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send to the
recipients through your method of choice.
We will generate signing links for you, which you can send to the recipients through your
method of choice.
</Trans>
</p>
</div>
@@ -466,7 +439,7 @@ export const EnvelopeDistributeDialog = ({
<AlertDescription>
<Trans>The following signers are missing signature fields:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
<ul className="mt-1 ml-2 list-inside list-disc">
{recipientsMissingSignatureFields.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
@@ -479,7 +452,7 @@ export const EnvelopeDistributeDialog = ({
<AlertDescription>
<Trans>The following recipients require an email address:</Trans>
<ul className="ml-2 mt-1 list-inside list-disc">
<ul className="mt-1 ml-2 list-inside list-disc">
{recipientsMissingRequiredEmail.map((recipient) => (
<li key={recipient.id}>
{recipient.email || recipient.name || t`Recipient ${recipient.index + 1}`}
@@ -1,10 +1,3 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon, FileTextIcon } from 'lucide-react';
import { downloadPDF } from '@documenso/lib/client-only/download-pdf';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,12 +11,29 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, type EnvelopeItem } from '@prisma/client';
import { DownloadIcon, FileTextIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
type EnvelopeItemToDownload = Pick<EnvelopeItem, 'id' | 'envelopeId' | 'title' | 'order'>;
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 +48,7 @@ type EnvelopeDownloadDialogProps = {
export const EnvelopeDownloadDialog = ({
envelopeId,
envelopeStatus,
isLegacy,
envelopeItems: initialEnvelopeItems,
token,
trigger,
@@ -51,27 +62,49 @@ export const EnvelopeDownloadDialog = ({
[envelopeItemIdAndVersion: string]: boolean;
}>({});
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed') =>
const generateDownloadKey = (envelopeItemId: string, version: 'original' | 'signed' | 'pending') =>
`${envelopeItemId}-${version}`;
const { data: envelopeItemsPayload, isLoading: isLoadingEnvelopeItems } =
trpc.envelope.item.getManyByToken.useQuery(
{
envelopeId,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
enabled: open,
},
);
// 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(
{
envelopeId,
access: token ? { type: 'recipient', token } : { type: 'user' },
},
{
initialData: initialEnvelopeItems ? { data: initialEnvelopeItems } : undefined,
enabled: open,
},
);
const envelopeItems = envelopeItemsPayload?.data || [];
const onDownload = async (
envelopeItem: EnvelopeItemToDownload,
version: 'original' | 'signed',
) => {
const onDownload = async (envelopeItem: EnvelopeItemToDownload, version: 'original' | 'signed' | 'pending') => {
const { id: envelopeItemId } = envelopeItem;
if (isDownloadingState[generateDownloadKey(envelopeItemId, version)]) {
@@ -127,13 +160,9 @@ export const EnvelopeDownloadDialog = ({
</DialogHeader>
<div className="flex w-full flex-col gap-4 overflow-hidden">
{isLoadingEnvelopeItems ? (
<>
{Array.from({ length: 1 }).map((_, index) => (
<div
key={index}
className="border-border bg-card flex items-center gap-2 rounded-lg border p-4"
>
{isLoadingEnvelopeItems
? Array.from({ length: 1 }).map((_, index) => (
<div key={index} 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" />
<div className="flex w-full flex-col gap-2">
@@ -143,62 +172,59 @@ export const EnvelopeDownloadDialog = ({
<Skeleton className="h-10 w-20 flex-shrink-0 rounded-lg" />
</div>
))}
</>
) : (
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"
>
<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" />
))
: envelopeItems.map((item) => (
<div
key={item.id}
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="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>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="text-foreground truncate text-sm font-medium" title={item.title}>
{item.title}
</h4>
<p className="text-muted-foreground mt-0.5 text-xs">
<Trans>PDF Document</Trans>
</p>
</div>
<div className="min-w-0 flex-1">
{/* Todo: Envelopes - Fix overflow */}
<h4 className="truncate font-medium text-foreground text-sm" title={item.title}>
{item.title}
</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
<Trans>PDF Document</Trans>
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, 'original')}
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
>
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans context="Original document (adjective)">Original</Trans>
</Button>
{envelopeStatus === DocumentStatus.COMPLETED && (
<div className="flex flex-shrink-0 items-center gap-2">
<Button
variant="default"
variant="outline"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, 'signed')}
loading={isDownloadingState[generateDownloadKey(item.id, 'signed')]}
onClick={async () => onDownload(item, 'original')}
loading={isDownloadingState[generateDownloadKey(item.id, 'original')]}
>
{!isDownloadingState[generateDownloadKey(item.id, 'signed')] && (
{!isDownloadingState[generateDownloadKey(item.id, 'original')] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
<Trans context="Signed document (adjective)">Signed</Trans>
<Trans context="Original document (adjective)">Original</Trans>
</Button>
)}
{secondaryDownload && (
<Button
variant="default"
size="sm"
className="text-xs"
onClick={async () => onDownload(item, secondaryDownload.version)}
loading={isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)]}
>
{!isDownloadingState[generateDownloadKey(item.id, secondaryDownload.version)] && (
<DownloadIcon className="mr-2 h-4 w-4" />
)}
{secondaryDownload.label}
</Button>
)}
</div>
</div>
</div>
))
)}
))}
</div>
</DialogContent>
</Dialog>
@@ -1,15 +1,9 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useNavigate } from 'react-router';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
@@ -18,6 +12,10 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
@@ -27,11 +25,7 @@ type EnvelopeDuplicateDialogProps = {
trigger?: React.ReactNode;
};
export const EnvelopeDuplicateDialog = ({
envelopeId,
envelopeType,
trigger,
}: EnvelopeDuplicateDialogProps) => {
export const EnvelopeDuplicateDialog = ({ envelopeId, envelopeType, trigger }: EnvelopeDuplicateDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
@@ -41,24 +35,24 @@ export const EnvelopeDuplicateDialog = ({
const team = useCurrentTeam();
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } =
trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: t`Envelope Duplicated`,
description: t`Your envelope has been successfully duplicated.`,
duration: 5000,
});
const isDocument = envelopeType === EnvelopeType.DOCUMENT;
const path =
envelopeType === EnvelopeType.DOCUMENT
? formatDocumentsPath(team.url)
: formatTemplatesPath(team.url);
const { mutateAsync: duplicateEnvelope, isPending: isDuplicating } = trpc.envelope.duplicate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: isDocument ? t`Document Duplicated` : t`Template Duplicated`,
description: isDocument
? t`Your document has been successfully duplicated.`
: t`Your template has been successfully duplicated.`,
duration: 5000,
});
await navigate(`${path}/${id}/edit`);
setOpen(false);
},
});
const path = isDocument ? formatDocumentsPath(team.url) : formatTemplatesPath(team.url);
await navigate(`${path}/${id}/edit`);
setOpen(false);
},
});
const onDuplicate = async () => {
try {
@@ -66,7 +60,9 @@ export const EnvelopeDuplicateDialog = ({
} catch {
toast({
title: t`Something went wrong`,
description: t`This document could not be duplicated at this time. Please try again.`,
description: isDocument
? t`This document could not be duplicated at this time. Please try again.`
: t`This template could not be duplicated at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
@@ -78,30 +74,25 @@ export const EnvelopeDuplicateDialog = ({
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
{envelopeType === EnvelopeType.DOCUMENT ? (
<DialogHeader>
<DialogTitle>
<Trans>Duplicate Document</Trans>
</DialogTitle>
<DialogDescription>
<DialogHeader>
<DialogTitle>
{isDocument ? <Trans>Duplicate Document</Trans> : <Trans>Duplicate Template</Trans>}
</DialogTitle>
<DialogDescription>
{isDocument ? (
<Trans>This document will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
) : (
<DialogHeader>
<DialogTitle>
<Trans>Duplicate Template</Trans>
</DialogTitle>
<DialogDescription>
) : (
<Trans>This template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
)}
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="secondary" disabled={isDuplicating}>
<Trans>Cancel</Trans>
</Button>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isDuplicating}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" loading={isDuplicating} onClick={onDuplicate}>
<Trans>Duplicate</Trans>
@@ -1,8 +1,3 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -16,6 +11,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
export type EnvelopeItemDeleteDialogProps = {
canItemBeDeleted: boolean;
@@ -39,28 +36,27 @@ export const EnvelopeItemDeleteDialog = ({
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteEnvelopeItem, isPending: isDeleting } =
trpc.envelope.item.delete.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully removed this envelope item.`,
duration: 5000,
});
const { mutateAsync: deleteEnvelopeItem, isPending: isDeleting } = trpc.envelope.item.delete.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully removed this envelope item.`,
duration: 5000,
});
onDelete?.(envelopeItemId);
onDelete?.(envelopeItemId);
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to remove this envelope item. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to remove this envelope item. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
@@ -74,16 +70,12 @@ export const EnvelopeItemDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following document and all associated fields
</Trans>
<Trans>You are about to remove the following document and all associated fields</Trans>
</DialogDescription>
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{envelopeItemTitle}
</AlertDescription>
<AlertDescription className="text-center font-semibold">{envelopeItemTitle}</AlertDescription>
</Alert>
<fieldset disabled={isDeleting}>
@@ -116,9 +108,7 @@ export const EnvelopeItemDeleteDialog = ({
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
You cannot delete this item because the document has been sent to recipients.
</Trans>
<Trans>You cannot delete this item because the document has been sent to recipients.</Trans>
</DialogDescription>
</DialogHeader>
@@ -0,0 +1,344 @@
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { trpc } from '@documenso/trpc/react';
import { ZDocumentTitleSchema } from '@documenso/trpc/server/document-router/schema';
import type { TReplaceEnvelopeItemPdfPayload } from '@documenso/trpc/server/envelope-router/replace-envelope-item-pdf.types';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { AlertTriangleIcon, FileIcon, UploadIcon, XIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZEditEnvelopeItemFormSchema = z.object({
title: ZDocumentTitleSchema,
});
type TEditEnvelopeItemFormSchema = z.infer<typeof ZEditEnvelopeItemFormSchema>;
/**
* Note: This should only be visible if the envelope item is editable.
*/
export type EnvelopeItemEditDialogProps = {
envelopeItem: { id: string; title: string };
allowConfigureTitle: boolean;
trigger: React.ReactNode;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const EnvelopeItemEditDialog = ({
envelopeItem,
allowConfigureTitle,
trigger,
...props
}: EnvelopeItemEditDialogProps) => {
const { t, i18n } = useLingui();
const { toast } = useToast();
const { envelope, editorFields, setLocalEnvelope, isEmbedded } = useCurrentEnvelopeEditor();
const [isOpen, setIsOpen] = useState(false);
const [replacementFile, setReplacementFile] = useState<{ file: File; pageCount: number } | null>(null);
const [isDropping, setIsDropping] = useState(false);
const form = useForm<TEditEnvelopeItemFormSchema>({
resolver: zodResolver(ZEditEnvelopeItemFormSchema),
defaultValues: {
title: envelopeItem.title,
},
});
const { mutateAsync: replaceEnvelopeItemPdf } = trpc.envelope.item.replacePdf.useMutation({
onSuccess: ({ data, fields }) => {
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === data.id ? { ...item, documentDataId: data.documentDataId, title: data.title } : item,
),
});
if (fields) {
setLocalEnvelope({ fields });
editorFields.resetForm(fields);
}
},
});
const fieldsOnExcessPages =
replacementFile !== null
? envelope.fields.filter(
(field) => field.envelopeItemId === envelopeItem.id && field.page > replacementFile.pageCount,
)
: [];
const onFileDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: t`Upload failed`,
description: i18n._(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
};
const onFileDrop = async (files: File[]) => {
const file = files[0];
if (!file || isDropping) {
return;
}
setIsDropping(true);
try {
const arrayBuffer = await file.arrayBuffer();
const fileData = new Uint8Array(arrayBuffer.slice(0));
const { PDF } = await import('@libpdf/core');
const pdfDoc = await PDF.load(fileData);
setReplacementFile({
file,
pageCount: pdfDoc.getPageCount(),
});
} catch (err) {
console.error(err);
toast({
title: t`Failed to read file`,
description: t`The file is not a valid PDF.`,
variant: 'destructive',
});
}
setIsDropping(false);
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: { 'application/pdf': ['.pdf'] },
maxFiles: 1,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
disabled: form.formState.isSubmitting,
onDrop: (files) => void onFileDrop(files),
onDropRejected: onFileDropRejected,
});
const onSubmit = async (data: TEditEnvelopeItemFormSchema) => {
if (isDropping || !replacementFile) {
return;
}
try {
const { file, pageCount } = replacementFile;
if (isEmbedded) {
const arrayBuffer = await file.arrayBuffer();
const fileData = new Uint8Array(arrayBuffer.slice(0));
const remainingFields = envelope.fields.filter(
(field) => field.envelopeItemId !== envelopeItem.id || field.page <= pageCount,
);
setLocalEnvelope({
envelopeItems: envelope.envelopeItems.map((item) =>
item.id === envelopeItem.id ? { ...item, title: data.title, data: fileData } : item,
),
fields: remainingFields,
});
editorFields.resetForm(remainingFields);
} else {
const payload = {
envelopeId: envelope.id,
envelopeItemId: envelopeItem.id,
title: data.title,
} satisfies TReplaceEnvelopeItemPdfPayload;
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('file', file);
await replaceEnvelopeItemPdf(formData);
}
setIsOpen(false);
} catch {
toast({
title: t`Failed to update item`,
description: t`Something went wrong while updating the envelope item.`,
variant: 'destructive',
});
}
};
useEffect(() => {
if (!isOpen) {
form.reset({ title: envelopeItem.title });
setReplacementFile(null);
setIsDropping(false);
}
}, [isOpen, form, envelopeItem.title]);
const formatFileSize = (bytes: number) => {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Dialog {...props} open={isOpen} onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Edit Item</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Update the title or replace the PDF file.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Document Title</Trans>
</FormLabel>
<FormControl>
<Input
data-testid="envelope-item-edit-title-input"
placeholder={t`Document Title`}
disabled={!allowConfigureTitle}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div>
<FormLabel>
<Trans>Replace PDF</Trans>
</FormLabel>
{replacementFile ? (
<div className="mt-1.5 space-y-2">
<div
data-testid="envelope-item-edit-selected-file"
className="flex items-center justify-between rounded-md border border-border bg-muted/50 px-3 py-2"
>
<div className="flex min-w-0 items-center space-x-2">
<FileIcon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0">
<p className="truncate font-medium text-sm">{replacementFile.file.name}</p>
<p className="text-muted-foreground text-xs">
{formatFileSize(replacementFile.file.size)}
{isDropping ? ' · …' : ' · '}
{!isDropping && replacementFile.pageCount !== null && (
<Plural one="1 page" other="# pages" value={replacementFile.pageCount} />
)}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="sm"
data-testid="envelope-item-edit-clear-file"
onClick={() => {
setReplacementFile(null);
}}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{fieldsOnExcessPages.length > 0 && (
<Alert variant="warning" padding="tight">
<AlertTriangleIcon className="h-4 w-4" />
<AlertDescription data-testid="envelope-item-edit-field-warning">
<Plural
one="1 field will be deleted because the new PDF has fewer pages than the current one."
other="# fields will be deleted because the new PDF has fewer pages than the current one."
value={fieldsOnExcessPages.length}
/>
</AlertDescription>
</Alert>
)}
</div>
) : (
<div
data-testid="envelope-item-edit-dropzone"
{...getRootProps()}
className={cn(
'mt-1.5 flex cursor-pointer items-center justify-center rounded-md border border-border border-dashed px-4 py-4 transition-colors',
isDragActive
? 'border-primary/50 bg-primary/5'
: 'hover:border-muted-foreground/50 hover:bg-muted/50',
)}
>
<input {...getInputProps()} />
<div className="flex items-center space-x-2 text-muted-foreground text-sm">
<UploadIcon className="h-4 w-4" />
<span>
<Trans>Drop PDF here or click to select</Trans>
</span>
</div>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="submit"
loading={form.formState.isSubmitting}
disabled={isDropping || !replacementFile}
data-testid="envelope-item-edit-update-button"
>
<Trans>Update</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -1,15 +1,6 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, type Recipient, SigningStatus } from '@prisma/client';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { getRecipientType } from '@documenso/lib/client-only/recipient-type';
import type { TEnvelope } from '@documenso/lib/types/envelope';
import type { TEnvelopeRecipientLite } from '@documenso/lib/types/recipient';
import { recipientAbbreviation } from '@documenso/lib/utils/recipient-formatter';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -25,20 +16,21 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { StackAvatar } from '../general/stack-avatar';
export type EnvelopeRedistributeDialogProps = {
envelope: Pick<TEnvelope, 'id' | 'userId' | 'teamId' | 'status' | 'type' | 'documentMeta'> & {
recipients: Recipient[];
recipients: TEnvelopeRecipientLite[];
};
trigger?: React.ReactNode;
};
@@ -51,10 +43,7 @@ export const ZEnvelopeRedistributeFormSchema = z.object({
export type TEnvelopeRedistributeFormSchema = z.infer<typeof ZEnvelopeRedistributeFormSchema>;
export const EnvelopeRedistributeDialog = ({
envelope,
trigger,
}: EnvelopeRedistributeDialogProps) => {
export const EnvelopeRedistributeDialog = ({ envelope, trigger }: EnvelopeRedistributeDialogProps) => {
const recipients = envelope.recipients;
const { toast } = useToast();
@@ -0,0 +1,120 @@
import { trpc as trpcReact } from '@documenso/trpc/react';
import { DOCUMENT_TITLE_MAX_LENGTH } from '@documenso/trpc/server/document-router/schema';
import { Button } from '@documenso/ui/primitives/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
export type EnvelopeRenameDialogProps = {
id: string;
initialTitle: string;
open: boolean;
onOpenChange: (_open: boolean) => void;
onSuccess?: () => Promise<void>;
envelopeType?: 'document' | 'template';
};
export const EnvelopeRenameDialog = ({
id,
initialTitle,
open,
onOpenChange,
onSuccess,
envelopeType = 'document',
}: EnvelopeRenameDialogProps) => {
const { toast } = useToast();
const { t } = useLingui();
const [title, setTitle] = useState(initialTitle);
useEffect(() => {
if (open) {
setTitle(initialTitle);
}
}, [open, initialTitle]);
const isTemplate = envelopeType === 'template';
const { mutate: updateEnvelope, isPending } = trpcReact.envelope.update.useMutation({
onSuccess: async () => {
await onSuccess?.();
toast({
title: isTemplate ? t`Template Renamed` : t`Document Renamed`,
description: isTemplate
? t`Your template has been successfully renamed.`
: t`Your document has been successfully renamed.`,
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
description: t`Something went wrong. Please try again.`,
variant: 'destructive',
duration: 7500,
});
},
});
const trimmedTitle = title.trim();
const onRename = () => {
if (!trimmedTitle || trimmedTitle === initialTitle) {
onOpenChange(false);
return;
}
updateEnvelope({
envelopeId: id,
data: {
title: trimmedTitle,
},
});
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isTemplate ? <Trans>Rename Template</Trans> : <Trans>Rename Document</Trans>}</DialogTitle>
</DialogHeader>
<div className="py-2">
<Label htmlFor="title" className="sr-only">
<Trans>Title</Trans>
</Label>
<Input
id="title"
value={title}
placeholder={t`Enter a new title`}
onChange={(e) => setTitle(e.target.value)}
disabled={isPending}
maxLength={DOCUMENT_TITLE_MAX_LENGTH}
className="w-full"
autoFocus
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={isPending || !trimmedTitle || trimmedTitle === initialTitle}
loading={isPending}
onClick={() => void onRename()}
>
<Trans>Rename</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,176 @@
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { useCurrentTeam } from '~/providers/team';
type EnvelopeSaveAsTemplateDialogProps = {
envelopeId: string;
trigger?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const EnvelopeSaveAsTemplateDialog = ({
envelopeId,
trigger,
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: EnvelopeSaveAsTemplateDialogProps) => {
const navigate = useNavigate();
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = controlledOnOpenChange ?? setInternalOpen;
const { toast } = useToast();
const { t } = useLingui();
const team = useCurrentTeam();
const templatesPath = formatTemplatesPath(team.url);
const form = useForm({
defaultValues: {
includeRecipients: true,
includeFields: true,
},
});
const includeRecipients = form.watch('includeRecipients');
const { mutateAsync: saveAsTemplate, isPending } = trpc.envelope.saveAsTemplate.useMutation({
onSuccess: async ({ id }) => {
toast({
title: t`Template Created`,
description: t`Your document has been saved as a template.`,
duration: 5000,
});
await navigate(`${templatesPath}/${id}/edit`);
setOpen(false);
},
});
const onSubmit = async () => {
const { includeRecipients, includeFields } = form.getValues();
try {
await saveAsTemplate({
envelopeId,
includeRecipients,
includeFields: includeRecipients && includeFields,
});
} catch {
toast({
title: t`Something went wrong`,
description: t`This document could not be saved as a template at this time. Please try again.`,
variant: 'destructive',
duration: 7500,
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
if (isPending) {
return;
}
setOpen(value);
if (!value) {
form.reset();
}
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Save as Template</Trans>
</DialogTitle>
<DialogDescription>
<Trans>Create a template from this document.</Trans>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Controller
control={form.control}
name="includeRecipients"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeRecipients"
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked === true);
if (!checked) {
form.setValue('includeFields', false);
}
}}
/>
<Label htmlFor="envelopeIncludeRecipients">
<Trans>Include Recipients</Trans>
</Label>
</div>
)}
/>
<Controller
control={form.control}
name="includeFields"
render={({ field }) => (
<div className="flex items-center space-x-2">
<Checkbox
id="envelopeIncludeFields"
checked={field.value}
disabled={!includeRecipients}
onCheckedChange={(checked) => field.onChange(checked === true)}
/>
<Label htmlFor="envelopeIncludeFields" className={!includeRecipients ? 'opacity-50' : ''}>
<Trans>Include Fields</Trans>
</Label>
</div>
)}
/>
</div>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary" disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button type="button" loading={isPending} onClick={onSubmit}>
<Trans>Save as Template</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,9 +1,3 @@
import { plural } from '@lingui/core/macro';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -16,6 +10,10 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { plural } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
export type EnvelopesBulkDeleteDialogProps = {
envelopeIds: string[];
@@ -88,9 +86,7 @@ export const EnvelopesBulkDeleteDialog = ({
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}
</DialogTitle>
<DialogTitle>{isDocument ? <Trans>Delete Documents</Trans> : <Trans>Delete Templates</Trans>}</DialogTitle>
<DialogDescription>
{isDocument ? (
@@ -149,12 +145,7 @@ export const EnvelopesBulkDeleteDialog = ({
</Alert>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)} disabled={isPending}>
<Trans>Cancel</Trans>
</Button>
@@ -1,15 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -21,16 +9,18 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { EnvelopeType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
export type EnvelopesBulkMoveDialogProps = {
envelopeIds: string[];
@@ -119,10 +109,7 @@ export const EnvelopesBulkMoveDialog = ({
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_FOUND,
() => t`The folder you are trying to move the items to does not exist.`,
)
.with(AppErrorCode.NOT_FOUND, () => t`The folder you are trying to move the items to does not exist.`)
.with(AppErrorCode.UNAUTHORIZED, () => t`You are not allowed to move these items.`)
.with(AppErrorCode.INVALID_BODY, () => t`All items must be of the same type.`)
.otherwise(() => t`An error occurred while moving the items.`);
@@ -143,11 +130,7 @@ export const EnvelopesBulkMoveDialog = ({
<DialogContent>
<DialogHeader>
<DialogTitle>
{isDocument ? (
<Trans>Move Documents to Folder</Trans>
) : (
<Trans>Move Templates to Folder</Trans>
)}
{isDocument ? <Trans>Move Documents to Folder</Trans> : <Trans>Move Templates to Folder</Trans>}
</DialogTitle>
<DialogDescription>
@@ -168,7 +151,7 @@ export const EnvelopesBulkMoveDialog = ({
</DialogHeader>
<div className="relative">
<Search className="absolute left-2 top-3 h-4 w-4 text-muted-foreground" />
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
@@ -222,7 +205,7 @@ export const EnvelopesBulkMoveDialog = ({
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="px-2 py-2 text-center text-sm text-muted-foreground">
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
@@ -1,14 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { FolderType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -20,16 +9,18 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { FolderType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderPlusIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useParams } from 'react-router';
import { z } from 'zod';
const ZCreateFolderFormSchema = z.object({
name: z.string().min(1, { message: 'Folder name is required' }),
@@ -40,13 +31,16 @@ type TCreateFolderFormSchema = z.infer<typeof ZCreateFolderFormSchema>;
export type FolderCreateDialogProps = {
type: FolderType;
trigger?: React.ReactNode;
parentFolderId?: string | null;
} & Omit<DialogPrimitive.DialogProps, 'children'>;
export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDialogProps) => {
export const FolderCreateDialog = ({ type, trigger, parentFolderId, ...props }: FolderCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const parentId = parentFolderId ?? folderId;
const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false);
const { mutateAsync: createFolder } = trpc.folder.createFolder.useMutation();
@@ -62,7 +56,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
try {
await createFolder({
name: data.name,
parentId: folderId,
parentId,
type,
});
@@ -90,11 +84,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<DialogTrigger asChild>
{trigger ?? (
<Button
variant="outline"
className="flex items-center"
data-testid="folder-create-button"
>
<Button variant="outline" className="flex items-center" data-testid="folder-create-button">
<FolderPlusIcon className="mr-2 h-4 w-4" />
<Trans>Create Folder</Trans>
</Button>
@@ -131,11 +121,7 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
/>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setIsCreateFolderOpen(false)}
>
<Button type="button" variant="secondary" onClick={() => setIsCreateFolderOpen(false)}>
<Trans>Cancel</Trans>
</Button>
@@ -1,12 +1,3 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@@ -20,16 +11,15 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export type FolderDeleteDialogProps = {
folder: TFolderWithSubfolders;
@@ -110,14 +100,12 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
</DialogDescription>
</DialogHeader>
{(folder._count.documents > 0 ||
folder._count.templates > 0 ||
folder._count.subfolders > 0) && (
{(folder._count.documents > 0 || folder._count.templates > 0 || folder._count.subfolders > 0) && (
<Alert variant="destructive">
<AlertDescription>
<Trans>
This folder contains multiple items. Deleting it will remove all subfolders and move
all nested documents and templates to the root folder.
This folder contains multiple items. Deleting it will remove all subfolders and move all nested
documents and templates to the root folder.
</Trans>
</AlertDescription>
</Alert>
@@ -134,9 +122,7 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
@@ -20,15 +10,16 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export type FolderMoveDialogProps = {
foldersData: TFolderWithSubfolders[] | undefined;
@@ -43,12 +34,7 @@ const ZMoveFolderFormSchema = z.object({
type TMoveFolderFormSchema = z.infer<typeof ZMoveFolderFormSchema>;
export const FolderMoveDialog = ({
foldersData,
folder,
isOpen,
onOpenChange,
}: FolderMoveDialogProps) => {
export const FolderMoveDialog = ({ foldersData, folder, isOpen, onOpenChange }: FolderMoveDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [searchTerm, setSearchTerm] = useState('');
@@ -129,7 +115,7 @@ export const FolderMoveDialog = ({
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t`Search folders...`}
value={searchTerm}
@@ -1,12 +1,3 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { trpc } from '@documenso/trpc/react';
@@ -21,23 +12,16 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useOptionalCurrentTeam } from '~/providers/team';
@@ -1,17 +1,3 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { ExternalLinkIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import type { InternalClaimPlans } from '@documenso/ee/server-only/stripe/get-internal-claim-plans';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
@@ -34,18 +20,22 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { ExternalLinkIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import type { z } from 'zod';
import { IndividualPersonalLayoutCheckoutButton } from '../general/billing-plans';
@@ -70,9 +60,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
const actionSearchParam = searchParams?.get('action');
const [step, setStep] = useState<'billing' | 'create'>(
IS_BILLING_ENABLED() ? 'billing' : 'create',
);
const [step, setStep] = useState<'billing' | 'create'>(IS_BILLING_ENABLED() ? 'billing' : 'create');
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
@@ -145,11 +133,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -215,10 +199,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -237,11 +218,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
<DialogFooter>
{IS_BILLING_ENABLED() ? (
<Button
type="button"
variant="secondary"
onClick={() => setStep('billing')}
>
<Button type="button" variant="secondary" onClick={() => setStep('billing')}>
<Trans>Back</Trans>
</Button>
) : (
@@ -290,30 +267,23 @@ type BillingPlanFormProps = {
canCreateFreeOrganisation: boolean;
};
const BillingPlanForm = ({
value,
onChange,
plans,
canCreateFreeOrganisation,
}: BillingPlanFormProps) => {
const BillingPlanForm = ({ value, onChange, plans, canCreateFreeOrganisation }: BillingPlanFormProps) => {
const { t } = useLingui();
const [billingPeriod, setBillingPeriod] = useState<'monthlyPrice' | 'yearlyPrice'>('yearlyPrice');
const dynamicPlans = useMemo(() => {
return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.TEAM, INTERNAL_CLAIM_ID.PLATFORM].map(
(planId) => {
const plan = plans[planId];
return [INTERNAL_CLAIM_ID.INDIVIDUAL, INTERNAL_CLAIM_ID.TEAM, INTERNAL_CLAIM_ID.PLATFORM].map((planId) => {
const plan = plans[planId];
return {
id: planId,
name: plan.name,
description: parseMessageDescriptorMacro(t, internalClaimsDescription[planId]),
monthlyPrice: plan.monthlyPrice,
yearlyPrice: plan.yearlyPrice,
};
},
);
return {
id: planId,
name: plan.name,
description: parseMessageDescriptorMacro(t, internalClaimsDescription[planId]),
monthlyPrice: plan.monthlyPrice,
yearlyPrice: plan.yearlyPrice,
};
});
}, [plans]);
useEffect(() => {
@@ -357,9 +327,9 @@ const BillingPlanForm = ({
<button
onClick={() => onChange('')}
className={cn(
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
'flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:border-primary hover:shadow-sm',
{
'ring-primary/10 border-primary ring-2 ring-offset-1': '' === value,
'border-primary ring-2 ring-primary/10 ring-offset-1': '' === value,
},
)}
disabled={!canCreateFreeOrganisation}
@@ -390,10 +360,9 @@ const BillingPlanForm = ({
key={plan[billingPeriod]?.id}
onClick={() => onChange(plan[billingPeriod]?.id ?? '')}
className={cn(
'hover:border-primary flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:shadow-sm',
'flex cursor-pointer items-center space-x-2 rounded-md border p-4 transition-all hover:border-primary hover:shadow-sm',
{
'ring-primary/10 border-primary ring-2 ring-offset-1':
plan[billingPeriod]?.id === value,
'border-primary ring-2 ring-primary/10 ring-offset-1': plan[billingPeriod]?.id === value,
},
)}
>
@@ -401,14 +370,10 @@ const BillingPlanForm = ({
<p className="font-medium">{plan.name}</p>
<p className="text-muted-foreground">{plan.description}</p>
</div>
<div className="whitespace-nowrap text-right text-sm font-medium">
<div className="whitespace-nowrap text-right font-medium text-sm">
<p>{plan[billingPeriod]?.friendlyPrice}</p>
<span className="text-muted-foreground text-xs">
{billingPeriod === 'monthlyPrice' ? (
<Trans>per month</Trans>
) : (
<Trans>per year</Trans>
)}
{billingPeriod === 'monthlyPrice' ? <Trans>per month</Trans> : <Trans>per year</Trans>}
</span>
</div>
</button>
@@ -417,13 +382,13 @@ const BillingPlanForm = ({
<Link
to="https://documen.so/enterprise-cta"
target="_blank"
className="bg-muted/30 flex items-center space-x-2 rounded-md border p-4"
className="flex items-center space-x-2 rounded-md border bg-muted/30 p-4"
>
<div className="flex-1 font-normal">
<p className="text-muted-foreground font-medium">
<p className="font-medium text-muted-foreground">
<Trans>Enterprise</Trans>
</p>
<p className="text-muted-foreground flex flex-row items-center gap-1">
<p className="flex flex-row items-center gap-1 text-muted-foreground">
<Trans>Contact sales here</Trans>
<ExternalLinkIcon className="h-4 w-4" />
</p>
@@ -434,7 +399,7 @@ const BillingPlanForm = ({
<div className="mt-6 text-center">
<Link
to="https://documenso.com/pricing"
className="text-primary hover:text-primary/80 flex items-center justify-center gap-1 text-sm hover:underline"
className="flex items-center justify-center gap-1 text-primary text-sm hover:text-primary/80 hover:underline"
target="_blank"
>
<Trans>Compare all plans and features in detail</Trans>
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
@@ -22,16 +12,17 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
export type OrganisationDeleteDialogProps = {
trigger?: React.ReactNode;
@@ -117,19 +108,16 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
<DialogDescription>
<Trans>
You are about to delete <span className="font-semibold">{organisation.name}</span>.
All data related to this organisation such as teams, documents, and all other
resources will be deleted. This action is irreversible.
You are about to delete <span className="font-semibold">{organisation.name}</span>. All data related to
this organisation such as teams, documents, and all other resources will be deleted. This action is
irreversible.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="organisationName"
@@ -1,11 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email.types';
@@ -30,6 +22,12 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
type EmailDomain = {
id: string;
@@ -69,8 +67,7 @@ export const OrganisationEmailCreateDialog = ({
},
});
const { mutateAsync: createOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.create.useMutation();
const { mutateAsync: createOrganisationEmail, isPending } = trpc.enterprise.organisation.email.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
@@ -176,14 +173,14 @@ export const OrganisationEmailCreateDialog = ({
}}
placeholder={t`support`}
/>
<div className="bg-muted text-muted-foreground absolute bottom-0 right-0 top-0 flex items-center rounded-r-md border px-3 py-2 text-sm">
<div className="absolute top-0 right-0 bottom-0 flex items-center rounded-r-md border bg-muted px-3 py-2 text-muted-foreground text-sm">
@{emailDomain.domain}
</div>
</div>
</FormControl>
<FormMessage />
{!form.formState.errors.email && (
<span className="text-foreground/50 text-xs font-normal">
<span className="font-normal text-foreground/50 text-xs">
{field.value ? (
field.value
) : (
@@ -225,11 +222,7 @@ export const OrganisationEmailCreateDialog = ({
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-organisation-email-button"
loading={isPending}
>
<Button type="submit" data-testid="dialog-create-organisation-email-button" loading={isPending}>
<Trans>Create Email</Trans>
</Button>
</DialogFooter>
@@ -1,8 +1,3 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -17,6 +12,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
export type OrganisationEmailDeleteDialogProps = {
emailId: string;
@@ -24,11 +21,7 @@ export type OrganisationEmailDeleteDialogProps = {
trigger?: React.ReactNode;
};
export const OrganisationEmailDeleteDialog = ({
trigger,
emailId,
email,
}: OrganisationEmailDeleteDialogProps) => {
export const OrganisationEmailDeleteDialog = ({ trigger, emailId, email }: OrganisationEmailDeleteDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
@@ -36,26 +29,25 @@ export const OrganisationEmailDeleteDialog = ({
const organisation = useCurrentOrganisation();
const { mutateAsync: deleteEmail, isPending: isDeleting } =
trpc.enterprise.organisation.email.delete.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully removed this email from the organisation.`,
duration: 5000,
});
const { mutateAsync: deleteEmail, isPending: isDeleting } = trpc.enterprise.organisation.email.delete.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully removed this email from the organisation.`,
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to remove this email. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to remove this email. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
@@ -1,12 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
@@ -32,6 +23,12 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { OrganisationEmailDomainRecordContent } from './organisation-email-domain-records-dialog';
@@ -43,9 +40,7 @@ const ZCreateOrganisationEmailDomainFormSchema = ZCreateOrganisationEmailDomainR
domain: true,
});
type TCreateOrganisationEmailDomainFormSchema = z.infer<
typeof ZCreateOrganisationEmailDomainFormSchema
>;
type TCreateOrganisationEmailDomainFormSchema = z.infer<typeof ZCreateOrganisationEmailDomainFormSchema>;
type DomainRecord = {
name: string;
@@ -53,10 +48,7 @@ type DomainRecord = {
type: string;
};
export const OrganisationEmailDomainCreateDialog = ({
trigger,
...props
}: OrganisationEmailCreateDialogProps) => {
export const OrganisationEmailDomainCreateDialog = ({ trigger, ...props }: OrganisationEmailCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const organisation = useCurrentOrganisation();
@@ -72,8 +64,7 @@ export const OrganisationEmailDomainCreateDialog = ({
},
});
const { mutateAsync: createOrganisationEmail } =
trpc.enterprise.organisation.emailDomain.create.useMutation();
const { mutateAsync: createOrganisationEmail } = trpc.enterprise.organisation.emailDomain.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
@@ -119,11 +110,7 @@ export const OrganisationEmailDomainCreateDialog = ({
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -140,18 +127,15 @@ export const OrganisationEmailDomainCreateDialog = ({
</DialogTitle>
<DialogDescription>
<Trans>
Add a custom domain to send emails on behalf of your organisation. We'll generate
DKIM records that you need to add to your DNS provider.
Add a custom domain to send emails on behalf of your organisation. We'll generate DKIM records that you
need to add to your DNS provider.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="domain"
@@ -165,10 +149,7 @@ export const OrganisationEmailDomainCreateDialog = ({
</FormControl>
<FormMessage />
<FormDescription>
<Trans>
Enter the domain you want to use for sending emails (without http:// or
www)
</Trans>
<Trans>Enter the domain you want to use for sending emails (without http:// or www)</Trans>
</FormDescription>
</FormItem>
)}
@@ -1,11 +1,3 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,16 +10,14 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export type OrganisationEmailDomainDeleteDialogProps = {
emailDomainId: string;
@@ -107,10 +97,9 @@ export const OrganisationEmailDomainDeleteDialog = ({
<DialogDescription className="mt-4">
<Trans>
You are about to remove the email domain{' '}
<span className="font-semibold">{emailDomain}</span> from{' '}
<span className="font-semibold">{organisation.name}</span>. All emails associated with
this domain will be deleted.
You are about to remove the email domain <span className="font-semibold">{emailDomain}</span> from{' '}
<span className="font-semibold">{organisation.name}</span>. All emails associated with this domain will be
deleted.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -126,9 +115,7 @@ export const OrganisationEmailDomainDeleteDialog = ({
<FormLabel>
<Trans>
Confirm by typing{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -1,7 +1,3 @@
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -18,6 +14,8 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
export type OrganisationEmailDomainRecordsDialogProps = {
trigger: React.ReactNode;
@@ -72,7 +70,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
<div className="relative">
<Input className="pr-12" disabled value={record.type} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
<CopyTextButton
value={record.type}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
@@ -88,7 +86,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
<div className="relative">
<Input className="pr-12" disabled value={record.name} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
<CopyTextButton
value={record.name}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
@@ -104,7 +102,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
<div className="relative">
<Input className="pr-12" disabled value={record.value} />
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
<div className="absolute top-0 right-2 bottom-0 flex items-center justify-center">
<CopyTextButton
value={record.value}
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
@@ -119,9 +117,8 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
<Alert variant="neutral">
<AlertDescription>
<Trans>
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
Once the DNS propagation is complete you will need to come back and press the "Sync"
domains button.
Once you update your DNS records, it may take up to 48 hours for it to be propogated. Once the DNS
propagation is complete you will need to come back and press the "Sync" domains button.
</Trans>
</AlertDescription>
</Alert>
@@ -1,11 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
import { ZUpdateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-email.types';
@@ -30,6 +22,12 @@ import {
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
export type OrganisationEmailUpdateDialogProps = {
trigger: React.ReactNode;
@@ -61,8 +59,7 @@ export const OrganisationEmailUpdateDialog = ({
},
});
const { mutateAsync: updateOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.update.useMutation();
const { mutateAsync: updateOrganisationEmail, isPending } = trpc.enterprise.organisation.email.update.useMutation();
const onFormSubmit = async ({ emailName }: ZUpdateOrganisationEmailSchema) => {
try {
@@ -98,11 +95,7 @@ export const OrganisationEmailUpdateDialog = ({
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
@@ -115,8 +108,7 @@ export const OrganisationEmailUpdateDialog = ({
<DialogDescription>
<Trans>
You are currently updating{' '}
<span className="font-bold">{organisationEmail.email}</span>
You are currently updating <span className="font-bold">{organisationEmail.email}</span>
</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { EXTENDED_ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
@@ -34,15 +24,20 @@ 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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import {
type OrganisationMemberOption,
OrganisationMembersMultiSelectCombobox,
} from '~/components/general/organisation-members-multiselect-combobox';
export type OrganisationGroupCreateDialogProps = {
trigger?: React.ReactNode;
@@ -56,14 +51,12 @@ const ZCreateOrganisationGroupFormSchema = ZCreateOrganisationGroupRequestSchema
type TCreateOrganisationGroupFormSchema = z.infer<typeof ZCreateOrganisationGroupFormSchema>;
export const OrganisationGroupCreateDialog = ({
trigger,
...props
}: OrganisationGroupCreateDialogProps) => {
export const OrganisationGroupCreateDialog = ({ trigger, ...props }: OrganisationGroupCreateDialogProps) => {
const { t } = useLingui();
const { toast } = useToast();
const [open, setOpen] = useState(false);
const [selectedMembers, setSelectedMembers] = useState<OrganisationMemberOption[]>([]);
const organisation = useCurrentOrganisation();
const form = useForm({
@@ -77,18 +70,7 @@ 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,
memberIds,
}: TCreateOrganisationGroupFormSchema) => {
const onFormSubmit = async ({ name, organisationRole, memberIds }: TCreateOrganisationGroupFormSchema) => {
try {
await createOrganisationGroup({
organisationId: organisation.id,
@@ -119,14 +101,11 @@ export const OrganisationGroupCreateDialog = ({
useEffect(() => {
form.reset();
setSelectedMembers([]);
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -148,10 +127,7 @@ export const OrganisationGroupCreateDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -178,14 +154,12 @@ 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>
<SelectContent position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
organisation.currentOrganisationRole
].map((role) => (
{ORGANISATION_MEMBER_ROLE_HIERARCHY[organisation.currentOrganisationRole].map((role) => (
<SelectItem key={role} value={role}>
{t(EXTENDED_ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
@@ -209,16 +183,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,9 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -18,6 +12,10 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type OrganisationGroupDeleteDialogProps = {
organisationGroupId: string;
@@ -37,28 +35,27 @@ export const OrganisationGroupDeleteDialog = ({
const organisation = useCurrentOrganisation();
const { mutateAsync: deleteGroup, isPending: isDeleting } =
trpc.organisation.group.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this group from the organisation.`),
duration: 5000,
});
const { mutateAsync: deleteGroup, isPending: isDeleting } = trpc.organisation.group.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this group from the organisation.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this group. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
@@ -85,9 +82,7 @@ export const OrganisationGroupDeleteDialog = ({
</DialogHeader>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{organisationGroupName}
</AlertDescription>
<AlertDescription className="text-center font-semibold">{organisationGroupName}</AlertDescription>
</Alert>
<fieldset disabled={isDeleting}>
@@ -1,9 +1,3 @@
import { useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { OrganisationMemberRole } from '@prisma/client';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { trpc } from '@documenso/trpc/react';
@@ -20,6 +14,9 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import type { OrganisationMemberRole } from '@prisma/client';
import { useState } from 'react';
export type OrganisationLeaveDialogProps = {
organisationId: string;
@@ -41,26 +38,25 @@ export const OrganisationLeaveDialog = ({
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } =
trpc.organisation.leave.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully left this organisation.`,
duration: 5000,
});
const { mutateAsync: leaveOrganisation, isPending: isLeavingOrganisation } = trpc.organisation.leave.useMutation({
onSuccess: () => {
toast({
title: t`Success`,
description: t`You have successfully left this organisation.`,
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
setOpen(false);
},
onError: () => {
toast({
title: t`An unknown error occurred`,
description: t`We encountered an unknown error while attempting to leave this organisation. Please try again later.`,
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isLeavingOrganisation && setOpen(value)}>
@@ -1,9 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Alert } from '@documenso/ui/primitives/alert';
@@ -19,6 +13,10 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type OrganisationMemberDeleteDialogProps = {
organisationMemberId: string;
@@ -81,8 +79,8 @@ export const OrganisationMemberDeleteDialog = ({
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following user from{' '}
<span className="font-semibold">{organisation.name}</span>.
You are about to remove the following user from <span className="font-semibold">{organisation.name}</span>
.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,22 +1,10 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
import { downloadFile } from '@documenso/lib/client-only/download-file';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { IS_BILLING_ENABLED, SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { zEmail } from '@documenso/lib/utils/zod';
import { trpc } from '@documenso/trpc/react';
import { ZCreateOrganisationMemberInvitesRequestSchema } from '@documenso/trpc/server/organisation-router/create-organisation-member-invites.types';
import { cn } from '@documenso/ui/lib/utils';
@@ -32,25 +20,23 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Download, Mail, MailIcon, PlusCircle, Trash, Upload, UsersIcon } from 'lucide-react';
import Papa, { type ParseResult } from 'papaparse';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { z } from 'zod';
export type OrganisationMemberInviteDialogProps = {
trigger?: React.ReactNode;
@@ -94,15 +80,12 @@ type TabTypes = 'INDIVIDUAL' | 'BULK';
const ZImportOrganisationMemberSchema = z.array(
z.object({
email: z.string().email(),
email: zEmail(),
organisationRole: z.nativeEnum(OrganisationMemberRole),
}),
);
export const OrganisationMemberInviteDialog = ({
trigger,
...props
}: OrganisationMemberInviteDialogProps) => {
export const OrganisationMemberInviteDialog = ({ trigger, ...props }: OrganisationMemberInviteDialogProps) => {
const [open, setOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [invitationType, setInvitationType] = useState<TabTypes>('INDIVIDUAL');
@@ -133,8 +116,7 @@ export const OrganisationMemberInviteDialog = ({
name: 'invitations',
});
const { mutateAsync: createOrganisationMemberInvites } =
trpc.organisation.member.invite.createMany.useMutation();
const { mutateAsync: createOrganisationMemberInvites } = trpc.organisation.member.invite.createMany.useMutation();
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.id,
@@ -241,9 +223,7 @@ export const OrganisationMemberInviteDialog = ({
toast({
title: _(msg`Something went wrong`),
description: _(
msg`Please check the CSV file and make sure it is according to our format`,
),
description: _(msg`Please check the CSV file and make sure it is according to our format`),
variant: 'destructive',
});
}
@@ -258,8 +238,7 @@ export const OrganisationMemberInviteDialog = ({
{ email: 'member@documenso.com', role: 'Member' },
];
const csvContent =
'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const csvContent = 'Email address,Role\n' + data.map((row) => `${row.email},${row.role}`).join('\n');
const blob = new Blob([csvContent], {
type: 'text/csv',
@@ -272,11 +251,7 @@ export const OrganisationMemberInviteDialog = ({
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -300,15 +275,11 @@ export const OrganisationMemberInviteDialog = ({
{dialogState === 'alert' && (
<>
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<AlertDescription>
<Trans>
Your plan does not support inviting members. Please upgrade or your plan or
contact sales at <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> if you
would like to discuss your options.
Your plan does not support inviting members. Please upgrade or your plan or contact sales at{' '}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> if you would like to discuss your options.
</Trans>
</AlertDescription>
</Alert>
@@ -329,12 +300,12 @@ export const OrganisationMemberInviteDialog = ({
onValueChange={(value) => setInvitationType(value as TabTypes)}
>
<TabsList className="w-full">
<TabsTrigger value="INDIVIDUAL" className="hover:text-foreground w-full">
<TabsTrigger value="INDIVIDUAL" className="w-full hover:text-foreground">
<MailIcon size={20} className="mr-2" />
<Trans>Invite Members</Trans>
</TabsTrigger>
<TabsTrigger value="BULK" className="hover:text-foreground w-full">
<TabsTrigger value="BULK" className="w-full hover:text-foreground">
<UsersIcon size={20} className="mr-2" /> <Trans>Bulk Import</Trans>
</TabsTrigger>
</TabsList>
@@ -342,16 +313,10 @@ export const OrganisationMemberInviteDialog = ({
<TabsContent value="INDIVIDUAL">
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<div className="custom-scrollbar -m-1 max-h-[60vh] space-y-4 overflow-y-auto p-1">
{organisationMemberInvites.map((organisationMemberInvite, index) => (
<div
className="flex w-full flex-row space-x-4"
key={organisationMemberInvite.id}
>
<div className="flex w-full flex-row space-x-4" key={organisationMemberInvite.id}>
<FormField
control={form.control}
name={`invitations.${index}.email`}
@@ -382,18 +347,18 @@ export const OrganisationMemberInviteDialog = ({
)}
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectTrigger className="max-w-[200px] text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[
organisation.currentOrganisationRole
].map((role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
{ORGANISATION_MEMBER_ROLE_HIERARCHY[organisation.currentOrganisationRole].map(
(role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormControl>
@@ -447,7 +412,7 @@ export const OrganisationMemberInviteDialog = ({
<div className="mt-4 space-y-4">
<Card gradient className="h-32">
<CardContent
className="text-muted-foreground/80 hover:text-muted-foreground/90 flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 transition-colors"
className="flex h-full cursor-pointer flex-col items-center justify-center rounded-lg p-0 text-muted-foreground/80 transition-colors hover:text-muted-foreground/90"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-5 w-5" />
@@ -456,13 +421,7 @@ export const OrganisationMemberInviteDialog = ({
<Trans>Click here to upload</Trans>
</p>
<input
onChange={onFileInputChange}
type="file"
ref={fileInputRef}
accept=".csv"
hidden
/>
<input onChange={onFileInputChange} type="file" ref={fileInputRef} accept=".csv" hidden />
</CardContent>
</Card>
@@ -1,14 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { ORGANISATION_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/organisations';
import { ORGANISATION_MEMBER_ROLE_MAP } from '@documenso/lib/constants/organisations-translations';
import { isOrganisationRoleWithinUserHierarchy } from '@documenso/lib/utils/organisations';
@@ -23,22 +12,18 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { OrganisationMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export type OrganisationMemberUpdateDialogProps = {
currentUserOrganisationRole: OrganisationMemberRole;
@@ -113,9 +98,7 @@ export const OrganisationMemberUpdateDialog = ({
form.reset();
if (
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
) {
if (!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)) {
setOpen(false);
toast({
@@ -127,11 +110,7 @@ export const OrganisationMemberUpdateDialog = ({
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -148,8 +127,7 @@ export const OrganisationMemberUpdateDialog = ({
<DialogDescription className="mt-4">
<Trans>
You are currently updating <span className="font-bold">{organisationMemberName}</span>
.
You are currently updating <span className="font-bold">{organisationMemberName}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -172,13 +150,11 @@ export const OrganisationMemberUpdateDialog = ({
</SelectTrigger>
<SelectContent className="w-full" position="popper">
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole].map(
(role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
{ORGANISATION_MEMBER_ROLE_HIERARCHY[currentUserOrganisationRole].map((role) => (
<SelectItem key={role} value={role}>
{_(ORGANISATION_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
@@ -1,15 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
import { MAXIMUM_PASSKEYS } from '@documenso/lib/constants/auth';
import { AppError } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
@@ -24,16 +12,19 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { startRegistration } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { z } from 'zod';
export type PasskeyCreateDialogProps = {
trigger?: React.ReactNode;
@@ -133,15 +124,11 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="secondary" loading={isPending}>
<KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />
<KeyRoundIcon className="mr-1 -ml-1 h-5 w-5" />
<Trans>Add passkey</Trans>
</Button>
)}
@@ -154,19 +141,13 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>
Passkeys allow you to sign in and authenticate using biometrics, password managers,
etc.
</Trans>
<Trans>Passkeys allow you to sign in and authenticate using biometrics, password managers, etc.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="passkeyName"
@@ -186,15 +167,15 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
<Alert variant="neutral">
<AlertDescription>
<Trans>
When you click continue, you will be prompted to add the first available
authenticator on your system.
When you click continue, you will be prompted to add the first available authenticator on your
system.
</Trans>
</AlertDescription>
<AlertDescription className="mt-2">
<Trans>
If you do not want to use the authenticator prompted, you can close it, which
will then display the next available authenticator.
If you do not want to use the authenticator prompted, you can close it, which will then display the
next available authenticator.
</Trans>
</AlertDescription>
</Alert>
@@ -219,9 +200,7 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
.with('InvalidStateError', () => (
<>
<AlertTitle className="text-sm">
<Trans>
Passkey creation cancelled due to one of the following reasons:
</Trans>
<Trans>Passkey creation cancelled due to one of the following reasons:</Trans>
</AlertTitle>
<AlertDescription>
<ul className="mt-1 list-inside list-disc">
@@ -1,17 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { P, match } from 'ts-pattern';
import { z } from 'zod';
import { type Template } from '@documenso/prisma/types/template-legacy-schema';
import type { Template } from '@documenso/prisma/types/template-legacy-schema';
import { trpc } from '@documenso/trpc/react';
import {
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH,
@@ -29,25 +16,22 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Plural, Trans } from '@lingui/react/macro';
import { type TemplateDirectLink, TemplateType } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { CheckCircle2Icon, CircleIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match, P } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -145,10 +129,7 @@ export const ManagePublicTemplateDialog = ({
}
};
const onFormSubmit = async ({
publicTitle,
publicDescription,
}: TUpdatePublicTemplateFormSchema) => {
const onFormSubmit = async ({ publicTitle, publicDescription }: TUpdatePublicTemplateFormSchema) => {
if (!selectedTemplateId) {
return;
}
@@ -251,9 +232,7 @@ export const ManagePublicTemplateDialog = ({
<DialogDescription>
{team ? (
<Trans>
Select a template you'd like to display on your team's public profile
</Trans>
<Trans>Select a template you'd like to display on your team's public profile</Trans>
) : (
<Trans>Select a template you'd like to display on your public profile</Trans>
)}
@@ -290,13 +269,9 @@ export const ManagePublicTemplateDialog = ({
key={row.id}
onClick={() => setSelectedTemplateId(row.id)}
>
<TableCell className="text-muted-foreground max-w-[30ch] text-sm">
{row.title}
</TableCell>
<TableCell className="max-w-[30ch] text-muted-foreground text-sm">{row.title}</TableCell>
<TableCell className="text-muted-foreground text-sm">
{i18n.date(row.createdAt)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">{i18n.date(row.createdAt)}</TableCell>
<TableCell>
{selectedTemplateId === row.id ? (
@@ -317,11 +292,7 @@ export const ManagePublicTemplateDialog = ({
</Button>
</DialogClose>
<Button
type="button"
disabled={selectedTemplateId === null}
onClick={() => onManageStep()}
>
<Button type="button" disabled={selectedTemplateId === null} onClick={() => onManageStep()}>
<Trans>Continue</Trans>
</Button>
</DialogFooter>
@@ -340,10 +311,7 @@ export const ManagePublicTemplateDialog = ({
</DialogHeader>
<Form {...form}>
<form
className="flex h-full flex-col space-y-4"
onSubmit={form.handleSubmit(onFormSubmit)}
>
<form className="flex h-full flex-col space-y-4" onSubmit={form.handleSubmit(onFormSubmit)}>
<FormField
control={form.control}
name="publicTitle"
@@ -351,10 +319,7 @@ export const ManagePublicTemplateDialog = ({
<FormItem>
<FormLabel required>Title</FormLabel>
<FormControl>
<Input
placeholder={_(msg`The public name for your template`)}
{...field}
/>
<Input placeholder={_(msg`The public name for your template`)} {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -365,17 +330,14 @@ export const ManagePublicTemplateDialog = ({
control={form.control}
name="publicDescription"
render={({ field }) => {
const remaningLength =
MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH - (field.value || '').length;
const remaningLength = MAX_TEMPLATE_PUBLIC_DESCRIPTION_LENGTH - (field.value || '').length;
return (
<FormItem>
<FormLabel required>Description</FormLabel>
<FormControl>
<Textarea
placeholder={_(
msg`The public description that will be displayed with this template`,
)}
placeholder={_(msg`The public description that will be displayed with this template`)}
{...field}
/>
</FormControl>
@@ -1,7 +1,3 @@
import { useState } from 'react';
import { Trans, useLingui } from '@lingui/react/macro';
import { authClient } from '@documenso/auth/client';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -15,6 +11,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { useState } from 'react';
type SessionLogoutAllDialogProps = {
onSuccess?: () => Promise<unknown>;
@@ -71,8 +69,8 @@ export const SessionLogoutAllDialog = ({ onSuccess, disabled }: SessionLogoutAll
</DialogTitle>
<DialogDescription>
<Trans>
This will sign you out of all other devices. You will need to sign in again on those
devices to continue using your account.
This will sign you out of all other devices. You will need to sign in again on those devices to continue
using your account.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,13 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm, useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { validateCheckboxLength } from '@documenso/lib/advanced-fields-validation/validate-checkbox';
import { type TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import type { TCheckboxFieldMeta } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -20,6 +12,13 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Plural, Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm, useWatch } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
export type SignFieldCheckboxDialogProps = {
fieldMeta: TCheckboxFieldMeta;
@@ -28,157 +27,133 @@ export type SignFieldCheckboxDialogProps = {
preselectedIndices: number[];
};
export const SignFieldCheckboxDialog = createCallable<
SignFieldCheckboxDialogProps,
number[] | null
>(({ call, fieldMeta, validationRule, validationLength, preselectedIndices }) => {
const ZSignFieldCheckboxFormSchema = z
.object({
values: z.array(
z.object({
checked: z.boolean(),
value: z.string(),
}),
),
})
.superRefine((data, ctx) => {
// Allow unselecting all options if the field is not required even if
// validation is not met.
if (!fieldMeta.required && data.values.every((value) => !value.checked)) {
return;
}
export const SignFieldCheckboxDialog = createCallable<SignFieldCheckboxDialogProps, number[] | null>(
({ call, fieldMeta, validationRule, validationLength, preselectedIndices }) => {
const ZSignFieldCheckboxFormSchema = z
.object({
values: z.array(
z.object({
checked: z.boolean(),
value: z.string(),
}),
),
})
.superRefine((data, ctx) => {
// Allow unselecting all options if the field is not required even if
// validation is not met.
if (!fieldMeta.required && data.values.every((value) => !value.checked)) {
return;
}
const numberOfSelectedValues = data.values.filter((value) => value.checked).length;
const numberOfSelectedValues = data.values.filter((value) => value.checked).length;
const isValid = validateCheckboxLength(
numberOfSelectedValues,
validationRule,
validationLength,
);
const isValid = validateCheckboxLength(numberOfSelectedValues, validationRule, validationLength);
if (!isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Validation failed`.id,
});
}
if (!isValid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: msg`Validation failed`.id,
});
}
});
const form = useForm<z.infer<typeof ZSignFieldCheckboxFormSchema>>({
resolver: zodResolver(ZSignFieldCheckboxFormSchema),
defaultValues: {
values: (fieldMeta.values || []).map((value, index) => ({
checked: preselectedIndices.includes(index) || false,
value: value.value,
})),
},
});
const form = useForm<z.infer<typeof ZSignFieldCheckboxFormSchema>>({
resolver: zodResolver(ZSignFieldCheckboxFormSchema),
defaultValues: {
values: (fieldMeta.values || []).map((value, index) => ({
checked: preselectedIndices.includes(index) || false,
value: value.value,
})),
},
});
const formValues = useWatch({
control: form.control,
});
const formValues = useWatch({
control: form.control,
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>{fieldMeta.label || <Trans>Select Options</Trans>}</DialogTitle>
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent position="center">
<DialogHeader>
<DialogTitle>{fieldMeta.label || <Trans>Select Options</Trans>}</DialogTitle>
<DialogDescription
className={cn('mt-4', {
'text-destructive': Object.keys(form.formState.errors).length > 0,
})}
>
{match(validationRule)
.with('>=', () => (
<Plural
value={validationLength}
one="Select at least # option"
other="Select at least # options"
/>
))
.with('=', () => (
<Plural
value={validationLength}
one="Select exactly # option"
other="Select exactly # options"
/>
))
.with('<=', () => (
<Plural
value={validationLength}
one="Select at most # option"
other="Select at most # options"
/>
))
.exhaustive()}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) =>
call.end(
data.values
.map((value, i) => (value.checked ? i : null))
.filter((value) => value !== null),
),
)}
>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
<DialogDescription
className={cn('mt-4', {
'text-destructive': Object.keys(form.formState.errors).length > 0,
})}
>
<ul className="space-y-3">
{(formValues.values || []).map((value, index) => (
<li key={`checkbox-${index}`}>
<FormField
control={form.control}
name={`values.${index}`}
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center">
<Checkbox
id={`checkbox-value-${index}`}
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value.checked}
onCheckedChange={(checked) => {
field.onChange({
...field.value,
checked,
});
}}
/>
{match(validationRule)
.with('>=', () => (
<Plural value={validationLength} one="Select at least # option" other="Select at least # options" />
))
.with('=', () => (
<Plural value={validationLength} one="Select exactly # option" other="Select exactly # options" />
))
.with('<=', () => (
<Plural value={validationLength} one="Select at most # option" other="Select at most # options" />
))
.exhaustive()}
</DialogDescription>
</DialogHeader>
<label
className="ml-2 w-full text-sm text-muted-foreground"
htmlFor={`checkbox-value-${index}`}
>
{value.value}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
</li>
))}
</ul>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) =>
call.end(data.values.map((value, i) => (value.checked ? i : null)).filter((value) => value !== null)),
)}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<ul className="space-y-3">
{(formValues.values || []).map((value, index) => (
<li key={`checkbox-${index}`}>
<FormField
control={form.control}
name={`values.${index}`}
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center">
<Checkbox
id={`checkbox-value-${index}`}
className="h-5 w-5 border-foreground/30 data-[state=checked]:bg-primary"
checked={field.value.checked}
onCheckedChange={(checked) => {
field.onChange({
...field.value,
checked,
});
}}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<label
className="ml-2 w-full text-muted-foreground text-sm"
htmlFor={`checkbox-value-${index}`}
>
{value.value}
</label>
</div>
</FormControl>
</FormItem>
)}
/>
</li>
))}
</ul>
<Button type="submit">
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
});
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Confirm</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
},
);
@@ -1,6 +1,3 @@
import { useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import type { TDropdownFieldMeta } from '@documenso/lib/types/field-meta';
import {
CommandDialog,
@@ -10,6 +7,8 @@ import {
CommandItem,
CommandList,
} from '@documenso/ui/primitives/command';
import { useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call';
export type SignFieldDropdownDialogProps = {
fieldMeta: TDropdownFieldMeta;
@@ -1,10 +1,4 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zEmail } from '@documenso/lib/utils/zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -14,20 +8,17 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZSignFieldEmailFormSchema = z.object({
email: z
.string()
.email()
.min(1, { message: msg`Email is required`.id }),
email: zEmail().min(1, { message: msg`Email is required`.id }),
});
type TSignFieldEmailFormSchema = z.infer<typeof ZSignFieldEmailFormSchema>;
@@ -60,10 +51,7 @@ export const SignFieldEmailDialog = createCallable<SignFieldEmailDialogProps, st
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.email))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="email"
@@ -1,10 +1,3 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -14,15 +7,14 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZSignFieldInitialsFormSchema = z.object({
initials: z.string().min(1, { message: msg`Initials are required`.id }),
@@ -34,64 +26,59 @@ export type SignFieldInitialsDialogProps = {
//
};
export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogProps, string | null>(
({ call }) => {
const form = useForm<TSignFieldInitialsFormSchema>({
resolver: zodResolver(ZSignFieldInitialsFormSchema),
defaultValues: {
initials: '',
},
});
export const SignFieldInitialsDialog = createCallable<SignFieldInitialsDialogProps, string | null>(({ call }) => {
const form = useForm<TSignFieldInitialsFormSchema>({
resolver: zodResolver(ZSignFieldInitialsFormSchema),
defaultValues: {
initials: '',
},
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Enter Initials</Trans>
</DialogTitle>
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Enter Initials</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Please enter your initials</Trans>
</DialogDescription>
</DialogHeader>
<DialogDescription className="mt-4">
<Trans>Please enter your initials</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.initials))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="initials"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Initials</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.initials))}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="initials"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Initials</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
},
);
<Button type="submit">
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
});
@@ -1,10 +1,3 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
@@ -14,14 +7,14 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZSignFieldNameFormSchema = z.object({
name: z.string().min(1, { message: msg`Name is required`.id }),
@@ -33,61 +26,56 @@ export type SignFieldNameDialogProps = {
//
};
export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, string | null>(
({ call }) => {
const form = useForm<TSignFieldNameFormSchema>({
resolver: zodResolver(ZSignFieldNameFormSchema),
defaultValues: {
name: '',
},
});
export const SignFieldNameDialog = createCallable<SignFieldNameDialogProps, string | null>(({ call }) => {
const form = useForm<TSignFieldNameFormSchema>({
resolver: zodResolver(ZSignFieldNameFormSchema),
defaultValues: {
name: '',
},
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Enter Name</Trans>
</DialogTitle>
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Enter Name</Trans>
</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Please enter your full name</Trans>
</DialogDescription>
</DialogHeader>
<DialogDescription className="mt-4">
<Trans>Please enter your full name</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.name))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.name))}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
},
);
<Button type="submit">
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
});
@@ -1,10 +1,3 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TNumberFieldMeta } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -17,14 +10,13 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { numberFormatValues } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export type SignFieldNumberDialogProps = {
fieldMeta: TNumberFieldMeta;
@@ -115,10 +107,7 @@ export const SignFieldNumberDialog = createCallable<SignFieldNumberDialogProps,
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.number))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="number"
@@ -1,17 +1,9 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog';
import { SignaturePad } from '@documenso/ui/primitives/signature-pad';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
import { createCallable } from 'react-call';
import { DocumentSigningDisclosure } from '../general/document-signing/document-signing-disclosure';
@@ -23,18 +15,8 @@ export type SignFieldSignatureDialogProps = {
drawSignatureEnabled?: boolean;
};
export const SignFieldSignatureDialog = createCallable<
SignFieldSignatureDialogProps,
string | null
>(
({
call,
fullName,
typedSignatureEnabled,
uploadSignatureEnabled,
drawSignatureEnabled,
initialSignature,
}) => {
export const SignFieldSignatureDialog = createCallable<SignFieldSignatureDialogProps, string | null>(
({ call, fullName, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled, initialSignature }) => {
const [localSignature, setLocalSignature] = useState(initialSignature);
return (
@@ -64,11 +46,7 @@ export const SignFieldSignatureDialog = createCallable<
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
disabled={!localSignature}
onClick={() => call.end(localSignature || null)}
>
<Button type="button" disabled={!localSignature} onClick={() => call.end(localSignature || null)}>
<Trans>Sign</Trans>
</Button>
</DialogFooter>
@@ -1,11 +1,3 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Plural, useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import type { TTextFieldMeta } from '@documenso/lib/types/field-meta';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@@ -17,14 +9,14 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormMessage } from '@documenso/ui/primitives/form/form';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { Plural, Trans, useLingui } from '@lingui/react/macro';
import { createCallable } from 'react-call';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
const ZSignFieldTextFormSchema = z.object({
text: z.string().min(1, { message: msg`Text is required`.id }),
@@ -36,80 +28,73 @@ export type SignFieldTextDialogProps = {
fieldMeta?: TTextFieldMeta;
};
export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, string | null>(
({ call, fieldMeta }) => {
const { t } = useLingui();
export const SignFieldTextDialog = createCallable<SignFieldTextDialogProps, string | null>(({ call, fieldMeta }) => {
const { t } = useLingui();
const form = useForm<TSignFieldTextFormSchema>({
resolver: zodResolver(ZSignFieldTextFormSchema),
defaultValues: {
text: '',
},
});
const form = useForm<TSignFieldTextFormSchema>({
resolver: zodResolver(ZSignFieldTextFormSchema),
defaultValues: {
text: '',
},
});
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
return (
<Dialog open={true} onOpenChange={(value) => (!value ? call.end(null) : null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{fieldMeta?.label || <Trans>Enter Text</Trans>}</DialogTitle>
<DialogDescription className="mt-4">
<Trans>Please enter a value</Trans>
</DialogDescription>
</DialogHeader>
<DialogDescription className="mt-4">
<Trans>Please enter a value</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<FormField
control={form.control}
name="text"
render={({ field, fieldState }) => (
<FormItem>
<FormControl>
<Textarea
id="custom-text"
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
className={cn('w-full rounded-md', {
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
fieldState.error,
})}
{...field}
<Form {...form}>
<form onSubmit={form.handleSubmit((data) => call.end(data.text))}>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="text"
render={({ field, fieldState }) => (
<FormItem>
<FormControl>
<Textarea
id="custom-text"
placeholder={fieldMeta?.placeholder ?? t`Enter your text here`}
className={cn('w-full rounded-md', {
'border-2 border-red-300 text-left ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 focus-visible:border-red-400 focus-visible:ring-4 focus-visible:ring-red-200 focus-visible:ring-offset-2 focus-visible:ring-offset-red-200':
fieldState.error,
})}
{...field}
/>
</FormControl>
<FormMessage />
{fieldMeta?.characterLimit !== undefined && fieldMeta?.characterLimit > 0 && !fieldState.error && (
<div className="text-muted-foreground text-sm">
<Plural
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
one="# character remaining"
other="# characters remaining"
/>
</FormControl>
<FormMessage />
{fieldMeta?.characterLimit !== undefined &&
fieldMeta?.characterLimit > 0 &&
!fieldState.error && (
<div className="text-sm text-muted-foreground">
<Plural
value={fieldMeta?.characterLimit - (field.value?.length ?? 0)}
one="# character remaining"
other="# characters remaining"
/>
</div>
)}
</FormItem>
)}
/>
</div>
)}
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => call.end(null)}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit">
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
},
);
<Button type="submit">
<Trans>Enter</Trans>
</Button>
</DialogFooter>
</fieldset>
</form>
</Form>
</DialogContent>
</Dialog>
);
});
@@ -1,22 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import type { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import {
IS_BILLING_ENABLED,
NEXT_PUBLIC_WEBAPP_URL,
SUPPORT_EMAIL,
} from '@documenso/lib/constants/app';
import { IS_BILLING_ENABLED, NEXT_PUBLIC_WEBAPP_URL, SUPPORT_EMAIL } from '@documenso/lib/constants/app';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamRequestSchema } from '@documenso/trpc/server/team-router/create-team.types';
@@ -32,17 +17,19 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useSearchParams } from 'react-router';
import type { z } from 'zod';
export type TeamCreateDialogProps = {
trigger?: React.ReactNode;
@@ -118,16 +105,18 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to create a team. Please try again later.`,
),
description: _(msg`We encountered an unknown error while attempting to create a team. Please try again later.`),
variant: 'destructive',
});
}
};
const mapTextToUrl = (text: string) => {
return text.toLowerCase().replace(/\s+/g, '-');
return text
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\s+/g, '-');
};
const dialogState = useMemo(() => {
@@ -162,11 +151,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -190,15 +175,11 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
{dialogState === 'alert' && (
<>
<Alert
className="flex flex-col justify-between p-6 sm:flex-row sm:items-center"
variant="neutral"
>
<Alert className="flex flex-col justify-between p-6 sm:flex-row sm:items-center" variant="neutral">
<AlertDescription className="mt-0">
<Trans>
You have reached the maximum number of teams for your plan. Please contact sales
at <a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> if you would like to
adjust your plan.
You have reached the maximum number of teams for your plan. Please contact sales at{' '}
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a> if you would like to adjust your plan.
</Trans>
</AlertDescription>
</Alert>
@@ -214,10 +195,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
{dialogState === 'form' && (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="teamName"
@@ -260,7 +238,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<Input className="bg-background" {...field} />
</FormControl>
{!form.formState.errors.teamUrl && (
<span className="text-foreground/50 text-xs font-normal">
<span className="font-normal text-foreground/50 text-xs">
{field.value ? (
`${NEXT_PUBLIC_WEBAPP_URL()}/t/${field.value}`
) : (
@@ -281,16 +259,9 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id="inherit-members"
checked={field.value}
onCheckedChange={field.onChange}
/>
<Checkbox id="inherit-members" checked={field.value} onCheckedChange={field.onChange} />
<label
className="text-muted-foreground ml-2 text-sm"
htmlFor="inherit-members"
>
<label className="ml-2 text-muted-foreground text-sm" htmlFor="inherit-members">
<Trans>Allow all organisation members to access this team</Trans>
</label>
</div>
@@ -304,11 +275,7 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
<Trans>Cancel</Trans>
</Button>
<Button
type="submit"
data-testid="dialog-create-team-button"
loading={form.formState.isSubmitting}
>
<Button type="submit" data-testid="dialog-create-team-button" loading={form.formState.isSubmitting}>
<Trans>Create Team</Trans>
</Button>
</DialogFooter>
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { AppError } from '@documenso/lib/errors/app-error';
@@ -22,24 +12,19 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
export type TeamDeleteDialogProps = {
teamId: number;
@@ -48,12 +33,7 @@ export type TeamDeleteDialogProps = {
trigger?: React.ReactNode;
};
export const TeamDeleteDialog = ({
trigger,
teamId,
teamName,
redirectTo,
}: TeamDeleteDialogProps) => {
export const TeamDeleteDialog = ({ trigger, teamId, teamName, redirectTo }: TeamDeleteDialogProps) => {
const navigate = useNavigate();
const [open, setOpen] = useState(false);
@@ -154,18 +134,15 @@ export const TeamDeleteDialog = ({
<DialogDescription className="mt-4">
<Trans>
Please note that you will lose access to all documents associated with this team & all
the members will be removed and notified
Please note that you will lose access to all documents associated with this team & all the members will be
removed and notified
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="teamName"
@@ -196,9 +173,7 @@ export const TeamDeleteDialog = ({
<FormControl>
<Select {...field} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue
placeholder={_(msg`Don't transfer (Delete all documents)`)}
/>
<SelectValue placeholder={_(msg`Don't transfer (Delete all documents)`)} />
</SelectTrigger>
<SelectContent>
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Plus } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { ZCreateTeamEmailVerificationMutationSchema } from '@documenso/trpc/server/team-router/schema';
@@ -21,16 +11,17 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { Plus } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import type { z } from 'zod';
export type TeamEmailAddDialogProps = {
teamId: number;
@@ -59,8 +50,7 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
},
});
const { mutateAsync: sendTeamEmailVerification, isPending } =
trpc.team.email.verification.send.useMutation();
const { mutateAsync: sendTeamEmailVerification, isPending } = trpc.team.email.verification.send.useMutation();
const onFormSubmit = async ({ name, email }: TCreateTeamEmailFormSchema) => {
try {
@@ -106,15 +96,11 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="outline" loading={isPending} className="bg-background">
<Plus className="-ml-1 mr-1 h-5 w-5" />
<Plus className="mr-1 -ml-1 h-5 w-5" />
<Trans>Add email</Trans>
</Button>
)}
@@ -133,10 +119,7 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -162,11 +145,7 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
<Trans>Email</Trans>
</FormLabel>
<FormControl>
<Input
className="bg-background"
placeholder="example@example.com"
{...field}
/>
<Input className="bg-background" placeholder="example@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -1,11 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Prisma } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { trpc } from '@documenso/trpc/react';
@@ -22,6 +14,12 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Prisma } from '@prisma/client';
import { useState } from 'react';
import { useRevalidator } from 'react-router';
export type TeamEmailDeleteDialogProps = {
trigger?: React.ReactNode;
@@ -47,24 +45,23 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
const { toast } = useToast();
const { revalidate } = useRevalidator();
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } =
trpc.team.email.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Team email has been removed`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to remove team email at this time. Please try again.`),
variant: 'destructive',
duration: 10000,
});
},
});
const { mutateAsync: deleteTeamEmail, isPending: isDeletingTeamEmail } = trpc.team.email.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`Team email has been removed`),
duration: 5000,
});
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Unable to remove team email at this time. Please try again.`),
variant: 'destructive',
duration: 10000,
});
},
});
const { mutateAsync: deleteTeamEmailVerification, isPending: isDeletingTeamEmailVerification } =
trpc.team.email.verification.delete.useMutation({
@@ -115,8 +112,7 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
<DialogDescription className="mt-4">
<Trans>
You are about to delete the following team email from{' '}
<span className="font-semibold">{teamName}</span>.
You are about to delete the following team email from <span className="font-semibold">{teamName}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -125,19 +121,13 @@ export const TeamEmailDeleteDialog = ({ trigger, teamName, team }: TeamEmailDele
<AvatarWithText
avatarClass="h-12 w-12"
avatarSrc={formatAvatarUrl(team.avatarImageId)}
avatarFallback={extractInitials(
(team.teamEmail?.name || team.emailVerification?.name) ?? '',
)}
avatarFallback={extractInitials((team.teamEmail?.name || team.emailVerification?.name) ?? '')}
primaryText={
<span className="text-foreground/80 text-sm font-semibold">
<span className="font-semibold text-foreground/80 text-sm">
{team.teamEmail?.name || team.emailVerification?.name}
</span>
}
secondaryText={
<span className="text-sm">
{team.teamEmail?.email || team.emailVerification?.email}
</span>
}
secondaryText={<span className="text-sm">{team.teamEmail?.email || team.emailVerification?.email}</span>}
/>
</Alert>
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamEmail } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -19,16 +9,17 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamEmail } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { z } from 'zod';
export type TeamEmailUpdateDialogProps = {
teamEmail: TeamEmail;
@@ -41,11 +32,7 @@ const ZUpdateTeamEmailFormSchema = z.object({
type TUpdateTeamEmailFormSchema = z.infer<typeof ZUpdateTeamEmailFormSchema>;
export const TeamEmailUpdateDialog = ({
teamEmail,
trigger,
...props
}: TeamEmailUpdateDialogProps) => {
export const TeamEmailUpdateDialog = ({ teamEmail, trigger, ...props }: TeamEmailUpdateDialogProps) => {
const [open, setOpen] = useState(false);
const { t } = useLingui();
@@ -95,11 +82,7 @@ export const TeamEmailUpdateDialog = ({
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="outline" className="bg-background">
@@ -121,10 +104,7 @@ export const TeamEmailUpdateDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="name"
@@ -1,13 +1,3 @@
import { useEffect, useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { trpc } from '@documenso/trpc/react';
@@ -31,16 +21,21 @@ 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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { match } from 'ts-pattern';
import { z } from 'zod';
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 +54,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 +70,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 +97,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
if (!open) {
form.reset();
setStep('SELECT');
setSelectedGroups([]);
}
}, [open, form]);
@@ -178,28 +155,23 @@ 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,
)?.teamRole || TeamMemberRole.MEMBER,
field.value.find((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 +215,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>
@@ -267,13 +238,11 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
(role) => (
<SelectItem key={role} value={role}>
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
@@ -1,10 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -19,6 +12,11 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { TeamMemberRole } from '@prisma/client';
import { useState } from 'react';
import { useCurrentTeam } from '~/providers/team';
@@ -82,8 +80,7 @@ export const TeamGroupDeleteDialog = ({
<DialogDescription className="mt-4">
<Trans context="Removing group from team">
You are about to remove the following group from{' '}
<span className="font-semibold">{team.name}</span>.
You are about to remove the following group from <span className="font-semibold">{team.name}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -91,9 +88,7 @@ export const TeamGroupDeleteDialog = ({
{isTeamRoleWithinUserHierarchy(team.currentTeamRole, teamGroupRole) ? (
<>
<Alert variant="neutral">
<AlertDescription className="text-center font-semibold">
{teamGroupName}
</AlertDescription>
<AlertDescription className="text-center font-semibold">{teamGroupName}</AlertDescription>
</Alert>
<fieldset disabled={isDeleting}>
@@ -1,14 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
@@ -24,22 +13,18 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -117,11 +102,7 @@ export const TeamGroupUpdateDialog = ({
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -138,8 +119,7 @@ export const TeamGroupUpdateDialog = ({
<DialogDescription>
<Trans>
You are currently updating the <span className="font-bold">{teamGroupName}</span> team
group.
You are currently updating the <span className="font-bold">{teamGroupName}</span> team group.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,6 +1,3 @@
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGroup } from '@prisma/client';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -14,6 +11,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import type { TeamGroup } from '@prisma/client';
import { useCurrentTeam } from '~/providers/team';
@@ -60,8 +59,8 @@ export const TeamMemberInheritDisableDialog = ({ group }: TeamMemberInheritDisab
<DialogDescription className="mt-4">
<Trans>
You are about to remove default access to this team for all organisation members. Any
members not explicitly added to this team will no longer have access.
You are about to remove default access to this team for all organisation members. Any members not
explicitly added to this team will no longer have access.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,6 +1,3 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -15,6 +12,8 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { Trans, useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
import { useCurrentTeam } from '~/providers/team';
@@ -81,8 +80,7 @@ export const TeamMemberInheritEnableDialog = () => {
<DialogDescription className="mt-4">
<Trans>
You are about to give all organisation members access to this team under their
organisation role.
You are about to give all organisation members access to this team under their organisation role.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -1,17 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon, UserPlusIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
@@ -34,18 +24,25 @@ 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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon, UserPlusIcon } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
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 = {
@@ -67,14 +64,21 @@ 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();
const { toast } = useToast();
const team = useCurrentTeam();
const organisation = useCurrentOrganisation();
const utils = trpc.useUtils();
const canInviteOrganisationMembers = canExecuteOrganisationAction(
'MANAGE_ORGANISATION',
organisation.currentOrganisationRole,
);
const form = useForm<TAddTeamMembersFormSchema>({
resolver: zodResolver(ZAddTeamMembersFormSchema),
defaultValues: {
@@ -84,29 +88,20 @@ 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) {
if (hasNoAvailableMembers) {
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
setInviteDialogOpen(true);
return;
}
@@ -151,6 +146,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
form.reset();
setStep('SELECT');
setInviteDialogOpen(false);
setSelectedMembers([]);
}
}, [open, form]);
@@ -191,8 +187,8 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-xs text-muted-foreground">
<Trans>
To be able to add members to a team, you must first add them to the
organisation. For more information, please see the{' '}
To be able to add members to a team, you must first add them to the organisation. For more
information, please see the{' '}
<Link
to="https://docs.documenso.com/users/organisations/members"
target="_blank"
@@ -231,7 +227,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
onKeyDown={(e) => {
if (e.key === 'Enter' && form.getValues('members').length === 0) {
e.preventDefault();
if (hasNoAvailableMembers) {
if (hasNoAvailableMembers && canInviteOrganisationMembers) {
setInviteDialogOpen(true);
}
// Don't show toast - the disabled Next button already communicates this
@@ -252,54 +248,57 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<FormControl>
{hasNoAvailableMembers ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/25 bg-muted/30 px-6 py-12 text-center">
<div className="flex flex-col items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/30 px-6 py-12 text-center">
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-muted">
<UserPlusIcon className="h-6 w-6 text-muted-foreground" />
</div>
<h3 className="mb-2 text-sm font-semibold">
<h3 className="mb-2 font-semibold text-sm">
<Trans>No organisation members available</Trans>
</h3>
<p className="mb-6 max-w-sm text-sm text-muted-foreground">
<Trans>
To add members to this team, you must first add them to the
organisation.
</Trans>
<p className="mb-6 max-w-sm text-muted-foreground text-sm">
{canInviteOrganisationMembers ? (
<Trans>
To add members to this team, you must first add them to the organisation.
</Trans>
) : (
<Trans>
To add members to this team, they must first be invited to the organisation. Only
organisation admins and managers can invite new members please contact one of them
to invite members on your behalf.
</Trans>
)}
</p>
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button type="button" variant="default">
<UserPlusIcon className="mr-2 h-4 w-4" />
<Trans>Invite organisation members</Trans>
</Button>
}
/>
{canInviteOrganisationMembers && (
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button type="button" variant="default">
<UserPlusIcon className="mr-2 h-4 w-4" />
<Trans>Invite organisation members</Trans>
</Button>
}
/>
)}
</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,
)?.teamRole || TeamMemberRole.MEMBER,
field.value.find((entry) => entry.organisationMemberId === member.id)?.teamRole ||
TeamMemberRole.MEMBER,
})),
);
}}
className="w-full bg-background"
emptySelectionPlaceholder={t`Select members`}
dataTestId="team-members-picker"
/>
)}
</FormControl>
@@ -310,30 +309,29 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<Trans>Select members to add to this team</Trans>
</FormDescription>
<Alert
variant="neutral"
className="mt-2 flex items-center gap-2 space-y-0"
>
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 text-sm font-medium text-documenso-700 hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
{canInviteOrganisationMembers && (
<Alert variant="neutral" className="mt-2 flex items-center gap-2 space-y-0">
<div>
<UserPlusIcon className="h-5 w-5 text-muted-foreground" />
</div>
<AlertDescription className="mt-0 flex-1">
<Trans>Can't find someone?</Trans>{' '}
<OrganisationMemberInviteDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
trigger={
<Button
type="button"
variant="link"
className="h-auto p-0 font-medium text-documenso-700 text-sm hover:text-documenso-600"
>
<Trans>Invite them to the organisation first</Trans>
</Button>
}
/>
</AlertDescription>
</Alert>
)}
</>
)}
</FormItem>
@@ -372,11 +370,7 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
<Input
readOnly
className="bg-background"
value={
organisationMemberQuery.data?.data.find(
({ id }) => id === member.organisationMemberId,
)?.name || ''
}
value={selectedMembers.find(({ id }) => id === member.organisationMemberId)?.name || ''}
/>
</div>
@@ -397,13 +391,11 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
</SelectTrigger>
<SelectContent position="popper">
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map(
(role) => (
<SelectItem key={role} value={role}>
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
),
)}
{TEAM_MEMBER_ROLE_HIERARCHY[team.currentTeamRole].map((role) => (
<SelectItem key={role} value={role}>
{t(TEAM_MEMBER_ROLE_MAP[role]) ?? role}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
@@ -1,9 +1,3 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
@@ -18,6 +12,10 @@ import {
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useState } from 'react';
export type TeamMemberDeleteDialogProps = {
teamId: number;
@@ -43,28 +41,27 @@ export const TeamMemberDeleteDialog = ({
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } =
trpc.team.member.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this user from the team.`),
duration: 5000,
});
const { mutateAsync: deleteTeamMember, isPending: isDeletingTeamMember } = trpc.team.member.delete.useMutation({
onSuccess: () => {
toast({
title: _(msg`Success`),
description: _(msg`You have successfully removed this user from the team.`),
duration: 5000,
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this user. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
setOpen(false);
},
onError: () => {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to remove this user. Please try again later.`,
),
variant: 'destructive',
duration: 10000,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeletingTeamMember && setOpen(value)}>
@@ -84,8 +81,7 @@ export const TeamMemberDeleteDialog = ({
<DialogDescription className="mt-4">
<Trans>
You are about to remove the following user from{' '}
<span className="font-semibold">{teamName}</span>.
You are about to remove the following user from <span className="font-semibold">{teamName}</span>.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -93,9 +89,7 @@ export const TeamMemberDeleteDialog = ({
{isInheritMemberEnabled ? (
<Alert variant="neutral">
<AlertDescription>
<Trans>
You cannot remove members from this team if the inherit member feature is enabled.
</Trans>
<Trans>You cannot remove members from this team if the inherit member feature is enabled.</Trans>
</AlertDescription>
</Alert>
) : (
@@ -1,14 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { TEAM_MEMBER_ROLE_HIERARCHY } from '@documenso/lib/constants/teams';
import { EXTENDED_TEAM_MEMBER_ROLE_MAP } from '@documenso/lib/constants/teams-translations';
import { isTeamRoleWithinUserHierarchy } from '@documenso/lib/utils/teams';
@@ -23,22 +12,18 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export type TeamMemberUpdateDialogProps = {
currentUserTeamRole: TeamMemberRole;
@@ -125,11 +110,7 @@ export const TeamMemberUpdateDialog = ({
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,11 +1,3 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
@@ -20,6 +12,13 @@ import {
} from '@documenso/ui/primitives/dialog';
import { Form, FormControl, FormField, FormItem } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { File as FileIcon, Upload, X } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -37,12 +36,7 @@ export type TemplateBulkSendDialogProps = {
onSuccess?: () => void;
};
export const TemplateBulkSendDialog = ({
templateId,
recipients,
trigger,
onSuccess,
}: TemplateBulkSendDialogProps) => {
export const TemplateBulkSendDialog = ({ templateId, recipients, trigger, onSuccess }: TemplateBulkSendDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
@@ -58,10 +52,7 @@ export const TemplateBulkSendDialog = ({
const { mutateAsync: uploadBulkSend } = trpc.template.uploadBulkSend.useMutation();
const onDownloadTemplate = () => {
const headers = recipients.flatMap((_, index) => [
`recipient_${index + 1}_email`,
`recipient_${index + 1}_name`,
]);
const headers = recipients.flatMap((_, index) => [`recipient_${index + 1}_email`, `recipient_${index + 1}_name`]);
const exampleRow = recipients.flatMap((recipient) => [recipient.email, recipient.name || '']);
@@ -92,9 +83,7 @@ export const TemplateBulkSendDialog = ({
toast({
title: _(msg`Success`),
description: _(
msg`Your bulk send has been initiated. You will receive an email notification upon completion.`,
),
description: _(msg`Your bulk send has been initiated. You will receive an email notification upon completion.`),
});
form.reset();
@@ -114,7 +103,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>
@@ -129,8 +118,8 @@ export const TemplateBulkSendDialog = ({
<DialogDescription>
<Trans>
Upload a CSV file to create multiple documents from this template. Each row represents
one document with its recipient details.
Upload a CSV file to create multiple documents from this template. Each row represents one document with
its recipient details.
</Trans>
</DialogDescription>
</DialogHeader>
@@ -138,14 +127,14 @@ export const TemplateBulkSendDialog = ({
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-y-4">
<div className="rounded-lg border bg-muted/70 p-4">
<h3 className="text-sm font-medium">
<h3 className="font-medium text-sm">
<Trans>CSV Structure</Trans>
</h3>
<p className="mt-1 text-sm text-muted-foreground">
<p className="mt-1 text-muted-foreground text-sm">
<Trans>
For each recipient, provide their email (required) and name (optional) in separate
columns. Download the template CSV below for the correct format.
For each recipient, provide their email (required) and name (optional) in separate columns. Download
the template CSV below for the correct format.
</Trans>
</p>
@@ -153,11 +142,9 @@ export const TemplateBulkSendDialog = ({
<Trans>Current recipients:</Trans>
</p>
<ul className="mt-2 list-inside list-disc text-sm text-muted-foreground">
<ul className="mt-2 list-inside list-disc text-muted-foreground text-sm">
{recipients.map((recipient, index) => (
<li key={index}>
{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}
</li>
<li key={index}>{recipient.name ? `${recipient.name} (${recipient.email})` : recipient.email}</li>
))}
</ul>
</div>
@@ -167,7 +154,7 @@ export const TemplateBulkSendDialog = ({
<Trans>Download Template CSV</Trans>
</Button>
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
<Trans>Pre-formatted CSV template with example data.</Trans>
</p>
</div>
@@ -207,7 +194,7 @@ export const TemplateBulkSendDialog = ({
<Button
type="button"
variant="link"
className="p-0 text-xs text-destructive hover:text-destructive"
className="p-0 text-destructive text-xs hover:text-destructive"
onClick={() => onChange(null)}
disabled={form.formState.isSubmitting}
>
@@ -220,12 +207,11 @@ export const TemplateBulkSendDialog = ({
)}
</FormControl>
{error && <p className="text-sm text-destructive">{error.message}</p>}
{error && <p className="text-destructive text-sm">{error.message}</p>}
<p className="text-xs text-muted-foreground">
<p className="text-muted-foreground text-xs">
<Trans>
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use
template defaults.
Maximum file size: 4MB. Maximum 100 rows per upload. Blank values will use template defaults.
</Trans>
</p>
</FormItem>
@@ -239,15 +225,11 @@ export const TemplateBulkSendDialog = ({
<FormItem className="flex items-center space-x-2">
<FormControl>
<div className="flex items-center">
<Checkbox
id="send-immediately"
checked={field.value}
onCheckedChange={field.onChange}
/>
<Checkbox id="send-immediately" checked={field.value} onCheckedChange={field.onChange} />
<label
htmlFor="send-immediately"
className="ml-2 flex items-center text-sm text-muted-foreground"
className="ml-2 flex items-center text-muted-foreground text-sm"
>
<Trans>Send documents to recipients immediately</Trans>
</label>
@@ -1,93 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDeleteDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
onDelete?: () => Promise<void> | void;
};
export const TemplateDeleteDialog = ({
id,
open,
onOpenChange,
onDelete,
}: TemplateDeleteDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: deleteTemplate, isPending } = trpcReact.template.deleteTemplate.useMutation({
onSuccess: async () => {
await onDelete?.();
toast({
title: _(msg`Template deleted`),
description: _(msg`Your template has been successfully deleted.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(msg`This template could not be deleted at this time. Please try again.`),
variant: 'destructive',
duration: 7500,
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to delete this template?</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Please note that this action is irreversible. Once confirmed, your template will be
permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
disabled={isPending}
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
variant="destructive"
loading={isPending}
onClick={async () => deleteTemplate({ templateId: id })}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,26 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { type Recipient, RecipientRole, type TemplateDirectLink } from '@prisma/client';
import {
CircleDotIcon,
CircleIcon,
ClipboardCopyIcon,
InfoIcon,
LinkIcon,
LoaderIcon,
} from 'lucide-react';
import { Link, useRevalidator } from 'react-router';
import { P, match } from 'ts-pattern';
import { useLimits } from '@documenso/ee/server-only/limits/provider/client';
import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { DIRECT_TEMPLATE_RECIPIENT_EMAIL } from '@documenso/lib/constants/direct-templates';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { DIRECT_TEMPLATE_DOCUMENTATION } from '@documenso/lib/constants/template';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
@@ -38,22 +22,24 @@ import {
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { Switch } from '@documenso/ui/primitives/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { RecipientRole, type TemplateDirectLink } from '@prisma/client';
import { CircleDotIcon, CircleIcon, ClipboardCopyIcon, InfoIcon, LinkIcon, LoaderIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useRevalidator } from 'react-router';
import { match, P } from 'ts-pattern';
type TemplateDirectLinkDialogProps = {
templateId: number;
directLink?: Pick<TemplateDirectLink, 'token' | 'enabled'> | null;
recipients: Recipient[];
recipients: TRecipientLite[];
trigger?: React.ReactNode;
triggerSizeVariant?: 'default' | 'sm' | 'lg';
onCreateSuccess?: () => Promise<void> | void;
onDeleteSuccess?: () => Promise<void> | void;
};
@@ -65,6 +51,7 @@ export const TemplateDirectLinkDialog = ({
directLink,
recipients,
trigger,
triggerSizeVariant = 'sm',
onCreateSuccess,
onDeleteSuccess,
}: TemplateDirectLinkDialogProps) => {
@@ -79,17 +66,14 @@ export const TemplateDirectLinkDialog = ({
const [isEnabled, setIsEnabled] = useState(directLink?.enabled ?? false);
const [token, setToken] = useState(directLink?.token ?? null);
const [selectedRecipientId, setSelectedRecipientId] = useState<number | null>(null);
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(
token ? 'MANAGE' : 'ONBOARD',
);
const [currentStep, setCurrentStep] = useState<TemplateDirectLinkStep>(token ? 'MANAGE' : 'ONBOARD');
const organisation = useCurrentOrganisation();
const validDirectTemplateRecipients = useMemo(
() =>
recipients.filter(
(recipient) =>
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
(recipient) => recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
),
[recipients],
);
@@ -163,9 +147,7 @@ export const TemplateDirectLinkDialog = ({
onError: () => {
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We encountered an error while removing the direct template link. Please try again later.`,
),
description: _(msg`We encountered an error while removing the direct template link. Please try again later.`),
variant: 'destructive',
});
},
@@ -192,8 +174,7 @@ export const TemplateDirectLinkDialog = ({
});
};
const isLoading =
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
const isLoading = isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
useEffect(() => {
resetCreateTemplateDirectLink();
@@ -207,7 +188,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>}
@@ -234,13 +215,13 @@ export const TemplateDirectLinkDialog = ({
{DIRECT_TEMPLATE_DOCUMENTATION.map((step, index) => (
<li className="relative" key={index}>
<div className="absolute -left-12">
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 text-sm font-bold">
<div className="flex h-8 w-8 items-center justify-center rounded-full border-[3px] border-neutral-200 font-bold text-sm">
{index + 1}
</div>
</div>
<h3 className="font-semibold">{_(step.title)}</h3>
<p className="mt-1 text-sm text-muted-foreground">{_(step.description)}</p>
<p className="mt-1 text-muted-foreground text-sm">{_(step.description)}</p>
</li>
))}
</ul>
@@ -249,14 +230,12 @@ export const TemplateDirectLinkDialog = ({
<Alert variant="warning">
<AlertTitle>
<Trans>
Direct template link usage exceeded ({quota.directTemplates}/
{quota.directTemplates})
Direct template link usage exceeded ({quota.directTemplates}/{quota.directTemplates})
</Trans>
</AlertTitle>
<AlertDescription>
<Trans>
You have reached the maximum limit of {quota.directTemplates} direct
templates.{' '}
You have reached the maximum limit of {quota.directTemplates} direct templates.{' '}
<Link
className="mt-1 block underline underline-offset-4"
to={`/o/${organisation.url}/settings/billing`}
@@ -326,13 +305,13 @@ export const TemplateDirectLinkDialog = ({
onClick={async () => onRecipientTableRowClick(row.id)}
>
<TableCell>
<div className="text-sm text-muted-foreground">
<div className="text-muted-foreground text-sm">
<p>{row.name}</p>
<p className="text-xs text-muted-foreground/70">{row.email}</p>
<p className="text-muted-foreground/70 text-xs">{row.email}</p>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
<TableCell className="text-muted-foreground text-sm">
{_(RECIPIENT_ROLES_DESCRIPTION[row.role].roleName)}
</TableCell>
@@ -350,13 +329,11 @@ export const TemplateDirectLinkDialog = ({
</div>
{/* Prevent creating placeholder direct template recipient if the email already exists. */}
{!recipients.some(
(recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL,
) && (
{!recipients.some((recipient) => recipient.email === DIRECT_TEMPLATE_RECIPIENT_EMAIL) && (
<DialogFooter className="mx-auto">
<div className="flex flex-col items-center justify-center">
{validDirectTemplateRecipients.length !== 0 && (
<p className="text-sm text-muted-foreground">
<p className="text-muted-foreground text-sm">
<Trans>Or</Trans>
</p>
)}
@@ -399,19 +376,12 @@ export const TemplateDirectLinkDialog = ({
<InfoIcon className="h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="z-9999 max-w-md p-4 text-foreground">
<Trans>
Disabling direct link signing will prevent anyone from accessing the
link.
</Trans>
<Trans>Disabling direct link signing will prevent anyone from accessing the link.</Trans>
</TooltipContent>
</Tooltip>
</Label>
<Switch
className="mt-2"
checked={isEnabled}
onCheckedChange={(value) => setIsEnabled(value)}
/>
<Switch className="mt-2" checked={isEnabled} onCheckedChange={(value) => setIsEnabled(value)} />
</div>
<div className="mt-2">
@@ -428,7 +398,7 @@ export const TemplateDirectLinkDialog = ({
className="pr-12"
/>
<div className="absolute bottom-0 right-1 top-0 flex items-center justify-center">
<div className="absolute top-0 right-1 bottom-0 flex items-center justify-center">
<Button
variant="none"
type="button"
@@ -479,18 +449,13 @@ export const TemplateDirectLinkDialog = ({
<DialogDescription>
<Trans>
Please note that proceeding will remove direct linking recipient and turn it
into a placeholder.
Please note that proceeding will remove direct linking recipient and turn it into a placeholder.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setCurrentStep('MANAGE')}
>
<Button type="button" variant="secondary" onClick={() => setCurrentStep('MANAGE')}>
<Trans>Cancel</Trans>
</Button>
@@ -1,89 +0,0 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { trpc as trpcReact } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
type TemplateDuplicateDialogProps = {
id: number;
open: boolean;
onOpenChange: (_open: boolean) => void;
};
export const TemplateDuplicateDialog = ({
id,
open,
onOpenChange,
}: TemplateDuplicateDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { mutateAsync: duplicateTemplate, isPending } =
trpcReact.template.duplicateTemplate.useMutation({
onSuccess: () => {
toast({
title: _(msg`Template duplicated`),
description: _(msg`Your template has been duplicated successfully.`),
duration: 5000,
});
onOpenChange(false);
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while duplicating template.`),
variant: 'destructive',
});
},
});
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans>Do you want to duplicate this template?</Trans>
</DialogTitle>
<DialogDescription className="pt-2">
<Trans>Your template will be duplicated.</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
disabled={isPending}
variant="secondary"
onClick={() => onOpenChange(false)}
>
<Trans>Cancel</Trans>
</Button>
<Button
type="button"
loading={isPending}
onClick={async () =>
duplicateTemplate({
templateId: id,
})
}
>
<Trans>Duplicate</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,15 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
@@ -23,16 +11,19 @@ import {
DialogHeader,
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { FolderIcon, HomeIcon, Loader2, Search } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -157,7 +148,7 @@ export function TemplateMoveToFolderDialog({
</DialogHeader>
<div className="relative">
<Search className="text-muted-foreground absolute left-2 top-3 h-4 w-4" />
<Search className="absolute top-3 left-2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={_(msg`Search folders...`)}
value={searchTerm}
@@ -211,7 +202,7 @@ export function TemplateMoveToFolderDialog({
))}
{searchTerm && filteredFolders?.length === 0 && (
<div className="text-muted-foreground px-2 py-2 text-center text-sm">
<div className="px-2 py-2 text-center text-muted-foreground text-sm">
<Trans>No folders found</Trans>
</div>
)}
@@ -1,27 +1,11 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Recipient } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import * as z from 'zod';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
} from '@documenso/lib/constants/template';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
} from '@documenso/lib/constants/trpc';
import { AppError } from '@documenso/lib/errors/app-error';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION, SKIP_QUERY_BATCH_META } from '@documenso/lib/constants/trpc';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { type TRecipientLite, ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
@@ -37,19 +21,22 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { Toast } from '@documenso/ui/primitives/use-toast';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/client';
import { FileTextIcon, InfoIcon, Plus, UploadCloudIcon, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useNavigate } from 'react-router';
import { match } from 'ts-pattern';
import * as z from 'zod';
const ZAddRecipientsForNewDocumentSchema = z.object({
distributeDocument: z.boolean(),
@@ -79,7 +66,7 @@ export type TemplateUseDialogProps = {
envelopeId: string;
templateId: number;
templateSigningOrder?: DocumentSigningOrder | null;
recipients: Recipient[];
recipients: TRecipientLite[];
documentDistributionMethod?: DocumentDistributionMethod;
documentRootPath: string;
trigger?: React.ReactNode;
@@ -127,13 +114,9 @@ export function TemplateUseDialog({
recipients: recipients
.sort((a, b) => (a.signingOrder || 0) - (b.signingOrder || 0))
.map((recipient) => {
const isRecipientEmailPlaceholder = recipient.email.match(
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
);
const isRecipientEmailPlaceholder = recipient.email.match(TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX);
const isRecipientNamePlaceholder = recipient.name.match(
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
);
const isRecipientNamePlaceholder = recipient.name.match(TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX);
return {
id: recipient.id,
@@ -155,8 +138,7 @@ export function TemplateUseDialog({
name: 'customDocumentData',
});
const { mutateAsync: createDocumentFromTemplate } =
trpc.template.createDocumentFromTemplate.useMutation();
const { mutateAsync: createDocumentFromTemplate } = trpc.template.createDocumentFromTemplate.useMutation();
const onSubmit = async (data: TAddRecipientsForNewDocumentSchema) => {
try {
@@ -191,10 +173,7 @@ export function TemplateUseDialog({
let documentPath = `${documentRootPath}/${envelopeId}`;
if (
data.distributeDocument &&
documentDistributionMethod === DocumentDistributionMethod.NONE
) {
if (data.distributeDocument && documentDistributionMethod === DocumentDistributionMethod.NONE) {
documentPath += '?action=view-signing-links';
}
@@ -202,19 +181,23 @@ export function TemplateUseDialog({
} catch (err) {
const error = AppError.parseError(err);
const toastPayload: Toast = {
const errorMessage = match(error.code)
.with('DOCUMENT_SEND_FAILED', () => msg`The document was created but could not be sent to recipients.`)
.with(
AppErrorCode.INVALID_BODY,
AppErrorCode.INVALID_REQUEST,
() =>
msg`The document could not be created because of missing or invalid information. Please review the template's recipients and fields.`,
)
.with(AppErrorCode.NOT_FOUND, () => msg`The template or one of its recipients could not be found.`)
.with(AppErrorCode.LIMIT_EXCEEDED, () => msg`You have reached your document limit for this plan.`)
.otherwise(() => msg`An error occurred while creating document from template.`);
toast({
title: _(msg`Error`),
description: _(msg`An error occurred while creating document from template.`),
description: _(errorMessage),
variant: 'destructive',
};
if (error.code === 'DOCUMENT_SEND_FAILED') {
toastPayload.description = _(
msg`The document was created but could not be sent to recipients.`,
);
}
toast(toastPayload);
});
}
};
@@ -246,7 +229,7 @@ export function TemplateUseDialog({
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="bg-background">
<Plus className="-ml-1 mr-2 h-4 w-4" />
<Plus className="mr-2 -ml-1 h-4 w-4" />
<Trans>Use Template</Trans>
</Button>
)}
@@ -286,10 +269,7 @@ export function TemplateUseDialog({
{...field}
disabled
className="items-center justify-center"
value={
field.value?.toString() ||
recipients[index]?.signingOrder?.toString()
}
value={field.value?.toString() || recipients[index]?.signingOrder?.toString()}
/>
</FormControl>
<FormMessage />
@@ -359,7 +339,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.EMAIL && (
<label
className="ml-2 flex items-center text-sm text-muted-foreground"
className="ml-2 flex items-center text-muted-foreground text-sm"
htmlFor="distributeDocument"
>
<Trans>Send document</Trans>
@@ -371,15 +351,12 @@ export function TemplateUseDialog({
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
The document will be immediately sent to recipients if this
is checked.
The document will be immediately sent to recipients if this is checked.
</Trans>
</p>
<p>
<Trans>
Otherwise, the document will be created as a draft.
</Trans>
<Trans>Otherwise, the document will be created as a draft.</Trans>
</p>
</TooltipContent>
</Tooltip>
@@ -388,7 +365,7 @@ export function TemplateUseDialog({
{documentDistributionMethod === DocumentDistributionMethod.NONE && (
<label
className="ml-2 flex items-center text-sm text-muted-foreground"
className="ml-2 flex items-center text-muted-foreground text-sm"
htmlFor="distributeDocument"
>
<Trans>Create as pending</Trans>
@@ -398,9 +375,7 @@ export function TemplateUseDialog({
</TooltipTrigger>
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Create the document as pending and ready to sign.
</Trans>
<Trans>Create the document as pending and ready to sign.</Trans>
</p>
<p>
@@ -409,8 +384,8 @@ export function TemplateUseDialog({
<p className="mt-2">
<Trans>
We will generate signing links for you, which you can send
to the recipients through your method of choice.
We will generate signing links for you, which you can send to the recipients
through your method of choice.
</Trans>
</p>
</TooltipContent>
@@ -442,7 +417,7 @@ export function TemplateUseDialog({
}}
/>
<label
className="ml-2 flex items-center text-sm text-muted-foreground"
className="ml-2 flex items-center text-muted-foreground text-sm"
htmlFor="useCustomDocument"
>
<Trans>Upload custom document</Trans>
@@ -453,8 +428,7 @@ export function TemplateUseDialog({
<TooltipContent className="z-[99999] max-w-md space-y-2 p-4 text-muted-foreground">
<p>
<Trans>
Upload a custom document to use instead of the template's default
document
Upload a custom document to use instead of the template's default document
</Trans>
</p>
</TooltipContent>
@@ -489,16 +463,11 @@ export function TemplateUseDialog({
</div>
<div className="min-w-0 flex-1">
<h4 className="truncate text-sm font-medium text-foreground">
{item.title}
</h4>
<p className="mt-0.5 text-xs text-muted-foreground">
<h4 className="truncate font-medium text-foreground text-sm">{item.title}</h4>
<p className="mt-0.5 text-muted-foreground text-xs">
{field.value ? (
<div>
<Trans>
Custom {(field.value.size / (1024 * 1024)).toFixed(2)}{' '}
MB file
</Trans>
<Trans>Custom {(field.value.size / (1024 * 1024)).toFixed(2)} MB file</Trans>
</div>
) : (
<Trans>Default file</Trans>
@@ -567,10 +536,7 @@ export function TemplateUseDialog({
return;
}
if (
file.size >
APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024
) {
if (file.size > APP_DOCUMENT_UPLOAD_SIZE_LIMIT * 1024 * 1024) {
form.setError('customDocumentData', {
type: 'manual',
message: _(
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -19,16 +9,17 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { ApiToken } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -102,10 +93,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
}, [isOpen, form]);
return (
<Dialog
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<Dialog open={isOpen} onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}>
<DialogTrigger asChild={true}>
{children ?? (
<Button className="mr-4" variant="destructive">
@@ -122,18 +110,14 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<DialogDescription>
<Trans>
Please note that this action is irreversible. Once confirmed, your token will be
permanently deleted.
Please note that this action is irreversible. Once confirmed, your token will be permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="tokenName"
@@ -142,9 +126,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
@@ -158,12 +140,7 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
<DialogFooter>
<div className="flex w-full flex-nowrap gap-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setIsOpen(false)}
>
<Button type="button" variant="secondary" className="flex-1" onClick={() => setIsOpen(false)}>
<Trans>Cancel</Trans>
</Button>
@@ -1,13 +1,3 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZCreateWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -33,6 +23,14 @@ import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -66,12 +64,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
const { mutateAsync: createWebhook } = trpc.webhook.createWebhook.useMutation();
const onSubmit = async ({
enabled,
eventTriggers,
secret,
webhookUrl,
}: TCreateWebhookFormSchema) => {
const onSubmit = async ({ enabled, eventTriggers, secret, webhookUrl }: TCreateWebhookFormSchema) => {
try {
await createWebhook({
enabled,
@@ -98,11 +91,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
};
return (
<Dialog
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
{...props}
>
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button className="flex-shrink-0">
@@ -123,10 +112,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
@@ -160,11 +146,7 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch className="bg-background" checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</div>
@@ -209,17 +191,13 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
<Trans>Secret</Trans>
</FormLabel>
<FormControl>
<PasswordInput
className="bg-background"
{...field}
value={field.value ?? ''}
/>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
<Trans>
A secret that will be sent to your URL so you can verify that the request
has been sent by Documenso.
A secret that will be sent to your URL so you can verify that the request has been sent by
Documenso.
</Trans>
</FormDescription>
<FormMessage />
@@ -1,13 +1,3 @@
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
@@ -19,16 +9,17 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { useCurrentTeam } from '~/providers/team';
@@ -79,9 +70,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
} catch (error) {
toast({
title: _(msg`An unknown error occurred`),
description: _(
msg`We encountered an unknown error while attempting to delete it. Please try again later.`,
),
description: _(msg`We encountered an unknown error while attempting to delete it. Please try again later.`),
variant: 'destructive',
duration: 5000,
});
@@ -112,18 +101,14 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
<DialogDescription>
<Trans>
Please note that this action is irreversible. Once confirmed, your webhook will be
permanently deleted.
Please note that this action is irreversible. Once confirmed, your webhook will be permanently deleted.
</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="webhookUrl"
@@ -132,9 +117,7 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
<FormLabel>
<Trans>
Confirm by typing:{' '}
<span className="font-sm text-destructive font-semibold">
{deleteMessage}
</span>
<span className="font-semibold font-sm text-destructive">{deleteMessage}</span>
</Trans>
</FormLabel>
<FormControl>
@@ -1,13 +1,3 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { ZEditWebhookRequestSchema } from '@documenso/trpc/server/webhook-router/schema';
import { Button } from '@documenso/ui/primitives/button';
@@ -34,6 +24,13 @@ import { Input } from '@documenso/ui/primitives/input';
import { PasswordInput } from '@documenso/ui/primitives/password-input';
import { Switch } from '@documenso/ui/primitives/switch';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import type { z } from 'zod';
import { WebhookMultiSelectCombobox } from '../general/webhook-multiselect-combobox';
@@ -86,11 +83,7 @@ export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDia
};
return (
<Dialog
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
{...props}
>
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)} {...props}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
@@ -105,10 +98,7 @@ export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDia
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col gap-y-6"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col gap-y-6" disabled={form.formState.isSubmitting}>
<div className="flex flex-col-reverse gap-4 md:flex-row">
<FormField
control={form.control}
@@ -140,11 +130,7 @@ export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDia
<div>
<FormControl>
<Switch
className="bg-background"
checked={field.value}
onCheckedChange={field.onChange}
/>
<Switch className="bg-background" checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</div>
@@ -187,17 +173,13 @@ export const WebhookEditDialog = ({ trigger, webhook, ...props }: WebhookEditDia
<FormItem>
<FormLabel>Secret</FormLabel>
<FormControl>
<PasswordInput
className="bg-background"
{...field}
value={field.value ?? ''}
/>
<PasswordInput className="bg-background" {...field} value={field.value ?? ''} />
</FormControl>
<FormDescription>
<Trans>
A secret that will be sent to your URL so you can verify that the request
has been sent by Documenso.
A secret that will be sent to your URL so you can verify that the request has been sent by
Documenso.
</Trans>
</FormDescription>
<FormMessage />
@@ -1,12 +1,3 @@
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { toFriendlyWebhookEventName } from '@documenso/lib/universal/webhook/to-friendly-webhook-event-name';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
@@ -19,22 +10,16 @@ import {
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import type { Webhook } from '@prisma/client';
import { WebhookTriggerEvents } from '@prisma/client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
export type WebhookTestDialogProps = {
webhook: Pick<Webhook, 'id' | 'webhookUrl' | 'eventTriggers'>;
@@ -97,18 +82,13 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
</DialogTitle>
<DialogDescription>
<Trans>
Send a test webhook with sample data to verify your integration is working correctly.
</Trans>
<Trans>Send a test webhook with sample data to verify your integration is working correctly.</Trans>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset
className="flex h-full flex-col space-y-4"
disabled={form.formState.isSubmitting}
>
<fieldset className="flex h-full flex-col space-y-4" disabled={form.formState.isSubmitting}>
<FormField
control={form.control}
name="event"
@@ -137,10 +117,10 @@ export const WebhookTestDialog = ({ webhook, children }: WebhookTestDialogProps)
/>
<div className="rounded-md border p-4">
<h4 className="mb-2 text-sm font-medium">
<h4 className="mb-2 font-medium text-sm">
<Trans>Webhook URL</Trans>
</h4>
<p className="text-muted-foreground break-all text-sm">{webhook.webhookUrl}</p>
<p className="break-all text-muted-foreground text-sm">{webhook.webhookUrl}</p>
</div>
<DialogFooter>
@@ -1,9 +1,3 @@
import { Trans, useLingui } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
import { DOCUMENT_SIGNATURE_TYPES } from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
@@ -21,16 +15,17 @@ import {
} 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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import type { Control } from 'react-hook-form';
import { useFormContext } from 'react-hook-form';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -44,7 +39,7 @@ export const ConfigureDocumentAdvancedSettings = ({
control,
isSubmitting,
}: ConfigureDocumentAdvancedSettingsProps) => {
const { t } = useLingui();
const { _ } = useLingui();
const form = useFormContext<TConfigureEmbedFormSchema>();
const { features } = useConfigureDocument();
@@ -58,11 +53,11 @@ export const ConfigureDocumentAdvancedSettings = ({
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<h3 className="mb-1 font-medium text-foreground text-lg">
<Trans>Advanced Settings</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<p className="mb-6 text-muted-foreground text-sm">
<Trans>Configure additional options and preferences</Trans>
</p>
@@ -93,12 +88,12 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<MultiSelectCombobox
options={Object.values(DOCUMENT_SIGNATURE_TYPES).map((option) => ({
label: t(option.label),
label: _(option.label),
value: option.value,
}))}
selectedValues={field.value}
onChange={field.onChange}
className="bg-background w-full"
className="w-full bg-background"
emptySelectionPlaceholder={t`Select signature types`}
/>
</FormControl>
@@ -125,7 +120,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<SelectContent>
{Object.entries(SUPPORTED_LANGUAGES).map(([code, language]) => (
<SelectItem key={code} value={code}>
{language.full}
{_(language.full)}
</SelectItem>
))}
</SelectContent>
@@ -202,10 +197,8 @@ export const ConfigureDocumentAdvancedSettings = ({
<TooltipTrigger>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground max-w-xs">
<Trans>
Add a URL to redirect the user to once the document is signed
</Trans>
<TooltipContent className="max-w-xs text-muted-foreground">
<Trans>Add a URL to redirect the user to once the document is signed</Trans>
</TooltipContent>
</Tooltip>
</FormLabel>
@@ -250,8 +243,8 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormDescription>
<Trans>
Choose how to distribute your document to recipients. Email will send
notifications, None will generate signing links for manual distribution.
Choose how to distribute your document to recipients. Email will send notifications, None will
generate signing links for manual distribution.
</Trans>
</FormDescription>
@@ -277,7 +270,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<Input
id="subject"
className="bg-background mt-2"
className="mt-2 bg-background"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
@@ -300,7 +293,7 @@ export const ConfigureDocumentAdvancedSettings = ({
<FormControl>
<Textarea
id="message"
className="bg-background mt-2 h-32 resize-none"
className="mt-2 h-32 resize-none bg-background"
disabled={isSubmitting || !isEmailDistribution}
{...field}
/>
@@ -1,5 +1,10 @@
import { useCallback, useRef } from 'react';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import type { DropResult, SensorAPI } from '@hello-pangea/dnd';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { msg } from '@lingui/core/macro';
@@ -9,23 +14,10 @@ import { DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { motion } from 'framer-motion';
import { GripVertical, HelpCircle, Plus, Trash } from 'lucide-react';
import { nanoid } from 'nanoid';
import { useCallback, useRef } from 'react';
import type { Control } from 'react-hook-form';
import { useFieldArray, useFormContext, useFormState } from 'react-hook-form';
import { RecipientRoleSelect } from '@documenso/ui/components/recipient/recipient-role-select';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Checkbox } from '@documenso/ui/primitives/checkbox';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -37,10 +29,7 @@ export interface ConfigureDocumentRecipientsProps {
isSubmitting: boolean;
}
export const ConfigureDocumentRecipients = ({
control,
isSubmitting,
}: ConfigureDocumentRecipientsProps) => {
export const ConfigureDocumentRecipients = ({ control, isSubmitting }: ConfigureDocumentRecipientsProps) => {
const { _ } = useLingui();
const { isTemplate } = useConfigureDocument();
@@ -66,16 +55,14 @@ export const ConfigureDocumentRecipients = ({
});
const onAddSigner = useCallback(() => {
const recipientSigningOrder =
signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
const recipientSigningOrder = signers.length > 0 ? (signers[signers.length - 1]?.signingOrder || 0) + 1 : 1;
appendSigner({
formId: nanoid(8),
name: '',
email: '',
role: RecipientRole.SIGNER,
signingOrder:
signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
signingOrder: signingOrder === DocumentSigningOrder.SEQUENTIAL ? recipientSigningOrder : undefined,
});
}, [appendSigner, signers]);
@@ -116,7 +103,9 @@ export const ConfigureDocumentRecipients = ({
const onDragEnd = useCallback(
(result: DropResult) => {
if (!result.destination) return;
if (!result.destination) {
return;
}
// Use the move function from useFieldArray which preserves input values
move(result.source.index, result.destination.index);
@@ -146,11 +135,11 @@ export const ConfigureDocumentRecipients = ({
return (
<div>
<h3 className="text-foreground mb-1 text-lg font-medium">
<h3 className="mb-1 font-medium text-foreground text-lg">
<Trans>Recipients</Trans>
</h3>
<p className="text-muted-foreground mb-6 text-sm">
<p className="mb-6 text-muted-foreground text-sm">
<Trans>Add signers and configure signing preferences</Trans>
</p>
@@ -165,9 +154,7 @@ export const ConfigureDocumentRecipients = ({
id="signingOrder"
checked={field.value === DocumentSigningOrder.SEQUENTIAL}
onCheckedChange={(checked) =>
onSigningOrderChange(
checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL,
)
onSigningOrderChange(checked ? DocumentSigningOrder.SEQUENTIAL : DocumentSigningOrder.PARALLEL)
}
disabled={isSubmitting}
/>
@@ -206,15 +193,15 @@ export const ConfigureDocumentRecipients = ({
</FormLabel>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-muted-foreground ml-1 cursor-help">
<span className="ml-1 cursor-help text-muted-foreground">
<HelpCircle className="h-3.5 w-3.5" />
</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 p-4">
<p>
<Trans>
When enabled, signers can choose who should sign next in the sequence instead
of following the predefined order.
When enabled, signers can choose who should sign next in the sequence instead of following the
predefined order.
</Trans>
</p>
</TooltipContent>
@@ -249,8 +236,7 @@ export const ConfigureDocumentRecipients = ({
{...provided.dragHandleProps}
disabled={signer.disabled}
className={cn('py-1', {
'bg-widget-foreground pointer-events-none rounded-md pt-2':
snapshot.isDragging,
'pointer-events-none rounded-md bg-widget-foreground pt-2': snapshot.isDragging,
})}
>
<motion.div
@@ -265,9 +251,7 @@ export const ConfigureDocumentRecipients = ({
render={({ field }) => (
<FormItem
className={cn('flex w-16 flex-none items-center gap-x-1', {
'mb-6':
errors?.signers?.[index] &&
!errors?.signers?.[index]?.signingOrder,
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.signingOrder,
})}
>
<GripVertical className="h-5 w-5 flex-shrink-0 opacity-40" />
@@ -325,8 +309,7 @@ export const ConfigureDocumentRecipients = ({
render={({ field }) => (
<FormItem
className={cn('flex-1', {
'mb-6':
errors?.signers?.[index] && !errors?.signers?.[index]?.email,
'mb-6': errors?.signers?.[index] && !errors?.signers?.[index]?.email,
})}
>
<FormLabel className="sr-only">
@@ -374,12 +357,7 @@ export const ConfigureDocumentRecipients = ({
<Button
type="button"
variant="ghost"
disabled={
isSubmitting ||
signers.length === 1 ||
snapshot.isDragging ||
signer.disabled
}
disabled={isSubmitting || signers.length === 1 || snapshot.isDragging || signer.disabled}
onClick={() => removeSigner(index)}
>
<Trash className="h-4 w-4" />
@@ -396,14 +374,8 @@ export const ConfigureDocumentRecipients = ({
</DragDropContext>
<div className="mt-4 flex justify-end">
<Button
type="button"
variant="outline"
className="w-auto"
disabled={isSubmitting}
onClick={onAddSigner}
>
<Plus className="-ml-1 mr-2 h-5 w-5" />
<Button type="button" variant="outline" className="w-auto" disabled={isSubmitting} onClick={onAddSigner}>
<Plus className="mr-2 -ml-1 h-5 w-5" />
<Trans>Add Signer</Trans>
</Button>
</div>
@@ -1,24 +1,17 @@
import { useState } from 'react';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { buildDropzoneRejectionDescription } from '@documenso/ui/lib/handle-dropzone-rejection';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Cloud, FileText, Loader, X } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { useState } from 'react';
import { type FileRejection, useDropzone } from 'react-dropzone';
import { useFormContext } from 'react-hook-form';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useConfigureDocument } from './configure-document-context';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
@@ -82,10 +75,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
}
};
const onDropRejected = () => {
const onDropRejected = (fileRejections: FileRejection[]) => {
toast({
title: _(msg`Your document failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
description: _(buildDropzoneRejectionDescription(fileRejections)),
duration: 5000,
variant: 'destructive',
});
@@ -106,11 +99,13 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
if (bytes === 0) {
return '0 Bytes';
}
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${parseFloat((bytes / Math.pow(1024, i)).toFixed(2))} ${sizes[i]}`;
return `${parseFloat((bytes / 1024 ** i).toFixed(2))} ${sizes[i]}`;
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -144,11 +139,10 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
<div
{...getRootProps()}
className={cn(
'border-border bg-background relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed transition',
'relative flex min-h-[160px] cursor-pointer flex-col items-center justify-center rounded-lg border border-border border-dashed bg-background transition',
{
'border-primary/50 bg-primary/5': isDragActive,
'hover:bg-muted/30':
!isDragActive && !isSubmitting && !isLoading && !isPersisted,
'hover:bg-muted/30': !isDragActive && !isSubmitting && !isLoading && !isPersisted,
'cursor-not-allowed opacity-60': isSubmitting || isLoading || isPersisted,
},
)}
@@ -169,7 +163,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
'text-muted-foreground': !isDragActive,
})}
>
<p className="text-sm font-medium">
<p className="font-medium text-sm">
{isDragActive ? (
<Trans>Drop your document here</Trans>
) : isPersisted ? (
@@ -182,9 +176,7 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
{isPersisted ? (
<Trans>This document cannot be changed</Trans>
) : (
<Trans>
.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)
</Trans>
<Trans>.PDF documents accepted (max {APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB)</Trans>
)}
</p>
</div>
@@ -193,23 +185,21 @@ export const ConfigureDocumentUpload = ({ isSubmitting = false }: ConfigureDocum
</FormControl>
{isLoading && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
<Loader className="text-muted-foreground h-10 w-10 animate-spin" />
<div className="absolute inset-0 flex items-center justify-center rounded-lg bg-background/50">
<Loader className="h-10 w-10 animate-spin text-muted-foreground" />
</div>
)}
</div>
) : (
<div className="mt-2 rounded-lg border p-4">
<div className="flex items-center gap-x-4">
<div className="bg-primary/10 text-primary flex h-12 w-12 items-center justify-center rounded-md">
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-primary/10 text-primary">
<FileText className="h-6 w-6" />
</div>
<div className="flex-1">
<div className="text-sm font-medium">{documentData.name}</div>
<div className="text-muted-foreground text-xs">
{formatFileSize(documentData.size)}
</div>
<div className="font-medium text-sm">{documentData.name}</div>
<div className="text-muted-foreground text-xs">{formatFileSize(documentData.size)}</div>
</div>
{!isPersisted && (
@@ -1,23 +1,15 @@
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { Button } from '@documenso/ui/primitives/button';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans } from '@lingui/react/macro';
import { DocumentDistributionMethod, DocumentSigningOrder, RecipientRole } from '@prisma/client';
import { nanoid } from 'nanoid';
import { useForm } from 'react-hook-form';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { ConfigureDocumentAdvancedSettings } from './configure-document-advanced-settings';
import { useConfigureDocument } from './configure-document-context';
import { ConfigureDocumentRecipients } from './configure-document-recipients';
@@ -45,9 +37,7 @@ export const ConfigureDocumentView = ({
const { isTemplate } = useConfigureDocument();
const form = useForm<TConfigureEmbedFormSchema>({
resolver: zodResolver(
type === 'template' ? ZConfigureTemplateEmbedFormSchema : ZConfigureEmbedFormSchema,
),
resolver: zodResolver(type === 'template' ? ZConfigureTemplateEmbedFormSchema : ZConfigureEmbedFormSchema),
defaultValues: {
title: defaultValues?.title || '',
signers: defaultValues?.signers || [
@@ -63,8 +53,7 @@ export const ConfigureDocumentView = ({
meta: {
subject: defaultValues?.meta?.subject || '',
message: defaultValues?.meta?.message || '',
distributionMethod:
defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
distributionMethod: defaultValues?.meta?.distributionMethod || DocumentDistributionMethod.EMAIL,
emailSettings: defaultValues?.meta?.emailSettings || ZDocumentEmailSettingsSchema.parse({}),
dateFormat: defaultValues?.meta?.dateFormat || DEFAULT_DOCUMENT_DATE_FORMAT,
timezone: defaultValues?.meta?.timezone || DEFAULT_DOCUMENT_TIME_ZONE,
@@ -88,7 +77,7 @@ export const ConfigureDocumentView = ({
return (
<div className="flex w-full flex-col space-y-8">
<div>
<h2 className="text-foreground mb-1 text-xl font-semibold">
<h2 className="mb-1 font-semibold text-foreground text-xl">
{isTemplate ? <Trans>Configure Template</Trans> : <Trans>Configure Document</Trans>}
</h2>
@@ -126,12 +115,7 @@ export const ConfigureDocumentView = ({
<ConfigureDocumentAdvancedSettings control={control} isSubmitting={isSubmitting} />
<div className="flex justify-end">
<Button
type="button"
onClick={onFormSubmit}
disabled={isSubmitting}
className="w-full sm:w-auto"
>
<Button type="button" onClick={onFormSubmit} disabled={isSubmitting} className="w-full sm:w-auto">
<Trans>Continue</Trans>
</Button>
</div>
@@ -1,12 +1,9 @@
import { z } from 'zod';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import {
ZDocumentMetaDateFormatSchema,
ZDocumentMetaLanguageSchema,
} from '@documenso/lib/types/document-meta';
import { ZDocumentMetaDateFormatSchema, ZDocumentMetaLanguageSchema } from '@documenso/lib/types/document-meta';
import { ZRecipientEmailSchema } from '@documenso/lib/types/recipient';
import { zEmail } from '@documenso/lib/utils/zod';
import { DocumentDistributionMethod } from '@documenso/prisma/generated/types';
import { z } from 'zod';
// Define the schema for configuration
export type TConfigureEmbedFormSchema = z.infer<typeof ZConfigureEmbedFormSchema>;
@@ -19,7 +16,7 @@ export const ZConfigureEmbedFormSchema = z.object({
nativeId: z.number().optional(),
formId: z.string(),
name: z.string(),
email: z.string().email('Invalid email address'),
email: zEmail('Invalid email address'),
role: z.enum(['SIGNER', 'CC', 'APPROVER', 'VIEWER', 'ASSISTANT']),
signingOrder: z.number().optional(),
disabled: z.boolean().optional(),
@@ -1,22 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, type Recipient, SendStatus, SigningStatus } from '@prisma/client';
import { base64 } from '@scure/base';
import { ChevronsUpDown } from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { getPdfPagesCount, PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { type TFieldMetaSchema, ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { TRecipientLite } from '@documenso/lib/types/recipient';
import { nanoid } from '@documenso/lib/universal/id';
import { ADVANCED_FIELD_TYPES_WITH_OPTIONAL_SETTING } from '@documenso/lib/utils/advanced-fields-helpers';
import { useRecipientColors } from '@documenso/ui/lib/recipient-colors';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { getRecipientColorStyles } from '@documenso/ui/lib/recipient-colors';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { FieldItem } from '@documenso/ui/primitives/document-flow/field-item';
@@ -24,14 +14,24 @@ import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/type
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { FieldSelector } from '@documenso/ui/primitives/field-selector';
import { Form } from '@documenso/ui/primitives/form/form';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
import { Sheet, SheetContent, SheetTrigger } from '@documenso/ui/primitives/sheet';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { EnvelopeItem, FieldType } from '@prisma/client';
import { ReadStatus, SendStatus, SigningStatus } from '@prisma/client';
import { ChevronsUpDown } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { FieldAdvancedSettingsDrawer } from '~/components/embed/authoring/field-advanced-settings-drawer';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import type { TConfigureEmbedFormSchema } from './configure-document-view.types';
import type { TConfigureFieldsFormSchema } from './configure-fields-view.types';
import { FieldAdvancedSettingsDrawer } from './field-advanced-settings-drawer';
const MIN_HEIGHT_PX = 12;
const MIN_WIDTH_PX = 36;
@@ -42,7 +42,7 @@ const DEFAULT_WIDTH_PX = MIN_WIDTH_PX * 2.5;
export type ConfigureFieldsViewProps = {
configData: TConfigureEmbedFormSchema;
presignToken?: string | undefined;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId'>;
envelopeItem?: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>;
defaultValues?: Partial<TConfigureFieldsFormSchema>;
onBack?: (data: TConfigureFieldsFormSchema) => void;
onSubmit: (data: TConfigureFieldsFormSchema) => void;
@@ -86,26 +86,25 @@ export const ConfigureFieldsView = ({
const normalizedDocumentData = useMemo(() => {
if (envelopeItem) {
return undefined;
return getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItem.envelopeId,
envelopeItemId: envelopeItem.id,
documentDataId: envelopeItem.documentDataId,
version: 'current',
token: undefined,
presignToken,
});
}
if (!configData.documentData) {
return undefined;
}
return base64.encode(configData.documentData.data);
}, [configData.documentData]);
const normalizedEnvelopeItem = useMemo(() => {
if (envelopeItem) {
return envelopeItem;
}
return { id: '', envelopeId: '' };
}, [envelopeItem]);
return configData.documentData.data;
}, [configData.documentData, envelopeItem, presignToken]);
const recipients = useMemo(() => {
return configData.signers.map<Recipient>((signer, index) => ({
return configData.signers.map<TRecipientLite>((signer, index) => ({
id: signer.nativeId || index,
name: signer.name || '',
email: signer.email || '',
@@ -115,7 +114,9 @@ export const ConfigureFieldsView = ({
templateId: null,
token: '',
documentDeletedAt: null,
expired: null,
expired: null, // !: deprecated Not in use. To be removed in a future migration.
expiresAt: null,
expirationNotifiedAt: null,
signedAt: null,
authOptions: null,
rejectionReason: null,
@@ -126,7 +127,7 @@ export const ConfigureFieldsView = ({
}));
}, [configData.signers]);
const [selectedRecipient, setSelectedRecipient] = useState<Recipient | null>(
const [selectedRecipient, setSelectedRecipient] = useState<TRecipientLite | null>(
() => recipients.find((r) => r.signingStatus === SigningStatus.NOT_SIGNED) || null,
);
const [selectedField, setSelectedField] = useState<FieldType | null>(null);
@@ -136,16 +137,10 @@ export const ConfigureFieldsView = ({
y: 0,
});
const [activeFieldId, setActiveFieldId] = useState<string | null>(null);
const [lastActiveField, setLastActiveField] = useState<
TConfigureFieldsFormSchema['fields'][0] | null
>(null);
const [fieldClipboard, setFieldClipboard] = useState<
TConfigureFieldsFormSchema['fields'][0] | null
>(null);
const [lastActiveField, setLastActiveField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(null);
const [fieldClipboard, setFieldClipboard] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(null);
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(
null,
);
const [currentField, setCurrentField] = useState<TConfigureFieldsFormSchema['fields'][0] | null>(null);
const fieldBounds = useRef({
height: DEFAULT_HEIGHT_PX,
@@ -153,9 +148,7 @@ export const ConfigureFieldsView = ({
});
const selectedRecipientIndex = recipients.findIndex((r) => r.id === selectedRecipient?.id);
const selectedRecipientStyles = useRecipientColors(
selectedRecipientIndex === -1 ? 0 : selectedRecipientIndex,
);
const selectedRecipientStyles = getRecipientColorStyles(selectedRecipientIndex);
const form = useForm<TConfigureFieldsFormSchema>({
defaultValues: {
@@ -177,8 +170,6 @@ export const ConfigureFieldsView = ({
name: 'fields',
});
const highestPageNumber = Math.max(...localFields.map((field) => field.pageNumber));
const onFieldCopy = useCallback(
(event?: KeyboardEvent | null, options?: { duplicate?: boolean; duplicateAll?: boolean }) => {
const { duplicate = false, duplicateAll = false } = options ?? {};
@@ -203,13 +194,15 @@ export const ConfigureFieldsView = ({
}
if (duplicateAll) {
const pages = Array.from(document.querySelectorAll(PDF_VIEWER_PAGE_SELECTOR));
const totalPages = getPdfPagesCount();
pages.forEach((_, index) => {
const pageNumber = index + 1;
if (totalPages < 1) {
return;
}
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
if (pageNumber === lastActiveField.pageNumber) {
return;
continue;
}
const newField: TConfigureFieldsFormSchema['fields'][0] = {
@@ -222,7 +215,7 @@ export const ConfigureFieldsView = ({
};
append(newField);
});
}
return;
}
@@ -265,15 +258,12 @@ export const ConfigureFieldsView = ({
const onMouseMove = useCallback(
(event: MouseEvent) => {
if (!selectedField) return;
if (!selectedField) {
return;
}
setIsFieldWithinBounds(
isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
),
isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height),
);
setCoords({
@@ -294,12 +284,7 @@ export const ConfigureFieldsView = ({
if (
!$page ||
!isWithinPageBounds(
event,
PDF_VIEWER_PAGE_SELECTOR,
fieldBounds.current.width,
fieldBounds.current.height,
)
!isWithinPageBounds(event, PDF_VIEWER_PAGE_SELECTOR, fieldBounds.current.width, fieldBounds.current.height)
) {
return;
}
@@ -358,12 +343,7 @@ export const ConfigureFieldsView = ({
return;
}
const {
x: pageX,
y: pageY,
width: pageWidth,
height: pageHeight,
} = getFieldPosition($page, node);
const { x: pageX, y: pageY, width: pageWidth, height: pageHeight } = getFieldPosition($page, node);
update(index, {
...field,
@@ -465,11 +445,11 @@ export const ConfigureFieldsView = ({
{!isMobile && (
<div className="order-2 col-span-12 md:order-1 md:col-span-4">
<div className="sticky top-4 max-h-[calc(100vh-2rem)] rounded-lg border border-border bg-widget p-4 pb-6">
<h2 className="mb-1 text-lg font-medium">
<h2 className="mb-1 font-medium text-lg">
<Trans>Configure Fields</Trans>
</h2>
<p className="mb-6 text-sm text-muted-foreground">
<p className="mb-6 text-muted-foreground text-sm">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
@@ -523,7 +503,7 @@ export const ConfigureFieldsView = ({
{selectedField && (
<div
className={cn(
'dark:text-muted-background pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size]',
'pointer-events-none fixed z-50 flex cursor-pointer flex-col items-center justify-center bg-white text-muted-foreground transition duration-200 [container-type:size] dark:text-muted-background',
selectedRecipientStyles.base,
{
'-rotate-6 scale-90 opacity-50 dark:bg-black/20': !isFieldWithinBounds,
@@ -538,25 +518,15 @@ export const ConfigureFieldsView = ({
width: fieldBounds.current.width,
}}
>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">
{_(FRIENDLY_FIELD_TYPE[selectedField])}
</span>
<span className="text-[clamp(0.425rem,25cqw,0.825rem)]">{_(FRIENDLY_FIELD_TYPE[selectedField])}</span>
</div>
)}
<Form {...form}>
<div>
<PDFViewerLazy
presignToken={presignToken}
overrideData={normalizedDocumentData}
envelopeItem={normalizedEnvelopeItem}
token={undefined}
version="signed"
/>
{normalizedDocumentData && <PDFViewerLazy data={normalizedDocumentData} scrollParentRef="window" />}
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}
>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{localFields.map((field, index) => {
const recipientIndex = recipients.findIndex((r) => r.id === field.recipientId);
@@ -598,8 +568,8 @@ export const ConfigureFieldsView = ({
{isMobile && (
<Sheet open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<SheetTrigger asChild>
<div className="fixed bottom-6 left-6 right-6 z-50 flex items-center justify-between gap-2 rounded-lg border border-border bg-widget p-4">
<span className="text-lg font-medium">
<div className="fixed right-6 bottom-6 left-6 z-50 flex items-center justify-between gap-2 rounded-lg border border-border bg-widget p-4">
<span className="font-medium text-lg">
<Trans>Configure Fields</Trans>
</span>
@@ -617,11 +587,11 @@ export const ConfigureFieldsView = ({
size="xl"
className="h-fit max-h-[80vh] overflow-y-auto rounded-t-xl bg-widget p-4"
>
<h2 className="mb-1 text-lg font-medium">
<h2 className="mb-1 font-medium text-lg">
<Trans>Configure Fields</Trans>
</h2>
<p className="mb-6 text-sm text-muted-foreground">
<p className="mb-6 text-muted-foreground text-sm">
<Trans>Configure the fields you want to place on the document.</Trans>
</p>
@@ -1,7 +1,6 @@
import { z } from 'zod';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { FieldType } from '@documenso/prisma/client';
import { z } from 'zod';
export const ZConfigureFieldsFormSchema = z.object({
fields: z.array(
@@ -24,6 +23,4 @@ export const ZConfigureFieldsFormSchema = z.object({
export type TConfigureFieldsFormSchema = z.infer<typeof ZConfigureFieldsFormSchema>;
export type TConfigureFieldsFormSchemaField = z.infer<
typeof ZConfigureFieldsFormSchema
>['fields'][number];
export type TConfigureFieldsFormSchemaField = z.infer<typeof ZConfigureFieldsFormSchema>['fields'][number];
@@ -1,11 +1,10 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { type TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import type { TFieldMetaSchema as FieldMeta } from '@documenso/lib/types/field-meta';
import { parseMessageDescriptor } from '@documenso/lib/utils/i18n';
import { FieldAdvancedSettings } from '@documenso/ui/primitives/document-flow/field-item-advanced-settings';
import { FRIENDLY_FIELD_TYPE } from '@documenso/ui/primitives/document-flow/types';
import { Sheet, SheetContent, SheetTitle } from '@documenso/ui/primitives/sheet';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import type { TConfigureFieldsFormSchemaField } from './configure-fields-view.types';
@@ -42,10 +41,7 @@ export const FieldAdvancedSettingsDrawer = ({
<FieldAdvancedSettings
title={msg`Advanced settings`}
description={msg`Configure the ${parseMessageDescriptor(
_,
FRIENDLY_FIELD_TYPE[currentField.type],
)} field`}
description={msg`Configure the ${parseMessageDescriptor(_, FRIENDLY_FIELD_TYPE[currentField.type])} field`}
field={currentField}
fields={fields}
onAdvancedSettings={() => onOpenChange(false)}
@@ -1,6 +1,5 @@
import { Trans } from '@lingui/react/macro';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
import { Trans } from '@lingui/react/macro';
import { SignInForm } from '~/components/forms/signin';
import { BrandingLogo } from '~/components/general/branding-logo';
@@ -29,10 +28,7 @@ export const EmbedAuthenticationRequired = ({
<Alert className="mt-8" variant="warning">
<AlertDescription>
<Trans>
To view this document you need to be signed into your account, please sign in to
continue.
</Trans>
<Trans>To view this document you need to be signed into your account, please sign in to continue.</Trans>
</AlertDescription>
</Alert>
@@ -1,11 +1,14 @@
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
export const EmbedClientLoading = () => {
return (
<div className="bg-background fixed left-0 top-0 z-[9999] flex h-full w-full items-center justify-center">
<div className="fixed top-0 left-0 z-[9999] flex h-full w-full items-center justify-center bg-background">
<Loader className="mr-2 h-4 w-4 animate-spin" />
<span>Loading...</span>
<span>
<Trans>Loading...</Trans>
</span>
</div>
);
};
@@ -1,5 +1,28 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import { ZDirectTemplateEmbedDataSchema } from '@documenso/lib/types/embed-direct-template-schema';
import { isFieldUnsignedAndRequired, isRequiredField } from '@documenso/lib/utils/advanced-fields-helpers';
import { getDocumentDataUrlForPdfViewer } from '@documenso/lib/utils/envelope-download';
import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields';
import { dynamicActivate } from '@documenso/lib/utils/i18n';
import { zEmail } from '@documenso/lib/utils/zod';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
@@ -13,34 +36,11 @@ import {
} from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
import { useEffect, useLayoutEffect, useState } from 'react';
import { useSearchParams } from 'react-router';
import { useThrottleFn } from '@documenso/lib/client-only/hooks/use-throttle-fn';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
import {
isFieldUnsignedAndRequired,
isRequiredField,
} from '@documenso/lib/utils/advanced-fields-helpers';
import { validateFieldsInserted } from '@documenso/lib/utils/fields';
import { isSignatureFieldType } from '@documenso/prisma/guards/is-signature-field';
import { trpc } from '@documenso/trpc/react';
import type {
TRemovedSignedFieldWithTokenMutationSchema,
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { Button } from '@documenso/ui/primitives/button';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
import { PDFViewerLazy } from '@documenso/ui/primitives/pdf-viewer/lazy';
import { SignaturePadDialog } from '@documenso/ui/primitives/signature-pad/signature-pad-dialog';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { BrandingLogo } from '~/components/general/branding-logo';
import { ZDirectTemplateEmbedDataSchema } from '~/types/embed-direct-template-schema';
import PDFViewerLazy from '~/components/general/pdf-viewer/pdf-viewer-lazy';
import { injectCss } from '~/utils/css-vars';
import type { DirectTemplateLocalField } from '../general/direct-template/direct-template-signing-form';
@@ -54,7 +54,7 @@ export type EmbedDirectTemplateClientPageProps = {
token: string;
envelopeId: string;
updatedAt: Date;
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId'>[];
envelopeItems: Pick<EnvelopeItem, 'id' | 'envelopeId' | 'documentDataId'>[];
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | null;
@@ -78,8 +78,7 @@ export const EmbedDirectTemplateClientPage = ({
const [searchParams] = useSearchParams();
const { fullName, email, signature, setFullName, setEmail, setSignature } =
useRequiredDocumentSigningContext();
const { fullName, email, signature, setFullName, setEmail, setSignature } = useRequiredDocumentSigningContext();
const [hasFinishedInit, setHasFinishedInit] = useState(false);
const [hasDocumentLoaded, setHasDocumentLoaded] = useState(false);
@@ -91,18 +90,17 @@ export const EmbedDirectTemplateClientPage = ({
const [isNameLocked, setIsNameLocked] = useState(false);
const [showPendingFieldTooltip, setShowPendingFieldTooltip] = useState(false);
const [emailError, setEmailError] = useState<string | null>(null);
const [throttledOnCompleteClick, isThrottled] = useThrottleFn(() => void onCompleteClick(), 500);
const [localFields, setLocalFields] = useState<DirectTemplateLocalField[]>(() => fields);
const [pendingFields, _completedFields] = [
localFields.filter((field) => isFieldUnsignedAndRequired(field)),
sortFieldsByPosition(localFields.filter((field) => isFieldUnsignedAndRequired(field))),
localFields.filter((field) => field.inserted),
];
const highestPendingPageNumber = Math.max(...pendingFields.map((field) => field.page));
const hasSignatureField = localFields.some((field) => isSignatureFieldType(field.type));
const signatureValid = !hasSignatureField || (signature && signature.trim() !== '');
@@ -130,10 +128,8 @@ export const EmbedDirectTemplateClientPage = ({
created: new Date(),
recipientId: 1,
fieldId: 1,
signatureImageAsBase64:
payload.value && payload.value.startsWith('data:') ? payload.value : null,
typedSignature:
payload.value && !payload.value.startsWith('data:') ? payload.value : null,
signatureImageAsBase64: payload.value && payload.value.startsWith('data:') ? payload.value : null,
typedSignature: payload.value && !payload.value.startsWith('data:') ? payload.value : null,
} satisfies Signature;
}
@@ -206,6 +202,14 @@ export const EmbedDirectTemplateClientPage = ({
return;
}
const { success: isEmailValid } = zEmail().safeParse(email);
if (!isEmailValid) {
setEmailError(_(msg`A valid email is required`));
setIsExpanded(true);
return;
}
let directTemplateExternalId = searchParams?.get('externalId') || undefined;
if (directTemplateExternalId) {
@@ -257,9 +261,7 @@ export const EmbedDirectTemplateClientPage = ({
toast({
title: _(msg`Something went wrong`),
description: _(
msg`We were unable to submit this document at this time. Please try again later.`,
),
description: _(msg`We were unable to submit this document at this time. Please try again later.`),
variant: 'destructive',
});
}
@@ -291,12 +293,19 @@ export const EmbedDirectTemplateClientPage = ({
cssVars: data.cssVars,
});
}
if (data.language && data.language !== APP_I18N_OPTIONS.sourceLang) {
void dynamicActivate(data.language).finally(() => {
setHasFinishedInit(true);
});
} else {
setHasFinishedInit(true);
}
} catch (err) {
console.error(err);
setHasFinishedInit(true);
}
setHasFinishedInit(true);
// !: While the two setters are stable we still want to ensure we're avoiding
// !: re-renders.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -342,9 +351,15 @@ export const EmbedDirectTemplateClientPage = ({
{/* Viewer */}
<div className="flex-1">
<PDFViewerLazy
envelopeItem={envelopeItems[0]}
token={recipient.token}
version="signed"
data={getDocumentDataUrlForPdfViewer({
envelopeId: envelopeItems[0]?.envelopeId,
envelopeItemId: envelopeItems[0]?.id,
documentDataId: envelopeItems[0]?.documentDataId,
version: 'current',
token: recipient.token,
presignToken: undefined,
})}
scrollParentRef="window"
onDocumentLoad={() => setHasDocumentLoaded(true)}
/>
</div>
@@ -352,31 +367,23 @@ export const EmbedDirectTemplateClientPage = ({
{/* Widget */}
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
className="group/document-widget fixed bottom-8 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-6 md:sticky md:top-4 md:bottom-[unset] md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="flex h-fit w-full flex-col rounded-xl border border-border bg-widget px-4 py-4 md:min-h-[min(calc(100dvh-2rem),48rem)] md:py-6">
{/* Header */}
<div>
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-xl font-semibold text-foreground md:text-2xl">
<h3 className="font-semibold text-foreground text-xl md:text-2xl">
<Trans>Sign document</Trans>
</h3>
{isExpanded ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(false)}
>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden" onClick={() => setIsExpanded(false)}>
<LucideChevronDown className="h-5 w-5 text-muted-foreground" />
</Button>
) : pendingFields.length > 0 ? (
<Button
variant="outline"
className="h-8 w-8 p-0 md:hidden"
onClick={() => setIsExpanded(true)}
>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden" onClick={() => setIsExpanded(true)}>
<LucideChevronUp className="h-5 w-5 text-muted-foreground" />
</Button>
) : (
@@ -395,11 +402,11 @@ export const EmbedDirectTemplateClientPage = ({
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="mt-2 text-sm text-muted-foreground">
<p className="mt-2 text-muted-foreground text-sm">
<Trans>Sign the document to complete the process.</Trans>
</p>
<hr className="mb-8 mt-4 border-border" />
<hr className="mt-4 mb-8 border-border" />
</div>
{/* Form */}
@@ -428,11 +435,18 @@ export const EmbedDirectTemplateClientPage = ({
<Input
type="email"
id="email"
className="mt-2 bg-background"
className={cn('mt-2 bg-background', emailError && 'border-destructive ring-2 ring-destructive/20')}
disabled={isEmailLocked}
value={email}
onChange={(e) => !isEmailLocked && setEmail(e.target.value.trim())}
onChange={(e) => {
if (!isEmailLocked) {
setEmail(e.target.value.trim());
setEmailError(null);
}
}}
/>
{emailError && <p className="mt-2 font-medium text-destructive text-xs">{emailError}</p>}
</div>
{hasSignatureField && (
@@ -478,15 +492,13 @@ export const EmbedDirectTemplateClientPage = ({
</div>
</div>
<ElementVisible
target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPendingPageNumber}"]`}
>
{showPendingFieldTooltip && pendingFields.length > 0 && (
{showPendingFieldTooltip && pendingFields.length > 0 && (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${pendingFields[0].page}"]`}>
<FieldToolTip key={pendingFields[0].id} field={pendingFields[0]} color="warning">
<Trans>Click to insert field</Trans>
</FieldToolTip>
)}
</ElementVisible>
</ElementVisible>
)}
{/* Fields */}
<EmbedDocumentFields
@@ -498,8 +510,10 @@ export const EmbedDirectTemplateClientPage = ({
</div>
{!hidePoweredBy && (
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 text-xs font-medium text-primary-foreground opacity-60 hover:opacity-100">
<span>Powered by</span>
<div className="fixed bottom-0 left-0 z-40 rounded-tr bg-primary px-2 py-1 font-medium text-primary-foreground text-xs opacity-60 hover:opacity-100">
<span>
<Trans>Powered by</Trans>
</span>
<BrandingLogo className="ml-2 inline-block h-[14px]" />
</div>
)}
@@ -1,8 +1,7 @@
import { Trans } from '@lingui/react/macro';
import type { Signature } from '@prisma/client';
import signingCelebration from '@documenso/assets/images/signing-celebration.png';
import { SigningCard3D } from '@documenso/ui/components/signing-card';
import { Trans } from '@lingui/react/macro';
import type { Signature } from '@prisma/client';
export type EmbedDocumentCompletedPageProps = {
name?: string;
@@ -12,7 +11,7 @@ export type EmbedDocumentCompletedPageProps = {
export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentCompletedPageProps) => {
return (
<div className="embed--DocumentCompleted relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<h3 className="text-foreground text-2xl font-semibold">
<h3 className="font-semibold text-2xl text-foreground">
<Trans>Document Completed!</Trans>
</h3>
@@ -25,10 +24,9 @@ export const EmbedDocumentCompleted = ({ name, signature }: EmbedDocumentComplet
/>
</div>
<p className="text-muted-foreground mt-8 max-w-[50ch] text-center text-sm">
<p className="mt-8 max-w-[50ch] text-center text-muted-foreground text-sm">
<Trans>
The document is now completed, please follow any instructions provided within the parent
application.
The document is now completed, please follow any instructions provided within the parent application.
</Trans>
</p>
</div>
@@ -1,7 +1,3 @@
import type { DocumentMeta } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones';
@@ -18,6 +14,9 @@ import type {
TSignFieldWithTokenMutationSchema,
} from '@documenso/trpc/server/field-router/schema';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import type { DocumentMeta } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
import { DocumentSigningCheckboxField } from '~/components/general/document-signing/document-signing-checkbox-field';
import { DocumentSigningDateField } from '~/components/general/document-signing/document-signing-date-field';
@@ -34,26 +33,15 @@ export type EmbedDocumentFieldsProps = {
fields: Field[];
metadata?: Pick<
DocumentMeta,
| 'timezone'
| 'dateFormat'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
'timezone' | 'dateFormat' | 'typedSignatureEnabled' | 'uploadSignatureEnabled' | 'drawSignatureEnabled'
> | null;
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
};
export const EmbedDocumentFields = ({
fields,
metadata,
onSignField,
onUnsignField,
}: EmbedDocumentFieldsProps) => {
const highestPageNumber = Math.max(...fields.map((field) => field.page));
export const EmbedDocumentFields = ({ fields, metadata, onSignField, onUnsignField }: EmbedDocumentFieldsProps) => {
return (
<ElementVisible target={`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${highestPageNumber}"]`}>
<ElementVisible target={PDF_VIEWER_PAGE_SELECTOR}>
{fields.map((field) =>
match(field.type)
.with(FieldType.SIGNATURE, () => (
@@ -6,25 +6,25 @@ export const EmbedDocumentRejected = () => {
<div className="embed--DocumentRejected relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
<div className="flex flex-col items-center">
<div className="flex items-center gap-x-4">
<XCircle className="text-destructive h-10 w-10" />
<XCircle className="h-10 w-10 text-destructive" />
<h2 className="max-w-[35ch] text-center text-2xl font-semibold leading-normal md:text-3xl lg:text-4xl">
<h2 className="max-w-[35ch] text-center font-semibold text-2xl leading-normal md:text-3xl lg:text-4xl">
<Trans>Document Rejected</Trans>
</h2>
</div>
<div className="text-destructive mt-4 flex items-center text-center text-sm">
<div className="mt-4 flex items-center text-center text-destructive text-sm">
<Trans>You have rejected this document</Trans>
</div>
<p className="text-muted-foreground mt-6 max-w-[60ch] text-center text-sm">
<p className="mt-6 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>
The document owner has been notified of your decision. They may contact you with further
instructions if necessary.
The document owner has been notified of your decision. They may contact you with further instructions if
necessary.
</Trans>
</p>
<p className="text-muted-foreground mt-2 max-w-[60ch] text-center text-sm">
<p className="mt-2 max-w-[60ch] text-center text-muted-foreground text-sm">
<Trans>No further action is required from you at this time.</Trans>
</p>
</div>

Some files were not shown because too many files have changed in this diff Show More