mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 12:22:14 +10:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0248c20eb | |||
| f129968968 | |||
| c5c87e3fd1 | |||
| 24a74c7b57 | |||
| f0a5a7e816 | |||
| 8462cd13fd | |||
| 576846de32 | |||
| 06071ea035 | |||
| b45a2691ba | |||
| f31cc575d0 | |||
| 05d7015ef0 | |||
| 2ca5d6cfaa | |||
| 04814ca14e | |||
| dd1dccdb6a | |||
| df4316ac5c | |||
| 02f1264eea | |||
| 928edb8645 | |||
| 54b0e4964e | |||
| 68e6ccdd19 | |||
| 09ab7e9a09 | |||
| 3bb0777914 | |||
| 4d6389e901 | |||
| 51e3d5030d | |||
| 0cebdec637 | |||
| 43486d8448 | |||
| 4d3d1b8d14 | |||
| 0387f3c20a | |||
| c5032d0c43 | |||
| 3bd34964cd | |||
| fe93b11a2c | |||
| 7638faf27b | |||
| 8fca029d96 |
@@ -15,7 +15,7 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"next": "^15.5.7",
|
||||
"next": "15.5.9",
|
||||
"next-plausible": "^3.12.5",
|
||||
"nextra": "^3",
|
||||
"nextra-theme-docs": "^3",
|
||||
@@ -29,4 +29,4 @@
|
||||
"pagefind": "^1.2.0",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ The dropdown/select field collects a single choice from a list of options.
|
||||
|
||||
Place the dropdown/select field on the document where you want the signer to select a choice. The dropdown/select field comes with additional settings that can be configured.
|
||||
|
||||
{/*  */}
|
||||

