4bnode 4.1.4 → 4.1.6

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 (70) hide show
  1. package/add-api.js +5 -3
  2. package/add-docs.js +18 -5
  3. package/add-email.js +118 -28
  4. package/add-login.js +35 -16
  5. package/add-mongo-schema.js +13 -5
  6. package/lib/codegen.js +408 -104
  7. package/lib/codegen.test.js +122 -15
  8. package/lib/names.js +1 -1
  9. package/package.json +1 -1
  10. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
  11. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
  12. package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
  13. package/skeleton/.4bnode/assets/js/pages/MailPage.js +221 -71
  14. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  15. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  16. package/skeleton/.4bnode/codegen.js +408 -104
  17. package/skeleton/.4bnode/dev-api.js +384 -30
  18. package/skeleton/.env +6 -0
  19. package/skeleton/.env.example +25 -0
  20. package/skeleton/_gitignore +1 -0
  21. package/skeleton/index.js +7 -2
  22. package/skeleton/src/middleware/apiKey.js +14 -1
  23. package/skeleton/src/middleware/errorHandler.js +7 -2
  24. package/app-1/.4bnode/.passkey +0 -1
  25. package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
  26. package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
  27. package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
  28. package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
  29. package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
  30. package/app-1/.4bnode/assets/images/mongo.png +0 -0
  31. package/app-1/.4bnode/assets/js/App.js +0 -139
  32. package/app-1/.4bnode/assets/js/Root.js +0 -40
  33. package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
  34. package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
  35. package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
  36. package/app-1/.4bnode/assets/js/context.js +0 -7
  37. package/app-1/.4bnode/assets/js/lib.js +0 -113
  38. package/app-1/.4bnode/assets/js/main.js +0 -5
  39. package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
  40. package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
  41. package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
  42. package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
  43. package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
  44. package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
  45. package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
  46. package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -134
  47. package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
  48. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1436
  49. package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
  50. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
  51. package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
  52. package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
  53. package/app-1/.4bnode/codegen.js +0 -1249
  54. package/app-1/.4bnode/dev-api.js +0 -3621
  55. package/app-1/.4bnode/ui.html +0 -1578
  56. package/app-1/README.md +0 -47
  57. package/app-1/ecosystem.config.cjs +0 -25
  58. package/app-1/index.js +0 -145
  59. package/app-1/package.json +0 -35
  60. package/app-1/public/index.html +0 -292
  61. package/app-1/src/db.js +0 -13
  62. package/app-1/src/docs.js +0 -84
  63. package/app-1/src/middleware/errorHandler.js +0 -15
  64. package/app-1/src/middleware/roles.js +0 -14
  65. package/app-1/src/middleware/validate.js +0 -20
  66. package/app-1/src/models/login.js +0 -9
  67. package/app-1/src/openapi.json +0 -62
  68. package/app-1/src/routes/products.js +0 -26
  69. package/app-1/src/validators/products.js +0 -7
  70. package/app-1/src/validators/user.js +0 -7
@@ -3,26 +3,109 @@ import { LuMail, LuCheck, LuSend } from "react-icons/lu";
3
3
  import { html, api } from "../lib.js";
4
4
  import { useApp } from "../context.js";
5
5
 
