feat: add email domains (#1895)

Implemented Email Domains which allows Platform/Enterprise customers to
send emails to recipients using their custom emails.
This commit is contained in:
David Nguyen
2025-07-24 16:05:00 +10:00
committed by GitHub
parent 07119f0e8d
commit 3409aae411
157 changed files with 5966 additions and 1090 deletions

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