From 4ee789ea378d12c85daacf7dceda80b4dec80652 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Fri, 5 Jun 2026 21:19:20 +1000 Subject: [PATCH] fix: add multi email transport system (#2942) --- .../dialogs/claim-update-dialog.tsx | 32 +- .../dialogs/email-transport-create-dialog.tsx | 95 ++++++ .../dialogs/email-transport-delete-dialog.tsx | 114 +++++++ .../email-transport-send-test-dialog.tsx | 126 +++++++ .../dialogs/email-transport-update-dialog.tsx | 104 ++++++ .../components/forms/email-transport-form.tsx | 317 ++++++++++++++++++ .../forms/subscription-claim-form.tsx | 41 +++ .../tables/admin-email-transports-table.tsx | 179 ++++++++++ .../routes/_authenticated+/admin+/_layout.tsx | 11 + .../admin+/email-transports._index.tsx | 59 ++++ .../admin+/organisations.$id.tsx | 40 +++ .../email-transport-claims.spec.ts | 268 +++++++++++++++ .../email-transport-crud.spec.ts | 284 ++++++++++++++++ ...isation-account-link-confirmation-email.ts | 11 + packages/email/transports/build-transport.ts | 51 +++ .../send-document-cancelled-emails.handler.ts | 5 +- .../send-document-completed-emails.handler.ts | 7 +- ...ated-from-direct-template-email.handler.ts | 5 +- ...ganisation-limit-exceeded-email.handler.ts | 5 +- ...rganisation-member-joined-email.handler.ts | 5 +- ...-organisation-member-left-email.handler.ts | 5 +- ...d-owner-recipient-expired-email.handler.ts | 5 +- .../send-recipient-signed-email.handler.ts | 5 +- .../emails/send-rejection-emails.handler.ts | 4 +- .../emails/send-signing-email.handler.ts | 4 +- .../admin-delete-organisation.handler.ts | 4 +- .../internal/backport-subscription-claims.ts | 4 +- .../internal/bulk-send-template.handler.ts | 5 +- .../process-signing-reminder.handler.ts | 4 +- .../2fa/email/send-2fa-token-email.ts | 5 +- .../admin/admin-super-delete-document.ts | 5 +- .../server-only/document/delete-document.ts | 5 +- .../server-only/document/resend-document.ts | 4 +- .../server-only/document/send-delete-email.ts | 5 +- .../document/send-pending-email.ts | 5 +- .../email/email-transport-config.ts | 107 ++++++ .../server-only/email/get-email-context.ts | 66 +++- .../email/resolve-email-transport.ts | 42 +++ .../create-organisation-member-invites.ts | 5 +- .../organisation/create-organisation.ts | 32 +- .../organisation/delete-organisation-email.ts | 11 +- .../recipient/delete-envelope-recipient.ts | 29 +- .../recipient/set-document-recipients.ts | 5 +- .../team/create-team-email-verification.ts | 5 +- .../lib/server-only/team/delete-team-email.ts | 5 +- packages/lib/server-only/team/delete-team.ts | 5 +- packages/lib/universal/id.ts | 1 + packages/lib/utils/organisations-claims.ts | 1 + .../migration.sql | 28 ++ packages/prisma/schema.prisma | 32 ++ .../admin-router/create-subscription-claim.ts | 2 + .../create-subscription-claim.types.ts | 2 + .../email-transport/create-email-transport.ts | 32 ++ .../create-email-transport.types.ts | 15 + .../email-transport/delete-email-transport.ts | 24 ++ .../delete-email-transport.types.ts | 7 + .../email-transport/find-email-transports.ts | 65 ++++ .../find-email-transports.types.ts | 31 ++ .../send-test-email-transport.ts | 49 +++ .../send-test-email-transport.types.ts | 10 + .../email-transport/update-email-transport.ts | 57 ++++ .../update-email-transport.types.ts | 33 ++ .../find-subscription-claims.types.ts | 1 + packages/trpc/server/admin-router/router.ts | 12 + .../update-admin-organisation.types.ts | 1 + .../admin-router/update-subscription-claim.ts | 9 +- .../update-subscription-claim.types.ts | 3 + 67 files changed, 2440 insertions(+), 115 deletions(-) create mode 100644 apps/remix/app/components/dialogs/email-transport-create-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx create mode 100644 apps/remix/app/components/dialogs/email-transport-update-dialog.tsx create mode 100644 apps/remix/app/components/forms/email-transport-form.tsx create mode 100644 apps/remix/app/components/tables/admin-email-transports-table.tsx create mode 100644 apps/remix/app/routes/_authenticated+/admin+/email-transports._index.tsx create mode 100644 packages/app-tests/e2e/admin/email-transports/email-transport-claims.spec.ts create mode 100644 packages/app-tests/e2e/admin/email-transports/email-transport-crud.spec.ts create mode 100644 packages/email/transports/build-transport.ts create mode 100644 packages/lib/server-only/email/email-transport-config.ts create mode 100644 packages/lib/server-only/email/resolve-email-transport.ts create mode 100644 packages/prisma/migrations/20260604143030_add_email_transports/migration.sql create mode 100644 packages/trpc/server/admin-router/email-transport/create-email-transport.ts create mode 100644 packages/trpc/server/admin-router/email-transport/create-email-transport.types.ts create mode 100644 packages/trpc/server/admin-router/email-transport/delete-email-transport.ts create mode 100644 packages/trpc/server/admin-router/email-transport/delete-email-transport.types.ts create mode 100644 packages/trpc/server/admin-router/email-transport/find-email-transports.ts create mode 100644 packages/trpc/server/admin-router/email-transport/find-email-transports.types.ts create mode 100644 packages/trpc/server/admin-router/email-transport/send-test-email-transport.ts create mode 100644 packages/trpc/server/admin-router/email-transport/send-test-email-transport.types.ts create mode 100644 packages/trpc/server/admin-router/email-transport/update-email-transport.ts create mode 100644 packages/trpc/server/admin-router/email-transport/update-email-transport.types.ts diff --git a/apps/remix/app/components/dialogs/claim-update-dialog.tsx b/apps/remix/app/components/dialogs/claim-update-dialog.tsx index bcbd91a56..bdcdfbd55 100644 --- a/apps/remix/app/components/dialogs/claim-update-dialog.tsx +++ b/apps/remix/app/components/dialogs/claim-update-dialog.tsx @@ -2,6 +2,7 @@ import type { TLicenseClaim } from '@documenso/lib/types/license'; import { trpc } from '@documenso/trpc/react'; import type { TFindSubscriptionClaimsResponse } from '@documenso/trpc/server/admin-router/find-subscription-claims.types'; import { Button } from '@documenso/ui/primitives/button'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; import { Dialog, DialogContent, @@ -28,6 +29,7 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD const { toast } = useToast(); const [open, setOpen] = useState(false); + const [backportEmailTransport, setBackportEmailTransport] = useState(false); const { mutateAsync: updateClaim, isPending } = trpc.admin.claims.update.useMutation({ onSuccess: () => { @@ -67,19 +69,33 @@ export const ClaimUpdateDialog = ({ claim, trigger, licenseFlags }: ClaimUpdateD await updateClaim({ id: claim.id, data, + backportEmailTransport, }) } licenseFlags={licenseFlags} formSubmitTrigger={ - - + <> +
+ setBackportEmailTransport(checked === true)} + /> + +
- -
+ + + + + + } /> diff --git a/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx new file mode 100644 index 000000000..462e41921 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-create-dialog.tsx @@ -0,0 +1,95 @@ +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 { useToast } from '@documenso/ui/primitives/use-toast'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +import { + EmailTransportForm, + type EmailTransportFormValues, + emailTransportFormToConfig, +} from '../forms/email-transport-form'; + +export type EmailTransportCreateDialogProps = { + trigger?: React.ReactNode; +}; + +export const EmailTransportCreateDialog = ({ trigger }: EmailTransportCreateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: createTransport, isPending } = trpc.admin.emailTransport.create.useMutation({ + onSuccess: () => { + toast({ + title: t`Transport created.`, + }); + + setOpen(false); + }, + onError: (error) => { + toast({ + title: t`Failed to create transport.`, + description: error.message, + variant: 'destructive', + }); + }, + }); + + const onFormSubmit = async (values: EmailTransportFormValues) => { + await createTransport({ + name: values.name, + fromName: values.fromName, + fromAddress: values.fromAddress, + config: emailTransportFormToConfig(values), + }); + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()} asChild> + {trigger ?? ( + + )} + + + + + + Add Email Transport + + + Fill in the details to create a new email transport. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx new file mode 100644 index 000000000..055d920b3 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-delete-dialog.tsx @@ -0,0 +1,114 @@ +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'; +import { Plural, Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +export type EmailTransportDeleteDialogProps = { + transportId: string; + transportName: string; + subscriptionClaimCount: number; + organisationClaimCount: number; + trigger: React.ReactNode; +}; + +export const EmailTransportDeleteDialog = ({ + transportId, + transportName, + subscriptionClaimCount, + organisationClaimCount, + trigger, +}: EmailTransportDeleteDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const isInUse = subscriptionClaimCount + organisationClaimCount > 0; + + const { mutateAsync: deleteTransport, isPending } = trpc.admin.emailTransport.delete.useMutation({ + onSuccess: () => { + toast({ + title: t`Transport deleted.`, + }); + + setOpen(false); + }, + onError: () => { + toast({ + title: t`Failed to delete transport.`, + variant: 'destructive', + }); + }, + }); + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Delete Email Transport + + + Are you sure you want to delete the following transport? + + + + + {transportName} + + + {isInUse && ( + + + Warning, this email transport is currently being used by: + +
    + {subscriptionClaimCount > 0 && ( +
  • + +
  • + )} + + {organisationClaimCount > 0 && ( +
  • + +
  • + )} +
