4bnode 4.1.3 → 4.1.5

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 (34) hide show
  1. package/add-docs.js +18 -5
  2. package/app-1/.4bnode/.passkey +1 -1
  3. package/app-1/.4bnode/assets/js/components/Sidebar.js +2 -2
  4. package/app-1/.4bnode/assets/js/pages/MailPage.js +144 -54
  5. package/app-1/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  6. package/app-1/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  7. package/app-1/.4bnode/codegen.js +247 -56
  8. package/app-1/.4bnode/dev-api.js +131 -5
  9. package/app-1/.env.example +11 -0
  10. package/app-1/index.js +49 -6
  11. package/app-1/package.json +2 -0
  12. package/app-1/src/docs.js +1 -1
  13. package/app-1/src/middleware/apiKey.js +36 -0
  14. package/app-1/src/middleware/auth.js +17 -0
  15. package/app-1/src/models/user.js +11 -0
  16. package/app-1/src/openapi.json +23 -5
  17. package/app-1/src/routes/upload-resume.js +21 -0
  18. package/app-1/src/services/mailer.js +52 -0
  19. package/app-1/src/validators/upload-resume.js +8 -0
  20. package/lib/codegen.js +247 -56
  21. package/lib/codegen.test.js +37 -1
  22. package/package.json +1 -1
  23. package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
  24. package/skeleton/.4bnode/assets/js/pages/MailPage.js +144 -54
  25. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
  26. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
  27. package/skeleton/.4bnode/codegen.js +247 -56
  28. package/skeleton/.4bnode/dev-api.js +131 -5
  29. package/skeleton/.env +6 -0
  30. package/skeleton/.env.example +11 -0
  31. package/app-1/src/models/login.js +0 -9
  32. package/app-1/src/routes/products.js +0 -26
  33. package/app-1/src/validators/products.js +0 -7
  34. package/app-1/src/validators/user.js +0 -7
@@ -3,14 +3,85 @@ 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("mailgun");
69
+ const [form, setForm] = useState({ host: "smtp.mailgun.org", port: 587, secure: false, user: "", pass: "", from: "" });
11
70
  const [busy, setBusy] = useState(false);
12
71
  const [testTo, setTestTo] = useState("");
13
72
  const [testing, setTesting] = useState(false);
73
+ const [copied, setCopied] = useState(false);
74
+ const [tab, setTab] = useState("basic");
75
+
76
+ const activeTab = USAGE_TABS.find((t) => t.id === tab) || USAGE_TABS[0];
77
+
78
+ function copyUsage() {
79
+ try {
80
+ if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(activeTab.code);
81
+ } catch (e) {}
82
+ setCopied(true);
83
+ setTimeout(() => setCopied(false), 1200);
84
+ }
14
85
 
