Compare commits

...

50 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan cb880fbd50 chore: fix conflicts 2025-08-22 16:17:59 +00:00
Ephraim Atta-Duncan a8b8721b22 chore: fix conflicts 2025-08-22 15:34:30 +00:00
Catalin Pit 67501b45cf feat: create document in a specific folder (#1965) 2025-08-23 00:12:17 +10:00
Catalin Pit 17b36ac8e4 feat: sync organization name with stripe (#1974) 2025-08-22 23:28:04 +10:00
Lucas Smith 80e452afa2 fix: get accurate pdf page size (#1980)
Handles edge cases with PDF media boxes and crop boxes, deals with
certain documents that had been uploaded with weird combos of sizings.
2025-08-22 22:50:41 +10:00
Ephraim Duncan 1cb9de8083 chore: remove 'use client' directives (#1979) 2025-08-22 02:20:41 +00:00
Catalin Pit 231ef9c27e chore: add support option (#1853) 2025-08-19 20:59:03 +10:00
Catalin Pit 6f35342a83 feat: reset user 2fa from admin panel (#1943) 2025-08-19 13:09:05 +10:00
David Nguyen a51110d276 fix: prevent document unsigning on edit (#1963) 2025-08-18 13:48:51 +10:00
David Nguyen 7f81231467 fix: template e2e tests (#1969) 2025-08-18 12:42:36 +10:00
Lucas Smith 439262fd02 v1.12.2-rc.4 2025-08-16 19:16:29 +10:00
Lucas Smith 93a184355b chore: add translations (#1955) 2025-08-16 19:10:21 +10:00
David Nguyen 1dea0b8fab add dummy teamid (#1968) 2025-08-16 19:09:21 +10:00
David Nguyen ea7a2c2712 fix: create customer on signup (#1964) 2025-08-14 16:30:16 +10:00
Catalin Pit deb3a63fb8 feat: allow empty placeholder emails on templates (#1930)
Allow users to create template placeholders without the placeholder
emails.
2025-08-12 20:41:23 +10:00
Ephraim Duncan cc05af2062 feat: backport the embedded mobile signing ux to main application (#1919)
This PR improves the mobile experience of the document signing page by
implementing a collapsible widget design for the signing form. On mobile
devices, the form now appears as a fixed bottom sheet that can be
expanded/collapsed, while maintaining the sticky sidebar layout on
desktop.
2025-08-12 20:40:14 +10:00
David Nguyen 9026aabe3b fix: broken e2e tests (#1956) 2025-08-11 16:16:21 +10:00
David Nguyen b844e166a9 fix: build 2025-08-11 12:16:34 +10:00
David Nguyen 950951de75 fix: github actions 2025-08-11 12:05:41 +10:00
David Nguyen c37e10faab fix: add document page access logging (#1947)
Add logging when someone accesses a document page
2025-08-11 11:50:32 +10:00
David Nguyen fdf6efe94e chore: extract translations (#1949)
Extract translations
2025-08-11 11:49:30 +10:00
David Nguyen 4c1eb8f874 fix: translation extraction github action (#1950)
Fix checkout action for translation extraction
2025-08-11 11:48:19 +10:00
Konrad e547b0b410 fix: add special context to strings (#1954) 2025-08-11 11:47:21 +10:00
Catalin Pit 803edf5b16 feat: implement Drag-n-Drop for templates (#1791) 2025-08-07 15:37:55 +10:00
David Nguyen 86c133ae84 fix: remove field truncate logic (#1940)
Remove the truncation logic and render the text for preview/edit mode.

Text will now overflow, but it's up to the user to correct it
2025-08-07 11:55:25 +10:00
David Nguyen c28c5ab91d chore: correct the email domains documentation (#1941)
## Description

Update the documentation for email domains
2025-08-07 11:54:41 +10:00
Catalin Pit d1eb14ac16 feat: include audit trail log in the completed doc (#1916)
This change allows users to include the audit trail log in the completed
documents; similar to the signing certificate.


https://github.com/user-attachments/assets/d9ae236a-2584-4ad6-b7bc-27b3eb8c74d3

It also solves the issue with the text cutoff.
2025-08-07 11:44:59 +10:00
Novapixel1010 f24b71f559 feat: env for support email (#1945)
Add the ability to change the support email. For the signature
disclosure page.
2025-08-07 10:39:03 +10:00
David Nguyen 2ee0d77870 fix: correctly set stripe customer names (#1939)
Currently the Stripe customer name is set to the organisation name,
which in some cases is just the organisation name.

This update makes it so it uses the owner name instead.
2025-08-05 12:30:02 +10:00
Ephraim Duncan 9b01a2318f feat: download document via api v2 (#1918)
adds document download functionality to the API v2, returning
pre-signed S3 URLs that provide secure, time-limited access to document
files similar to what happens in the API v1 download document endpoint.
2025-08-05 12:29:21 +10:00
Catalin Pit 5689cd1538 feat: add tooltip to team member creation dialog for guidance (#1933) 2025-08-04 08:49:43 +03:00
Lucas Smith 9d5b573dda v1.12.2-rc.3 2025-08-02 00:46:22 +10:00
David Nguyen c48486472a fix: add missing email reply validation (#1934)
## Description

General fixes to the email domain features

Changes made:
- Add "email" validation for "Reply-To email" fields
- Fix issue where you can't remove the "Reply-To" email after it's set
- Fix issue where setting the "Sender email" back to Documenso would
still send using the org/team pref
2025-08-02 00:40:41 +10:00
Lucas Smith 1e2388519c hotfix: certificate pdfs are blank when using browserless (#1935)
Certificates have suddenly become blank when using browserless and
Chrome CDP.

This change introduces a workaround that involves reloading the
certificate pdf. Which is hacky but seems to work for now, a better
solution should be found in the future.
2025-08-02 00:39:48 +10:00
Ephraim Duncan 20198b5b6c feat: add european date format (#1925) 2025-07-28 12:32:10 +10:00
Ephraim Atta-Duncan 3b2cb681fd chore: refactor 2025-07-25 10:36:50 +00:00
Ephraim Atta-Duncan 582fe91b14 fix: translation patterns 2025-07-24 16:42:24 +00:00
Ephraim Atta-Duncan 87e0ea2ee3 fix: build errors 2025-07-24 16:16:29 +00:00
David Nguyen ef3885d407 Merge branch 'main' into feat/document-table-filters 2025-07-23 14:41:41 +10:00
Ephraim Duncan 1b39799fc3 Merge branch 'main' into feat/document-table-filters 2025-07-10 09:09:28 +00:00
Ephraim Atta-Duncan 0f3c9dafa8 fix: cannot find source column 2025-06-19 15:35:31 +00:00
Ephraim Atta-Duncan 8484783ec5 fix: merge conflicts 2025-06-19 15:16:16 +00:00
Ephraim Duncan 5545fb36e8 Merge branch 'main' into feat/document-table-filters 2025-06-05 12:58:48 +00:00
Ephraim Atta-Duncan c5bc3a32f8 chore: clean up unused imports and improve accessibility in pagination 2025-06-05 12:55:14 +00:00
Ephraim Atta-Duncan 64695fad32 chore: add translations 2025-06-05 12:31:53 +00:00
Ephraim Atta-Duncan de45a63c97 chore: minor changes 2025-06-05 12:07:19 +00:00
Ephraim Atta-Duncan 2c064d5aff chore: minor changes 2025-06-05 11:41:26 +00:00
Ephraim Atta-Duncan 9739a0ca96 feat: use data-table on template pages 2025-06-05 10:53:53 +00:00
Ephraim Atta-Duncan 9ccd8e0397 fix: table empty state and use the table somewhere else 2025-06-05 07:42:39 +00:00
Ephraim Atta-Duncan f5365554ab feat: rework document table filters 2025-05-30 18:17:50 +00:00
140 changed files with 7906 additions and 1712 deletions
+2
View File
@@ -136,3 +136,5 @@ E2E_TEST_AUTHENTICATE_USER_PASSWORD="test_Password123"
# OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=
@@ -20,8 +20,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ secrets.GH_PAT }}
- uses: ./.github/actions/node-install
+1 -1
View File
@@ -308,7 +308,7 @@ The Web UI can be found at http://localhost:9000, while the SMTP port will be on
### Support IPv6
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Next.js start command
If you are deploying to a cluster that uses only IPv6, You can use a custom command to pass a parameter to the Remix start command
For local docker run
@@ -5,15 +5,14 @@ import { Callout, Steps } from 'nextra/components';
Email Domains allow you to send emails to recipients from your own domain instead of the default Documenso email address.
<Callout type="info">
**Platform and Enterprise Only**: Email Domains is only available to Platform and Enterprise
customers.
**Enterprise Only**: Email Domains is only available to Enterprise customers and custom plans
</Callout>
## Creating Email Domains
Before setting up email domains, ensure you have:
- A Platform or Enterprise subscription
- An Enterprise subscription
- Access to your domain's DNS settings
- Access to your Documenso organisation as an admin or manager
@@ -0,0 +1,159 @@
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { User } from '@prisma/client';
import { useRevalidator } from 'react-router';
import { match } from 'ts-pattern';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@documenso/ui/primitives/dialog';
import { Input } from '@documenso/ui/primitives/input';
import { useToast } from '@documenso/ui/primitives/use-toast';
export type AdminUserResetTwoFactorDialogProps = {
className?: string;
user: User;
};
export const AdminUserResetTwoFactorDialog = ({
className,
user,
}: AdminUserResetTwoFactorDialogProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { revalidate } = useRevalidator();
const [email, setEmail] = useState('');
const [open, setOpen] = useState(false);
const { mutateAsync: resetTwoFactor, isPending: isResettingTwoFactor } =
trpc.admin.user.resetTwoFactor.useMutation();
const onResetTwoFactor = async () => {
try {
await resetTwoFactor({
userId: user.id,
});
toast({
title: _(msg`2FA Reset`),
description: _(msg`The user's two factor authentication has been reset successfully.`),
duration: 5000,
});
await revalidate();
setOpen(false);
} catch (err) {
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(AppErrorCode.NOT_FOUND, () => msg`User not found.`)
.with(
AppErrorCode.UNAUTHORIZED,
() => msg`You are not authorized to reset two factor authentcation for this user.`,
)
.otherwise(
() => msg`An error occurred while resetting two factor authentication for the user.`,
);
toast({
title: _(msg`Error`),
description: _(errorMessage),
variant: 'destructive',
duration: 7500,
});
}
};
const handleOpenChange = (newOpen: boolean) => {
setOpen(newOpen);
if (!newOpen) {
setEmail('');
}
};
return (
<div className={className}>
<Alert
className="flex flex-col items-center justify-between gap-4 p-6 md:flex-row"
variant="neutral"
>
<div>
<AlertTitle>Reset Two Factor Authentication</AlertTitle>
<AlertDescription className="mr-2">
<Trans>
Reset the users two factor authentication. This action is irreversible and will
disable two factor authentication for the user.
</Trans>
</AlertDescription>
</div>
<div className="flex-shrink-0">
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="destructive">
<Trans>Reset 2FA</Trans>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader className="space-y-4">
<DialogTitle>
<Trans>Reset Two Factor Authentication</Trans>
</DialogTitle>
</DialogHeader>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100">
<Trans>
This action is irreversible. Please ensure you have informed the user before
proceeding.
</Trans>
</AlertDescription>
</Alert>
<div>
<DialogDescription>
<Trans>
To confirm, please enter the accounts email address <br />({user.email}).
</Trans>
</DialogDescription>
<Input
className="mt-2"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<DialogFooter>
<Button
variant="destructive"
disabled={email !== user.email}
onClick={onResetTwoFactor}
loading={isResettingTwoFactor}
>
<Trans>Reset 2FA</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</Alert>
</div>
);
};
@@ -4,7 +4,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { TeamMemberRole } from '@prisma/client';
import type * as DialogPrimitive from '@radix-ui/react-dialog';
import { InfoIcon } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { Link } from 'react-router';
import { match } from 'ts-pattern';
import { z } from 'zod';
@@ -39,6 +41,7 @@ import {
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
@@ -140,8 +143,28 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
{match(step)
.with('SELECT', () => (
<DialogHeader>
<DialogTitle>
<DialogTitle className="flex flex-row items-center">
<Trans>Add members</Trans>
<Tooltip>
<TooltipTrigger asChild>
<InfoIcon className="mx-2 h-4 w-4" />
</TooltipTrigger>
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
<Trans>
To be able to add members to a team, you must first add them to the
organisation. For more information, please see the{' '}
<Link
to="https://docs.documenso.com/users/organisations/members"
target="_blank"
rel="noreferrer"
className="text-documenso-700 hover:text-documenso-600 hover:underline"
>
documentation
</Link>
.
</Trans>
</TooltipContent>
</Tooltip>
</DialogTitle>
<DialogDescription>
@@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import {
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
isTemplateRecipientEmailPlaceholder,
} from '@documenso/lib/constants/template';
import { AppError } from '@documenso/lib/errors/app-error';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
@@ -279,7 +280,11 @@ export function TemplateUseDialog({
<FormControl>
<Input
{...field}
placeholder={recipients[index].email || _(msg`Email`)}
placeholder={
isTemplateRecipientEmailPlaceholder(field.value)
? ''
: _(msg`Email`)
}
/>
</FormControl>
<FormMessage />
@@ -484,6 +489,7 @@ export function TemplateUseDialog({
<input
type="file"
data-testid="template-use-dialog-file-input"
className="absolute h-full w-full opacity-0"
accept=".pdf,application/pdf"
onChange={(e) => {
@@ -55,6 +55,7 @@ export type TDocumentPreferencesFormSchema = {
documentDateFormat: TDocumentMetaDateFormat | null;
includeSenderDetails: boolean | null;
includeSigningCertificate: boolean | null;
includeAuditLog: boolean | null;
signatureTypes: DocumentSignatureType[];
};
@@ -66,6 +67,7 @@ type SettingsSubset = Pick<
| 'documentDateFormat'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'includeAuditLog'
| 'typedSignatureEnabled'
| 'uploadSignatureEnabled'
| 'drawSignatureEnabled'
@@ -96,6 +98,7 @@ export const DocumentPreferencesForm = ({
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
includeSenderDetails: z.boolean().nullable(),
includeSigningCertificate: z.boolean().nullable(),
includeAuditLog: z.boolean().nullable(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
message: msg`At least one signature type must be enabled`.id,
}),
@@ -112,6 +115,7 @@ export const DocumentPreferencesForm = ({
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
includeSenderDetails: settings.includeSenderDetails,
includeSigningCertificate: settings.includeSigningCertificate,
includeAuditLog: settings.includeAuditLog,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
},
resolver: zodResolver(ZDocumentPreferencesFormSchema),
@@ -452,6 +456,56 @@ export const DocumentPreferencesForm = ({
)}
/>
<FormField
control={form.control}
name="includeAuditLog"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
<Trans>Include the Audit Logs in the Document</Trans>
</FormLabel>
<FormControl>
<Select
{...field}
value={field.value === null ? '-1' : field.value.toString()}
onValueChange={(value) =>
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
}
>
<SelectTrigger className="bg-background text-muted-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">
<Trans>Yes</Trans>
</SelectItem>
<SelectItem value="false">
<Trans>No</Trans>
</SelectItem>
{canInherit && (
<SelectItem value={'-1'}>
<Trans>Inherit from organisation</Trans>
</SelectItem>
)}
</SelectContent>
</Select>
</FormControl>
<FormDescription>
<Trans>
Controls whether the audit logs will be included in the document when it is
downloaded. The audit logs can still be downloaded from the logs page
separately.
</Trans>
</FormDescription>
</FormItem>
)}
/>
<div className="flex flex-row justify-end space-x-4">
<Button type="submit" loading={form.formState.isSubmitting}>
<Trans>Update</Trans>
@@ -0,0 +1,138 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { Textarea } from '@documenso/ui/primitives/textarea';
import { useToast } from '@documenso/ui/primitives/use-toast';
const ZSupportTicketSchema = z.object({
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
type TSupportTicket = z.infer<typeof ZSupportTicketSchema>;
export type SupportTicketFormProps = {
organisationId: string;
teamId?: string | null;
onSuccess?: () => void;
onClose?: () => void;
};
export const SupportTicketForm = ({
organisationId,
teamId,
onSuccess,
onClose,
}: SupportTicketFormProps) => {
const { t } = useLingui();
const { toast } = useToast();
const { mutateAsync: submitSupportTicket, isPending } =
trpc.profile.submitSupportTicket.useMutation();
const form = useForm<TSupportTicket>({
resolver: zodResolver(ZSupportTicketSchema),
defaultValues: {
subject: '',
message: '',
},
});
const isLoading = form.formState.isLoading || isPending;
const onSubmit = async (data: TSupportTicket) => {
const { subject, message } = data;
try {
await submitSupportTicket({
subject,
message,
organisationId,
teamId,
});
toast({
title: t`Support ticket created`,
description: t`Your support request has been submitted. We'll get back to you soon!`,
});
if (onSuccess) {
onSuccess();
}
form.reset();
} catch (err) {
toast({
title: t`Failed to create support ticket`,
description: t`An error occurred. Please try again later.`,
variant: 'destructive',
});
}
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<fieldset disabled={isLoading} className="flex flex-col gap-4">
<FormField
control={form.control}
name="subject"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Subject</Trans>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel required>
<Trans>Message</Trans>
</FormLabel>
<FormControl>
<Textarea rows={5} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="mt-2 flex flex-row gap-2">
<Button type="submit" size="sm" loading={isLoading}>
<Trans>Submit</Trans>
</Button>
{onClose && (
<Button variant="outline" size="sm" type="button" onClick={onClose}>
<Trans>Close</Trans>
</Button>
)}
</div>
</fieldset>
</form>
</Form>
</>
);
};
@@ -1,5 +1,3 @@
'use client';
import { DateTime } from 'luxon';
import type { TooltipProps } from 'recharts';
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
@@ -16,7 +16,6 @@ import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/uti
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { trpc } from '@documenso/trpc/react';
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
import { Input } from '@documenso/ui/primitives/input';
import { Label } from '@documenso/ui/primitives/label';
@@ -177,15 +176,7 @@ export const DocumentSigningForm = ({
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
return (
<div
className={cn(
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
{
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': user,
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
},
)}
>
<div className="flex h-full flex-col">
{validateUninsertedFields && uninsertedFields[0] && (
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
<Trans>Click to insert field</Trans>
@@ -194,21 +185,8 @@ export const DocumentSigningForm = ({
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
<div className="flex flex-1 flex-col">
<h3 className="text-foreground text-2xl font-semibold">
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
</h3>
{recipient.role === RecipientRole.VIEWER ? (
<>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>Please mark as viewed to complete</Trans>
</p>
<hr className="border-border mb-8 mt-4" />
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
<div className="flex flex-1 flex-col gap-y-4" />
<div className="flex flex-col gap-4 md:flex-row">
@@ -245,15 +223,6 @@ export const DocumentSigningForm = ({
) : recipient.role === RecipientRole.ASSISTANT ? (
<>
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
<p className="text-muted-foreground mt-2 text-sm">
<Trans>
Complete the fields for the following signers. Once reviewed, they will inform
you if any modifications are needed.
</Trans>
</p>
<hr className="border-border my-4" />
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
<Controller
name="selectedSignerId"
@@ -340,88 +309,76 @@ export const DocumentSigningForm = ({
</>
) : (
<>
<div>
<p className="text-muted-foreground mt-2 text-sm">
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
<Trans>Please review the document before approving.</Trans>
) : (
<Trans>Please review the document before signing.</Trans>
)}
</p>
<fieldset
disabled={isSubmitting}
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
>
<div className="flex flex-1 flex-col gap-y-4">
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
</Label>
<hr className="border-border mb-8 mt-4" />
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
/>
</div>
<fieldset
disabled={isSubmitting}
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
>
<div className="flex flex-1 flex-col gap-y-4">
{hasSignatureField && (
<div>
<Label htmlFor="full-name">
<Trans>Full Name</Trans>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<Input
type="text"
id="full-name"
className="bg-background mt-2"
value={fullName}
onChange={(e) => setFullName(e.target.value.trimStart())}
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
/>
</div>
{hasSignatureField && (
<div>
<Label htmlFor="Signature">
<Trans>Signature</Trans>
</Label>
<SignaturePadDialog
className="mt-2"
disabled={isSubmitting}
value={signature ?? ''}
onChange={(v) => setSignature(v ?? '')}
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
/>
</div>
)}
</div>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
)}
</div>
</fieldset>
<div className="mt-6 flex flex-col gap-4 md:flex-row">
<Button
type="button"
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
variant="secondary"
size="lg"
disabled={typeof window !== 'undefined' && window.history.length <= 1}
onClick={async () => navigate(-1)}
>
<Trans>Cancel</Trans>
</Button>
<DocumentSigningCompleteDialog
isSubmitting={isSubmitting || isAssistantSubmitting}
documentTitle={document.title}
fields={fields}
fieldsValidated={fieldsValidated}
disabled={!isRecipientsTurn}
onSignatureComplete={async (nextSigner) => {
await completeDocument(undefined, nextSigner);
}}
role={recipient.role}
allowDictateNextSigner={
nextRecipient && document.documentMeta?.allowDictateNextSigner
}
defaultNextSigner={
nextRecipient
? { name: nextRecipient.name, email: nextRecipient.email }
: undefined
}
/>
</div>
</>
)}
@@ -3,6 +3,7 @@ import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import type { Field } from '@prisma/client';
import { FieldType, RecipientRole } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { match } from 'ts-pattern';
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
@@ -20,6 +21,7 @@ import type { CompletedField } from '@documenso/lib/types/fields';
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Button } from '@documenso/ui/primitives/button';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
@@ -62,6 +64,7 @@ export const DocumentSigningPageView = ({
const { documentData, documentMeta } = document;
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
const [isExpanded, setIsExpanded] = useState(false);
let senderName = document.user.name ?? '';
let senderEmail = `(${document.user.email})`;
@@ -77,15 +80,15 @@ export const DocumentSigningPageView = ({
return (
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
<div className="mx-auto w-full max-w-screen-xl">
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
<h1
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
title={document.title}
>
{document.title}
</h1>
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
<div className="max-w-[50ch]">
<span className="text-muted-foreground truncate" title={senderName}>
{senderName} {senderEmail}
@@ -135,26 +138,79 @@ export const DocumentSigningPageView = ({
<DocumentSigningRejectDialog document={document} token={recipient.token} />
</div>
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
<Card
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
gradient
>
<CardContent className="p-2">
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
</CardContent>
</Card>
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
<div className="flex-1">
<Card className="rounded-xl before:rounded-xl" gradient>
<CardContent className="p-2">
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
</CardContent>
</Card>
</div>
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
<DocumentSigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
/>
<div
key={isExpanded ? 'expanded' : 'collapsed'}
className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
data-expanded={isExpanded || undefined}
>
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
<div className="flex items-center justify-between gap-x-2">
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
.otherwise(() => null)}
</h3>
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
{isExpanded ? (
<LucideChevronDown
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(false)}
/>
) : (
<LucideChevronUp
className="text-muted-foreground h-5 w-5"
onClick={() => setIsExpanded(true)}
/>
)}
</Button>
</div>
<div className="hidden group-data-[expanded]/document-widget:block md:block">
<p className="text-muted-foreground mt-2 text-sm">
{match(recipient.role)
.with(RecipientRole.VIEWER, () => (
<Trans>Please mark as viewed to complete.</Trans>
))
.with(RecipientRole.SIGNER, () => (
<Trans>Please review the document before signing.</Trans>
))
.with(RecipientRole.APPROVER, () => (
<Trans>Please review the document before approving.</Trans>
))
.with(RecipientRole.ASSISTANT, () => (
<Trans>Complete the fields for the following signers.</Trans>
))
.otherwise(() => null)}
</p>
<hr className="border-border mb-8 mt-4" />
</div>
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
<DocumentSigningForm
document={document}
recipient={recipient}
fields={fields}
redirectUrl={documentMeta?.redirectUrl}
isRecipientsTurn={isRecipientsTurn}
allRecipients={allRecipients}
setSelectedSignerId={setSelectedSignerId}
/>
</div>
</div>
</div>
</div>
@@ -227,19 +227,8 @@ export const DocumentSigningTextField = ({
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
const labelDisplay =
parsedField?.label && parsedField.label.length < 20
? parsedField.label
: parsedField?.label
? parsedField?.label.substring(0, 20) + '...'
: undefined;
const textDisplay =
parsedField?.text && parsedField.text.length < 20
? parsedField.text
: parsedField?.text
? parsedField?.text.substring(0, 20) + '...'
: undefined;
const labelDisplay = parsedField?.label;
const textDisplay = parsedField?.text;
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay;
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
@@ -289,7 +289,7 @@ export const DocumentEditForm = ({
message,
distributionMethod,
emailId,
emailReplyTo,
emailReplyTo: emailReplyTo || null,
emailSettings: emailSettings,
},
});
@@ -164,7 +164,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
<DropdownMenuItem asChild>
<Link to={`${documentsPath}/${document.id}/logs`}>
<ScrollTextIcon className="mr-2 h-4 w-4" />
<Trans>Audit Log</Trans>
<Trans>Audit Logs</Trans>
</Link>
</DropdownMenuItem>
@@ -321,6 +321,19 @@ export const OrgMenuSwitcher = () => {
<Trans>Language</Trans>
</DropdownMenuItem>
{currentOrganisation && (
<DropdownMenuItem className="text-muted-foreground px-4 py-2" asChild>
<Link
to={{
pathname: `/o/${currentOrganisation.url}/support`,
search: currentTeam ? `?team=${currentTeam.id}` : '',
}}
>
<Trans>Support</Trans>
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
onSelect={async () => authClient.signOut()}
@@ -1,70 +0,0 @@
import { useMemo } from 'react';
import { Trans } from '@lingui/react/macro';
import { useLocation, useNavigate, useSearchParams } from 'react-router';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
const isPeriodSelectorValue = (value: unknown): value is PeriodSelectorValue => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return ['', '7d', '14d', '30d'].includes(value as string);
};
export const PeriodSelector = () => {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const period = useMemo(() => {
const p = searchParams?.get('period') ?? 'all';
return isPeriodSelectorValue(p) ? p : 'all';
}, [searchParams]);
const onPeriodChange = (newPeriod: string) => {
if (!pathname) {
return;
}
const params = new URLSearchParams(searchParams?.toString());
params.set('period', newPeriod);
if (newPeriod === '' || newPeriod === 'all') {
params.delete('period');
}
void navigate(`${pathname}?${params.toString()}`, { preventScrollReset: true });
};
return (
<Select defaultValue={period} onValueChange={onPeriodChange}>
<SelectTrigger className="text-muted-foreground max-w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="all">
<Trans>All Time</Trans>
</SelectItem>
<SelectItem value="7d">
<Trans>Last 7 days</Trans>
</SelectItem>
<SelectItem value="14d">
<Trans>Last 14 days</Trans>
</SelectItem>
<SelectItem value="30d">
<Trans>Last 30 days</Trans>
</SelectItem>
</SelectContent>
</Select>
);
};
@@ -0,0 +1,129 @@
import { type ReactNode, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { Loader } from 'lucide-react';
import { useDropzone } from 'react-dropzone';
import { useNavigate, useParams } from 'react-router';
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { cn } from '@documenso/ui/lib/utils';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { useCurrentTeam } from '~/providers/team';
export interface TemplateDropZoneWrapperProps {
children: ReactNode;
className?: string;
}
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const { folderId } = useParams();
const team = useCurrentTeam();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
const onFileDrop = async (file: File) => {
try {
setIsLoading(true);
const documentData = await putPdfFile(file);
const { id } = await createTemplate({
title: file.name,
templateDocumentDataId: documentData.id,
folderId: folderId ?? undefined,
});
toast({
title: _(msg`Template uploaded`),
description: _(
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
),
duration: 5000,
});
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
} catch {
toast({
title: _(msg`Something went wrong`),
description: _(msg`Please try again later.`),
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const onFileDropRejected = () => {
toast({
title: _(msg`Your template failed to upload.`),
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
duration: 5000,
variant: 'destructive',
});
};
const { getRootProps, getInputProps, isDragActive } = useDropzone({
accept: {
'application/pdf': ['.pdf'],
},
//disabled: isUploadDisabled,
multiple: false,
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
onDrop: ([acceptedFile]) => {
if (acceptedFile) {
void onFileDrop(acceptedFile);
}
},
onDropRejected: () => {
void onFileDropRejected();
},
noClick: true,
noDragEventsBubbling: true,
});
return (
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
<input {...getInputProps()} />
{children}
{isDragActive && (
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
<h2 className="text-foreground text-2xl font-semibold">
<Trans>Upload Template</Trans>
</h2>
<p className="text-muted-foreground text-md mt-4">
<Trans>Drag and drop your PDF file here</Trans>
</p>
</div>
</div>
)}
{isLoading && (
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
<Loader className="text-primary h-12 w-12 animate-spin" />
<p className="text-foreground mt-8 font-medium">
<Trans>Uploading template...</Trans>
</p>
</div>
</div>
)}
</div>
);
};
@@ -143,6 +143,7 @@ export const TemplateEditForm = ({
},
meta: {
...data.meta,
emailReplyTo: data.meta.emailReplyTo || null,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
@@ -1,80 +1,136 @@
import { useMemo } from 'react';
import { useMemo, useTransition } from 'react';
import { i18n } from '@lingui/core';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DocumentSource, DocumentStatus as DocumentStatusEnum } from '@prisma/client';
import { InfoIcon } from 'lucide-react';
import { DocumentSource } from '@prisma/client';
import { InfoIcon, Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { useSearchParams } from 'react-router';
import { z } from 'zod';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { SelectItem } from '@documenso/ui/primitives/select';
import { DataTable } from '@documenso/ui/primitives/data-table/data-table';
import {
type TimePeriod,
isDateInPeriod,
timePeriods,
} from '@documenso/ui/primitives/data-table/utils/time-filters';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
import { SearchParamSelector } from '~/components/forms/search-param-selector';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { DocumentStatus as DocumentStatusComponent } from '~/components/general/document/document-status';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { DocumentsTableActionButton } from '~/components/tables/documents-table-action-button';
import { DocumentsTableActionDropdown } from '~/components/tables/documents-table-action-dropdown';
import { DataTableTitle } from '~/components/tables/documents-table-title';
import { TemplateDocumentsTableEmptyState } from '~/components/tables/template-documents-table-empty-state';
import { useCurrentTeam } from '~/providers/team';
import { PeriodSelector } from '../period-selector';
const DOCUMENT_SOURCE_LABELS: { [key in DocumentSource]: MessageDescriptor } = {
DOCUMENT: msg`Document`,
TEMPLATE: msg`Template`,
TEMPLATE_DIRECT_LINK: msg`Direct link`,
};
const ZDocumentSearchParamsSchema = ZUrlSearchParamsSchema.extend({
source: z
.nativeEnum(DocumentSource)
.optional()
.catch(() => undefined),
status: z
.nativeEnum(DocumentStatusEnum)
.optional()
.catch(() => undefined),
});
type TemplatePageViewDocumentsTableProps = {
templateId: number;
};
type DocumentsTableRow = TFindDocumentsInternalResponse['data'][number];
export const TemplatePageViewDocumentsTable = ({
templateId,
}: TemplatePageViewDocumentsTableProps) => {
const { _, i18n } = useLingui();
const { _ } = useLingui();
const [searchParams] = useSearchParams();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
Object.fromEntries(searchParams ?? []),
);
const createFilterHandler = (paramName: string, isSingleValue = false) => {
return (values: string[]) => {
startTransition(() => {
if (values.length === 0) {
updateSearchParams({ [paramName]: undefined, page: undefined });
} else {
const value = isSingleValue ? values[0] : values.join(',');
updateSearchParams({ [paramName]: value, page: undefined });
}
});
};
};
const { data, isLoading, isLoadingError } = trpc.document.findDocuments.useQuery(
const getFilterValues = (paramName: string, isSingleValue = false): string[] => {
const value = searchParams.get(paramName);
if (!value) return [];
return isSingleValue ? [value] : value.split(',').filter(Boolean);
};
const handleStatusFilterChange = createFilterHandler('status');
const handleTimePeriodFilterChange = createFilterHandler('period', true);
const handleSourceFilterChange = createFilterHandler('source');
const selectedStatusValues = getFilterValues('status');
const selectedTimePeriodValues = getFilterValues('period', true);
const selectedSourceValues = getFilterValues('source');
const isStatusFiltered = selectedStatusValues.length > 0;
const isTimePeriodFiltered = selectedTimePeriodValues.length > 0;
const isSourceFiltered = selectedSourceValues.length > 0;
const handleResetFilters = () => {
startTransition(() => {
updateSearchParams({
status: undefined,
source: undefined,
period: undefined,
page: undefined,
});
});
};
const sourceParam = searchParams.get('source');
const statusParam = searchParams.get('status');
const periodParam = searchParams.get('period');
// Parse status parameter to handle multiple values
const parsedStatus = statusParam
? statusParam
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.filter((status) =>
Object.values(ExtendedDocumentStatus).includes(status as ExtendedDocumentStatus),
)
.map((status) => status as ExtendedDocumentStatus)
: undefined;
const parsedPeriod =
periodParam && timePeriods.includes(periodParam as TimePeriod)
? (periodParam as TimePeriod)
: undefined;
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
{
templateId,
page: parsedSearchParams.page,
perPage: parsedSearchParams.perPage,
query: parsedSearchParams.query,
source: parsedSearchParams.source,
status: parsedSearchParams.status,
page: Number(searchParams.get('page')) || 1,
perPage: Number(searchParams.get('perPage')) || 10,
query: searchParams.get('query') || undefined,
source:
sourceParam && Object.values(DocumentSource).includes(sourceParam as DocumentSource)
? (sourceParam as DocumentSource)
: undefined,
status: parsedStatus,
period: parsedPeriod,
},
{
placeholderData: (previousData) => previousData,
@@ -82,9 +138,11 @@ export const TemplatePageViewDocumentsTable = ({
);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
perPage,
startTransition(() => {
updateSearchParams({
page,
perPage,
});
});
};
@@ -95,6 +153,13 @@ export const TemplatePageViewDocumentsTable = ({
totalPages: 1,
};
const getEmptyStateStatus = (): ExtendedDocumentStatus => {
if (selectedStatusValues.length > 0) {
return selectedStatusValues[0] as ExtendedDocumentStatus;
}
return ExtendedDocumentStatus.ALL;
};
const columns = useMemo(() => {
return [
{
@@ -102,12 +167,21 @@ export const TemplatePageViewDocumentsTable = ({
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
filterFn: (row, id, value) => {
const createdAt = row.getValue(id) as Date;
if (!value || !Array.isArray(value) || value.length === 0) {
return true;
}
const period = value[0] as TimePeriod;
return isDateInPeriod(createdAt, period);
},
},
{
header: _(msg`Title`),
accessorKey: 'title',
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
header: _(msg`Recipient`),
accessorKey: 'recipient',
@@ -121,8 +195,14 @@ export const TemplatePageViewDocumentsTable = ({
{
header: _(msg`Status`),
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.getValue('status')} />,
cell: ({ row }) => <DocumentStatusComponent status={row.original.status} />,
size: 140,
filterFn: (row, id, value) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return true;
}
return value.includes(row.getValue(id));
},
},
{
header: () => (
@@ -161,79 +241,51 @@ export const TemplatePageViewDocumentsTable = ({
</Tooltip>
</div>
),
accessorKey: 'type',
accessorKey: 'source',
cell: ({ row }) => (
<div className="flex flex-row items-center">
{_(DOCUMENT_SOURCE_LABELS[row.original.source])}
{_(DOCUMENT_SOURCE_LABELS[row.original.source as DocumentSource])}
</div>
),
filterFn: (row, id, value) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return true;
}
return value.includes(row.getValue(id));
},
},
{
id: 'actions',
header: _(msg`Actions`),
cell: ({ row }) => (
<div className="flex items-center space-x-2">
<div className="flex items-center gap-x-4">
<DocumentsTableActionButton row={row.original} />
<DocumentsTableActionDropdown row={row.original} />
</div>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
] satisfies DataTableColumnDef<DocumentsTableRow>[];
}, [_, team?.url]);
return (
<div>
<div className="mb-4 flex flex-row space-x-4">
<DocumentSearch />
<SearchParamSelector
paramKey="status"
isValueValid={(value) =>
[...DocumentStatusEnum.COMPLETED].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Status</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.COMPLETED}>
<Trans>Completed</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.PENDING}>
<Trans>Pending</Trans>
</SelectItem>
<SelectItem value={DocumentStatusEnum.DRAFT}>
<Trans>Draft</Trans>
</SelectItem>
</SearchParamSelector>
<SearchParamSelector
paramKey="source"
isValueValid={(value) =>
[...DocumentSource.TEMPLATE].includes(value as unknown as string)
}
>
<SelectItem value="all">
<Trans>Any Source</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE}>
<Trans>Template</Trans>
</SelectItem>
<SelectItem value={DocumentSource.TEMPLATE_DIRECT_LINK}>
<Trans>Direct Link</Trans>
</SelectItem>
</SearchParamSelector>
<PeriodSelector />
</div>
<div className="relative">
<DataTable
columns={columns}
data={results.data}
columns={columns}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
stats={data?.stats}
onStatusFilterChange={handleStatusFilterChange}
selectedStatusValues={selectedStatusValues}
onTimePeriodFilterChange={handleTimePeriodFilterChange}
selectedTimePeriodValues={selectedTimePeriodValues}
onSourceFilterChange={handleSourceFilterChange}
selectedSourceValues={selectedSourceValues}
onResetFilters={handleResetFilters}
isStatusFiltered={isStatusFiltered}
isTimePeriodFiltered={isTimePeriodFiltered}
isSourceFiltered={isSourceFiltered}
error={{
enable: isLoadingError,
}}
@@ -265,9 +317,19 @@ export const TemplatePageViewDocumentsTable = ({
</>
),
}}
emptyState={{
enable: !isLoading && !isLoadingError,
component: <TemplateDocumentsTableEmptyState status={getEmptyStateStatus()} />,
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isPending && (
<div className="bg-background/50 absolute inset-0 flex items-center justify-center">
<Loader className="text-muted-foreground h-8 w-8 animate-spin" />
</div>
)}
</div>
);
};
@@ -6,6 +6,8 @@ import { PenIcon, PlusIcon } from 'lucide-react';
import { Link } from 'react-router';
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
export type TemplatePageViewRecipientsProps = {
@@ -53,8 +55,18 @@ export const TemplatePageViewRecipients = ({
{recipients.map((recipient) => (
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
<AvatarWithText
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
avatarFallback={
isTemplateRecipientEmailPlaceholder(recipient.email)
? extractInitials(recipient.name)
: recipient.email.slice(0, 1).toUpperCase()
}
primaryText={
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
<p className="text-muted-foreground text-sm">{recipient.name}</p>
) : (
<p className="text-muted-foreground text-sm">{recipient.email}</p>
)
}
secondaryText={
<p className="text-muted-foreground/70 text-xs">
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
@@ -1,6 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { Bird, CheckCircle2, XCircle } from 'lucide-react';
import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
@@ -25,6 +25,21 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
message: msg`There are no active drafts at the current moment. You can upload a document to start drafting.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
title: msg`No pending documents`,
message: msg`There are no pending documents at the moment. Documents awaiting signatures will appear here.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.REJECTED, () => ({
title: msg`No rejected documents`,
message: msg`There are no rejected documents. Documents that have been declined will appear here.`,
icon: XCircle,
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
title: msg`Your inbox is empty`,
message: msg`There are no documents waiting for your action. Documents requiring your signature will appear here.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: msg`We're all empty`,
message: msg`You have not yet created or received any documents. To create a document please upload one.`,
@@ -38,7 +53,7 @@ export const DocumentsTableEmptyState = ({ status }: DocumentsTableEmptyStatePro
return (
<div
className="text-muted-foreground/60 flex h-60 flex-col items-center justify-center gap-y-4"
className="text-muted-foreground/60 mt-12 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} />
@@ -1,51 +1,100 @@
import { useMemo, useTransition } from 'react';
import { i18n } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Loader } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link } from 'react-router';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import type { TFindDocumentsResponse } from '@documenso/trpc/server/document-router/schema';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TFindDocumentsInternalResponse } from '@documenso/trpc/server/document-router/schema';
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
import { DataTable } from '@documenso/ui/primitives/data-table';
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
import { DataTable } from '@documenso/ui/primitives/data-table/data-table';
import {
type TimePeriod,
isDateInPeriod,
} from '@documenso/ui/primitives/data-table/utils/time-filters';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
import { TableCell } from '@documenso/ui/primitives/table';
import { DocumentStatus } from '~/components/general/document/document-status';
import { StackAvatarsWithTooltip } from '~/components/general/stack-avatars-with-tooltip';
import { useCurrentTeam } from '~/providers/team';
import { StackAvatarsWithTooltip } from '../general/stack-avatars-with-tooltip';
import { DocumentsTableActionButton } from './documents-table-action-button';
import { DocumentsTableActionDropdown } from './documents-table-action-dropdown';
import { DocumentsTableEmptyState } from './documents-table-empty-state';
export type DocumentsTableProps = {
data?: TFindDocumentsResponse;
export type DataTableProps = {
data?: TFindDocumentsInternalResponse;
isLoading?: boolean;
isLoadingError?: boolean;
onMoveDocument?: (documentId: number) => void;
};
type DocumentsTableRow = TFindDocumentsResponse['data'][number];
type DocumentsTableRow = TFindDocumentsInternalResponse['data'][number];
export const DocumentsTable = ({
export function DocumentsDataTable({
data,
isLoading,
isLoadingError,
onMoveDocument,
}: DocumentsTableProps) => {
const { _, i18n } = useLingui();
}: DataTableProps) {
const { _ } = useLingui();
const team = useCurrentTeam();
const [isPending, startTransition] = useTransition();
const [isPending, startTransition] = useTransition();
const updateSearchParams = useUpdateSearchParams();
const [searchParams] = useSearchParams();
const handleStatusFilterChange = (values: string[]) => {
startTransition(() => {
if (values.length === 0) {
updateSearchParams({ status: undefined, page: undefined });
} else {
updateSearchParams({ status: values.join(','), page: undefined });
}
});
};
const currentStatus = searchParams.get('status');
const selectedStatusValues = currentStatus ? currentStatus.split(',').filter(Boolean) : [];
const handleResetFilters = () => {
startTransition(() => {
updateSearchParams({ status: undefined, period: undefined, page: undefined });
});
};
const isStatusFiltered = selectedStatusValues.length > 0;
const handleTimePeriodFilterChange = (values: string[]) => {
startTransition(() => {
if (values.length === 0) {
updateSearchParams({ period: undefined, page: undefined });
} else {
updateSearchParams({ period: values[0], page: undefined });
}
});
};
const currentPeriod = searchParams.get('period');
const selectedTimePeriodValues = currentPeriod ? [currentPeriod] : [];
const isTimePeriodFiltered = selectedTimePeriodValues.length > 0;
const handleSourceFilterChange = (values: string[]) => {
// Documents table doesn't have source filtering
};
const selectedSourceValues: string[] = [];
const isSourceFiltered = false;
const columns = useMemo(() => {
return [
@@ -54,9 +103,19 @@ export const DocumentsTable = ({
accessorKey: 'createdAt',
cell: ({ row }) =>
i18n.date(row.original.createdAt, { ...DateTime.DATETIME_SHORT, hourCycle: 'h12' }),
filterFn: (row, id, value) => {
const createdAt = row.getValue(id) as Date;
if (!value || !Array.isArray(value) || value.length === 0) {
return true;
}
const period = value[0] as TimePeriod;
return isDateInPeriod(createdAt, period);
},
},
{
header: _(msg`Title`),
accessorKey: 'title',
cell: ({ row }) => <DataTableTitle row={row.original} teamUrl={team?.url} />,
},
{
@@ -79,6 +138,12 @@ export const DocumentsTable = ({
accessorKey: 'status',
cell: ({ row }) => <DocumentStatus status={row.original.status} />,
size: 140,
filterFn: (row, id, value) => {
if (!value || !Array.isArray(value) || value.length === 0) {
return true;
}
return value.includes(row.getValue(id));
},
},
{
header: _(msg`Actions`),
@@ -112,18 +177,34 @@ export const DocumentsTable = ({
totalPages: 1,
};
const getEmptyStateStatus = (): ExtendedDocumentStatus => {
if (selectedStatusValues.length > 0) {
return selectedStatusValues[0] as ExtendedDocumentStatus;
}
return ExtendedDocumentStatus.ALL;
};
return (
<div className="relative">
<DataTable
columns={columns}
data={results.data}
columns={columns}
perPage={results.perPage}
currentPage={results.currentPage}
totalPages={results.totalPages}
onPaginationChange={onPaginationChange}
columnVisibility={{
sender: team !== undefined,
}}
stats={data?.stats}
onStatusFilterChange={handleStatusFilterChange}
selectedStatusValues={selectedStatusValues}
onTimePeriodFilterChange={handleTimePeriodFilterChange}
selectedTimePeriodValues={selectedTimePeriodValues}
onSourceFilterChange={handleSourceFilterChange}
selectedSourceValues={selectedSourceValues}
onResetFilters={handleResetFilters}
isStatusFiltered={isStatusFiltered}
isTimePeriodFiltered={isTimePeriodFiltered}
isSourceFiltered={isSourceFiltered}
showSourceFilter={false}
error={{
enable: isLoadingError || false,
}}
@@ -152,6 +233,10 @@ export const DocumentsTable = ({
</>
),
}}
emptyState={{
enable: !isLoading && !isLoadingError,
component: <DocumentsTableEmptyState status={getEmptyStateStatus()} />,
}}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
@@ -163,14 +248,14 @@ export const DocumentsTable = ({
)}
</div>
);
};
}
type DataTableTitleProps = {
row: DocumentsTableRow;
teamUrl: string;
};
const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
export const DataTableTitle = ({ row, teamUrl }: DataTableTitleProps) => {
const { user } = useSession();
const recipient = row.recipients.find((recipient) => recipient.email === user.email);
@@ -1,20 +1,18 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { DateTime } from 'luxon';
import type { DateTimeFormatOptions } from 'luxon';
import { DateTime } from 'luxon';
import { P, match } from 'ts-pattern';
import { UAParser } from 'ua-parser-js';
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@documenso/ui/primitives/table';
DOCUMENT_AUDIT_LOG_TYPE,
type TDocumentAuditLog,
} from '@documenso/lib/types/document-audit-logs';
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
import { cn } from '@documenso/ui/lib/utils';
import { Card, CardContent } from '@documenso/ui/primitives/card';
export type AuditLogDataTableProps = {
logs: TDocumentAuditLog[];
@@ -25,71 +23,129 @@ const dateFormat: DateTimeFormatOptions = {
hourCycle: 'h12',
};
/**
* Get the color indicator for the audit log type
*/
const getAuditLogIndicatorColor = (type: string) =>
match(type)
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => 'bg-green-500')
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => 'bg-red-500')
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => 'bg-orange-500')
.with(
P.union(
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
),
() => 'bg-blue-500',
)
.otherwise(() => 'bg-muted');
/**
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
*/
const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => {
if (!userAgent) {
return msg`N/A`;
}
const browser = userAgentInfo.browser.name;
const version = userAgentInfo.browser.version;
const os = userAgentInfo.os.name;
// If we can parse meaningful browser info, format it nicely
if (browser && os) {
const browserInfo = version ? `${browser} ${version}` : browser;
return msg`${browserInfo} on ${os}`;
}
return msg`${userAgent}`;
};
export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
const { _ } = useLingui();
const parser = new UAParser();
const uppercaseFistLetter = (text: string) => {
return text.charAt(0).toUpperCase() + text.slice(1);
};
return (
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>{_(msg`Time`)}</TableHead>
<TableHead>{_(msg`User`)}</TableHead>
<TableHead>{_(msg`Action`)}</TableHead>
<TableHead>{_(msg`IP Address`)}</TableHead>
<TableHead>{_(msg`Browser`)}</TableHead>
</TableRow>
</TableHeader>
<div className="space-y-4">
{logs.map((log, index) => {
parser.setUA(log.userAgent || '');
const formattedAction = formatDocumentAuditLogAction(_, log);
const userAgentInfo = parser.getResult();
<TableBody className="print:text-xs">
{logs.map((log, i) => (
<TableRow className="break-inside-avoid" key={i}>
<TableCell>
{DateTime.fromJSDate(log.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toLocaleString(dateFormat)}
</TableCell>
return (
<Card
key={index}
// Add top margin for the first card to ensure it's not cut off from the 2nd page onwards
className={`border shadow-sm ${index > 0 ? 'print:mt-8' : ''}`}
style={{
pageBreakInside: 'avoid',
breakInside: 'avoid',
}}
>
<CardContent className="p-4">
{/* Header Section with indicator, event type, and timestamp */}
<div className="mb-3 flex items-start justify-between">
<div className="flex items-baseline gap-3">
<div
className={cn(`h-2 w-2 rounded-full`, getAuditLogIndicatorColor(log.type))}
/>
<TableCell>
{log.name || log.email ? (
<div>
{log.name && (
<p className="break-all" title={log.name}>
{log.name}
</p>
)}
<div>
<div className="text-muted-foreground text-sm font-medium uppercase tracking-wide print:text-[8pt]">
{log.type.replace(/_/g, ' ')}
</div>
{log.email && (
<p className="text-muted-foreground break-all" title={log.email}>
{log.email}
</p>
)}
<div className="text-foreground text-sm font-medium print:text-[8pt]">
{formattedAction.description}
</div>
</div>
</div>
) : (
<p>N/A</p>
)}
</TableCell>
<TableCell>
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
</TableCell>
<div className="text-muted-foreground text-sm print:text-[8pt]">
{DateTime.fromJSDate(log.createdAt)
.setLocale(APP_I18N_OPTIONS.defaultLocale)
.toLocaleString(dateFormat)}
</div>
</div>
<TableCell>{log.ipAddress}</TableCell>
<hr className="my-4" />
<TableCell>
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Details Section - Two column layout */}
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-xs print:text-[6pt]">
<div>
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
{_(msg`User`)}
</div>
<div className="text-foreground mt-1 font-mono">{log.email || 'N/A'}</div>
</div>
<div className="text-right">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
{_(msg`IP Address`)}
</div>
<div className="text-foreground mt-1 font-mono">{log.ipAddress || 'N/A'}</div>
</div>
<div className="col-span-2">
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
{_(msg`User Agent`)}
</div>
<div className="text-foreground mt-1">
{_(formatUserAgent(log.userAgent, userAgentInfo))}
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -0,0 +1,70 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Bird, CheckCircle2 } from 'lucide-react';
import { match } from 'ts-pattern';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
export type TemplateDocumentsTableEmptyStateProps = { status: ExtendedDocumentStatus };
export const TemplateDocumentsTableEmptyState = ({
status,
}: TemplateDocumentsTableEmptyStateProps) => {
const { _ } = useLingui();
const {
title,
message,
icon: Icon,
} = match(status)
.with(ExtendedDocumentStatus.COMPLETED, () => ({
title: msg`No completed documents`,
message: msg`No documents created from this template have been completed yet. Completed documents will appear here once all recipients have signed.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.DRAFT, () => ({
title: msg`No draft documents`,
message: msg`There are no draft documents created from this template. Use this template to create a new document.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.PENDING, () => ({
title: msg`No pending documents`,
message: msg`There are no pending documents created from this template. Documents awaiting signatures will appear here.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.REJECTED, () => ({
title: msg`No rejected documents`,
message: msg`No documents created from this template have been rejected. Documents that have been declined will appear here.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.INBOX, () => ({
title: msg`No documents in inbox`,
message: msg`There are no documents from this template waiting for your action. Documents requiring your signature will appear here.`,
icon: CheckCircle2,
}))
.with(ExtendedDocumentStatus.ALL, () => ({
title: msg`No documents yet`,
message: msg`No documents have been created from this template yet. Use this template to create your first document.`,
icon: Bird,
}))
.otherwise(() => ({
title: msg`No documents found`,
message: msg`No documents created from this template match the current filters. Try adjusting your search criteria.`,
icon: CheckCircle2,
}));
return (
<div
className="text-muted-foreground/60 mt-12 flex h-60 flex-col items-center justify-center gap-y-4"
data-testid="empty-template-document-state"
>
<Icon className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">{_(title)}</h3>
<p className="mt-2 max-w-[60ch]">{_(message)}</p>
</div>
</div>
);
};
@@ -27,6 +27,7 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-dialog';
import { AdminUserResetTwoFactorDialog } from '~/components/dialogs/admin-user-reset-two-factor-dialog';
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => {
/>
</div>
<div className="mt-16 flex flex-col items-center gap-4">
{user && <AdminUserDeleteDialog user={user} />}
<div className="mt-16 flex flex-col gap-4">
{user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
</div>
</div>
);
@@ -46,6 +46,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
} = data;
@@ -54,7 +55,8 @@ export default function OrganisationSettingsDocumentPage() {
documentLanguage === null ||
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null
includeSigningCertificate === null ||
includeAuditLog === null
) {
throw new Error('Should not be possible.');
}
@@ -68,6 +70,7 @@ export default function OrganisationSettingsDocumentPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
@@ -171,7 +171,7 @@ export default function OrganisationEmailDomainSettingsPage({ params }: Route.Co
<OrganisationEmailDomainRecordsDialog
records={records}
trigger={
<Button variant="secondary">
<Button variant="outline">
<Trans>View DNS Records</Trans>
</Button>
}
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { BookIcon, HelpCircleIcon, Link2Icon } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { useSession } from '@documenso/lib/client-only/providers/session';
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { Button } from '@documenso/ui/primitives/button';
import { SupportTicketForm } from '~/components/forms/support-ticket-form';
import { appMetaTags } from '~/utils/meta';
export function meta() {
return appMetaTags('Support');
}
export default function SupportPage() {
const [showForm, setShowForm] = useState(false);
const { user } = useSession();
const organisation = useCurrentOrganisation();
const [searchParams] = useSearchParams();
const teamId = searchParams.get('team');
const subscriptionStatus = organisation.subscription?.status;
const handleSuccess = () => {
setShowForm(false);
};
const handleCloseForm = () => {
setShowForm(false);
};
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
<div className="mb-8">
<h1 className="flex flex-row items-center gap-2 text-3xl font-bold">
<HelpCircleIcon className="text-muted-foreground h-8 w-8" />
<Trans>Support</Trans>
</h1>
<p className="text-muted-foreground mt-2">
<Trans>Your current plan includes the following support channels:</Trans>
</p>
<div className="mt-6 flex flex-col gap-4">
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<BookIcon className="text-muted-foreground h-5 w-5" />
<Link
to="https://docs.documenso.com"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Documentation</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>Read our documentation to get started with Documenso.</Trans>
</p>
</div>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
<Trans>Discord</Trans>
</Link>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>
Join our community on{' '}
<Link
to="https://documen.so/discord"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
Discord
</Link>{' '}
for community support and discussion.
</Trans>
</p>
</div>
{organisation && IS_BILLING_ENABLED() && subscriptionStatus && (
<>
<div className="rounded-lg border p-4">
<h2 className="flex items-center gap-2 text-lg font-bold">
<Link2Icon className="text-muted-foreground h-5 w-5" />
<Trans>Contact us</Trans>
</h2>
<p className="text-muted-foreground mt-1">
<Trans>We'll get back to you as soon as possible via email.</Trans>
</p>
<div className="mt-4">
{!showForm ? (
<Button variant="outline" size="sm" onClick={() => setShowForm(true)}>
<Trans>Create a support ticket</Trans>
</Button>
) : (
<SupportTicketForm
organisationId={organisation.id}
teamId={teamId}
onSuccess={handleSuccess}
onClose={handleCloseForm}
/>
)}
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}
@@ -10,6 +10,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
import { Badge } from '@documenso/ui/primitives/badge';
@@ -83,6 +84,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(documentRootPath);
}
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({
document,
documentRootPath,
@@ -9,6 +9,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
import { isDocumentCompleted } from '@documenso/lib/utils/document';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
@@ -78,6 +79,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
throw redirect(`${documentRootPath}/${documentId}`);
}
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return superLoaderJson({
document: {
...document,
@@ -11,6 +11,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
import { logDocumentAccess } from '@documenso/lib/utils/logger';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { Card } from '@documenso/ui/primitives/card';
@@ -59,6 +60,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
teamId: team?.id,
});
logDocumentAccess({
request,
documentId,
userId: user.id,
});
return {
document,
documentRootPath,
@@ -170,7 +177,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
<ul className="text-muted-foreground list-inside list-disc">
{recipients.map((recipient) => (
<li key={`recipient-${recipient.id}`}>
<span className="-ml-2">{formatRecipientText(recipient)}</span>
<span>{formatRecipientText(recipient)}</span>
</li>
))}
</ul>
@@ -1,33 +1,21 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FolderType, OrganisationType } from '@prisma/client';
import { FolderType } from '@prisma/client';
import { useParams, useSearchParams } from 'react-router';
import { Link } from 'react-router';
import { z } from 'zod';
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import { trpc } from '@documenso/trpc/react';
import {
type TFindDocumentsInternalResponse,
ZFindDocumentsInternalRequestSchema,
} from '@documenso/trpc/server/document-router/schema';
import { ZFindDocumentsInternalRequestSchema } from '@documenso/trpc/server/document-router/schema';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { DocumentMoveToFolderDialog } from '~/components/dialogs/document-move-to-folder-dialog';
import { DocumentDropZoneWrapper } from '~/components/general/document/document-drop-zone-wrapper';
import { DocumentSearch } from '~/components/general/document/document-search';
import { DocumentStatus } from '~/components/general/document/document-status';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { PeriodSelector } from '~/components/general/period-selector';
import { DocumentsTable } from '~/components/tables/documents-table';
import { DocumentsTableEmptyState } from '~/components/tables/documents-table-empty-state';
import { DocumentsTableSenderFilter } from '~/components/tables/documents-table-sender-filter';
import { DocumentsDataTable } from '~/components/tables/documents-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -36,17 +24,26 @@ export function meta() {
}
const ZSearchParamsSchema = ZFindDocumentsInternalRequestSchema.pick({
status: true,
period: true,
page: true,
perPage: true,
query: true,
}).extend({
senderIds: z.string().transform(parseToIntegerArray).optional().catch([]),
status: z
.string()
.transform(
(val) =>
val
.split(',')
.map((s) => s.trim())
.filter(Boolean) as ExtendedDocumentStatus[],
)
.optional()
.catch(undefined),
});
export default function DocumentsPage() {
const organisation = useCurrentOrganisation();
const team = useCurrentTeam();
const { folderId } = useParams();
@@ -55,15 +52,6 @@ export default function DocumentsPage() {
const [isMovingDocument, setIsMovingDocument] = useState(false);
const [documentToMove, setDocumentToMove] = useState<number | null>(null);
const [stats, setStats] = useState<TFindDocumentsInternalResponse['stats']>({
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
});
const findDocumentSearchParams = useMemo(
() => ZSearchParamsSchema.safeParse(Object.fromEntries(searchParams.entries())).data || {},
[searchParams],
@@ -74,42 +62,6 @@ export default function DocumentsPage() {
folderId,
});
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
params.set('status', value);
if (value === ExtendedDocumentStatus.ALL) {
params.delete('status');
}
if (value === ExtendedDocumentStatus.INBOX && organisation.type === OrganisationType.PERSONAL) {
params.delete('status');
}
if (params.has('page')) {
params.delete('page');
}
let path = formatDocumentsPath(team.url);
if (folderId) {
path += `/f/${folderId}`;
}
if (params.toString()) {
path += `?${params.toString()}`;
}
return path;
};
useEffect(() => {
if (data?.stats) {
setStats(data.stats);
}
}, [data?.stats]);
return (
<DocumentDropZoneWrapper>
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">
@@ -128,72 +80,18 @@ export default function DocumentsPage() {
<Trans>Documents</Trans>
</h2>
</div>
<div className="-m-1 flex flex-wrap gap-x-4 gap-y-6 overflow-hidden p-1">
<Tabs value={findDocumentSearchParams.status || 'ALL'} className="overflow-x-auto">
<TabsList>
{[
ExtendedDocumentStatus.INBOX,
ExtendedDocumentStatus.PENDING,
ExtendedDocumentStatus.COMPLETED,
ExtendedDocumentStatus.DRAFT,
ExtendedDocumentStatus.ALL,
]
.filter((value) => {
if (organisation.type === OrganisationType.PERSONAL) {
return value !== ExtendedDocumentStatus.INBOX;
}
return true;
})
.map((value) => (
<TabsTrigger
key={value}
className="hover:text-foreground min-w-[60px]"
value={value}
asChild
>
<Link to={getTabHref(value)} preventScrollReset>
<DocumentStatus status={value} />
{value !== ExtendedDocumentStatus.ALL && (
<span className="ml-1 inline-block opacity-50">{stats[value]}</span>
)}
</Link>
</TabsTrigger>
))}
</TabsList>
</Tabs>
{team && <DocumentsTableSenderFilter teamId={team.id} />}
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<PeriodSelector />
</div>
<div className="flex w-48 flex-wrap items-center justify-between gap-x-2 gap-y-4">
<DocumentSearch initialValue={findDocumentSearchParams.query} />
</div>
</div>
</div>
<div className="mt-8">
<div>
{data && data.count === 0 ? (
<DocumentsTableEmptyState
status={findDocumentSearchParams.status || ExtendedDocumentStatus.ALL}
/>
) : (
<DocumentsTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
onMoveDocument={(documentId) => {
setDocumentToMove(documentId);
setIsMovingDocument(true);
}}
/>
)}
</div>
<DocumentsDataTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
onMoveDocument={(documentId) => {
setDocumentToMove(documentId);
setIsMovingDocument(true);
}}
/>
</div>
{documentToMove && (
@@ -38,6 +38,7 @@ export default function TeamsSettingsPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
signatureTypes,
} = data;
@@ -50,6 +51,7 @@ export default function TeamsSettingsPage() {
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
includeAuditLog,
...(signatureTypes.length === 0
? {
typedSignatureEnabled: null,
@@ -1,14 +1,15 @@
import { Trans } from '@lingui/react/macro';
import { FolderType } from '@prisma/client';
import { Bird } from 'lucide-react';
import { useParams, useSearchParams } from 'react-router';
import { FolderType } from '@documenso/lib/types/folder-type';
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
import { trpc } from '@documenso/trpc/react';
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
import { FolderGrid } from '~/components/general/folder/folder-grid';
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
import { TemplatesTable } from '~/components/tables/templates-table';
import { useCurrentTeam } from '~/providers/team';
import { appMetaTags } from '~/utils/meta';
@@ -18,10 +19,10 @@ export function meta() {
}
export default function TemplatesPage() {
const team = useCurrentTeam();
const { folderId } = useParams();
const [searchParams] = useSearchParams();
const { folderId } = useParams();
const team = useCurrentTeam();
const page = Number(searchParams.get('page')) || 1;
const perPage = Number(searchParams.get('perPage')) || 10;
@@ -36,51 +37,54 @@ export default function TemplatesPage() {
});
return (
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
<div className="mt-8">
<div className="flex flex-row items-center">
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<h1 className="truncate text-2xl font-semibold md:text-3xl">
<Trans>Templates</Trans>
</h1>
</div>
<TemplateDropZoneWrapper>
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
<div className="mt-8">
{data && data.count === 0 ? (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="flex flex-row items-center">
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
<AvatarFallback className="text-muted-foreground text-xs">
{team.name.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<h1 className="truncate text-2xl font-semibold md:text-3xl">
<Trans>Templates</Trans>
</h1>
</div>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload one.
</Trans>
</p>
<div className="mt-8">
{data && data.count === 0 ? (
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
<Bird className="h-12 w-12" strokeWidth={1.5} />
<div className="text-center">
<h3 className="text-lg font-semibold">
<Trans>We're all empty</Trans>
</h3>
<p className="mt-2 max-w-[50ch]">
<Trans>
You have not yet created any templates. To create a template please upload
one.
</Trans>
</p>
</div>
</div>
</div>
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
/>
)}
) : (
<TemplatesTable
data={data}
isLoading={isLoading}
isLoadingError={isLoadingError}
documentRootPath={documentRootPath}
templateRootPath={templateRootPath}
/>
)}
</div>
</div>
</div>
</div>
</TemplateDropZoneWrapper>
);
}
@@ -0,0 +1,5 @@
@media print {
html {
font-size: 10pt;
}
}
@@ -1,5 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { DateTime } from 'luxon';
import { redirect } from 'react-router';
@@ -12,10 +13,17 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-
import { getTranslations } from '@documenso/lib/utils/i18n';
import { Card, CardContent } from '@documenso/ui/primitives/card';
import appStylesheet from '~/app.css?url';
import { BrandingLogo } from '~/components/general/branding-logo';
import { InternalAuditLogTable } from '~/components/tables/internal-audit-log-table';
import type { Route } from './+types/audit-log';
import auditLogStylesheet from './audit-log.print.css?url';
export const links: Route.LinksFunction = () => [
{ rel: 'stylesheet', href: appStylesheet },
{ rel: 'stylesheet', href: auditLogStylesheet },
];
export async function loader({ request }: Route.LoaderArgs) {
const d = new URL(request.url).searchParams.get('d');
@@ -76,26 +84,32 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">{_(msg`Version History`)}</h1>
<div className="mb-6 border-b pb-4">
<h1 className="text-xl font-semibold">{_(msg`Audit Log`)}</h1>
</div>
<Card>
<CardContent className="grid grid-cols-2 gap-4 p-6 text-sm print:text-xs">
<p>
<span className="font-medium">{_(msg`Document ID`)}</span>
<span className="font-medium">
<Trans>Document ID</Trans>
</span>
<span className="mt-1 block break-words">{document.id}</span>
</p>
<p>
<span className="font-medium">{_(msg`Enclosed Document`)}</span>
<span className="font-medium">
<Trans>Enclosed Document</Trans>
</span>
<span className="mt-1 block break-words">{document.title}</span>
</p>
<p>
<span className="font-medium">{_(msg`Status`)}</span>
<span className="font-medium">
<Trans>Status</Trans>
</span>
<span className="mt-1 block">
{_(
@@ -105,7 +119,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<p>
<span className="font-medium">{_(msg`Owner`)}</span>
<span className="font-medium">
<Trans>Owner</Trans>
</span>
<span className="mt-1 block break-words">
{document.user.name} ({document.user.email})
@@ -113,7 +129,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<p>
<span className="font-medium">{_(msg`Created At`)}</span>
<span className="font-medium">
<Trans>Created At</Trans>
</span>
<span className="mt-1 block">
{DateTime.fromJSDate(document.createdAt)
@@ -123,7 +141,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<p>
<span className="font-medium">{_(msg`Last Updated`)}</span>
<span className="font-medium">
<Trans>Last Updated</Trans>
</span>
<span className="mt-1 block">
{DateTime.fromJSDate(document.updatedAt)
@@ -133,7 +153,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<p>
<span className="font-medium">{_(msg`Time Zone`)}</span>
<span className="font-medium">
<Trans>Time Zone</Trans>
</span>
<span className="mt-1 block break-words">
{document.documentMeta?.timezone ?? 'N/A'}
@@ -141,7 +163,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</p>
<div>
<p className="font-medium">{_(msg`Recipients`)}</p>
<p className="font-medium">
<Trans>Recipients</Trans>
</p>
<ul className="mt-1 list-inside list-disc">
{document.recipients.map((recipient) => (
@@ -157,11 +181,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
</CardContent>
</Card>
<Card className="mt-8">
<CardContent className="p-0">
<InternalAuditLogTable logs={auditLogs} />
</CardContent>
</Card>
<div className="mt-8">
<InternalAuditLogTable logs={auditLogs} />
</div>
<div className="my-8 flex-row-reverse">
<div className="flex items-end justify-end gap-x-4">
@@ -1,5 +1,6 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { FieldType, SigningStatus } from '@prisma/client';
import { DateTime } from 'luxon';
import { redirect } from 'react-router';
@@ -199,7 +200,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
return (
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
<div className="flex items-center">
<h1 className="my-8 text-2xl font-bold">{_(msg`Signing Certificate`)}</h1>
<h1 className="my-8 text-2xl font-bold">
<Trans>Signing Certificate</Trans>
</h1>
</div>
<Card>
@@ -207,9 +210,15 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<Table overflowHidden>
<TableHeader>
<TableRow>
<TableHead>{_(msg`Signer Events`)}</TableHead>
<TableHead>{_(msg`Signature`)}</TableHead>
<TableHead>{_(msg`Details`)}</TableHead>
<TableHead>
<Trans>Signer Events</Trans>
</TableHead>
<TableHead>
<Trans>Signature</Trans>
</TableHead>
<TableHead>
<Trans>Details</Trans>
</TableHead>
{/* <TableHead>Security</TableHead> */}
</TableRow>
</TableHeader>
@@ -229,7 +238,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`Authentication Level`)}:</span>{' '}
<span className="font-medium">
<Trans>Authentication Level</Trans>:
</span>{' '}
<span className="block">{getAuthenticationLevel(recipient.id)}</span>
</p>
</TableCell>
@@ -259,7 +270,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</div>
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`Signature ID`)}:</span>{' '}
<span className="font-medium">
<Trans>Signature ID</Trans>:
</span>{' '}
<span className="block font-mono uppercase">
{signature.secondaryId}
</span>
@@ -270,14 +283,18 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
)}
<p className="text-muted-foreground mt-2 text-sm print:text-xs">
<span className="font-medium">{_(msg`IP Address`)}:</span>{' '}
<span className="font-medium">
<Trans>IP Address</Trans>:
</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">
<span className="font-medium">{_(msg`Device`)}:</span>{' '}
<span className="font-medium">
<Trans>Device</Trans>:
</span>{' '}
<span className="inline-block">
{getDevice(logs.DOCUMENT_RECIPIENT_COMPLETED[0]?.userAgent)}
</span>
@@ -287,7 +304,9 @@ 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">
<span className="font-medium">{_(msg`Sent`)}:</span>{' '}
<span className="font-medium">
<Trans>Sent</Trans>:
</span>{' '}
<span className="inline-block">
{logs.EMAIL_SENT[0]
? DateTime.fromJSDate(logs.EMAIL_SENT[0].createdAt)
@@ -298,7 +317,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Viewed`)}:</span>{' '}
<span className="font-medium">
<Trans>Viewed</Trans>:
</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_OPENED[0]
? DateTime.fromJSDate(logs.DOCUMENT_OPENED[0].createdAt)
@@ -310,7 +331,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
{logs.DOCUMENT_RECIPIENT_REJECTED[0] ? (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Rejected`)}:</span>{' '}
<span className="font-medium">
<Trans>Rejected</Trans>:
</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_REJECTED[0]
? DateTime.fromJSDate(logs.DOCUMENT_RECIPIENT_REJECTED[0].createdAt)
@@ -321,7 +344,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
</p>
) : (
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Signed`)}:</span>{' '}
<span className="font-medium">
<Trans>Signed</Trans>:
</span>{' '}
<span className="inline-block">
{logs.DOCUMENT_RECIPIENT_COMPLETED[0]
? DateTime.fromJSDate(
@@ -335,7 +360,9 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
)}
<p className="text-muted-foreground text-sm print:text-xs">
<span className="font-medium">{_(msg`Reason`)}:</span>{' '}
<span className="font-medium">
<Trans>Reason</Trans>:
</span>{' '}
<span className="inline-block">
{recipient.signingStatus === SigningStatus.REJECTED
? recipient.rejectionReason
@@ -371,7 +398,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
<div className="flex items-end justify-end gap-x-4">
<p className="flex-shrink-0 text-sm font-medium print:text-xs">
{_(msg`Signing certificate provided by`)}:
<Trans>Signing certificate provided by</Trans>:
</p>
<BrandingLogo className="max-h-6 print:max-h-4" />
</div>
@@ -1,5 +1,6 @@
import { useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
@@ -51,8 +52,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
if (!configuration || !configuration.documentData) {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Please configure the document first'),
title: _(msg`Error`),
description: _(msg`Please configure the document first`),
});
return;
@@ -103,8 +104,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
});
toast({
title: _('Success'),
description: _('Document created successfully'),
title: _(msg`Success`),
description: _(msg`Document created successfully`),
});
// Send a message to the parent window with the document details
@@ -130,8 +131,8 @@ export default function EmbeddingAuthoringDocumentCreatePage() {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Failed to create document'),
title: _(msg`Error`),
description: _(msg`Failed to create document`),
});
}
};
@@ -1,5 +1,6 @@
import { useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { useNavigate } from 'react-router';
@@ -49,8 +50,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
if (!configuration || !configuration.documentData) {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Please configure the template first'),
title: _(msg`Error`),
description: _(msg`Please configure the template first`),
});
return;
@@ -93,8 +94,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
});
toast({
title: _('Success'),
description: _('Template created successfully'),
title: _(msg`Success`),
description: _(msg`Template created successfully`),
});
// Send a message to the parent window with the template details
@@ -120,8 +121,8 @@ export default function EmbeddingAuthoringTemplateCreatePage() {
toast({
variant: 'destructive',
title: _('Error'),
description: _('Failed to create template'),
title: _(msg`Error`),
description: _(msg`Failed to create template`),
});
}
};
+1 -1
View File
@@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4"
},
"version": "1.12.2-rc.2"
"version": "1.12.2-rc.4"
}
+53 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.12.2-rc.2",
"version": "1.12.2-rc.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.12.2-rc.2",
"version": "1.12.2-rc.4",
"workspaces": [
"apps/*",
"packages/*"
@@ -89,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.12.2-rc.2",
"version": "1.12.2-rc.4",
"dependencies": {
"@documenso/api": "*",
"@documenso/assets": "*",
@@ -3522,6 +3522,15 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@graphql-typed-document-node/core": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz",
"integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==",
"license": "MIT",
"peerDependencies": {
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.13.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz",
@@ -11826,6 +11835,20 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@team-plain/typescript-sdk": {
"version": "5.9.0",
"resolved": "https://registry.npmjs.org/@team-plain/typescript-sdk/-/typescript-sdk-5.9.0.tgz",
"integrity": "sha512-AHSXyt1kDt74m9YKZBCRCd6cQjB8QjUNr9cehtR2QHzZ/8yXJPzawPJDqOQ3ms5KvwuYrBx2qT3e6C/zrQ5UtA==",
"license": "MIT",
"dependencies": {
"@graphql-typed-document-node/core": "^3.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"graphql": "^16.6.0",
"lodash.get": "^4.4.2",
"zod": "3.22.4"
}
},
"node_modules/@theguild/remark-mermaid": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
@@ -13235,7 +13258,6 @@
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -13248,6 +13270,23 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/ansi-escapes": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@@ -18771,7 +18810,6 @@
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"funding": [
{
"type": "github",
@@ -19847,6 +19885,15 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"license": "MIT"
},
"node_modules/graphql": {
"version": "16.11.0",
"resolved": "https://registry.npmjs.org/graphql/-/graphql-16.11.0.tgz",
"integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==",
"license": "MIT",
"engines": {
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
@@ -22329,7 +22376,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT"
},
"node_modules/json-stable-stringify-without-jsonify": {
@@ -30570,7 +30616,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -36583,6 +36628,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "1.12.2-rc.2",
"version": "1.12.2-rc.4",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
+2
View File
@@ -330,6 +330,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
userId: user.id,
teamId: team?.id,
formValues: body.formValues,
folderId: body.folderId,
documentDataId: documentData.id,
requestMetadata: metadata,
});
@@ -736,6 +737,7 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
teamId: team?.id,
recipients: body.recipients,
prefillFields: body.prefillFields,
folderId: body.folderId,
override: {
title: body.title,
...body.meta,
+12
View File
@@ -136,6 +136,12 @@ export type TUploadDocumentSuccessfulSchema = z.infer<typeof ZUploadDocumentSucc
export const ZCreateDocumentMutationSchema = z.object({
title: z.string().min(1),
externalId: z.string().nullish(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z.array(
z.object({
name: z.string().min(1),
@@ -287,6 +293,12 @@ export type TCreateDocumentFromTemplateMutationResponseSchema = z.infer<
export const ZGenerateDocumentFromTemplateMutationSchema = z.object({
title: z.string().optional(),
externalId: z.string().optional(),
folderId: z
.string()
.describe(
'The ID of the folder to create the document in. If not provided, the document will be created in the root folder.',
)
.optional(),
recipients: z
.array(
z.object({
@@ -1178,13 +1178,12 @@ test.describe('Unauthorized Access - Document API V2', () => {
const { user: firstRecipientUser } = await seedUser();
const { user: secondRecipientUser } = await seedUser();
await prisma.template.update({
const updatedTemplate = await prisma.template.update({
where: { id: template.id },
data: {
recipients: {
create: [
{
id: firstRecipientUser.id,
name: firstRecipientUser.name || '',
email: firstRecipientUser.email,
token: nanoid(12),
@@ -1193,7 +1192,6 @@ test.describe('Unauthorized Access - Document API V2', () => {
signingStatus: SigningStatus.NOT_SIGNED,
},
{
id: secondRecipientUser.id,
name: secondRecipientUser.name || '',
email: secondRecipientUser.email,
token: nanoid(12),
@@ -1204,21 +1202,35 @@ test.describe('Unauthorized Access - Document API V2', () => {
],
},
},
include: {
recipients: true,
},
});
const recipientAId = updatedTemplate.recipients.find(
(recipient) => recipient.email === firstRecipientUser.email,
)?.id;
const recipientBId = updatedTemplate.recipients.find(
(recipient) => recipient.email === secondRecipientUser.email,
)?.id;
if (!recipientAId || !recipientBId) {
throw new Error('Recipient IDs not found');
}
const res = await request.post(`${WEBAPP_BASE_URL}/api/v2-beta/template/use`, {
headers: { Authorization: `Bearer ${tokenB}` },
data: {
templateId: template.id,
recipients: [
{
id: firstRecipientUser.id,
id: recipientAId,
name: firstRecipientUser.name,
email: firstRecipientUser.email,
role: RecipientRole.SIGNER,
},
{
id: secondRecipientUser.id,
id: recipientBId,
name: secondRecipientUser.name,
email: secondRecipientUser.email,
role: RecipientRole.SIGNER,
@@ -379,10 +379,11 @@ test('[TEAMS]: can create a template inside a template folder', async ({ page })
.filter({ hasText: /^Upload Template DocumentDrag & drop your PDF here\.$/ })
.nth(2)
.click();
await page.locator('input[type="file"]').waitFor({ state: 'attached' });
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 page.waitForTimeout(3000);
@@ -268,7 +268,7 @@ test('[TEMPLATE]: should create a document from a template with custom document'
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
@@ -361,7 +361,7 @@ test('[TEMPLATE]: should create a team document from a template with custom docu
// Upload document.
const [fileChooser] = await Promise.all([
page.waitForEvent('filechooser'),
page.locator('input[type=file]').evaluate((e) => {
page.getByTestId('template-use-dialog-file-input').evaluate((e) => {
if (e instanceof HTMLInputElement) {
e.click();
}
@@ -144,10 +144,11 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.getByRole('button', { name: 'Use Template' }).click();
// Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click();
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').click();
await page.getByPlaceholder('Recipient 1').fill('name');
// Get input with Email label placeholder.
await page.getByLabel('Email').click();
await page.getByLabel('Email').fill(teamMemberUser.email);
await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/);
+1 -1
View File
@@ -13,4 +13,4 @@ export const IS_BILLING_ENABLED = () => env('NEXT_PUBLIC_FEATURE_BILLING_ENABLED
export const API_V2_BETA_URL = '/api/v2-beta';
export const SUPPORT_EMAIL = 'support@documenso.com';
export const SUPPORT_EMAIL = env('NEXT_PUBLIC_SUPPORT_EMAIL') ?? 'support@documenso.com';
+6
View File
@@ -9,6 +9,7 @@ export const VALID_DATE_FORMAT_VALUES = [
'yyyy-MM-dd',
'dd/MM/yyyy hh:mm a',
'MM/dd/yyyy hh:mm a',
'dd.MM.yyyy HH:mm',
'yyyy-MM-dd HH:mm',
'yy-MM-dd hh:mm a',
'yyyy-MM-dd HH:mm:ss',
@@ -40,6 +41,11 @@ export const DATE_FORMATS = [
label: 'MM/DD/YYYY',
value: 'MM/dd/yyyy hh:mm a',
},
{
key: 'DDMMYYYYHHMM',
label: 'DD.MM.YYYY HH:mm',
value: 'dd.MM.yyyy HH:mm',
},
{
key: 'YYYYMMDDHHmm',
label: 'YYYY-MM-DD HH:mm',
+12 -3
View File
@@ -49,15 +49,24 @@ type DocumentSignatureTypeData = {
export const DOCUMENT_SIGNATURE_TYPES = {
[DocumentSignatureType.DRAW]: {
label: msg`Draw`,
label: msg({
message: `Draw`,
context: `Draw signatute type`,
}),
value: DocumentSignatureType.DRAW,
},
[DocumentSignatureType.TYPE]: {
label: msg`Type`,
label: msg({
message: `Type`,
context: `Type signatute type`,
}),
value: DocumentSignatureType.TYPE,
},
[DocumentSignatureType.UPLOAD]: {
label: msg`Upload`,
label: msg({
message: `Upload`,
context: `Upload signatute type`,
}),
value: DocumentSignatureType.UPLOAD,
},
} satisfies Record<DocumentSignatureType, DocumentSignatureTypeData>;
+100 -25
View File
@@ -4,39 +4,114 @@ import { RecipientRole } from '@prisma/client';
export const RECIPIENT_ROLES_DESCRIPTION = {
[RecipientRole.APPROVER]: {
actionVerb: msg`Approve`,
actioned: msg`Approved`,
progressiveVerb: msg`Approving`,
roleName: msg`Approver`,
roleNamePlural: msg`Approvers`,
actionVerb: msg({
message: `Approve`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Approved`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Approving`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Approver`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Approvers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.CC]: {
actionVerb: msg`CC`,
actioned: msg`CC'd`,
progressiveVerb: msg`CC`,
roleName: msg`Cc`,
roleNamePlural: msg`Ccers`,
actionVerb: msg({
message: `CC`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `CC'd`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `CC`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Cc`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Ccers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.SIGNER]: {
actionVerb: msg`Sign`,
actioned: msg`Signed`,
progressiveVerb: msg`Signing`,
roleName: msg`Signer`,
roleNamePlural: msg`Signers`,
actionVerb: msg({
message: `Sign`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Signed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Signing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Signer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Signers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.VIEWER]: {
actionVerb: msg`View`,
actioned: msg`Viewed`,
progressiveVerb: msg`Viewing`,
roleName: msg`Viewer`,
roleNamePlural: msg`Viewers`,
actionVerb: msg({
message: `View`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Viewed`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Viewing`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Viewer`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Viewers`,
context: `Recipient role plural name`,
}),
},
[RecipientRole.ASSISTANT]: {
actionVerb: msg`Assist`,
actioned: msg`Assisted`,
progressiveVerb: msg`Assisting`,
roleName: msg`Assistant`,
roleNamePlural: msg`Assistants`,
actionVerb: msg({
message: `Assist`,
context: `Recipient role action verb`,
}),
actioned: msg({
message: `Assisted`,
context: `Recipient role actioned`,
}),
progressiveVerb: msg({
message: `Assisting`,
context: `Recipient role progressive verb`,
}),
roleName: msg({
message: `Assistant`,
context: `Recipient role name`,
}),
roleNamePlural: msg({
message: `Assistants`,
context: `Recipient role plural name`,
}),
},
} satisfies Record<keyof typeof RecipientRole, unknown>;
+4
View File
@@ -3,6 +3,10 @@ import { msg } from '@lingui/core/macro';
export const TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX = /recipient\.\d+@documenso\.com/i;
export const TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX = /Recipient \d+/i;
export const isTemplateRecipientEmailPlaceholder = (email: string) => {
return TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX.test(email);
};
export const DIRECT_TEMPLATE_DOCUMENTATION = [
{
title: msg`Enable Direct Link Signing`,
@@ -48,7 +48,7 @@ export const run = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const { documentMeta, user: documentOwner } = document;
@@ -76,7 +76,7 @@ export const run = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const assetBaseUrl = NEXT_PUBLIC_WEBAPP_URL() || 'http://localhost:3000';
@@ -68,7 +68,7 @@ export const run = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const i18n = await getI18nInstance(emailLanguage);
@@ -86,7 +86,7 @@ export const run = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const customEmail = document?.documentMeta;
@@ -9,6 +9,7 @@ import { signPdf } from '@documenso/signing';
import { AppError, AppErrorCode } from '../../../errors/app-error';
import { sendCompletedEmail } from '../../../server-only/document/send-completed-email';
import PostHogServerClient from '../../../server-only/feature-flags/get-post-hog-server-client';
import { getAuditLogsPdf } from '../../../server-only/htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../../../server-only/htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../../../server-only/pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../../../server-only/pdf/flatten-annotations';
@@ -145,7 +146,24 @@ export const run = async ({
? await getCertificatePdf({
documentId,
language: document.documentMeta?.language,
}).catch(() => null)
}).catch((e) => {
console.log('Failed to get certificate PDF');
console.error(e);
return null;
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const newDataId = await io.runTask('decorate-and-sign-pdf', async () => {
@@ -174,6 +192,16 @@ export const run = async ({
});
}
if (auditLogData) {
const auditLogDoc = await PDFDocument.load(auditLogData);
const auditLogPages = await pdfDoc.copyPages(auditLogDoc, auditLogDoc.getPageIndices());
auditLogPages.forEach((page) => {
pdfDoc.addPage(page);
});
}
for (const field of fields) {
if (field.inserted) {
document.useLegacyFieldInsertion
+1
View File
@@ -33,6 +33,7 @@
"@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0",
"inngest": "^3.19.13",
+7
View File
@@ -0,0 +1,7 @@
import { PlainClient } from '@team-plain/typescript-sdk';
import { env } from '@documenso/lib/utils/env';
export const plainClient = new PlainClient({
apiKey: env('NEXT_PRIVATE_PLAIN_API_KEY') ?? '',
});
@@ -1,6 +1,7 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import {
DocumentSource,
FolderType,
RecipientRole,
SendStatus,
SigningStatus,
@@ -45,6 +46,7 @@ export type CreateDocumentOptions = {
globalActionAuth?: TDocumentActionAuthTypes[];
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
folderId?: string;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
@@ -59,7 +61,7 @@ export const createDocumentV2 = async ({
meta,
requestMetadata,
}: CreateDocumentOptions) => {
const { title, formValues } = data;
const { title, formValues, folderId } = data;
const team = await prisma.team.findFirst({
where: buildTeamWhereQuery({ teamId, userId }),
@@ -78,6 +80,22 @@ export const createDocumentV2 = async ({
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
@@ -164,6 +182,7 @@ export const createDocumentV2 = async ({
teamId,
authOptions,
visibility,
folderId,
formValues,
source: DocumentSource.DOCUMENT,
documentMeta: {
@@ -212,7 +231,7 @@ export const createDocumentV2 = async ({
}),
);
// Todo: Is it necessary to create a full audit log with all fields and recipients audit logs?
// Todo: Is it necessary to create a full audit logs with all fields and recipients audit logs?
await tx.documentAuditLog.create({
data: createDocumentAuditLogData({
@@ -156,7 +156,7 @@ const handleDocumentOwnerDelete = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
// Soft delete completed documents.
@@ -5,27 +5,26 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TimePeriod } from '@documenso/ui/primitives/data-table/utils/time-filters';
import { DocumentVisibility } from '../../types/document-visibility';
import { type FindResultResponse } from '../../types/search-params';
import { maskRecipientTokensForDocument } from '../../utils/mask-recipient-tokens-for-document';
import { getTeamById } from '../team/get-team';
export type PeriodSelectorValue = '' | '7d' | '14d' | '30d';
export type FindDocumentsOptions = {
userId: number;
teamId?: number;
templateId?: number;
source?: DocumentSource;
status?: ExtendedDocumentStatus;
status?: ExtendedDocumentStatus[];
page?: number;
perPage?: number;
orderBy?: {
column: keyof Omit<Document, 'document'>;
direction: 'asc' | 'desc';
};
period?: PeriodSelectorValue;
period?: TimePeriod;
senderIds?: number[];
query?: string;
folderId?: string;
@@ -36,7 +35,7 @@ export const findDocuments = async ({
teamId,
templateId,
source,
status = ExtendedDocumentStatus.ALL,
status = [ExtendedDocumentStatus.ALL],
page = 1,
perPage = 10,
orderBy,
@@ -106,10 +105,30 @@ export const findDocuments = async ({
},
];
let filters: Prisma.DocumentWhereInput | null = findDocumentsFilter(status, user, folderId);
let filters: Prisma.DocumentWhereInput | null = null;
if (status.length === 1) {
filters = findDocumentsFilter(status[0], user, folderId);
} else if (status.length > 1) {
const statusFilters = status
.map((s) => findDocumentsFilter(s, user, folderId))
.filter((filter): filter is Prisma.DocumentWhereInput => filter !== null);
if (statusFilters.length > 0) {
filters = { OR: statusFilters };
}
}
if (team) {
filters = findTeamDocumentsFilter(status, team, visibilityFilters, folderId);
if (status.length === 1) {
filters = findTeamDocumentsFilter(status[0], team, visibilityFilters, folderId);
} else if (status.length > 1) {
const statusFilters = status
.map((s) => findTeamDocumentsFilter(s, team, visibilityFilters, folderId))
.filter((filter): filter is Prisma.DocumentWhereInput => filter !== null);
if (statusFilters.length > 0) {
filters = { OR: statusFilters };
}
}
}
if (filters === null) {
@@ -197,13 +216,73 @@ export const findDocuments = async ({
AND: whereAndClause,
};
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
if (period && period !== 'all-time') {
const now = DateTime.now();
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
const { startDate, endDate } = match(period)
.with('today', () => ({
startDate: now.startOf('day'),
endDate: now.startOf('day').plus({ days: 1 }),
}))
.with('yesterday', () => {
const yesterday = now.minus({ days: 1 });
return {
startDate: yesterday.startOf('day'),
endDate: yesterday.startOf('day').plus({ days: 1 }),
};
})
.with('this-week', () => ({
startDate: now.startOf('week'),
endDate: now.startOf('week').plus({ weeks: 1 }),
}))
.with('last-week', () => {
const lastWeek = now.minus({ weeks: 1 });
return {
startDate: lastWeek.startOf('week'),
endDate: lastWeek.startOf('week').plus({ weeks: 1 }),
};
})
.with('this-month', () => ({
startDate: now.startOf('month'),
endDate: now.startOf('month').plus({ months: 1 }),
}))
.with('last-month', () => {
const lastMonth = now.minus({ months: 1 });
return {
startDate: lastMonth.startOf('month'),
endDate: lastMonth.startOf('month').plus({ months: 1 }),
};
})
.with('this-quarter', () => ({
startDate: now.startOf('quarter'),
endDate: now.startOf('quarter').plus({ quarters: 1 }),
}))
.with('last-quarter', () => {
const lastQuarter = now.minus({ quarters: 1 });
return {
startDate: lastQuarter.startOf('quarter'),
endDate: lastQuarter.startOf('quarter').plus({ quarters: 1 }),
};
})
.with('this-year', () => ({
startDate: now.startOf('year'),
endDate: now.startOf('year').plus({ years: 1 }),
}))
.with('last-year', () => {
const lastYear = now.minus({ years: 1 });
return {
startDate: lastYear.startOf('year'),
endDate: lastYear.startOf('year').plus({ years: 1 }),
};
})
.otherwise(() => ({
startDate: now.startOf('day'),
endDate: now.startOf('day').plus({ days: 1 }),
}));
whereClause.createdAt = {
gte: startOfPeriod.toJSDate(),
gte: startDate.toJSDate(),
lt: endDate.toJSDate(),
};
}
@@ -111,7 +111,7 @@ export const getDocumentWhereInput = async ({
visibility: {
in: teamVisibilityFilters,
},
teamId,
teamId: team.id,
},
// Or, if they are a recipient of the document.
{
+12 -13
View File
@@ -1,19 +1,17 @@
import { TeamMemberRole } from '@prisma/client';
import type { Prisma, User } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { DocumentVisibility } from '@prisma/client';
import { DateTime } from 'luxon';
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@prisma/client';
import { match } from 'ts-pattern';
import type { PeriodSelectorValue } from '@documenso/lib/server-only/document/find-documents';
import { prisma } from '@documenso/prisma';
import { isExtendedDocumentStatus } from '@documenso/prisma/guards/is-extended-document-status';
import { ExtendedDocumentStatus } from '@documenso/prisma/types/extended-document-status';
import type { TimePeriod } from '@documenso/ui/primitives/data-table/utils/time-filters';
import { getDateRangeForPeriod } from '@documenso/ui/primitives/data-table/utils/time-filters';
export type GetStatsInput = {
user: Pick<User, 'id' | 'email'>;
team?: Omit<GetTeamCountsOption, 'createdAt'>;
period?: PeriodSelectorValue;
period?: TimePeriod;
search?: string;
folderId?: string;
};
@@ -27,14 +25,15 @@ export const getStats = async ({
}: GetStatsInput) => {
let createdAt: Prisma.DocumentWhereInput['createdAt'];
if (period) {
const daysAgo = parseInt(period.replace(/d$/, ''), 10);
if (period && period !== 'all-time') {
const dateRange = getDateRangeForPeriod(period);
const startOfPeriod = DateTime.now().minus({ days: daysAgo }).startOf('day');
createdAt = {
gte: startOfPeriod.toJSDate(),
};
if (dateRange) {
createdAt = {
gte: dateRange.start.toJSDate(),
lte: dateRange.end.toJSDate(),
};
}
}
const [ownerCounts, notSignedCounts, hasSignedCounts] = await (options.team
@@ -102,7 +102,7 @@ export const resendDocument = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
await Promise.all(
@@ -17,6 +17,7 @@ import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { getFileServerSide } from '../../universal/upload/get-file.server';
import { putPdfFileServerSide } from '../../universal/upload/put-file.server';
import { fieldsContainUnsignedRequiredField } from '../../utils/advanced-fields-helpers';
import { getAuditLogsPdf } from '../htmltopdf/get-audit-logs-pdf';
import { getCertificatePdf } from '../htmltopdf/get-certificate-pdf';
import { addRejectionStampToPdf } from '../pdf/add-rejection-stamp-to-pdf';
import { flattenAnnotations } from '../pdf/flatten-annotations';
@@ -125,6 +126,18 @@ export const sealDocument = async ({
})
: null;
const auditLogData = settings.includeAuditLog
? await getAuditLogsPdf({
documentId,
language: document.documentMeta?.language,
}).catch((e) => {
console.log('Failed to get audit logs PDF');
console.error(e);
return null;
})
: null;
const doc = await PDFDocument.load(pdfData);
// Normalize and flatten layers that could cause issues with the signature
@@ -147,6 +160,16 @@ export const sealDocument = async ({
});
}
if (auditLogData) {
const auditLog = await PDFDocument.load(auditLogData);
const auditLogPages = await doc.copyPages(auditLog, auditLog.getPageIndices());
auditLogPages.forEach((page) => {
doc.addPage(page);
});
}
for (const field of fields) {
document.useLegacyFieldInsertion
? await legacy_insertFieldInPDF(doc, field)
@@ -59,7 +59,7 @@ export const sendCompletedEmail = async ({ documentId, requestMetadata }: SendDo
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const { user: owner } = document;
@@ -49,7 +49,7 @@ export const sendDeleteEmail = async ({ documentId, reason }: SendDeleteEmailOpt
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const { email, name } = document.user;
@@ -51,7 +51,7 @@ export const sendPendingEmail = async ({ documentId, recipientId }: SendPendingE
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const isDocumentPendingEmailEnabled = extractDerivedDocumentEmailSettings(
@@ -46,7 +46,7 @@ export const superDeleteDocument = async ({ id, requestMetadata }: SuperDeleteDo
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const { status, user } = document;
@@ -59,7 +59,7 @@ type RecipientGetEmailContextOptions = BaseGetEmailContextOptions & {
* Force meta options as a typesafe way to ensure developers don't forget to
* pass it in if it is available.
*/
meta: EmailMetaOption | null;
meta: EmailMetaOption | null | undefined;
};
type GetEmailContextOptions = InternalGetEmailContextOptions | RecipientGetEmailContextOptions;
@@ -104,7 +104,7 @@ export const getEmailContext = async (
}
const replyToEmail = meta?.emailReplyTo || emailContext.settings.emailReplyTo || undefined;
const senderEmailId = meta?.emailId || emailContext.settings.emailId;
const senderEmailId = meta?.emailId === null ? null : emailContext.settings.emailId;
const foundSenderEmail = emailContext.allowedEmails.find((email) => email.id === senderEmailId);
@@ -0,0 +1,83 @@
import { DateTime } from 'luxon';
import type { Browser } from 'playwright';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { type SupportedLanguageCodes, isValidLanguageCode } from '../../constants/i18n';
import { env } from '../../utils/env';
import { encryptSecondaryData } from '../crypto/encrypt';
export type GetAuditLogsPdfOptions = {
documentId: number;
// eslint-disable-next-line @typescript-eslint/ban-types
language?: SupportedLanguageCodes | (string & {});
};
export const getAuditLogsPdf = async ({ documentId, language }: GetAuditLogsPdfOptions) => {
const { chromium } = await import('playwright');
const encryptedId = encryptSecondaryData({
data: documentId.toString(),
expiresAt: DateTime.now().plus({ minutes: 5 }).toJSDate().valueOf(),
});
let browser: Browser;
const browserlessUrl = env('NEXT_PRIVATE_BROWSERLESS_URL');
if (browserlessUrl) {
// !: Use CDP rather than the default `connect` method to avoid coupling to the playwright version.
// !: Previously we would have to keep the playwright version in sync with the browserless version to avoid errors.
browser = await chromium.connectOverCDP(browserlessUrl);
} else {
browser = await chromium.launch();
}
if (!browser) {
throw new Error(
'Failed to establish a browser, please ensure you have either a Browserless.io url or chromium browser installed',
);
}
const browserContext = await browser.newContext();
const page = await browserContext.newPage();
const lang = isValidLanguageCode(language) ? language : 'en';
await page.context().addCookies([
{
name: 'language',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
]);
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/__htmltopdf/audit-log?d=${encryptedId}`, {
waitUntil: 'networkidle',
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();
void browser.close();
return result;
};
@@ -46,7 +46,7 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
await page.context().addCookies([
{
name: 'language',
name: 'lang',
value: lang,
url: NEXT_PUBLIC_WEBAPP_URL(),
},
@@ -57,8 +57,22 @@ export const getCertificatePdf = async ({ documentId, language }: GetCertificate
timeout: 10_000,
});
// !: This is a workaround to ensure the page is loaded correctly.
// !: It's not clear why but suddenly browserless cdp connections would
// !: cause the page to render blank until a reload is performed.
await page.reload({
waitUntil: 'networkidle',
timeout: 10_000,
});
await page.waitForSelector('h1', {
state: 'visible',
timeout: 10_000,
});
const result = await page.pdf({
format: 'A4',
printBackground: true,
});
await browserContext.close();
@@ -2,8 +2,10 @@ import type { Prisma } from '@prisma/client';
import { OrganisationType } from '@prisma/client';
import { OrganisationMemberRole } from '@prisma/client';
import { createCustomer } from '@documenso/ee/server-only/stripe/create-customer';
import { prisma } from '@documenso/prisma';
import { IS_BILLING_ENABLED } from '../../constants/app';
import { ORGANISATION_INTERNAL_GROUPS } from '../../constants/organisations';
import { AppErrorCode } from '../../errors/app-error';
import { AppError } from '../../errors/app-error';
@@ -30,6 +32,33 @@ export const createOrganisation = async ({
customerId,
claim,
}: CreateOrganisationOptions) => {
let customerIdToUse = customerId;
if (!customerId && IS_BILLING_ENABLED()) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
customerIdToUse = await createCustomer({
name: user.name || user.email,
email: user.email,
})
.then((customer) => customer.id)
.catch((err) => {
console.error(err);
return undefined;
});
}
return await prisma.$transaction(async (tx) => {
const organisationSetting = await tx.organisationGlobalSettings.create({
data: {
@@ -64,7 +93,7 @@ export const createOrganisation = async ({
id: generateDatabaseId('org_group'),
})),
},
customerId,
customerId: customerIdToUse,
},
include: {
groups: true,
@@ -3,6 +3,7 @@ import type { PDFDocument } from 'pdf-lib';
import { TextAlignment, rgb, setFontAndSize } from 'pdf-lib';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { getPageSize } from './get-page-size';
/**
* Adds a rejection stamp to each page of a PDF document.
@@ -27,7 +28,7 @@ export async function addRejectionStampToPdf(
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const { width, height } = page.getSize();
const { width, height } = getPageSize(page);
// Draw the "REJECTED" text
const rejectedTitleText = 'DOCUMENT REJECTED';
@@ -0,0 +1,18 @@
import type { PDFPage } from 'pdf-lib';
/**
* Gets the effective page size for PDF operations.
*
* Uses CropBox by default to handle rare cases where MediaBox is larger than CropBox.
* Falls back to MediaBox when it's smaller than CropBox, following typical PDF reader behavior.
*/
export const getPageSize = (page: PDFPage) => {
const cropBox = page.getCropBox();
const mediaBox = page.getMediaBox();
if (mediaBox.width < cropBox.width || mediaBox.height < cropBox.height) {
return mediaBox;
}
return cropBox;
};
@@ -33,6 +33,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { getPageSize } from './get-page-size';
export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
@@ -77,7 +78,7 @@ export const insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignatu
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
@@ -26,6 +26,7 @@ import {
ZRadioFieldMeta,
ZTextFieldMeta,
} from '../../types/field-meta';
import { getPageSize } from './get-page-size';
export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWithSignature) => {
const [fontCaveat, fontNoto] = await Promise.all([
@@ -63,7 +64,7 @@ export const legacy_insertFieldInPDF = async (pdf: PDFDocument, field: FieldWith
const isPageRotatedToLandscape = pageRotationInDegrees === 90 || pageRotationInDegrees === 270;
let { width: pageWidth, height: pageHeight } = page.getSize();
let { width: pageWidth, height: pageHeight } = getPageSize(page);
// PDFs can have pages that are rotated, which are correctly rendered in the frontend.
// However when we load the PDF in the backend, the rotation is applied.
@@ -130,7 +130,7 @@ export const deleteDocumentRecipient = async ({
type: 'team',
teamId: document.teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const [html, text] = await Promise.all([
@@ -95,7 +95,7 @@ export const setDocumentRecipients = async ({
type: 'team',
teamId,
},
meta: document.documentMeta || null,
meta: document.documentMeta,
});
const recipientsHaveActionAuth = recipients.some(
@@ -134,6 +134,9 @@ export const setDocumentRecipients = async ({
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
);
const canPersistedRecipientBeModified =
existing && canRecipientBeModified(existing, document.fields);
if (
existing &&
hasRecipientBeenChanged(existing, recipient) &&
@@ -147,6 +150,7 @@ export const setDocumentRecipients = async ({
return {
...recipient,
_persisted: existing,
canPersistedRecipientBeModified,
};
});
@@ -162,6 +166,13 @@ export const setDocumentRecipients = async ({
});
}
if (recipient._persisted && !recipient.canPersistedRecipientBeModified) {
return {
...recipient._persisted,
clientId: recipient.clientId,
};
}
const upsertedRecipient = await tx.recipient.upsert({
where: {
id: recipient._persisted?.id ?? -1,
@@ -1,7 +1,5 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } from '@prisma/client';
import { isDeepEqual } from 'remeda';
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@@ -104,10 +102,7 @@ export const updateDocumentRecipients = async ({
});
}
if (
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.fields)
) {
if (!canRecipientBeModified(originalRecipient, document.fields)) {
throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document',
});
@@ -203,9 +198,6 @@ export const updateDocumentRecipients = async ({
};
};
/**
* If you change this you MUST update the `hasRecipientBeenChanged` function.
*/
type RecipientData = {
id: number;
email?: string;
@@ -215,19 +207,3 @@ type RecipientData = {
accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[];
};
const hasRecipientBeenChanged = (recipient: Recipient, newRecipientData: RecipientData) => {
const authOptions = ZRecipientAuthOptionsSchema.parse(recipient.authOptions);
const newRecipientAccessAuth = newRecipientData.accessAuth || null;
const newRecipientActionAuth = newRecipientData.actionAuth || null;
return (
recipient.email !== newRecipientData.email ||
recipient.name !== newRecipientData.name ||
recipient.role !== newRecipientData.role ||
recipient.signingOrder !== newRecipientData.signingOrder ||
!isDeepEqual(authOptions.accessAuth, newRecipientAccessAuth) ||
!isDeepEqual(authOptions.actionAuth, newRecipientActionAuth)
);
};
@@ -2,6 +2,7 @@ import type { DocumentDistributionMethod, DocumentSigningOrder } from '@prisma/c
import {
DocumentSource,
type Field,
FolderType,
type Recipient,
RecipientRole,
SendStatus,
@@ -69,6 +70,7 @@ export type CreateDocumentFromTemplateOptions = {
email: string;
signingOrder?: number | null;
}[];
folderId?: string;
prefillFields?: TFieldMetaPrefillFieldsSchema[];
customDocumentDataId?: string;
@@ -274,6 +276,7 @@ export const createDocumentFromTemplate = async ({
customDocumentDataId,
override,
requestMetadata,
folderId,
prefillFields,
}: CreateDocumentFromTemplateOptions) => {
const template = await prisma.template.findUnique({
@@ -298,6 +301,22 @@ export const createDocumentFromTemplate = async ({
});
}
if (folderId) {
const folder = await prisma.folder.findUnique({
where: {
id: folderId,
type: FolderType.DOCUMENT,
team: buildTeamWhereQuery({ teamId, userId }),
},
});
if (!folder) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Folder not found',
});
}
}
const settings = await getTeamSettings({
userId,
teamId,
@@ -368,6 +387,7 @@ export const createDocumentFromTemplate = async ({
externalId: externalId || template.externalId,
templateId: template.id,
userId,
folderId,
teamId: template.teamId,
title: override?.title || template.title,
documentDataId: documentData.id,
@@ -0,0 +1,72 @@
import { plainClient } from '@documenso/lib/plain/client';
import { prisma } from '@documenso/prisma';
import { AppError, AppErrorCode } from '../../errors/app-error';
import { buildOrganisationWhereQuery } from '../../utils/organisations';
import { getTeamById } from '../team/get-team';
type SubmitSupportTicketOptions = {
subject: string;
message: string;
userId: number;
organisationId: string;
teamId?: number | null;
};
export const submitSupportTicket = async ({
subject,
message,
userId,
organisationId,
teamId,
}: SubmitSupportTicketOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'User not found',
});
}
const organisation = await prisma.organisation.findFirst({
where: buildOrganisationWhereQuery({
organisationId,
userId,
}),
});
if (!organisation) {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: 'Organisation not found',
});
}
const team = teamId
? await getTeamById({
userId,
teamId,
})
: null;
const customMessage = `
Organisation: ${organisation.name} (${organisation.id})
Team: ${team ? `${team.name} (${team.id})` : 'No team provided'}
${message}`;
const res = await plainClient.createThread({
title: subject,
customerIdentifier: { emailAddress: user.email },
components: [{ componentText: { text: customMessage } }],
});
if (res.error) {
throw new Error(res.error.message);
}
return res;
};
File diff suppressed because it is too large Load Diff
+60 -8
View File
@@ -167,6 +167,10 @@ msgstr "{0} Recipient(s)"
msgid "{0} Teams"
msgstr "{0} Teams"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "{browserInfo} on {os}"
msgstr "{browserInfo} on {os}"
#: apps/remix/app/components/general/document-signing/document-signing-text-field.tsx
msgid "{charactersRemaining, plural, one {1 character remaining} other {{charactersRemaining} characters remaining}}"
msgstr "{charactersRemaining, plural, one {1 character remaining} other {{charactersRemaining} characters remaining}}"
@@ -368,6 +372,10 @@ msgstr "{teamName} has invited you to {0}<0/>\"{documentName}\""
msgid "{teamName} has invited you to {action} {documentName}"
msgstr "{teamName} has invited you to {action} {documentName}"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "{userAgent}"
msgstr "{userAgent}"
#: packages/lib/utils/document-audit-logs.ts
msgid "{userName} approved the document"
msgstr "{userName} approved the document"
@@ -745,7 +753,6 @@ msgstr "Acknowledgment"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/components/tables/settings-public-profile-templates-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/documents-table-action-dropdown.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
@@ -1486,10 +1493,14 @@ msgstr "At least one signature type must be enabled"
msgid "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
msgstr "Attempts sealing the document again, useful for after a code change has occurred to resolve an erroneous document."
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
msgid "Audit Log"
msgstr "Audit Log"
#: apps/remix/app/components/general/document/document-page-view-dropdown.tsx
msgid "Audit Logs"
msgstr "Audit Logs"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx
msgid "Authentication Level"
msgstr "Authentication Level"
@@ -1591,7 +1602,6 @@ msgid "Branding preferences updated"
msgstr "Branding preferences updated"
#: apps/remix/app/components/tables/settings-security-activity-table.tsx
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "Browser"
msgstr "Browser"
@@ -2109,6 +2119,10 @@ msgstr "Controls the formatting of the message that will be sent when inviting a
msgid "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
msgstr "Controls the language for the document, including the language to be used for email notifications, and the final certificate that is generated and attached to the document."
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Controls whether the audit logs will be included in the document when it is downloaded. The audit logs can still be downloaded from the logs page separately."
msgstr "Controls whether the audit logs will be included in the document when it is downloaded. The audit logs can still be downloaded from the logs page separately."
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
msgstr "Controls whether the signing certificate will be included in the document when it is downloaded. The signing certificate can still be downloaded from the logs page separately."
@@ -3140,6 +3154,7 @@ msgstr "Drag & drop your PDF here."
msgid "Drag and drop or click to upload"
msgstr "Drag and drop or click to upload"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
msgid "Drag and drop your PDF file here"
msgstr "Drag and drop your PDF file here"
@@ -3668,6 +3683,7 @@ msgstr "Fields"
msgid "Fields updated"
msgstr "Fields updated"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/document/document-upload.tsx
#: apps/remix/app/components/general/document/document-drop-zone-wrapper.tsx
#: apps/remix/app/components/embed/authoring/configure-document-upload.tsx
@@ -4042,6 +4058,10 @@ msgstr "Inbox"
msgid "Inbox documents"
msgstr "Inbox documents"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Include the Audit Logs in the Document"
msgstr "Include the Audit Logs in the Document"
#: apps/remix/app/components/forms/document-preferences-form.tsx
msgid "Include the Signing Certificate in the Document"
msgstr "Include the Signing Certificate in the Document"
@@ -4064,6 +4084,7 @@ msgstr "Inherit authentication method"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
msgid "Inherit from organisation"
msgstr "Inherit from organisation"
@@ -4599,6 +4620,10 @@ msgstr "Multiple access methods can be selected."
msgid "My Folder"
msgstr "My Folder"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "N/A"
msgstr "N/A"
#: apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.email-domains.$id.tsx
#: apps/remix/app/routes/_authenticated+/admin+/users.$id.tsx
#: apps/remix/app/components/tables/settings-security-passkey-table.tsx
@@ -4679,6 +4704,7 @@ msgstr "Next"
msgid "Next field"
msgstr "Next field"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
@@ -5338,6 +5364,7 @@ msgstr "Please try a different domain."
msgid "Please try again and make sure you enter the correct email address."
msgstr "Please try again and make sure you enter the correct email address."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/dialogs/template-create-dialog.tsx
msgid "Please try again later."
msgstr "Please try again later."
@@ -6390,6 +6417,7 @@ msgstr "Some signers have not been assigned a signature field. Please assign at
#: apps/remix/app/components/general/share-document-download-button.tsx
#: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/billing-plans.tsx
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
#: apps/remix/app/components/general/teams/team-email-usage.tsx
#: apps/remix/app/components/general/teams/team-email-dropdown.tsx
#: apps/remix/app/components/general/organisations/organisation-invitations.tsx
@@ -6827,6 +6855,10 @@ msgstr "Template title"
msgid "Template updated successfully"
msgstr "Template updated successfully"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Template uploaded"
msgstr "Template uploaded"
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/settings.public-profile.tsx
@@ -7445,7 +7477,6 @@ msgstr "This will remove all emails associated with this email domain"
msgid "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
msgstr "This will sign you out of all other devices. You will need to sign in again on those devices to continue using your account."
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
#: apps/remix/app/components/tables/document-logs-table.tsx
msgid "Time"
msgstr "Time"
@@ -7479,6 +7510,10 @@ msgstr "Title cannot be empty"
msgid "To accept this invitation you must create an account."
msgstr "To accept this invitation you must create an account."
#: apps/remix/app/components/dialogs/team-member-create-dialog.tsx
msgid "To be able to add members to a team, you must first add them to the organisation. For more information, please see the <0>documentation</0>."
msgstr "To be able to add members to a team, you must first add them to the organisation. For more information, please see the <0>documentation</0>."
#: apps/remix/app/components/dialogs/team-email-update-dialog.tsx
msgid "To change the email you must remove and add a new email address."
msgstr "To change the email you must remove and add a new email address."
@@ -7928,6 +7963,10 @@ msgstr "Upload Document"
msgid "Upload Signature"
msgstr "Upload Signature"
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Upload Template"
msgstr "Upload Template"
#: packages/ui/primitives/document-upload.tsx
#: packages/ui/primitives/document-dropzone.tsx
msgid "Upload Template Document"
@@ -7958,6 +7997,10 @@ msgstr "Uploaded file not an allowed file type"
msgid "Uploading document..."
msgstr "Uploading document..."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Uploading template..."
msgstr "Uploading template..."
#: apps/remix/app/routes/_authenticated+/t.$teamUrl+/templates.$id._index.tsx
msgid "Use"
msgstr "Use"
@@ -7985,6 +8028,10 @@ msgstr "Use your passkey for authentication"
msgid "User"
msgstr "User"
#: apps/remix/app/components/tables/internal-audit-log-table.tsx
msgid "User Agent"
msgstr "User Agent"
#: apps/remix/app/components/forms/password.tsx
msgid "User has no password."
msgstr "User has no password."
@@ -8062,10 +8109,6 @@ msgstr "Verify your email to upload documents."
msgid "Verify your team email address"
msgstr "Verify your team email address"
#: apps/remix/app/routes/_internal+/[__htmltopdf]+/audit-log.tsx
msgid "Version History"
msgstr "Version History"
#: packages/ui/primitives/document-flow/field-items-advanced-settings/checkbox-field.tsx
msgid "Vertical"
msgstr "Vertical"
@@ -8616,6 +8659,7 @@ msgstr "Write a description to display on your public profile"
msgid "Yearly"
msgstr "Yearly"
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/document-preferences-form.tsx
#: apps/remix/app/components/forms/branding-preferences-form.tsx
@@ -9224,6 +9268,10 @@ msgstr "Your team has been successfully deleted."
msgid "Your team has been successfully updated."
msgstr "Your team has been successfully updated."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Your template failed to upload."
msgstr "Your template failed to upload."
#: apps/remix/app/routes/embed+/v1+/authoring_.completed.create.tsx
msgid "Your template has been created successfully"
msgstr "Your template has been created successfully"
@@ -9236,6 +9284,10 @@ msgstr "Your template has been duplicated successfully."
msgid "Your template has been successfully deleted."
msgstr "Your template has been successfully deleted."
#: apps/remix/app/components/general/template/template-drop-zone-wrapper.tsx
msgid "Your template has been uploaded successfully. You will be redirected to the template page."
msgstr "Your template has been uploaded successfully. You will be redirected to the template page."
#: apps/remix/app/components/dialogs/template-duplicate-dialog.tsx
msgid "Your template will be duplicated."
msgstr "Your template will be duplicated."
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -134,7 +134,7 @@ export const internalClaims: InternalClaims = {
unlimitedDocuments: true,
allowCustomBranding: true,
hidePoweredBy: true,
emailDomains: true,
emailDomains: false,
embedAuthoring: false,
embedAuthoringWhiteLabel: true,
embedSigning: false,
+92 -23
View File
@@ -305,87 +305,150 @@ export const formatDocumentAuditLogAction = (
const description = match(auditLog)
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_CREATED }, () => ({
anonymous: msg`A field was added`,
anonymous: msg({
message: `A field was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_DELETED }, () => ({
anonymous: msg`A field was removed`,
anonymous: msg({
message: `A field was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.FIELD_UPDATED }, () => ({
anonymous: msg`A field was updated`,
anonymous: msg({
message: `A field was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_CREATED }, () => ({
anonymous: msg`A recipient was added`,
anonymous: msg({
message: `A recipient was added`,
context: `Audit log format`,
}),
identified: msg`${prefix} added a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_DELETED }, () => ({
anonymous: msg`A recipient was removed`,
anonymous: msg({
message: `A recipient was removed`,
context: `Audit log format`,
}),
identified: msg`${prefix} removed a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.RECIPIENT_UPDATED }, () => ({
anonymous: msg`A recipient was updated`,
anonymous: msg({
message: `A recipient was updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated a recipient`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_CREATED }, () => ({
anonymous: msg`Document created`,
anonymous: msg({
message: `Document created`,
context: `Audit log format`,
}),
identified: msg`${prefix} created the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_DELETED }, () => ({
anonymous: msg`Document deleted`,
anonymous: msg({
message: `Document deleted`,
context: `Audit log format`,
}),
identified: msg`${prefix} deleted the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED }, () => ({
anonymous: msg`Field signed`,
anonymous: msg({
message: `Field signed`,
context: `Audit log format`,
}),
identified: msg`${prefix} signed a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED }, () => ({
anonymous: msg`Field unsigned`,
anonymous: msg({
message: `Field unsigned`,
context: `Audit log format`,
}),
identified: msg`${prefix} unsigned a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_PREFILLED }, () => ({
anonymous: msg`Field prefilled by assistant`,
anonymous: msg({
message: `Field prefilled by assistant`,
context: `Audit log format`,
}),
identified: msg`${prefix} prefilled a field`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VISIBILITY_UPDATED }, () => ({
anonymous: msg`Document visibility updated`,
anonymous: msg({
message: `Document visibility updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document visibility`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACCESS_UPDATED }, () => ({
anonymous: msg`Document access auth updated`,
anonymous: msg({
message: `Document access auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document access auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_GLOBAL_AUTH_ACTION_UPDATED }, () => ({
anonymous: msg`Document signing auth updated`,
anonymous: msg({
message: `Document signing auth updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document signing auth requirements`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_META_UPDATED }, () => ({
anonymous: msg`Document updated`,
anonymous: msg({
message: `Document updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_OPENED }, () => ({
anonymous: msg`Document opened`,
anonymous: msg({
message: `Document opened`,
context: `Audit log format`,
}),
identified: msg`${prefix} opened the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_VIEWED }, () => ({
anonymous: msg`Document viewed`,
anonymous: msg({
message: `Document viewed`,
context: `Audit log format`,
}),
identified: msg`${prefix} viewed the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_TITLE_UPDATED }, () => ({
anonymous: msg`Document title updated`,
anonymous: msg({
message: `Document title updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document title`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_EXTERNAL_ID_UPDATED }, () => ({
anonymous: msg`Document external ID updated`,
anonymous: msg({
message: `Document external ID updated`,
context: `Audit log format`,
}),
identified: msg`${prefix} updated the document external ID`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT }, () => ({
anonymous: msg`Document sent`,
anonymous: msg({
message: `Document sent`,
context: `Audit log format`,
}),
identified: msg`${prefix} sent the document`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_MOVED_TO_TEAM }, () => ({
anonymous: msg`Document moved to team`,
anonymous: msg({
message: `Document moved to team`,
context: `Audit log format`,
}),
identified: msg`${prefix} moved the document to team`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED }, ({ data }) => {
@@ -420,8 +483,14 @@ export const formatDocumentAuditLogAction = (
: msg`${prefix} sent an email to ${data.recipientEmail}`,
}))
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_COMPLETED }, () => ({
anonymous: msg`Document completed`,
identified: msg`Document completed`,
anonymous: msg({
message: `Document completed`,
context: `Audit log format`,
}),
identified: msg({
message: `Document completed`,
context: `Audit log format`,
}),
}))
.exhaustive();
+30
View File
@@ -1,5 +1,7 @@
import { type TransportTargetOptions, pino } from 'pino';
import type { BaseApiLog } from '../types/api-logs';
import { extractRequestMetadata } from '../universal/extract-request-metadata';
import { env } from './env';
const transports: TransportTargetOptions[] = [];
@@ -33,3 +35,31 @@ export const logger = pino({
}
: undefined,
});
export const logDocumentAccess = ({
request,
documentId,
userId,
}: {
request: Request;
documentId: number;
userId: number;
}) => {
const metadata = extractRequestMetadata(request);
const data: BaseApiLog = {
ipAddress: metadata.ipAddress,
userAgent: metadata.userAgent,
path: new URL(request.url).pathname,
auth: 'session',
source: 'app',
userId,
};
logger.info({
...data,
input: {
documentId,
},
});
};
+1
View File
@@ -120,6 +120,7 @@ export const generateDefaultOrganisationSettings = (): Omit<
includeSenderDetails: true,
includeSigningCertificate: true,
includeAuditLog: false,
typedSignatureEnabled: true,
uploadSignatureEnabled: true,
+1
View File
@@ -170,6 +170,7 @@ export const generateDefaultTeamSettings = (): Omit<TeamGlobalSettings, 'id' | '
includeSenderDetails: null,
includeSigningCertificate: null,
includeAuditLog: null,
typedSignatureEnabled: null,
uploadSignatureEnabled: null,
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "OrganisationGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "TeamGlobalSettings" ADD COLUMN "includeAuditLog" BOOLEAN;
+8 -7
View File
@@ -734,13 +734,13 @@ model OrganisationGlobalSettings {
id String @id
organisation Organisation?
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
documentVisibility DocumentVisibility @default(EVERYONE)
documentLanguage String @default("en")
includeSenderDetails Boolean @default(true)
includeSigningCertificate Boolean @default(true)
includeAuditLog Boolean @default(false)
documentTimezone String? // Nullable to allow using local timezones if not set.
documentDateFormat String @default("yyyy-MM-dd hh:mm a")
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
@@ -771,6 +771,7 @@ model TeamGlobalSettings {
includeSenderDetails Boolean?
includeSigningCertificate Boolean?
includeAuditLog Boolean?
typedSignatureEnabled Boolean?
uploadSignatureEnabled Boolean?
@@ -0,0 +1,50 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { adminProcedure } from '../trpc';
import {
ZResetTwoFactorRequestSchema,
ZResetTwoFactorResponseSchema,
} from './reset-two-factor-authentication.types';
export const resetTwoFactorRoute = adminProcedure
.input(ZResetTwoFactorRequestSchema)
.output(ZResetTwoFactorResponseSchema)
.mutation(async ({ input, ctx }) => {
const { userId } = input;
ctx.logger.info({
input: {
userId,
},
});
return await resetTwoFactor({ userId });
});
export type ResetTwoFactorOptions = {
userId: number;
};
export const resetTwoFactor = async ({ userId }: ResetTwoFactorOptions) => {
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, { message: 'User not found' });
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
twoFactorEnabled: false,
twoFactorBackupCodes: null,
twoFactorSecret: null,
},
});
};

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