Merge branch 'main' into feat/document-2fa-redo

This commit is contained in:
Ephraim Atta-Duncan
2025-08-01 09:00:30 +00:00
167 changed files with 6918 additions and 1245 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,6 +51,8 @@ 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;
signatureTypes: DocumentSignatureType[];
@ -53,6 +62,8 @@ type SettingsSubset = Pick<
TeamGlobalSettings,
| 'documentVisibility'
| 'documentLanguage'
| 'documentTimezone'
| 'documentDateFormat'
| 'includeSenderDetails'
| 'includeSigningCertificate'
| 'typedSignatureEnabled'
@ -81,6 +92,8 @@ 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(),
signatureTypes: z.array(z.nativeEnum(DocumentSignatureType)).min(canInherit ? 0 : 1, {
@ -94,6 +107,9 @@ 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,
signatureTypes: extractTeamSignatureSettings({ ...settings }),
@ -124,7 +140,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 +190,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 +221,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 +310,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 +345,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 +416,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>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,116 @@
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,
signatureTypes,
} = data;
if (
documentVisibility === null ||
documentLanguage === null ||
documentDateFormat === null ||
includeSenderDetails === null ||
includeSigningCertificate === null
) {
throw new Error('Should not be possible.');
}
await updateOrganisationSettings({
organisationId: organisation.id,
data: {
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
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',
});
}
};
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>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import BrandingPage, { meta } from '../../o.$orgUrl.settings.branding';
export { meta };
export default BrandingPage;

View File

@ -0,0 +1,5 @@
import DocumentPage, { meta } from '../../o.$orgUrl.settings.document';
export { meta };
export default DocumentPage;

View File

@ -0,0 +1,5 @@
import EmailPage, { meta } from '../../o.$orgUrl.settings.email';
export { meta };
export default EmailPage;

View File

@ -1,5 +0,0 @@
import PreferencesPage, { meta } from '../../o.$orgUrl.settings.preferences';
export { meta };
export default PreferencesPage;

View File

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

View File

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

View File

@ -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,6 +34,8 @@ export default function TeamsSettingsPage() {
const {
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
signatureTypes,
@ -49,6 +46,8 @@ export default function TeamsSettingsPage() {
data: {
documentVisibility,
documentLanguage,
documentTimezone,
documentDateFormat,
includeSenderDetails,
includeSigningCertificate,
...(signatureTypes.length === 0
@ -78,43 +77,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 +88,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 +99,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>
);
}

View File

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