4bnode 4.1.9 → 4.2.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/add-bonjour.js +29 -1
- package/add-email.js +33 -15
- package/add-serialport.js +133 -41
- package/index.js +3 -3
- package/lib/codegen.js +1442 -36
- package/lib/codegen.test.js +233 -13
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +116 -7
- package/skeleton/.4bnode/assets/js/components/CommandPalette.js +165 -0
- package/skeleton/.4bnode/assets/js/components/SerialWorkspace.js +634 -0
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +64 -64
- package/skeleton/.4bnode/assets/js/lib.js +76 -0
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +113 -53
- package/skeleton/.4bnode/assets/js/pages/DashboardPage.js +240 -403
- package/skeleton/.4bnode/assets/js/pages/DiscoveryPage.js +86 -8
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +37 -19
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +1 -1
- package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +5 -5
- package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +154 -3
- package/skeleton/.4bnode/assets/js/pages/SerialPortPage.js +455 -139
- package/skeleton/.4bnode/codegen.js +1442 -36
- package/skeleton/.4bnode/dev-api.js +568 -95
- package/skeleton/.4bnode/ui.html +1241 -240
- package/skeleton/.env.example +23 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +8 -0
- package/skeleton/public/index.html +345 -237
- package/skeleton/src/discovery.js +128 -6
- package/skeleton/src/middleware/apiKey.js +63 -20
|
@@ -15,9 +15,12 @@ export function DiscoveryPage() {
|
|
|
15
15
|
const [info, setInfo] = useState(null);
|
|
16
16
|
const [loading, setLoading] = useState(true);
|
|
17
17
|
const [saving, setSaving] = useState(false);
|
|
18
|
+
const [toggling, setToggling] = useState(false);
|
|
18
19
|
const [name, setName] = useState("");
|
|
19
20
|
const [type, setType] = useState("http");
|
|
20
|
-
const [enabled, setEnabled] = useState(
|
|
21
|
+
const [enabled, setEnabled] = useState(false);
|
|
22
|
+
const [iface, setIface] = useState("");
|
|
23
|
+
const [switchingIface, setSwitchingIface] = useState(false);
|
|
21
24
|
|
|
22
25
|
const [browseType, setBrowseType] = useState("http");
|
|
23
26
|
const [services, setServices] = useState(null);
|
|
@@ -30,7 +33,8 @@ export function DiscoveryPage() {
|
|
|
30
33
|
setInfo(r);
|
|
31
34
|
setName((r.config && r.config.name) || "");
|
|
32
35
|
setType((r.config && r.config.type) || "http");
|
|
33
|
-
setEnabled(r.enabled
|
|
36
|
+
setEnabled(r.enabled === true);
|
|
37
|
+
setIface((r.config && r.config.interface) || "");
|
|
34
38
|
} catch (err) {
|
|
35
39
|
toast(err.message, "error");
|
|
36
40
|
}
|
|
@@ -41,6 +45,40 @@ export function DiscoveryPage() {
|
|
|
41
45
|
loadInfo();
|
|
42
46
|
}, []);
|
|
43
47
|
|
|
48
|
+
// Flip Bonjour on/off immediately — no "Save" step. The dashboard runs in the
|
|
49
|
+
// same process as the advertiser, so the server re-publishes / unpublishes live
|
|
50
|
+
// and persists BONJOUR_ENABLED to .env in one call.
|
|
51
|
+
async function handleToggle(e) {
|
|
52
|
+
const next = e.target.checked;
|
|
53
|
+
setEnabled(next); // optimistic
|
|
54
|
+
setToggling(true);
|
|
55
|
+
try {
|
|
56
|
+
await api("/discovery/config", { method: "POST", body: { enabled: next } });
|
|
57
|
+
toast(next ? "Bonjour enabled" : "Bonjour disabled", "success");
|
|
58
|
+
loadInfo();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setEnabled(!next); // revert on failure
|
|
61
|
+
toast(err.message, "error");
|
|
62
|
+
}
|
|
63
|
+
setToggling(false);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Pick which network to advertise on. Applies live, like the toggle.
|
|
67
|
+
async function handleInterface(next) {
|
|
68
|
+
const prev = iface;
|
|
69
|
+
setIface(next);
|
|
70
|
+
setSwitchingIface(true);
|
|
71
|
+
try {
|
|
72
|
+
await api("/discovery/config", { method: "POST", body: { interface: next } });
|
|
73
|
+
toast(next ? "Advertising on " + next : "Advertising on all networks", "success");
|
|
74
|
+
loadInfo();
|
|
75
|
+
} catch (err) {
|
|
76
|
+
setIface(prev);
|
|
77
|
+
toast(err.message, "error");
|
|
78
|
+
}
|
|
79
|
+
setSwitchingIface(false);
|
|
80
|
+
}
|
|
81
|
+
|
|
44
82
|
async function handleSave() {
|
|
45
83
|
setSaving(true);
|
|
46
84
|
try {
|
|
@@ -114,6 +152,20 @@ export function DiscoveryPage() {
|
|
|
114
152
|
</div>
|
|
115
153
|
</div>`
|
|
116
154
|
: html`<div>
|
|
155
|
+
<!-- Enable / disable — applies instantly, no restart -->
|
|
156
|
+
<div style=${{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "12px", padding: "10px 12px", border: "1px solid var(--border)", borderRadius: "8px", marginBottom: "14px" }}>
|
|
157
|
+
<div>
|
|
158
|
+
<div style=${{ fontSize: "13px", fontWeight: 600 }}>Advertise on local network</div>
|
|
159
|
+
<div className="text-muted" style=${{ fontSize: "12px" }}>
|
|
160
|
+
${enabled ? "On — this app is discoverable via Bonjour/mDNS" : "Off — not broadcasting on the network"}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
<label className="form-toggle">
|
|
164
|
+
<input type="checkbox" checked=${enabled} disabled=${toggling} onChange=${handleToggle} />
|
|
165
|
+
<span className="slider"></span>
|
|
166
|
+
</label>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
117
169
|
${status === "advertising"
|
|
118
170
|
? html`<div style=${{ display: "grid", gridTemplateColumns: "90px 1fr", gap: "6px 12px", fontSize: "13px", marginBottom: "12px" }}>
|
|
119
171
|
<span className="text-muted">Name</span><span>${self.name}</span>
|
|
@@ -122,6 +174,8 @@ export function DiscoveryPage() {
|
|
|
122
174
|
<a href=${`http://${self.host}:${self.port}`} target="_blank" rel="noreferrer">${self.host}:${self.port}</a>
|
|
123
175
|
</span>
|
|
124
176
|
<span className="text-muted">Service</span><span style=${{ fontFamily: "monospace" }}>_${self.type}._tcp.local</span>
|
|
177
|
+
<span className="text-muted">Network</span>
|
|
178
|
+
<span>${self.interface ? `${self.interface.label} · ${self.interface.address}` : "All networks"}</span>
|
|
125
179
|
${self.txt ? Object.entries(self.txt).map(([k, v]) => html`
|
|
126
180
|
<span key=${k} className="text-muted">txt · ${k}</span><span>${String(v)}</span>`) : null}
|
|
127
181
|
</div>`
|
|
@@ -130,16 +184,40 @@ export function DiscoveryPage() {
|
|
|
130
184
|
? html`Advertising failed: <span style=${{ color: "var(--danger, #e5484d)" }}>${self.error}</span>`
|
|
131
185
|
: enabled
|
|
132
186
|
? "Not advertising yet — restart the app to start."
|
|
133
|
-
: "Advertising is
|
|
187
|
+
: "Advertising is off. Turn it on above (sets BONJOUR_ENABLED=on)."}
|
|
134
188
|
</p>`}
|
|
135
189
|
|
|
136
|
-
|
|
190
|
+
${self && self.warning && enabled && html`<div className="text-sm" style=${{ padding: "8px 10px", borderRadius: "7px", background: "var(--warning-glow)", color: "var(--warning)", marginBottom: "12px" }}>
|
|
191
|
+
${self.warning}
|
|
192
|
+
</div>`}
|
|
137
193
|
|
|
138
|
-
|
|
139
|
-
<input type="checkbox" checked=${enabled} onChange=${(e) => setEnabled(e.target.checked)} />
|
|
140
|
-
Advertise this app on the local network
|
|
141
|
-
</label>
|
|
194
|
+
<!-- Which network to advertise on -->
|
|
142
195
|
<div className="form-group">
|
|
196
|
+
<label className="form-label">
|
|
197
|
+
Network${(info.interfaces || []).length > 1 ? " — several connected, pick one" : ""}
|
|
198
|
+
</label>
|
|
199
|
+
<div style=${{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
|
200
|
+
${[{ name: "", label: "All networks", address: "Advertise on every connected network" }, ...(info.interfaces || [])].map((n) => html`
|
|
201
|
+
<label key=${n.name || "all"} style=${{ display: "flex", alignItems: "center", gap: "10px", padding: "8px 10px", cursor: switchingIface ? "wait" : "pointer",
|
|
202
|
+
border: "1px solid " + (iface === n.name ? "var(--border-focus)" : "var(--border)"), borderRadius: "7px",
|
|
203
|
+
background: iface === n.name ? "var(--accent-glow)" : "transparent" }}>
|
|
204
|
+
<input type="radio" name="bonjour-iface" checked=${iface === n.name} disabled=${switchingIface}
|
|
205
|
+
onChange=${() => handleInterface(n.name)} />
|
|
206
|
+
<span style=${{ flex: 1, minWidth: 0 }}>
|
|
207
|
+
<span style=${{ fontSize: "13px", fontWeight: 500 }}>${n.label}</span>
|
|
208
|
+
<span className="text-muted" style=${{ fontSize: "12px", marginLeft: "8px", fontFamily: n.name ? "var(--mono)" : "inherit" }}>${n.address}</span>
|
|
209
|
+
</span>
|
|
210
|
+
${n.selfAssigned && html`<span className="badge badge-warning" title="Self-assigned 169.254.x.x address: no network answered on this adapter">No network</span>`}
|
|
211
|
+
</label>`)}
|
|
212
|
+
${iface && !(info.interfaces || []).some((n) => n.name === iface) && html`<div className="text-sm" style=${{ color: "var(--warning)" }}>
|
|
213
|
+
Saved network "${iface}" is not connected right now, so all networks are used.
|
|
214
|
+
</div>`}
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
<div style=${{ borderTop: "1px solid var(--border)", margin: "4px 0 14px" }}></div>
|
|
219
|
+
|
|
220
|
+
<div className="form-group" style=${{ opacity: enabled ? 1 : 0.55 }}>
|
|
143
221
|
<label className="form-label">Service name</label>
|
|
144
222
|
<input className="form-input" value=${name} placeholder="My App (auto: app + hostname)" onInput=${(e) => setName(e.target.value)} />
|
|
145
223
|
</div>
|
|
@@ -16,13 +16,15 @@ await sendMail({
|
|
|
16
16
|
subject: 'Welcome to 4Brains!',
|
|
17
17
|
html: emailTemplate({ title: 'Welcome', body: 'Thanks for signing up.' }),
|
|
18
18
|
// text: 'Plain-text fallback', // optional
|
|
19
|
-
//
|
|
19
|
+
// replyTo, from // optional
|
|
20
|
+
// cc, bcc // SMTP only
|
|
20
21
|
});`,
|
|
21
22
|
},
|
|
22
23
|
{
|
|
23
24
|
id: "attachment",
|
|
24
25
|
label: "Attachment",
|
|
25
|
-
code:
|
|
26
|
+
code: `// Attachments need the Custom SMTP provider (Sancharak sends text/html only).
|
|
27
|
+
import { sendMail } from '../services/mailer.js';
|
|
26
28
|
|
|
27
29
|
await sendMail({
|
|
28
30
|
to: 'user@example.com',
|
|
@@ -65,9 +67,9 @@ await sendMail({ to: user.email, subject: 'Welcome', html });
|
|
|
65
67
|
export function MailPage() {
|
|
66
68
|
const { toast, refresh } = useApp();
|
|
67
69
|
const [info, setInfo] = useState(null);
|
|
68
|
-
const [provider, setProvider] = useState("
|
|
70
|
+
const [provider, setProvider] = useState("sancharak");
|
|
69
71
|
const [form, setForm] = useState({
|
|
70
|
-
apiKey: "",
|
|
72
|
+
apiKey: "", mailFrom: "", replyTo: "",
|
|
71
73
|
host: "", port: 587, secure: false, user: "", pass: "", smtpFrom: "",
|
|
72
74
|
});
|
|
73
75
|
const [busy, setBusy] = useState(false);
|
|
@@ -96,11 +98,12 @@ export function MailPage() {
|
|
|
96
98
|
setConfigError(r.configured && r.verified === false
|
|
97
99
|
? "The saved email configuration failed its last connection test. Update the details and save again."
|
|
98
100
|
: "");
|
|
99
|
-
setProvider(r.provider || "
|
|
100
|
-
const
|
|
101
|
+
setProvider(r.provider || "sancharak");
|
|
102
|
+
const sk = r.sancharak || {}, sm = r.smtp || {};
|
|
101
103
|
setForm((f) => ({
|
|
102
104
|
...f,
|
|
103
|
-
|
|
105
|
+
mailFrom: sk.from || f.mailFrom,
|
|
106
|
+
replyTo: sk.replyTo || f.replyTo,
|
|
104
107
|
host: sm.host || f.host,
|
|
105
108
|
port: sm.port || f.port || 587,
|
|
106
109
|
secure: !!sm.secure,
|
|
@@ -120,10 +123,18 @@ export function MailPage() {
|
|
|
120
123
|
|
|
121
124
|
async function save() {
|
|
122
125
|
let body;
|
|
123
|
-
if (provider === "
|
|
124
|
-
if (!form.
|
|
125
|
-
if (!form.apiKey.trim() && !(info.
|
|
126
|
-
|
|
126
|
+
if (provider === "sancharak") {
|
|
127
|
+
if (!form.mailFrom.trim()) { toast("From address is required", "error"); return; }
|
|
128
|
+
if (!form.apiKey.trim() && !(info.sancharak && info.sancharak.hasKey)) { toast("API key is required", "error"); return; }
|
|
129
|
+
const k = form.apiKey.trim();
|
|
130
|
+
if (k) {
|
|
131
|
+
// Same rules as the server: a key copied in shortened form ("snk_…") is rejected
|
|
132
|
+
// here, before it can replace the working key.
|
|
133
|
+
if (/[^!-~]/.test(k)) { toast(k.includes("\u2026") ? 'That key contains "…" — it looks shortened. Copy the full key from Sancharak.' : "That key contains spaces or non-ASCII characters.", "error"); return; }
|
|
134
|
+
if (!k.startsWith("snk_")) { toast('A Sancharak API key starts with "snk_"', "error"); return; }
|
|
135
|
+
if (k.length < 24) { toast("That key is too short (" + k.length + " characters) — it may be cut off.", "error"); return; }
|
|
136
|
+
}
|
|
137
|
+
body = { provider: "sancharak", apiKey: form.apiKey.trim(), from: form.mailFrom.trim(), replyTo: form.replyTo.trim(), domainPassword };
|
|
127
138
|
} else {
|
|
128
139
|
if (!form.host.trim()) { toast("SMTP host is required", "error"); return; }
|
|
129
140
|
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 };
|
|
@@ -167,7 +178,7 @@ export function MailPage() {
|
|
|
167
178
|
// SMTP sender is the From, or the User when From is blank — check both, matching
|
|
168
179
|
// the server (from || user).
|
|
169
180
|
const officialRe = /4brains\.in/i;
|
|
170
|
-
const officialValues = provider === "
|
|
181
|
+
const officialValues = provider === "sancharak" ? [form.mailFrom] : [form.host, form.user, form.smtpFrom];
|
|
171
182
|
const isOfficial = officialValues.some((v) => officialRe.test(String(v || "")));
|
|
172
183
|
|
|
173
184
|
const labelStyle = { fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" };
|
|
@@ -176,7 +187,7 @@ export function MailPage() {
|
|
|
176
187
|
<div className="flex items-center justify-between mb-4">
|
|
177
188
|
<div>
|
|
178
189
|
<div className="card-title">Email</div>
|
|
179
|
-
<div className="card-subtitle">Transactional email via
|
|
190
|
+
<div className="card-subtitle">Transactional email via Sancharak or any custom SMTP server</div>
|
|
180
191
|
</div>
|
|
181
192
|
${info.configured && html`<span className="badge badge-success">Configured</span>`}
|
|
182
193
|
</div>
|
|
@@ -192,14 +203,21 @@ export function MailPage() {
|
|
|
192
203
|
</div>
|
|
193
204
|
${current.hint && html`<div className="text-sm text-muted" style=${{ marginBottom: "14px" }}>${current.hint}</div>`}
|
|
194
205
|
|
|
195
|
-
${
|
|
206
|
+
${info.legacyResend && html`<div className="text-sm" style=${{ padding: "8px 10px", borderRadius: "7px", background: "var(--warning-glow)", color: "var(--warning)", marginBottom: "14px" }}>
|
|
207
|
+
This project used IntraApp (Postmaster), which has been replaced by Sancharak. Enter your Sancharak API key and save to switch.
|
|
208
|
+
</div>`}
|
|
209
|
+
${provider === "sancharak"
|
|
196
210
|
? html`<div className="form-group">
|
|
197
211
|
<label className="form-label">From address</label>
|
|
198
|
-
<input className="form-input" value=${form.
|
|
212
|
+
<input className="form-input" value=${form.mailFrom} onInput=${(e) => setField({ mailFrom: e.target.value })} placeholder="Your App <hello@yourdomain.com>" />
|
|
213
|
+
</div>
|
|
214
|
+
<div className="form-group">
|
|
215
|
+
<label className="form-label">Reply-to <span className="text-muted" style=${{ fontWeight: 400 }}>(optional)</span></label>
|
|
216
|
+
<input className="form-input" value=${form.replyTo} onInput=${(e) => setField({ replyTo: e.target.value })} placeholder="support@yourdomain.com" />
|
|
199
217
|
</div>
|
|
200
218
|
<div className="form-group">
|
|
201
|
-
<label className="form-label">API Key ${info.
|
|
202
|
-
<input className="form-input" type="password" value=${form.apiKey} onInput=${(e) => setField({ apiKey: e.target.value })} placeholder=${info.
|
|
219
|
+
<label className="form-label">API Key ${info.sancharak && info.sancharak.hasKey ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved)</span>` : ""}</label>
|
|
220
|
+
<input className="form-input" type="password" autoComplete="off" value=${form.apiKey} onInput=${(e) => setField({ apiKey: e.target.value })} placeholder=${info.sancharak && info.sancharak.hasKey ? "••••••••" : "snk_..."} />
|
|
203
221
|
</div>`
|
|
204
222
|
: html`<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
205
223
|
<div className="form-group" style=${{ flex: "1 1 200px" }}>
|
|
@@ -240,9 +258,9 @@ export function MailPage() {
|
|
|
240
258
|
<label className="form-label">Authorization password</label>
|
|
241
259
|
<input className="form-input" type="password" value=${domainPassword} onInput=${(e) => setDomainPassword(e.target.value)} placeholder="Required for 4brains.in" />
|
|
242
260
|
</div>`}
|
|
243
|
-
<button className="btn btn-primary" onClick=${save} disabled=${busy || (provider === "smtp" && !form.host.trim()) || (provider === "
|
|
261
|
+
<button className="btn btn-primary" onClick=${save} disabled=${busy || (provider === "smtp" && !form.host.trim()) || (provider === "sancharak" && !form.mailFrom.trim()) || (isOfficial && !domainPassword)}>
|
|
244
262
|
${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
|
|
245
|
-
${busy ? "Saving..." : "Save & install"}
|
|
263
|
+
${busy ? "Saving..." : provider === "smtp" ? "Save & install" : "Save"}
|
|
246
264
|
</button>
|
|
247
265
|
|
|
248
266
|
${info.configured && html`<div style=${{ marginTop: "16px", paddingTop: "16px", borderTop: "1px solid var(--border)" }}>
|
|
@@ -232,7 +232,7 @@ function SchemaDetailPanel({ model, reqCount, uniqCount, onEdit, onDelete }) {
|
|
|
232
232
|
<div className="flex items-center justify-between mb-3">
|
|
233
233
|
<div>
|
|
234
234
|
<div style=${{ fontWeight: 600, fontSize: '16px', marginBottom: '2px' }}>${model.name}</div>
|
|
235
|
-
<div className="text-sm text-muted" style=${{ fontFamily: "
|
|
235
|
+
<div className="text-sm text-muted" style=${{ fontFamily: "var(--mono)" }}>src/models/${model.name}.js</div>
|
|
236
236
|
</div>
|
|
237
237
|
<div className="flex gap-2">
|
|
238
238
|
<button className="btn btn-ghost btn-sm" onClick=${onEdit}>
|
|
@@ -264,7 +264,7 @@ function SchemaDetailPanel({ model, reqCount, uniqCount, onEdit, onDelete }) {
|
|
|
264
264
|
<tbody>
|
|
265
265
|
${model.fields?.map((f, i) => html`<tr key=${f.name} style=${{ borderBottom: '1px solid var(--border)' }}>
|
|
266
266
|
<td style=${{ ...tdStyle, color: 'var(--text-muted)' }}>${i + 1}</td>
|
|
267
|
-
<td style=${{ ...tdStyle, fontWeight: 500, fontSize: '13px', fontFamily: "
|
|
267
|
+
<td style=${{ ...tdStyle, fontWeight: 500, fontSize: '13px', fontFamily: "var(--mono)" }}>${f.name}</td>
|
|
268
268
|
<td style=${tdStyle}><span style=${{ fontSize: '12px', padding: '2px 8px', borderRadius: '4px', background: 'var(--accent-glow)', color: 'var(--accent-light)' }}>${f.type || 'String'}</span></td>
|
|
269
269
|
<td style=${tdStyle}><div className="flex gap-1">
|
|
270
270
|
${f.required && html`<span className="badge badge-success" style=${{ fontSize: '10px' }}>required</span>`}
|
|
@@ -415,7 +415,7 @@ function SchemaBuilderPanel({ model, onDone }) {
|
|
|
415
415
|
String: "var(--accent-light)",
|
|
416
416
|
Number: "var(--success)",
|
|
417
417
|
Boolean: "var(--warning)",
|
|
418
|
-
Date: "#
|
|
418
|
+
Date: "#8fb0ff",
|
|
419
419
|
ObjectId: "#f472b6",
|
|
420
420
|
Array: "#22d3ee",
|
|
421
421
|
Mixed: "var(--text-secondary)",
|
|
@@ -658,7 +658,7 @@ function SchemaBuilderPanel({ model, onDone }) {
|
|
|
658
658
|
fontWeight: 500,
|
|
659
659
|
fontSize: "13px",
|
|
660
660
|
flex: 1,
|
|
661
|
-
fontFamily: "
|
|
661
|
+
fontFamily: "var(--mono)",
|
|
662
662
|
}}
|
|
663
663
|
>${f.name}</span
|
|
664
664
|
>
|
|
@@ -819,7 +819,7 @@ function SchemaBuilderPanel({ model, onDone }) {
|
|
|
819
819
|
${indexes.length > 0 && html`<div style=${{ marginTop: '12px' }}>
|
|
820
820
|
${indexes.map((idx, i) => html`<div key=${i} className="flex items-center gap-2" style=${{ padding: '6px 12px', background: 'var(--bg-input)', borderRadius: '6px', marginBottom: '4px', fontSize: '12px' }}>
|
|
821
821
|
<span style=${{ fontSize: '9px', padding: '2px 6px', borderRadius: '4px', fontWeight: 600,
|
|
822
|
-
background: idx.type === 'text' ? 'rgba(59,130,246,0.15)' : 'rgba(
|
|
822
|
+
background: idx.type === 'text' ? 'rgba(59,130,246,0.15)' : 'rgba(91,140,255,0.15)',
|
|
823
823
|
color: idx.type === 'text' ? 'var(--info)' : 'var(--accent-light)' }}>
|
|
824
824
|
${idx.type === 'text' ? 'TEXT' : 'COMPOUND'}
|
|
825
825
|
</span>
|
|
@@ -1,8 +1,157 @@
|
|
|
1
1
|
import React, { useState, useEffect } from "react";
|
|
2
|
-
import { LuCheck, LuGauge } from "react-icons/lu";
|
|
3
|
-
import { html, api } from "../lib.js";
|
|
2
|
+
import { LuCheck, LuGauge, LuKeyRound, LuCopy, LuTrash2, LuPlus } from "react-icons/lu";
|
|
3
|
+
import { html, api, copyToClipboard } from "../lib.js";
|
|
4
4
|
import { useApp } from "../context.js";
|
|
5
5
|
|
|
6
|
+
// Save a key as the API Tester's {{apiKey}} variable. The tester sends it as
|
|
7
|
+
// x-api-key on every request to this app, so the gate never blocks testing.
|
|
8
|
+
function useKeyInTester(key) {
|
|
9
|
+
try {
|
|
10
|
+
const vars = JSON.parse(sessionStorage.getItem("_4bnode_vars") || "[]");
|
|
11
|
+
const list = Array.isArray(vars) ? vars.filter((v) => v.key !== "apiKey") : [];
|
|
12
|
+
list.push({ key: "apiKey", value: key, id: Date.now() });
|
|
13
|
+
sessionStorage.setItem("_4bnode_vars", JSON.stringify(list));
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ApiKeysCard() {
|
|
21
|
+
const { toast, showConfirm, refresh } = useApp();
|
|
22
|
+
const [info, setInfo] = useState(null);
|
|
23
|
+
const [name, setName] = useState("");
|
|
24
|
+
const [creating, setCreating] = useState(false);
|
|
25
|
+
const [fresh, setFresh] = useState(null); // { key, name } shown once
|
|
26
|
+
const [paths, setPaths] = useState("");
|
|
27
|
+
const [savingGate, setSavingGate] = useState(false);
|
|
28
|
+
|
|
29
|
+
async function load() {
|
|
30
|
+
try {
|
|
31
|
+
const r = await api("/security/api-keys");
|
|
32
|
+
setInfo(r);
|
|
33
|
+
setPaths(r.gate.publicPaths || "");
|
|
34
|
+
} catch (err) {
|
|
35
|
+
toast(err.message, "error");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
useEffect(() => { load(); }, []);
|
|
39
|
+
|
|
40
|
+
async function create() {
|
|
41
|
+
setCreating(true);
|
|
42
|
+
try {
|
|
43
|
+
const r = await api("/security/api-keys", { method: "POST", body: { name: name.trim() } });
|
|
44
|
+
setFresh(r);
|
|
45
|
+
setName("");
|
|
46
|
+
load();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
toast(err.message, "error");
|
|
49
|
+
}
|
|
50
|
+
setCreating(false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function revoke(k) {
|
|
54
|
+
const ok = await showConfirm(`Revoke "${k.name}"? Any client using it gets 401 immediately.`, { confirmLabel: "Revoke", danger: true });
|
|
55
|
+
if (!ok) return;
|
|
56
|
+
try {
|
|
57
|
+
await api("/security/api-keys/" + encodeURIComponent(k.id), { method: "DELETE" });
|
|
58
|
+
toast("Key revoked", "success");
|
|
59
|
+
load();
|
|
60
|
+
} catch (err) {
|
|
61
|
+
toast(err.message, "error");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function saveGate(body) {
|
|
66
|
+
setSavingGate(true);
|
|
67
|
+
try {
|
|
68
|
+
const r = await api("/security/api-gate", { method: "POST", body });
|
|
69
|
+
toast(r.message || "Saved", "success");
|
|
70
|
+
if (r.wiredNow) await refresh().catch(() => {});
|
|
71
|
+
load();
|
|
72
|
+
} catch (err) {
|
|
73
|
+
toast(err.message, "error");
|
|
74
|
+
}
|
|
75
|
+
setSavingGate(false);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (!info) return html`<div className="card mb-4"><div className="text-muted text-sm">Loading API keys…</div></div>`;
|
|
79
|
+
const { gate, keys } = info;
|
|
80
|
+
const on = gate.wired && gate.enabled;
|
|
81
|
+
|
|
82
|
+
return html`<div className="card mb-4">
|
|
83
|
+
<div className="flex items-center justify-between mb-1" style=${{ gap: "12px", flexWrap: "wrap" }}>
|
|
84
|
+
<div className="flex items-center gap-2">
|
|
85
|
+
<${LuKeyRound} size=${16} />
|
|
86
|
+
<div className="card-title" style=${{ margin: 0 }}>API keys</div>
|
|
87
|
+
<span className=${`badge ${on ? "badge-success" : "badge-muted"}`}>${on ? "Required on every route" : "Not enforced"}</span>
|
|
88
|
+
</div>
|
|
89
|
+
<label className="form-toggle" title=${on ? "Turn the API key gate off" : "Require an API key on every route"}>
|
|
90
|
+
<input type="checkbox" checked=${on} disabled=${savingGate} onChange=${(e) => saveGate({ enabled: e.target.checked })} />
|
|
91
|
+
<span className="slider"></span>
|
|
92
|
+
</label>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="card-subtitle mb-4">
|
|
95
|
+
Every request must send a valid key in the <span className="mono">x-api-key</span> header, including login and
|
|
96
|
+
registration. Routes that need a signed-in user still check the JWT as well. <span className="mono">/docs</span> and
|
|
97
|
+
the dashboard stay public.
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
${on && keys.length === 0 && html`<div className="text-sm" style=${{ padding: "8px 10px", borderRadius: "7px", background: "var(--warning-glow)", color: "var(--warning)", marginBottom: "14px" }}>
|
|
101
|
+
No keys yet, so every API call is rejected with 401. Create a key below.
|
|
102
|
+
</div>`}
|
|
103
|
+
|
|
104
|
+
${fresh && html`<div style=${{ border: "1px solid var(--border-focus)", background: "var(--accent-glow)", borderRadius: "8px", padding: "12px", marginBottom: "14px" }}>
|
|
105
|
+
<div style=${{ fontSize: "13px", fontWeight: 600, marginBottom: "4px" }}>Key "${fresh.name}" created</div>
|
|
106
|
+
<div className="text-sm text-muted" style=${{ marginBottom: "8px" }}>Copy it now. It is stored hashed and can't be shown again.</div>
|
|
107
|
+
<div className="code-block" style=${{ display: "flex", alignItems: "center", gap: "8px", whiteSpace: "normal", wordBreak: "break-all" }}>
|
|
108
|
+
<code style=${{ flex: 1 }}>${fresh.key}</code>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="flex gap-2" style=${{ marginTop: "10px", flexWrap: "wrap" }}>
|
|
111
|
+
<button className="btn btn-secondary btn-sm" onClick=${() => copyToClipboard(fresh.key).then(() => toast("Copied", "success"))}><${LuCopy} size=${13} /> Copy</button>
|
|
112
|
+
<button className="btn btn-secondary btn-sm" onClick=${() => { if (useKeyInTester(fresh.key)) toast("Saved as {{apiKey}} in the API Tester", "success"); }}>Use in API Tester</button>
|
|
113
|
+
<button className="btn btn-ghost btn-sm" onClick=${() => setFresh(null)}>Done</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>`}
|
|
116
|
+
|
|
117
|
+
<div className="flex gap-2 mb-3" style=${{ flexWrap: "wrap" }}>
|
|
118
|
+
<input className="form-input" style=${{ flex: "1 1 220px" }} placeholder="Key name, e.g. Mobile app" value=${name}
|
|
119
|
+
onInput=${(e) => setName(e.target.value)} onKeyDown=${(e) => e.key === "Enter" && !creating && create()} />
|
|
120
|
+
<button className="btn btn-primary" onClick=${create} disabled=${creating}>
|
|
121
|
+
${creating ? html`<span className="spinner" />` : html`<${LuPlus} size=${14} />`} Create key
|
|
122
|
+
</button>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
${keys.length > 0 && html`<div className="table-wrap" style=${{ border: "1px solid var(--border)", borderRadius: "8px", marginBottom: "14px" }}>
|
|
126
|
+
<table>
|
|
127
|
+
<thead><tr><th>Name</th><th>Key</th><th>Created</th><th></th></tr></thead>
|
|
128
|
+
<tbody>
|
|
129
|
+
${keys.map((k) => html`<tr key=${k.id || k.prefix}>
|
|
130
|
+
<td>${k.name || "Unnamed"}${k.legacy && html` <span className="badge badge-warning" title="Stored in plaintext by an older version. Revoke it and create a new one.">plaintext</span>`}</td>
|
|
131
|
+
<td><span className="mono text-muted">${k.prefix}…</span></td>
|
|
132
|
+
<td className="text-muted">${k.createdAt ? new Date(k.createdAt).toLocaleDateString() : "—"}</td>
|
|
133
|
+
<td style=${{ textAlign: "right" }}>
|
|
134
|
+
<button className="btn btn-ghost btn-sm" onClick=${() => revoke(k)} disabled=${!k.id} title=${k.id ? "Revoke" : "Remove it from src/api-keys.json by hand"}><${LuTrash2} size=${13} /> Revoke</button>
|
|
135
|
+
</td>
|
|
136
|
+
</tr>`)}
|
|
137
|
+
</tbody>
|
|
138
|
+
</table>
|
|
139
|
+
</div>`}
|
|
140
|
+
|
|
141
|
+
<div className="form-group" style=${{ marginBottom: 0 }}>
|
|
142
|
+
<label className="form-label">Public paths (no key needed)</label>
|
|
143
|
+
<div className="flex gap-2" style=${{ flexWrap: "wrap" }}>
|
|
144
|
+
<input className="form-input mono" style=${{ flex: "1 1 260px" }} placeholder="/health,/webhooks/stripe" value=${paths}
|
|
145
|
+
onInput=${(e) => setPaths(e.target.value)} />
|
|
146
|
+
<button className="btn btn-secondary" onClick=${() => saveGate({ publicPaths: paths })} disabled=${savingGate}>Save</button>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="text-sm text-muted" style=${{ marginTop: "6px" }}>
|
|
149
|
+
Comma-separated path prefixes. Keep this short: anything listed here can be called by anyone.
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
</div>`;
|
|
153
|
+
}
|
|
154
|
+
|
|
6
155
|
export function SecurityPage() {
|
|
7
156
|
const { toast } = useApp();
|
|
8
157
|
|
|
@@ -35,10 +184,12 @@ export function SecurityPage() {
|
|
|
35
184
|
<div className="card-subtitle">
|
|
36
185
|
<b>Security is built in</b> — Helmet, rate limiting, CORS, error handling, RBAC, and Zod
|
|
37
186
|
validation ship with every app. Validation is generated per route from each route's own
|
|
38
|
-
fields when you create it.
|
|
187
|
+
fields when you create it. Manage API keys and rate limiting below.
|
|
39
188
|
</div>
|
|
40
189
|
</div>
|
|
41
190
|
|
|
191
|
+
<${ApiKeysCard} />
|
|
192
|
+
|
|
42
193
|
<div className="card">
|
|
43
194
|
<div className="flex items-center gap-2 mb-1">
|
|
44
195
|
<${LuGauge} size=${16} />
|