+
+
+ )} + + + + + + +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx new file mode 100644 index 000000000..1a463ff72 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-send-test-dialog.tsx @@ -0,0 +1,126 @@ +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'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const ZSendTestEmailFormSchema = z.object({ + to: z.string().email(), +}); + +type TSendTestEmailFormSchema = z.infer; + +export type EmailTransportSendTestDialogProps = { + transportId: string; + trigger: React.ReactNode; +}; + +export const EmailTransportSendTestDialog = ({ transportId, trigger }: EmailTransportSendTestDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: sendTest } = trpc.admin.emailTransport.sendTest.useMutation({ + onSuccess: () => { + toast({ + title: t`Test email sent.`, + }); + setOpen(false); + }, + onError: (error) => { + toast({ + title: t`Test failed.`, + description: error.message, + variant: 'destructive', + }); + }, + }); + + const form = useForm({ + resolver: zodResolver(ZSendTestEmailFormSchema), + defaultValues: { + to: '', + }, + }); + + const onFormSubmit = async ({ to }: TSendTestEmailFormSchema) => { + await sendTest({ id: transportId, to }); + }; + + useEffect(() => { + if (!open) { + form.reset(); + } + }, [open, form]); + + return ( + !form.formState.isSubmitting && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Send Test Email + + + Send a test email using this transport to verify the configuration. + + + +
+ +
+ ( + + + Email + + + + + + + )} + /> + + + + + + +
+
+ +
+
+ ); +}; diff --git a/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx new file mode 100644 index 000000000..5ad3ae023 --- /dev/null +++ b/apps/remix/app/components/dialogs/email-transport-update-dialog.tsx @@ -0,0 +1,104 @@ +import { trpc } from '@documenso/trpc/react'; +import type { TFindEmailTransportsResponse } from '@documenso/trpc/server/admin-router/email-transport/find-email-transports.types'; +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'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useState } from 'react'; + +import { + EmailTransportForm, + type EmailTransportFormValues, + emailTransportFormToConfig, +} from '../forms/email-transport-form'; + +export type EmailTransportUpdateDialogProps = { + transport: TFindEmailTransportsResponse['data'][number]; + trigger: React.ReactNode; +}; + +export const EmailTransportUpdateDialog = ({ transport, trigger }: EmailTransportUpdateDialogProps) => { + const { t } = useLingui(); + const { toast } = useToast(); + + const [open, setOpen] = useState(false); + + const { mutateAsync: updateTransport, isPending } = trpc.admin.emailTransport.update.useMutation(); + + const onFormSubmit = async (values: EmailTransportFormValues) => { + try { + await updateTransport({ + id: transport.id, + data: { + name: values.name, + fromName: values.fromName, + fromAddress: values.fromAddress, + config: emailTransportFormToConfig(values), + }, + }); + + toast({ + title: t`Transport updated.`, + }); + + setOpen(false); + } catch { + toast({ + title: t`Failed to save transport.`, + variant: 'destructive', + }); + } + }; + + return ( + !isPending && setOpen(value)}> + e.stopPropagation()}> + {trigger} + + + + + + Edit Email Transport + + + Modify the details of the email transport. + + + + + + + + + } + /> + + + ); +}; diff --git a/apps/remix/app/components/forms/email-transport-form.tsx b/apps/remix/app/components/forms/email-transport-form.tsx new file mode 100644 index 000000000..2e20fdb59 --- /dev/null +++ b/apps/remix/app/components/forms/email-transport-form.tsx @@ -0,0 +1,317 @@ +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'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +const ZEmailTransportFormSchema = z.object({ + name: z.string().min(1), + fromName: z.string().min(1), + fromAddress: z.string().email(), + type: z.enum(['SMTP_AUTH', 'SMTP_API', 'RESEND', 'MAILCHANNELS']), + host: z.string().optional(), + port: z.coerce.number().int().positive().optional(), + secure: z.boolean().optional(), + ignoreTLS: z.boolean().optional(), + username: z.string().optional(), + password: z.string().optional(), + service: z.string().optional(), + apiKey: z.string().optional(), + apiKeyUser: z.string().optional(), + endpoint: z.string().optional(), +}); + +export type EmailTransportFormValues = z.infer; + +type EmailTransportFormProps = { + defaultValues?: Partial; + isEdit?: boolean; + onFormSubmit: (values: EmailTransportFormValues) => Promise; + formSubmitTrigger?: React.ReactNode; +}; + +export const EmailTransportForm = ({ + defaultValues, + isEdit = false, + onFormSubmit, + formSubmitTrigger, +}: EmailTransportFormProps) => { + const { t } = useLingui(); + + const form = useForm({ + resolver: zodResolver(ZEmailTransportFormSchema), + defaultValues: { + name: '', + fromName: '', + fromAddress: '', + type: 'SMTP_AUTH', + secure: false, + ignoreTLS: false, + ...defaultValues, + }, + }); + + const type = form.watch('type'); + const secretPlaceholder = isEdit ? t`Leave blank to keep current` : undefined; + + return ( +
+ +
+ ( + + + Name + + + + + + + )} + /> + +
+ ( + + + From name + + + + + + + )} + /> + + ( + + + From address + + + + + + + )} + /> +
+ + ( + + + Transport type + + + {isEdit && ( + + Transport type cannot be changed after creation. + + )} + + + )} + /> + + {(type === 'SMTP_AUTH' || type === 'SMTP_API') && ( +
+ ( + + + Host + + + + + + + )} + /> + ( + + + Port + + + + + + + )} + /> +
+ )} + + {type === 'SMTP_AUTH' && ( + <> + ( + + + Username + + + + + + + )} + /> + ( + + + Password + + + + + + + )} + /> + + )} + + {type === 'SMTP_API' && ( + ( + + + API key + + + + + + + )} + /> + )} + + {(type === 'RESEND' || type === 'MAILCHANNELS') && ( + ( + + + API key + + + + + + + )} + /> + )} + + {type === 'MAILCHANNELS' && ( + ( + + + Endpoint (optional) + + + + + + + )} + /> + )} + + {formSubmitTrigger} +
+
+ + ); +}; + +/** + * Maps flat form values to the tRPC `config` discriminated union. + */ +export const emailTransportFormToConfig = (values: EmailTransportFormValues) => { + switch (values.type) { + case 'SMTP_AUTH': + return { + type: 'SMTP_AUTH' as const, + host: values.host ?? '', + port: values.port ?? 587, + secure: values.secure ?? false, + ignoreTLS: values.ignoreTLS ?? false, + username: values.username || undefined, + password: values.password || undefined, + service: values.service || undefined, + }; + case 'SMTP_API': + return { + type: 'SMTP_API' as const, + host: values.host ?? '', + port: values.port ?? 587, + secure: values.secure ?? false, + apiKey: values.apiKey || '', + apiKeyUser: values.apiKeyUser || undefined, + }; + case 'RESEND': + return { type: 'RESEND' as const, apiKey: values.apiKey || '' }; + case 'MAILCHANNELS': + return { + type: 'MAILCHANNELS' as const, + apiKey: values.apiKey || '', + endpoint: values.endpoint || undefined, + }; + } +}; diff --git a/apps/remix/app/components/forms/subscription-claim-form.tsx b/apps/remix/app/components/forms/subscription-claim-form.tsx index e3cef8d92..1dd903a3f 100644 --- a/apps/remix/app/components/forms/subscription-claim-form.tsx +++ b/apps/remix/app/components/forms/subscription-claim-form.tsx @@ -1,5 +1,6 @@ import type { TLicenseClaim } from '@documenso/lib/types/license'; import { SUBSCRIPTION_CLAIM_FEATURE_FLAGS } from '@documenso/lib/types/subscription'; +import { trpc } from '@documenso/trpc/react'; import { ZCreateSubscriptionClaimRequestSchema } from '@documenso/trpc/server/admin-router/create-subscription-claim.types'; import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Checkbox } from '@documenso/ui/primitives/checkbox'; @@ -13,6 +14,7 @@ import { 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'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans, useLingui } from '@lingui/react/macro'; import type { SubscriptionClaim } from '@prisma/client'; @@ -59,9 +61,14 @@ export const SubscriptionClaimForm = ({ emailQuota: subscriptionClaim.emailQuota, apiRateLimits: subscriptionClaim.apiRateLimits, apiQuota: subscriptionClaim.apiQuota, + emailTransportId: subscriptionClaim.emailTransportId ?? null, }, }); + const { data: transportsData } = trpc.admin.emailTransport.find.useQuery({ perPage: 100 }); + const transports = transportsData?.data ?? []; + const NONE_VALUE = '__none__'; + return (
@@ -238,6 +245,40 @@ export const SubscriptionClaimForm = ({ + ( + + + Email transport + + + + Plans without a transport use the system default mailer. + + + + )} + /> + {formSubmitTrigger} diff --git a/apps/remix/app/components/tables/admin-email-transports-table.tsx b/apps/remix/app/components/tables/admin-email-transports-table.tsx new file mode 100644 index 000000000..9b4fff07f --- /dev/null +++ b/apps/remix/app/components/tables/admin-email-transports-table.tsx @@ -0,0 +1,179 @@ +import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params'; +import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params'; +import { trpc } from '@documenso/trpc/react'; +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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from '@documenso/ui/primitives/dropdown-menu'; +import { Skeleton } from '@documenso/ui/primitives/skeleton'; +import { TableCell } from '@documenso/ui/primitives/table'; +import { Trans, useLingui } from '@lingui/react/macro'; +import { EditIcon, MoreHorizontalIcon, SendIcon, Trash2Icon } from 'lucide-react'; +import { useMemo } from 'react'; +import { useSearchParams } from 'react-router'; + +import { EmailTransportDeleteDialog } from '../dialogs/email-transport-delete-dialog'; +import { EmailTransportSendTestDialog } from '../dialogs/email-transport-send-test-dialog'; +import { EmailTransportUpdateDialog } from '../dialogs/email-transport-update-dialog'; + +export const AdminEmailTransportsTable = () => { + const { t, i18n } = useLingui(); + + const [searchParams] = useSearchParams(); + const updateSearchParams = useUpdateSearchParams(); + + const parsedSearchParams = ZUrlSearchParamsSchema.parse(Object.fromEntries(searchParams ?? [])); + + const { data, isLoading, isLoadingError } = trpc.admin.emailTransport.find.useQuery({ + query: parsedSearchParams.query, + page: parsedSearchParams.page, + perPage: parsedSearchParams.perPage, + }); + + const onPaginationChange = (page: number, perPage: number) => { + updateSearchParams({ + page, + perPage, + }); + }; + + const results = data ?? { + data: [], + perPage: 20, + currentPage: 1, + totalPages: 1, + }; + + const columns = useMemo(() => { + return [ + { + header: t`Name`, + accessorKey: 'name', + }, + { + header: t`Type`, + accessorKey: 'type', + }, + { + header: t`From`, + cell: ({ row }) => `${row.original.fromName} <${row.original.fromAddress}>`, + }, + { + header: t`Used by claims`, + cell: ({ row }) => row.original._count.subscriptionClaims + row.original._count.organisationClaims, + }, + { + header: t`Created`, + accessorKey: 'createdAt', + cell: ({ row }) => i18n.date(row.original.createdAt), + }, + { + id: 'actions', + cell: ({ row }) => ( + + + + + + + + Actions + + + e.preventDefault()}> +
+ + Edit +
+ + } + /> + + e.preventDefault()}> +
+ + Send test +
+ + } + /> + + e.preventDefault()}> +
+ + Delete +
+ + } + /> +
+
+ ), + }, + ] satisfies DataTableColumnDef<(typeof results)['data'][number]>[]; + }, []); + + return ( +
+ + + + + + + + + + + + + + + + + +
+ +
+
+ + ), + }} + > + {(table) => } +
+
+ ); +}; diff --git a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx index af00d92cb..ef3e23781 100644 --- a/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/admin+/_layout.tsx @@ -129,6 +129,17 @@ export default function AdminLayout({ loaderData }: Route.ComponentProps) { + +