|
||||
|
||||
The dropdown/select field settings include:
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@@ -12,11 +12,11 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "^15.5.7"
|
||||
"next": "15.5.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
"@types/react": "18.3.27",
|
||||
"typescript": "5.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,8 +156,8 @@ 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,141 @@
|
||||
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 { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type AiFeaturesEnableDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onEnabled: () => void;
|
||||
};
|
||||
|
||||
export const AiFeaturesEnableDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onEnabled,
|
||||
}: AiFeaturesEnableDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isTeamAdmin = team.currentTeamRole === TeamMemberRole.ADMIN;
|
||||
const isOrganisationAdmin = organisation.currentOrganisationRole === OrganisationMemberRole.ADMIN;
|
||||
const canEnableAiFeatures = isTeamAdmin || isOrganisationAdmin;
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { mutateAsync: updateTeamSettings, isPending: isUpdatingTeamSettings } =
|
||||
trpc.team.settings.update.useMutation();
|
||||
const { mutateAsync: updateOrganisationSettings, isPending: isUpdatingOrganisationSettings } =
|
||||
trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const isSubmitting = isUpdatingTeamSettings || isUpdatingOrganisationSettings;
|
||||
|
||||
const onEnableClick = async () => {
|
||||
if (!canEnableAiFeatures) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (isTeamAdmin) {
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: { aiFeaturesEnabled: true },
|
||||
});
|
||||
} else {
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: { aiFeaturesEnabled: true },
|
||||
});
|
||||
}
|
||||
|
||||
onEnabled();
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Enable AI features</Trans>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
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.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{canEnableAiFeatures ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<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.
|
||||
</Trans>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
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}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
|
||||
{canEnableAiFeatures ? (
|
||||
<Button type="button" onClick={() => void onEnableClick()} loading={isSubmitting}>
|
||||
<Trans>Enable AI features</Trans>
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Plural, Trans } from '@lingui/react/macro';
|
||||
import { CheckIcon, FormInputIcon, ShieldCheckIcon } from 'lucide-react';
|
||||
|
||||
import type { NormalizedFieldWithContext } from '@documenso/lib/server-only/ai/envelope/detect-fields/types';
|
||||
@@ -232,10 +232,19 @@ export const AiFieldDetectionDialog = ({
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.fieldsDetected} field(s) found
|
||||
</Trans>
|
||||
<Plural
|
||||
value={progress.fieldsDetected}
|
||||
one={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # field found
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # fields found
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -279,7 +288,11 @@ export const AiFieldDetectionDialog = ({
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>We found {detectedFields.length} field(s) in your document.</Trans>
|
||||
<Plural
|
||||
value={detectedFields.length}
|
||||
one="We found # field in your document."
|
||||
other="We found # fields in your document."
|
||||
/>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
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';
|
||||
@@ -190,10 +190,19 @@ export const AiRecipientDetectionDialog = ({
|
||||
|
||||
{progress && (
|
||||
<p className="mt-2 text-xs text-muted-foreground/60">
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} -{' '}
|
||||
{progress.recipientsDetected} recipient(s) found
|
||||
</Trans>
|
||||
<Plural
|
||||
value={progress.recipientsDetected}
|
||||
one={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # recipient found
|
||||
</Trans>
|
||||
}
|
||||
other={
|
||||
<Trans>
|
||||
Page {progress.pagesProcessed} of {progress.totalPages} - # recipients found
|
||||
</Trans>
|
||||
}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -237,9 +246,11 @@ export const AiRecipientDetectionDialog = ({
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>
|
||||
We found {detectedRecipients.length} recipient(s) in your document.
|
||||
</Trans>
|
||||
<Plural
|
||||
value={detectedRecipients.length}
|
||||
one="We found # recipient in your document."
|
||||
other="We found # recipients in your document."
|
||||
/>
|
||||
</p>
|
||||
|
||||
<ul className="mt-4 divide-y rounded-lg border">
|
||||
|
||||
@@ -353,8 +353,10 @@ export const EnvelopeDistributeDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply To Email</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Trans>
|
||||
Reply To Email{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
@@ -372,8 +374,10 @@ export const EnvelopeDistributeDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Subject</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Trans>
|
||||
Subject{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
@@ -390,8 +394,10 @@ export const EnvelopeDistributeDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Message</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Trans>
|
||||
Message{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger type="button">
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
|
||||
@@ -117,7 +117,7 @@ export const EnvelopeItemDeleteDialog = ({
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You cannot delete this item because the document has been sent to recipients
|
||||
You cannot delete this item because the document has been sent to recipients.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -121,7 +121,7 @@ export const OrganisationEmailDomainRecordContent = ({ records }: { records: Dom
|
||||
<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
|
||||
domains button.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -148,8 +148,8 @@ 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>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DocumentSigningDisclosure } from '../general/document-signing/document-
|
||||
|
||||
export type SignFieldSignatureDialogProps = {
|
||||
initialSignature?: string;
|
||||
fullName?: string;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
drawSignatureEnabled?: boolean;
|
||||
@@ -28,6 +29,7 @@ export const SignFieldSignatureDialog = createCallable<
|
||||
>(
|
||||
({
|
||||
call,
|
||||
fullName,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
@@ -46,6 +48,7 @@ export const SignFieldSignatureDialog = createCallable<
|
||||
</DialogHeader>
|
||||
|
||||
<SignaturePad
|
||||
fullName={fullName}
|
||||
value={localSignature ?? ''}
|
||||
onChange={({ value }) => setLocalSignature(value)}
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@@ -30,6 +31,13 @@ import {
|
||||
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 type { Toast } from '@documenso/ui/primitives/use-toast';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
@@ -53,26 +61,34 @@ export const TeamDeleteDialog = ({
|
||||
const { toast } = useToast();
|
||||
const { refreshSession } = useSession();
|
||||
|
||||
const currentOrganisation = useCurrentOrganisation();
|
||||
|
||||
const deleteMessage = _(msg`delete ${teamName}`);
|
||||
|
||||
const filteredTeams = currentOrganisation.teams.filter((team) => team.id !== teamId);
|
||||
|
||||
const ZDeleteTeamFormSchema = z.object({
|
||||
teamName: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: _(msg`You must enter '${deleteMessage}' to proceed`) }),
|
||||
}),
|
||||
transferTeamId: z.string().optional(),
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZDeleteTeamFormSchema),
|
||||
defaultValues: {
|
||||
teamName: '',
|
||||
transferTeamId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteTeam } = trpc.team.delete.useMutation();
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
const onFormSubmit = async (data: z.infer<typeof ZDeleteTeamFormSchema>) => {
|
||||
try {
|
||||
await deleteTeam({ teamId });
|
||||
const transferTeamId = data.transferTeamId ? parseInt(data.transferTeamId, 10) : undefined;
|
||||
|
||||
await deleteTeam({ teamId, transferTeamId: transferTeamId || undefined });
|
||||
|
||||
await refreshSession();
|
||||
|
||||
@@ -168,6 +184,43 @@ export const TeamDeleteDialog = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{filteredTeams.length > 0 && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="transferTeamId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Transfer documents to a different team</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select {...field} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={_(msg`Don't transfer (Delete all documents)`)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="-1">
|
||||
<Trans>Don't transfer (Delete all documents)</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{filteredTeams.map((team) => (
|
||||
<SelectItem key={team.id} value={team.id.toString()}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
|
||||
@@ -146,7 +146,7 @@ export const TeamMemberUpdateDialog = ({
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are currently updating <span className="font-bold">{memberName}.</span>
|
||||
You are currently updating <span className="font-bold">{memberName}</span>.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FilePlus, Loader } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { DocumentDropzone } from '@documenso/ui/primitives/document-dropzone';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
type TemplateCreateDialogProps = {
|
||||
folderId?: string;
|
||||
};
|
||||
|
||||
export const TemplateCreateDialog = ({ folderId }: TemplateCreateDialogProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { user } = useSession();
|
||||
const { toast } = useToast();
|
||||
const { _ } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const [showTemplateCreateDialog, setShowTemplateCreateDialog] = useState(false);
|
||||
const [isUploadingFile, setIsUploadingFile] = useState(false);
|
||||
|
||||
const onFileDrop = async (files: File[]) => {
|
||||
const file = files[0];
|
||||
|
||||
if (isUploadingFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploadingFile(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: file.name,
|
||||
folderId: folderId,
|
||||
} satisfies TCreateTemplatePayloadSchema;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
const { envelopeId: id } = await createTemplate(formData);
|
||||
|
||||
toast({
|
||||
title: _(msg`Template document uploaded`),
|
||||
description: _(
|
||||
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setShowTemplateCreateDialog(false);
|
||||
|
||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Please try again later.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
|
||||
setIsUploadingFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={showTemplateCreateDialog}
|
||||
onOpenChange={(value) => !isUploadingFile && setShowTemplateCreateDialog(value)}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="cursor-pointer" disabled={!user.emailVerified}>
|
||||
<FilePlus className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Template (Legacy)</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="w-full max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>New Template</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Templates allow you to quickly generate documents with pre-filled recipients and
|
||||
fields.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="relative">
|
||||
<DocumentDropzone className="h-[40vh]" onDrop={onFileDrop} type="template" />
|
||||
|
||||
{isUploadingFile && (
|
||||
<div className="bg-background/50 absolute inset-0 flex items-center justify-center rounded-lg">
|
||||
<Loader className="text-muted-foreground h-12 w-12 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="secondary" disabled={isUploadingFile}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -219,9 +219,8 @@ export const WebhookCreateDialog = ({ trigger, ...props }: WebhookCreateDialogPr
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
A secret that will be sent to your URL so you can verify that the request
|
||||
has been sent by Documenso
|
||||
has been sent by Documenso.
|
||||
</Trans>
|
||||
.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -438,6 +438,7 @@ export const EmbedDirectTemplateClientPage = ({
|
||||
className="mt-2"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
disableAnimation
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
|
||||
@@ -455,6 +455,7 @@ export const EmbedSignDocumentV1ClientPage = ({
|
||||
className="mt-2"
|
||||
disabled={isThrottled || isSubmitting}
|
||||
disableAnimation
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={metadata?.typedSignatureEnabled}
|
||||
|
||||
@@ -319,6 +319,7 @@ export const MultiSignDocumentSigningView = ({
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
disableAnimation
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={
|
||||
|
||||
@@ -110,7 +110,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
<Trans>Email</Trans>
|
||||
</Label>
|
||||
<Input id="email" type="email" className="bg-muted mt-2" value={user.email} disabled />
|
||||
<Input id="email" type="email" className="mt-2 bg-muted" value={user.email} disabled />
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
@@ -124,6 +124,7 @@ export const ProfileForm = ({ className }: ProfileFormProps) => {
|
||||
<FormControl>
|
||||
<SignaturePadDialog
|
||||
disabled={isSubmitting}
|
||||
fullName={user.name ?? ''}
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? '')}
|
||||
/>
|
||||
|
||||
@@ -244,11 +244,11 @@ export const SignInForm = ({
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AuthenticationErrorCode.InvalidCredentials,
|
||||
() => msg`The email or password provided is incorrect`,
|
||||
() => msg`The email or password provided is incorrect.`,
|
||||
)
|
||||
.with(
|
||||
AuthenticationErrorCode.InvalidTwoFactorCode,
|
||||
() => msg`The two-factor authentication code provided is incorrect`,
|
||||
() => msg`The two-factor authentication code provided is incorrect.`,
|
||||
)
|
||||
.otherwise(() => handleFallbackErrorMessages(error.code));
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export const ApiTokenForm = ({ className, tokens }: ApiTokenFormProps) => {
|
||||
const errorMessage = match(error.code)
|
||||
.with(
|
||||
AppErrorCode.UNAUTHORIZED,
|
||||
() => msg`You do not have permission to create a token for this team`,
|
||||
() => msg`You do not have permission to create a token for this team.`,
|
||||
)
|
||||
.otherwise(() => msg`Something went wrong. Please try again later.`);
|
||||
|
||||
|
||||
@@ -417,6 +417,7 @@ export const DirectTemplateSigningForm = ({
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(value) => setSignature(value)}
|
||||
typedSignatureEnabled={template.templateMeta?.typedSignatureEnabled}
|
||||
@@ -433,7 +434,7 @@ export const DirectTemplateSigningForm = ({
|
||||
|
||||
<div className="mt-4 flex gap-x-4">
|
||||
<Button
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
className="w-full bg-black/5 hover:bg-black/10 dark:bg-muted dark:hover:bg-muted/80"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -280,6 +280,7 @@ export const DocumentSigningForm = ({
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
|
||||
+1
-1
@@ -106,7 +106,7 @@ export const DocumentSigningMobileWidget = () => {
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-signing-mobile-widget-progress-bar"
|
||||
className="bg-documenso absolute inset-y-0 left-0"
|
||||
className="bg-primary absolute inset-y-0 left-0"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ export const DocumentSigningPageViewV2 = () => {
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="absolute inset-y-0 left-0 bg-documenso"
|
||||
className="absolute inset-y-0 left-0 bg-primary"
|
||||
style={{
|
||||
width: `${100 - (100 / requiredRecipientFields.length) * (recipientFieldsRemaining.length ?? 0)}%`,
|
||||
}}
|
||||
|
||||
+11
-7
@@ -56,8 +56,11 @@ export const DocumentSigningSignatureField = ({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [fontSize, setFontSize] = useState(2);
|
||||
|
||||
const { signature: providedSignature, setSignature: setProvidedSignature } =
|
||||
useRequiredDocumentSigningContext();
|
||||
const {
|
||||
fullName,
|
||||
signature: providedSignature,
|
||||
setSignature: setProvidedSignature,
|
||||
} = useRequiredDocumentSigningContext();
|
||||
|
||||
const { executeActionAuthProcedure } = useRequiredDocumentSigningAuthContext();
|
||||
|
||||
@@ -236,13 +239,13 @@ export const DocumentSigningSignatureField = ({
|
||||
type="Signature"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="bg-background absolute inset-0 flex items-center justify-center rounded-md">
|
||||
<Loader className="text-primary h-5 w-5 animate-spin md:h-8 md:w-8" />
|
||||
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background">
|
||||
<Loader className="h-5 w-5 animate-spin text-primary md:h-8 md:w-8" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'empty' && (
|
||||
<p className="group-hover:text-primary font-signature text-muted-foreground group-hover:text-recipient-green text-[clamp(0.575rem,25cqw,1.2rem)] text-xl duration-200">
|
||||
<p className="font-signature text-[clamp(0.575rem,25cqw,1.2rem)] text-xl text-muted-foreground duration-200 group-hover:text-primary group-hover:text-recipient-green">
|
||||
<Trans>Signature</Trans>
|
||||
</p>
|
||||
)}
|
||||
@@ -259,7 +262,7 @@ export const DocumentSigningSignatureField = ({
|
||||
<div ref={containerRef} className="flex h-full w-full items-center justify-center p-2">
|
||||
<p
|
||||
ref={signatureRef}
|
||||
className="font-signature text-muted-foreground w-full overflow-hidden break-all text-center leading-tight duration-200"
|
||||
className="w-full overflow-hidden break-all text-center font-signature leading-tight text-muted-foreground duration-200"
|
||||
style={{ fontSize: `${fontSize}rem` }}
|
||||
>
|
||||
{signature?.typedSignature}
|
||||
@@ -272,12 +275,13 @@ export const DocumentSigningSignatureField = ({
|
||||
<DialogTitle>
|
||||
<Trans>
|
||||
Sign as {recipient.name}{' '}
|
||||
<div className="text-muted-foreground h-5">({recipient.email})</div>
|
||||
<div className="h-5 text-muted-foreground">({recipient.email})</div>
|
||||
</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<SignaturePad
|
||||
className="mt-2"
|
||||
fullName={fullName}
|
||||
value={localSignature ?? ''}
|
||||
onChange={({ value }) => setLocalSignature(value)}
|
||||
typedSignatureEnabled={typedSignatureEnabled}
|
||||
|
||||
@@ -14,9 +14,10 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TCreateDocumentPayloadSchema } from '@documenso/trpc/server/document-router/create-document.types';
|
||||
import type { TCreateTemplatePayloadSchema } from '@documenso/trpc/server/template-router/schema';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { DocumentUploadButton as DocumentUploadButtonPrimitive } from '@documenso/ui/primitives/document-upload-button';
|
||||
import {
|
||||
@@ -31,9 +32,13 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export type DocumentUploadButtonLegacyProps = {
|
||||
className?: string;
|
||||
type: EnvelopeType;
|
||||
};
|
||||
|
||||
export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLegacyProps) => {
|
||||
export const DocumentUploadButtonLegacy = ({
|
||||
className,
|
||||
type,
|
||||
}: DocumentUploadButtonLegacyProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { user } = useSession();
|
||||
@@ -54,8 +59,18 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createDocument } = trpc.document.create.useMutation();
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const disabledMessage = useMemo(() => {
|
||||
if (!user.emailVerified) {
|
||||
return msg`Verify your email to upload documents.`;
|
||||
}
|
||||
|
||||
// No errors for templates.
|
||||
if (type === EnvelopeType.TEMPLATE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (organisation.subscription && remaining.documents === 0) {
|
||||
return msg`Document upload disabled due to unpaid invoices`;
|
||||
}
|
||||
@@ -64,11 +79,8 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
||||
return msg`You have reached your document limit.`;
|
||||
}
|
||||
|
||||
if (!user.emailVerified) {
|
||||
return msg`Verify your email to upload documents.`;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [remaining.documents, user.emailVerified, team]);
|
||||
}, [remaining.documents, user.emailVerified, team, type]);
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
@@ -80,44 +92,62 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
||||
meta: {
|
||||
timezone: userTimezone,
|
||||
},
|
||||
} satisfies TCreateDocumentPayloadSchema;
|
||||
} satisfies TCreateDocumentPayloadSchema | TCreateTemplatePayloadSchema;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('file', file);
|
||||
|
||||
const { envelopeId: id } = await createDocument(formData);
|
||||
// Handle legacy document creation.
|
||||
if (type === EnvelopeType.DOCUMENT) {
|
||||
const { envelopeId: id } = await createDocument(formData);
|
||||
|
||||
void refreshLimits();
|
||||
void refreshLimits();
|
||||
|
||||
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
||||
await navigate(`${formatDocumentsPath(team.url)}/${id}/edit`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Document uploaded`),
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
toast({
|
||||
title: _(msg`Document uploaded`),
|
||||
description: _(msg`Your document has been uploaded successfully.`),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
analytics.capture('App: Document Uploaded', {
|
||||
userId: user.id,
|
||||
documentId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Handle legacy template creation.
|
||||
if (type === EnvelopeType.TEMPLATE) {
|
||||
const { envelopeId: id } = await createTemplate(formData);
|
||||
|
||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||
|
||||
toast({
|
||||
title: _(msg`Template document uploaded`),
|
||||
description: _(
|
||||
msg`Your document has been uploaded successfully. You will be redirected to the template page.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs`)
|
||||
.with('INVALID_DOCUMENT_FILE', () => msg`You cannot upload encrypted PDFs.`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => msg`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with(
|
||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||
() => msg`You have reached the limit of the number of files per envelope`,
|
||||
() => msg`You have reached the limit of the number of files per envelope.`,
|
||||
)
|
||||
.otherwise(() => msg`An error occurred while uploading your document.`);
|
||||
|
||||
@@ -149,17 +179,18 @@ export const DocumentUploadButtonLegacy = ({ className }: DocumentUploadButtonLe
|
||||
<div>
|
||||
<DocumentUploadButtonPrimitive
|
||||
loading={isLoading}
|
||||
disabled={remaining.documents === 0 || !user.emailVerified}
|
||||
disabled={disabledMessage !== undefined}
|
||||
disabledMessage={disabledMessage}
|
||||
onDrop={async (files) => onFileDrop(files[0])}
|
||||
onDropRejected={onFileDropRejected}
|
||||
type={EnvelopeType.DOCUMENT}
|
||||
type={type}
|
||||
internalVersion="1"
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
|
||||
{team?.id === undefined &&
|
||||
type === EnvelopeType.DOCUMENT &&
|
||||
remaining.documents > 0 &&
|
||||
Number.isFinite(remaining.documents) && (
|
||||
<TooltipContent>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DocumentStatus, FieldType, RecipientRole } from '@prisma/client';
|
||||
import { FileTextIcon, SparklesIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Link, useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual } from 'remeda';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/al
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Separator } from '@documenso/ui/primitives/separator';
|
||||
|
||||
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||
import { AiFieldDetectionDialog } from '~/components/dialogs/ai-field-detection-dialog';
|
||||
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
||||
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
||||
@@ -81,6 +82,8 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const [isAiFieldDialogOpen, setIsAiFieldDialogOpen] = useState(false);
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const selectedField = useMemo(
|
||||
() => structuredClone(editorFields.selectedField),
|
||||
@@ -135,6 +138,22 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
||||
}, []);
|
||||
|
||||
const onDetectClick = () => {
|
||||
if (!team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAiFieldDialogOpen(true);
|
||||
};
|
||||
|
||||
const onAiFeaturesEnabled = () => {
|
||||
void revalidate().then(() => {
|
||||
setIsAiEnableDialogOpen(false);
|
||||
setIsAiFieldDialogOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div className="flex w-full flex-col overflow-y-auto">
|
||||
@@ -230,34 +249,36 @@ export const EnvelopeEditorFieldsPage = () => {
|
||||
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
||||
/>
|
||||
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={() => setIsAiFieldDialogOpen(true)}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 w-full"
|
||||
onClick={onDetectClick}
|
||||
disabled={envelope.status !== DocumentStatus.DRAFT}
|
||||
title={
|
||||
envelope.status !== DocumentStatus.DRAFT
|
||||
? _(msg`You can only detect fields in draft envelopes`)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SparklesIcon className="-ml-1 mr-2 h-4 w-4" />
|
||||
<Trans>Detect with AI</Trans>
|
||||
</Button>
|
||||
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<AiFieldDetectionDialog
|
||||
open={isAiFieldDialogOpen}
|
||||
onOpenChange={setIsAiFieldDialogOpen}
|
||||
onComplete={onFieldDetectionComplete}
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
onOpenChange={setIsAiEnableDialogOpen}
|
||||
onEnabled={onAiFeaturesEnabled}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Field details section. */}
|
||||
|
||||
+60
-20
@@ -8,12 +8,13 @@ import {
|
||||
type SensorAPI,
|
||||
} from '@hello-pangea/dnd';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { DocumentSigningOrder, EnvelopeType, RecipientRole, SendStatus } from '@prisma/client';
|
||||
import { motion } from 'framer-motion';
|
||||
import { GripVerticalIcon, HelpCircleIcon, PlusIcon, SparklesIcon, TrashIcon } from 'lucide-react';
|
||||
import { useFieldArray, useForm, useWatch } from 'react-hook-form';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useRevalidator, useSearchParams } from 'react-router';
|
||||
import { isDeepEqual, prop, sortBy } from 'remeda';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -62,6 +63,7 @@ import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { AiFeaturesEnableDialog } from '~/components/dialogs/ai-features-enable-dialog';
|
||||
import { AiRecipientDetectionDialog } from '~/components/dialogs/ai-recipient-detection-dialog';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
@@ -96,11 +98,19 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [recipientSearchQuery, setRecipientSearchQuery] = useState('');
|
||||
const [isAiEnableDialogOpen, setIsAiEnableDialogOpen] = useState(false);
|
||||
|
||||
// AI recipient detection dialog state
|
||||
const [isAiDialogOpen, setIsAiDialogOpen] = useState(() => searchParams.get('ai') === 'true');
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const onAiDialogOpenChange = (open: boolean) => {
|
||||
if (open && !team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
setIsAiDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAiDialogOpen(open);
|
||||
|
||||
if (!open && searchParams.get('ai') === 'true') {
|
||||
@@ -117,6 +127,22 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onDetectRecipientsClick = () => {
|
||||
if (!team.preferences.aiFeaturesEnabled) {
|
||||
setIsAiEnableDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAiDialogOpen(true);
|
||||
};
|
||||
|
||||
const onAiFeaturesEnabled = () => {
|
||||
void revalidate().then(() => {
|
||||
setIsAiEnableDialogOpen(false);
|
||||
setIsAiDialogOpen(true);
|
||||
});
|
||||
};
|
||||
|
||||
const debouncedRecipientSearchQuery = useDebouncedValue(recipientSearchQuery, 500);
|
||||
|
||||
const initialId = useId();
|
||||
@@ -329,8 +355,14 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Recipients added`,
|
||||
description: t`${detectedRecipients.length} recipient(s) have been added from AI detection.`,
|
||||
title: plural(detectedRecipients.length, {
|
||||
one: `Recipient added`,
|
||||
other: `Recipients added`,
|
||||
}),
|
||||
description: plural(detectedRecipients.length, {
|
||||
one: `# recipient have been added from AI detection.`,
|
||||
other: `# recipients have been added from AI detection.`,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -625,25 +657,27 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
{team.preferences.aiFeaturesEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={() => setIsAiDialogOpen(true)}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isSubmitting}
|
||||
onClick={onDetectRecipientsClick}
|
||||
>
|
||||
<SparklesIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
|
||||
<TooltipContent>
|
||||
<TooltipContent>
|
||||
{team.preferences.aiFeaturesEnabled ? (
|
||||
<Trans>Detect recipients with AI</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
) : (
|
||||
<Trans>Enable AI detection</Trans>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -1100,6 +1134,12 @@ export const EnvelopeEditorRecipientForm = () => {
|
||||
envelopeId={envelope.id}
|
||||
teamId={envelope.teamId}
|
||||
/>
|
||||
|
||||
<AiFeaturesEnableDialog
|
||||
open={isAiEnableDialogOpen}
|
||||
onOpenChange={setIsAiEnableDialogOpen}
|
||||
onEnabled={onAiFeaturesEnabled}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
+7
-4
@@ -687,8 +687,10 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply To Email</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Trans>
|
||||
Reply To Email{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
@@ -726,8 +728,9 @@ export const EnvelopeEditorSettingsDialog = ({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex flex-row items-center">
|
||||
<Trans>Message</Trans>{' '}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
<Trans>
|
||||
Message <span className="text-muted-foreground">(Optional)</span>
|
||||
</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function EnvelopeEditor() {
|
||||
<motion.div
|
||||
layout="size"
|
||||
layoutId="document-flow-container-step"
|
||||
className="absolute inset-y-0 left-0 bg-documenso"
|
||||
className="absolute inset-y-0 left-0 bg-primary"
|
||||
style={{
|
||||
width: `${(100 / envelopeEditorSteps.length) * (currentStepData.order ?? 0)}%`,
|
||||
}}
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function EnvelopeSignerForm() {
|
||||
|
||||
if (recipient.role === RecipientRole.ASSISTANT) {
|
||||
return (
|
||||
<fieldset className="embed--DocumentWidgetForm dark:bg-background border-border rounded-2xl sm:border sm:p-3">
|
||||
<fieldset className="embed--DocumentWidgetForm rounded-2xl border-border sm:border sm:p-3 dark:bg-background">
|
||||
<RadioGroup
|
||||
className="gap-0 space-y-2 shadow-none sm:space-y-3"
|
||||
value={selectedAssistantRecipient?.id?.toString()}
|
||||
@@ -54,7 +54,7 @@ export default function EnvelopeSignerForm() {
|
||||
.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="bg-widget border-border relative flex flex-col gap-4 rounded-lg border p-4"
|
||||
className="relative flex flex-col gap-4 rounded-lg border border-border bg-widget p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -69,15 +69,15 @@ export default function EnvelopeSignerForm() {
|
||||
{r.name}
|
||||
|
||||
{r.id === recipient.id && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
<Trans>(You)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">{r.email}</p>
|
||||
<p className="text-xs text-muted-foreground">{r.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs leading-[inherit]">
|
||||
<div className="text-xs leading-[inherit] text-muted-foreground">
|
||||
<Plural
|
||||
value={assistantFields.filter((field) => field.recipientId === r.id).length}
|
||||
one="# field"
|
||||
@@ -103,7 +103,7 @@ export default function EnvelopeSignerForm() {
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
className="mt-2 bg-background"
|
||||
value={fullName}
|
||||
disabled={isNameLocked}
|
||||
onChange={(e) => !isNameLocked && setFullName(e.target.value.trimStart())}
|
||||
@@ -119,6 +119,7 @@ export default function EnvelopeSignerForm() {
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
fullName={fullName}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={envelope.documentMeta.typedSignatureEnabled}
|
||||
|
||||
@@ -374,6 +374,7 @@ export default function EnvelopeSignerPageRenderer() {
|
||||
.with({ type: FieldType.SIGNATURE }, (field) => {
|
||||
handleSignatureFieldClick({
|
||||
field,
|
||||
fullName,
|
||||
signature,
|
||||
typedSignatureEnabled: envelope.documentMeta.typedSignatureEnabled,
|
||||
uploadSignatureEnabled: envelope.documentMeta.uploadSignatureEnabled,
|
||||
|
||||
@@ -123,14 +123,14 @@ export const EnvelopeDropZoneWrapper = ({
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
|
||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with(
|
||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||
() => t`You have reached the limit of the number of files per envelope`,
|
||||
() => t`You have reached the limit of the number of files per envelope.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred during upload.`);
|
||||
|
||||
|
||||
@@ -126,14 +126,14 @@ export const EnvelopeUploadButton = ({ className, type, folderId }: EnvelopeUplo
|
||||
console.error(err);
|
||||
|
||||
const errorMessage = match(error.code)
|
||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs`)
|
||||
.with('INVALID_DOCUMENT_FILE', () => t`You cannot upload encrypted PDFs.`)
|
||||
.with(
|
||||
AppErrorCode.LIMIT_EXCEEDED,
|
||||
() => t`You have reached your document limit for this month. Please upgrade your plan.`,
|
||||
)
|
||||
.with(
|
||||
'ENVELOPE_ITEM_LIMIT_EXCEEDED',
|
||||
() => t`You have reached the limit of the number of files per envelope`,
|
||||
() => t`You have reached the limit of the number of files per envelope.`,
|
||||
)
|
||||
.otherwise(() => t`An error occurred while uploading your document.`);
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
|
||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||
import { DocumentUploadButtonLegacy } from '~/components/general/document/document-upload-button-legacy';
|
||||
import { FolderCard, FolderCardEmpty } from '~/components/general/folder/folder-card';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@@ -70,7 +69,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
<div>
|
||||
<div className="mb-4 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div
|
||||
className="text-muted-foreground hover:text-muted-foreground/80 flex flex-1 items-center text-sm font-medium"
|
||||
className="flex flex-1 items-center text-sm font-medium text-muted-foreground hover:text-muted-foreground/80"
|
||||
data-testid="folder-grid-breadcrumbs"
|
||||
>
|
||||
<Link to={formatRootPath()} className="flex items-center">
|
||||
@@ -100,10 +99,9 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
<EnvelopeUploadButton type={type} folderId={parentId || undefined} />
|
||||
|
||||
{type === FolderType.DOCUMENT ? (
|
||||
<DocumentUploadButtonLegacy /> // If you delete this, delete the component as well.
|
||||
) : (
|
||||
<TemplateCreateDialog folderId={parentId ?? undefined} /> // If you delete this, delete the component as well.
|
||||
{/* If you delete this, delete the component as well. */}
|
||||
{organisation.organisationClaim.flags.allowLegacyEnvelopes && (
|
||||
<DocumentUploadButtonLegacy type={type} />
|
||||
)}
|
||||
|
||||
<FolderCreateDialog type={type} />
|
||||
@@ -113,7 +111,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
{isPending ? (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div key={index} className="border-border bg-card h-full rounded-lg border px-4 py-5">
|
||||
<div key={index} className="h-full rounded-lg border border-border bg-card px-4 py-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded" />
|
||||
<div className="flex w-full items-center justify-between">
|
||||
@@ -194,7 +192,7 @@ export const FolderGrid = ({ type, parentId }: FolderGridProps) => {
|
||||
{foldersData.folders.length > 12 && (
|
||||
<div className="mt-2 flex items-center justify-center">
|
||||
<Link
|
||||
className="text-muted-foreground hover:text-foreground text-sm font-medium"
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground"
|
||||
to={formatViewAllFoldersPath()}
|
||||
>
|
||||
View all folders
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { EnvelopeType, RecipientRole, SigningStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
import { Link, redirect } from 'react-router';
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground mt-4 text-sm">
|
||||
<div className="mt-4 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<Trans>Created on</Trans>: {i18n.date(envelope.createdAt, DateTime.DATETIME_MED)}
|
||||
</div>
|
||||
@@ -112,7 +112,8 @@ export default function AdminDocumentDetailsPage({ loaderData }: Route.Component
|
||||
disabled={envelope.recipients.some(
|
||||
(recipient) =>
|
||||
recipient.signingStatus !== SigningStatus.SIGNED &&
|
||||
recipient.signingStatus !== SigningStatus.REJECTED,
|
||||
recipient.signingStatus !== SigningStatus.REJECTED &&
|
||||
recipient.role !== RecipientRole.CC,
|
||||
)}
|
||||
onClick={() => resealDocument({ id: envelope.id })}
|
||||
>
|
||||
|
||||
@@ -178,7 +178,9 @@ const TeamDropdownMenu = ({ team }: { team: TGetOrganisationSessionResponse[0]['
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVerticalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -87,9 +87,9 @@ export default function OrganisationSettingsBrandingPage() {
|
||||
const settingsHeaderText = t`Branding Preferences`;
|
||||
|
||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||
? t`Here you can set your general branding preferences`
|
||||
? t`Here you can set your general branding preferences.`
|
||||
: team
|
||||
? t`Here you can set branding preferences for your team`
|
||||
? t`Here you can set branding preferences for your team.`
|
||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function OrganisationSettingsDocumentPage() {
|
||||
|
||||
const settingsHeaderText = t`Document Preferences`;
|
||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||
? t`Here you can set your general document preferences`
|
||||
? t`Here you can set your general document preferences.`
|
||||
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -65,7 +65,7 @@ export default function OrganisationSettingsGeneral() {
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={t`Email Preferences`}
|
||||
subtitle={t`You can manage your email preferences here`}
|
||||
subtitle={t`You can manage your email preferences here.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function TeamEmailSettingsGeneral() {
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={t`Email Preferences`}
|
||||
subtitle={t`You can manage your email preferences here`}
|
||||
subtitle={t`You can manage your email preferences here.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -185,6 +185,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
(log) =>
|
||||
log.type === DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT && log.data.recipientId === recipientId,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT]: auditLogs[
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT
|
||||
].filter((log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs[
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED
|
||||
].filter(
|
||||
@@ -245,11 +248,11 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
<TableCell truncate={false} className="w-[min-content] max-w-[220px] align-top">
|
||||
<div className="hyphens-auto break-words font-medium">{recipient.name}</div>
|
||||
<div className="break-all">{recipient.email}</div>
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
|
||||
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
|
||||
</p>
|
||||
@@ -273,13 +276,13 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
)}
|
||||
|
||||
{signature.signature?.typedSignature && (
|
||||
<p className="font-signature text-center text-sm">
|
||||
<p className="text-center font-signature text-sm">
|
||||
{signature.signature?.typedSignature}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
|
||||
<span className="block font-mono uppercase">
|
||||
{signature.secondaryId}
|
||||
@@ -290,14 +293,14 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
<p className="text-muted-foreground">N/A</p>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
|
||||
<p className="mt-2 text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.ipAddress ?? _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground mt-1 text-sm print:text-xs">
|
||||
<p className="mt-1 text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
|
||||
@@ -307,18 +310,22 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
|
||||
<TableCell truncate={false} className="w-[min-content] align-top">
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<p className="text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.EMAIL_SENT[0]
|
||||
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||
: _(msg`Unknown`)}
|
||||
: logs.DOCUMENT_SENT[0]
|
||||
? DateTime.fromJSDate(logs.DOCUMENT_SENT[0].createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toFormat('yyyy-MM-dd hh:mm:ss a (ZZZZ)')
|
||||
: _(msg`Unknown`)}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<p className="text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_OPENED[0]
|
||||
@@ -330,7 +337,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</p>
|
||||
|
||||
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<p className="text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
|
||||
@@ -341,7 +348,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</span>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<p className="text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
|
||||
@@ -355,7 +362,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-muted-foreground text-sm print:text-xs">
|
||||
<p className="text-sm text-muted-foreground print:text-xs">
|
||||
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
|
||||
<span className="inline-block">
|
||||
{recipient.signingStatus === SigningStatus.REJECTED
|
||||
|
||||
@@ -371,8 +371,9 @@ const SigningPageV1 = ({ data }: { data: Awaited<ReturnType<typeof handleV1Loade
|
||||
to="https://documenso.com"
|
||||
className="text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
Check out Documenso.
|
||||
Check out Documenso
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
@@ -470,8 +471,9 @@ const SigningPageV2 = ({ data }: { data: Awaited<ReturnType<typeof handleV2Loade
|
||||
to="https://documenso.com"
|
||||
className="text-documenso-700 hover:text-documenso-600"
|
||||
>
|
||||
Check out Documenso.
|
||||
Check out Documenso
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import satori from 'satori';
|
||||
import sharp from 'sharp';
|
||||
import { P, match } from 'ts-pattern';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { getRecipientOrSenderByShareLinkSlug } from '@documenso/lib/server-only/document/get-recipient-or-sender-by-share-link-slug';
|
||||
import { svgToPng } from '@documenso/lib/utils/images/svg-to-png';
|
||||
|
||||
import type { Route } from './+types/share.$slug.opengraph';
|
||||
|
||||
@@ -181,8 +181,7 @@ export const loader = async ({ params }: Route.LoaderArgs) => {
|
||||
},
|
||||
);
|
||||
|
||||
// Convert SVG to PNG using sharp
|
||||
const pngBuffer = await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
|
||||
const pngBuffer = await svgToPng(svg.toString());
|
||||
|
||||
return new Response(pngBuffer, {
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { loadLogo } from '@documenso/lib/utils/images/logo';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import type { Route } from './+types/branding.logo.organisation.$orgId';
|
||||
@@ -63,16 +62,12 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const img = await sharp(file)
|
||||
.toFormat('png', {
|
||||
quality: 80,
|
||||
})
|
||||
.toBuffer();
|
||||
const { content, contentType } = await loadLogo(file);
|
||||
|
||||
return new Response(Buffer.from(img), {
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': img.length.toString(),
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': content.length.toString(),
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { loadLogo } from '@documenso/lib/utils/images/logo';
|
||||
|
||||
import type { Route } from './+types/branding.logo.team.$teamId';
|
||||
|
||||
@@ -56,16 +55,12 @@ export async function loader({ params }: Route.LoaderArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const img = await sharp(file)
|
||||
.toFormat('png', {
|
||||
quality: 80,
|
||||
})
|
||||
.toBuffer();
|
||||
const { content, contentType } = await loadLogo(file);
|
||||
|
||||
return new Response(img, {
|
||||
return new Response(content, {
|
||||
headers: {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': img.length.toString(),
|
||||
'Content-Type': contentType,
|
||||
'Content-Length': content.length.toString(),
|
||||
// Stale while revalidate for 1 hours to 24 hours
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SignFieldSignatureDialog } from '~/components/dialogs/sign-field-signat
|
||||
|
||||
type HandleSignatureFieldClickOptions = {
|
||||
field: TFieldSignature;
|
||||
fullName?: string;
|
||||
signature: string | null;
|
||||
typedSignatureEnabled?: boolean;
|
||||
uploadSignatureEnabled?: boolean;
|
||||
@@ -17,8 +18,14 @@ type HandleSignatureFieldClickOptions = {
|
||||
export const handleSignatureFieldClick = async (
|
||||
options: HandleSignatureFieldClickOptions,
|
||||
): Promise<Extract<TSignEnvelopeFieldValue, { type: typeof FieldType.SIGNATURE }> | null> => {
|
||||
const { field, signature, typedSignatureEnabled, uploadSignatureEnabled, drawSignatureEnabled } =
|
||||
options;
|
||||
const {
|
||||
field,
|
||||
fullName,
|
||||
signature,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
} = options;
|
||||
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
@@ -37,6 +44,7 @@ export const handleSignatureFieldClick = async (
|
||||
|
||||
if (!signatureToInsert) {
|
||||
signatureToInsert = await SignFieldSignatureDialog.call({
|
||||
fullName,
|
||||
typedSignatureEnabled,
|
||||
uploadSignatureEnabled,
|
||||
drawSignatureEnabled,
|
||||
|
||||
@@ -70,7 +70,6 @@
|
||||
"remeda": "^2.32.0",
|
||||
"remix-themes": "^2.0.4",
|
||||
"satori": "^0.18.3",
|
||||
"sharp": "0.34.5",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
@@ -108,5 +107,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "2.2.5"
|
||||
"version": "2.2.8"
|
||||
}
|
||||
|
||||
Generated
+16
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@documenso/root",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.8",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@documenso/root",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.8",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
@@ -78,7 +78,7 @@
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/trpc": "*",
|
||||
"@documenso/ui": "*",
|
||||
"next": "^15.5.7",
|
||||
"next": "15.5.9",
|
||||
"next-plausible": "^3.12.5",
|
||||
"nextra": "^3",
|
||||
"nextra-theme-docs": "^3",
|
||||
@@ -99,7 +99,7 @@
|
||||
"dependencies": {
|
||||
"@documenso/prisma": "*",
|
||||
"luxon": "^3.7.2",
|
||||
"next": "^15.5.7"
|
||||
"next": "15.5.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20",
|
||||
@@ -109,7 +109,7 @@
|
||||
},
|
||||
"apps/remix": {
|
||||
"name": "@documenso/remix",
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.8",
|
||||
"dependencies": {
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@documenso/api": "*",
|
||||
@@ -167,7 +167,6 @@
|
||||
"remeda": "^2.32.0",
|
||||
"remix-themes": "^2.0.4",
|
||||
"satori": "^0.18.3",
|
||||
"sharp": "0.34.5",
|
||||
"tailwindcss": "^3.4.18",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"ua-parser-js": "^1.0.41",
|
||||
@@ -5037,9 +5036,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
||||
"version": "15.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
|
||||
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -26397,12 +26396,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
@@ -28913,12 +28912,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
||||
"version": "15.5.9",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
|
||||
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.7",
|
||||
"@next/env": "15.5.9",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"version": "2.2.5",
|
||||
"version": "2.2.8",
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"build": "turbo run build",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Page } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
|
||||
@@ -58,3 +58,15 @@ const getCsrfToken = async (page: Page) => {
|
||||
|
||||
return csrfToken;
|
||||
};
|
||||
|
||||
export const checkSessionValid = async (page: Page): Promise<boolean> => {
|
||||
const { request } = page.context();
|
||||
|
||||
const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/session`, {
|
||||
method: 'get',
|
||||
});
|
||||
|
||||
const session = await response.json();
|
||||
|
||||
return session.isAuthenticated === true;
|
||||
};
|
||||
|
||||
@@ -364,6 +364,25 @@ test('[TEAMS]: can create a template subfolder inside a template folder', async
|
||||
test('[TEAMS]: can create a template inside a template folder', async ({ page }) => {
|
||||
const { team, teamOwner } = await seedTeamDocuments();
|
||||
|
||||
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: {
|
||||
organisation: {
|
||||
id: team.organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
flags: {
|
||||
allowLegacyEnvelopes: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const folder = await seedBlankFolder(teamOwner, team.id, {
|
||||
createFolderOptions: {
|
||||
name: 'Team Client Templates',
|
||||
@@ -380,16 +399,15 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
|
||||
|
||||
await expect(page.getByText('Team Client Templates')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Template (Legacy)' }).click();
|
||||
// Upload document.
|
||||
const [fileChooser] = await Promise.all([
|
||||
page.waitForEvent('filechooser'),
|
||||
page.getByRole('button', { name: 'Template (Legacy)' }).click(),
|
||||
]);
|
||||
|
||||
await page.getByText('Upload Template Document').click();
|
||||
|
||||
await page.locator('input[type="file"]').nth(0).waitFor({ state: 'attached' });
|
||||
|
||||
await page
|
||||
.locator('input[type="file"]')
|
||||
.nth(0)
|
||||
.setInputFiles(path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'));
|
||||
await fileChooser.setFiles(
|
||||
path.join(__dirname, '../../../assets/documenso-supporter-pledge.pdf'),
|
||||
);
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type Page, expect, test } from '@playwright/test';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication';
|
||||
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
@@ -17,6 +17,7 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
|
||||
|
||||
await page.goto('http://localhost:3000/signin');
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
|
||||
|
||||
await page.getByRole('textbox', { name: 'Email' }).click();
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
|
||||
@@ -24,7 +25,9 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P
|
||||
await expect(page.getByRole('button', { name: 'Reset Password' })).toBeEnabled();
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', { timeout: 10000 });
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
|
||||
where: {
|
||||
@@ -109,3 +112,116 @@ test('[USER] can reset password via user settings', async ({ page }: { page: Pag
|
||||
await page.waitForURL('/settings/profile');
|
||||
await expect(page).toHaveURL('/settings/profile');
|
||||
});
|
||||
|
||||
test('[USER] password reset invalidates all sessions', async ({ page }: { page: Page }) => {
|
||||
const oldPassword = 'Test123!';
|
||||
const newPassword = 'Test124!';
|
||||
|
||||
const { user } = await seedUser({
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
const initialCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
await page.goto('http://localhost:3000/signin');
|
||||
await page.getByRole('link', { name: 'Forgot your password?' }).click();
|
||||
await expect(page).toHaveURL('http://localhost:3000/forgot-password');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill(user.email);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Reset email sent', {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
const foundToken = await prisma.passwordResetToken.findFirstOrThrow({
|
||||
where: { userId: user.id },
|
||||
});
|
||||
|
||||
await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`);
|
||||
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||
await page.getByLabel('Repeat Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Your password has been updated successfully.');
|
||||
|
||||
await page.context().addCookies(initialCookies);
|
||||
|
||||
await page.goto('http://localhost:3000/settings/profile');
|
||||
await expect(page).toHaveURL('http://localhost:3000/signin');
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(false);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: newPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
await page.waitForURL('/settings/profile');
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
});
|
||||
|
||||
test('[USER] password update invalidates other sessions but keeps current', async ({
|
||||
page,
|
||||
}: {
|
||||
page: Page;
|
||||
}) => {
|
||||
const oldPassword = 'Test123!';
|
||||
const newPassword = 'Test124!';
|
||||
|
||||
const { user } = await seedUser({
|
||||
password: oldPassword,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
const initialCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
password: oldPassword,
|
||||
redirectPath: '/settings/profile',
|
||||
});
|
||||
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
|
||||
await page.goto('http://localhost:3000/settings/security');
|
||||
await page.getByLabel('Current password').fill(oldPassword);
|
||||
await page.getByLabel('New password').fill(newPassword);
|
||||
await page.getByLabel('Repeat password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Update password' }).click();
|
||||
await expect(page.locator('body')).toContainText('Password updated');
|
||||
|
||||
const finalCookies = await page.context().cookies();
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.context().addCookies(initialCookies);
|
||||
await page.goto('http://localhost:3000/settings/profile');
|
||||
await expect(page).toHaveURL('http://localhost:3000/signin');
|
||||
expect(await checkSessionValid(page)).toBe(false);
|
||||
|
||||
await page.context().clearCookies();
|
||||
await page.context().addCookies(finalCookies);
|
||||
await page.goto('http://localhost:3000/settings/security');
|
||||
await expect(page).toHaveURL('http://localhost:3000/settings/security');
|
||||
expect(await checkSessionValid(page)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ auth.onError((err, c) => {
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
console.error('Unknown Error:', err);
|
||||
return c.json(
|
||||
{
|
||||
code: AppErrorCode.UNKNOWN_ERROR,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AuthenticationErrorCode } from '../lib/errors/error-codes';
|
||||
import { invalidateSessions } from '../lib/session/session';
|
||||
import { getCsrfCookie } from '../lib/session/session-cookies';
|
||||
import { onAuthorize } from '../lib/utils/authorizer';
|
||||
import { getSession } from '../lib/utils/get-session';
|
||||
@@ -170,15 +171,38 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
const { password, currentPassword } = c.req.valid('json');
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
const session = await getSession(c);
|
||||
const { session, user } = await getSession(c);
|
||||
|
||||
await updatePassword({
|
||||
userId: session.user.id,
|
||||
userId: user.id,
|
||||
password,
|
||||
currentPassword,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
const userSessionIds = await prisma.session
|
||||
.findMany({
|
||||
where: {
|
||||
userId: user.id satisfies number, // Incase we pass undefined somehow.
|
||||
id: {
|
||||
not: session.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then((sessions) => sessions.map((s) => s.id));
|
||||
|
||||
if (userSessionIds.length > 0) {
|
||||
await invalidateSessions({
|
||||
userId: user.id,
|
||||
sessionIds: userSessionIds,
|
||||
metadata: requestMetadata,
|
||||
isRevoke: true,
|
||||
});
|
||||
}
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
@@ -231,12 +255,33 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
await resetPassword({
|
||||
const { userId } = await resetPassword({
|
||||
token,
|
||||
password,
|
||||
requestMetadata,
|
||||
});
|
||||
|
||||
// Invalidate all sessions after successful password reset
|
||||
const userSessionIds = await prisma.session
|
||||
.findMany({
|
||||
where: {
|
||||
userId: userId satisfies number, // Incase we pass undefined somehow.
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
})
|
||||
.then((sessions) => sessions.map((session) => session.id));
|
||||
|
||||
if (userSessionIds.length > 0) {
|
||||
await invalidateSessions({
|
||||
userId,
|
||||
sessionIds: userSessionIds,
|
||||
metadata: requestMetadata,
|
||||
isRevoke: true,
|
||||
});
|
||||
}
|
||||
|
||||
return c.text('OK', 201);
|
||||
})
|
||||
/**
|
||||
|
||||
@@ -17,8 +17,9 @@ export const TemplateFooter = ({ isDocument = true }: TemplateFooterProps) => {
|
||||
<Trans>
|
||||
This document was sent using{' '}
|
||||
<Link className="text-[#7AC455]" href="https://documen.so/mail-footer">
|
||||
Documenso.
|
||||
Documenso
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -106,7 +106,7 @@ export const ConfirmTeamEmailTemplate = ({
|
||||
<Text className="mt-2 text-sm">
|
||||
<Trans>
|
||||
You can revoke access at any time in your team settings on Documenso{' '}
|
||||
<Link href={`${baseUrl}/settings/teams`}>here.</Link>
|
||||
<Link href={`${baseUrl}/settings/teams`}>here</Link>.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@@ -73,8 +73,9 @@ export const ResetPasswordTemplate = ({
|
||||
Didn't request a password change? We are here to help you secure your account,
|
||||
just{' '}
|
||||
<Link className="text-documenso-700 font-normal" href="mailto:hi@documenso.com">
|
||||
contact us.
|
||||
contact us
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
@@ -63,6 +63,7 @@ export class LocalJobProvider extends BaseJobProvider {
|
||||
jobId: pendingJob.id,
|
||||
jobDefinitionId: pendingJob.jobId,
|
||||
data: options,
|
||||
isRetry: false,
|
||||
});
|
||||
}),
|
||||
);
|
||||
@@ -198,6 +199,7 @@ export class LocalJobProvider extends BaseJobProvider {
|
||||
jobId,
|
||||
jobDefinitionId: backgroundJob.jobId,
|
||||
data: options,
|
||||
isRetry: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -92,9 +92,23 @@ export const run = async ({
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
// Ensure all CC recipients are marked as signed
|
||||
await prisma.recipient.updateMany({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
role: RecipientRole.CC,
|
||||
},
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
},
|
||||
});
|
||||
|
||||
const isComplete =
|
||||
envelope.recipients.some((recipient) => recipient.signingStatus === SigningStatus.REJECTED) ||
|
||||
envelope.recipients.every((recipient) => recipient.signingStatus === SigningStatus.SIGNED);
|
||||
envelope.recipients.every(
|
||||
(recipient) =>
|
||||
recipient.signingStatus === SigningStatus.SIGNED || recipient.role === RecipientRole.CC,
|
||||
);
|
||||
|
||||
if (!isComplete) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
|
||||
@@ -2,12 +2,12 @@ import { createCanvas, loadImage } from '@napi-rs/canvas';
|
||||
import { DocumentStatus, type Field, RecipientRole } from '@prisma/client';
|
||||
import { generateObject } from 'ai';
|
||||
import pMap from 'p-map';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../../../errors/app-error';
|
||||
import { getFileServerSide } from '../../../../universal/upload/get-file.server';
|
||||
import { resizeImageToGeminiImage } from '../../../../utils/images/resize-image-to-gemini-image';
|
||||
import { getEnvelopeById } from '../../../envelope/get-envelope-by-id';
|
||||
import { createEnvelopeRecipients } from '../../../recipient/create-envelope-recipients';
|
||||
import { vertex } from '../../google';
|
||||
@@ -238,21 +238,6 @@ const maskFieldsOnImage = async ({ image, width, height, fields }: MaskFieldsOnI
|
||||
return canvas.encode('jpeg');
|
||||
};
|
||||
|
||||
const TARGET_SIZE = 1000;
|
||||
|
||||
type ResizeImageOptions = {
|
||||
image: Buffer;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize image to 1000x1000 using fill strategy.
|
||||
* Scales to cover the target area and crops any overflow.
|
||||
*/
|
||||
const resizeImageToSquare = async ({ image, size = TARGET_SIZE }: ResizeImageOptions) => {
|
||||
return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer();
|
||||
};
|
||||
|
||||
type DetectFieldsFromPageOptions = {
|
||||
image: Buffer;
|
||||
pageNumber: number;
|
||||
@@ -267,7 +252,7 @@ const detectFieldsFromPage = async ({
|
||||
context,
|
||||
}: DetectFieldsFromPageOptions) => {
|
||||
// Resize to 1000x1000 for consistent coordinate mapping
|
||||
const resizedImage = await resizeImageToSquare({ image });
|
||||
const resizedImage = await resizeImageToGeminiImage({ image });
|
||||
|
||||
// Build messages array
|
||||
const messages: Parameters<typeof generateObject>[0]['messages'] = [
|
||||
|
||||
@@ -44,10 +44,12 @@ export type PdfToImagesOptions = {
|
||||
export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOptions = {}) => {
|
||||
const { scale = 2 } = options;
|
||||
|
||||
const pdf = await pdfjsLib.getDocument({
|
||||
const task = await pdfjsLib.getDocument({
|
||||
data: pdfBytes,
|
||||
CanvasFactory: SkiaCanvasFactory,
|
||||
}).promise;
|
||||
});
|
||||
|
||||
const pdf = await task.promise;
|
||||
|
||||
const images = await pMap(
|
||||
Array.from({ length: pdf.numPages }),
|
||||
@@ -68,18 +70,23 @@ export const pdfToImages = async (pdfBytes: Uint8Array, options: PdfToImagesOpti
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
return {
|
||||
const result = {
|
||||
pageNumber,
|
||||
image: await canvas.toBuffer('jpeg'),
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
mimeType: 'image/jpeg',
|
||||
};
|
||||
|
||||
void page.cleanup();
|
||||
|
||||
return result;
|
||||
},
|
||||
{ concurrency: 10 },
|
||||
);
|
||||
|
||||
void pdf.destroy();
|
||||
void pdf.destroy().catch((e) => console.error(e));
|
||||
void task.destroy().catch((e) => console.error(e));
|
||||
|
||||
return images;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export const getDocumentCertificateAuditLogs = async ({
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -37,6 +38,9 @@ export const getDocumentCertificateAuditLogs = async ({
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT,
|
||||
),
|
||||
[DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED]: auditLogs.filter(
|
||||
(log) => log.type === DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { deletedAccountServiceAccount } from '../user/service-accounts/deleted-account';
|
||||
|
||||
export type OrphanEnvelopesOptions = {
|
||||
teamId: number;
|
||||
};
|
||||
|
||||
export const orphanEnvelopes = async ({ teamId }: OrphanEnvelopesOptions) => {
|
||||
const serviceAccount = await deletedAccountServiceAccount();
|
||||
|
||||
// Transfer all inflight and completed envelopes to the service account.
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
userId: serviceAccount.id,
|
||||
teamId: serviceAccount.ownedOrganisations[0].teams[0].id,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Transfer any remaining deleted envelopes to the service account.
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
teamId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
userId: serviceAccount.id,
|
||||
teamId: serviceAccount.ownedOrganisations[0].teams[0].id,
|
||||
},
|
||||
});
|
||||
|
||||
// Then delete anything remaining across documents and templates.
|
||||
await prisma.envelope.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type TransferTeamEnvelopesOptions = {
|
||||
sourceTeamId: number;
|
||||
targetTeamId: number;
|
||||
};
|
||||
|
||||
export const transferTeamEnvelopes = async ({
|
||||
sourceTeamId,
|
||||
targetTeamId,
|
||||
}: TransferTeamEnvelopesOptions) => {
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
teamId: sourceTeamId,
|
||||
},
|
||||
data: {
|
||||
teamId: targetTeamId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -40,7 +40,10 @@ export const acceptOrganisationInvitation = async ({
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: organisationMemberInvite.email,
|
||||
email: {
|
||||
equals: organisationMemberInvite.email,
|
||||
mode: 'insensitive',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
PDFDict,
|
||||
type PDFDocument,
|
||||
PDFName,
|
||||
PDFNumber,
|
||||
PDFRadioGroup,
|
||||
PDFRef,
|
||||
PDFStream,
|
||||
drawObject,
|
||||
popGraphicsState,
|
||||
pushGraphicsState,
|
||||
@@ -103,6 +105,36 @@ const getAppearanceRefForWidget = (field: PDFField, widget: PDFWidgetAnnotation)
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures that an appearance stream has the required dictionary entries to be
|
||||
* used as a Form XObject. Some PDFs have appearance streams that are missing
|
||||
* the /Subtype /Form entry, which causes Adobe Reader to fail to render them.
|
||||
*
|
||||
* Per PDF spec, a Form XObject stream requires:
|
||||
* - /Subtype /Form (required)
|
||||
* - /BBox (required, but should already exist for appearance streams)
|
||||
* - /FormType 1 (optional, defaults to 1)
|
||||
*/
|
||||
const normalizeAppearanceStream = (document: PDFDocument, appearanceRef: PDFRef) => {
|
||||
const appearanceStream = document.context.lookup(appearanceRef);
|
||||
|
||||
if (!(appearanceStream instanceof PDFStream)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dict = appearanceStream.dict;
|
||||
|
||||
// Ensure /Subtype /Form is set (required for XObject Form)
|
||||
if (!dict.has(PDFName.of('Subtype'))) {
|
||||
dict.set(PDFName.of('Subtype'), PDFName.of('Form'));
|
||||
}
|
||||
|
||||
// Ensure /FormType is set (optional, but good practice)
|
||||
if (!dict.has(PDFName.of('FormType'))) {
|
||||
dict.set(PDFName.of('FormType'), PDFNumber.of(1));
|
||||
}
|
||||
};
|
||||
|
||||
const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidgetAnnotation) => {
|
||||
try {
|
||||
const page = getPageForWidget(document, widget);
|
||||
@@ -117,6 +149,9 @@ const flattenWidget = (document: PDFDocument, field: PDFField, widget: PDFWidget
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the appearance stream has required XObject Form dictionary entries
|
||||
normalizeAppearanceStream(document, appearanceRef);
|
||||
|
||||
const xObjectKey = page.node.newXObject('FlatWidget', appearanceRef);
|
||||
|
||||
const rectangle = widget.getRectangle();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { loadAvatar } from '../../utils/images/avatar';
|
||||
|
||||
export type GetAvatarImageOptions = {
|
||||
id: string;
|
||||
};
|
||||
@@ -17,10 +17,5 @@ export const getAvatarImage = async ({ id }: GetAvatarImageOptions) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = Buffer.from(avatarImage.bytes, 'base64');
|
||||
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
content: await sharp(bytes).toFormat('jpeg').toBuffer(),
|
||||
};
|
||||
return await loadAvatar(avatarImage.bytes);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/organisations';
|
||||
import { TEAM_MEMBER_ROLE_PERMISSIONS_MAP } from '../../constants/teams';
|
||||
import { AppError } from '../../errors/app-error';
|
||||
import type { ApiRequestMetadata } from '../../universal/extract-request-metadata';
|
||||
import { optimiseAvatar } from '../../utils/images/avatar';
|
||||
import { buildOrganisationWhereQuery } from '../../utils/organisations';
|
||||
import { buildTeamWhereQuery } from '../../utils/teams';
|
||||
|
||||
@@ -100,10 +99,7 @@ export const setAvatarImage = async ({
|
||||
let newAvatarImageId: string | null = null;
|
||||
|
||||
if (bytes) {
|
||||
const optimisedBytes = await sharp(Buffer.from(bytes, 'base64'))
|
||||
.resize(512, 512)
|
||||
.toFormat('jpeg', { quality: 75 })
|
||||
.toBuffer();
|
||||
const optimisedBytes = await optimiseAvatar(bytes);
|
||||
|
||||
const avatarImage = await prisma.avatarImage.create({
|
||||
data: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
@@ -38,11 +39,17 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
});
|
||||
|
||||
if (!apiToken) {
|
||||
throw new Error('Invalid token');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (apiToken.expires && apiToken.expires < new Date()) {
|
||||
throw new Error('Expired token');
|
||||
throw new AppError(AppErrorCode.EXPIRED_CODE, {
|
||||
message: 'Expired token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle a silly choice from many moons ago
|
||||
@@ -54,7 +61,10 @@ export const getApiTokenByToken = async ({ token }: { token: string }) => {
|
||||
|
||||
// This will never happen but we need to narrow types
|
||||
if (!user) {
|
||||
throw new Error('Invalid token');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { hashString } from '../auth/hash';
|
||||
|
||||
export const getUserByApiToken = async ({ token }: { token: string }) => {
|
||||
@@ -19,14 +20,20 @@ export const getUserByApiToken = async ({ token }: { token: string }) => {
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Invalid token');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const retrievedToken = user.apiTokens.find((apiToken) => apiToken.token === hashedToken);
|
||||
|
||||
// This should be impossible but we need to satisfy TypeScript
|
||||
if (!retrievedToken) {
|
||||
throw new Error('Invalid token');
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'Invalid token',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
if (retrievedToken.expires && retrievedToken.expires < new Date()) {
|
||||
|
||||
@@ -185,7 +185,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
documentAuth: directTemplateEnvelope.authOptions,
|
||||
});
|
||||
|
||||
const directRecipientName = user?.name || initialDirectRecipientName;
|
||||
let directRecipientName = user?.name || initialDirectRecipientName;
|
||||
|
||||
// Ensure typesafety when we add more options.
|
||||
const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0))
|
||||
@@ -238,7 +238,7 @@ export const createDocumentFromDirectTemplate = async ({
|
||||
}
|
||||
|
||||
if (templateField.type === FieldType.NAME && directRecipientName === undefined) {
|
||||
directRecipientName === signedFieldValue?.value;
|
||||
directRecipientName = signedFieldValue?.value;
|
||||
}
|
||||
|
||||
const derivedRecipientActionAuth = await validateFieldAuth({
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { deletedAccountServiceAccount } from './service-accounts/deleted-account';
|
||||
import { orphanEnvelopes } from '../envelope/orphan-envelopes';
|
||||
|
||||
export type DeleteUserOptions = {
|
||||
id: number;
|
||||
@@ -14,6 +12,30 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
include: {
|
||||
ownedOrganisations: {
|
||||
include: {
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organisationMember: {
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -22,22 +44,36 @@ export const deleteUser = async ({ id }: DeleteUserOptions) => {
|
||||
});
|
||||
}
|
||||
|
||||
const serviceAccount = await deletedAccountServiceAccount();
|
||||
// Get team IDs from organisations the user owns.
|
||||
const ownedTeamIds = user.ownedOrganisations.flatMap((org) => org.teams.map((team) => team.id));
|
||||
|
||||
// TODO: Send out cancellations for all pending docs
|
||||
await prisma.envelope.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
status: {
|
||||
in: [DocumentStatus.PENDING, DocumentStatus.REJECTED, DocumentStatus.COMPLETED],
|
||||
},
|
||||
},
|
||||
data: {
|
||||
userId: serviceAccount.id,
|
||||
deletedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Get team IDs from organisations the user is a member of (but not owner).
|
||||
const memberTeams = user.organisationMember
|
||||
.filter((member) => member.organisation.ownerUserId !== user.id)
|
||||
.flatMap((member) =>
|
||||
member.organisation.teams.map((team) => ({
|
||||
teamId: team.id,
|
||||
orgOwnerId: member.organisation.ownerUserId,
|
||||
})),
|
||||
);
|
||||
|
||||
// For teams where user is the org owner - orphan their envelopes.
|
||||
await Promise.all(ownedTeamIds.map(async (teamId) => orphanEnvelopes({ teamId })));
|
||||
|
||||
// For teams where user is a member (not owner) - transfer envelopes to team owner.
|
||||
await Promise.all(
|
||||
memberTeams.map(async ({ teamId, orgOwnerId }) => {
|
||||
return prisma.envelope.updateMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
userId: orgOwnerId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return await prisma.user.delete({
|
||||
where: {
|
||||
|
||||
@@ -85,4 +85,8 @@ export const resetPassword = async ({ token, password, requestMetadata }: ResetP
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
userId: foundToken.userId,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,20 @@ export const deletedAccountServiceAccount = async () => {
|
||||
where: {
|
||||
email: 'deleted-account@documenso.com',
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
ownedOrganisations: {
|
||||
select: {
|
||||
id: true,
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!serviceAccount) {
|
||||
|
||||
+533
-120
File diff suppressed because it is too large
Load Diff
+532
-115
File diff suppressed because it is too large
Load Diff
+534
-121
File diff suppressed because it is too large
Load Diff
+533
-120
File diff suppressed because it is too large
Load Diff
+535
-122
File diff suppressed because it is too large
Load Diff
+531
-118
File diff suppressed because it is too large
Load Diff
+530
-117
File diff suppressed because it is too large
Load Diff
+531
-118
File diff suppressed because it is too large
Load Diff
+530
-117
File diff suppressed because it is too large
Load Diff
+1474
-1057
File diff suppressed because it is too large
Load Diff
+535
-122
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,7 @@ export const ZClaimFlagsSchema = z.object({
|
||||
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
|
||||
allowEnvelopes: z.boolean().optional(),
|
||||
allowLegacyEnvelopes: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
|
||||
@@ -84,9 +84,9 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'authenticationPortal',
|
||||
label: 'Authentication portal',
|
||||
},
|
||||
allowEnvelopes: {
|
||||
key: 'allowEnvelopes',
|
||||
label: 'Allow envelopes',
|
||||
allowLegacyEnvelopes: {
|
||||
key: 'allowLegacyEnvelopes',
|
||||
label: 'Allow Legacy Envelopes',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const optimiseAvatar = async (bytes: string) => {
|
||||
return await sharp(Buffer.from(bytes, 'base64'))
|
||||
.resize(512, 512)
|
||||
.toFormat('jpeg', { quality: 75 })
|
||||
.toBuffer();
|
||||
};
|
||||
|
||||
export const loadAvatar = async (bytes: string) => {
|
||||
const content = await sharp(Buffer.from(bytes, 'base64')).toFormat('jpeg').toBuffer();
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
content,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const loadLogo = async (file: Uint8Array) => {
|
||||
const content = await sharp(file).toFormat('png', { quality: 80 }).toBuffer();
|
||||
|
||||
return {
|
||||
contentType: 'image/png',
|
||||
content,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const TARGET_SIZE = 1000;
|
||||
|
||||
type ResizeImageToGeminiImageOptions = {
|
||||
image: Buffer;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resize image to 1000x1000 using fill strategy.
|
||||
* Scales to cover the target area and crops any overflow.
|
||||
*/
|
||||
export const resizeImageToGeminiImage = async ({
|
||||
image,
|
||||
size = TARGET_SIZE,
|
||||
}: ResizeImageToGeminiImageOptions) => {
|
||||
return await sharp(image).resize(size, size, { fit: 'fill' }).toBuffer();
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export const svgToPng = async (svg: string) => {
|
||||
return await sharp(Buffer.from(svg)).toFormat('png').toBuffer();
|
||||
};
|
||||
@@ -60,6 +60,18 @@ export const seedUser = async ({
|
||||
},
|
||||
include: {
|
||||
teams: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
flags: {
|
||||
allowLegacyEnvelopes: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
} from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { orphanEnvelopes } from '@documenso/lib/server-only/envelope/orphan-envelopes';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
@@ -32,6 +33,19 @@ export const deleteOrganisationRoute = authenticatedProcedure
|
||||
userId: user.id,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP['DELETE_ORGANISATION'],
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
@@ -40,6 +54,9 @@ export const deleteOrganisationRoute = authenticatedProcedure
|
||||
});
|
||||
}
|
||||
|
||||
// Orphan all envelopes to get rid of foreign key constraints.
|
||||
await Promise.all(organisation.teams.map(async (team) => orphanEnvelopes({ teamId: team.id })));
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.deleteMany({
|
||||
where: {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { orphanEnvelopes } from '@documenso/lib/server-only/envelope/orphan-envelopes';
|
||||
import { transferTeamEnvelopes } from '@documenso/lib/server-only/envelope/transfer-team-envelopes';
|
||||
import { deleteTeam } from '@documenso/lib/server-only/team/delete-team';
|
||||
import { getTeamById } from '@documenso/lib/server-only/team/get-team';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { ZDeleteTeamRequestSchema, ZDeleteTeamResponseSchema } from './delete-team.types';
|
||||
@@ -8,15 +14,40 @@ export const deleteTeamRoute = authenticatedProcedure
|
||||
.input(ZDeleteTeamRequestSchema)
|
||||
.output(ZDeleteTeamResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { teamId } = input;
|
||||
const { teamId, transferTeamId } = input;
|
||||
const { user } = ctx;
|
||||
|
||||
const team = await getTeamById({ userId: user.id, teamId });
|
||||
|
||||
if (team.currentTeamRole !== TeamMemberRole.ADMIN) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'You are not allowed to delete this team',
|
||||
});
|
||||
}
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
const transferTeam = transferTeamId
|
||||
? await getTeamById({ userId: user.id, teamId: transferTeamId }).catch(() => {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Invalid transfer team ID',
|
||||
});
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (transferTeam) {
|
||||
await transferTeamEnvelopes({
|
||||
sourceTeamId: teamId,
|
||||
targetTeamId: transferTeam.id,
|
||||
});
|
||||
} else {
|
||||
await orphanEnvelopes({ teamId });
|
||||
}
|
||||
|
||||
await deleteTeam({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { z } from 'zod';
|
||||
|
||||
export const ZDeleteTeamRequestSchema = z.object({
|
||||
teamId: z.number(),
|
||||
transferTeamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export const ZDeleteTeamResponseSchema = z.void();
|
||||
|
||||
@@ -17,7 +17,7 @@ const EnvelopePdfViewer = lazy(async () => import('./pdf-viewer-konva'));
|
||||
|
||||
export const PDFViewerKonvaLazy = (props: PDFViewerProps) => {
|
||||
return (
|
||||
<Suspense fallback={<div>Loading client component...</div>}>
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<EnvelopePdfViewer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -95,7 +95,9 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<span className="sr-only">
|
||||
<Trans>Go to first page</Trans>
|
||||
</span>
|
||||
<ChevronsLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -104,7 +106,9 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<span className="sr-only">
|
||||
<Trans>Go to previous page</Trans>
|
||||
</span>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -113,7 +117,9 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<span className="sr-only">
|
||||
<Trans>Go to next page</Trans>
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
@@ -122,7 +128,9 @@ export function DataTablePagination<TData>({
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<span className="sr-only">
|
||||
<Trans>Go to last page</Trans>
|
||||
</span>
|
||||
<ChevronsRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user