Compare commits

...

8 Commits

Author SHA1 Message Date
David Nguyen e7e2aa9bd8 fix: migrate template metadata 2025-08-21 17:56:04 +10: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
44 changed files with 2340 additions and 1142 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=
@@ -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>
);
};
@@ -3,7 +3,7 @@ import { useEffect, useLayoutEffect, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Recipient, Signature, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, Recipient, Signature } from '@prisma/client';
import { type DocumentData, type Field, FieldType } from '@prisma/client';
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
import { DateTime } from 'luxon';
@@ -48,7 +48,7 @@ export type EmbedDirectTemplateClientPageProps = {
documentData: DocumentData;
recipient: Recipient;
fields: Field[];
metadata?: DocumentMeta | TemplateMeta | null;
metadata?: DocumentMeta | null;
hidePoweredBy?: boolean;
allowWhiteLabelling?: boolean;
};
@@ -1,4 +1,4 @@
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import type { DocumentMeta } from '@prisma/client';
import { type Field, FieldType } from '@prisma/client';
import { match } from 'ts-pattern';
@@ -33,7 +33,7 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
export type EmbedDocumentFieldsProps = {
fields: Field[];
metadata?: Pick<
DocumentMeta | TemplateMeta,
DocumentMeta,
| 'timezone'
| 'dateFormat'
| 'typedSignatureEnabled'
@@ -3,7 +3,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, TemplateMeta } from '@prisma/client';
import type { DocumentMeta } from '@prisma/client';
import {
type DocumentData,
type Field,
@@ -50,7 +50,7 @@ export type EmbedSignDocumentClientPageProps = {
recipient: RecipientWithFields;
fields: Field[];
completedFields: DocumentField[];
metadata?: DocumentMeta | TemplateMeta | null;
metadata?: DocumentMeta | null;
isCompleted?: boolean;
hidePoweredBy?: boolean;
allowWhitelabelling?: boolean;
@@ -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>
</>
);
};
@@ -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()}
@@ -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>
);
@@ -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>
);
}
+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.3"
"version": "1.12.2-rc.4"
}
+53 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@documenso/root",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@documenso/root",
"version": "1.12.2-rc.3",
"version": "1.12.2-rc.4",
"workspaces": [
"apps/*",
"packages/*"
@@ -89,7 +89,7 @@
},
"apps/remix": {
"name": "@documenso/remix",
"version": "1.12.2-rc.3",
"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.3",
"version": "1.12.2-rc.4",
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix",
+6
View File
@@ -554,6 +554,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
status: 200,
body: {
...template,
templateMeta: template.templateMeta
? {
...template.templateMeta,
templateId: template.id,
}
: null,
Field: template.fields.map((field) => ({
...field,
fieldMeta: field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null,
@@ -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
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,4 +1,4 @@
import type { DocumentVisibility, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, DocumentVisibility } from '@prisma/client';
import {
DocumentSource,
RecipientRole,
@@ -46,7 +46,7 @@ export type CreateDocumentOptions = {
formValues?: TDocumentFormValues;
recipients: TCreateDocumentV2Request['recipients'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
requestMetadata: ApiRequestMetadata;
};
@@ -111,7 +111,7 @@ export const getDocumentWhereInput = async ({
visibility: {
in: teamVisibilityFilters,
},
teamId,
teamId: team.id,
},
// Or, if they are a recipient of the document.
{
@@ -1,13 +0,0 @@
import { prisma } from '@documenso/prisma';
export interface GetDocumentMetaByDocumentIdOptions {
id: number;
}
export const getDocumentMetaByDocumentId = async ({ id }: GetDocumentMetaByDocumentIdOptions) => {
return await prisma.documentMeta.findFirstOrThrow({
where: {
documentId: id,
},
});
};
@@ -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)
);
};
@@ -1,4 +1,4 @@
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, DocumentVisibility, Template } from '@prisma/client';
import type { z } from 'zod';
import { prisma } from '@documenso/prisma';
@@ -26,7 +26,7 @@ export type CreateTemplateOptions = {
publicDescription?: string;
type?: Template['type'];
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
};
export const ZCreateTemplateResponseSchema = TemplateSchema;
@@ -1,4 +1,4 @@
import type { DocumentVisibility, Template, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, DocumentVisibility, Template } from '@prisma/client';
import { prisma } from '@documenso/prisma';
@@ -22,7 +22,7 @@ export type UpdateTemplateOptions = {
type?: Template['type'];
useLegacyFieldInsertion?: boolean;
};
meta?: Partial<Omit<TemplateMeta, 'id' | 'templateId'>>;
meta?: Partial<Omit<DocumentMeta, 'id' | 'templateId'>>;
};
export const updateTemplate = async ({
@@ -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
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
+3 -3
View File
@@ -1,10 +1,10 @@
import type { z } from 'zod';
import { DocumentDataSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentDataSchema';
import { DocumentMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/DocumentMetaSchema';
import { FolderSchema } from '@documenso/prisma/generated/zod/modelSchema/FolderSchema';
import TeamSchema from '@documenso/prisma/generated/zod/modelSchema/TeamSchema';
import { TemplateDirectLinkSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateDirectLinkSchema';
import { TemplateMetaSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateMetaSchema';
import { TemplateSchema } from '@documenso/prisma/generated/zod/modelSchema/TemplateSchema';
import { UserSchema } from '@documenso/prisma/generated/zod/modelSchema/UserSchema';
@@ -39,7 +39,7 @@ export const ZTemplateSchema = TemplateSchema.pick({
data: true,
initialData: true,
}),
templateMeta: TemplateMetaSchema.pick({
templateMeta: DocumentMetaSchema.pick({
id: true,
subject: true,
message: true,
@@ -129,7 +129,7 @@ export const ZTemplateManySchema = TemplateSchema.pick({
}).nullable(),
fields: ZFieldSchema.array(),
recipients: ZRecipientLiteSchema.array(),
templateMeta: TemplateMetaSchema.pick({
templateMeta: DocumentMetaSchema.pick({
signingOrder: true,
distributionMethod: true,
}).nullable(),
+3 -8
View File
@@ -1,9 +1,4 @@
import type {
Document,
DocumentMeta,
OrganisationGlobalSettings,
TemplateMeta,
} from '@prisma/client';
import type { Document, DocumentMeta, OrganisationGlobalSettings } from '@prisma/client';
import { DocumentDistributionMethod, DocumentSigningOrder, DocumentStatus } from '@prisma/client';
import { DEFAULT_DOCUMENT_TIME_ZONE } from '../constants/time-zones';
@@ -29,7 +24,7 @@ export const isDocumentCompleted = (document: Pick<Document, 'status'> | Documen
*/
export const extractDerivedDocumentMeta = (
settings: Omit<OrganisationGlobalSettings, 'id'>,
overrideMeta: Partial<DocumentMeta | TemplateMeta> | undefined | null,
overrideMeta: Partial<DocumentMeta> | undefined | null,
) => {
const meta = overrideMeta ?? {};
@@ -58,5 +53,5 @@ export const extractDerivedDocumentMeta = (
emailReplyTo: meta.emailReplyTo ?? settings.emailReplyTo,
emailSettings:
meta.emailSettings || settings.emailDocumentSettings || DEFAULT_DOCUMENT_EMAIL_SETTINGS,
} satisfies Omit<DocumentMeta, 'id' | 'documentId'>;
} satisfies Omit<DocumentMeta, 'id' | 'documentId' | 'templateId'>;
};
@@ -0,0 +1,57 @@
-- DropForeignKey
ALTER TABLE "TemplateMeta" DROP CONSTRAINT "TemplateMeta_templateId_fkey";
-- AlterTable
ALTER TABLE "DocumentMeta" ADD COLUMN "templateId" INTEGER,
ALTER COLUMN "documentId" DROP NOT NULL;
-- [CUSTOM_CHANGE] Migrate existing TemplateMeta to DocumentMeta
INSERT INTO "DocumentMeta" (
"id",
"subject",
"message",
"timezone",
"password",
"dateFormat",
"redirectUrl",
"signingOrder",
"allowDictateNextSigner",
"typedSignatureEnabled",
"uploadSignatureEnabled",
"drawSignatureEnabled",
"language",
"distributionMethod",
"emailSettings",
"emailReplyTo",
"emailId",
"templateId"
)
SELECT
gen_random_uuid()::text, -- Generate new CUID-like IDs to avoid collisions
"subject",
"message",
"timezone",
"password",
"dateFormat",
"redirectUrl",
"signingOrder",
"allowDictateNextSigner",
"typedSignatureEnabled",
"uploadSignatureEnabled",
"drawSignatureEnabled",
"language",
"distributionMethod",
"emailSettings",
"emailReplyTo",
"emailId",
"templateId"
FROM "TemplateMeta";
-- DropTable
DROP TABLE "TemplateMeta";
-- CreateIndex
CREATE UNIQUE INDEX "DocumentMeta_templateId_key" ON "DocumentMeta"("templateId");
-- AddForeignKey
ALTER TABLE "DocumentMeta" ADD CONSTRAINT "DocumentMeta_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+7 -29
View File
@@ -456,8 +456,6 @@ model DocumentMeta {
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
documentId Int @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
redirectUrl String?
signingOrder DocumentSigningOrder @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
@@ -472,6 +470,12 @@ model DocumentMeta {
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
emailId String?
documentId Int? @unique
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
templateId Int? @unique
template Template? @relation(fields: [templateId], references: [id], onDelete: Cascade)
}
enum ReadStatus {
@@ -842,32 +846,6 @@ enum TemplateType {
PRIVATE
}
/// @zod.import(["import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';"])
model TemplateMeta {
id String @id @default(cuid())
subject String?
message String?
timezone String? @default("Etc/UTC") @db.Text
password String?
dateFormat String? @default("yyyy-MM-dd hh:mm a") @db.Text
signingOrder DocumentSigningOrder? @default(PARALLEL)
allowDictateNextSigner Boolean @default(false)
distributionMethod DocumentDistributionMethod @default(EMAIL)
typedSignatureEnabled Boolean @default(true)
uploadSignatureEnabled Boolean @default(true)
drawSignatureEnabled Boolean @default(true)
templateId Int @unique
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
redirectUrl String?
language String @default("en")
emailSettings Json? /// [DocumentEmailSettings] @zod.custom.use(ZDocumentEmailSettingsSchema)
emailReplyTo String?
emailId String?
}
/// @zod.import(["import { ZDocumentAuthOptionsSchema } from '@documenso/lib/types/document-auth';"])
model Template {
id Int @id @default(autoincrement())
@@ -876,7 +854,7 @@ model Template {
title String
visibility DocumentVisibility @default(EVERYONE)
authOptions Json? /// [DocumentAuthOptions] @zod.custom.use(ZDocumentAuthOptionsSchema)
templateMeta TemplateMeta?
templateMeta DocumentMeta?
templateDocumentDataId String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -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,
},
});
};
@@ -0,0 +1,10 @@
import { z } from 'zod';
export const ZResetTwoFactorRequestSchema = z.object({
userId: z.number(),
});
export const ZResetTwoFactorResponseSchema = z.void();
export type TResetTwoFactorRequest = z.infer<typeof ZResetTwoFactorRequestSchema>;
export type TResetTwoFactorResponse = z.infer<typeof ZResetTwoFactorResponseSchema>;
@@ -21,6 +21,7 @@ import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import {
ZAdminDeleteDocumentMutationSchema,
ZAdminDeleteUserMutationSchema,
@@ -51,6 +52,9 @@ export const adminRouter = router({
stripe: {
createCustomer: createStripeCustomerRoute,
},
user: {
resetTwoFactor: resetTwoFactorRoute,
},
// Todo: migrate old routes
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {
@@ -94,7 +94,7 @@ export const createEmbeddingTemplateRoute = procedure
emailSettings: meta.emailSettings,
};
await prisma.templateMeta.upsert({
await prisma.documentMeta.upsert({
where: {
templateId: template.id,
},
@@ -1,8 +1,10 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import type { SetAvatarImageOptions } from '@documenso/lib/server-only/profile/set-avatar-image';
import { setAvatarImage } from '@documenso/lib/server-only/profile/set-avatar-image';
import { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs';
import { getUserById } from '@documenso/lib/server-only/user/get-user-by-id';
import { submitSupportTicket } from '@documenso/lib/server-only/user/submit-support-ticket';
import { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { adminProcedure, authenticatedProcedure, router } from '../trpc';
@@ -10,6 +12,7 @@ import {
ZFindUserSecurityAuditLogsSchema,
ZRetrieveUserByIdQuerySchema,
ZSetProfileImageMutationSchema,
ZSubmitSupportTicketMutationSchema,
ZUpdateProfileMutationSchema,
} from './schema';
@@ -91,4 +94,28 @@ export const profileRouter = router({
requestMetadata: ctx.metadata,
});
}),
submitSupportTicket: authenticatedProcedure
.input(ZSubmitSupportTicketMutationSchema)
.mutation(async ({ input, ctx }) => {
const { subject, message, organisationId, teamId } = input;
const userId = ctx.user.id;
const parsedTeamId = teamId ? Number(teamId) : null;
if (Number.isNaN(parsedTeamId)) {
throw new AppError(AppErrorCode.INVALID_BODY, {
message: 'Invalid team ID provided',
});
}
return await submitSupportTicket({
subject,
message,
userId,
organisationId,
teamId: parsedTeamId,
});
}),
});
@@ -27,3 +27,12 @@ export const ZSetProfileImageMutationSchema = z.object({
});
export type TSetProfileImageMutationSchema = z.infer<typeof ZSetProfileImageMutationSchema>;
export const ZSubmitSupportTicketMutationSchema = z.object({
organisationId: z.string(),
teamId: z.string().min(1).nullish(),
subject: z.string().min(3, 'Subject is required'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export type TSupportTicketRequest = z.infer<typeof ZSubmitSupportTicketMutationSchema>;
+1
View File
@@ -141,6 +141,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path })
return await next({
ctx: {
...ctx,
teamId: ctx.teamId || -1,
logger: trpcSessionLogger,
user: ctx.user,
session: ctx.session,
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import type { DocumentMeta, Field, Recipient, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, Field, Recipient } from '@prisma/client';
import { SigningStatus } from '@prisma/client';
import { Clock, EyeOffIcon } from 'lucide-react';
@@ -36,7 +36,7 @@ const getRecipientDisplayText = (recipient: { name: string; email: string }) =>
export type DocumentReadOnlyFieldsProps = {
fields: DocumentField[];
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
showFieldStatus?: boolean;
@@ -1,5 +1,5 @@
import { useLingui } from '@lingui/react';
import type { DocumentMeta, Signature, TemplateMeta } from '@prisma/client';
import type { DocumentMeta, Signature } from '@prisma/client';
import { FieldType } from '@prisma/client';
import { ChevronDown } from 'lucide-react';
@@ -27,7 +27,7 @@ type FieldIconProps = {
fieldMeta?: TFieldMetaSchema | null;
signature?: Signature | null;
};
documentMeta?: Pick<DocumentMeta | TemplateMeta, 'dateFormat'>;
documentMeta?: Pick<DocumentMeta, 'dateFormat'>;
};
/**
+1
View File
@@ -47,6 +47,7 @@
"NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL",