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.
- package/add-docs.js +18 -5
- package/app-1/.4bnode/.passkey +1 -1
- package/app-1/.4bnode/assets/js/components/Sidebar.js +2 -2
- package/app-1/.4bnode/assets/js/pages/MailPage.js +144 -54
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +51 -2
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +1 -1
- package/app-1/.4bnode/codegen.js +247 -56
- package/app-1/.4bnode/dev-api.js +131 -5
- package/app-1/.env.example +11 -0
- package/app-1/index.js +49 -6
- package/app-1/package.json +2 -0
- package/app-1/src/docs.js +1 -1
- package/app-1/src/middleware/apiKey.js +36 -0
- package/app-1/src/middleware/auth.js +17 -0
- package/app-1/src/models/user.js +11 -0
- package/app-1/src/openapi.json +23 -5
- package/app-1/src/routes/upload-resume.js +21 -0
- package/app-1/src/services/mailer.js +52 -0
- package/app-1/src/validators/upload-resume.js +8 -0
- package/lib/codegen.js +247 -56
- package/lib/codegen.test.js +37 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -2
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +144 -54
- 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 +247 -56
- package/skeleton/.4bnode/dev-api.js +131 -5
- package/skeleton/.env +6 -0
- package/skeleton/.env.example +11 -0
- package/app-1/src/models/login.js +0 -9
- 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
package/add-docs.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from "path";
|
|
|
5
5
|
import { getProjectRoot, listRoutes, listModels, getModelSchemaDetailed } from "./lib/project.js";
|
|
6
6
|
import { addRouteRegistration, readIndex } from "./lib/indexFile.js";
|
|
7
7
|
import { showHeader, showTaskDone, showError, showFileAction, showInfo } from "./lib/ui.js";
|
|
8
|
-
import { extractRequestFields, buildOpenApiSpec, buildDocsRouter } from "./lib/codegen.js";
|
|
8
|
+
import { extractRequestFields, extractFileFields, requiredFieldsFromZod, buildOpenApiSpec, buildDocsRouter } from "./lib/codegen.js";
|
|
9
9
|
|
|
10
10
|
function collectEndpoints() {
|
|
11
11
|
const root = getProjectRoot();
|
|
@@ -21,21 +21,34 @@ function collectEndpoints() {
|
|
|
21
21
|
if (reg) prefix = reg[1];
|
|
22
22
|
const modelImport = content.match(/import\s+(\w+)\s+from\s+['"]\.\.\/models\/(\w+)(?:\.js)?['"]/);
|
|
23
23
|
const modelFields = modelImport ? getModelSchemaDetailed(modelImport[2]) : [];
|
|
24
|
+
// Required-ness for the API comes from the Zod validator (the request gate),
|
|
25
|
+
// not the Mongoose model. Parse the route's validator so docs match the contract.
|
|
26
|
+
const validatorImport = content.match(/from\s+['"]\.\.\/validators\/([\w-]+)(?:\.js)?['"]/);
|
|
27
|
+
let validatorReq = null;
|
|
28
|
+
if (validatorImport) {
|
|
29
|
+
const vPath = path.join(root, "src", "validators", validatorImport[1] + ".js");
|
|
30
|
+
if (fs.existsSync(vPath)) {
|
|
31
|
+
try { validatorReq = requiredFieldsFromZod(fs.readFileSync(vPath, "utf8")); } catch {}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
24
34
|
const epRegex = /router\.(get|post|put|patch|delete)\(\s*['"`](\/[^'"`]*?)['"`]\s*,([\s\S]*?)(?=\nrouter\.|$)/gi;
|
|
25
35
|
let m;
|
|
26
36
|
while ((m = epRegex.exec(content)) !== null) {
|
|
27
37
|
const method = m[1].toUpperCase();
|
|
28
38
|
const epath = m[2];
|
|
29
39
|
const handler = m[3] || "";
|
|
40
|
+
const usesPartial = /\.partial\s*\(\s*\)/.test(handler); // PATCH: all optional
|
|
30
41
|
const bodyFields = extractRequestFields(handler, "body").map((fn) => {
|
|
31
42
|
const meta = modelFields.find((x) => x.name === fn);
|
|
32
|
-
|
|
43
|
+
let required;
|
|
44
|
+
if (usesPartial) required = false;
|
|
45
|
+
else if (validatorReq && Object.prototype.hasOwnProperty.call(validatorReq, fn)) required = validatorReq[fn];
|
|
46
|
+
else required = meta?.required || false;
|
|
47
|
+
return { name: fn, type: meta?.type || "String", required };
|
|
33
48
|
});
|
|
34
49
|
const queryFields = extractRequestFields(handler, "query").map((fn) => ({ name: fn, type: "String" }));
|
|
35
50
|
const params = (epath.match(/:(\w+)/g) || []).map((p) => p.slice(1));
|
|
36
|
-
const fileFields =
|
|
37
|
-
const us = handler.match(/upload\.single\(\s*['"](\w+)['"]/);
|
|
38
|
-
if (us) fileFields.push({ name: us[1] });
|
|
51
|
+
const fileFields = extractFileFields(handler);
|
|
39
52
|
const fullPath = (prefix + (epath === "/" ? "" : epath)).replace(/\/+/g, "/");
|
|
40
53
|
out.push({ method, path: epath, fullPath, bodyFields, queryFields, params, fileFields, hasAuth: /\bauth\b\s*,/.test(m[0]) });
|
|
41
54
|
}
|
package/app-1/.4bnode/.passkey
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
scrypt$
|
|
1
|
+
scrypt$926cd8cc7936dc52823cb6f1a404b733$4a40ed68116af0face0fb413db7ebc5f1ae075b13c6af86ae74d947cf4b620d7e04ccf98df9e84721c11caeefc47e6ed5a6e2742ccc2a43a1ec1a8f9b7f81e32
|
|
@@ -29,7 +29,7 @@ export function Sidebar() {
|
|
|
29
29
|
lockMsg: "Requires database",
|
|
30
30
|
},
|
|
31
31
|
{ id: "routes", label: "API Routes", icon: Icons.route },
|
|
32
|
-
|
|
32
|
+
{ id: "docs", label: "API Docs", icon: Icons.docs },
|
|
33
33
|
{ id: "api-tester", label: "API Tester", icon: Icons.apiTester },
|
|
34
34
|
{
|
|
35
35
|
id: "security",
|
|
@@ -39,7 +39,7 @@ export function Sidebar() {
|
|
|
39
39
|
},
|
|
40
40
|
{ id: "realtime", label: "Real-time", icon: Icons.realtime },
|
|
41
41
|
{ id: "serialport", label: "SerialPort", icon: Icons.serial },
|
|
42
|
-
|
|
42
|
+
{ id: "mail", label: "Email", icon: Icons.mail },
|
|
43
43
|
{ id: "logs", label: "Logs", icon: Icons.logs, section: "Settings" },
|
|
44
44
|
{ id: "env", label: "Environment", icon: Icons.env },
|
|
45
45
|
];
|
|
@@ -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("
|
|
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">
|
|
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
|
|
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" />
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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"
|
|
106
|
-
<label className="form-label">
|
|
107
|
-
<input className="form-input"
|
|
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
|
-
|
|
121
|
-
<div className="card
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
<
|
|
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:
|
|
15
|
+
.catch(() => setRl({ windowMinutes: 15, max: 500 }));
|
|
16
16
|
}, []);
|
|
17
17
|
|
|
18
18
|
async function saveRl() {
|