diff --git a/src/components/dashboard-navigation.ts b/src/components/dashboard-navigation.ts index 450e157..428bb3a 100644 --- a/src/components/dashboard-navigation.ts +++ b/src/components/dashboard-navigation.ts @@ -7,6 +7,7 @@ import { Globe, LayoutDashboard, type LucideIcon, + Mail, Settings, ShieldCheck, Tags, @@ -61,6 +62,7 @@ export const dashboardNavigation = [ items: [ { title: "Groups", url: "/dashboard/azure/groups", icon: Database }, { title: "Members", url: "/dashboard/azure/members", icon: UsersRound }, + { title: "Email templates", url: "/dashboard/azure/email-templates", icon: Mail }, ], }, { diff --git a/src/features/email-templates/email-template-dialogs.tsx b/src/features/email-templates/email-template-dialogs.tsx new file mode 100644 index 0000000..a8854b6 --- /dev/null +++ b/src/features/email-templates/email-template-dialogs.tsx @@ -0,0 +1,179 @@ +import { useServerFn } from "@tanstack/react-start" +import { LoaderCircle, OctagonX } from "lucide-react" +import { useState } from "react" +import { toast } from "sonner" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import type { EmailTemplate } from "@/lib/api/types" +import { errorMessage } from "@/lib/errors" + +import { addEmailTemplate, deleteEmailTemplate, editEmailTemplate } from "./email-templates.functions" + +export function EmailTemplateDialog({ + template, + onClose, + onSaved, +}: { + template: EmailTemplate | null + onClose: () => void + onSaved: (template: EmailTemplate) => void +}) { + const [subject, setSubject] = useState(template?.subject ?? "") + const [body, setBody] = useState(template?.body ?? "") + const [pending, setPending] = useState(false) + const [error, setError] = useState("") + const addEmailTemplateFn = useServerFn(addEmailTemplate) + const editEmailTemplateFn = useServerFn(editEmailTemplate) + + const trimmedSubject = subject.trim() + const trimmedBody = body.trim() + + async function submit(event: React.FormEvent) { + event.preventDefault() + if (!trimmedSubject || !trimmedBody) return + + setPending(true) + setError("") + try { + const saved = template + ? await editEmailTemplateFn({ data: { id: template.id, subject: trimmedSubject, body: trimmedBody } }) + : await addEmailTemplateFn({ data: { subject: trimmedSubject, body: trimmedBody } }) + onSaved(saved) + } catch (cause) { + console.error(cause) + setError(errorMessage(cause, "The template could not be saved. Check your permissions and try again.")) + } finally { + setPending(false) + } + } + + return ( + !open && !pending && onClose()}> + + + + AZURE · EMAIL TEMPLATES + + + {template ? "Edit email template" : "New email template"} + + + {template + ? "Update the predefined subject and body for this template." + : "Save a predefined subject and body that can be selected when emailing members."} + + + void submit(event)}> + + + Subject + setSubject(event.target.value)} + placeholder="e.g. Membership renewal reminder" + required + autoFocus + /> + + + Body + setBody(event.target.value)} + placeholder="Write the email body…" + className="h-56 field-sizing-fixed resize-none overflow-y-auto" + required + /> + + {error && {error}} + + + + Cancel + + + {pending && }{" "} + {template ? "Save changes" : "Create template"} + + + + + + ) +} + +export function DeleteEmailTemplateDialog({ + template, + onClose, + onDeleted, +}: { + template: EmailTemplate + onClose: () => void + onDeleted: (id: number) => void +}) { + const [pending, setPending] = useState(false) + const deleteEmailTemplateFn = useServerFn(deleteEmailTemplate) + + async function remove() { + setPending(true) + try { + await deleteEmailTemplateFn({ data: { id: template.id } }) + onDeleted(template.id) + } catch (error) { + console.error(error) + toast.error("The template could not be deleted. Check your permissions and try again.") + } finally { + setPending(false) + } + } + + return ( + !open && !pending && onClose()}> + + + + + + Delete email template + + Are you sure you want to delete {template.subject}? + This action cannot be undone. + + + + + + Cancel + + void remove()}> + {pending ? : "Confirm"} + + + + + ) +} diff --git a/src/features/email-templates/email-templates-page.tsx b/src/features/email-templates/email-templates-page.tsx new file mode 100644 index 0000000..df71e3b --- /dev/null +++ b/src/features/email-templates/email-templates-page.tsx @@ -0,0 +1,162 @@ +import { useRouter } from "@tanstack/react-router" +import { Mail, Pencil, Plus, Trash2 } from "lucide-react" +import { useEffect, useMemo, useState } from "react" +import { toast } from "sonner" + +import { DataToolbar } from "@/components/data-toolbar" +import { EmptyState } from "@/components/empty-state" +import { Button } from "@/components/ui/button" +import { DataTableHead, Table, TableBody, TableCell, TableHeader, TableRow, TableSurface } from "@/components/ui/table" +import type { EmailTemplate } from "@/lib/api/types" + +import { DeleteEmailTemplateDialog, EmailTemplateDialog } from "./email-template-dialogs" + +function truncate(value: string, length: number) { + return value.length > length ? `${value.slice(0, length).trimEnd()}…` : value +} + +export function EmailTemplatesPage({ + initialTemplates, + canWrite, +}: { + initialTemplates: EmailTemplate[] + canWrite: boolean +}) { + const router = useRouter() + const [templates, setTemplates] = useState(initialTemplates) + const [query, setQuery] = useState("") + const [editing, setEditing] = useState(null) + const [deleting, setDeleting] = useState(null) + + useEffect(() => setTemplates(initialTemplates), [initialTemplates]) + + const filteredTemplates = useMemo(() => { + const normalized = query.trim().toLocaleLowerCase() + return normalized + ? templates.filter((template) => template.subject.toLocaleLowerCase().includes(normalized)) + : templates + }, [templates, query]) + + async function refresh() { + try { + await router.invalidate({ sync: true }) + } catch (error) { + console.error(error) + toast.warning("Your change was saved, but the latest template list could not be refreshed.") + } + } + + return ( + + setEditing("new")}> + Add template + + ) : undefined + } + /> + {filteredTemplates.length ? ( + + + + + Subject + Body + {canWrite && Actions} + + + + {filteredTemplates.map((template) => ( + + + + + + + {template.subject} + + + + {truncate(template.body, 120)} + + {canWrite && ( + + + setEditing(template)} + > + + + setDeleting(template)} + > + + + + + )} + + ))} + + + + ) : ( + setEditing("new")}>Add first template + ) : undefined + } + /> + )} + {editing && ( + setEditing(null)} + onSaved={(saved) => { + setTemplates((current) => + editing === "new" ? [...current, saved] : current.map((t) => (t.id === saved.id ? saved : t)) + ) + setEditing(null) + toast.success(editing === "new" ? "Template created successfully" : "Template updated successfully") + void refresh() + }} + /> + )} + {deleting && ( + setDeleting(null)} + onDeleted={(id) => { + setTemplates((current) => current.filter((template) => template.id !== id)) + setDeleting(null) + toast.success("Template deleted") + void refresh() + }} + /> + )} + + ) +} diff --git a/src/features/email-templates/email-templates.functions.ts b/src/features/email-templates/email-templates.functions.ts new file mode 100644 index 0000000..a97e676 --- /dev/null +++ b/src/features/email-templates/email-templates.functions.ts @@ -0,0 +1,40 @@ +import { createServerFn } from "@tanstack/react-start" + +import { adminMiddleware, writeAdminMiddleware } from "@/server/auth.middleware" + +import { addEmailTemplateInput, deleteEmailTemplateInput, editEmailTemplateInput } from "./email-templates.validation" + +export const listEmailTemplates = createServerFn() + .middleware([adminMiddleware]) + .handler(({ context }) => context.backend.email.templates.getAll.query()) + +export const addEmailTemplate = createServerFn({ method: "POST" }) + .middleware([writeAdminMiddleware]) + .validator(addEmailTemplateInput) + .handler(({ data, context }) => + context.backend.email.templates.add.mutate({ + ...data, + createdBy: context.telegramId, + }) + ) + +export const editEmailTemplate = createServerFn({ method: "POST" }) + .middleware([writeAdminMiddleware]) + .validator(editEmailTemplateInput) + .handler(async ({ data, context }) => { + const result = await context.backend.email.templates.edit.mutate({ + ...data, + modifiedBy: context.telegramId, + }) + if ("error" in result) throw new Error(result.error) + return result + }) + +export const deleteEmailTemplate = createServerFn({ method: "POST" }) + .middleware([writeAdminMiddleware]) + .validator(deleteEmailTemplateInput) + .handler(async ({ data, context }) => { + const result = await context.backend.email.templates.delete.mutate(data) + if (result.error) throw new Error(result.error) + return result + }) diff --git a/src/features/email-templates/email-templates.validation.ts b/src/features/email-templates/email-templates.validation.ts new file mode 100644 index 0000000..fd8bc7f --- /dev/null +++ b/src/features/email-templates/email-templates.validation.ts @@ -0,0 +1,14 @@ +import { z } from "zod" + +export const addEmailTemplateInput = z.object({ + subject: z.string().trim().min(1), + body: z.string().trim().min(1), +}) + +export const editEmailTemplateInput = addEmailTemplateInput.extend({ + id: z.number().int().positive(), +}) + +export const deleteEmailTemplateInput = z.object({ + id: z.number().int().positive(), +}) diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts index 1c3c8c9..55f36e5 100644 --- a/src/lib/api/types.ts +++ b/src/lib/api/types.ts @@ -20,5 +20,7 @@ export type WebProject = ApiOutput["web"]["projects"]["getAllProjects"][number] export type WaGroup = ApiOutput["wa"]["groups"]["getAll"][number] +export type EmailTemplate = ApiOutput["email"]["templates"]["getAll"][number] + /** A group (Telegram or WhatsApp) with its labels already resolved, from the cross-platform search router. */ export type GroupWithLabels = ApiOutput["groups"]["search"]["getAll"][number] diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index cd4bd23..d1c2d9f 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -27,6 +27,7 @@ import { Route as DashboardTelegramGroupsRouteImport } from './routes/dashboard/ import { Route as DashboardTelegramGrantsRouteImport } from './routes/dashboard/telegram/grants' import { Route as DashboardAzureMembersRouteImport } from './routes/dashboard/azure/members' import { Route as DashboardAzureGroupsRouteImport } from './routes/dashboard/azure/groups' +import { Route as DashboardAzureEmailTemplatesRouteImport } from './routes/dashboard/azure/email-templates' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as DashboardWebGroupsByLabelIndexRouteImport } from './routes/dashboard/web/groups-by-label/index' import { Route as DashboardTelegramUsersIndexRouteImport } from './routes/dashboard/telegram/users/index' @@ -125,6 +126,12 @@ const DashboardAzureGroupsRoute = DashboardAzureGroupsRouteImport.update({ path: '/azure/groups', getParentRoute: () => DashboardRoute, } as any) +const DashboardAzureEmailTemplatesRoute = + DashboardAzureEmailTemplatesRouteImport.update({ + id: '/azure/email-templates', + path: '/azure/email-templates', + getParentRoute: () => DashboardRoute, + } as any) const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({ id: '/api/auth/$', path: '/api/auth/$', @@ -170,6 +177,7 @@ export interface FileRoutesByFullPath { '/onboarding/unauthorized': typeof OnboardingUnauthorizedRoute '/dashboard/': typeof DashboardIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/dashboard/azure/email-templates': typeof DashboardAzureEmailTemplatesRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute @@ -195,6 +203,7 @@ export interface FileRoutesByTo { '/onboarding/unauthorized': typeof OnboardingUnauthorizedRoute '/dashboard': typeof DashboardIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/dashboard/azure/email-templates': typeof DashboardAzureEmailTemplatesRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute @@ -222,6 +231,7 @@ export interface FileRoutesById { '/onboarding/unauthorized': typeof OnboardingUnauthorizedRoute '/dashboard/': typeof DashboardIndexRoute '/api/auth/$': typeof ApiAuthSplatRoute + '/dashboard/azure/email-templates': typeof DashboardAzureEmailTemplatesRoute '/dashboard/azure/groups': typeof DashboardAzureGroupsRoute '/dashboard/azure/members': typeof DashboardAzureMembersRoute '/dashboard/telegram/grants': typeof DashboardTelegramGrantsRoute @@ -250,6 +260,7 @@ export interface FileRouteTypes { | '/onboarding/unauthorized' | '/dashboard/' | '/api/auth/$' + | '/dashboard/azure/email-templates' | '/dashboard/azure/groups' | '/dashboard/azure/members' | '/dashboard/telegram/grants' @@ -275,6 +286,7 @@ export interface FileRouteTypes { | '/onboarding/unauthorized' | '/dashboard' | '/api/auth/$' + | '/dashboard/azure/email-templates' | '/dashboard/azure/groups' | '/dashboard/azure/members' | '/dashboard/telegram/grants' @@ -301,6 +313,7 @@ export interface FileRouteTypes { | '/onboarding/unauthorized' | '/dashboard/' | '/api/auth/$' + | '/dashboard/azure/email-templates' | '/dashboard/azure/groups' | '/dashboard/azure/members' | '/dashboard/telegram/grants' @@ -455,6 +468,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardAzureGroupsRouteImport parentRoute: typeof DashboardRoute } + '/dashboard/azure/email-templates': { + id: '/dashboard/azure/email-templates' + path: '/azure/email-templates' + fullPath: '/dashboard/azure/email-templates' + preLoaderRoute: typeof DashboardAzureEmailTemplatesRouteImport + parentRoute: typeof DashboardRoute + } '/api/auth/$': { id: '/api/auth/$' path: '/api/auth/$' @@ -530,6 +550,7 @@ interface DashboardRouteChildren { DashboardAccountRoute: typeof DashboardAccountRoute DashboardWebRoute: typeof DashboardWebRouteWithChildren DashboardIndexRoute: typeof DashboardIndexRoute + DashboardAzureEmailTemplatesRoute: typeof DashboardAzureEmailTemplatesRoute DashboardAzureGroupsRoute: typeof DashboardAzureGroupsRoute DashboardAzureMembersRoute: typeof DashboardAzureMembersRoute DashboardTelegramGrantsRoute: typeof DashboardTelegramGrantsRoute @@ -543,6 +564,7 @@ const DashboardRouteChildren: DashboardRouteChildren = { DashboardAccountRoute: DashboardAccountRoute, DashboardWebRoute: DashboardWebRouteWithChildren, DashboardIndexRoute: DashboardIndexRoute, + DashboardAzureEmailTemplatesRoute: DashboardAzureEmailTemplatesRoute, DashboardAzureGroupsRoute: DashboardAzureGroupsRoute, DashboardAzureMembersRoute: DashboardAzureMembersRoute, DashboardTelegramGrantsRoute: DashboardTelegramGrantsRoute, diff --git a/src/routes/dashboard/azure/email-templates.tsx b/src/routes/dashboard/azure/email-templates.tsx new file mode 100644 index 0000000..67b2403 --- /dev/null +++ b/src/routes/dashboard/azure/email-templates.tsx @@ -0,0 +1,18 @@ +import { createFileRoute } from "@tanstack/react-router" + +import { DataPageSkeleton } from "@/components/loading-skeleton" +import { EmailTemplatesPage } from "@/features/email-templates/email-templates-page" +import { listEmailTemplates } from "@/features/email-templates/email-templates.functions" +import { hasWriteAdminRole } from "@/server/authorization" + +export const Route = createFileRoute("/dashboard/azure/email-templates")({ + loader: () => listEmailTemplates(), + pendingComponent: () => , + component: EmailTemplatesRoute, +}) + +function EmailTemplatesRoute() { + const templates = Route.useLoaderData() + const { roles } = Route.useRouteContext() + return +}
+ AZURE · EMAIL TEMPLATES +