feat: add admin email domain management and sync job (#2538)

This commit is contained in:
Lucas Smith
2026-02-25 15:14:18 +11:00
committed by GitHub
parent bc72d9cb17
commit c112392da9
22 changed files with 1257 additions and 5 deletions
@@ -3,6 +3,7 @@ import {
BarChart3,
Building2Icon,
FileStack,
MailIcon,
Settings,
Trophy,
Users,
@@ -122,6 +123,20 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) {
</Link>
</Button>
<Button
variant="ghost"
className={cn(
'justify-start md:w-full',
pathname?.startsWith('/admin/email-domains') && 'bg-secondary',
)}
asChild
>
<Link to="/admin/email-domains">
<MailIcon className="mr-2 h-5 w-5" />
<Trans>Email Domains</Trans>
</Link>
</Button>
<Button
variant="ghost"
className={cn(
@@ -0,0 +1,295 @@
import { useMemo } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EmailDomainStatus } from '@prisma/client';
import { CheckCircle2Icon, ClockIcon, CopyIcon, RotateCcwIcon } from 'lucide-react';
import { DateTime } from 'luxon';
import { Link, redirect } from 'react-router';
import { match } from 'ts-pattern';
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
import { trpc } from '@documenso/trpc/react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@documenso/ui/primitives/alert-dialog';
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 { useToast } from '@documenso/ui/primitives/use-toast';
import type { Route } from './+types/email-domains.$id';
export function loader({ params }: Route.LoaderArgs) {
const id = params.id;
if (!id) {
throw redirect('/admin/email-domains');
}
return { emailDomainId: id };
}
export default function AdminEmailDomainDetailPage({ loaderData }: Route.ComponentProps) {
const { emailDomainId } = loaderData;
const { _, i18n } = useLingui();
const { toast } = useToast();
const {
data: emailDomain,
isPending: isLoading,
refetch,
} = trpc.admin.emailDomain.get.useQuery({ emailDomainId });
const { mutate: reregisterDomain, isPending: isReregistering } =
trpc.admin.emailDomain.reregister.useMutation({
onSuccess: () => {
toast({
title: _(msg`Domain re-registered`),
description: _(
msg`The SES identity has been deleted and recreated with the same keys. DNS records remain unchanged.`,
),
});
void refetch();
},
onError: () => {
toast({
title: _(msg`Error`),
description: _(msg`Failed to re-register email domain`),
variant: 'destructive',
});
},
});
const dnsRecords = useMemo(() => {
if (!emailDomain) {
return [];
}
return generateEmailDomainRecords(emailDomain.selector, emailDomain.publicKey);
}, [emailDomain]);
const emailColumns = useMemo(() => {
return [
{
header: _(msg`Email`),
accessorKey: 'email',
},
{
header: _(msg`Display Name`),
accessorKey: 'emailName',
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
] satisfies DataTableColumnDef<NonNullable<typeof emailDomain>['emails'][number]>[];
}, []);
const onCopyToClipboard = async (text: string) => {
await navigator.clipboard.writeText(text);
toast({
title: _(msg`Copied to clipboard`),
});
};
if (isLoading || !emailDomain) {
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Email Domain</Trans>
</h2>
<p className="mt-4 text-muted-foreground">
<Trans>Loading...</Trans>
</p>
</div>
);
}
const pendingDuration =
emailDomain.status === EmailDomainStatus.PENDING
? DateTime.fromJSDate(new Date(emailDomain.createdAt)).toRelative()
: null;
return (
<div>
<div className="flex items-start justify-between">
<div className="flex items-center gap-x-4">
<h2 className="text-2xl font-semibold">{emailDomain.domain}</h2>
{match(emailDomain.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()}
</div>
</div>
<div className="mt-4 text-sm text-muted-foreground">
<div>
<Trans>ID</Trans>: {emailDomain.id}
</div>
<div>
<Trans>Organisation</Trans>:{' '}
<Link
to={`/admin/organisations/${emailDomain.organisation.id}`}
className="hover:underline"
>
{emailDomain.organisation.name}
</Link>
</div>
<div>
<Trans>Selector</Trans>: {emailDomain.selector}
</div>
<div>
<Trans>Created</Trans>: {i18n.date(emailDomain.createdAt, DateTime.DATETIME_MED)}
</div>
<div>
<Trans>Last Verified</Trans>:{' '}
{emailDomain.lastVerifiedAt
? i18n.date(emailDomain.lastVerifiedAt, DateTime.DATETIME_MED)
: '-'}
</div>
{pendingDuration && (
<div className="mt-1 text-yellow-600 dark:text-yellow-400">
<Trans>Pending since</Trans>: {pendingDuration}
</div>
)}
</div>
<hr className="my-4" />
<h3 className="text-lg font-semibold">
<Trans>Admin Actions</Trans>
</h3>
<div className="mt-2 flex gap-x-4">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" loading={isReregistering}>
<RotateCcwIcon className="mr-2 h-4 w-4" />
<Trans>Re-register</Trans>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Re-register Email Domain</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This will delete the existing SES identity for{' '}
<strong>{emailDomain.domain}</strong> and recreate it using the same DKIM keys.
The user will not need to update their DNS records. The domain status will be
reset to Pending.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
onClick={() => reregisterDomain({ emailDomainId: emailDomain.id })}
>
<Trans>Re-register</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
<hr className="my-4" />
<h3 className="text-lg font-semibold">
<Trans>DNS Records</Trans>
</h3>
<div className="mt-4 space-y-4">
{dnsRecords.map((record, index) => (
<div key={index} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">
{record.type} <Trans>Record</Trans>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => void onCopyToClipboard(record.value)}
>
<CopyIcon className="mr-2 h-4 w-4" />
<Trans>Copy Value</Trans>
</Button>
</div>
<div className="mt-2 space-y-1 text-sm">
<div>
<span className="text-muted-foreground">
<Trans>Name</Trans>:{' '}
</span>
<code className="rounded bg-muted px-1 py-0.5">{record.name}</code>
</div>
<div>
<span className="text-muted-foreground">
<Trans>Value</Trans>:{' '}
</span>
<code className="block break-all rounded bg-muted px-1 py-0.5">{record.value}</code>
</div>
</div>
</div>
))}
</div>
<hr className="my-4" />
<h3 className="text-lg font-semibold">
<Trans>Emails</Trans> ({emailDomain.emails.length})
</h3>
<div className="mt-4">
{emailDomain.emails.length > 0 ? (
<DataTable
columns={emailColumns}
data={emailDomain.emails}
perPage={emailDomain.emails.length}
currentPage={1}
totalPages={1}
onPaginationChange={() => {}}
/>
) : (
<p className="text-sm text-muted-foreground">
<Trans>No emails configured for this domain.</Trans>
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,207 @@
import { useMemo, useState } from 'react';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { EmailDomainStatus } from '@prisma/client';
import { CheckCircle2Icon, ClockIcon, Loader } from 'lucide-react';
import { Link, useSearchParams } from 'react-router';
import { match } from 'ts-pattern';
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { trpc } from '@documenso/trpc/react';
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 { Input } from '@documenso/ui/primitives/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@documenso/ui/primitives/select';
export default function AdminEmailDomainsPage() {
const { _, i18n } = useLingui();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const [term, setTerm] = useState(() => searchParams?.get?.('term') ?? '');
const debouncedTerm = useDebouncedValue(term, 500);
const page = searchParams?.get?.('page') ? Number(searchParams.get('page')) : undefined;
const perPage = searchParams?.get?.('perPage') ? Number(searchParams.get('perPage')) : undefined;
const statusParam = searchParams?.get?.('status') ?? 'ALL';
const statusFilter =
statusParam === 'PENDING' || statusParam === 'ACTIVE' ? statusParam : undefined;
const { data: findEmailDomainsData, isPending: isFindEmailDomainsLoading } =
trpc.admin.emailDomain.find.useQuery(
{
query: debouncedTerm,
page: page || 1,
perPage: perPage || 20,
status: statusFilter,
},
{
placeholderData: (previousData) => previousData,
},
);
const results = findEmailDomainsData ?? {
data: [],
perPage: 20,
currentPage: 1,
totalPages: 1,
};
const columns = useMemo(() => {
return [
{
header: _(msg`Domain`),
accessorKey: 'domain',
cell: ({ row }) => (
<Link
to={`/admin/email-domains/${row.original.id}`}
className="block max-w-[10rem] truncate font-medium hover:underline md:max-w-[15rem]"
>
{row.original.domain}
</Link>
),
},
{
header: _(msg`Organisation`),
accessorKey: 'organisation',
cell: ({ row }) => (
<Link
to={`/admin/organisations/${row.original.organisation.id}`}
className="hover:underline"
>
{row.original.organisation.name}
</Link>
),
},
{
header: _(msg`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: _(msg`Emails`),
accessorKey: '_count',
cell: ({ row }) => row.original._count.emails,
},
{
header: _(msg`Created`),
accessorKey: 'createdAt',
cell: ({ row }) => i18n.date(row.original.createdAt),
},
{
header: _(msg`Last Verified`),
accessorKey: 'lastVerifiedAt',
cell: ({ row }) =>
row.original.lastVerifiedAt ? i18n.date(row.original.lastVerifiedAt) : '-',
},
{
header: _(msg`Actions`),
cell: ({ row }) => (
<Button asChild variant="outline" size="sm">
<Link to={`/admin/email-domains/${row.original.id}`}>
<Trans>View</Trans>
</Link>
</Button>
),
},
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
}, []);
const onPaginationChange = (newPage: number, newPerPage: number) => {
updateSearchParams({
page: newPage,
perPage: newPerPage,
});
};
const onStatusChange = (value: string) => {
updateSearchParams({
status: value === 'ALL' ? undefined : value,
page: 1,
});
};
return (
<div>
<h2 className="text-4xl font-semibold">
<Trans>Email Domains</Trans>
</h2>
<div className="mt-8">
<div className="flex flex-col gap-4 sm:flex-row">
<Input
className="flex-1"
type="search"
placeholder={_(msg`Search by domain or organisation name`)}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
<Select value={statusParam} onValueChange={onStatusChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder={_(msg`Filter by status`)} />
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">
<Trans>All Statuses</Trans>
</SelectItem>
<SelectItem value="PENDING">
<Trans>Pending</Trans>
</SelectItem>
<SelectItem value="ACTIVE">
<Trans>Active</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="relative mt-4">
<DataTable
columns={columns}
data={results.data}
perPage={results.perPage ?? 20}
currentPage={results.currentPage ?? 1}
totalPages={results.totalPages ?? 1}
onPaginationChange={onPaginationChange}
>
{(table) => <DataTablePagination additionalInformation="VisibleCount" table={table} />}
</DataTable>
{isFindEmailDomainsLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/50">
<Loader className="h-8 w-8 animate-spin text-gray-500" />
</div>
)}
</div>
</div>
</div>
);
}