4bnode 4.1.0 → 4.1.1
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/README.md +25 -206
- package/add-crud.js +6 -106
- package/add-docs.js +91 -0
- package/add-email.js +63 -0
- package/add-security.js +143 -0
- package/index.js +76 -8
- package/lib/codegen.js +1223 -0
- package/lib/codegen.test.js +480 -0
- package/lib/indexFile.js +10 -12
- package/lib/names.js +4 -21
- package/lib/routeFile.js +9 -27
- package/lib/sync-skeleton.js +47 -0
- package/lib/ui.js +15 -2
- package/package.json +4 -2
- package/scripts/audit-dev-surface.mjs +153 -0
- package/scripts/reveal-passkey.mjs +35 -0
- package/skeleton/.4bnode/assets/js/App.js +31 -9
- package/skeleton/.4bnode/assets/js/Root.js +1 -5
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
- package/skeleton/.4bnode/assets/js/lib.js +18 -5
- package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
- package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
- package/skeleton/.4bnode/codegen.js +1223 -0
- package/skeleton/.4bnode/dev-api.js +634 -728
- package/skeleton/.4bnode/ui.html +62 -40
- package/skeleton/_gitignore +7 -0
- package/skeleton/index.js +46 -5
- package/skeleton/package.json +4 -1
- package/skeleton/src/middleware/apiKey.js +36 -0
- package/skeleton/src/middleware/errorHandler.js +15 -0
- package/skeleton/src/middleware/roles.js +14 -0
- package/skeleton/src/middleware/validate.js +20 -0
- package/skeleton/.4bnode/assets/images/mysql.png +0 -0
- package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
- package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
- package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
- package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { LuCheck, LuGauge } from "react-icons/lu";
|
|
3
|
+
import { html, api } from "../lib.js";
|
|
4
|
+
import { useApp } from "../context.js";
|
|
5
|
+
|
|
6
|
+
export function SecurityPage() {
|
|
7
|
+
const { toast } = useApp();
|
|
8
|
+
|
|
9
|
+
const [rl, setRl] = useState(null);
|
|
10
|
+
const [savingRl, setSavingRl] = useState(false);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
api("/security/rate-limit")
|
|
14
|
+
.then((r) => setRl({ windowMinutes: r.windowMinutes, max: r.max }))
|
|
15
|
+
.catch(() => setRl({ windowMinutes: 15, max: 100 }));
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
async function saveRl() {
|
|
19
|
+
setSavingRl(true);
|
|
20
|
+
try {
|
|
21
|
+
await api("/security/rate-limit", {
|
|
22
|
+
method: "POST",
|
|
23
|
+
body: { windowMinutes: Number(rl.windowMinutes), max: Number(rl.max) },
|
|
24
|
+
});
|
|
25
|
+
toast("Rate limit saved — restart the app to apply.", "success");
|
|
26
|
+
} catch (err) {
|
|
27
|
+
toast(err.message, "error");
|
|
28
|
+
}
|
|
29
|
+
setSavingRl(false);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return html`<div>
|
|
33
|
+
<div className="mb-4">
|
|
34
|
+
<div className="card-title">Security</div>
|
|
35
|
+
<div className="card-subtitle">
|
|
36
|
+
<b>Security is built in</b> — Helmet, rate limiting, CORS, error handling, RBAC, and Zod
|
|
37
|
+
validation ship with every app. Validation is generated per route from each route's own
|
|
38
|
+
fields when you create it. Adjust rate limiting below.
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div className="card">
|
|
43
|
+
<div className="flex items-center gap-2 mb-1">
|
|
44
|
+
<${LuGauge} size=${16} />
|
|
45
|
+
<div className="card-title" style=${{ margin: 0 }}>Rate limiting</div>
|
|
46
|
+
</div>
|
|
47
|
+
<div className="card-subtitle mb-4">
|
|
48
|
+
Throttle requests per IP. Applies to your API routes (the <span style=${{ fontFamily: "monospace" }}>/_dev</span> dashboard is exempt).
|
|
49
|
+
</div>
|
|
50
|
+
${rl === null
|
|
51
|
+
? html`<div className="text-muted text-sm">Loading…</div>`
|
|
52
|
+
: html`<div style=${{ display: "flex", gap: "16px", alignItems: "flex-end", flexWrap: "wrap" }}>
|
|
53
|
+
<div className="form-group" style=${{ marginBottom: 0 }}>
|
|
54
|
+
<label className="form-label">Window (minutes)</label>
|
|
55
|
+
<input className="form-input" type="number" min="1" value=${rl.windowMinutes}
|
|
56
|
+
onInput=${(e) => setRl((s) => ({ ...s, windowMinutes: e.target.value }))}
|
|
57
|
+
style=${{ width: "140px" }} />
|
|
58
|
+
</div>
|
|
59
|
+
<div className="form-group" style=${{ marginBottom: 0 }}>
|
|
60
|
+
<label className="form-label">Max requests / window</label>
|
|
61
|
+
<input className="form-input" type="number" min="1" value=${rl.max}
|
|
62
|
+
onInput=${(e) => setRl((s) => ({ ...s, max: e.target.value }))}
|
|
63
|
+
style=${{ width: "180px" }} />
|
|
64
|
+
</div>
|
|
65
|
+
<button className="btn btn-primary" onClick=${saveRl} disabled=${savingRl}>
|
|
66
|
+
${savingRl ? html`<span className="spinner" />` : html`<${LuCheck} size=${15} />`} Save
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="text-sm text-muted" style=${{ marginTop: "10px" }}>
|
|
70
|
+
Restart the app for changes to take effect (values are read from <span style=${{ fontFamily: "monospace" }}>.env</span> at startup).
|
|
71
|
+
</div>`}
|
|
72
|
+
</div>
|
|
73
|
+
</div>`;
|
|
74
|
+
}
|
|
@@ -1,70 +1,52 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef } from "react";
|
|
2
2
|
import { motion } from "framer-motion";
|
|
3
|
-
import { LuLock } from "react-icons/lu";
|
|
4
|
-
import { html, API
|
|
3
|
+
import { LuLock, LuEye, LuEyeOff } from "react-icons/lu";
|
|
4
|
+
import { html, API } from "../lib.js";
|
|
5
5
|
|
|
6
6
|
export function PasskeyScreen({ onSuccess }) {
|
|
7
|
-
const [
|
|
8
|
-
const [
|
|
7
|
+
const [passkey, setPasskey] = useState("");
|
|
8
|
+
const [show, setShow] = useState(false);
|
|
9
|
+
const [error, setError] = useState("");
|
|
9
10
|
const [loading, setLoading] = useState(false);
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
const handleChange = (index, value) => {
|
|
13
|
-
if (!/^\d?$/.test(value)) return;
|
|
14
|
-
const next = [...digits];
|
|
15
|
-
next[index] = value;
|
|
16
|
-
setDigits(next);
|
|
17
|
-
setError('');
|
|
18
|
-
if (value && index < 5) {
|
|
19
|
-
inputRefs.current[index + 1]?.focus();
|
|
20
|
-
}
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
const handleKeyDown = (index, e) => {
|
|
24
|
-
if (e.key === 'Backspace' && !digits[index] && index > 0) {
|
|
25
|
-
inputRefs.current[index - 1]?.focus();
|
|
26
|
-
}
|
|
27
|
-
if (e.key === 'Enter') handleSubmit();
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const handlePaste = (e) => {
|
|
31
|
-
e.preventDefault();
|
|
32
|
-
const pasted = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, 6);
|
|
33
|
-
if (pasted.length === 0) return;
|
|
34
|
-
const next = [...digits];
|
|
35
|
-
for (let i = 0; i < 6; i++) next[i] = pasted[i] || '';
|
|
36
|
-
setDigits(next);
|
|
37
|
-
const focusIdx = Math.min(pasted.length, 5);
|
|
38
|
-
inputRefs.current[focusIdx]?.focus();
|
|
39
|
-
};
|
|
11
|
+
const inputRef = useRef(null);
|
|
40
12
|
|
|
41
13
|
const handleSubmit = async () => {
|
|
42
|
-
|
|
43
|
-
if (
|
|
14
|
+
if (loading) return;
|
|
15
|
+
if (!passkey) {
|
|
16
|
+
setError("Enter your passkey");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
44
19
|
setLoading(true);
|
|
20
|
+
setError("");
|
|
45
21
|
try {
|
|
46
|
-
const res = await fetch(API +
|
|
47
|
-
method:
|
|
48
|
-
headers: {
|
|
49
|
-
body: JSON.stringify({ passkey
|
|
22
|
+
const res = await fetch(API + "/auth", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
25
|
+
body: JSON.stringify({ passkey }),
|
|
50
26
|
});
|
|
51
|
-
const data = await res.json();
|
|
27
|
+
const data = await res.json().catch(() => ({}));
|
|
52
28
|
if (!res.ok) {
|
|
53
|
-
setError(data.error ||
|
|
54
|
-
|
|
55
|
-
|
|
29
|
+
setError(data.error || "Invalid passkey");
|
|
30
|
+
setPasskey("");
|
|
31
|
+
inputRef.current?.focus();
|
|
56
32
|
} else {
|
|
57
|
-
sessionStorage.setItem(
|
|
33
|
+
sessionStorage.setItem("_dev_passkey", passkey);
|
|
58
34
|
onSuccess();
|
|
59
35
|
}
|
|
60
36
|
} catch {
|
|
61
|
-
setError(
|
|
37
|
+
setError("Connection failed");
|
|
62
38
|
} finally {
|
|
63
39
|
setLoading(false);
|
|
64
40
|
}
|
|
65
41
|
};
|
|
66
42
|
|
|
67
|
-
|
|
43
|
+
const handleKeyDown = (e) => {
|
|
44
|
+
if (e.key === "Enter") handleSubmit();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
inputRef.current?.focus();
|
|
49
|
+
}, []);
|
|
68
50
|
|
|
69
51
|
return html`<${motion.div}
|
|
70
52
|
className="passkey-screen"
|
|
@@ -82,23 +64,41 @@ export function PasskeyScreen({ onSuccess }) {
|
|
|
82
64
|
>
|
|
83
65
|
<${LuLock} size=${28} color="#0072ff" />
|
|
84
66
|
<h2>Dashboard Passkey</h2>
|
|
85
|
-
<p>Enter your
|
|
86
|
-
<div className="passkey-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
onInput=${(e) =>
|
|
96
|
-
|
|
97
|
-
|
|
67
|
+
<p>Enter your passkey to continue</p>
|
|
68
|
+
<div className="passkey-field" style=${{ position: "relative", width: "100%" }}>
|
|
69
|
+
<input
|
|
70
|
+
ref=${inputRef}
|
|
71
|
+
type=${show ? "text" : "password"}
|
|
72
|
+
autoComplete="current-password"
|
|
73
|
+
className=${error ? "error" : ""}
|
|
74
|
+
value=${passkey}
|
|
75
|
+
placeholder="Passkey"
|
|
76
|
+
style=${{ width: "100%", paddingRight: "40px" }}
|
|
77
|
+
onInput=${(e) => {
|
|
78
|
+
setPasskey(e.target.value);
|
|
79
|
+
setError("");
|
|
80
|
+
}}
|
|
81
|
+
onKeyDown=${handleKeyDown}
|
|
82
|
+
/>
|
|
83
|
+
<span
|
|
84
|
+
onClick=${() => setShow((s) => !s)}
|
|
85
|
+
title=${show ? "Hide" : "Show"}
|
|
86
|
+
style=${{
|
|
87
|
+
position: "absolute",
|
|
88
|
+
right: "12px",
|
|
89
|
+
top: "50%",
|
|
90
|
+
transform: "translateY(-50%)",
|
|
91
|
+
cursor: "pointer",
|
|
92
|
+
color: "var(--text-muted, #888)",
|
|
93
|
+
display: "flex",
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
${show ? html`<${LuEyeOff} size=${18} />` : html`<${LuEye} size=${18} />`}
|
|
97
|
+
</span>
|
|
98
98
|
</div>
|
|
99
99
|
<div className="passkey-error">${error}</div>
|
|
100
100
|
<button className="passkey-btn" onClick=${handleSubmit} disabled=${loading}>
|
|
101
|
-
${loading ?
|
|
101
|
+
${loading ? "Verifying..." : "Unlock Dashboard"}
|
|
102
102
|
</button>
|
|
103
103
|
<//>
|
|
104
104
|
<//>`;
|