6
+ // Copy-paste usage shown in the right-hand code panel.
7
+ const USAGE_TABS = [
8
+ {
9
+ id: "basic",
10
+ label: "Basic",
11
+ code: `import { sendMail, emailTemplate } from '../services/mailer.js';
12
+
13
+ // Call from any route handler. Credentials come from .env.
14
+ await sendMail({
15
+ to: 'user@example.com',
16
+ subject: 'Welcome to 4Brains!',
17
+ html: emailTemplate({ title: 'Welcome', body: 'Thanks for signing up.' }),
18
+ // text: 'Plain-text fallback', // optional
19
+ // cc, bcc, replyTo, from // optional
20
+ });`,
21
+ },
22
+ {
23
+ id: "attachment",
24
+ label: "Attachment",
25
+ code: `import { sendMail } from '../services/mailer.js';
26
+
27
+ await sendMail({
28
+ to: 'user@example.com',
29
+ subject: 'Your invoice',
30
+ text: 'Invoice attached.',
31
+ attachments: [
32
+ // a) from a file on disk
33
+ { filename: 'invoice.pdf', path: './invoices/inv-001.pdf' },
34
+ // b) generated content (string or Buffer)
35
+ { filename: 'note.txt', content: 'Generated at ' + new Date().toISOString() },
36
+ // c) an uploaded file (multer) -> req.file
37
+ { filename: req.file.originalname, path: req.file.path },
38
+ ],
39
+ });`,
40
+ },
41
+ {
42
+ id: "ejs",
43
+ label: "Dynamic (EJS)",
44
+ code: `// Any template engine works: render it to an HTML string, pass as \`html\`.
45
+ import { sendMail } from '../services/mailer.js';
46
+ import ejs from 'ejs'; // npm i ejs
47
+ import path from 'path';
48
+
49
+ // views/emails/welcome.ejs:
50
+ // <h1>Hi <%= name %></h1>
51
+ // <p>Verify: <a href="<%= link %>"><%= link %></a></p>
52
+
53
+ const html = await ejs.renderFile(
54
+ path.join(process.cwd(), 'views/emails/welcome.ejs'),
55
+ { name: user.name, link: 'https://app.com/verify/' + token } // dynamic data
56
+ );
57
+
58
+ await sendMail({ to: user.email, subject: 'Welcome', html });
59
+
60
+ // Handlebars / Pug / React Email: same idea — produce an HTML
61
+ // string from your data, then hand it to sendMail({ html }).`,
62
+ },
63
+ ];
64
+
6
65
  export function MailPage() {
7
66
  const { toast, refresh } = useApp();
8
67
  const [info, setInfo] = useState(null);
9
- const [provider, setProvider] = useState("brevo");
10
- const [form, setForm] = useState({ host: "", port: 587, secure: false, user: "", pass: "", from: "" });
68
+ const [provider, setProvider] = useState("resend");
69
+ const [form, setForm] = useState({
70
+ apiKey: "", resendFrom: "",
71
+ host: "", port: 587, secure: false, user: "", pass: "", smtpFrom: "",
72
+ });
11
73
  const [busy, setBusy] = useState(false);
74
+ const [configError, setConfigError] = useState("");
75
+ const [domainPassword, setDomainPassword] = useState("");
12
76
  const [testTo, setTestTo] = useState("");
13
77
  const [testing, setTesting] = useState(false);
78
+ const [copied, setCopied] = useState(false);
79
+ const [tab, setTab] = useState("basic");
80
+
81
+ const activeTab = USAGE_TABS.find((t) => t.id === tab) || USAGE_TABS[0];
82
+
83
+ function copyUsage() {
84
+ try {
85
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(activeTab.code);
86
+ } catch (e) {}
87
+ setCopied(true);
88
+ setTimeout(() => setCopied(false), 1200);
89
+ }
14
90
 
15
91
  const load = useCallback(async () => {
16
92
  try {
17
93
  const r = await api("/mail");
18
94
  setInfo(r);
95
+ // Persisted failure from the last save survives reloads until it's fixed.
96
+ setConfigError(r.configured && r.verified === false
97
+ ? "The saved email configuration failed its last connection test. Update the details and save again."
98
+ : "");
99
+ setProvider(r.provider || "resend");
100
+ const rs = r.resend || {}, sm = r.smtp || {};
19
101
  setForm((f) => ({
20
102
  ...f,
21
- host: r.host || f.host,
22
- port: r.port || f.port || 587,
23
- secure: !!r.secure,
24
- user: r.user || f.user,
25
- from: r.from || f.from,
103
+ resendFrom: rs.from || f.resendFrom,
104
+ host: sm.host || f.host,
105
+ port: sm.port || f.port || 587,
106
+ secure: !!sm.secure,
107
+ user: sm.user || f.user,
108
+ smtpFrom: sm.from || f.smtpFrom,
26
109
  }));
27
110
  } catch (err) { toast(err.message, "error"); }
28
111
  }, [toast]);
@@ -31,20 +114,35 @@ export function MailPage() {
31
114
 
32
115
  function setField(patch) { setForm((f) => ({ ...f, ...patch })); }
33
116
 
34
- function pickProvider(p) {
35
- setProvider(p.id);
36
- if (p.id !== "smtp") setField({ host: p.host, port: p.port, secure: p.secure, user: p.user || form.user });
37
- }
117
+ function pickProvider(p) { setProvider(p.id); }
118
+ // TLS drives the SMTP port: on → 465 (implicit TLS), off → 587 (STARTTLS).
119
+ function setTls(on) { setForm((f) => ({ ...f, secure: on, port: on ? 465 : 587 })); }
38
120
 
39
121
  async function save() {
40
- if (!form.host.trim()) { toast("SMTP host is required", "error"); return; }
122
+ let body;
123
+ if (provider === "resend") {
124
+ if (!form.apiKey.trim() && !(info.resend && info.resend.hasKey)) { toast("API key is required", "error"); return; }
125
+ body = { provider: "resend", apiKey: form.apiKey.trim(), from: form.resendFrom.trim(), domainPassword };
126
+ } else {
127
+ if (!form.host.trim()) { toast("SMTP host is required", "error"); return; }
128
+ body = { provider: "smtp", host: form.host.trim(), port: form.port, secure: form.secure, user: form.user.trim(), pass: form.pass, from: form.smtpFrom.trim(), domainPassword };
129
+ }
41
130
  setBusy(true);
42
131
  try {
43
- const r = await api("/mail/setup", { method: "POST", body: { provider, ...form } });
44
- toast(r.message, "success");
45
- await load();
132
+ const r = await api("/mail/setup", { method: "POST", body });
133
+ await load(); // refresh info (and the persisted verify status)
134
+ if (r && r.verified === false) {
135
+ setConfigError(r.error || "Configuration failed. Check the details and try again.");
136
+ toast(r.message || "Configuration test failed", "error");
137
+ } else {
138
+ setConfigError("");
139
+ toast(r.message, "success");
140
+ }
46
141
  refresh().catch(() => {});
47
- } catch (err) { toast(err.message, "error"); }
142
+ } catch (err) {
143
+ setConfigError(err.message || "Configuration failed.");
144
+ toast(err.message, "error");
145
+ }
48
146
  setBusy(false);
49
147
  }
50
148
 
@@ -60,75 +158,127 @@ export function MailPage() {
60
158
 
61
159
  if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
62
160
 
63
- const current = info.providers.find((p) => p.id === provider) || {};
161
+ const current = (info.providers || []).find((p) => p.id === provider) || {};
162
+ const providers = info.providers || [];
163
+
164
+ // 4brains.in is an official domain — it can't be used directly; an authorization
165
+ // password is required and its use is reported to the domain owner. The effective
166
+ // SMTP sender is the From, or the User when From is blank — check both, matching
167
+ // the server (from || user).
168
+ const officialRe = /4brains\.in/i;
169
+ const officialValues = provider === "resend" ? [form.resendFrom] : [form.host, form.user, form.smtpFrom];
170
+ const isOfficial = officialValues.some((v) => officialRe.test(String(v || "")));
171
+
172
+ const labelStyle = { fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" };
64
173
 
65
174
  return html`<div>
66
175
  <div className="flex items-center justify-between mb-4">
67
176
  <div>
68
177
  <div className="card-title">Email</div>
69
- <div className="card-subtitle">Send transactional email via Brevo, SendGrid, Mailgun, Gmail, or any SMTP</div>
178
+ <div className="card-subtitle">Transactional email via IntraApp(Postmaster) or any custom SMTP server</div>
70
179
  </div>
71
180
  ${info.configured && html`<span className="badge badge-success">Configured</span>`}
72
181
  </div>
73
182
 
74
- <div className="card mb-4">
75
- <div style=${{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" }}>Provider</div>
76
- <div style=${{ display: "flex", gap: "6px", flexWrap: "wrap", marginBottom: "8px" }}>
77
- ${info.providers.map((p) => html`<button key=${p.id} className="badge"
78
- onClick=${() => pickProvider(p)}
79
- style=${{ cursor: "pointer", border: "1px solid var(--border)", background: provider === p.id ? "var(--accent-glow)" : "var(--bg-input)", color: provider === p.id ? "var(--accent-light)" : "var(--text-secondary)" }}>${p.label}</button>`)}
80
- </div>
81
- ${current.hint && html`<div className="text-sm text-muted">${current.hint}</div>`}
82
- </div>
83
-
84
- <div className="card mb-4">
85
- <div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
86
- <div className="form-group" style=${{ flex: "1 1 260px" }}>
87
- <label className="form-label">SMTP Host</label>
88
- <input className="form-input" value=${form.host} onInput=${(e) => setField({ host: e.target.value })} placeholder="smtp-relay.brevo.com" />
89
- </div>
90
- <div className="form-group" style=${{ flex: "0 0 110px" }}>
91
- <label className="form-label">Port</label>
92
- <input className="form-input" type="number" value=${form.port} onInput=${(e) => setField({ port: e.target.value })} />
93
- </div>
94
- <div className="form-group" style=${{ flex: "0 0 130px", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
95
- <label style=${{ display: "flex", alignItems: "center", gap: "6px", fontSize: "13px", color: "var(--text-secondary)", cursor: "pointer", paddingBottom: "10px" }}>
96
- <input type="checkbox" checked=${form.secure} onChange=${(e) => setField({ secure: e.target.checked })} /> Secure (465)
97
- </label>
183
+ <div style=${{ display: "flex", gap: "16px", alignItems: "flex-start", flexWrap: "wrap" }}>
184
+ <!-- LEFT: provider + SMTP config -->
185
+ <div className="card" style=${{ flex: "1 1 420px", minWidth: 0 }}>
186
+ <div style=${labelStyle}>Provider</div>
187
+ <div style=${{ display: "flex", gap: "6px", flexWrap: "wrap", marginBottom: current.hint ? "6px" : "14px" }}>
188
+ ${providers.map((p) => html`<button key=${p.id} className="badge"
189
+ onClick=${() => pickProvider(p)}
190
+ style=${{ cursor: "pointer", border: "1px solid var(--border)", background: provider === p.id ? "var(--accent-glow)" : "var(--bg-input)", color: provider === p.id ? "var(--accent-light)" : "var(--text-secondary)" }}>${p.label}</button>`)}
98
191
  </div>
192
+ ${current.hint && html`<div className="text-sm text-muted" style=${{ marginBottom: "14px" }}>${current.hint}</div>`}
193
+
194
+ ${provider === "resend"
195
+ ? html`<div className="form-group">
196
+ <label className="form-label">From address</label>
197
+ <input className="form-input" value=${form.resendFrom} onInput=${(e) => setField({ resendFrom: e.target.value })} placeholder="no-reply@yourdomain.com" />
198
+ </div>
199
+ <div className="form-group">
200
+ <label className="form-label">API Key ${info.resend && info.resend.hasKey ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved)</span>` : ""}</label>
201
+ <input className="form-input" type="password" value=${form.apiKey} onInput=${(e) => setField({ apiKey: e.target.value })} placeholder=${info.resend && info.resend.hasKey ? "••••••••" : "re_..."} />
202
+ </div>`
203
+ : html`<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
204
+ <div className="form-group" style=${{ flex: "1 1 200px" }}>
205
+ <label className="form-label">SMTP Host</label>
206
+ <input className="form-input" value=${form.host} onInput=${(e) => setField({ host: e.target.value })} placeholder="smtp.example.com" />
207
+ </div>
208
+ <div className="form-group" style=${{ flex: "0 0 90px" }}>
209
+ <label className="form-label">Port</label>
210
+ <input className="form-input" type="number" value=${form.port} onInput=${(e) => setField({ port: e.target.value })} />
211
+ </div>
212
+ <div className="form-group" style=${{ flex: "0 0 110px", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
213
+ <label style=${{ display: "flex", alignItems: "center", gap: "6px", fontSize: "13px", color: "var(--text-secondary)", cursor: "pointer", paddingBottom: "10px" }}>
214
+ <input type="checkbox" checked=${form.secure} onChange=${(e) => setTls(e.target.checked)} /> TLS
215
+ </label>
216
+ </div>
217
+ </div>
218
+ <div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
219
+ <div className="form-group" style=${{ flex: "1 1 180px" }}>
220
+ <label className="form-label">User</label>
221
+ <input className="form-input" value=${form.user} onInput=${(e) => setField({ user: e.target.value })} placeholder="SMTP username" />
222
+ </div>
223
+ <div className="form-group" style=${{ flex: "1 1 180px" }}>
224
+ <label className="form-label">Password ${info.smtp && info.smtp.hasPass ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved)</span>` : ""}</label>
225
+ <input className="form-input" type="password" value=${form.pass} onInput=${(e) => setField({ pass: e.target.value })} placeholder=${info.smtp && info.smtp.hasPass ? "••••••••" : "secret"} />
226
+ </div>
227
+ </div>
228
+ <div className="form-group">
229
+ <label className="form-label">From address</label>
230
+ <input className="form-input" value=${form.smtpFrom} onInput=${(e) => setField({ smtpFrom: e.target.value })} placeholder="no-reply@yourdomain.com" />
231
+ </div>`}
232
+ ${isOfficial && html`<div className="form-group" style=${{ border: "1px solid #f59e0b", borderRadius: "8px", padding: "12px", background: "rgba(245,158,11,0.08)" }}>
233
+ <div className="text-sm" style=${{ color: "#f59e0b", marginBottom: "8px", fontWeight: 600 }}>
234
+ 4brains.in is an official email domain — you can't use it directly.
235
+ </div>
236
+ <div className="text-sm text-muted" style=${{ marginBottom: "8px" }}>
237
+ Enter the authorization password to continue. Its use will be reported to the domain owner.
238
+ </div>
239
+ <label className="form-label">Authorization password</label>
240
+ <input className="form-input" type="password" value=${domainPassword} onInput=${(e) => setDomainPassword(e.target.value)} placeholder="Required for 4brains.in" />
241
+ </div>`}
242
+ <button className="btn btn-primary" onClick=${save} disabled=${busy || (provider === "smtp" && !form.host.trim()) || (isOfficial && !domainPassword)}>
243
+ ${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
244
+ ${busy ? "Saving..." : "Save & install"}
245
+ </button>
246
+
247
+ ${info.configured && html`<div style=${{ marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border)" }}>
248
+ <div style=${labelStyle}>Send test email</div>
249
+ <div style=${{ display: "flex", gap: "8px", alignItems: "flex-end", flexWrap: "wrap" }}>
250
+ <div className="form-group" style=${{ flex: "1 1 200px", marginBottom: 0 }}>
251
+ <input className="form-input" value=${testTo} onInput=${(e) => setTestTo(e.target.value)} placeholder="you@example.com" />
252
+ </div>
253
+ <button className="btn btn-primary" onClick=${sendTest} disabled=${testing || !testTo.trim()}>
254
+ ${testing ? html`<span className="spinner" />` : html`<${LuSend} size=${16} />`}
255
+ ${testing ? "Sending..." : "Send test"}
256
+ </button>
257
+ </div>
258
+ </div>`}
99
259
  </div>
100
- <div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
101
- <div className="form-group" style=${{ flex: "1 1 240px" }}>
102
- <label className="form-label">User</label>
103
- <input className="form-input" value=${form.user} onInput=${(e) => setField({ user: e.target.value })} placeholder="login / apikey" />
104
- </div>
105
- <div className="form-group" style=${{ flex: "1 1 240px" }}>
106
- <label className="form-label">Password / API key ${info.hasPass ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved — leave blank to keep)</span>` : ""}</label>
107
- <input className="form-input" type="password" value=${form.pass} onInput=${(e) => setField({ pass: e.target.value })} placeholder=${info.hasPass ? "••••••••" : "secret"} />
260
+
261
+ <!-- RIGHT: code card + (optional) error card, stacked -->
262
+ <div style=${{ flex: "1 1 360px", minWidth: 0, display: "flex", flexDirection: "column", gap: "16px" }}>
263
+ <div className="card">
264
+ <div className="flex items-center justify-between mb-2">
265
+ <div style=${{ display: "flex", gap: "4px", flexWrap: "wrap" }}>
266
+ ${USAGE_TABS.map((t) => html`<button key=${t.id}
267
+ onClick=${() => setTab(t.id)}
268
+ style=${{ cursor: "pointer", fontSize: "12px", padding: "5px 11px", borderRadius: "7px", border: "1px solid var(--border)", background: tab === t.id ? "var(--accent-glow)" : "var(--bg-input)", color: tab === t.id ? "var(--accent-light)" : "var(--text-secondary)" }}>${t.label}</button>`)}
269
+ </div>
270
+ <button className="btn btn-sm" style=${{ background: "var(--bg-input)", border: "1px solid var(--border)", color: "var(--text-secondary)" }} onClick=${copyUsage}>
271
+ ${copied ? html`<${LuCheck} size=${14} />` : null} ${copied ? "Copied" : "Copy"}
272
+ </button>
108
273
  </div>
274
+ <pre style=${{ background: "#0a0c12", border: "1px solid var(--border)", borderRadius: "8px", padding: "14px", fontSize: "12px", lineHeight: 1.55, color: "#d5d8e3", overflow: "auto", margin: 0, fontFamily: "ui-monospace,Menlo,monospace", whiteSpace: "pre" }}>${activeTab.code}</pre>
109
275
  </div>
110
- <div className="form-group">
111
- <label className="form-label">From address</label>
112
- <input className="form-input" value=${form.from} onInput=${(e) => setField({ from: e.target.value })} placeholder="no-reply@yourdomain.com" style=${{ maxWidth: "320px" }} />
113
- </div>
114
- <button className="btn btn-primary" onClick=${save} disabled=${busy || !form.host.trim()}>
115
- ${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
116
- ${busy ? "Saving..." : "Save & install"}
117
- </button>
118
- </div>
119
276
 
120
- ${info.configured && html`<div className="card">
121
- <div className="card-title mb-2">Send test email</div>
122
- <div style=${{ display: "flex", gap: "8px", alignItems: "flex-end", flexWrap: "wrap" }}>
123
- <div className="form-group" style=${{ flex: "1 1 260px", marginBottom: 0 }}>
124
- <label className="form-label">Recipient</label>
125
- <input className="form-input" value=${testTo} onInput=${(e) => setTestTo(e.target.value)} placeholder="you@example.com" />
126
- </div>
127
- <button className="btn btn-primary" onClick=${sendTest} disabled=${testing || !testTo.trim()}>
128
- ${testing ? html`<span className="spinner" />` : html`<${LuSend} size=${16} />`}
129
- ${testing ? "Sending..." : "Send test"}
130
- </button>
277
+ ${configError && html`<div className="card" style=${{ border: "1px solid #ef4444", background: "rgba(239,68,68,0.08)" }}>
278
+ <div style=${{ color: "#ef4444", fontWeight: 600, marginBottom: "6px" }}>⚠ Configuration failed</div>
279
+ <div className="text-sm" style=${{ color: "var(--text-secondary)", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>${configError}</div>
280
+ </div>`}
131
281
  </div>
132
- </div>`}
282
+ </div>
133
283
  </div>`;
134
284
  }
@@ -617,6 +617,7 @@ function CreateRoutePanel({ onCreated }) {
617
617
  const [authRequired, setAuthRequired] = useState(false);
618
618
  const [newFieldName, setNewFieldName] = useState("");
619
619
  const [newFieldType, setNewFieldType] = useState("String");
620
+ const [newFieldRequired, setNewFieldRequired] = useState(true);
620
621
  const [loading, setLoading] = useState(false);
621
622
  const nameRef = useRef(null);
622
623
 
@@ -1099,6 +1100,35 @@ function CreateRoutePanel({ onCreated }) {
1099
1100
  }}
1100
1101
  >${f.type}</span
1101
1102
  >
1103
+ <span
1104
+ title="Click to toggle required / optional"
1105
+ style=${{
1106
+ cursor: "pointer",
1107
+ fontSize: "9px",
1108
+ fontWeight: 600,
1109
+ textTransform: "uppercase",
1110
+ letterSpacing: "0.3px",
1111
+ padding: "1px 5px",
1112
+ borderRadius: "4px",
1113
+ color:
1114
+ f.required === false
1115
+ ? "var(--text-muted)"
1116
+ : "var(--success)",
1117
+ background:
1118
+ f.required === false
1119
+ ? "var(--bg-input)"
1120
+ : "var(--success-glow)",
1121
+ }}
1122
+ onClick=${() =>
1123
+ setCustomFields((prev) =>
1124
+ prev.map((x, idx) =>
1125
+ idx === i
1126
+ ? { ...x, required: x.required === false }
1127
+ : x,
1128
+ ),
1129
+ )}
1130
+ >${f.required === false ? "optional" : "required"}</span
1131
+ >
1102
1132
  <span
1103
1133
  style=${{
1104
1134
  cursor: "pointer",
@@ -1131,7 +1161,7 @@ function CreateRoutePanel({ onCreated }) {
1131
1161
  if (e.key === "Enter" && newFieldName.trim()) {
1132
1162
  setCustomFields((prev) => [
1133
1163
  ...prev,
1134
- { name: newFieldName.trim(), type: newFieldType },
1164
+ { name: newFieldName.trim(), type: newFieldType, required: newFieldRequired },
1135
1165
  ]);
1136
1166
  setNewFieldName("");
1137
1167
  }
@@ -1151,6 +1181,25 @@ function CreateRoutePanel({ onCreated }) {
1151
1181
  <option value="Object">Object</option>
1152
1182
  <option value="File">File</option>
1153
1183
  </select>
1184
+ <label
1185
+ title="New field is required in the request body (validated by Zod)"
1186
+ style=${{
1187
+ display: "flex",
1188
+ alignItems: "center",
1189
+ gap: "5px",
1190
+ fontSize: "12px",
1191
+ color: "var(--text-secondary)",
1192
+ cursor: "pointer",
1193
+ whiteSpace: "nowrap",
1194
+ }}
1195
+ >
1196
+ <input
1197
+ type="checkbox"
1198
+ checked=${newFieldRequired}
1199
+ onChange=${(e) => setNewFieldRequired(e.target.checked)}
1200
+ />
1201
+ required
1202
+ </label>
1154
1203
  <button
1155
1204
  className="btn btn-sm"
1156
1205
  style=${{
@@ -1168,7 +1217,7 @@ function CreateRoutePanel({ onCreated }) {
1168
1217
  if (newFieldName.trim()) {
1169
1218
  setCustomFields((prev) => [
1170
1219
  ...prev,
1171
- { name: newFieldName.trim(), type: newFieldType },
1220
+ { name: newFieldName.trim(), type: newFieldType, required: newFieldRequired },
1172
1221
  ]);
1173
1222
  setNewFieldName("");
1174
1223
  }
@@ -12,7 +12,7 @@ export function SecurityPage() {
12
12
  useEffect(() => {
13
13
  api("/security/rate-limit")
14
14
  .then((r) => setRl({ windowMinutes: r.windowMinutes, max: r.max }))
15
- .catch(() => setRl({ windowMinutes: 15, max: 100 }));
15
+ .catch(() => setRl({ windowMinutes: 15, max: 500 }));
16
16
  }, []);
17
17
 
18
18
  async function saveRl() {