@intranefr/superbackend 1.4.3 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.env.example +6 -1
  2. package/README.md +5 -5
  3. package/index.js +23 -5
  4. package/package.json +5 -2
  5. package/public/sdk/ui-components.iife.js +191 -0
  6. package/sdk/error-tracking/browser/package.json +4 -3
  7. package/sdk/error-tracking/browser/src/embed.js +29 -0
  8. package/sdk/ui-components/browser/src/index.js +228 -0
  9. package/src/controllers/admin.controller.js +139 -1
  10. package/src/controllers/adminHeadless.controller.js +82 -0
  11. package/src/controllers/adminMigration.controller.js +5 -1
  12. package/src/controllers/adminScripts.controller.js +229 -0
  13. package/src/controllers/adminTerminals.controller.js +39 -0
  14. package/src/controllers/adminUiComponents.controller.js +315 -0
  15. package/src/controllers/adminUiComponentsAi.controller.js +34 -0
  16. package/src/controllers/orgAdmin.controller.js +286 -0
  17. package/src/controllers/uiComponentsPublic.controller.js +118 -0
  18. package/src/middleware/auth.js +7 -0
  19. package/src/middleware.js +119 -0
  20. package/src/models/HeadlessModelDefinition.js +10 -0
  21. package/src/models/ScriptDefinition.js +42 -0
  22. package/src/models/ScriptRun.js +22 -0
  23. package/src/models/UiComponent.js +29 -0
  24. package/src/models/UiComponentProject.js +26 -0
  25. package/src/models/UiComponentProjectComponent.js +18 -0
  26. package/src/routes/admin.routes.js +2 -0
  27. package/src/routes/adminHeadless.routes.js +6 -0
  28. package/src/routes/adminScripts.routes.js +21 -0
  29. package/src/routes/adminTerminals.routes.js +13 -0
  30. package/src/routes/adminUiComponents.routes.js +29 -0
  31. package/src/routes/llmUi.routes.js +26 -0
  32. package/src/routes/orgAdmin.routes.js +5 -0
  33. package/src/routes/uiComponentsPublic.routes.js +9 -0
  34. package/src/services/consoleOverride.service.js +291 -0
  35. package/src/services/email.service.js +17 -1
  36. package/src/services/headlessExternalModels.service.js +292 -0
  37. package/src/services/headlessModels.service.js +26 -6
  38. package/src/services/scriptsRunner.service.js +259 -0
  39. package/src/services/terminals.service.js +152 -0
  40. package/src/services/terminalsWs.service.js +100 -0
  41. package/src/services/uiComponentsAi.service.js +312 -0
  42. package/src/services/uiComponentsCrypto.service.js +39 -0
  43. package/src/services/webhook.service.js +2 -2
  44. package/src/services/workflow.service.js +1 -1
  45. package/src/utils/encryption.js +5 -3
  46. package/views/admin-coolify-deploy.ejs +1 -1
  47. package/views/admin-dashboard-home.ejs +1 -1
  48. package/views/admin-dashboard.ejs +1 -1
  49. package/views/admin-errors.ejs +2 -2
  50. package/views/admin-global-settings.ejs +3 -3
  51. package/views/admin-headless.ejs +294 -24
  52. package/views/admin-json-configs.ejs +8 -1
  53. package/views/admin-llm.ejs +2 -2
  54. package/views/admin-organizations.ejs +365 -9
  55. package/views/admin-scripts.ejs +497 -0
  56. package/views/admin-seo-config.ejs +1 -1
  57. package/views/admin-terminals.ejs +328 -0
  58. package/views/admin-test.ejs +3 -3
  59. package/views/admin-ui-components.ejs +709 -0
  60. package/views/admin-users.ejs +440 -4
  61. package/views/admin-webhooks.ejs +1 -1
  62. package/views/admin-workflows.ejs +1 -1
  63. package/views/partials/admin-assets-script.ejs +3 -3
  64. package/views/partials/dashboard/nav-items.ejs +3 -0
  65. package/views/partials/dashboard/palette.ejs +1 -1
