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