mirror of
https://github.com/documenso/documenso.git
synced 2025-11-22 04:31:39 +10:00
chore: fix conflicts
This commit is contained in:
@ -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>
|
||||
);
|
||||
};
|
||||
@ -87,7 +87,7 @@ export const OrganisationCreateDialog = ({ trigger, ...props }: OrganisationCrea
|
||||
|
||||
const { mutateAsync: createOrganisation } = trpc.organisation.create.useMutation();
|
||||
|
||||
const { data: plansData } = trpc.billing.plans.get.useQuery(undefined, {
|
||||
const { data: plansData } = trpc.enterprise.billing.plans.get.useQuery(undefined, {
|
||||
enabled: IS_BILLING_ENABLED(),
|
||||
});
|
||||
|
||||
|
||||
@ -0,0 +1,243 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
type EmailDomain = {
|
||||
id: string;
|
||||
domain: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type OrganisationEmailCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
emailDomain: EmailDomain;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreateOrganisationEmailFormSchema = ZCreateOrganisationEmailRequestSchema.pick({
|
||||
emailName: true,
|
||||
email: true,
|
||||
// replyTo: true,
|
||||
});
|
||||
|
||||
type TCreateOrganisationEmailFormSchema = z.infer<typeof ZCreateOrganisationEmailFormSchema>;
|
||||
|
||||
export const OrganisationEmailCreateDialog = ({
|
||||
trigger,
|
||||
emailDomain,
|
||||
...props
|
||||
}: OrganisationEmailCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateOrganisationEmailFormSchema),
|
||||
defaultValues: {
|
||||
emailName: '',
|
||||
email: '',
|
||||
// replyTo: '',
|
||||
},
|
||||
});
|
||||
|
||||
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({
|
||||
emailDomainId: emailDomain.id,
|
||||
...data,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Email Created`,
|
||||
description: t`The organisation email has been created successfully.`,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
console.error(error);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
toast({
|
||||
title: t`Email already exists`,
|
||||
description: t`An email with this address already exists.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`An error occurred`,
|
||||
description: t`We encountered an error while creating the email. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog {...props} open={open} onOpenChange={(value) => !isPending && setOpen(value)}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
<Trans>Add Email</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Add Organisation Email</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Create a new email address for your organisation using the domain{' '}
|
||||
<span className="font-bold">{emailDomain.domain}</span>.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Display Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Support" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>The display name for this email address</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Email Address</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative flex items-center gap-2">
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value.split('@')[0]}
|
||||
onChange={(e) => {
|
||||
field.onChange(e.target.value + '@' + emailDomain.domain);
|
||||
}}
|
||||
placeholder="support"
|
||||
/>
|
||||
<div className="bg-muted text-muted-foreground absolute bottom-0 right-0 top-0 flex items-center rounded-r-md border px-3 py-2 text-sm">
|
||||
@{emailDomain.domain}
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
{!form.formState.errors.email && (
|
||||
<span className="text-foreground/50 text-xs font-normal">
|
||||
{field.value ? (
|
||||
field.value
|
||||
) : (
|
||||
<Trans>
|
||||
The part before the @ symbol (e.g., "support" for support@
|
||||
{emailDomain.domain})
|
||||
</Trans>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="replyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply-To Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="noreply@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Optional no-reply email address attached to emails. Leave blank to default
|
||||
to the organisation settings reply-to email.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="dialog-create-organisation-email-button"
|
||||
loading={isPending}
|
||||
>
|
||||
<Trans>Create Email</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type OrganisationEmailDeleteDialogProps = {
|
||||
emailId: string;
|
||||
email: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const OrganisationEmailDeleteDialog = ({
|
||||
trigger,
|
||||
emailId,
|
||||
email,
|
||||
}: OrganisationEmailDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { mutateAsync: deleteEmail, isPending: isDeleting } =
|
||||
trpc.enterprise.organisation.email.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`You have successfully removed this email from the organisation.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to remove this email. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
<Trans>Delete email</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to remove the following email from{' '}
|
||||
<span className="font-semibold">{organisation.name}</span>.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription className="text-center font-semibold">{email}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<fieldset disabled={isDeleting}>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
loading={isDeleting}
|
||||
onClick={async () =>
|
||||
deleteEmail({
|
||||
emailId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { ZCreateOrganisationEmailDomainRequestSchema } from '@documenso/trpc/server/enterprise-router/create-organisation-email-domain.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { OrganisationEmailDomainRecordContent } from './organisation-email-domain-records-dialog';
|
||||
|
||||
export type OrganisationEmailCreateDialogProps = {
|
||||
trigger?: React.ReactNode;
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZCreateOrganisationEmailDomainFormSchema = ZCreateOrganisationEmailDomainRequestSchema.pick({
|
||||
domain: true,
|
||||
});
|
||||
|
||||
type TCreateOrganisationEmailDomainFormSchema = z.infer<
|
||||
typeof ZCreateOrganisationEmailDomainFormSchema
|
||||
>;
|
||||
|
||||
type DomainRecord = {
|
||||
name: string;
|
||||
value: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export const OrganisationEmailDomainCreateDialog = ({
|
||||
trigger,
|
||||
...props
|
||||
}: OrganisationEmailCreateDialogProps) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [step, setStep] = useState<'domain' | 'verification'>('domain');
|
||||
const [recordsToAdd, setRecordsToAdd] = useState<DomainRecord[]>([]);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(ZCreateOrganisationEmailDomainFormSchema),
|
||||
defaultValues: {
|
||||
domain: '',
|
||||
},
|
||||
});
|
||||
|
||||
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({
|
||||
domain,
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
setRecordsToAdd(records);
|
||||
setStep('verification');
|
||||
|
||||
toast({
|
||||
title: t`Domain Added`,
|
||||
description: t`DKIM records generated. Please add the DNS records to verify your domain.`,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
console.error(error);
|
||||
|
||||
if (error.code === AppErrorCode.ALREADY_EXISTS) {
|
||||
toast({
|
||||
title: t`Domain already in use`,
|
||||
description: t`Please try a different domain.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to add your domain. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger ?? (
|
||||
<Button className="flex-shrink-0" variant="secondary">
|
||||
<Trans>Add Email Domain</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
{step === 'domain' ? (
|
||||
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Add Custom Email Domain</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
Add a custom domain to send emails on behalf of your organisation. We'll generate
|
||||
DKIM records that you need to add to your DNS provider.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full flex-col space-y-4"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="domain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Domain Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="example.com" className="bg-background" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Enter the domain you want to use for sending emails (without http:// or
|
||||
www)
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="dialog-create-organisation-email-button"
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Generate DKIM Records</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
) : (
|
||||
<OrganisationEmailDomainRecordContent records={recordsToAdd} />
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,161 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type OrganisationEmailDomainDeleteDialogProps = {
|
||||
emailDomainId: string;
|
||||
emailDomain: string;
|
||||
trigger?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const OrganisationEmailDomainDeleteDialog = ({
|
||||
trigger,
|
||||
emailDomainId,
|
||||
emailDomain,
|
||||
}: OrganisationEmailDomainDeleteDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const deleteMessage = t`delete ${emailDomain}`;
|
||||
|
||||
const ZDeleteEmailDomainFormSchema = z.object({
|
||||
confirmText: z.literal(deleteMessage, {
|
||||
errorMap: () => ({ message: t`You must type '${deleteMessage}' to confirm` }),
|
||||
}),
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof ZDeleteEmailDomainFormSchema>>({
|
||||
resolver: zodResolver(ZDeleteEmailDomainFormSchema),
|
||||
defaultValues: {
|
||||
confirmText: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteEmailDomain, isPending: isDeleting } =
|
||||
trpc.enterprise.organisation.emailDomain.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Success`,
|
||||
description: t`You have successfully removed this email domain from the organisation.`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
description: t`We encountered an unknown error while attempting to remove this email domain. Please try again later.`,
|
||||
variant: 'destructive',
|
||||
duration: 10000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async () => {
|
||||
await deleteEmailDomain({
|
||||
emailDomainId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(value) => !isDeleting && setOpen(value)}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button variant="secondary">
|
||||
<Trans>Delete email domain</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Are you sure?</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription className="mt-4">
|
||||
<Trans>
|
||||
You are about to remove the email domain{' '}
|
||||
<span className="font-semibold">{emailDomain}</span> from{' '}
|
||||
<span className="font-semibold">{organisation.name}</span>. All emails associated with
|
||||
this domain will be deleted.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={form.formState.isSubmitting} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmText"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>
|
||||
Confirm by typing{' '}
|
||||
<span className="font-sm text-destructive font-semibold">
|
||||
{deleteMessage}
|
||||
</span>
|
||||
</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={deleteMessage} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,139 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
|
||||
import { CopyTextButton } from '@documenso/ui/components/common/copy-text-button';
|
||||
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type OrganisationEmailDomainRecordsDialogProps = {
|
||||
trigger: React.ReactNode;
|
||||
records: DomainRecord[];
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
type DomainRecord = {
|
||||
name: string;
|
||||
value: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export const OrganisationEmailDomainRecordsDialog = ({
|
||||
trigger,
|
||||
records,
|
||||
...props
|
||||
}: OrganisationEmailDomainRecordsDialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild={true}>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<OrganisationEmailDomainRecordContent records={records} />
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const OrganisationEmailDomainRecordContent = ({ records }: { records: DomainRecord[] }) => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<DialogContent position="center" className="max-h-[90vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Verify Domain</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Add these DNS records to verify your domain ownership</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{records.map((record) => (
|
||||
<div className="space-y-4 rounded-md border p-4" key={record.name}>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Record Type</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input className="pr-12" disabled value={record.type} />
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={record.type}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Record Name</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input className="pr-12" disabled value={record.name} />
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={record.name}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
<Trans>Record Value</Trans>
|
||||
</Label>
|
||||
|
||||
<div className="relative">
|
||||
<Input className="pr-12" disabled value={record.value} />
|
||||
<div className="absolute bottom-0 right-2 top-0 flex items-center justify-center">
|
||||
<CopyTextButton
|
||||
value={record.value}
|
||||
onCopySuccess={() => toast({ title: t`Copied to clipboard` })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
Once you update your DNS records, it may take up to 48 hours for it to be propogated.
|
||||
Once the DNS propagation is complete you will need to come back and press the "Sync"
|
||||
domains button
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
||||
import { ZUpdateOrganisationEmailRequestSchema } from '@documenso/trpc/server/enterprise-router/update-organisation-email.types';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
export type OrganisationEmailUpdateDialogProps = {
|
||||
trigger: React.ReactNode;
|
||||
organisationEmail: TGetOrganisationEmailDomainResponse['emails'][number];
|
||||
} & Omit<DialogPrimitive.DialogProps, 'children'>;
|
||||
|
||||
const ZUpdateOrganisationEmailFormSchema = ZUpdateOrganisationEmailRequestSchema.pick({
|
||||
emailName: true,
|
||||
// replyTo: true,
|
||||
});
|
||||
|
||||
type ZUpdateOrganisationEmailSchema = z.infer<typeof ZUpdateOrganisationEmailFormSchema>;
|
||||
|
||||
export const OrganisationEmailUpdateDialog = ({
|
||||
trigger,
|
||||
organisationEmail,
|
||||
...props
|
||||
}: OrganisationEmailUpdateDialogProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm<ZUpdateOrganisationEmailSchema>({
|
||||
resolver: zodResolver(ZUpdateOrganisationEmailFormSchema),
|
||||
defaultValues: {
|
||||
emailName: organisationEmail.emailName,
|
||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationEmail, isPending } =
|
||||
trpc.enterprise.organisation.email.update.useMutation();
|
||||
|
||||
const onFormSubmit = async ({ emailName }: ZUpdateOrganisationEmailSchema) => {
|
||||
try {
|
||||
await updateOrganisationEmail({
|
||||
emailId: organisationEmail.id,
|
||||
emailName,
|
||||
// replyTo,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Success`,
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast({
|
||||
title: t`An unknown error occurred`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.reset({
|
||||
emailName: organisationEmail.emailName,
|
||||
// replyTo: organisationEmail.replyTo ?? undefined,
|
||||
});
|
||||
}, [open, form]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...props}
|
||||
open={open}
|
||||
onOpenChange={(value) => !form.formState.isSubmitting && setOpen(value)}
|
||||
>
|
||||
<DialogTrigger onClick={(e) => e.stopPropagation()} asChild>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent position="center">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Update email</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>
|
||||
You are currently updating{' '}
|
||||
<span className="font-bold">{organisationEmail.email}</span>
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset className="flex h-full flex-col space-y-4" disabled={isPending}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Display Name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="Support" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>The display name for this email address</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="replyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply-To Email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="noreply@example.com" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Optional no-reply email address attached to emails. Leave blank to default
|
||||
to the organisation settings reply-to email.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => setOpen(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isPending}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -4,7 +4,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { TeamMemberRole } from '@prisma/client';
|
||||
import type * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { InfoIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
import { z } from 'zod';
|
||||
|
||||
@ -39,6 +41,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
@ -140,8 +143,28 @@ export const TeamMemberCreateDialog = ({ trigger, ...props }: TeamMemberCreateDi
|
||||
{match(step)
|
||||
.with('SELECT', () => (
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<DialogTitle className="flex flex-row items-center">
|
||||
<Trans>Add members</Trans>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon className="mx-2 h-4 w-4" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="text-muted-foreground z-[99999] max-w-xs">
|
||||
<Trans>
|
||||
To be able to add members to a team, you must first add them to the
|
||||
organisation. For more information, please see the{' '}
|
||||
<Link
|
||||
to="https://docs.documenso.com/users/organisations/members"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-documenso-700 hover:text-documenso-600 hover:underline"
|
||||
>
|
||||
documentation
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
|
||||
@ -15,6 +15,7 @@ import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
TEMPLATE_RECIPIENT_EMAIL_PLACEHOLDER_REGEX,
|
||||
TEMPLATE_RECIPIENT_NAME_PLACEHOLDER_REGEX,
|
||||
isTemplateRecipientEmailPlaceholder,
|
||||
} from '@documenso/lib/constants/template';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
@ -279,7 +280,11 @@ export function TemplateUseDialog({
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={recipients[index].email || _(msg`Email`)}
|
||||
placeholder={
|
||||
isTemplateRecipientEmailPlaceholder(field.value)
|
||||
? ''
|
||||
: _(msg`Email`)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@ -484,6 +489,7 @@ export function TemplateUseDialog({
|
||||
|
||||
<input
|
||||
type="file"
|
||||
data-testid="template-use-dialog-file-input"
|
||||
className="absolute h-full w-full opacity-0"
|
||||
accept=".pdf,application/pdf"
|
||||
onChange={(e) => {
|
||||
|
||||
@ -32,7 +32,14 @@ import { DocumentSigningTextField } from '~/components/general/document-signing/
|
||||
|
||||
export type EmbedDocumentFieldsProps = {
|
||||
fields: Field[];
|
||||
metadata?: DocumentMeta | TemplateMeta | null;
|
||||
metadata?: Pick<
|
||||
DocumentMeta | TemplateMeta,
|
||||
| 'timezone'
|
||||
| 'dateFormat'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
> | null;
|
||||
onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise<void> | void;
|
||||
};
|
||||
|
||||
@ -8,17 +8,24 @@ import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DATE_FORMATS } from '@documenso/lib/constants/date-formats';
|
||||
import { DOCUMENT_SIGNATURE_TYPES, DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import {
|
||||
SUPPORTED_LANGUAGES,
|
||||
SUPPORTED_LANGUAGE_CODES,
|
||||
isValidLanguageCode,
|
||||
} from '@documenso/lib/constants/i18n';
|
||||
import { TIME_ZONES } from '@documenso/lib/constants/time-zones';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { extractTeamSignatureSettings } from '@documenso/lib/utils/teams';
|
||||
import {
|
||||
type TDocumentMetaDateFormat,
|
||||
ZDocumentMetaTimezoneSchema,
|
||||
} from '@documenso/trpc/server/document-router/schema';
|
||||
import { DocumentSignatureSettingsTooltip } from '@documenso/ui/components/document/document-signature-settings-tooltip';
|
||||
import { Alert } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Combobox } from '@documenso/ui/primitives/combobox';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@ -44,8 +51,11 @@ import {
|
||||
export type TDocumentPreferencesFormSchema = {
|
||||
documentVisibility: DocumentVisibility | null;
|
||||
documentLanguage: (typeof SUPPORTED_LANGUAGE_CODES)[number] | null;
|
||||
documentTimezone: string | null;
|
||||
documentDateFormat: TDocumentMetaDateFormat | null;
|
||||
includeSenderDetails: boolean | null;
|
||||
includeSigningCertificate: boolean | null;
|
||||
includeAuditLog: boolean | null;
|
||||
signatureTypes: DocumentSignatureType[];
|
||||
};
|
||||
|
||||
@ -53,8 +63,11 @@ type SettingsSubset = Pick<
|
||||
TeamGlobalSettings,
|
||||
| 'documentVisibility'
|
||||
| 'documentLanguage'
|
||||
| 'documentTimezone'
|
||||
| 'documentDateFormat'
|
||||
| 'includeSenderDetails'
|
||||
| 'includeSigningCertificate'
|
||||
| 'includeAuditLog'
|
||||
| 'typedSignatureEnabled'
|
||||
| 'uploadSignatureEnabled'
|
||||
| 'drawSignatureEnabled'
|
||||
@ -81,8 +94,11 @@ export const DocumentPreferencesForm = ({
|
||||
const ZDocumentPreferencesFormSchema = z.object({
|
||||
documentVisibility: z.nativeEnum(DocumentVisibility).nullable(),
|
||||
documentLanguage: z.enum(SUPPORTED_LANGUAGE_CODES).nullable(),
|
||||
documentTimezone: z.string().nullable(),
|
||||
documentDateFormat: ZDocumentMetaTimezoneSchema.nullable(),
|
||||
includeSenderDetails: z.boolean().nullable(),
|
||||
includeSigningCertificate: z.boolean().nullable(),
|
||||
includeAuditLog: z.boolean().nullable(),
|
||||
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
|
||||
message: msg`At least one signature type must be enabled`.id,
|
||||
}),
|
||||
@ -94,8 +110,12 @@ export const DocumentPreferencesForm = ({
|
||||
documentLanguage: isValidLanguageCode(settings.documentLanguage)
|
||||
? settings.documentLanguage
|
||||
: null,
|
||||
documentTimezone: settings.documentTimezone,
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
documentDateFormat: settings.documentDateFormat as TDocumentMetaDateFormat | null,
|
||||
includeSenderDetails: settings.includeSenderDetails,
|
||||
includeSigningCertificate: settings.includeSigningCertificate,
|
||||
includeAuditLog: settings.includeAuditLog,
|
||||
signatureTypes: extractTeamSignatureSettings({ ...settings }),
|
||||
},
|
||||
resolver: zodResolver(ZDocumentPreferencesFormSchema),
|
||||
@ -124,7 +144,10 @@ export const DocumentPreferencesForm = ({
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="document-visibility-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -171,7 +194,10 @@ export const DocumentPreferencesForm = ({
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="document-language-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -199,6 +225,72 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentDateFormat"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Default Date Format</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger data-testid="document-date-format-trigger">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{DATE_FORMATS.map((format) => (
|
||||
<SelectItem key={format.key} value={format.value}>
|
||||
{format.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="documentTimezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Default Time Zone</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Combobox
|
||||
triggerPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Local timezone`
|
||||
}
|
||||
placeholder={t`Select a time zone`}
|
||||
options={TIME_ZONES}
|
||||
value={field.value}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
testId="document-timezone-trigger"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="signatureTypes"
|
||||
@ -222,7 +314,7 @@ export const DocumentPreferencesForm = ({
|
||||
emptySelectionPlaceholder={
|
||||
canInherit ? t`Inherit from organisation` : t`Select signature types`
|
||||
}
|
||||
testId="signature-types-combobox"
|
||||
testId="signature-types-trigger"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@ -257,7 +349,10 @@ export const DocumentPreferencesForm = ({
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-sender-details-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -325,7 +420,10 @@ export const DocumentPreferencesForm = ({
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectTrigger
|
||||
className="bg-background text-muted-foreground"
|
||||
data-testid="include-signing-certificate-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
@ -358,6 +456,56 @@ export const DocumentPreferencesForm = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="includeAuditLog"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Include the Audit Logs in the Document</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value.toString()}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === 'true' ? true : value === 'false' ? false : null)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background text-muted-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<Trans>Yes</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value="false">
|
||||
<Trans>No</Trans>
|
||||
</SelectItem>
|
||||
|
||||
{canInherit && (
|
||||
<SelectItem value={'-1'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls whether the audit logs will be included in the document when it is
|
||||
downloaded. The audit logs can still be downloaded from the logs page
|
||||
separately.
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
|
||||
238
apps/remix/app/components/forms/email-preferences-form.tsx
Normal file
238
apps/remix/app/components/forms/email-preferences-form.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { TeamGlobalSettings } from '@prisma/client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { FROM_ADDRESS } from '@documenso/lib/constants/email';
|
||||
import {
|
||||
DEFAULT_DOCUMENT_EMAIL_SETTINGS,
|
||||
ZDocumentEmailSettingsSchema,
|
||||
} from '@documenso/lib/types/document-email';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { DocumentEmailCheckboxes } from '@documenso/ui/components/document/document-email-checkboxes';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@documenso/ui/primitives/select';
|
||||
|
||||
const ZEmailPreferencesFormSchema = z.object({
|
||||
emailId: z.string().nullable(),
|
||||
emailReplyTo: z.string().email().nullable(),
|
||||
// emailReplyToName: z.string(),
|
||||
emailDocumentSettings: ZDocumentEmailSettingsSchema.nullable(),
|
||||
});
|
||||
|
||||
export type TEmailPreferencesFormSchema = z.infer<typeof ZEmailPreferencesFormSchema>;
|
||||
|
||||
type SettingsSubset = Pick<
|
||||
TeamGlobalSettings,
|
||||
'emailId' | 'emailReplyTo' | 'emailDocumentSettings'
|
||||
>;
|
||||
|
||||
export type EmailPreferencesFormProps = {
|
||||
settings: SettingsSubset;
|
||||
canInherit: boolean;
|
||||
onFormSubmit: (data: TEmailPreferencesFormSchema) => Promise<void>;
|
||||
};
|
||||
|
||||
export const EmailPreferencesForm = ({
|
||||
settings,
|
||||
onFormSubmit,
|
||||
canInherit,
|
||||
}: EmailPreferencesFormProps) => {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const form = useForm<TEmailPreferencesFormSchema>({
|
||||
defaultValues: {
|
||||
emailId: settings.emailId,
|
||||
emailReplyTo: settings.emailReplyTo,
|
||||
// emailReplyToName: settings.emailReplyToName,
|
||||
emailDocumentSettings: settings.emailDocumentSettings,
|
||||
},
|
||||
resolver: zodResolver(ZEmailPreferencesFormSchema),
|
||||
});
|
||||
|
||||
const { data: emailData, isLoading: isLoadingEmails } =
|
||||
trpc.enterprise.organisation.email.find.useQuery({
|
||||
organisationId: organisation.id,
|
||||
perPage: 100,
|
||||
});
|
||||
|
||||
const emails = emailData?.data || [];
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset
|
||||
className="flex h-full max-w-2xl flex-col gap-y-6"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{organisation.organisationClaim.flags.emailDomains && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Email</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<Select
|
||||
{...field}
|
||||
value={field.value === null ? '-1' : field.value}
|
||||
onValueChange={(value) => field.onChange(value === '-1' ? null : value)}
|
||||
>
|
||||
<SelectTrigger loading={isLoadingEmails}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
{emails.map((email) => (
|
||||
<SelectItem key={email.id} value={email.id}>
|
||||
{email.email}
|
||||
</SelectItem>
|
||||
))}
|
||||
|
||||
<SelectItem value={'-1'}>
|
||||
{canInherit ? <Trans>Inherit from organisation</Trans> : FROM_ADDRESS}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormDescription>
|
||||
<Trans>The default email to use when sending emails to recipients</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailReplyTo"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply to email</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
onChange={(value) => field.onChange(value.target.value || null)}
|
||||
placeholder="noreply@example.com"
|
||||
type="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
The email address which will show up in the "Reply To" field in emails
|
||||
</Trans>
|
||||
|
||||
{canInherit && (
|
||||
<span>
|
||||
{'. '}
|
||||
<Trans>Leave blank to inherit from the organisation.</Trans>
|
||||
</span>
|
||||
)}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* <FormField
|
||||
control={form.control}
|
||||
name="emailReplyToName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans>Reply to name</Trans>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={field.value ?? ''} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="emailDocumentSettings"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
<Trans>Default Email Settings</Trans>
|
||||
</FormLabel>
|
||||
{canInherit && (
|
||||
<Select
|
||||
value={field.value === null ? 'INHERIT' : 'CONTROLLED'}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(
|
||||
value === 'CONTROLLED' ? DEFAULT_DOCUMENT_EMAIL_SETTINGS : null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
||||
<SelectContent>
|
||||
<SelectItem value={'INHERIT'}>
|
||||
<Trans>Inherit from organisation</Trans>
|
||||
</SelectItem>
|
||||
|
||||
<SelectItem value={'CONTROLLED'}>
|
||||
<Trans>Override organisation settings</Trans>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{field.value && (
|
||||
<div className="space-y-2 rounded-md border p-4">
|
||||
<DocumentEmailCheckboxes
|
||||
value={field.value ?? DEFAULT_DOCUMENT_EMAIL_SETTINGS}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormDescription>
|
||||
<Trans>
|
||||
Controls the default email settings when new documents or templates are created
|
||||
</Trans>
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end space-x-4">
|
||||
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||
<Trans>Update</Trans>
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
138
apps/remix/app/components/forms/support-ticket-form.tsx
Normal file
138
apps/remix/app/components/forms/support-ticket-form.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { DateTime } from 'luxon';
|
||||
import type { TooltipProps } from 'recharts';
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
|
||||
@ -186,7 +186,7 @@ const BillingDialog = ({
|
||||
});
|
||||
|
||||
const { mutateAsync: createSubscription, isPending: isCreatingSubscription } =
|
||||
trpc.billing.subscription.create.useMutation();
|
||||
trpc.enterprise.billing.subscription.create.useMutation();
|
||||
|
||||
const { mutateAsync: createOrganisation, isPending: isCreatingOrganisation } =
|
||||
trpc.organisation.create.useMutation();
|
||||
@ -346,7 +346,7 @@ export const IndividualPersonalLayoutCheckoutButton = ({
|
||||
const { organisations } = useSession();
|
||||
|
||||
const { mutateAsync: createSubscription, isPending } =
|
||||
trpc.billing.subscription.create.useMutation();
|
||||
trpc.enterprise.billing.subscription.create.useMutation();
|
||||
|
||||
const onSubscribeClick = async () => {
|
||||
try {
|
||||
|
||||
@ -12,7 +12,7 @@ export const DocumentSigningFieldsLoader = () => {
|
||||
|
||||
export const DocumentSigningFieldsUninserted = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<p className="group-hover:text-primary text-foreground group-hover:text-recipient-green text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
<p className="text-foreground group-hover:text-recipient-green whitespace-pre-wrap text-[clamp(0.425rem,25cqw,0.825rem)] duration-200">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@ -37,7 +37,7 @@ export const DocumentSigningFieldsInserted = ({
|
||||
<div className="flex h-full w-full items-center overflow-hidden">
|
||||
<p
|
||||
className={cn(
|
||||
'text-foreground w-full text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
'text-foreground w-full whitespace-pre-wrap text-left text-[clamp(0.425rem,25cqw,0.825rem)] duration-200',
|
||||
{
|
||||
'!text-center': textAlign === 'center',
|
||||
'!text-right': textAlign === 'right',
|
||||
|
||||
@ -16,7 +16,6 @@ import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/uti
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Input } from '@documenso/ui/primitives/input';
|
||||
import { Label } from '@documenso/ui/primitives/label';
|
||||
@ -177,15 +176,7 @@ export const DocumentSigningForm = ({
|
||||
}, [document.documentMeta?.signingOrder, allRecipients, recipient.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'dark:bg-background border-border bg-widget sticky flex h-full flex-col rounded-xl border px-4 py-6',
|
||||
{
|
||||
'top-20 max-h-[min(68rem,calc(100vh-6rem))]': user,
|
||||
'top-4 max-h-[min(68rem,calc(100vh-2rem))]': !user,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
{validateUninsertedFields && uninsertedFields[0] && (
|
||||
<FieldToolTip key={uninsertedFields[0].id} field={uninsertedFields[0]} color="warning">
|
||||
<Trans>Click to insert field</Trans>
|
||||
@ -194,21 +185,8 @@ export const DocumentSigningForm = ({
|
||||
|
||||
<div className="custom-scrollbar -mx-2 flex flex-1 flex-col overflow-y-auto overflow-x-hidden px-2">
|
||||
<div className="flex flex-1 flex-col">
|
||||
<h3 className="text-foreground text-2xl font-semibold">
|
||||
{recipient.role === RecipientRole.VIEWER && <Trans>View Document</Trans>}
|
||||
{recipient.role === RecipientRole.SIGNER && <Trans>Sign Document</Trans>}
|
||||
{recipient.role === RecipientRole.APPROVER && <Trans>Approve Document</Trans>}
|
||||
{recipient.role === RecipientRole.ASSISTANT && <Trans>Assist Document</Trans>}
|
||||
</h3>
|
||||
|
||||
{recipient.role === RecipientRole.VIEWER ? (
|
||||
<>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>Please mark as viewed to complete</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
|
||||
<div className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2">
|
||||
<div className="flex flex-1 flex-col gap-y-4" />
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
@ -245,15 +223,6 @@ export const DocumentSigningForm = ({
|
||||
) : recipient.role === RecipientRole.ASSISTANT ? (
|
||||
<>
|
||||
<form onSubmit={assistantForm.handleSubmit(onAssistantFormSubmit)}>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
<Trans>
|
||||
Complete the fields for the following signers. Once reviewed, they will inform
|
||||
you if any modifications are needed.
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<hr className="border-border my-4" />
|
||||
|
||||
<fieldset className="dark:bg-background border-border rounded-2xl border bg-white p-3">
|
||||
<Controller
|
||||
name="selectedSignerId"
|
||||
@ -340,88 +309,76 @@ export const DocumentSigningForm = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{recipient.role === RecipientRole.APPROVER && !hasSignatureField ? (
|
||||
<Trans>Please review the document before approving.</Trans>
|
||||
) : (
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
)}
|
||||
</p>
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
</Label>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
disabled={isSubmitting}
|
||||
className="-mx-2 flex flex-1 flex-col gap-4 overflow-y-auto px-2"
|
||||
>
|
||||
<div className="flex flex-1 flex-col gap-y-4">
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="full-name">
|
||||
<Trans>Full Name</Trans>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
id="full-name"
|
||||
className="bg-background mt-2"
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value.trimStart())}
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasSignatureField && (
|
||||
<div>
|
||||
<Label htmlFor="Signature">
|
||||
<Trans>Signature</Trans>
|
||||
</Label>
|
||||
|
||||
<SignaturePadDialog
|
||||
className="mt-2"
|
||||
disabled={isSubmitting}
|
||||
value={signature ?? ''}
|
||||
onChange={(v) => setSignature(v ?? '')}
|
||||
typedSignatureEnabled={document.documentMeta?.typedSignatureEnabled}
|
||||
uploadSignatureEnabled={document.documentMeta?.uploadSignatureEnabled}
|
||||
drawSignatureEnabled={document.documentMeta?.drawSignatureEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={async () => navigate(-1)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-4 md:flex-row">
|
||||
<Button
|
||||
type="button"
|
||||
className="dark:bg-muted dark:hover:bg-muted/80 w-full bg-black/5 hover:bg-black/10"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
disabled={typeof window !== 'undefined' && window.history.length <= 1}
|
||||
onClick={async () => navigate(-1)}
|
||||
>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<DocumentSigningCompleteDialog
|
||||
isSubmitting={isSubmitting || isAssistantSubmitting}
|
||||
documentTitle={document.title}
|
||||
fields={fields}
|
||||
fieldsValidated={fieldsValidated}
|
||||
disabled={!isRecipientsTurn}
|
||||
onSignatureComplete={async (nextSigner) => {
|
||||
await completeDocument(undefined, nextSigner);
|
||||
}}
|
||||
role={recipient.role}
|
||||
allowDictateNextSigner={
|
||||
nextRecipient && document.documentMeta?.allowDictateNextSigner
|
||||
}
|
||||
defaultNextSigner={
|
||||
nextRecipient
|
||||
? { name: nextRecipient.name, email: nextRecipient.email }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -3,6 +3,7 @@ import { useState } from 'react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import type { Field } from '@prisma/client';
|
||||
import { FieldType, RecipientRole } from '@prisma/client';
|
||||
import { LucideChevronDown, LucideChevronUp } from 'lucide-react';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
@ -20,6 +21,7 @@ import type { CompletedField } from '@documenso/lib/types/fields';
|
||||
import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta';
|
||||
import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
import { ElementVisible } from '@documenso/ui/primitives/element-visible';
|
||||
import { PDFViewer } from '@documenso/ui/primitives/pdf-viewer';
|
||||
@ -62,6 +64,7 @@ export const DocumentSigningPageView = ({
|
||||
const { documentData, documentMeta } = document;
|
||||
|
||||
const [selectedSignerId, setSelectedSignerId] = useState<number | null>(allRecipients?.[0]?.id);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
let senderName = document.user.name ?? '';
|
||||
let senderEmail = `(${document.user.email})`;
|
||||
@ -77,15 +80,15 @@ export const DocumentSigningPageView = ({
|
||||
|
||||
return (
|
||||
<DocumentSigningRecipientProvider recipient={recipient} targetSigner={targetSigner}>
|
||||
<div className="mx-auto w-full max-w-screen-xl">
|
||||
<div className="mx-auto w-full max-w-screen-xl sm:px-6">
|
||||
<h1
|
||||
className="mt-4 block max-w-[20rem] truncate text-2xl font-semibold md:max-w-[30rem] md:text-3xl"
|
||||
className="block max-w-[20rem] truncate text-2xl font-semibold sm:mt-4 md:max-w-[30rem] md:text-3xl"
|
||||
title={document.title}
|
||||
>
|
||||
{document.title}
|
||||
</h1>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center justify-between gap-x-6">
|
||||
<div className="mt-1.5 flex flex-wrap items-center justify-between gap-y-2 sm:mt-2.5 sm:gap-y-0">
|
||||
<div className="max-w-[50ch]">
|
||||
<span className="text-muted-foreground truncate" title={senderName}>
|
||||
{senderName} {senderEmail}
|
||||
@ -135,26 +138,79 @@ export const DocumentSigningPageView = ({
|
||||
<DocumentSigningRejectDialog document={document} token={recipient.token} />
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-12 gap-y-8 lg:gap-x-8 lg:gap-y-0">
|
||||
<Card
|
||||
className="col-span-12 rounded-xl before:rounded-xl lg:col-span-7 xl:col-span-8"
|
||||
gradient
|
||||
>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="relative mt-4 flex w-full flex-col gap-x-6 gap-y-8 sm:mt-8 md:flex-row lg:gap-x-8 lg:gap-y-0">
|
||||
<div className="flex-1">
|
||||
<Card className="rounded-xl before:rounded-xl" gradient>
|
||||
<CardContent className="p-2">
|
||||
<PDFViewer key={documentData.id} documentData={documentData} document={document} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 xl:col-span-4">
|
||||
<DocumentSigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
/>
|
||||
<div
|
||||
key={isExpanded ? 'expanded' : 'collapsed'}
|
||||
className="group/document-widget fixed bottom-6 left-0 z-50 h-fit max-h-[calc(100dvh-2rem)] w-full flex-shrink-0 px-4 md:sticky md:bottom-[unset] md:top-4 md:z-auto md:w-[350px] md:px-0"
|
||||
data-expanded={isExpanded || undefined}
|
||||
>
|
||||
<div className="border-border bg-widget flex w-full flex-col rounded-xl border px-4 py-4 md:py-6">
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<h3 className="text-foreground text-xl font-semibold md:text-2xl">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => <Trans>View Document</Trans>)
|
||||
.with(RecipientRole.SIGNER, () => <Trans>Sign Document</Trans>)
|
||||
.with(RecipientRole.APPROVER, () => <Trans>Approve Document</Trans>)
|
||||
.with(RecipientRole.ASSISTANT, () => <Trans>Assist Document</Trans>)
|
||||
.otherwise(() => null)}
|
||||
</h3>
|
||||
|
||||
<Button variant="outline" className="h-8 w-8 p-0 md:hidden">
|
||||
{isExpanded ? (
|
||||
<LucideChevronDown
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
/>
|
||||
) : (
|
||||
<LucideChevronUp
|
||||
className="text-muted-foreground h-5 w-5"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden group-data-[expanded]/document-widget:block md:block">
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
{match(recipient.role)
|
||||
.with(RecipientRole.VIEWER, () => (
|
||||
<Trans>Please mark as viewed to complete.</Trans>
|
||||
))
|
||||
.with(RecipientRole.SIGNER, () => (
|
||||
<Trans>Please review the document before signing.</Trans>
|
||||
))
|
||||
.with(RecipientRole.APPROVER, () => (
|
||||
<Trans>Please review the document before approving.</Trans>
|
||||
))
|
||||
.with(RecipientRole.ASSISTANT, () => (
|
||||
<Trans>Complete the fields for the following signers.</Trans>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
</p>
|
||||
|
||||
<hr className="border-border mb-8 mt-4" />
|
||||
</div>
|
||||
|
||||
<div className="-mx-2 hidden px-2 group-data-[expanded]/document-widget:block md:block">
|
||||
<DocumentSigningForm
|
||||
document={document}
|
||||
recipient={recipient}
|
||||
fields={fields}
|
||||
redirectUrl={documentMeta?.redirectUrl}
|
||||
isRecipientsTurn={isRecipientsTurn}
|
||||
allRecipients={allRecipients}
|
||||
setSelectedSignerId={setSelectedSignerId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -227,19 +227,8 @@ export const DocumentSigningTextField = ({
|
||||
|
||||
const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined;
|
||||
|
||||
const labelDisplay =
|
||||
parsedField?.label && parsedField.label.length < 20
|
||||
? parsedField.label
|
||||
: parsedField?.label
|
||||
? parsedField?.label.substring(0, 20) + '...'
|
||||
: undefined;
|
||||
|
||||
const textDisplay =
|
||||
parsedField?.text && parsedField.text.length < 20
|
||||
? parsedField.text
|
||||
: parsedField?.text
|
||||
? parsedField?.text.substring(0, 20) + '...'
|
||||
: undefined;
|
||||
const labelDisplay = parsedField?.label;
|
||||
const textDisplay = parsedField?.text;
|
||||
|
||||
const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay;
|
||||
const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0);
|
||||
|
||||
@ -67,7 +67,7 @@ export const DocumentDropZoneWrapper = ({ children, className }: DocumentDropZon
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone,
|
||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@ -278,7 +278,8 @@ export const DocumentEditForm = ({
|
||||
};
|
||||
|
||||
const onAddSubjectFormSubmit = async (data: TAddSubjectFormSchema) => {
|
||||
const { subject, message, distributionMethod, emailSettings } = data.meta;
|
||||
const { subject, message, distributionMethod, emailId, emailReplyTo, emailSettings } =
|
||||
data.meta;
|
||||
|
||||
try {
|
||||
await sendDocument({
|
||||
@ -287,7 +288,9 @@ export const DocumentEditForm = ({
|
||||
subject,
|
||||
message,
|
||||
distributionMethod,
|
||||
emailSettings,
|
||||
emailId,
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
emailSettings: emailSettings,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -164,7 +164,7 @@ export const DocumentPageViewDropdown = ({ document }: DocumentPageViewDropdownP
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`${documentsPath}/${document.id}/logs`}>
|
||||
<ScrollTextIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Audit Log</Trans>
|
||||
<Trans>Audit Logs</Trans>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
|
||||
@ -78,7 +78,7 @@ export const DocumentUploadDropzone = ({ className }: DocumentUploadDropzoneProp
|
||||
const { id } = await createDocument({
|
||||
title: file.name,
|
||||
documentDataId: response.id,
|
||||
timezone: userTimezone,
|
||||
timezone: userTimezone, // Note: When migrating to v2 document upload remember to pass this through as a 'userTimezone' field.
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -35,7 +35,7 @@ export const OrganisationBillingBanner = () => {
|
||||
const organisation = useOptionalCurrentOrganisation();
|
||||
|
||||
const { mutateAsync: manageSubscription, isPending } =
|
||||
trpc.billing.subscription.manage.useMutation();
|
||||
trpc.enterprise.billing.subscription.manage.useMutation();
|
||||
|
||||
const handleCreatePortal = async (organisationId: string) => {
|
||||
try {
|
||||
|
||||
@ -21,7 +21,7 @@ export const OrganisationBillingPortalButton = ({
|
||||
const { toast } = useToast();
|
||||
|
||||
const { mutateAsync: manageSubscription, isPending } =
|
||||
trpc.billing.subscription.manage.useMutation();
|
||||
trpc.enterprise.billing.subscription.manage.useMutation();
|
||||
|
||||
const canManageBilling = canExecuteOrganisationAction(
|
||||
'MANAGE_BILLING',
|
||||
|
||||
@ -46,16 +46,46 @@ export const SettingsDesktopNav = ({ className, ...props }: SettingsDesktopNavPr
|
||||
|
||||
{isPersonalLayoutMode && (
|
||||
<>
|
||||
<Link to="/settings/preferences">
|
||||
<Link to="/settings/document">
|
||||
<Button variant="ghost" className={cn('w-full justify-start')}>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/document">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/document') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
<Trans>Document</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/branding">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/branding') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Trans>Branding</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link className="w-full pl-8" to="/settings/email">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/email') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Trans>Email</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ import {
|
||||
CreditCardIcon,
|
||||
Globe2Icon,
|
||||
Lock,
|
||||
MailIcon,
|
||||
PaletteIcon,
|
||||
Settings2Icon,
|
||||
User,
|
||||
Users,
|
||||
@ -48,16 +50,42 @@ export const SettingsMobileNav = ({ className, ...props }: SettingsMobileNavProp
|
||||
|
||||
{isPersonalLayoutMode && (
|
||||
<>
|
||||
<Link to="/settings/preferences">
|
||||
<Link to="/settings/document">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/preferences') && 'bg-secondary',
|
||||
pathname?.startsWith('/settings/document') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
<Trans>Document Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/branding">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/branding') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<PaletteIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Branding Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to="/settings/email">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith('/settings/email') && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<MailIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Email Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
|
||||
@ -28,10 +28,6 @@ export const ShareDocumentDownloadButton = ({
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 4000);
|
||||
});
|
||||
|
||||
await downloadPDF({ documentData, fileName: title });
|
||||
} catch (err) {
|
||||
toast({
|
||||
|
||||
@ -1,112 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Braces, Globe2Icon, GroupIcon, Settings, Settings2, Users, Webhook } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type TeamSettingsNavDesktopProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TeamSettingsNavDesktop = ({ className, ...props }: TeamSettingsNavDesktopProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/settings/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const groupsPath = `/t/${teamUrl}/settings/groups`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-2', className)} {...props}>
|
||||
<Link to={settingsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname === settingsPath && 'bg-secondary')}
|
||||
>
|
||||
<Settings className="mr-2 h-5 w-5" />
|
||||
<Trans>General</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={publicProfilePath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={membersPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Users className="mr-2 h-5 w-5" />
|
||||
<Trans>Members</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={groupsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
|
||||
>
|
||||
<GroupIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Groups</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={tokensPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={webhooksPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,121 +0,0 @@
|
||||
import type { HTMLAttributes } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Braces, Globe2Icon, GroupIcon, Key, Settings2, User, Webhook } from 'lucide-react';
|
||||
import { Link, useLocation, useParams } from 'react-router';
|
||||
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
export type TeamSettingsNavMobileProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TeamSettingsNavMobile = ({ className, ...props }: TeamSettingsNavMobileProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const params = useParams();
|
||||
|
||||
const teamUrl = typeof params?.teamUrl === 'string' ? params?.teamUrl : '';
|
||||
|
||||
const settingsPath = `/t/${teamUrl}/settings`;
|
||||
const preferencesPath = `/t/${teamUrl}/preferences`;
|
||||
const publicProfilePath = `/t/${teamUrl}/settings/public-profile`;
|
||||
const membersPath = `/t/${teamUrl}/settings/members`;
|
||||
const groupsPath = `/t/${teamUrl}/settings/groups`;
|
||||
const tokensPath = `/t/${teamUrl}/settings/tokens`;
|
||||
const webhooksPath = `/t/${teamUrl}/settings/webhooks`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex flex-wrap items-center justify-start gap-x-2 gap-y-4', className)}
|
||||
{...props}
|
||||
>
|
||||
<Link to={settingsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(settingsPath) &&
|
||||
pathname.split('/').length === 4 &&
|
||||
'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
<Trans>General</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={preferencesPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(preferencesPath) &&
|
||||
pathname.split('/').length === 4 &&
|
||||
'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Settings2 className="mr-2 h-5 w-5" />
|
||||
<Trans>Preferences</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={publicProfilePath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(publicProfilePath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Globe2Icon className="mr-2 h-5 w-5" />
|
||||
<Trans>Public Profile</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={membersPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(membersPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Key className="mr-2 h-5 w-5" />
|
||||
<Trans>Members</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={groupsPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(groupsPath) && 'bg-secondary')}
|
||||
>
|
||||
<GroupIcon className="mr-2 h-5 w-5" />
|
||||
<Trans>Groups</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={tokensPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', pathname?.startsWith(tokensPath) && 'bg-secondary')}
|
||||
>
|
||||
<Braces className="mr-2 h-5 w-5" />
|
||||
<Trans>API Tokens</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link to={webhooksPath}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'w-full justify-start',
|
||||
pathname?.startsWith(webhooksPath) && 'bg-secondary',
|
||||
)}
|
||||
>
|
||||
<Webhook className="mr-2 h-5 w-5" />
|
||||
<Trans>Webhooks</Trans>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,129 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
|
||||
import { APP_DOCUMENT_UPLOAD_SIZE_LIMIT } from '@documenso/lib/constants/app';
|
||||
import { megabytesToBytes } from '@documenso/lib/universal/unit-convertions';
|
||||
import { putPdfFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
|
||||
export interface TemplateDropZoneWrapperProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TemplateDropZoneWrapper = ({ children, className }: TemplateDropZoneWrapperProps) => {
|
||||
const { _ } = useLingui();
|
||||
const { toast } = useToast();
|
||||
const { folderId } = useParams();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const { mutateAsync: createTemplate } = trpc.template.createTemplate.useMutation();
|
||||
|
||||
const onFileDrop = async (file: File) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const documentData = await putPdfFile(file);
|
||||
|
||||
const { id } = await createTemplate({
|
||||
title: file.name,
|
||||
templateDocumentDataId: documentData.id,
|
||||
folderId: folderId ?? undefined,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: _(msg`Template uploaded`),
|
||||
description: _(
|
||||
msg`Your template has been uploaded successfully. You will be redirected to the template page.`,
|
||||
),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
await navigate(`${formatTemplatesPath(team.url)}/${id}/edit`);
|
||||
} catch {
|
||||
toast({
|
||||
title: _(msg`Something went wrong`),
|
||||
description: _(msg`Please try again later.`),
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onFileDropRejected = () => {
|
||||
toast({
|
||||
title: _(msg`Your template failed to upload.`),
|
||||
description: _(msg`File cannot be larger than ${APP_DOCUMENT_UPLOAD_SIZE_LIMIT}MB`),
|
||||
duration: 5000,
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
},
|
||||
//disabled: isUploadDisabled,
|
||||
multiple: false,
|
||||
maxSize: megabytesToBytes(APP_DOCUMENT_UPLOAD_SIZE_LIMIT),
|
||||
onDrop: ([acceptedFile]) => {
|
||||
if (acceptedFile) {
|
||||
void onFileDrop(acceptedFile);
|
||||
}
|
||||
},
|
||||
onDropRejected: () => {
|
||||
void onFileDropRejected();
|
||||
},
|
||||
noClick: true,
|
||||
noDragEventsBubbling: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...getRootProps()} className={cn('relative min-h-screen', className)}>
|
||||
<input {...getInputProps()} />
|
||||
{children}
|
||||
|
||||
{isDragActive && (
|
||||
<div className="bg-muted/60 fixed left-0 top-0 z-[9999] h-full w-full backdrop-blur-[4px]">
|
||||
<div className="pointer-events-none flex h-full w-full flex-col items-center justify-center">
|
||||
<h2 className="text-foreground text-2xl font-semibold">
|
||||
<Trans>Upload Template</Trans>
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-md mt-4">
|
||||
<Trans>Drag and drop your PDF file here</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="bg-muted/30 absolute inset-0 z-50 backdrop-blur-[2px]">
|
||||
<div className="pointer-events-none flex h-1/2 w-full flex-col items-center justify-center">
|
||||
<Loader className="text-primary h-12 w-12 animate-spin" />
|
||||
<p className="text-foreground mt-8 font-medium">
|
||||
<Trans>Uploading template...</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -143,6 +143,7 @@ export const TemplateEditForm = ({
|
||||
},
|
||||
meta: {
|
||||
...data.meta,
|
||||
emailReplyTo: data.meta.emailReplyTo || null,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
|
||||
@ -6,6 +6,8 @@ import { PenIcon, PlusIcon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { RECIPIENT_ROLES_DESCRIPTION } from '@documenso/lib/constants/recipient-roles';
|
||||
import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template';
|
||||
import { extractInitials } from '@documenso/lib/utils/recipient-formatter';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
|
||||
export type TemplatePageViewRecipientsProps = {
|
||||
@ -53,8 +55,18 @@ export const TemplatePageViewRecipients = ({
|
||||
{recipients.map((recipient) => (
|
||||
<li key={recipient.id} className="flex items-center justify-between px-4 py-2.5 text-sm">
|
||||
<AvatarWithText
|
||||
avatarFallback={recipient.email.slice(0, 1).toUpperCase()}
|
||||
primaryText={<p className="text-muted-foreground text-sm">{recipient.email}</p>}
|
||||
avatarFallback={
|
||||
isTemplateRecipientEmailPlaceholder(recipient.email)
|
||||
? extractInitials(recipient.name)
|
||||
: recipient.email.slice(0, 1).toUpperCase()
|
||||
}
|
||||
primaryText={
|
||||
isTemplateRecipientEmailPlaceholder(recipient.email) ? (
|
||||
<p className="text-muted-foreground text-sm">{recipient.name}</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">{recipient.email}</p>
|
||||
)
|
||||
}
|
||||
secondaryText={
|
||||
<p className="text-muted-foreground/70 text-xs">
|
||||
{_(RECIPIENT_ROLES_DESCRIPTION[recipient.role].roleName)}
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { DateTime } from 'luxon';
|
||||
import type { DateTimeFormatOptions } from 'luxon';
|
||||
import { DateTime } from 'luxon';
|
||||
import { P, match } from 'ts-pattern';
|
||||
import { UAParser } from 'ua-parser-js';
|
||||
|
||||
import { APP_I18N_OPTIONS } from '@documenso/lib/constants/i18n';
|
||||
import type { TDocumentAuditLog } from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@documenso/ui/primitives/table';
|
||||
DOCUMENT_AUDIT_LOG_TYPE,
|
||||
type TDocumentAuditLog,
|
||||
} from '@documenso/lib/types/document-audit-logs';
|
||||
import { formatDocumentAuditLogAction } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
export type AuditLogDataTableProps = {
|
||||
logs: TDocumentAuditLog[];
|
||||
@ -25,81 +23,129 @@ const dateFormat: DateTimeFormatOptions = {
|
||||
hourCycle: 'h12',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the color indicator for the audit log type
|
||||
*/
|
||||
|
||||
const getAuditLogIndicatorColor = (type: string) =>
|
||||
match(type)
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED, () => 'bg-green-500')
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_REJECTED, () => 'bg-red-500')
|
||||
.with(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_SENT, () => 'bg-orange-500')
|
||||
.with(
|
||||
P.union(
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_INSERTED,
|
||||
DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_FIELD_UNINSERTED,
|
||||
),
|
||||
() => 'bg-blue-500',
|
||||
)
|
||||
.otherwise(() => 'bg-muted');
|
||||
|
||||
/**
|
||||
* DO NOT USE TRANS. YOU MUST USE _ FOR THIS FILE AND ALL CHILDREN COMPONENTS.
|
||||
*/
|
||||
|
||||
const formatUserAgent = (userAgent: string | null | undefined, userAgentInfo: UAParser.IResult) => {
|
||||
if (!userAgent) {
|
||||
return msg`N/A`;
|
||||
}
|
||||
|
||||
const browser = userAgentInfo.browser.name;
|
||||
const version = userAgentInfo.browser.version;
|
||||
const os = userAgentInfo.os.name;
|
||||
|
||||
// If we can parse meaningful browser info, format it nicely
|
||||
if (browser && os) {
|
||||
const browserInfo = version ? `${browser} ${version}` : browser;
|
||||
|
||||
return msg`${browserInfo} on ${os}`;
|
||||
}
|
||||
|
||||
return msg`${userAgent}`;
|
||||
};
|
||||
|
||||
export const InternalAuditLogTable = ({ logs }: AuditLogDataTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const parser = new UAParser();
|
||||
|
||||
const uppercaseFistLetter = (text: string) => {
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<Table overflowHidden>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<Trans>Time</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>User</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Action</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>IP Address</Trans>
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
<Trans>Browser</Trans>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<div className="space-y-4">
|
||||
{logs.map((log, index) => {
|
||||
parser.setUA(log.userAgent || '');
|
||||
const formattedAction = formatDocumentAuditLogAction(_, log);
|
||||
const userAgentInfo = parser.getResult();
|
||||
|
||||
<TableBody className="print:text-xs">
|
||||
{logs.map((log, i) => (
|
||||
<TableRow className="break-inside-avoid" key={i}>
|
||||
<TableCell>
|
||||
{DateTime.fromJSDate(log.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toLocaleString(dateFormat)}
|
||||
</TableCell>
|
||||
return (
|
||||
<Card
|
||||
key={index}
|
||||
// Add top margin for the first card to ensure it's not cut off from the 2nd page onwards
|
||||
className={`border shadow-sm ${index > 0 ? 'print:mt-8' : ''}`}
|
||||
style={{
|
||||
pageBreakInside: 'avoid',
|
||||
breakInside: 'avoid',
|
||||
}}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Header Section with indicator, event type, and timestamp */}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<div
|
||||
className={cn(`h-2 w-2 rounded-full`, getAuditLogIndicatorColor(log.type))}
|
||||
/>
|
||||
|
||||
<TableCell>
|
||||
{log.name || log.email ? (
|
||||
<div>
|
||||
{log.name && (
|
||||
<p className="break-all" title={log.name}>
|
||||
{log.name}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-muted-foreground text-sm font-medium uppercase tracking-wide print:text-[8pt]">
|
||||
{log.type.replace(/_/g, ' ')}
|
||||
</div>
|
||||
|
||||
{log.email && (
|
||||
<p className="text-muted-foreground break-all" title={log.email}>
|
||||
{log.email}
|
||||
</p>
|
||||
)}
|
||||
<div className="text-foreground text-sm font-medium print:text-[8pt]">
|
||||
{formattedAction.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p>N/A</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
{uppercaseFistLetter(formatDocumentAuditLogAction(_, log).description)}
|
||||
</TableCell>
|
||||
<div className="text-muted-foreground text-sm print:text-[8pt]">
|
||||
{DateTime.fromJSDate(log.createdAt)
|
||||
.setLocale(APP_I18N_OPTIONS.defaultLocale)
|
||||
.toLocaleString(dateFormat)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TableCell>{log.ipAddress}</TableCell>
|
||||
<hr className="my-4" />
|
||||
|
||||
<TableCell>
|
||||
{log.userAgent ? parser.setUA(log.userAgent).getBrowser().name : 'N/A'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{/* Details Section - Two column layout */}
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2 text-xs print:text-[6pt]">
|
||||
<div>
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`User`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1 font-mono">{log.email || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`IP Address`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1 font-mono">{log.ipAddress || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<div className="text-muted-foreground/70 font-medium uppercase tracking-wide">
|
||||
{_(msg`User Agent`)}
|
||||
</div>
|
||||
|
||||
<div className="text-foreground mt-1">
|
||||
{_(formatUserAgent(log.userAgent, userAgentInfo))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -25,7 +25,7 @@ export const OrganisationBillingInvoicesTable = ({
|
||||
}: OrganisationBillingInvoicesTableProps) => {
|
||||
const { _ } = useLingui();
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.billing.invoices.get.useQuery(
|
||||
const { data, isLoading, isLoadingError } = trpc.enterprise.billing.invoices.get.useQuery(
|
||||
{
|
||||
organisationId,
|
||||
},
|
||||
|
||||
@ -0,0 +1,205 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
import { CheckCircle2Icon, ClockIcon } from 'lucide-react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import type { DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTable } from '@documenso/ui/primitives/data-table';
|
||||
import { DataTablePagination } from '@documenso/ui/primitives/data-table-pagination';
|
||||
import { Skeleton } from '@documenso/ui/primitives/skeleton';
|
||||
import { TableCell } from '@documenso/ui/primitives/table';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import { OrganisationEmailDomainDeleteDialog } from '../dialogs/organisation-email-domain-delete-dialog';
|
||||
|
||||
export const OrganisationEmailDomainsDataTable = () => {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
|
||||
|
||||
const { mutate: verifyEmails, isPending: isVerifyingEmails } =
|
||||
trpc.enterprise.organisation.emailDomain.verify.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: t`Email domains synced`,
|
||||
description: t`All email domains have been synced successfully`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading, isLoadingError } =
|
||||
trpc.enterprise.organisation.emailDomain.find.useQuery(
|
||||
{
|
||||
organisationId: organisation.id,
|
||||
query: parsedSearchParams.query,
|
||||
page: parsedSearchParams.page,
|
||||
perPage: parsedSearchParams.perPage,
|
||||
},
|
||||
{
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
const onPaginationChange = (page: number, perPage: number) => {
|
||||
updateSearchParams({
|
||||
page,
|
||||
perPage,
|
||||
});
|
||||
};
|
||||
|
||||
const results = data ?? {
|
||||
data: [],
|
||||
perPage: 10,
|
||||
currentPage: 1,
|
||||
totalPages: 1,
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Domain`,
|
||||
accessorKey: 'domain',
|
||||
},
|
||||
{
|
||||
header: t`Status`,
|
||||
accessorKey: 'status',
|
||||
cell: ({ row }) =>
|
||||
match(row.original.status)
|
||||
.with(EmailDomainStatus.ACTIVE, () => (
|
||||
<Badge>
|
||||
<CheckCircle2Icon className="mr-2 h-4 w-4 text-green-500 dark:text-green-300" />
|
||||
<Trans>Active</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.with(EmailDomainStatus.PENDING, () => (
|
||||
<Badge variant="warning">
|
||||
<ClockIcon className="mr-2 h-4 w-4 text-yellow-500 dark:text-yellow-200" />
|
||||
<Trans>Pending</Trans>
|
||||
</Badge>
|
||||
))
|
||||
.exhaustive(),
|
||||
},
|
||||
{
|
||||
header: t`Emails`,
|
||||
accessorKey: 'emailCount',
|
||||
cell: ({ row }) => row.original.emailCount,
|
||||
},
|
||||
{
|
||||
header: t`Actions`,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link to={`/o/${organisation.url}/settings/email-domains/${row.original.id}`}>
|
||||
Manage
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<OrganisationEmailDomainDeleteDialog
|
||||
emailDomainId={row.original.id}
|
||||
emailDomain={row.original.domain}
|
||||
trigger={
|
||||
<Button variant="destructive" title={t`Remove email domain`}>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={results.data}
|
||||
perPage={results.perPage}
|
||||
currentPage={results.currentPage}
|
||||
totalPages={results.totalPages}
|
||||
onPaginationChange={onPaginationChange}
|
||||
error={{
|
||||
enable: isLoadingError,
|
||||
}}
|
||||
skeleton={{
|
||||
enable: isLoading,
|
||||
rows: 1,
|
||||
component: (
|
||||
<>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-20 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-6 w-20 rounded" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-12 rounded-full" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-row justify-end space-x-2">
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
<Skeleton className="h-10 w-20 rounded" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{(table) =>
|
||||
results.totalPages > 1 && (
|
||||
<DataTablePagination additionalInformation="VisibleCount" table={table} />
|
||||
)
|
||||
}
|
||||
</DataTable>
|
||||
|
||||
<AnimateGenericFadeInOut key={results.data.length}>
|
||||
{results.data.length > 0 && (
|
||||
<Alert
|
||||
className="mt-2 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Sync Email Domains</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
This will check and sync the status of all email domains for this organisation
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={isVerifyingEmails}
|
||||
onClick={() => {
|
||||
verifyEmails({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans>Sync</Trans>
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
</AnimateGenericFadeInOut>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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,6 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Building2Icon, CreditCardIcon, GroupIcon, Settings2Icon, Users2Icon } from 'lucide-react';
|
||||
import {
|
||||
Building2Icon,
|
||||
CreditCardIcon,
|
||||
GroupIcon,
|
||||
MailboxIcon,
|
||||
Settings2Icon,
|
||||
Users2Icon,
|
||||
} from 'lucide-react';
|
||||
import { FaUsers } from 'react-icons/fa6';
|
||||
import { Link, NavLink, Outlet } from 'react-router';
|
||||
|
||||
@ -30,9 +37,30 @@ export default function SettingsLayout() {
|
||||
icon: Building2Icon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/preferences`,
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: t`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
hideHighlight: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/document`,
|
||||
label: t`Document`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/branding`,
|
||||
label: t`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/email`,
|
||||
label: t`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/email-domains`,
|
||||
label: t`Email Domains`,
|
||||
icon: MailboxIcon,
|
||||
},
|
||||
{
|
||||
path: `/o/${organisation.url}/settings/teams`,
|
||||
@ -54,7 +82,20 @@ export default function SettingsLayout() {
|
||||
label: t`Billing`,
|
||||
icon: CreditCardIcon,
|
||||
},
|
||||
].filter((route) => (isBillingEnabled ? route : !route.path.includes('/billing')));
|
||||
].filter((route) => {
|
||||
if (!isBillingEnabled && route.path.includes('/billing')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
(!isBillingEnabled || !organisation.organisationClaim.flags.emailDomains) &&
|
||||
route.path.includes('/email-domains')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!canExecuteOrganisationAction('MANAGE_ORGANISATION', organisation.currentOrganisationRole)) {
|
||||
return (
|
||||
@ -93,12 +134,18 @@ export default function SettingsLayout() {
|
||||
)}
|
||||
>
|
||||
{organisationSettingRoutes.map((route) => (
|
||||
<NavLink to={route.path} className="group w-full justify-start" key={route.path}>
|
||||
<NavLink
|
||||
to={route.path}
|
||||
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
|
||||
key={route.path}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="group-aria-[current]:bg-secondary w-full justify-start"
|
||||
className={cn('w-full justify-start', {
|
||||
'group-aria-[current]:bg-secondary': !route.hideHighlight,
|
||||
})}
|
||||
>
|
||||
<route.icon className="mr-2 h-5 w-5" />
|
||||
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
|
||||
<Trans>{route.label}</Trans>
|
||||
</Button>
|
||||
</NavLink>
|
||||
|
||||
@ -24,7 +24,7 @@ export default function TeamsSettingBillingPage() {
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
|
||||
trpc.billing.subscription.get.useQuery({
|
||||
trpc.enterprise.billing.subscription.get.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import { Link } 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 { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
@ -17,21 +16,19 @@ import {
|
||||
BrandingPreferencesForm,
|
||||
type TBrandingPreferencesFormSchema,
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import {
|
||||
DocumentPreferencesForm,
|
||||
type TDocumentPreferencesFormSchema,
|
||||
} from '~/components/forms/document-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useOptionalCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Preferences');
|
||||
return appMetaTags('Branding Preferences');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsPreferencesPage() {
|
||||
export default function OrganisationSettingsBrandingPage() {
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
const team = useOptionalCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
@ -46,51 +43,6 @@ export default function OrganisationSettingsPreferencesPage() {
|
||||
const { mutateAsync: updateOrganisationSettings } =
|
||||
trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const onDocumentPreferencesFormSubmit = async (data: TDocumentPreferencesFormSchema) => {
|
||||
try {
|
||||
const {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
signatureTypes,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
documentVisibility === null ||
|
||||
documentLanguage === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null
|
||||
) {
|
||||
throw new Error('Should not be possible.');
|
||||
}
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Document preferences updated`,
|
||||
description: t`Your document preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your document preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
@ -132,32 +84,21 @@ export default function OrganisationSettingsPreferencesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const settingsHeaderText = isPersonalLayoutMode ? t`Preferences` : t`Organisation Preferences`;
|
||||
const settingsHeaderText = t`Branding Preferences`;
|
||||
|
||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||
? t`Here you can set your general preferences`
|
||||
: t`Here you can set preferences and defaults for your organisation. Teams will inherit these settings by default.`;
|
||||
? t`Here you can set your general branding preferences`
|
||||
: team
|
||||
? t`Here you can set branding preferences for your team`
|
||||
: t`Here you can set branding preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={false}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{organisationWithSettings.organisationClaim.flags.allowCustomBranding ||
|
||||
!IS_BILLING_ENABLED() ? (
|
||||
<section>
|
||||
<SettingsHeader
|
||||
title={t`Branding Preferences`}
|
||||
subtitle={t`Here you can set preferences and defaults for branding.`}
|
||||
className="mt-8"
|
||||
/>
|
||||
|
||||
<BrandingPreferencesForm
|
||||
context="Organisation"
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
@ -0,0 +1,119 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import {
|
||||
DocumentPreferencesForm,
|
||||
type TDocumentPreferencesFormSchema,
|
||||
} from '~/components/forms/document-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Document Preferences');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsDocumentPage() {
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } =
|
||||
trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } =
|
||||
trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const onDocumentPreferencesFormSubmit = async (data: TDocumentPreferencesFormSchema) => {
|
||||
try {
|
||||
const {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
} = data;
|
||||
|
||||
if (
|
||||
documentVisibility === null ||
|
||||
documentLanguage === null ||
|
||||
documentDateFormat === null ||
|
||||
includeSenderDetails === null ||
|
||||
includeSigningCertificate === null ||
|
||||
includeAuditLog === null
|
||||
) {
|
||||
throw new Error('Should not be possible.');
|
||||
}
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
typedSignatureEnabled: signatureTypes.includes(DocumentSignatureType.TYPE),
|
||||
uploadSignatureEnabled: signatureTypes.includes(DocumentSignatureType.UPLOAD),
|
||||
drawSignatureEnabled: signatureTypes.includes(DocumentSignatureType.DRAW),
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Document preferences updated`,
|
||||
description: t`Your document preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your document preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const settingsHeaderText = t`Document Preferences`;
|
||||
const settingsHeaderSubtitle = isPersonalLayoutMode
|
||||
? t`Here you can set your general document preferences`
|
||||
: t`Here you can set document preferences for your organisation. Teams will inherit these settings by default.`;
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader title={settingsHeaderText} subtitle={settingsHeaderSubtitle} />
|
||||
|
||||
<section>
|
||||
<DocumentPreferencesForm
|
||||
canInherit={false}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onDocumentPreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,207 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { EditIcon, MoreHorizontalIcon, Trash2Icon } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TGetOrganisationEmailDomainResponse } from '@documenso/trpc/server/enterprise-router/get-organisation-email-domain.types';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DataTable, type DataTableColumnDef } from '@documenso/ui/primitives/data-table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@documenso/ui/primitives/dropdown-menu';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
|
||||
import { OrganisationEmailCreateDialog } from '~/components/dialogs/organisation-email-create-dialog';
|
||||
import { OrganisationEmailDeleteDialog } from '~/components/dialogs/organisation-email-delete-dialog';
|
||||
import { OrganisationEmailDomainDeleteDialog } from '~/components/dialogs/organisation-email-domain-delete-dialog';
|
||||
import { OrganisationEmailDomainRecordsDialog } from '~/components/dialogs/organisation-email-domain-records-dialog';
|
||||
import { OrganisationEmailUpdateDialog } from '~/components/dialogs/organisation-email-update-dialog';
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
|
||||
import type { Route } from './+types/o.$orgUrl.settings.groups.$id';
|
||||
|
||||
export default function OrganisationEmailDomainSettingsPage({ params }: Route.ComponentProps) {
|
||||
const { t } = useLingui();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const emailDomainId = params.id;
|
||||
|
||||
const { data: emailDomain, isLoading: isLoadingEmailDomain } =
|
||||
trpc.enterprise.organisation.emailDomain.get.useQuery(
|
||||
{
|
||||
emailDomainId,
|
||||
},
|
||||
{
|
||||
enabled: !!emailDomainId,
|
||||
},
|
||||
);
|
||||
|
||||
const emailColumns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
header: t`Name`,
|
||||
accessorKey: 'emailName',
|
||||
},
|
||||
{
|
||||
header: t`Email`,
|
||||
accessorKey: 'email',
|
||||
},
|
||||
{
|
||||
header: t`Actions`,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="text-muted-foreground h-5 w-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-52" align="start" forceMount>
|
||||
<DropdownMenuLabel>
|
||||
<Trans>Actions</Trans>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<OrganisationEmailUpdateDialog
|
||||
organisationEmail={row.original}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<EditIcon className="mr-2 h-4 w-4" />
|
||||
<Trans>Update</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
|
||||
<OrganisationEmailDeleteDialog
|
||||
emailId={row.original.id}
|
||||
email={row.original.email}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
<Trash2Icon className="mr-2 h-4 w-4" />
|
||||
<Trans>Remove</Trans>
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<TGetOrganisationEmailDomainResponse['emails'][number]>[];
|
||||
}, [organisation]);
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoadingEmailDomain) {
|
||||
return <SpinnerBox className="py-32" />;
|
||||
}
|
||||
|
||||
// Todo: Update UI, currently out of place.
|
||||
if (!emailDomain) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
errorCode={404}
|
||||
errorCodeMap={{
|
||||
404: {
|
||||
heading: msg`Email domain not found`,
|
||||
subHeading: msg`404 Email domain not found`,
|
||||
message: msg`The email domain you are looking for may have been removed, renamed or may have never
|
||||
existed.`,
|
||||
},
|
||||
}}
|
||||
primaryButton={
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}/settings/email-domains`}>
|
||||
<Trans>Go back</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
secondaryButton={null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const records = generateEmailDomainRecords(emailDomain.selector, emailDomain.publicKey);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Email Domain Settings`}
|
||||
subtitle={t`Manage your email domain settings.`}
|
||||
>
|
||||
<OrganisationEmailCreateDialog emailDomain={emailDomain} />
|
||||
</SettingsHeader>
|
||||
|
||||
<div className="mt-4">
|
||||
<label className="text-sm font-medium leading-none">
|
||||
<Trans>Emails</Trans>
|
||||
</label>
|
||||
|
||||
<div className="my-2">
|
||||
<DataTable columns={emailColumns} data={emailDomain.emails} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>DNS Records</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>View the DNS records for this email domain</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<OrganisationEmailDomainRecordsDialog
|
||||
records={records}
|
||||
trigger={
|
||||
<Button variant="outline">
|
||||
<Trans>View DNS Records</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Alert>
|
||||
|
||||
<Alert
|
||||
className="mt-6 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Delete email domain</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>This will remove all emails associated with this email domain</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
<OrganisationEmailDomainDeleteDialog
|
||||
emailDomainId={emailDomainId}
|
||||
emailDomain={emailDomain.domain}
|
||||
trigger={
|
||||
<Button variant="destructive" title={t`Remove email domain`}>
|
||||
<Trans>Delete Email Domain</Trans>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import { Link } 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 { canExecuteOrganisationAction, isPersonalLayout } from '@documenso/lib/utils/organisations';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { OrganisationEmailDomainCreateDialog } from '~/components/dialogs/organisation-email-domain-create-dialog';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { OrganisationEmailDomainsDataTable } from '~/components/tables/organisation-email-domains-table';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Email Domains');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsEmailDomains() {
|
||||
const { t } = useLingui();
|
||||
const { organisations } = useSession();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const isPersonalLayoutMode = isPersonalLayout(organisations);
|
||||
|
||||
const isEmailDomainsEnabled = organisation.organisationClaim.flags.emailDomains;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsHeader
|
||||
title={t`Email Domains`}
|
||||
subtitle={t`Here you can add email domains to your organisation.`}
|
||||
>
|
||||
{isEmailDomainsEnabled && <OrganisationEmailDomainCreateDialog />}
|
||||
</SettingsHeader>
|
||||
|
||||
{isEmailDomainsEnabled ? (
|
||||
<section>
|
||||
<OrganisationEmailDomainsDataTable />
|
||||
</section>
|
||||
) : (
|
||||
<Alert
|
||||
className="mt-8 flex flex-col justify-between p-6 sm:flex-row sm:items-center"
|
||||
variant="neutral"
|
||||
>
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<AlertTitle>
|
||||
<Trans>Email Domains</Trans>
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription className="mr-2">
|
||||
<Trans>
|
||||
Currently email domains can only be configured for Platform and above plans.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</div>
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
to={
|
||||
isPersonalLayoutMode
|
||||
? '/settings/billing'
|
||||
: `/o/${organisation.url}/settings/billing`
|
||||
}
|
||||
>
|
||||
<Trans>Update Billing</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { useCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import {
|
||||
EmailPreferencesForm,
|
||||
type TEmailPreferencesFormSchema,
|
||||
} from '~/components/forms/email-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Email Preferences');
|
||||
}
|
||||
|
||||
export default function OrganisationSettingsGeneral() {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const { data: organisationWithSettings, isLoading: isLoadingOrganisation } =
|
||||
trpc.organisation.get.useQuery({
|
||||
organisationReference: organisation.url,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateOrganisationSettings } =
|
||||
trpc.organisation.settings.update.useMutation();
|
||||
|
||||
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
|
||||
try {
|
||||
const { emailId, emailReplyTo, emailDocumentSettings } = data;
|
||||
|
||||
await updateOrganisationSettings({
|
||||
organisationId: organisation.id,
|
||||
data: {
|
||||
emailId,
|
||||
emailReplyTo: emailReplyTo || null,
|
||||
// emailReplyToName,
|
||||
emailDocumentSettings,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Email preferences updated`,
|
||||
description: t`Your email preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your email preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingOrganisation || !organisationWithSettings) {
|
||||
return <SpinnerBox />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={t`Email Preferences`}
|
||||
subtitle={t`You can manage your email preferences here`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<EmailPreferencesForm
|
||||
canInherit={false}
|
||||
settings={organisationWithSettings.organisationGlobalSettings}
|
||||
onFormSubmit={onEmailPreferencesSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
125
apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
Normal file
125
apps/remix/app/routes/_authenticated+/o.$orgUrl.support.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
import BrandingPage, { meta } from '../../o.$orgUrl.settings.branding';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default BrandingPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import DocumentPage, { meta } from '../../o.$orgUrl.settings.document';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default DocumentPage;
|
||||
@ -0,0 +1,5 @@
|
||||
import EmailPage, { meta } from '../../o.$orgUrl.settings.email';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default EmailPage;
|
||||
@ -1,5 +0,0 @@
|
||||
import PreferencesPage, { meta } from '../../o.$orgUrl.settings.preferences';
|
||||
|
||||
export { meta };
|
||||
|
||||
export default PreferencesPage;
|
||||
@ -10,6 +10,7 @@ import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/get-document-with-details-by-id';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { DocumentReadOnlyFields } from '@documenso/ui/components/document/document-read-only-fields';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
@ -83,6 +84,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw redirect(documentRootPath);
|
||||
}
|
||||
|
||||
logDocumentAccess({
|
||||
request,
|
||||
documentId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
document,
|
||||
documentRootPath,
|
||||
|
||||
@ -9,6 +9,7 @@ import { getDocumentWithDetailsById } from '@documenso/lib/server-only/document/
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { DocumentVisibility } from '@documenso/lib/types/document-visibility';
|
||||
import { isDocumentCompleted } from '@documenso/lib/utils/document';
|
||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
|
||||
import { DocumentEditForm } from '~/components/general/document/document-edit-form';
|
||||
@ -78,6 +79,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
throw redirect(`${documentRootPath}/${documentId}`);
|
||||
}
|
||||
|
||||
logDocumentAccess({
|
||||
request,
|
||||
documentId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return superLoaderJson({
|
||||
document: {
|
||||
...document,
|
||||
|
||||
@ -11,6 +11,7 @@ import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getDocumentById } from '@documenso/lib/server-only/document/get-document-by-id';
|
||||
import { getRecipientsForDocument } from '@documenso/lib/server-only/recipient/get-recipients-for-document';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { logDocumentAccess } from '@documenso/lib/utils/logger';
|
||||
import { formatDocumentsPath } from '@documenso/lib/utils/teams';
|
||||
import { Card } from '@documenso/ui/primitives/card';
|
||||
|
||||
@ -59,6 +60,12 @@ export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
teamId: team?.id,
|
||||
});
|
||||
|
||||
logDocumentAccess({
|
||||
request,
|
||||
documentId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return {
|
||||
document,
|
||||
documentRootPath,
|
||||
@ -170,7 +177,7 @@ export default function DocumentsLogsPage({ loaderData }: Route.ComponentProps)
|
||||
<ul className="text-muted-foreground list-inside list-disc">
|
||||
{recipients.map((recipient) => (
|
||||
<li key={`recipient-${recipient.id}`}>
|
||||
<span className="-ml-2">{formatRecipientText(recipient)}</span>
|
||||
<span>{formatRecipientText(recipient)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -1,15 +1,23 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { Link, Outlet, redirect } from 'react-router';
|
||||
import { Trans, useLingui } from '@lingui/react/macro';
|
||||
import {
|
||||
BracesIcon,
|
||||
Globe2Icon,
|
||||
GroupIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
Users2Icon,
|
||||
WebhookIcon,
|
||||
} from 'lucide-react';
|
||||
import { Link, NavLink, Outlet, redirect } from 'react-router';
|
||||
|
||||
import { getSession } from '@documenso/auth/server/lib/utils/get-session';
|
||||
import { getTeamByUrl } from '@documenso/lib/server-only/team/get-team';
|
||||
import { canExecuteTeamAction } from '@documenso/lib/utils/teams';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { GenericErrorLayout } from '~/components/general/generic-error-layout';
|
||||
import { TeamSettingsNavDesktop } from '~/components/general/teams/team-settings-nav-desktop';
|
||||
import { TeamSettingsNavMobile } from '~/components/general/teams/team-settings-nav-mobile';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
@ -37,8 +45,64 @@ export async function clientLoader() {
|
||||
}
|
||||
|
||||
export default function TeamsSettingsLayout() {
|
||||
const { t } = useLingui();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const teamSettingRoutes = [
|
||||
{
|
||||
path: `/t/${team.url}/settings`,
|
||||
label: t`General`,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/document`,
|
||||
label: t`Preferences`,
|
||||
icon: Settings2Icon,
|
||||
isSubNavParent: true,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/document`,
|
||||
label: t`Document`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/branding`,
|
||||
label: t`Branding`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/email`,
|
||||
label: t`Email`,
|
||||
isSubNav: true,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/public-profile`,
|
||||
label: t`Public Profile`,
|
||||
icon: Globe2Icon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/members`,
|
||||
label: t`Members`,
|
||||
icon: Users2Icon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/groups`,
|
||||
label: t`Groups`,
|
||||
icon: GroupIcon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/tokens`,
|
||||
label: t`API Tokens`,
|
||||
icon: BracesIcon,
|
||||
},
|
||||
{
|
||||
path: `/t/${team.url}/settings/webhooks`,
|
||||
label: t`Webhooks`,
|
||||
icon: WebhookIcon,
|
||||
},
|
||||
];
|
||||
|
||||
if (!canExecuteTeamAction('MANAGE_TEAM', team.currentTeamRole)) {
|
||||
return (
|
||||
<GenericErrorLayout
|
||||
@ -69,8 +133,29 @@ export default function TeamsSettingsLayout() {
|
||||
</h1>
|
||||
|
||||
<div className="mt-4 grid grid-cols-12 gap-x-8 md:mt-8">
|
||||
<TeamSettingsNavDesktop className="hidden md:col-span-3 md:flex" />
|
||||
<TeamSettingsNavMobile className="col-span-12 mb-8 md:hidden" />
|
||||
<div
|
||||
className={cn(
|
||||
'col-span-12 mb-8 flex flex-wrap items-center justify-start gap-x-2 gap-y-4 md:col-span-3 md:w-full md:flex-col md:items-start md:gap-y-2',
|
||||
)}
|
||||
>
|
||||
{teamSettingRoutes.map((route) => (
|
||||
<NavLink
|
||||
to={route.path}
|
||||
className={cn('group w-full justify-start', route.isSubNav && 'pl-8')}
|
||||
key={route.path}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn('w-full justify-start', {
|
||||
'group-aria-[current]:bg-secondary': !route.isSubNavParent,
|
||||
})}
|
||||
>
|
||||
{route.icon && <route.icon className="mr-2 h-5 w-5" />}
|
||||
<Trans>{route.label}</Trans>
|
||||
</Button>
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 md:col-span-9">
|
||||
<Outlet />
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import {
|
||||
BrandingPreferencesForm,
|
||||
type TBrandingPreferencesFormSchema,
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Branding Preferences');
|
||||
}
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
|
||||
teamReference: team.id,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
|
||||
let uploadedBrandingLogo = teamWithSettings?.teamSettings?.brandingLogo;
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo || null,
|
||||
brandingUrl: brandingUrl || null,
|
||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTeam || !teamWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={t`Branding Preferences`}
|
||||
subtitle={t`Here you can set preferences and defaults for branding.`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
canInherit={true}
|
||||
context="Team"
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,14 +2,9 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import { DocumentSignatureType } from '@documenso/lib/constants/document';
|
||||
import { putFile } from '@documenso/lib/universal/upload/put-file';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import {
|
||||
BrandingPreferencesForm,
|
||||
type TBrandingPreferencesFormSchema,
|
||||
} from '~/components/forms/branding-preferences-form';
|
||||
import {
|
||||
DocumentPreferencesForm,
|
||||
type TDocumentPreferencesFormSchema,
|
||||
@ -19,7 +14,7 @@ import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Preferences');
|
||||
return appMetaTags('Document Preferences');
|
||||
}
|
||||
|
||||
export default function TeamsSettingsPage() {
|
||||
@ -39,8 +34,11 @@ export default function TeamsSettingsPage() {
|
||||
const {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
signatureTypes,
|
||||
} = data;
|
||||
|
||||
@ -49,8 +47,11 @@ export default function TeamsSettingsPage() {
|
||||
data: {
|
||||
documentVisibility,
|
||||
documentLanguage,
|
||||
documentTimezone,
|
||||
documentDateFormat,
|
||||
includeSenderDetails,
|
||||
includeSigningCertificate,
|
||||
includeAuditLog,
|
||||
...(signatureTypes.length === 0
|
||||
? {
|
||||
typedSignatureEnabled: null,
|
||||
@ -78,43 +79,6 @@ export default function TeamsSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onBrandingPreferencesFormSubmit = async (data: TBrandingPreferencesFormSchema) => {
|
||||
try {
|
||||
const { brandingEnabled, brandingLogo, brandingUrl, brandingCompanyDetails } = data;
|
||||
|
||||
let uploadedBrandingLogo = teamWithSettings?.teamSettings?.brandingLogo;
|
||||
|
||||
if (brandingLogo) {
|
||||
uploadedBrandingLogo = JSON.stringify(await putFile(brandingLogo));
|
||||
}
|
||||
|
||||
if (brandingLogo === null) {
|
||||
uploadedBrandingLogo = '';
|
||||
}
|
||||
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
brandingEnabled,
|
||||
brandingLogo: uploadedBrandingLogo || null,
|
||||
brandingUrl: brandingUrl || null,
|
||||
brandingCompanyDetails: brandingCompanyDetails || null,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Branding preferences updated`,
|
||||
description: t`Your branding preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong`,
|
||||
description: t`We were unable to update your branding preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTeam || !teamWithSettings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
@ -126,7 +90,7 @@ export default function TeamsSettingsPage() {
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={t`Team Preferences`}
|
||||
title={t`Document Preferences`}
|
||||
subtitle={t`Here you can set preferences and defaults for your team.`}
|
||||
/>
|
||||
|
||||
@ -137,21 +101,6 @@ export default function TeamsSettingsPage() {
|
||||
onFormSubmit={onDocumentPreferencesSubmit}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<SettingsHeader
|
||||
title={t`Branding Preferences`}
|
||||
subtitle={t`Here you can set preferences and defaults for branding.`}
|
||||
className="mt-8"
|
||||
/>
|
||||
|
||||
<section>
|
||||
<BrandingPreferencesForm
|
||||
canInherit={true}
|
||||
context="Team"
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onBrandingPreferencesFormSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { SpinnerBox } from '@documenso/ui/primitives/spinner';
|
||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||
|
||||
import {
|
||||
EmailPreferencesForm,
|
||||
type TEmailPreferencesFormSchema,
|
||||
} from '~/components/forms/email-preferences-form';
|
||||
import { SettingsHeader } from '~/components/general/settings-header';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
|
||||
export function meta() {
|
||||
return appMetaTags('Settings');
|
||||
}
|
||||
|
||||
export default function TeamEmailSettingsGeneral() {
|
||||
const { t } = useLingui();
|
||||
const { toast } = useToast();
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { data: teamWithSettings, isLoading: isLoadingTeam } = trpc.team.get.useQuery({
|
||||
teamReference: team.url,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateTeamSettings } = trpc.team.settings.update.useMutation();
|
||||
|
||||
const onEmailPreferencesSubmit = async (data: TEmailPreferencesFormSchema) => {
|
||||
try {
|
||||
const { emailId, emailReplyTo, emailDocumentSettings } = data;
|
||||
|
||||
await updateTeamSettings({
|
||||
teamId: team.id,
|
||||
data: {
|
||||
emailId,
|
||||
emailReplyTo,
|
||||
// emailReplyToName,
|
||||
emailDocumentSettings,
|
||||
},
|
||||
});
|
||||
|
||||
toast({
|
||||
title: t`Email preferences updated`,
|
||||
description: t`Your email preferences have been updated`,
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: t`Something went wrong!`,
|
||||
description: t`We were unable to update your email preferences at this time, please try again later`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTeam || !teamWithSettings) {
|
||||
return <SpinnerBox />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<SettingsHeader
|
||||
title={t`Email Preferences`}
|
||||
subtitle={t`You can manage your email preferences here`}
|
||||
/>
|
||||
|
||||
<section>
|
||||
<EmailPreferencesForm
|
||||
canInherit={true}
|
||||
settings={teamWithSettings.teamSettings}
|
||||
onFormSubmit={onEmailPreferencesSubmit}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,23 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { FolderType } from '@prisma/client';
|
||||
import { Bird, FolderIcon, HomeIcon, Loader2 } from 'lucide-react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { Bird } from 'lucide-react';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { formatDocumentsPath, formatTemplatesPath } from '@documenso/lib/utils/teams';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import type { TFolderWithSubfolders } from '@documenso/trpc/server/folder-router/schema';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@documenso/ui/primitives/avatar';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
|
||||
import { FolderCreateDialog } from '~/components/dialogs/folder-create-dialog';
|
||||
import { FolderDeleteDialog } from '~/components/dialogs/folder-delete-dialog';
|
||||
import { FolderMoveDialog } from '~/components/dialogs/folder-move-dialog';
|
||||
import { FolderUpdateDialog } from '~/components/dialogs/folder-update-dialog';
|
||||
import { TemplateCreateDialog } from '~/components/dialogs/template-create-dialog';
|
||||
import { FolderCard } from '~/components/general/folder/folder-card';
|
||||
import { FolderGrid } from '~/components/general/folder/folder-grid';
|
||||
import { TemplateDropZoneWrapper } from '~/components/general/template/template-drop-zone-wrapper';
|
||||
import { TemplatesTable } from '~/components/tables/templates-table';
|
||||
import { useCurrentTeam } from '~/providers/team';
|
||||
import { appMetaTags } from '~/utils/meta';
|
||||
@ -29,246 +21,70 @@ export function meta() {
|
||||
export default function TemplatesPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { folderId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isMovingFolder, setIsMovingFolder] = useState(false);
|
||||
const [folderToMove, setFolderToMove] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isDeletingFolder, setIsDeletingFolder] = useState(false);
|
||||
const [folderToDelete, setFolderToDelete] = useState<TFolderWithSubfolders | null>(null);
|
||||
const [isSettingsFolderOpen, setIsSettingsFolderOpen] = useState(false);
|
||||
const [folderToSettings, setFolderToSettings] = useState<TFolderWithSubfolders | null>(null);
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const { mutateAsync: pinFolder } = trpc.folder.pinFolder.useMutation();
|
||||
const { mutateAsync: unpinFolder } = trpc.folder.unpinFolder.useMutation();
|
||||
|
||||
const page = Number(searchParams.get('page')) || 1;
|
||||
const perPage = Number(searchParams.get('perPage')) || 10;
|
||||
|
||||
const documentRootPath = formatDocumentsPath(team.url);
|
||||
const templateRootPath = formatTemplatesPath(team.url);
|
||||
|
||||
const { data, isLoading, isLoadingError, refetch } = trpc.template.findTemplates.useQuery({
|
||||
const { data, isLoading, isLoadingError } = trpc.template.findTemplates.useQuery({
|
||||
page: page,
|
||||
perPage: perPage,
|
||||
folderId,
|
||||
});
|
||||
|
||||
const {
|
||||
data: foldersData,
|
||||
isLoading: isFoldersLoading,
|
||||
refetch: refetchFolders,
|
||||
} = trpc.folder.getFolders.useQuery({
|
||||
parentId: folderId,
|
||||
type: FolderType.TEMPLATE,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void refetch();
|
||||
void refetchFolders();
|
||||
}, [refetch, refetchFolders]);
|
||||
|
||||
const navigateToFolder = (folderId?: string | null) => {
|
||||
const templatesPath = formatTemplatesPath(team.url);
|
||||
|
||||
if (folderId) {
|
||||
void navigate(`${templatesPath}/f/${folderId}`);
|
||||
} else {
|
||||
void navigate(templatesPath);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMove = (folder: TFolderWithSubfolders) => {
|
||||
setFolderToMove(folder);
|
||||
setIsMovingFolder(true);
|
||||
};
|
||||
|
||||
const handlePin = (folderId: string) => {
|
||||
void pinFolder({ folderId });
|
||||
};
|
||||
|
||||
const handleUnpin = (folderId: string) => {
|
||||
void unpinFolder({ folderId });
|
||||
};
|
||||
|
||||
const handleSettings = (folder: TFolderWithSubfolders) => {
|
||||
setFolderToSettings(folder);
|
||||
setIsSettingsFolderOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (folder: TFolderWithSubfolders) => {
|
||||
setFolderToDelete(folder);
|
||||
setIsDeletingFolder(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-1 items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-0 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder()}
|
||||
>
|
||||
<HomeIcon className="h-4 w-4" />
|
||||
<span>Home</span>
|
||||
</Button>
|
||||
|
||||
{foldersData?.breadcrumbs.map((folder) => (
|
||||
<div key={folder.id} className="flex items-center space-x-2">
|
||||
<span>/</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-2 pl-1 hover:bg-transparent"
|
||||
onClick={() => navigateToFolder(folder.id)}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
<span>{folder.name}</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 sm:flex-row sm:justify-end">
|
||||
<FolderCreateDialog type={FolderType.TEMPLATE} />
|
||||
<TemplateCreateDialog folderId={folderId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFoldersLoading ? (
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{foldersData?.folders && foldersData.folders.some((folder) => folder.pinned) && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData.folders
|
||||
.filter((folder) => folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onMove={handleMove}
|
||||
onPin={handlePin}
|
||||
onUnpin={handleUnpin}
|
||||
onSettings={handleSettings}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{foldersData?.folders
|
||||
.filter((folder) => !folder.pinned)
|
||||
.map((folder) => (
|
||||
<FolderCard
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onMove={handleMove}
|
||||
onPin={handlePin}
|
||||
onUnpin={handleUnpin}
|
||||
onSettings={handleSettings}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative mt-12">
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Templates</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
<TemplateDropZoneWrapper>
|
||||
<div className="mx-auto max-w-screen-xl px-4 md:px-8">
|
||||
<FolderGrid type={FolderType.TEMPLATE} parentId={folderId ?? null} />
|
||||
|
||||
<div className="mt-8">
|
||||
{data && data.count === 0 ? (
|
||||
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
<div className="flex flex-row items-center">
|
||||
<Avatar className="dark:border-border mr-3 h-12 w-12 border-2 border-solid border-white">
|
||||
{team.avatarImageId && <AvatarImage src={formatAvatarUrl(team.avatarImageId)} />}
|
||||
<AvatarFallback className="text-muted-foreground text-xs">
|
||||
{team.name.slice(0, 1)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>We're all empty</Trans>
|
||||
</h3>
|
||||
<h1 className="truncate text-2xl font-semibold md:text-3xl">
|
||||
<Trans>Templates</Trans>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 max-w-[50ch]">
|
||||
<Trans>
|
||||
You have not yet created any templates. To create a template please upload one.
|
||||
</Trans>
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
{data && data.count === 0 ? (
|
||||
<div className="text-muted-foreground/60 flex h-96 flex-col items-center justify-center gap-y-4">
|
||||
<Bird className="h-12 w-12" strokeWidth={1.5} />
|
||||
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold">
|
||||
<Trans>We're all empty</Trans>
|
||||
</h3>
|
||||
|
||||
<p className="mt-2 max-w-[50ch]">
|
||||
<Trans>
|
||||
You have not yet created any templates. To create a template please upload
|
||||
one.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<TemplatesTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
)}
|
||||
) : (
|
||||
<TemplatesTable
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isLoadingError={isLoadingError}
|
||||
documentRootPath={documentRootPath}
|
||||
templateRootPath={templateRootPath}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{folderToMove && (
|
||||
<FolderMoveDialog
|
||||
foldersData={foldersData?.folders}
|
||||
folder={folderToMove}
|
||||
isOpen={isMovingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsMovingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToMove(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{folderToSettings && (
|
||||
<FolderUpdateDialog
|
||||
folder={folderToSettings}
|
||||
isOpen={isSettingsFolderOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsSettingsFolderOpen(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToSettings(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{folderToDelete && (
|
||||
<FolderDeleteDialog
|
||||
folder={folderToDelete}
|
||||
isOpen={isDeletingFolder}
|
||||
onOpenChange={(open) => {
|
||||
setIsDeletingFolder(open);
|
||||
|
||||
if (!open) {
|
||||
setFolderToDelete(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TemplateDropZoneWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
@media print {
|
||||
html {
|
||||
font-size: 10pt;
|
||||
}
|
||||
}
|
||||
@ -13,10 +13,17 @@ import { findDocumentAuditLogs } from '@documenso/lib/server-only/document/find-
|
||||
import { getTranslations } from '@documenso/lib/utils/i18n';
|
||||
import { Card, CardContent } from '@documenso/ui/primitives/card';
|
||||
|
||||
import appStylesheet from '~/app.css?url';
|
||||
import { BrandingLogo } from '~/components/general/branding-logo';
|
||||
import { InternalAuditLogTable } from '~/components/tables/internal-audit-log-table';
|
||||
|
||||
import type { Route } from './+types/audit-log';
|
||||
import auditLogStylesheet from './audit-log.print.css?url';
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: 'stylesheet', href: appStylesheet },
|
||||
{ rel: 'stylesheet', href: auditLogStylesheet },
|
||||
];
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const d = new URL(request.url).searchParams.get('d');
|
||||
@ -77,10 +84,8 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
|
||||
|
||||
return (
|
||||
<div className="print-provider pointer-events-none mx-auto max-w-screen-md">
|
||||
<div className="flex items-center">
|
||||
<h1 className="my-8 text-2xl font-bold">
|
||||
<Trans>Version History</Trans>
|
||||
</h1>
|
||||
<div className="mb-6 border-b pb-4">
|
||||
<h1 className="text-xl font-semibold">{_(msg`Audit Log`)}</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@ -176,11 +181,9 @@ export default function AuditLog({ loaderData }: Route.ComponentProps) {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-8">
|
||||
<CardContent className="p-0">
|
||||
<InternalAuditLogTable logs={auditLogs} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="mt-8">
|
||||
<InternalAuditLogTable logs={auditLogs} />
|
||||
</div>
|
||||
|
||||
<div className="my-8 flex-row-reverse">
|
||||
<div className="flex items-end justify-end gap-x-4">
|
||||
|
||||
@ -101,5 +101,5 @@
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
},
|
||||
"version": "1.12.2-rc.2"
|
||||
"version": "1.12.2-rc.4"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user