diff --git a/package-lock.json b/package-lock.json index 10a9f44b..f65ede85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "semantic-release": "^25.0.2" }, "engines": { - "node": ">=20.0.0" + "node": ">=24.0.0" } }, "node_modules/@acemir/cssom": { @@ -5726,6 +5726,16 @@ "undici-types": "~7.16.0" } }, + "node_modules/@types/nodemailer": { + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -11656,6 +11666,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-package-data": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-6.0.2.tgz", @@ -18278,7 +18297,7 @@ }, "packages/api": { "name": "@DarkAuth/api", - "version": "1.0.0", + "version": "0.0.0", "license": "AGPL-3.0", "dependencies": { "@electric-sql/pglite": "^0.3.15", @@ -18287,6 +18306,7 @@ "drizzle-orm": "^0.45.1", "drizzle-zod": "^0.8.3", "jose": "^6.1.3", + "nodemailer": "^7.0.13", "opaque-ts": "file:../opaque-ts", "pg": "^8.17.1", "pino": "^10.2.0", @@ -18296,13 +18316,14 @@ "devDependencies": { "@biomejs/biome": "^2.3.11", "@types/node": "^25.0.9", + "@types/nodemailer": "^7.0.3", "@types/pg": "^8.16.0", "drizzle-kit": "^0.31.8", "pino-pretty": "^13.1.3", "typescript": "^5.9.3" }, "engines": { - "node": ">=20.0.0" + "node": ">=24.0.0" } }, "packages/brochureware": { @@ -18388,7 +18409,7 @@ }, "packages/darkauth-client": { "name": "@darkauth/client", - "version": "0.2.1", + "version": "1.4.4", "license": "MIT", "dependencies": { "jose": "^6.1.3" @@ -18400,7 +18421,7 @@ }, "packages/demo-app": { "name": "@DarkAuth/demo-app", - "version": "0.1.0", + "version": "0.0.0", "license": "MIT", "dependencies": { "@automerge/automerge": "^3.2.2", diff --git a/packages/admin-ui/src/App.tsx b/packages/admin-ui/src/App.tsx index caf0ff95..d53ee879 100644 --- a/packages/admin-ui/src/App.tsx +++ b/packages/admin-ui/src/App.tsx @@ -20,6 +20,7 @@ import Changelog from "./pages/Changelog"; import ClientEdit from "./pages/ClientEdit"; import Clients from "./pages/Clients"; import Dashboard from "./pages/Dashboard"; +import EmailTemplates from "./pages/EmailTemplates"; import ErrorPage from "./pages/Error"; import Install from "./pages/Install"; import Keys from "./pages/Keys"; @@ -445,6 +446,14 @@ const App = () => { } /> + + + + } + /> definition.key === value); +} + +export default function EmailTemplates() { + const { toast } = useToast(); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [templates, setTemplates] = useState | null>(null); + const [selectedKey, setSelectedKey] = useState("signup_verification"); + const [subject, setSubject] = useState(""); + const [text, setText] = useState(""); + const [html, setHtml] = useState(""); + + const load = useCallback(async () => { + setLoading(true); + try { + const response = await adminApiService.getEmailTemplates(); + setTemplates(response.templates); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Failed to load templates", + variant: "destructive", + }); + } finally { + setLoading(false); + } + }, [toast]); + + useEffect(() => { + load(); + }, [load]); + + const activeTemplate = useMemo(() => { + if (!templates) return null; + return templates[selectedKey] || null; + }, [templates, selectedKey]); + + const activeDefinition = useMemo( + () => TEMPLATE_DEFINITIONS.find((definition) => definition.key === selectedKey) ?? null, + [selectedKey] + ); + + useEffect(() => { + if (!activeTemplate) return; + setSubject(activeTemplate.subject); + setText(activeTemplate.text); + setHtml(activeTemplate.html); + }, [activeTemplate]); + + useEffect(() => { + const tabFromUrl = new URLSearchParams(window.location.search).get("tab"); + if (!tabFromUrl || !isEmailTemplateKey(tabFromUrl)) return; + setSelectedKey(tabFromUrl); + }, []); + + const setTab = (value: string) => { + if (!isEmailTemplateKey(value)) return; + setSelectedKey(value); + const url = new URL(window.location.href); + url.searchParams.set("tab", value); + window.history.replaceState(window.history.state, "", url.toString()); + }; + + const hasChanges = + !!activeTemplate && + (subject !== activeTemplate.subject || + text !== activeTemplate.text || + html !== activeTemplate.html); + + const resetDraft = () => { + if (!activeTemplate) return; + setSubject(activeTemplate.subject); + setText(activeTemplate.text); + setHtml(activeTemplate.html); + }; + + const handleSave = async () => { + if (!hasChanges) return; + setSaving(true); + try { + await adminApiService.updateEmailTemplate(selectedKey, { subject, text, html }); + toast({ title: "Saved", description: `${activeDefinition?.label || "Template"} updated` }); + await load(); + } catch (error) { + toast({ + title: "Error", + description: error instanceof Error ? error.message : "Failed to save template", + variant: "destructive", + }); + } finally { + setSaving(false); + } + }; + + return ( +
+ + + +
+ } + /> + +
+ {loading || !templates ? ( + + Loading templates... + + ) : ( + + + {TEMPLATE_DEFINITIONS.map((definition) => ( + + {definition.label} + + ))} + + + {TEMPLATE_DEFINITIONS.map((definition) => ( + + + + {definition.label} + {definition.description} + + +
+ + setSubject(e.target.value)} + placeholder="Email subject line" + /> +
+ +
+
+ +