Merge branch 'main' into feat/org-insights

This commit is contained in:
Ephraim Duncan
2025-08-20 17:07:59 +00:00
committed by GitHub
29 changed files with 2247 additions and 1072 deletions

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. # OPTIONAL: The file to save the logger output to. Will disable stdout if provided.
NEXT_PRIVATE_LOGGER_FILE_PATH= NEXT_PRIVATE_LOGGER_FILE_PATH=
# [[PLAIN SUPPORT]]
NEXT_PRIVATE_PLAIN_API_KEY=

View File

@ -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>
);
};

View File

@ -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>
</>
);
};

View File

@ -321,6 +321,19 @@ export const OrgMenuSwitcher = () => {
<Trans>Language</Trans> <Trans>Language</Trans>
</DropdownMenuItem> </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 <DropdownMenuItem
className="text-muted-foreground hover:!text-muted-foreground px-4 py-2" className="text-muted-foreground hover:!text-muted-foreground px-4 py-2"
onSelect={async () => authClient.signOut()} onSelect={async () => authClient.signOut()}

View File

@ -27,6 +27,7 @@ import { AdminOrganisationCreateDialog } from '~/components/dialogs/admin-organi
import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog'; import { AdminUserDeleteDialog } from '~/components/dialogs/admin-user-delete-dialog';
import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog'; import { AdminUserDisableDialog } from '~/components/dialogs/admin-user-disable-dialog';
import { AdminUserEnableDialog } from '~/components/dialogs/admin-user-enable-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 { GenericErrorLayout } from '~/components/general/generic-error-layout';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table'; import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@ -219,10 +220,11 @@ const AdminUserPage = ({ user }: { user: User }) => {
/> />
</div> </div>
<div className="mt-16 flex flex-col items-center gap-4"> <div className="mt-16 flex flex-col gap-4">
{user && <AdminUserDeleteDialog user={user} />} {user && user.twoFactorEnabled && <AdminUserResetTwoFactorDialog user={user} />}
{user && user.disabled && <AdminUserEnableDialog userToEnable={user} />} {user && user.disabled && <AdminUserEnableDialog userToEnable={user} />}
{user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />} {user && !user.disabled && <AdminUserDisableDialog userToDisable={user} />}
{user && <AdminUserDeleteDialog user={user} />}
</div> </div>
</div> </div>
); );

View File

@ -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>
);
}

View File

@ -101,5 +101,5 @@
"vite-plugin-babel-macros": "^1.0.6", "vite-plugin-babel-macros": "^1.0.6",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
}, },
"version": "1.12.2-rc.3" "version": "1.12.2-rc.4"
} }

