diff --git a/AGENTS.md b/AGENTS.md index 125f0b7ec..0620a18f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,8 @@ Main apps: - `apps/dashboard-api`: admin/project management API for the dashboard - `apps/public-api`: public project API for data, auth, storage - `apps/web-dashboard`: React/Vite dashboard +- `apps/consumer`: Consumer Worker for background jobs and webhooks +- `apps/python-service`: Python AI Microservice (FastAPI/Groq) - `packages/common`: shared models, validation, middleware, encryption, DB/model utilities SDKs: @@ -18,6 +20,13 @@ SDKs: Workspace scripts are defined in [package.json](/package.json). +## Production Deployment Endpoints & Domains + +- **Frontend Landing**: `https://urbackend.bitbros.in` +- **Dashboard App**: `https://app.urbackend.bitbros.in` +- **Dashboard API (Internal/Admin)**: `https://api.urbackend.bitbros.in` +- **Public API (SDK & User Auth)**: `https://api.ub.bitbros.in` + ## Important project rules 1. Do not treat `users` like a normal collection. @@ -151,6 +160,12 @@ cd apps/web-dashboard npm run build ``` +Run python service tests: +```bash +cd apps/python-service +pytest +``` + Run SDK tests: ```bash # JS Core SDK @@ -169,6 +184,7 @@ pytest Before shipping auth, RLS, or schema changes: - run `apps/public-api` tests - run `apps/dashboard-api` tests +- run `apps/python-service` tests - run `@urbackend/sdk` tests - run `@urbackend/react` tests - run `apps/web-dashboard` lint diff --git a/apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx b/apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx index 25e9b18f6..154e685e3 100644 --- a/apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx +++ b/apps/web-dashboard/src/components/Auth/SocialAuthConfig.jsx @@ -1,7 +1,11 @@ import React from 'react'; -import { Settings } from 'lucide-react'; +import { Settings, ExternalLink } from 'lucide-react'; +import { useNavigate, useParams } from 'react-router-dom'; const SocialAuthConfig = ({ authProviders, onOpenModal, setSelectedProvider }) => { + const navigate = useNavigate(); + const { projectId } = useParams(); + return (
@@ -13,9 +17,19 @@ const SocialAuthConfig = ({ authProviders, onOpenModal, setSelectedProvider }) = Configure third-party login providers for your application.