@@ -0,0 +1,312 @@
1
+ const UiComponent = require('../models/UiComponent');
2
+ const { getSettingValue } = require('./globalSettings.service');
3
+ const llmService = require('./llm.service');
4
+ const { createAuditEvent } = require('./audit.service');
5
+
6
+ const ALLOWED_FIELDS = new Set(['html', 'css', 'js', 'usageMarkdown']);
7
+
8
+ function normalizeTargets(targets) {
9
+ const t = targets && typeof targets === 'object' ? targets : {};
10
+ const out = {};
11
+ for (const f of ALLOWED_FIELDS) out[f] = Boolean(t[f]);
12
+ if (!Object.values(out).some(Boolean)) {
13
+ // default: all
14
+ for (const f of ALLOWED_FIELDS) out[f] = true;
15
+ }
16
+ return out;
17
+ }
18
+
19
+ function parseFieldPatches(raw) {
20
+ const text = String(raw || '');
21
+ const lines = text.split(/\r?\n/);
22
+ const result = [];
23
+
24
+ let current = null;
25
+ for (const line of lines) {
26
+ const m = line.match(/^FIELD:\s*(.+)$/);
27
+ if (m) {
28
+ if (current) result.push(current);
29
+ current = { field: String(m[1] || '').trim(), content: '' };
30
+ continue;
31
+ }
32
+ if (!current) continue;
33
+ current.content += (current.content ? '\n' : '') + line;
34
+ }
35
+ if (current) result.push(current);
36
+
37
+ return result;
38
+ }
39
+
40
+ function parseDiffBlocks(patchText) {
41
+ const raw = String(patchText || '');
42
+ const blocks = [];
43
+
44
+ const re = /<<<<<<< SEARCH\n([\s\S]*?)\n=======\n([\s\S]*?)\n>>>>>>> REPLACE/g;
45
+ let m;
46
+ while ((m = re.exec(raw)) !== null) {
47
+ blocks.push({ search: m[1], replace: m[2] });
48
+ }
49
+ return blocks;
50
+ }
51
+
52
+ function applyBlocks(currentValue, blocks) {
53
+ let next = String(currentValue || '');
54
+
55
+ for (const b of blocks) {
56
+ const search = String(b.search);
57
+ const replace = String(b.replace);
58
+
59
+ if (search === '__FULL__') {
60
+ next = replace;
61
+ continue;
62
+ }
63
+
64
+ const idx = next.indexOf(search);
65
+ if (idx === -1) {
66
+ const err = new Error('SEARCH block did not match current content');
67
+ err.code = 'AI_INVALID';
68
+ err.meta = { searchPreview: search.slice(0, 120) };
69
+ throw err;
70
+ }
71
+
72
+ next = next.replace(search, replace);
73
+ }
74
+
75
+ return next;
76
+ }
77
+
78
+ function computeWarnings(nextFields) {
79
+ const warnings = [];
80
+ const js = String(nextFields.js || '');
81
+
82
+ const checks = [
83
+ { token: 'eval(', msg: 'JS contains eval( which is unsafe.' },
84
+ { token: 'document.cookie', msg: 'JS references document.cookie.' },
85
+ { token: 'Function(', msg: 'JS contains Function( which may indicate dynamic code execution.' },
86
+ { token: 'fetch(', msg: 'JS uses fetch(. Consider origin allowlists and error handling.' },
87
+ ];
88
+
89
+ for (const c of checks) {
90
+ if (js.includes(c.token)) warnings.push(c.msg);
91
+ }
92
+
93
+ return warnings;
94
+ }
95
+
96
+ async function resolveLlmDefaults({ providerKey, model }) {
97
+ const uiProvider = String(providerKey || '').trim();
98
+ const uiModel = String(model || '').trim();
99
+
100
+ const settingProvider = String(await getSettingValue('uiComponents.ai.providerKey', '') || '').trim();
101
+ const settingModel = String(await getSettingValue('uiComponents.ai.model', '') || '').trim();
102
+
103
+ const envProvider = String(process.env.DEFAULT_LLM_PROVIDER_KEY || '').trim();
104
+ const envModel = String(process.env.DEFAULT_LLM_MODEL || '').trim();
105
+
106
+ const resolvedProviderKey = uiProvider || settingProvider || envProvider;
107
+ if (!resolvedProviderKey) {
108
+ const err = new Error('Missing LLM providerKey (configure uiComponents.ai.providerKey or DEFAULT_LLM_PROVIDER_KEY, or send from UI)');
109
+ err.code = 'VALIDATION';
110
+ throw err;
111
+ }
112
+
113
+ const resolvedModel = uiModel || settingModel || envModel || 'x-ai/grok-code-fast-1';
114
+ return { providerKey: resolvedProviderKey, model: resolvedModel };
115
+ }
116
+
117
+ function buildSystemPrompt({ targets }) {
118
+ const allowed = Array.from(ALLOWED_FIELDS).filter((f) => targets[f]);
119
+
120
+ return [
121
+ 'You are a code editor assistant modifying a UI component stored in a database.',
122
+ `You may edit ONLY these fields: ${allowed.join(', ')}.`,
123
+ 'Return ONLY changes using FIELD-based SEARCH/REPLACE patches.',
124
+ '',
125
+ 'Format:',
126
+ 'FIELD: <fieldName>',
127
+ '<<<<<<< SEARCH',
128
+ '[exact text to find - must match character-by-character including whitespace]',
129
+ '=======',
130
+ '[replacement text]',
131
+ '>>>>>>> REPLACE',
132
+ '',
133
+ 'Rules:',
134
+ '- You can include multiple FIELD sections.',
135
+ '- SEARCH must match exactly (whitespace matters).',
136
+ '- Include enough context (5-10 lines) for unique matching.',
137
+ '- Do not include any text outside FIELD sections and SEARCH/REPLACE blocks.',
138
+ "- If you cannot reliably match the existing text, use SEARCH content '__FULL__' to replace the entire field.",
139
+ '',
140
+ 'JS contract:',
141
+ "- The component JS is executed as new Function('api','templateRootEl','props', js).",
142
+ '- Your JS must return an object with methods.',
143
+ '- Use templateRootEl for DOM queries (do not use document.querySelector without scoping).',
144
+ ].join('\n');
145
+ }
146
+
147
+ async function proposeComponentEdit({
148
+ code,
149
+ prompt,
150
+ providerKey,
151
+ model,
152
+ targets,
153
+ mode,
154
+ actor,
155
+ }) {
156
+ const componentCode = String(code || '').trim().toLowerCase();
157
+ if (!componentCode) {
158
+ const err = new Error('code is required');
159
+ err.code = 'VALIDATION';
160
+ throw err;
161
+ }
162
+
163
+ const instruction = String(prompt || '').trim();
164
+ if (!instruction) {
165
+ const err = new Error('prompt is required');
166
+ err.code = 'VALIDATION';
167
+ throw err;
168
+ }
169
+
170
+ const targetFlags = normalizeTargets(targets);
171
+ const allowedTargets = Object.entries(targetFlags)
172
+ .filter(([, v]) => v)
173
+ .map(([k]) => k);
174
+
175
+ const doc = await UiComponent.findOne({ code: componentCode });
176
+ if (!doc) {
177
+ const err = new Error('Component not found');
178
+ err.code = 'NOT_FOUND';
179
+ throw err;
180
+ }
181
+
182
+ const current = doc.toObject();
183
+
184
+ const llmDefaults = await resolveLlmDefaults({ providerKey, model });
185
+
186
+ const systemPrompt = buildSystemPrompt({ targets: targetFlags });
187
+
188
+ const context = {
189
+ code: current.code,
190
+ name: current.name,
191
+ version: current.version,
192
+ html: String(current.html || ''),
193
+ css: String(current.css || ''),
194
+ js: String(current.js || ''),
195
+ usageMarkdown: String(current.usageMarkdown || ''),
196
+ };
197
+
198
+ const userContextLines = [
199
+ `Component code: ${context.code}`,
200
+ `Component name: ${context.name}`,
201
+ `Component version: ${context.version}`,
202
+ `Target fields: ${allowedTargets.join(', ')}`,
203
+ `Mode: ${String(mode || 'minimal')}`,
204
+ '',
205
+ 'Current fields:',
206
+ '',
207
+ `FIELD: html\n${context.html}`,
208
+ '',
209
+ `FIELD: css\n${context.css}`,
210
+ '',
211
+ `FIELD: js\n${context.js}`,
212
+ '',
213
+ `FIELD: usageMarkdown\n${context.usageMarkdown}`,
214
+ ];
215
+
216
+ const result = await llmService.callAdhoc(
217
+ {
218
+ providerKey: llmDefaults.providerKey,
219
+ model: llmDefaults.model,
220
+ messages: [
221
+ { role: 'system', content: systemPrompt },
222
+ { role: 'user', content: `Instruction:\n${instruction}` },
223
+ { role: 'user', content: userContextLines.join('\n') },
224
+ ],
225
+ promptKeyForAudit: 'uiComponents.ai.propose',
226
+ },
227
+ {
228
+ temperature: String(mode || '').toLowerCase() === 'rewrite' ? 0.6 : 0.3,
229
+ },
230
+ );
231
+
232
+ const raw = String(result.content || '');
233
+ const fieldPatches = parseFieldPatches(raw);
234
+
235
+ const patchByField = new Map();
236
+ for (const fp of fieldPatches) {
237
+ patchByField.set(String(fp.field || '').trim(), fp.content);
238
+ }
239
+
240
+ const nextFields = {
241
+ html: context.html,
242
+ css: context.css,
243
+ js: context.js,
244
+ usageMarkdown: context.usageMarkdown,
245
+ };
246
+
247
+ const appliedFields = [];
248
+
249
+ for (const field of ALLOWED_FIELDS) {
250
+ if (!targetFlags[field]) continue;
251
+ const patchText = patchByField.get(field);
252
+ if (!patchText) continue;
253
+
254
+ const blocks = parseDiffBlocks(patchText);
255
+ if (!blocks.length) {
256
+ const err = new Error(`No diff blocks found for field ${field}`);
257
+ err.code = 'AI_INVALID';
258
+ throw err;
259
+ }
260
+
261
+ nextFields[field] = applyBlocks(nextFields[field], blocks);
262
+ appliedFields.push(field);
263
+ }
264
+
265
+ if (!appliedFields.length) {
266
+ const err = new Error('No applicable field patches returned');
267
+ err.code = 'AI_INVALID';
268
+ throw err;
269
+ }
270
+
271
+ const warnings = computeWarnings(nextFields);
272
+
273
+ await createAuditEvent({
274
+ ...(actor || { actorType: 'system', actorId: null }),
275
+ action: 'uiComponents.ai.propose',
276
+ entityType: 'UiComponent',
277
+ entityId: componentCode,
278
+ before: {
279
+ code: current.code,
280
+ version: current.version,
281
+ },
282
+ after: {
283
+ code: current.code,
284
+ version: current.version,
285
+ appliedFields,
286
+ },
287
+ meta: {
288
+ providerKey: llmDefaults.providerKey,
289
+ model: llmDefaults.model,
290
+ targets: targetFlags,
291
+ mode: String(mode || 'minimal'),
292
+ warnings,
293
+ patchPreview: raw.slice(0, 4000),
294
+ },
295
+ });
296
+
297
+ return {
298
+ component: { code: current.code, version: current.version },
299
+ proposal: {
300
+ patch: raw,
301
+ fields: nextFields,
302
+ appliedFields,
303
+ warnings,
304
+ },
305
+ providerKey: llmDefaults.providerKey,
306
+ model: llmDefaults.model,
307
+ };
308
+ }
309
+
310
+ module.exports = {
311
+ proposeComponentEdit,
312
+ };
@@ -0,0 +1,39 @@
1
+ const crypto = require('crypto');
2
+
3
+ function base64UrlEncode(buf) {
4
+ return buf
5
+ .toString('base64')
6
+ .replace(/\+/g, '-')
7
+ .replace(/\//g, '_')
8
+ .replace(/=+$/g, '');
9
+ }
10
+
11
+ function generateProjectApiKeyPlaintext() {
12
+ const raw = crypto.randomBytes(32);
13
+ return `uk_${base64UrlEncode(raw)}`;
14
+ }
15
+
16
+ function hashKey(plaintext) {
17
+ return crypto.createHash('sha256').update(String(plaintext)).digest('hex');
18
+ }
19
+
20
+ function timingSafeEqualHex(a, b) {
21
+ const aBuf = Buffer.from(String(a), 'hex');
22
+ const bBuf = Buffer.from(String(b), 'hex');
23
+ if (aBuf.length !== bBuf.length) return false;
24
+ return crypto.timingSafeEqual(aBuf, bBuf);
25
+ }
26
+
27
+ function verifyKey(plaintext, expectedHash) {
28
+ const provided = String(plaintext || '').trim();
29
+ if (!provided) return false;
30
+ const providedHash = hashKey(provided);
31
+ return timingSafeEqualHex(providedHash, expectedHash);
32
+ }
33
+
34
+ module.exports = {
35
+ generateProjectApiKeyPlaintext,
36
+ hashKey,
37
+ timingSafeEqualHex,
38
+ verifyKey,
39
+ };
@@ -57,7 +57,7 @@ class WebhookService {
57
57
  headers: {
58
58
  'Content-Type': 'application/json',
59
59
  'X-SaaS-Signature': signature,
60
- 'User-Agent': 'SaaSBackend-Webhook/1.0'
60
+ 'User-Agent': 'SuperBackend-Webhook/1.0'
61
61
  },
62
62
  timeout: timeout
63
63
  }).catch(err => {
@@ -87,7 +87,7 @@ class WebhookService {
87
87
  headers: {
88
88
  'Content-Type': 'application/json',
89
89
  'X-SaaS-Signature': signature,
90
- 'User-Agent': 'SaaSBackend-Webhook/1.0'
90
+ 'User-Agent': 'SuperBackend-Webhook/1.0'
91
91
  },
92
92
  timeout: timeout
93
93
  });
@@ -5,7 +5,7 @@ const { NodeVM } = require('vm2');
5
5
 
6
6
  /**
7
7
  * Workflow Service
8
- * Handles execution of stacked workflow nodes within SaaSBackend.
8
+ * Handles execution of stacked workflow nodes within SuperBackend.
9
9
  */
10
10
  class WorkflowService {
11
11
  constructor(workflowId, initialContext = {}) {
@@ -1,9 +1,11 @@
1
1
  const crypto = require('crypto');
2
2
 
3
3
  function getEncryptionKey() {
4
- const raw = process.env.SAASBACKEND_ENCRYPTION_KEY;
4
+ // Try new name first, then fallback to old name for backward compatibility
5
+ const raw = process.env.SUPERBACKEND_ENCRYPTION_KEY || process.env.SAASBACKEND_ENCRYPTION_KEY;
6
+
5
7
  if (!raw) {
6
- throw new Error('SAASBACKEND_ENCRYPTION_KEY is required for encrypted settings');
8
+ throw new Error('SUPERBACKEND_ENCRYPTION_KEY (or SAASBACKEND_ENCRYPTION_KEY for compatibility) is required for encrypted settings');
7
9
  }
8
10
 
9
11
  let key;
@@ -23,7 +25,7 @@ function getEncryptionKey() {
23
25
 
24
26
  if (key.length !== 32) {
25
27
  throw new Error(
26
- 'SAASBACKEND_ENCRYPTION_KEY must be 32 bytes (base64-encoded 32 bytes, hex 64 chars, or 32-char utf8)',
28
+ 'SUPERBACKEND_ENCRYPTION_KEY (or SAASBACKEND_ENCRYPTION_KEY) must be 32 bytes (base64-encoded 32 bytes, hex 64 chars, or 32-char utf8)',
27
29
  );
28
30
  }
29
31
 
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Coolify Headless Deploy - SaaSBackend</title>
6
+ <title>Coolify Headless Deploy - SuperBackend</title>
7
7
  <script src="https://cdn.tailwindcss.com"></script>
8
8
  <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
9
9
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
@@ -3,7 +3,7 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>SaaSBackend Command Center</title>
6
+ <title>SuperBackend Command Center</title>
7
7
  <script src="https://cdn.tailwindcss.com"></script>
8
8
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
9
9
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
@@ -19,7 +19,7 @@
19
19
  <div class="flex items-center gap-4">
20
20
  <i class="ti ti-layout-dashboard text-2xl text-blue-600"></i>
21
21
  <h1 class="text-xl font-bold text-gray-800">
22
- Superbackend <span class="text-xs font-normal text-gray-500 ml-2 align-middle">(saasbackend)</span>
22
+ SuperBackend <span class="text-xs font-normal text-gray-500 ml-2 align-middle">(@intranefr/superbackend)</span>
23
23
  </h1>
24
24
  </div>
25
25
  <div class="flex items-center gap-6">
@@ -50,7 +50,7 @@
50
50
  <div>
51
51
  <div class="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Identify user (JWT header)</div>
52
52
  <div class="relative">
53
- <pre class="p-3 bg-gray-50 rounded text-xs overflow-x-auto"><code id="snippet-jwt">saasbackend.errorTracking.config({
53
+ <pre class="p-3 bg-gray-50 rounded text-xs overflow-x-auto"><code id="snippet-jwt">superbackend.errorTracking.config({
54
54
  headers: { authorization: "Bearer XXX" }
55
55
  })</code></pre>
56
56
  <button type="button" class="absolute top-2 right-2 text-xs px-2 py-1 bg-white border border-gray-200 rounded hover:border-blue-500" onclick="copySnippet('snippet-jwt')">Copy</button>
@@ -60,7 +60,7 @@
60
60
  <div>
61
61
  <div class="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-2">Future npm package (bundlers)</div>
62
62
  <div class="relative">
63
- <pre class="p-3 bg-gray-50 rounded text-xs overflow-x-auto"><code id="snippet-npm">import { createErrorTrackingClient } from '@saasbackend/sdk/error-tracking/browser';
63
+ <pre class="p-3 bg-gray-50 rounded text-xs overflow-x-auto"><code id="snippet-npm">import { createErrorTrackingClient } from '@intranefr/superbackend-error-tracking-browser';
64
64
 
65
65
  const client = createErrorTrackingClient({
66
66
  endpoint: '/api/log/error',
@@ -30,7 +30,7 @@
30
30
  <div class="flex justify-between items-center">
31
31
  <div>
32
32
  <h1 class="text-2xl font-bold text-gray-900">Global Settings Manager</h1>
33
- <p class="text-sm text-gray-600 mt-1">Configure system-wide settings for SaaSBackend</p>
33
+ <p class="text-sm text-gray-600 mt-1">Configure system-wide settings for SuperBackend</p>
34
34
  </div>
35
35
  </div>
36
36
  </div>
@@ -66,8 +66,8 @@
66
66
  <h3 class="font-semibold text-blue-900 mb-2">📋 Available Setting Keys</h3>
67
67
  <div class="text-sm text-blue-800 space-y-2">
68
68
  <p><strong>RESEND_API_KEY</strong> (string) - API key for Resend email service</p>
69
- <p><strong>EMAIL_FROM</strong> (string) - Default "From" address for emails (e.g., "SaaSBackend &lt;no-reply@yourdomain.com&gt;")</p>
70
- <p><strong>FRONTEND_URL</strong> (string) - Frontend application URL (e.g., "https://app.saasbackend.com")</p>
69
+ <p><strong>EMAIL_FROM</strong> (string) - Default "From" address for emails (e.g., "SuperBackend &lt;no-reply@yourdomain.com&gt;")</p>
70
+ <p><strong>FRONTEND_URL</strong> (string) - Frontend application URL (e.g., "https://app.superbackend.com")</p>
71
71
  <p><strong>EMAIL_PASSWORD_RESET_SUBJECT</strong> (string) - Subject line for password reset emails</p>
72
72
  <p><strong>EMAIL_PASSWORD_RESET_HTML</strong> (html) - HTML template for password reset emails</p>
73
73
  <p class="pl-4 text-blue-700">Available variables: <code class="bg-blue-100 px-1 rounded">{{resetUrl}}</code></p>