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.
- package/add-api.js +5 -3
- package/add-docs.js +18 -5
- package/add-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +408 -104
- package/lib/codegen.test.js +122 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +221 -71
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +51 -2
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +1 -1
- package/skeleton/.4bnode/codegen.js +408 -104
- package/skeleton/.4bnode/dev-api.js +384 -30
- package/skeleton/.env +6 -0
- package/skeleton/.env.example +25 -0
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +7 -2
- package/skeleton/src/middleware/apiKey.js +14 -1
- package/skeleton/src/middleware/errorHandler.js +7 -2
- package/app-1/.4bnode/.passkey +0 -1
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +0 -139
- package/app-1/.4bnode/assets/js/Root.js +0 -40
- package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
- package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
- package/app-1/.4bnode/assets/js/context.js +0 -7
- package/app-1/.4bnode/assets/js/lib.js +0 -113
- package/app-1/.4bnode/assets/js/main.js +0 -5
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
- package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -134
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1436
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
- package/app-1/.4bnode/codegen.js +0 -1249
- package/app-1/.4bnode/dev-api.js +0 -3621
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -145
- package/app-1/package.json +0 -35
- package/app-1/public/index.html +0 -292
- package/app-1/src/db.js +0 -13
- package/app-1/src/docs.js +0 -84
- package/app-1/src/middleware/errorHandler.js +0 -15
- package/app-1/src/middleware/roles.js +0 -14
- package/app-1/src/middleware/validate.js +0 -20
- package/app-1/src/models/login.js +0 -9
- package/app-1/src/openapi.json +0 -62
- package/app-1/src/routes/products.js +0 -26
- package/app-1/src/validators/products.js +0 -7
- 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("
|
|
10
|
-
const [form, setForm] = useState({
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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) {
|
|
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">
|
|
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
|
|
75
|
-
|
|
76
|
-
<div style=${{
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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:
|
|
15
|
+
.catch(() => setRl({ windowMinutes: 15, max: 500 }));
|
|
16
16
|
}, []);
|
|
17
17
|
|
|
18
18
|
async function saveRl() {
|