Compare commits

...

3 Commits

Author SHA1 Message Date
Ephraim Atta-Duncan db4d33d039 chore: cleanup useEffect 2025-08-21 21:58:14 +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
71 changed files with 1309 additions and 649 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=
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -96,16 +96,17 @@ export const AdminOrganisationCreateDialog = ({
}
};
useEffect(() => {
form.reset();
}, [open, form]);
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -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>
);
};
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -73,20 +73,23 @@ export const DocumentDeleteDialog = ({
},
});
useEffect(() => {
if (open) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
}, [open, status]);
const onInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value);
setIsDeleteEnabled(event.target.value === _(deleteMessage));
};
const handleOpenChange = (value: boolean) => {
if (value) {
setInputValue('');
setIsDeleteEnabled(status === DocumentStatus.DRAFT);
}
if (!isPending) {
onOpenChange(value);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isPending && onOpenChange(value)}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -83,15 +83,6 @@ export const DocumentMoveToFolderDialog = ({
const { mutateAsync: moveDocumentToFolder } = trpc.folder.moveDocumentToFolder.useMutation();
useEffect(() => {
if (!open) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
}, [open, currentFolderId, form]);
const onSubmit = async (data: TMoveDocumentFormSchema) => {
try {
await moveDocumentToFolder({
@@ -145,12 +136,22 @@ export const DocumentMoveToFolderDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId });
}
onOpenChange(value);
};
const filteredFolders = folders?.data.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={open} onOpenChange={onOpenChange}>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -80,14 +80,15 @@ export const FolderCreateDialog = ({ type, trigger, ...props }: FolderCreateDial
}
};
useEffect(() => {
if (!isCreateFolderOpen) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
}, [isCreateFolderOpen, form]);
setIsCreateFolderOpen(value);
};
return (
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={setIsCreateFolderOpen}>
<Dialog {...props} open={isCreateFolderOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button
@@ -1,5 +1,3 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -92,14 +90,15 @@ export const FolderDeleteDialog = ({ folder, isOpen, onOpenChange }: FolderDelet
}
};
useEffect(() => {
if (!isOpen) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
}, [isOpen]);
onOpenChange(value);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -97,12 +97,13 @@ export const FolderMoveDialog = ({
}
};
useEffect(() => {
if (!isOpen) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setSearchTerm('');
}
}, [isOpen, form]);
onOpenChange(value);
};
// Filter out the current folder, only show folders of the same type, and filter by search term
const filteredFolders = foldersData?.filter(
@@ -113,7 +114,7 @@ export const FolderMoveDialog = ({
);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,5 +1,3 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -71,15 +69,6 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
},
});
useEffect(() => {
if (folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
}, [folder, form]);
const onFormSubmit = async (data: TUpdateFolderFormSchema) => {
if (!folder) {
return;
@@ -110,8 +99,18 @@ export const FolderUpdateDialog = ({ folder, isOpen, onOpenChange }: FolderUpdat
}
};
const handleOpenChange = (value: boolean) => {
if (value && folder) {
form.reset({
name: folder.name,
visibility: folder.visibility ?? DocumentVisibility.EVERYONE,
});
}
onOpenChange(value);
};
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -76,7 +76,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
const [selectedPriceId, setSelectedPriceId] = useState<string>('');
const [open, setOpen] = useState(false);
const [open, setOpen] = useState(actionSearchParam === 'add-organisation');
const form = useForm({
resolver: zodResolver(ZCreateOrganisationFormSchema),
@@ -91,6 +91,19 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
enabled: IS_BILLING_ENABLED(),
});
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
if (actionSearchParam === 'add-organisation') {
updateSearchParams({ action: null });
}
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
const onFormSubmit = async ({ name }: TCreateOrganisationFormSchema) => {
try {
const response = await createOrganisation({
@@ -126,17 +139,6 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
}
};
useEffect(() => {
if (actionSearchParam === 'add-organisation') {
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open]);
useEffect(() => {
form.reset();
}, [open, form]);
const isIndividualPlan = (priceId: string) => {
return (
plansData?.plans[INTERNAL_CLAIM_ID.INDIVIDUAL]?.monthlyPrice?.id === priceId ||
@@ -145,11 +147,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -314,13 +312,16 @@ const BillingPlanForm = ({
};
},
);
}, [plans]);
}, [plans, t]);
useEffect(() => {
if (value === '' && !canCreateFreeOrganisation) {
onChange(dynamicPlans[0][billingPeriod]?.id ?? '');
if (value === '' && !canCreateFreeOrganisation && dynamicPlans.length > 0) {
const defaultValue = dynamicPlans[0][billingPeriod]?.id ?? '';
if (defaultValue) {
onChange(defaultValue);
}
}
}, [value]);
}, [canCreateFreeOrganisation, dynamicPlans, billingPeriod, onChange, value]);
const onBillingPeriodChange = (billingPeriod: 'monthlyPrice' | 'yearlyPrice') => {
const plan = dynamicPlans.find(
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -93,14 +93,19 @@ export const OrganisationDeleteDialog = ({ trigger }: OrganisationDeleteDialogPr
}
};
useEffect(() => {
if (!open) {
const handleOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) {
return;
}
if (!value) {
form.reset();
}
}, [open, form]);
setOpen(value);
};
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -73,13 +73,6 @@ export const OrganisationEmailCreateDialog = ({
const { mutateAsync: createOrganisationEmail, isPending } =
trpc.enterprise.organisation.email.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
}
}, [open, form]);
const onFormSubmit = async (data: TCreateOrganisationEmailFormSchema) => {
try {
await createOrganisationEmail({
@@ -114,8 +107,17 @@ export const OrganisationEmailCreateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
if (!isPending) {
setOpen(value);
}
};
return (
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -36,6 +36,12 @@ export const OrganisationEmailDeleteDialog = ({
const organisation = useCurrentOrganisation();
const handleOpenChange = (value: boolean) => {
if (!isDeleting) {
setOpen(value);
}
};
const { mutateAsync: deleteEmail, isPending: isDeleting } =
trpc.enterprise.organisation.email.delete.useMutation({
onSuccess: () => {
@@ -58,7 +64,7 @@ export const OrganisationEmailDeleteDialog = ({
});
return (
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -75,14 +75,6 @@ export const OrganisationEmailDomainCreateDialog = ({
const { mutateAsync: createOrganisationEmail } =
trpc.enterprise.organisation.emailDomain.create.useMutation();
// Reset state when dialog closes
useEffect(() => {
if (!open) {
form.reset();
setStep('domain');
}
}, [open, form]);
const onFormSubmit = async ({ domain }: TCreateOrganisationEmailDomainFormSchema) => {
try {
const { records } = await createOrganisationEmail({
@@ -118,12 +110,18 @@ export const OrganisationEmailDomainCreateDialog = ({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setStep('domain');
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -87,23 +87,20 @@ export const OrganisationEmailUpdateDialog = ({
}
};
useEffect(() => {
if (!open) {
return;
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset({
emailName: organisationEmail.emailName,
// replyTo: organisationEmail.replyTo ?? undefined,
});
}
form.reset({
emailName: organisationEmail.emailName,
// replyTo: organisationEmail.replyTo ?? undefined,
});
}, [open, form]);
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger}
</DialogTrigger>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
@@ -117,16 +117,17 @@ export const OrganisationGroupCreateDialog = ({
}
};
useEffect(() => {
form.reset();
}, [open, form]);
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -193,13 +193,6 @@ export const OrganisationMemberInviteDialog = ({
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (!open) {
form.reset();
setInvitationType('INDIVIDUAL');
}
}, [open, form]);
const onFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!e.target.files?.length) {
return;
@@ -267,12 +260,18 @@ export const OrganisationMemberInviteDialog = ({
});
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setInvitationType('INDIVIDUAL');
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -106,32 +106,27 @@ export const OrganisationMemberUpdateDialog = ({
}
};
useEffect(() => {
if (!open) {
return;
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
if (
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
) {
setOpen(false);
toast({
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
variant: 'destructive',
});
return;
}
}
form.reset();
if (
!isOrganisationRoleWithinUserHierarchy(currentUserOrganisationRole, organisationMemberRole)
) {
setOpen(false);
toast({
title: _(msg`You cannot modify a organisation member who has a higher role than you.`),
variant: 'destructive',
});
if (!form.formState.isSubmitting) {
setOpen(value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentUserOrganisationRole, organisationMemberRole, form, toast]);
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -120,24 +120,21 @@ export const PasskeyCreateDialog = ({ trigger, onSuccess, ...props }: PasskeyCre
return passkeyName;
};
useEffect(() => {
if (!open) {
const handleDialogOpenChange = (value: boolean) => {
if (!value) {
const defaultPasskeyName = extractDefaultPasskeyName();
form.reset({
passkeyName: defaultPasskeyName,
});
setFormError(null);
}
}, [open, form]);
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleDialogOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="secondary" loading={isPending}>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -66,14 +66,14 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
const updateSearchParams = useUpdateSearchParams();
const organisation = useCurrentOrganisation();
const [open, setOpen] = useState(false);
const actionSearchParam = searchParams?.get('action');
const shouldOpenDialog = actionSearchParam === 'add-team';
const [open, setOpen] = useState(shouldOpenDialog);
const { data: fullOrganisation } = trpc.organisation.get.useQuery({
organisationReference: organisation.id,
});
const actionSearchParam = searchParams?.get('action');
const form = useForm({
resolver: zodResolver(ZCreateTeamFormSchema),
defaultValues: {
@@ -85,6 +85,18 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
const { mutateAsync: createTeam } = trpc.team.create.useMutation();
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
if (shouldOpenDialog) {
updateSearchParams({ action: null });
}
}
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
const onFormSubmit = async ({ teamName, teamUrl, inheritMembers }: TCreateTeamFormSchema) => {
try {
await createTeam({
@@ -150,23 +162,8 @@ export const TeamCreateDialog = ({ trigger, onCreated, ...props }: TeamCreateDia
return 'form';
}, [fullOrganisation]);
useEffect(() => {
if (actionSearchParam === 'add-team') {
setOpen(true);
updateSearchParams({ action: null });
}
}, [actionSearchParam, open]);
useEffect(() => {
form.reset();
}, [open, form]);
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button className="flex-shrink-0" variant="secondary">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -114,14 +114,17 @@ export const TeamDeleteDialog = ({
}
};
useEffect(() => {
if (!open) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
}, [open, form]);
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger ?? (
<Button variant="destructive">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -103,18 +103,17 @@ export const TeamEmailAddDialog = ({ teamId, trigger, ...props }: TeamEmailAddDi
}
};
useEffect(() => {
if (!open) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
}, [open, form]);
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
{trigger ?? (
<Button variant="outline" loading={isPending} className="bg-background">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -92,18 +92,17 @@ export const TeamEmailUpdateDialog = ({
}
};
useEffect(() => {
if (!open) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
}, [open, form]);
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="outline" className="bg-background">
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -107,7 +107,7 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
duration: 5000,
});
setOpen(false);
handleClose();
} catch {
toast({
title: t`An unknown error occurred`,
@@ -117,17 +117,23 @@ export const TeamGroupCreateDialog = ({ ...props }: TeamGroupCreateDialogProps)
}
};
useEffect(() => {
if (!open) {
form.reset();
setStep('SELECT');
const handleClose = () => {
setOpen(false);
form.reset();
setStep('SELECT');
};
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) {
handleClose();
}
}, [open, form]);
};
return (
<Dialog
{...props}
open={open}
onOpenChange={handleOpenChange}
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process.
>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -106,22 +106,17 @@ export const TeamGroupUpdateDialog = ({
}
};
useEffect(() => {
if (!open) {
return;
const handleOpenChange = (value: boolean) => {
if (!form.formState.isSubmitting) {
if (value) {
form.reset();
}
setOpen(value);
}
form.reset();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, team.currentTeamRole, teamGroupRole, form, toast]);
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { Trans, useLingui } from '@lingui/react/macro';
@@ -119,20 +119,17 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
}
};
useEffect(() => {
if (!open) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setStep('SELECT');
}
}, [open, form]);
// Disable automatic onOpenChange events to prevent dialog from closing if user 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process, we handle open state manually
};
return (
<Dialog
{...props}
open={open}
// Disable automatic onOpenChange events to prevent dialog from closing if auser 'accidentally' clicks the overlay.
// Since it would be annoying to redo the whole process.
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
<Button variant="secondary" onClick={() => setOpen(true)}>
<Trans>Add members</Trans>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -106,30 +106,25 @@ export const TeamMemberUpdateDialog = ({
}
};
useEffect(() => {
if (!open) {
return;
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
setOpen(false);
toast({
title: _(msg`You cannot modify a team member who has a higher role than you.`),
variant: 'destructive',
});
return;
}
}
form.reset();
if (!isTeamRoleWithinUserHierarchy(currentUserTeamRole, memberTeamRole)) {
setOpen(false);
toast({
title: _(msg`You cannot modify a team member who has a higher role than you.`),
variant: 'destructive',
});
if (!form.formState.isSubmitting) {
setOpen(value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, currentUserTeamRole, memberTeamRole, form, toast]);
};
return (
<Dialog
{...props}
open={open}
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
>
<Dialog {...props} open={open} onOpenChange={handleOpenChange}>
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
{trigger ?? (
<Button variant="secondary">
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -186,16 +186,20 @@ export const TemplateDirectLinkDialog = ({
const isLoading =
isCreatingTemplateDirectLink || isTogglingTemplateAccess || isDeletingTemplateDirectLink;
useEffect(() => {
resetCreateTemplateDirectLink();
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
setSelectedRecipientId(null);
const handleOpenChange = (value: boolean) => {
if (!isLoading) {
if (value) {
resetCreateTemplateDirectLink();
setCurrentStep(token ? 'MANAGE' : 'ONBOARD');
setSelectedRecipientId(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]);
onOpenChange(value);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !isLoading && onOpenChange(value)}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<fieldset disabled={isLoading} className="relative">
<AnimateGenericFadeInOut motionKey={currentStep}>
{match({ token, currentStep })
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -85,15 +85,6 @@ export function TemplateMoveToFolderDialog({
const { mutateAsync: moveTemplateToFolder } = trpc.folder.moveTemplateToFolder.useMutation();
useEffect(() => {
if (!isOpen) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
}, [isOpen, currentFolderId, form]);
const onSubmit = async (data: TMoveTemplateFormSchema) => {
try {
await moveTemplateToFolder({
@@ -137,12 +128,22 @@ export function TemplateMoveToFolderDialog({
}
};
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
setSearchTerm('');
} else {
form.reset({ folderId: currentFolderId ?? null });
}
onOpenChange(value);
};
const filteredFolders = folders?.data?.filter((folder) =>
folder.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
return (
<Dialog {...props} open={isOpen} onOpenChange={onOpenChange}>
<Dialog {...props} open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -203,14 +203,17 @@ export function TemplateUseDialog({
name: 'recipients',
});
useEffect(() => {
if (!open) {
const handleOpenChange = (value: boolean) => {
if (form.formState.isSubmitting) return;
if (!value) {
form.reset();
}
}, [open, form]);
setOpen(value);
};
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{trigger || (
<Button variant="outline" className="bg-background">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -95,17 +95,17 @@ export default function TokenDeleteDialog({ token, onDelete, children }: TokenDe
}
};
useEffect(() => {
if (!isOpen) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
}, [isOpen, form]);
if (!form.formState.isSubmitting) {
setIsOpen(value);
}
};
return (
<Dialog
open={isOpen}
onOpenChange={(value) => !form.formState.isSubmitting && setIsOpen(value)}
>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild={true}>
{children ?? (
<Button className="mr-4" variant="destructive">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -88,14 +88,17 @@ export const WebhookDeleteDialog = ({ webhook, children }: WebhookDeleteDialogPr
}
};
useEffect(() => {
if (!open) {
const handleOpenChange = (value: boolean) => {
if (!value) {
form.reset();
}
}, [open, form]);
if (!form.formState.isSubmitting) {
setOpen(value);
}
};
return (
<Dialog open={open} onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{children ?? (
<Button className="mr-4" variant="destructive">
@@ -1,12 +1,10 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { Trans } from '@lingui/react/macro';
export const EmbedDocumentWaitingForTurn = () => {
const [hasPostedMessage, setHasPostedMessage] = useState(false);
useEffect(() => {
if (window.parent && !hasPostedMessage) {
if (window.parent) {
window.parent.postMessage(
{
action: 'document-waiting-for-turn',
@@ -15,13 +13,7 @@ export const EmbedDocumentWaitingForTurn = () => {
'*',
);
}
setHasPostedMessage(true);
}, [hasPostedMessage]);
if (!hasPostedMessage) {
return null;
}
}, []);
return (
<div className="embed--WaitingForTurn relative mx-auto flex min-h-[100dvh] max-w-screen-lg flex-col items-center justify-center p-6">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { msg } from '@lingui/core/macro';
@@ -136,18 +136,19 @@ export const EnableAuthenticatorAppDialog = ({ onSuccess }: EnableAuthenticatorA
setIsOpen(true);
};
useEffect(() => {
enable2FAForm.reset();
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (!isOpen && recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null);
if (!open) {
enable2FAForm.reset();
if (recoveryCodes && recoveryCodes.length > 0) {
setRecoveryCodes(null);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild={true}>
<Button
className="flex-shrink-0"
+11 -14
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
@@ -116,9 +116,18 @@ export const SignInForm = ({
const { mutateAsync: createPasskeySigninOptions } =
trpc.auth.createPasskeySigninOptions.useMutation();
const emailFromHash = useMemo(() => {
if (typeof window === 'undefined') {
return null;
}
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
return params.get('email');
}, []);
const form = useForm<TSignInFormSchema>({
values: {
email: initialEmail ?? '',
email: emailFromHash ?? initialEmail ?? '',
password: '',
totpCode: '',
backupCode: '',
@@ -287,18 +296,6 @@ export const SignInForm = ({
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const email = params.get('email');
if (email) {
form.setValue('email', email);
}
}, [form]);
return (
<Form {...form}>
<form
+11 -14
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import type { MessageDescriptor } from '@lingui/core';
@@ -84,10 +84,19 @@ export const SignUpForm = ({
const utmSrc = searchParams.get('utm_source') ?? null;
const emailFromHash = useMemo(() => {
if (typeof window === 'undefined') {
return null;
}
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
return params.get('email');
}, []);
const form = useForm<TSignUpFormSchema>({
values: {
name: '',
email: initialEmail ?? '',
email: emailFromHash ?? initialEmail ?? '',
password: '',
signature: '',
},
@@ -162,18 +171,6 @@ export const SignUpForm = ({
}
};
useEffect(() => {
const hash = window.location.hash.slice(1);
const params = new URLSearchParams(hash);
const email = params.get('email');
if (email) {
form.setValue('email', email);
}
}, [form]);
return (
<div className={cn('flex justify-center gap-x-12', className)}>
<div className="border-border relative hidden flex-1 overflow-hidden rounded-xl border xl:flex">
@@ -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>
</>
);
};
@@ -9,7 +9,6 @@ import { useForm } from 'react-hook-form';
import { useRevalidator } from 'react-router';
import { P, match } from 'ts-pattern';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { AUTO_SIGNABLE_FIELD_TYPES } from '@documenso/lib/constants/autosign';
import { DocumentAuth } from '@documenso/lib/types/document-auth';
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
@@ -61,12 +60,6 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
const { email, fullName } = useRequiredDocumentSigningContext();
const { derivedRecipientActionAuth } = useRequiredDocumentSigningAuthContext();
const [open, setOpen] = useState(false);
const form = useForm();
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const autoSignableFields = fields.filter((field) => {
if (field.inserted) {
return false;
@@ -95,6 +88,14 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
(actionAuth) => !NON_AUTO_SIGNABLE_ACTION_AUTH_TYPES.includes(actionAuth),
);
const [open, setOpen] = useState(() => {
return actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD;
});
const form = useForm();
const { mutateAsync: signFieldWithToken } = trpc.field.signFieldWithToken.useMutation();
const onSubmit = async () => {
const results = await Promise.allSettled(
autoSignableFields.map(async (field) => {
@@ -152,12 +153,6 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
await revalidate();
};
unsafe_useEffectOnce(() => {
if (actionAuthAllowsAutoSign && autoSignableFields.length > AUTO_SIGN_THRESHOLD) {
setOpen(true);
}
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -80,20 +80,16 @@ export const DocumentSigningTextField = ({
const parsedFieldMeta = safeFieldMeta.success ? safeFieldMeta.data : null;
const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading;
const shouldAutoSignField =
(!field.inserted && parsedFieldMeta?.text) ||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly);
const shouldAutoSignField = useMemo(
() =>
(!field.inserted && parsedFieldMeta?.text) ||
(!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly),
[field.inserted, parsedFieldMeta?.text, parsedFieldMeta?.readOnly],
);
const [showCustomTextModal, setShowCustomTextModal] = useState(false);
const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? '');
useEffect(() => {
if (!showCustomTextModal) {
setLocalCustomText(parsedFieldMeta?.text ?? '');
setErrors(initialErrors);
}
}, [showCustomTextModal]);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const text = e.target.value;
setLocalCustomText(text);
@@ -216,14 +212,12 @@ export const DocumentSigningTextField = ({
}
};
useEffect(() => {
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
}, []);
if (shouldAutoSignField) {
void executeActionAuthProcedure({
onReauthFormSubmit: async (authOptions) => await onSign(authOptions),
actionTarget: field.type,
});
}
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
@@ -318,7 +312,8 @@ export const DocumentSigningTextField = ({
className="flex-1"
onClick={() => {
setShowCustomTextModal(false);
setLocalCustomText('');
setLocalCustomText(parsedFieldMeta?.text ?? '');
setErrors(initialErrors);
}}
>
<Trans>Cancel</Trans>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -26,16 +26,13 @@ export const DocumentSearch = ({ initialValue = '' }: { initialValue?: string })
setSearchParams(params);
},
[searchParams],
[searchParams, setSearchParams],
);
useEffect(() => {
const currentQueryParam = searchParams.get('query') || '';
if (debouncedSearchTerm !== currentQueryParam) {
handleSearch(debouncedSearchTerm);
}
}, [debouncedSearchTerm, searchParams]);
const currentQuery = searchParams?.get('query') ?? '';
if (currentQuery !== debouncedSearchTerm) {
handleSearch(debouncedSearchTerm);
}
return (
<Input
@@ -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,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -22,11 +22,34 @@ export type VerifyEmailBannerProps = {
const RESEND_CONFIRMATION_EMAIL_TIMEOUT = 20 * ONE_SECOND;
const shouldShowDialog = () => {
try {
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < ONE_DAY) {
return false;
}
}
// Update the timestamp when showing the dialog
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
return true;
} catch {
// In case localStorage is not available (SSR, incognito mode, etc.)
return false;
}
};
export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
const { _ } = useLingui();
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isOpen, setIsOpen] = useState(shouldShowDialog);
const [isPending, setIsPending] = useState(false);
const [isButtonDisabled, setIsButtonDisabled] = useState(false);
@@ -62,27 +85,6 @@ export const VerifyEmailBanner = ({ email }: VerifyEmailBannerProps) => {
setIsPending(false);
};
useEffect(() => {
// Check localStorage to see if we've recently automatically displayed the dialog
// if it was within the past 24 hours, don't show it again
// otherwise, show it again and update the localStorage timestamp
const emailVerificationDialogLastShown = localStorage.getItem(
'emailVerificationDialogLastShown',
);
if (emailVerificationDialogLastShown) {
const lastShownTimestamp = parseInt(emailVerificationDialogLastShown);
if (Date.now() - lastShownTimestamp < ONE_DAY) {
return;
}
}
setIsOpen(true);
localStorage.setItem('emailVerificationDialogLastShown', Date.now().toString());
}, []);
return (
<>
<div className="bg-yellow-200 dark:bg-yellow-400">
@@ -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>
);
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
@@ -29,22 +29,29 @@ export default function TeamsSettingsMembersPage() {
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
const handleSearchQueryChange = useCallback(
(newQuery: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (newQuery.trim()) {
params.set('query', newQuery);
} else {
params.delete('query');
}
if (debouncedSearchQuery === '') {
params.delete('query');
}
if (params.toString() === searchParams?.toString()) {
return;
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
},
[searchParams, setSearchParams],
);
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const currentParamQuery = searchParams?.get('query') ?? '';
if (currentParamQuery !== debouncedSearchQuery) {
handleSearchQueryChange(debouncedSearchQuery);
}
return (
<div>
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { useSearchParams } from 'react-router';
@@ -20,21 +20,28 @@ export default function OrganisationSettingsTeamsPage() {
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
const handleSearchQueryChange = useCallback(
(newQuery: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (newQuery.trim()) {
params.set('query', newQuery);
} else {
params.delete('query');
}
if (debouncedSearchQuery === '') {
params.delete('query');
}
setSearchParams(params);
},
[searchParams, setSearchParams],
);
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const currentParamQuery = searchParams?.get('query') ?? '';
if (currentParamQuery !== debouncedSearchQuery) {
handleSearchQueryChange(debouncedSearchQuery);
}
return (
<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,5 +1,3 @@
import { useEffect } from 'react';
import { Outlet, useNavigate } from 'react-router';
import { OrganisationProvider } from '@documenso/lib/client-only/providers/organisation';
@@ -30,13 +28,8 @@ export default function Layout() {
const currentOrganisation = organisations[0];
const team = currentOrganisation?.teams[0] || null;
useEffect(() => {
if (!isPersonalLayoutMode || !team) {
void navigate('/settings/profile');
}
}, []);
if (!isPersonalLayoutMode || !team) {
void navigate('/settings/profile');
return null;
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Trans } from '@lingui/react/macro';
import { FolderType, OrganisationType } from '@prisma/client';
@@ -12,10 +12,7 @@ import { parseToIntegerArray } from '@documenso/lib/utils/params';
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
import { 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';
@@ -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,6 +62,19 @@ export default function DocumentsPage() {
folderId,
});
const stats = useMemo(
() =>
data?.stats ?? {
[ExtendedDocumentStatus.DRAFT]: 0,
[ExtendedDocumentStatus.PENDING]: 0,
[ExtendedDocumentStatus.COMPLETED]: 0,
[ExtendedDocumentStatus.REJECTED]: 0,
[ExtendedDocumentStatus.INBOX]: 0,
[ExtendedDocumentStatus.ALL]: 0,
},
[data?.stats],
);
const getTabHref = (value: keyof typeof ExtendedDocumentStatus) => {
const params = new URLSearchParams(searchParams);
@@ -104,12 +105,6 @@ export default function DocumentsPage() {
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">
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { useLocation, useSearchParams } from 'react-router';
@@ -19,26 +19,33 @@ export default function TeamsSettingsMembersPage() {
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
const handleSearchQueryChange = useCallback(
(newQuery: string) => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (newQuery.trim()) {
params.set('query', newQuery);
} else {
params.delete('query');
}
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
},
[searchParams, setSearchParams],
);
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const currentParamQuery = searchParams?.get('query') ?? '';
if (currentParamQuery !== debouncedSearchQuery) {
handleSearchQueryChange(debouncedSearchQuery);
}
return (
<div>
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -130,12 +130,8 @@ export default function PublicProfilePage({ loaderData }: Route.ComponentProps)
}
};
useEffect(() => {
setIsPublicProfileVisible(profile.enabled);
}, [profile.enabled]);
return (
<div className="max-w-2xl">
<div key={team.id} className="max-w-2xl">
<SettingsHeader
title={t`Public Profile`}
subtitle={t`You can choose to enable or disable the profile for public view.`}
@@ -38,33 +38,48 @@ export default function VerifyEmailPage({ loaderData }: Route.ComponentProps) {
const [state, setState] = useState<keyof typeof EMAIL_VERIFICATION_STATE | null>(null);
const [isLoading, setIsLoading] = useState(false);
const verifyToken = async () => {
setIsLoading(true);
try {
const response = await authClient.emailPassword.verifyEmail({
token,
});
await refreshSession();
setState(response.state);
} catch (err) {
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your email at this time.`),
});
await navigate('/verify-email');
}
setIsLoading(false);
};
useEffect(() => {
void verifyToken();
let ignore = false;
const verify = async () => {
try {
setIsLoading(true);
const response = await authClient.emailPassword.verifyEmail({
token,
});
if (ignore) {
return;
}
await refreshSession();
setState(response.state);
} catch (err) {
if (ignore) {
return;
}
console.error(err);
toast({
title: _(msg`Something went wrong`),
description: _(msg`We were unable to verify your email at this time.`),
});
await navigate('/verify-email');
} finally {
if (!ignore) {
setIsLoading(false);
}
}
};
void verify();
return () => {
ignore = true;
};
}, []);
if (isLoading || state === null) {
+50 -4
View File
@@ -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,23 +1,23 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
export const useElementBounds = (elementOrSelector: HTMLElement | string, withScroll = false) => {
const [bounds, setBounds] = useState({
top: 0,
left: 0,
height: 0,
width: 0,
});
const [forceRecalc, setForceRecalc] = useState(0);
const calculateBounds = useCallback(() => {
const bounds = useMemo(() => {
const $el =
typeof elementOrSelector === 'string'
? document.querySelector<HTMLElement>(elementOrSelector)
: elementOrSelector;
if (!$el) {
throw new Error('Element not found');
return {
top: 0,
left: 0,
height: 0,
width: 0,
};
}
if (withScroll) {
@@ -32,15 +32,11 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
width,
height,
};
}, [elementOrSelector, withScroll]);
useEffect(() => {
setBounds(calculateBounds());
}, []);
}, [elementOrSelector, withScroll, forceRecalc]);
useEffect(() => {
const onResize = () => {
setBounds(calculateBounds());
setForceRecalc((prev) => prev + 1);
};
window.addEventListener('resize', onResize);
@@ -61,7 +57,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
}
const observer = new ResizeObserver(() => {
setBounds(calculateBounds());
setForceRecalc((prev) => prev + 1);
});
observer.observe($el);
@@ -69,7 +65,7 @@ export const useElementBounds = (elementOrSelector: HTMLElement | string, withSc
return () => {
observer.disconnect();
};
}, []);
}, [elementOrSelector]);
return bounds;
};
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import { RefObject, useEffect, useState } from 'react';
import { useMemo } from 'react';
/**
* Calculate the width and height of a text element.
@@ -64,13 +64,7 @@ export function useElementScaleSize(
fontSize: number,
fontFamily: string,
) {
const [scalingFactor, setScalingFactor] = useState(1);
useEffect(() => {
const scaleSize = calculateTextScaleSize(container, text, `${fontSize}px`, fontFamily);
setScalingFactor(scaleSize);
}, [text, container, fontFamily, fontSize]);
return scalingFactor;
return useMemo(() => {
return calculateTextScaleSize(container, text, `${fontSize}px`, fontFamily);
}, [container, text, fontSize, fontFamily]);
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { Field } from '@prisma/client';
@@ -6,20 +6,20 @@ import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-c
import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer';
export const useFieldPageCoords = (field: Field) => {
const [coords, setCoords] = useState({
x: 0,
y: 0,
height: 0,
width: 0,
});
const [forceRecalc, setForceRecalc] = useState(0);
const calculateCoords = useCallback(() => {
const coords = useMemo(() => {
const $page = document.querySelector<HTMLElement>(
`${PDF_VIEWER_PAGE_SELECTOR}[data-page-number="${field.page}"]`,
);
if (!$page) {
return;
return {
x: 0,
y: 0,
height: 0,
width: 0,
};
}
const { top, left, height, width } = getBoundingClientRect($page);
@@ -31,21 +31,17 @@ export const useFieldPageCoords = (field: Field) => {
const fieldHeight = (Number(field.height) / 100) * height;
const fieldWidth = (Number(field.width) / 100) * width;
setCoords({
return {
x: fieldX,
y: fieldY,
height: fieldHeight,
width: fieldWidth,
});
}, [field.height, field.page, field.positionX, field.positionY, field.width]);
useEffect(() => {
calculateCoords();
}, [calculateCoords]);
};
}, [field.height, field.page, field.positionX, field.positionY, field.width, forceRecalc]);
useEffect(() => {
const onResize = () => {
calculateCoords();
setForceRecalc((prev) => prev + 1);
};
window.addEventListener('resize', onResize);
@@ -53,7 +49,7 @@ export const useFieldPageCoords = (field: Field) => {
return () => {
window.removeEventListener('resize', onResize);
};
}, [calculateCoords]);
}, []);
useEffect(() => {
const $page = document.querySelector<HTMLElement>(
@@ -65,7 +61,7 @@ export const useFieldPageCoords = (field: Field) => {
}
const observer = new ResizeObserver(() => {
calculateCoords();
setForceRecalc((prev) => prev + 1);
});
observer.observe($page);
@@ -73,7 +69,7 @@ export const useFieldPageCoords = (field: Field) => {
return () => {
observer.disconnect();
};
}, [calculateCoords, field.page]);
}, [field.page]);
return coords;
};
@@ -1,11 +1,17 @@
import { useEffect, useState } from 'react';
import { useSyncExternalStore } from 'react';
const subscribe = () => {
return () => {};
};
const getSnapshot = () => {
return true;
};
const getServerSnapshot = () => {
return false;
};
export const useIsMounted = () => {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
return isMounted;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
};
+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') ?? '',
});
@@ -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;
};
@@ -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 }) => {
@@ -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>;
+21 -9
View File
@@ -1,11 +1,23 @@
import { useEffect, useState } from 'react';
import { useSyncExternalStore } from 'react';
export const ClientOnly = async ({ children }: { children: React.ReactNode }) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted ? children : null;
const subscribe = () => {
return () => {};
};
const getSnapshot = () => {
return true;
};
const getServerSnapshot = () => {
return false;
};
export const ClientOnly = ({
children,
}: {
children: React.ReactNode;
}): React.ReactElement | null => {
const isClient = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
return isClient ? <>{children}</> : null;
};
@@ -18,7 +18,6 @@ import {
} from 'lucide-react';
import { useFieldArray, useForm } from 'react-hook-form';
import { useHotkeys } from 'react-hotkeys-hook';
import { prop, sortBy } from 'remeda';
import { getBoundingClientRect } from '@documenso/lib/client-only/get-bounding-client-rect';
import { useDocumentElement } from '@documenso/lib/client-only/hooks/use-document-element';
@@ -103,6 +102,10 @@ export const AddFieldsFormPartial = ({
const [isMissingSignatureDialogVisible, setIsMissingSignatureDialogVisible] = useState(false);
const handleMissingSignatureDialogOpenChange = (value: boolean) => {
setIsMissingSignatureDialogVisible(value);
};
const { isWithinPageBounds, getFieldPosition, getPage } = useDocumentElement();
const { currentStep, totalSteps, previousStep } = useStep();
const canRenderBackButtonAsRemove =
@@ -511,18 +514,24 @@ export const AddFieldsFormPartial = ({
};
}, []);
useEffect(() => {
const defaultSelectedSigner = useMemo(() => {
const recipientsByRoleToDisplay = recipients.filter(
(recipient) =>
recipient.role !== RecipientRole.CC && recipient.role !== RecipientRole.ASSISTANT,
);
setSelectedSigner(
return (
recipientsByRoleToDisplay.find((r) => r.sendStatus !== SendStatus.SENT) ??
recipientsByRoleToDisplay[0],
recipientsByRoleToDisplay[0]
);
}, [recipients]);
if (selectedSigner && !recipients.find((r) => r.id === selectedSigner.id)) {
setSelectedSigner(defaultSelectedSigner);
} else if (!selectedSigner && defaultSelectedSigner) {
setSelectedSigner(defaultSelectedSigner);
}
const recipientsByRole = useMemo(() => {
const recipientsByRole: Record<RecipientRole, Recipient[]> = {
CC: [],
@@ -539,29 +548,6 @@ export const AddFieldsFormPartial = ({
return recipientsByRole;
}, [recipients]);
const recipientsByRoleToDisplay = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return (Object.entries(recipientsByRole) as [RecipientRole, Recipient[]][])
.filter(
([role]) =>
role !== RecipientRole.CC &&
role !== RecipientRole.VIEWER &&
role !== RecipientRole.ASSISTANT,
)
.map(
([role, roleRecipients]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
[
role,
sortBy(
roleRecipients,
[(r) => r.signingOrder || Number.MAX_SAFE_INTEGER, 'asc'],
[prop('id'), 'asc'],
),
] as [RecipientRole, Recipient[]],
);
}, [recipientsByRole]);
const handleAdvancedSettings = () => {
setShowAdvancedSettings((prev) => !prev);
};
@@ -995,7 +981,7 @@ export const AddFieldsFormPartial = ({
<MissingSignatureFieldDialog
isOpen={isMissingSignatureDialogVisible}
onOpenChange={(value) => setIsMissingSignatureDialogVisible(value)}
onOpenChange={handleMissingSignatureDialogOpenChange}
/>
</>
)}
@@ -1,4 +1,4 @@
import { forwardRef, useEffect, useState } from 'react';
import { forwardRef, useMemo, useState } from 'react';
import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
@@ -160,22 +160,32 @@ export const FieldAdvancedSettings = forwardRef<HTMLDivElement, FieldAdvancedSet
const defaultState: FieldMeta = getDefaultState(field.type);
const [fieldState, setFieldState] = useState(() => {
const savedState = localStorage.getItem(localStorageKey);
return savedState ? { ...defaultState, ...JSON.parse(savedState) } : defaultState;
});
useEffect(() => {
const initialFieldState = useMemo(() => {
if (fieldMeta && typeof fieldMeta === 'object') {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
setFieldState({
return {
...defaultState,
...parsedFieldMeta,
});
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fieldMeta]);
const savedState = localStorage.getItem(localStorageKey);
return savedState ? { ...defaultState, ...JSON.parse(savedState) } : defaultState;
}, [fieldMeta, defaultState, localStorageKey]);
const [fieldState, setFieldState] = useState(initialFieldState);
if (fieldMeta && typeof fieldMeta === 'object') {
const parsedFieldMeta = ZFieldMetaSchema.parse(fieldMeta);
const expectedState = {
...defaultState,
...parsedFieldMeta,
};
if (JSON.stringify(fieldState) !== JSON.stringify(expectedState)) {
setFieldState(expectedState);
}
}
const handleFieldChange = (
key: FieldMetaKeys,
@@ -1,12 +1,12 @@
import type { MouseEvent, PointerEvent, RefObject, TouchEvent } from 'react';
import { useMemo, useRef, useState } from 'react';
import { useLayoutEffect } from 'react';
import { Trans } from '@lingui/react/macro';
import { Undo2 } from 'lucide-react';
import type { StrokeOptions } from 'perfect-freehand';
import { getStroke } from 'perfect-freehand';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import {
SIGNATURE_CANVAS_DPI,
SIGNATURE_MIN_COVERAGE_THRESHOLD,
@@ -247,7 +247,7 @@ export const SignaturePadDraw = ({
onChange?.($el.current.toDataURL());
};
unsafe_useEffectOnce(() => {
useLayoutEffect(() => {
if ($el.current) {
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
$el.current.height = $el.current.clientHeight * SIGNATURE_CANVAS_DPI;
@@ -270,7 +270,7 @@ export const SignaturePadDraw = ({
img.src = value;
}
});
}, [value]);
return (
<div className={cn('h-full w-full', className)}>
@@ -1,10 +1,9 @@
import { useRef } from 'react';
import { useLayoutEffect, useRef } from 'react';
import { Trans } from '@lingui/react/macro';
import { motion } from 'framer-motion';
import { UploadCloudIcon } from 'lucide-react';
import { unsafe_useEffectOnce } from '@documenso/lib/client-only/hooks/use-effect-once';
import { SIGNATURE_CANVAS_DPI } from '@documenso/lib/constants/signatures';
import { cn } from '../../lib/utils';
@@ -97,7 +96,7 @@ export const SignaturePadUpload = ({
}
};
unsafe_useEffectOnce(() => {
useLayoutEffect(() => {
// Todo: Not really sure if this is required for uploaded images.
if ($el.current) {
$el.current.width = $el.current.clientWidth * SIGNATURE_CANVAS_DPI;
@@ -121,7 +120,7 @@ export const SignaturePadUpload = ({
img.src = value;
}
});
}, [value]);
return (
<div className={cn('relative h-full w-full', className)}>
@@ -1,5 +1,3 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useLingui } from '@lingui/react/macro';
import { Trans } from '@lingui/react/macro';
@@ -16,7 +14,7 @@ import {
DOCUMENT_SIGNATURE_TYPES,
} from '@documenso/lib/constants/document';
import { SUPPORTED_LANGUAGES } from '@documenso/lib/constants/i18n';
import { DEFAULT_DOCUMENT_TIME_ZONE, TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
import { ZDocumentEmailSettingsSchema } from '@documenso/lib/types/document-email';
import type { TTemplate } from '@documenso/lib/types/template';
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
@@ -113,7 +111,8 @@ export const AddTemplateSettingsFormPartial = ({
meta: {
subject: template.templateMeta?.subject ?? '',
message: template.templateMeta?.message ?? '',
timezone: template.templateMeta?.timezone ?? DEFAULT_DOCUMENT_TIME_ZONE,
timezone:
template.templateMeta?.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
dateFormat: (template.templateMeta?.dateFormat ??
DEFAULT_DOCUMENT_DATE_FORMAT) as TDocumentMetaDateFormat,
@@ -152,14 +151,6 @@ export const AddTemplateSettingsFormPartial = ({
)
.otherwise(() => false);
// We almost always want to set the timezone to the user's local timezone to avoid confusion
// when the document is signed.
useEffect(() => {
if (!form.formState.touchedFields.meta?.timezone && !template.templateMeta?.timezone) {
form.setValue('meta.timezone', Intl.DateTimeFormat().resolvedOptions().timeZone);
}
}, [form, form.setValue, form.formState.touchedFields.meta?.timezone]);
return (
<>
<DocumentFlowFormContainerHeader
+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",