15
86
  const load = useCallback(async () => {
16
87
  try {
@@ -61,74 +132,93 @@ export function MailPage() {
61
132
  if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
62
133
 
63
134
  const current = info.providers.find((p) => p.id === provider) || {};
135
+ // Render Mailgun first regardless of the backend list order.
136
+ const providers = info.providers.slice().sort((a, b) => (a.id === "mailgun" ? -1 : b.id === "mailgun" ? 1 : 0));
137
+
138
+ const labelStyle = { fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" };
64
139
 
65
140
  return html`<div>
66
141
  <div className="flex items-center justify-between mb-4">
67
142
  <div>
68
143
  <div className="card-title">Email</div>
69
- <div className="card-subtitle">Send transactional email via Brevo, SendGrid, Mailgun, Gmail, or any SMTP</div>
144
+ <div className="card-subtitle">Transactional email via Mailgun, Brevo, SendGrid, Gmail, or any SMTP</div>
70
145
  </div>
71
146
  ${info.configured && html`<span className="badge badge-success">Configured</span>`}
72
147
  </div>
73
148
 
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" />
149
+ <div style=${{ display: "flex", gap: "16px", alignItems: "flex-start", flexWrap: "wrap" }}>
150
+ <!-- LEFT: provider + SMTP config -->
151
+ <div className="card" style=${{ flex: "1 1 420px", minWidth: 0 }}>
152
+ <div style=${labelStyle}>Provider</div>
153
+ <div style=${{ display: "flex", gap: "6px", flexWrap: "wrap", marginBottom: current.hint ? "6px" : "14px" }}>
154
+ ${providers.map((p) => html`<button key=${p.id} className="badge"
155
+ onClick=${() => pickProvider(p)}
156
+ 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>`)}
89
157
  </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>
158
+ ${current.hint && html`<div className="text-sm text-muted" style=${{ marginBottom: "14px" }}>${current.hint}</div>`}
159
+
160
+ <div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
161
+ <div className="form-group" style=${{ flex: "1 1 200px" }}>
162
+ <label className="form-label">SMTP Host</label>
163
+ <input className="form-input" value=${form.host} onInput=${(e) => setField({ host: e.target.value })} placeholder="smtp.mailgun.org" />
164
+ </div>
165
+ <div className="form-group" style=${{ flex: "0 0 90px" }}>
166
+ <label className="form-label">Port</label>
167
+ <input className="form-input" type="number" value=${form.port} onInput=${(e) => setField({ port: e.target.value })} />
168
+ </div>
169
+ <div className="form-group" style=${{ flex: "0 0 120px", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
170
+ <label style=${{ display: "flex", alignItems: "center", gap: "6px", fontSize: "13px", color: "var(--text-secondary)", cursor: "pointer", paddingBottom: "10px" }}>
171
+ <input type="checkbox" checked=${form.secure} onChange=${(e) => setField({ secure: e.target.checked })} /> Secure (465)
172
+ </label>
173
+ </div>
98
174
  </div>
99
- </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" />
175
+ <div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
176
+ <div className="form-group" style=${{ flex: "1 1 180px" }}>
177
+ <label className="form-label">User</label>
178
+ <input className="form-input" value=${form.user} onInput=${(e) => setField({ user: e.target.value })} placeholder="SMTP username" />
179
+ </div>
180
+ <div className="form-group" style=${{ flex: "1 1 180px" }}>
181
+ <label className="form-label">Password ${info.hasPass ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved)</span>` : ""}</label>
182
+ <input className="form-input" type="password" value=${form.pass} onInput=${(e) => setField({ pass: e.target.value })} placeholder=${info.hasPass ? "••••••••" : "secret"} />
183
+ </div>
104
184
  </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"} />
185
+ <div className="form-group">
186
+ <label className="form-label">From address</label>
187
+ <input className="form-input" value=${form.from} onInput=${(e) => setField({ from: e.target.value })} placeholder="no-reply@yourdomain.com" />
108
188
  </div>
189
+ <button className="btn btn-primary" onClick=${save} disabled=${busy || !form.host.trim()}>
190
+ ${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
191
+ ${busy ? "Saving..." : "Save & install"}
192
+ </button>
193
+
194
+ ${info.configured && html`<div style=${{ marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border)" }}>
195
+ <div style=${labelStyle}>Send test email</div>
196
+ <div style=${{ display: "flex", gap: "8px", alignItems: "flex-end", flexWrap: "wrap" }}>
197
+ <div className="form-group" style=${{ flex: "1 1 200px", marginBottom: 0 }}>
198
+ <input className="form-input" value=${testTo} onInput=${(e) => setTestTo(e.target.value)} placeholder="you@example.com" />
199
+ </div>
200
+ <button className="btn btn-primary" onClick=${sendTest} disabled=${testing || !testTo.trim()}>
201
+ ${testing ? html`<span className="spinner" />` : html`<${LuSend} size=${16} />`}
202
+ ${testing ? "Sending..." : "Send test"}
203
+ </button>
204
+ </div>
205
+ </div>`}
109
206
  </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
207
 
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" />
208
+ <!-- RIGHT: code only -->
209
+ <div className="card" style=${{ flex: "1 1 360px", minWidth: 0 }}>
210
+ <div className="flex items-center justify-between mb-2">
211
+ <div style=${{ display: "flex", gap: "4px", flexWrap: "wrap" }}>
212
+ ${USAGE_TABS.map((t) => html`<button key=${t.id}
213
+ onClick=${() => setTab(t.id)}
214
+ 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>`)}
215
+ </div>
216
+ <button className="btn btn-sm" style=${{ background: "var(--bg-input)", border: "1px solid var(--border)", color: "var(--text-secondary)" }} onClick=${copyUsage}>
217
+ ${copied ? html`<${LuCheck} size=${14} />` : null} ${copied ? "Copied" : "Copy"}
218
+ </button>
126
219
  </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>
220
+ <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>
131
221
  </div>
132
- </div>`}
222
+ </div>
133
223
  </div>`;
134
224
  }
@@ -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() {