From 73e4118901ef7e635352bae26c51d24fbdf45c45 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Fri, 22 May 2026 06:39:26 -0400 Subject: [PATCH] feat(campaigns): create new templates from UI + enable Unlayer template library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two improvements to the template editor: 1. "+ Nouveau" button + creation dialog Users can now create new templates from the editor UI without us re-deploying the hub. Click "Nouveau" next to the template selector, pick a name + prefix + starter (blank or copy from existing), submit. The hub PUTs the new template (existing endpoint, no new code needed on the backend — just relaxed validation). Form: • Type (prefix): gift-email / newsletter / transactional • Name suffix: lowercase letters/digits/dashes (e.g. summer-2026) • Starter: "Vide" or "Copier depuis " On submit: • If starter != blank: GET source template's html + design • PUT new template name with that content • Refresh templates list + switch editor to the new one 2. Backend: replace hardcoded EDITABLE_TEMPLATES allow-list with regex-validated prefix matching + disk scan • EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-'] — bounds what categories users can create • TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ — prevents path traversal • isValidTemplateName() validates both regex + prefix membership • scanEditableTemplates() returns all matching .html/.mjml files currently on disk (excludes .bak-* and .legacy-* variants) • listEditableTemplates() now scans disk instead of a static list, so newly-created templates appear automatically in the dropdown 3. Enable Unlayer's built-in panels • templates: true — exposes Unlayer's template library (limited free-tier selection but ~10-20 starters available without a projectId) • stockImages: true — Unsplash search built into image picker • imageEditor: true — basic crop/resize on inserted images • undoRedo: true — history navigation Co-Authored-By: Claude Opus 4.7 --- .../campaigns/pages/TemplateEditorPage.vue | 105 ++++++++++++++++++ services/targo-hub/lib/campaigns.js | 44 ++++++-- 2 files changed, 138 insertions(+), 11 deletions(-) diff --git a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue index c37c5cb..dac085a 100644 --- a/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue +++ b/apps/ops/src/modules/campaigns/pages/TemplateEditorPage.vue @@ -7,6 +7,9 @@
Éditeur de template
+ + Créer un nouveau template (vide ou copié depuis un existant) + Sauvegardé · {{ lastSavedLabel }} @@ -60,6 +63,40 @@ + + + + + +
Nouveau template
+ + +
+ + + + + + + Le template sera créé sous le nom {{ newTemplateFinal }} + + · contenu copié depuis {{ newTemplateForm.starter }} + + + + + + + +
+
+ @@ -165,6 +202,20 @@ const editorOptions = { displayMode: 'email', // Locale for built-in strings locale: 'fr-CA', + // Enable optional sidebar features (free tier limits some — see Unlayer docs) + features: { + // Built-in Unlayer template library — limited selection without projectId + // but still gives the user some pre-built starts to pick from. + templates: true, + // Unsplash image search panel + stockImages: true, + // Image upload (uses Unlayer's CDN by default; can be wired to our hub + // /campaigns/assets/upload endpoint via customJS later if we want to + // self-host uploads — for now their CDN is fine for ad-hoc images) + imageEditor: true, + // Undo/redo + history + undoRedo: true, + }, // Use Unlayer's free CDN. For paid users this would carry a projectId. // Without a projectId the "Powered by Unlayer" badge shows in the sidebar. } @@ -180,6 +231,60 @@ async function loadAvailableTemplates () { templates.value = await listTemplates() } +// ── New template creation ─────────────────────────────────────────────────── +const newTemplateOpen = ref(false) +const creating = ref(false) +const newTemplateForm = ref({ + prefix: 'gift-email', + suffix: '', + starter: 'blank', // 'blank' | template name to copy from +}) + +const newTemplateFinal = computed(() => { + const { prefix, suffix } = newTemplateForm.value + if (!suffix || !/^[a-z0-9-]+$/.test(suffix)) return '' + return `${prefix}-${suffix}` +}) + +const newTemplateValid = computed(() => + !!newTemplateFinal.value && + !templates.value.find(t => t.name === newTemplateFinal.value), +) + +const starterOptions = computed(() => [ + { label: 'Vide (canevas blanc)', value: 'blank' }, + ...templates.value.map(t => ({ label: `Copier depuis ${t.name}`, value: t.name })), +]) + +async function createTemplate () { + if (!newTemplateValid.value) return + creating.value = true + try { + const newName = newTemplateFinal.value + let html = '
Nouveau template — drag des blocs depuis la sidebar pour commencer.
' + let design = null + if (newTemplateForm.value.starter !== 'blank') { + // Copy html + design from the chosen source template + const src = await getTemplate(newTemplateForm.value.starter) + html = src.html || html + design = src.design || null + } + await saveTemplateApi(newName, html, { design }) + await loadAvailableTemplates() + newTemplateOpen.value = false + // Switch to the new template + currentName.value = newName + router.replace({ path: `/campaigns/templates/${newName}` }) + loadTemplateIntoEditor(newName) + newTemplateForm.value.suffix = '' // reset for next time + $q.notify({ type: 'positive', message: `Template "${newName}" créé`, timeout: 3000 }) + } catch (e) { + $q.notify({ type: 'negative', message: 'Erreur création: ' + e.message }) + } finally { + creating.value = false + } +} + // ── Load template into the Unlayer canvas on editor ready + on switch ────── async function loadTemplateIntoEditor (name) { if (!editorReady.value || !editor.value) return diff --git a/services/targo-hub/lib/campaigns.js b/services/targo-hub/lib/campaigns.js index 8b25e85..09ea6b2 100644 --- a/services/targo-hub/lib/campaigns.js +++ b/services/targo-hub/lib/campaigns.js @@ -652,10 +652,32 @@ const DEFAULT_TEMPLATE = path.join(TEMPLATES_DIR, 'gift-email-fr.html') // gets used at send time. `-simple` variants are flat-structured copies // designed to parse cleanly in GrapesJS visual editor (no nested tables). // Adding a new lang or variant = drop a new .html here + add to this list. -// Canonical templates — both now backed by .mjml source (compiled to .html -// on save for the send-worker). Legacy HTML-only variants were dropped -// after the MJML migration (see git history for rollback). -const EDITABLE_TEMPLATES = ['gift-email-fr', 'gift-email-en'] +// Templates allow-list — moved from a hardcoded array to a regex-validated +// + disk-scan approach so the user can create new templates from the editor +// UI without us re-deploying the hub. The prefix list bounds what names are +// allowed (prevents arbitrary file writes outside the campaign domain). +const EDITABLE_TEMPLATE_PREFIXES = ['gift-email-', 'newsletter-', 'transactional-'] +const TEMPLATE_NAME_RE = /^[a-z0-9-]+$/ // lowercase, digits, dashes only + +function isValidTemplateName (name) { + if (typeof name !== 'string' || !TEMPLATE_NAME_RE.test(name)) return false + return EDITABLE_TEMPLATE_PREFIXES.some(p => name.startsWith(p)) +} + +// Scan the templates dir and return every file matching our allow-list +// prefixes (.html OR .mjml — either format makes it editable). The .json +// design file alone doesn't count (it's a companion). +function scanEditableTemplates () { + if (!fs.existsSync(TEMPLATES_DIR)) return [] + const seen = new Set() + for (const f of fs.readdirSync(TEMPLATES_DIR)) { + const m = f.match(/^([a-z0-9-]+)\.(html|mjml)$/) + if (!m) continue + if (m[1].includes('.bak-') || m[1].includes('.legacy-')) continue // skip backups + if (isValidTemplateName(m[1])) seen.add(m[1]) + } + return [...seen].sort() +} // Resolve the template path for a given recipient language. Falls back to // the French version (most of the customer base) if the language-specific @@ -669,8 +691,8 @@ function templateForLanguage (lang) { } function templatePath (name) { - if (!EDITABLE_TEMPLATES.includes(name)) { - throw new Error(`template not editable: ${name}`) + if (!isValidTemplateName(name)) { + throw new Error(`template name invalid or not allowed: ${name}`) } return path.join(TEMPLATES_DIR, name + '.html') } @@ -679,8 +701,8 @@ function templatePath (name) { // is in MJML mode — the editor loads the .mjml source and on save we // recompile to .html. If only the .html exists, classic HTML mode. function templateMjmlPath (name) { - if (!EDITABLE_TEMPLATES.includes(name)) { - throw new Error(`template not editable: ${name}`) + if (!isValidTemplateName(name)) { + throw new Error(`template name invalid or not allowed: ${name}`) } return path.join(TEMPLATES_DIR, name + '.mjml') } @@ -690,8 +712,8 @@ function templateMjmlPath (name) { // present, the editor uses it as the source of truth on load. Generated // client-side by the editor on save; we just persist it as-is. function templateJsonPath (name) { - if (!EDITABLE_TEMPLATES.includes(name)) { - throw new Error(`template not editable: ${name}`) + if (!isValidTemplateName(name)) { + throw new Error(`template name invalid or not allowed: ${name}`) } return path.join(TEMPLATES_DIR, name + '.json') } @@ -704,7 +726,7 @@ function templateFormat (name) { } function listEditableTemplates () { - return EDITABLE_TEMPLATES.map(name => { + return scanEditableTemplates().map(name => { const format = templateFormat(name) const p = format === 'mjml' ? templateMjmlPath(name) : path.join(TEMPLATES_DIR, name + '.html') let size = 0, modified = null