- +
+ + +
diff --git a/apps/web-dashboard/src/components/Settings/AllowedDomainsForm.jsx b/apps/web-dashboard/src/components/Settings/AllowedDomainsForm.jsx new file mode 100644 index 000000000..954f05a3b --- /dev/null +++ b/apps/web-dashboard/src/components/Settings/AllowedDomainsForm.jsx @@ -0,0 +1,109 @@ +import React, { useState, useEffect } from "react"; +import api from "../../utils/api"; +import toast from "react-hot-toast"; +import { Globe, Plus, AlertTriangle, X } from "lucide-react"; +import { SettingsCard } from "./formPrimitives"; +import { inputStyle } from "../../utils/styles"; + +export default function AllowedDomainsForm({ project, projectId, onProjectUpdate, role }) { + const isViewer = role === 'viewer'; + const [domains, setDomains] = useState(project?.allowedDomains || []); + const [newDomain, setNewDomain] = useState(""); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (project?.allowedDomains) { + Promise.resolve().then(() => setDomains(project.allowedDomains)); + } + }, [project?.allowedDomains]); + + const handleUpdate = async (updatedDomains) => { + setLoading(true); + try { + await api.patch(`/api/projects/${projectId}/allowed-domains`, { domains: updatedDomains }); + toast.success("Allowed domains updated!"); + setDomains(updatedDomains); + onProjectUpdate((prev) => ({ ...prev, allowedDomains: updatedDomains })); + } catch (err) { + toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to update allowed domains"); + } finally { + setLoading(false); + } + }; + + const addDomain = () => { + let domain = newDomain.trim(); + if (!domain) return; + if (domain !== "*" && domain.endsWith("/")) domain = domain.slice(0, -1); + if (domains.includes(domain)) return toast.error("Domain already added"); + const updated = domain === "*" ? ["*"] : [...domains.filter(d => d !== "*"), domain]; + handleUpdate(updated); + setNewDomain(""); + }; + + const removeDomain = (d) => handleUpdate(domains.filter(x => x !== d)); + + return ( + +

+ Restrict which websites can send requests using your Publishable API Key.{' '} + Use * to allow all, or specify like https://example.com. +

+ + {!isViewer && ( +
+ setNewDomain(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addDomain(); } }} + style={{ ...inputStyle, flex: 1 }} + /> + +
+ )} + + {domains.length === 0 ? ( +
+ No domains configured — your publishable key won't work on the web. Add * to allow all. +
+ ) : ( +
+ {domains.map((domain) => ( +
+
+ {domain === "*" ? ( + + ALLOW ALL (*) + + ) : ( + {domain} + )} +
+ {!isViewer && ( + + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx b/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx new file mode 100644 index 000000000..a47f6e763 --- /dev/null +++ b/apps/web-dashboard/src/components/Settings/DatabaseConfigForm.jsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from "react"; +import api from "../../utils/api"; +import toast from "react-hot-toast"; +import { Database, CheckCircle, Server, Copy } from "lucide-react"; +import ConfirmationModal from "../../pages/ConfirmationModal"; +import { SettingsCard } from "./formPrimitives"; +import { inputStyle } from "../../utils/styles"; + +export default function DatabaseConfigForm({ project, projectId, onProjectUpdate, role }) { + const isViewer = role === 'viewer'; + const [dbUri, setDbUri] = useState(""); + const [loading, setLoading] = useState(false); + const isConfigured = project?.resources?.db?.isExternal || false; + const [showForm, setShowForm] = useState(!isConfigured); + const [showRemoveModal, setShowRemoveModal] = useState(false); + const [serverIp, setServerIp] = useState(null); + + useEffect(() => { + let isMounted = true; + Promise.resolve().then(() => setShowForm(!(project?.resources?.db?.isExternal || false))); + const fetchIp = async () => { + try { + const res = await api.get(`/api/server-ip`); + if (isMounted) setServerIp(res.data.ip); + } + catch (e) { console.error("Failed to fetch server IP", e); } + }; + fetchIp(); + return () => { isMounted = false; }; + }, [project]); + + const copyIp = async () => { + if (serverIp && navigator?.clipboard) { + try { + await navigator.clipboard.writeText(serverIp); + toast.success("Server IP copied!"); + } catch { + toast.error("Failed to copy server IP"); + } + } + }; + + const handleUpdate = async () => { + if (!dbUri) return toast.error("Database URI is required"); + setLoading(true); + try { + await api.patch(`/api/projects/${projectId}/byod-config`, { dbUri }); + toast.success("Database configuration updated!"); + setShowForm(false); + setDbUri(""); + } catch (err) { + const errorMsg = err.response?.data?.message || err.response?.data?.error || "Failed to update DB config"; + if (errorMsg.includes("whitelist Server IP")) { + toast.error(
Access Denied!
{errorMsg}
, { duration: 6000 }); + } else { + toast.error(errorMsg); + } + } finally { + setLoading(false); + } + }; + + const executeRemove = async () => { + try { + await api.delete(`/api/projects/${projectId}/byod-config/db`, { data: { projectId } }); + toast.success("External database configuration removed!"); + onProjectUpdate(prev => ({ ...prev, resources: { ...prev.resources, db: { ...prev.resources.db, isExternal: false } } })); + setShowForm(true); + } catch (err) { + toast.error(err.response?.data?.message || err.response?.data?.error || "Failed to remove DB config"); + } finally { + setLoading(false); + } + }; + + return ( + + {showRemoveModal && ( + { executeRemove(); setShowRemoveModal(false); }} + onCancel={() => setShowRemoveModal(false)} + /> + )} +

Connect your own MongoDB cluster for full data ownership.

+ + {isConfigured && !showForm ? ( +
+
+ Connected to external MongoDB +
+ {!isViewer && ( +
+ + +
+ )} +
+ ) : ( +
+
+ + setDbUri(e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> +
+
+
+ + Public IP: {serverIp || "..."} + {serverIp && ( + + )} +
+ {!isViewer && ( +
+ {isConfigured && } + +
+ )} +
+
+ )} +
+ ); +} diff --git a/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx b/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx new file mode 100644 index 000000000..3b5508b3b --- /dev/null +++ b/apps/web-dashboard/src/components/Settings/IntegrationsSettings.jsx @@ -0,0 +1,467 @@ +import React, { useState, useEffect } from 'react'; +import { + Database, Shield, Mail, HardDrive, Check, Copy, Server +} from 'lucide-react'; +import api from '../../utils/api'; +import toast from 'react-hot-toast'; +import { PUBLIC_API_URL } from '../../config'; +import { FormField } from './formPrimitives'; +import { inputStyle } from '../../utils/styles'; +import DatabaseConfigForm from './DatabaseConfigForm'; +import StorageConfigForm from './StorageConfigForm'; +import MailTemplatesForm from './MailTemplatesForm'; + +function IntegrationSectionCard({ title, description, icon: Icon, iconColor = 'var(--color-primary)', accentColor, children, style = {} }) { + return ( +
+ {accentColor && ( +
+ )} +
+ {title && ( +
+
+ {Icon && } +

{title}

+
+ {description && ( +

+ {description} +

+ )} +
+ )} + {children} +
+
+ ); +} + +export default function IntegrationsSettings({ + project, + projectId, + onProjectUpdate, + role, + hasResendKey, + resendKeyValue, + setResendKeyValue, + resendFromEmailValue, + setResendFromEmailValue, + resendKeyLoading, + handleResendKeySave, +}) { + const isViewer = role === 'viewer'; + + // Auth Providers State + const [authProviders, setAuthProviders] = useState({ + github: { enabled: false, clientId: '', clientSecret: '', hasClientSecret: false }, + google: { enabled: false, clientId: '', clientSecret: '', hasClientSecret: false } + }); + const [isSavingProviders, setIsSavingProviders] = useState(false); + const [activeAuthTab, setActiveAuthTab] = useState('github'); + const [copiedUrl, setCopiedUrl] = useState(null); + + const siteUrl = project?.siteUrl || ''; + const githubCallbackUrl = `${PUBLIC_API_URL}/api/userAuth/social/github/callback`; + const googleCallbackUrl = `${PUBLIC_API_URL}/api/userAuth/social/google/callback`; + + useEffect(() => { + if (project?.authProviders) { + const providers = project.authProviders; + Promise.resolve().then(() => setAuthProviders(providers)); + } + }, [project?.authProviders]); + + const handleProviderFieldChange = (provider, field, value) => { + setAuthProviders((prev) => ({ + ...prev, + [provider]: { ...prev[provider], [field]: value } + })); + }; + + const handleSaveProviders = async () => { + setIsSavingProviders(true); + try { + const payload = { + github: { + enabled: !!authProviders.github.enabled, + clientId: authProviders.github.clientId, + ...(authProviders.github.clientSecret ? { clientSecret: authProviders.github.clientSecret } : {}) + }, + google: { + enabled: !!authProviders.google.enabled, + clientId: authProviders.google.clientId, + ...(authProviders.google.clientSecret ? { clientSecret: authProviders.google.clientSecret } : {}) + } + }; + const res = await api.patch(`/api/projects/${projectId}/auth/providers`, payload); + setAuthProviders(res.data.authProviders); + if (onProjectUpdate) { + onProjectUpdate(prev => ({ ...prev, authProviders: res.data.authProviders })); + } + toast.success('Auth provider settings saved!'); + } catch (err) { + toast.error(err.response?.data?.message || 'Failed to save auth provider settings'); + } finally { + setIsSavingProviders(false); + } + }; + + const copyToClipboard = async (text, type) => { + if (!navigator?.clipboard) { + toast.error('Clipboard access is not available.'); + return; + } + try { + await navigator.clipboard.writeText(text); + setCopiedUrl(type); + toast.success('Callback URL copied!'); + setTimeout(() => setCopiedUrl(null), 2000); + } catch { + toast.error('Failed to copy to clipboard.'); + } + }; + + return ( +
+ + {/* 1. DATABASES SECTION */} + +
+ {/* Primary MongoDB */} + + + {/* Redis (Coming Soon) */} +
+
+
+ +
+

Redis Cache

+

+ High performance memory cache & pub/sub engine. +

+
+
+ + Coming Soon + +
+
+
+
+ + {/* 2. AUTH INTEGRATIONS SECTION */} + +
+ + {/* Site URL warning check */} + {!siteUrl && ( +
+ ⚠️ Site URL is not set: Please set your app's frontend URL in the General Settings so OAuth redirects to /auth/callback correctly. +
+ )} + + {/* Auth Provider Selector Tabs */} +
+ {[ + { id: 'github', label: 'GitHub OAuth', enabled: authProviders.github.enabled, configured: authProviders.github.hasClientSecret }, + { id: 'google', label: 'Google OAuth', enabled: authProviders.google.enabled, configured: authProviders.google.hasClientSecret } + ].map((prov) => ( + + ))} +
+ + {/* Active Provider Form */} + {activeAuthTab === 'github' && ( +
+
+
+

GitHub OAuth Integration

+

+ Allow users to log in with their GitHub account. +

+
+ +
+ + +
+ + +
+
+ +
+ + handleProviderFieldChange('github', 'clientId', e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + + + + Client Secret{' '} + + • {authProviders.github.hasClientSecret ? 'Configured (encrypted)' : 'Not set'} + + + } + > + handleProviderFieldChange('github', 'clientSecret', e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + +
+
+ )} + + {activeAuthTab === 'google' && ( +
+
+
+

Google OAuth Integration

+

+ Allow users to log in with Google accounts. +

+
+ +
+ + +
+ + +
+
+ +
+ + handleProviderFieldChange('google', 'clientId', e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + + + + Client Secret{' '} + + • {authProviders.google.hasClientSecret ? 'Configured (encrypted)' : 'Not set'} + + + } + > + handleProviderFieldChange('google', 'clientSecret', e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + +
+
+ )} + + {!isViewer && ( +
+ +
+ )} +
+
+ + {/* 3. MAIL INTEGRATIONS SECTION */} + +
+ {/* Provider Selection Info */} +
+
+ Active Provider: Resend.com (BYOK) + + SendGrid, AWS SES & urMail Engine support are in development. + +
+
+ + {/* Resend BYOK Card */} +
+

Resend.com API Key

+

+ Upload a per-project Resend API key to send mail from your custom domain. +

+
+ + Resend API Key{' '} + + · {hasResendKey ? 'Configured' : 'Not configured'} + + + }> + setResendKeyValue(e.target.value)} + style={{ ...inputStyle, fontFamily: 'monospace' }} + disabled={isViewer} + /> + + Blank defaults to onboarding@resend.dev}> + setResendFromEmailValue(e.target.value)} + style={inputStyle} + disabled={isViewer} + /> + +
+ {!isViewer && ( +
+ +
+ )} +
+ + {/* Mail Templates Sub-form */} + +
+
+ + {/* 4. STORAGE INTEGRATIONS SECTION */} + + + + +
+ ); +} diff --git a/apps/web-dashboard/src/components/Settings/MailTemplatesForm.jsx b/apps/web-dashboard/src/components/Settings/MailTemplatesForm.jsx new file mode 100644 index 000000000..a1814f459 --- /dev/null +++ b/apps/web-dashboard/src/components/Settings/MailTemplatesForm.jsx @@ -0,0 +1,475 @@ +import React, { useState, useEffect, useMemo } from "react"; +import api from "../../utils/api"; +import toast from "react-hot-toast"; +import { Mail, Eye, Pencil, Trash2, Plus, Save, X } from "lucide-react"; +import ConfirmationModal from "../../pages/ConfirmationModal"; +import { SettingsCard, FormField } from "./formPrimitives"; +import { inputStyle } from "../../utils/styles"; + +export default function MailTemplatesForm({ projectId, role }) { + const isViewer = role === 'viewer'; + const [templates, setTemplates] = useState([]); + const [globalTemplates, setGlobalTemplates] = useState([]); + const [loading, setLoading] = useState(true); + + const [editorOpen, setEditorOpen] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); + + const [saving, setSaving] = useState(false); + const [activeTemplate, setActiveTemplate] = useState(null); + const [editingId, setEditingId] = useState(null); + + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + + const [form, setForm] = useState({ key: "", name: "", subject: "", html: "", text: "" }); + const [variablesText, setVariablesText] = useState('{\n "name": "John"\n}'); + + const fetchTemplates = async () => { + setLoading(true); + try { + const [projectRes, globalRes] = await Promise.all([ + api.get(`/api/projects/${projectId}/mail/templates`), + api.get(`/api/projects/${projectId}/mail/templates/global`), + ]); + setTemplates(projectRes.data?.data?.templates || []); + setGlobalTemplates(globalRes.data?.data?.templates || []); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to load templates"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + let isMounted = true; + Promise.resolve().then(() => { + if (isMounted) fetchTemplates(); + }); + return () => { isMounted = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); + + const fetchTemplateDetails = async (templateId) => { + const res = await api.get(`/api/projects/${projectId}/mail/templates/${templateId}`); + return res.data?.data?.template; + }; + + const openCreate = () => { + setEditingId(null); + setActiveTemplate(null); + setForm({ key: "", name: "", subject: "", html: "", text: "" }); + setEditorOpen(true); + }; + + const openEdit = async (t) => { + try { + const full = await fetchTemplateDetails(t.id); + setEditingId(t.id); + setActiveTemplate(full); + setForm({ + key: full?.key || "", + name: full?.name || "", + subject: full?.subject || "", + html: full?.html || "", + text: full?.text || "", + }); + setEditorOpen(true); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to load template"); + } + }; + + const openPreview = async (t) => { + try { + const full = await fetchTemplateDetails(t.id); + setActiveTemplate(full); + setPreviewOpen(true); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to load template"); + } + }; + + const closeEditor = () => { + setEditorOpen(false); + setSaving(false); + }; + + const closePreview = () => { + setPreviewOpen(false); + }; + + const handleSave = async () => { + if (!form.name.trim()) return toast.error("Template name is required"); + if (!form.subject.trim()) return toast.error("Subject is required"); + const hasBody = (form.html && form.html.trim()) || (form.text && form.text.trim()); + if (!hasBody) return toast.error("Provide at least one of html or text"); + + setSaving(true); + try { + if (editingId) { + const payload = { + name: form.name, + subject: form.subject, + html: form.html, + text: form.text, + }; + const nextKey = String(form.key ?? "").trim(); + const prevKey = String(activeTemplate?.key ?? "").trim(); + if (nextKey && nextKey !== prevKey) { + payload.key = nextKey; + } + + await api.patch(`/api/projects/${projectId}/mail/templates/${editingId}`, payload); + toast.success("Template updated"); + } else { + await api.post(`/api/projects/${projectId}/mail/templates`, { + key: form.key, + name: form.name, + subject: form.subject, + html: form.html, + text: form.text, + }); + toast.success("Template created"); + } + + closeEditor(); + await fetchTemplates(); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to save template"); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + const templateId = confirmDeleteId; + if (!templateId) return; + try { + await api.delete(`/api/projects/${projectId}/mail/templates/${templateId}`); + toast.success("Template deleted"); + setConfirmDeleteId(null); + await fetchTemplates(); + } catch (err) { + toast.error(err.response?.data?.message || "Failed to delete template"); + } + }; + + const formatDate = (iso) => { + if (!iso) return ""; + try { + return new Date(iso).toLocaleString(); + } catch { + return String(iso); + } + }; + + const { variablesError, preview } = useMemo(() => { + const escapeHtml = (value) => { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + const getVarByPath = (vars, path) => { + if (!vars || typeof vars !== "object") return ""; + const parts = String(path || "") + .split(".") + .map((p) => p.trim()) + .filter(Boolean); + + let cur = vars; + for (const p of parts) { + if (cur && typeof cur === "object" && p in cur) { + cur = cur[p]; + } else { + return ""; + } + } + return cur ?? ""; + }; + + const renderTemplateString = (template, vars, { mode }) => { + if (typeof template !== "string" || !template) return template; + const isHtml = mode === "html"; + + let out = template.replace(/\{\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}\}/g, (_, key) => { + const v = getVarByPath(vars, key); + return String(v ?? ""); + }); + + out = out.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (_, key) => { + const v = getVarByPath(vars, key); + const s = String(v ?? ""); + return isHtml ? escapeHtml(s) : s; + }); + + return out; + }; + + let vars = {}; + let nextVariablesError = null; + + try { + vars = variablesText && variablesText.trim() ? JSON.parse(variablesText) : {}; + if (vars === null || typeof vars !== "object" || Array.isArray(vars)) { + nextVariablesError = "Variables must be a JSON object"; + vars = {}; + } + } catch { + nextVariablesError = "Invalid JSON"; + vars = {}; + } + + const nextPreview = activeTemplate + ? { + subject: renderTemplateString(activeTemplate.subject || "", vars, { mode: "text" }), + html: renderTemplateString(activeTemplate.html || "", vars, { mode: "html" }), + text: renderTemplateString(activeTemplate.text || "", vars, { mode: "text" }), + } + : { subject: "", html: "", text: "" }; + + return { variablesError: nextVariablesError, preview: nextPreview }; + }, [variablesText, activeTemplate]); + + return ( + <> + {confirmDeleteId && ( + setConfirmDeleteId(null)} + /> + )} + + +

+ Create reusable subjects/bodies and preview how they render with variables. Use {'{{name}}'} (escaped) or {'{{{name}}}'} (raw) inside HTML. +

+ + {loading ? ( +
+ ) : ( + <> + {/* Global templates (read-only) */} +
+
+
Global templates
+
Use templateName = key
+
+ + {globalTemplates.length === 0 ? ( +
+ No global templates available. +
+ ) : ( +
+ {globalTemplates.map((t) => ( +
+
+
+ {t.name} + {t.key && ( + {t.key} + )} +
+
{formatDate(t.updatedAt)}
+
+
+ {t.subject} +
+
+ +
+
+ ))} +
+ )} +
+ +
+ + {/* Project templates (editable) */} +
+
Project templates
+ {!isViewer && ( + + )} +
+ + {templates.length === 0 ? ( +
+ No project templates yet. +
+ ) : ( +
+ {templates.map((t) => ( +
+
+
+ {t.name} + {t.key && ( + {t.key} + )} +
+
{formatDate(t.updatedAt)}
+
+
+ {t.subject} +
+
+ + {!isViewer && ( + <> + + + + )} +
+
+ ))} +
+ )} + + )} + + + {/* Editor Modal */} + {editorOpen && ( +
+
e.stopPropagation()} + > + + +
+

{editingId ? 'Edit Template' : 'New Template'}

+

+ Use variables like {'{{user.name}}'}. In HTML, {'{{{rawHtml}}}'} inserts raw. +

+
+ +
+ Use this in API/SDK as templateName. If blank, it’s auto-generated.}> + { + const nextKey = e.target.value; + setForm((p) => ({ + ...p, + key: nextKey.trim() === "" ? undefined : nextKey, + })); + }} + style={{ ...inputStyle, fontFamily: 'monospace' }} + /> + + + setForm((p) => ({ ...p, name: e.target.value }))} style={inputStyle} /> + +
+ + setForm((p) => ({ ...p, subject: e.target.value }))} style={inputStyle} /> + +
+
+ +
+ +