60
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.12.2-rc.3", "version": "1.12.2-rc.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@documenso/root", "name": "@documenso/root",
"version": "1.12.2-rc.3", "version": "1.12.2-rc.4",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
"packages/*" "packages/*"
@ -89,7 +89,7 @@
}, },
"apps/remix": { "apps/remix": {
"name": "@documenso/remix", "name": "@documenso/remix",
"version": "1.12.2-rc.3", "version": "1.12.2-rc.4",
"dependencies": { "dependencies": {
"@documenso/api": "*", "@documenso/api": "*",
"@documenso/assets": "*", "@documenso/assets": "*",
@ -3522,6 +3522,15 @@
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT" "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": { "node_modules/@grpc/grpc-js": {
"version": "1.13.3", "version": "1.13.3",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.3.tgz",
@ -11826,6 +11835,20 @@
"url": "https://github.com/sponsors/tannerlinsley" "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": { "node_modules/@theguild/remark-mermaid": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz", "resolved": "https://registry.npmjs.org/@theguild/remark-mermaid/-/remark-mermaid-0.0.5.tgz",
@ -13235,7 +13258,6 @@
"version": "8.17.1", "version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
@ -13248,6 +13270,23 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/ansi-escapes": {
"version": "4.3.2", "version": "4.3.2",
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
@ -18771,7 +18810,6 @@
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz",
"integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -19847,6 +19885,15 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"license": "MIT" "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": { "node_modules/gray-matter": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
@ -22329,7 +22376,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/json-stable-stringify-without-jsonify": { "node_modules/json-stable-stringify-without-jsonify": {
@ -30570,7 +30616,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "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==", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@ -36583,6 +36628,7 @@
"@pdf-lib/fontkit": "^1.1.1", "@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3", "@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0", "@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"inngest": "^3.19.13", "inngest": "^3.19.13",

View File

@ -1,6 +1,6 @@
{ {
"private": true, "private": true,
"version": "1.12.2-rc.3", "version": "1.12.2-rc.4",
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --filter=@documenso/remix", "dev": "turbo run dev --filter=@documenso/remix",

View File

@ -144,10 +144,11 @@ test('[TEMPLATES]: use template', async ({ page }) => {
await page.getByRole('button', { name: 'Use Template' }).click(); await page.getByRole('button', { name: 'Use Template' }).click();
// Enter template values. // Enter template values.
await page.getByPlaceholder('recipient.1@documenso.com').click(); // Get input with Email label placeholder.
await page.getByPlaceholder('recipient.1@documenso.com').fill(teamMemberUser.email); await page.getByLabel('Email').click();
await page.getByPlaceholder('Recipient 1').click(); await page.getByLabel('Email').fill(teamMemberUser.email);
await page.getByPlaceholder('Recipient 1').fill('name'); await page.getByLabel('Name').click();
await page.getByLabel('Name').fill('name');
await page.getByRole('button', { name: 'Create as draft' }).click(); await page.getByRole('button', { name: 'Create as draft' }).click();
await page.waitForURL(/\/t\/.+\/documents/); await page.waitForURL(/\/t\/.+\/documents/);

View File

@ -33,6 +33,7 @@
"@pdf-lib/fontkit": "^1.1.1", "@pdf-lib/fontkit": "^1.1.1",
"@scure/base": "^1.1.3", "@scure/base": "^1.1.3",
"@sindresorhus/slugify": "^2.2.1", "@sindresorhus/slugify": "^2.2.1",
"@team-plain/typescript-sdk": "^5.9.0",
"@vvo/tzdb": "^6.117.0", "@vvo/tzdb": "^6.117.0",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"inngest": "^3.19.13", "inngest": "^3.19.13",

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') ?? '',
});

View File

@ -111,7 +111,7 @@ export const getDocumentWhereInput = async ({
visibility: { visibility: {
in: teamVisibilityFilters, in: teamVisibilityFilters,
}, },
teamId, teamId: team.id,
}, },
// Or, if they are a recipient of the document. // Or, if they are a recipient of the document.
{ {

View File

@ -134,6 +134,9 @@ export const setDocumentRecipients = async ({
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email, existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
); );
const canPersistedRecipientBeModified =
existing && canRecipientBeModified(existing, document.fields);
if ( if (
existing && existing &&
hasRecipientBeenChanged(existing, recipient) && hasRecipientBeenChanged(existing, recipient) &&
@ -147,6 +150,7 @@ export const setDocumentRecipients = async ({
return { return {
...recipient, ...recipient,
_persisted: existing, _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({ const upsertedRecipient = await tx.recipient.upsert({
where: { where: {
id: recipient._persisted?.id ?? -1, id: recipient._persisted?.id ?? -1,

View File

@ -1,7 +1,5 @@
import type { Recipient } from '@prisma/client';
import { RecipientRole } from '@prisma/client'; import { RecipientRole } from '@prisma/client';
import { SendStatus, SigningStatus } 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 { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth'; import type { TRecipientAccessAuthTypes } from '@documenso/lib/types/document-auth';
@ -104,10 +102,7 @@ export const updateDocumentRecipients = async ({
}); });
} }
if ( if (!canRecipientBeModified(originalRecipient, document.fields)) {
hasRecipientBeenChanged(originalRecipient, recipient) &&
!canRecipientBeModified(originalRecipient, document.fields)
) {
throw new AppError(AppErrorCode.INVALID_REQUEST, { throw new AppError(AppErrorCode.INVALID_REQUEST, {
message: 'Cannot modify a recipient who has already interacted with the document', 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 = { type RecipientData = {
id: number; id: number;
email?: string; email?: string;
@ -215,19 +207,3 @@ type RecipientData = {
accessAuth?: TRecipientAccessAuthTypes[]; accessAuth?: TRecipientAccessAuthTypes[];
actionAuth?: TRecipientActionAuthTypes[]; 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)
);
};

View File

@ -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

View File

@ -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,
},
});
};

View File

@ -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>;

View File

@ -21,6 +21,7 @@ import { deleteSubscriptionClaimRoute } from './delete-subscription-claim';
import { findAdminOrganisationsRoute } from './find-admin-organisations'; import { findAdminOrganisationsRoute } from './find-admin-organisations';
import { findSubscriptionClaimsRoute } from './find-subscription-claims'; import { findSubscriptionClaimsRoute } from './find-subscription-claims';
import { getAdminOrganisationRoute } from './get-admin-organisation'; import { getAdminOrganisationRoute } from './get-admin-organisation';
import { resetTwoFactorRoute } from './reset-two-factor-authentication';
import { import {
ZAdminDeleteDocumentMutationSchema, ZAdminDeleteDocumentMutationSchema,
ZAdminDeleteUserMutationSchema, ZAdminDeleteUserMutationSchema,
@ -51,6 +52,9 @@ export const adminRouter = router({
stripe: { stripe: {
createCustomer: createStripeCustomerRoute, createCustomer: createStripeCustomerRoute,
}, },
user: {
resetTwoFactor: resetTwoFactorRoute,
},
// Todo: migrate old routes // Todo: migrate old routes
findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => { findDocuments: adminProcedure.input(ZAdminFindDocumentsQuerySchema).query(async ({ input }) => {

View File

@ -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 type { SetAvatarImageOptions } from '@documenso/lib/server-only/profile/set-avatar-image';
import { setAvatarImage } 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 { deleteUser } from '@documenso/lib/server-only/user/delete-user';
import { findUserSecurityAuditLogs } from '@documenso/lib/server-only/user/find-user-security-audit-logs'; 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 { 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 { updateProfile } from '@documenso/lib/server-only/user/update-profile';
import { adminProcedure, authenticatedProcedure, router } from '../trpc'; import { adminProcedure, authenticatedProcedure, router } from '../trpc';
@ -10,6 +12,7 @@ import {
ZFindUserSecurityAuditLogsSchema, ZFindUserSecurityAuditLogsSchema,
ZRetrieveUserByIdQuerySchema, ZRetrieveUserByIdQuerySchema,
ZSetProfileImageMutationSchema, ZSetProfileImageMutationSchema,
ZSubmitSupportTicketMutationSchema,
ZUpdateProfileMutationSchema, ZUpdateProfileMutationSchema,
} from './schema'; } from './schema';
@ -91,4 +94,28 @@ export const profileRouter = router({
requestMetadata: ctx.metadata, 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,
});
}),
}); });

View File

@ -27,3 +27,12 @@ export const ZSetProfileImageMutationSchema = z.object({
}); });
export type TSetProfileImageMutationSchema = z.infer<typeof ZSetProfileImageMutationSchema>; 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>;

View File

@ -141,6 +141,7 @@ export const authenticatedMiddleware = t.middleware(async ({ ctx, next, path })
return await next({ return await next({
ctx: { ctx: {
...ctx, ...ctx,
teamId: ctx.teamId || -1,
logger: trpcSessionLogger, logger: trpcSessionLogger,
user: ctx.user, user: ctx.user,
session: ctx.session, session: ctx.session,

View File

@ -47,6 +47,7 @@
"NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_FEATURE_BILLING_ENABLED", "NEXT_PUBLIC_FEATURE_BILLING_ENABLED",
"NEXT_PUBLIC_DISABLE_SIGNUP", "NEXT_PUBLIC_DISABLE_SIGNUP",
"NEXT_PRIVATE_PLAIN_API_KEY",
"NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT", "NEXT_PUBLIC_DOCUMENT_SIZE_UPLOAD_LIMIT",
"NEXT_PRIVATE_DATABASE_URL", "NEXT_PRIVATE_DATABASE_URL",
"NEXT_PRIVATE_DIRECT_DATABASE_URL", "NEXT_PRIVATE_DIRECT_DATABASE_URL",