4bnode 4.2.0 → 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.
@@ -18,7 +18,9 @@ export function DiscoveryPage() {
18
18
  const [toggling, setToggling] = useState(false);
19
19
  const [name, setName] = useState("");
20
20
  const [type, setType] = useState("http");
21
- const [enabled, setEnabled] = useState(true);
21
+ const [enabled, setEnabled] = useState(false);
22
+ const [iface, setIface] = useState("");
23
+ const [switchingIface, setSwitchingIface] = useState(false);
22
24
 
23
25
  const [browseType, setBrowseType] = useState("http");
24
26
  const [services, setServices] = useState(null);
@@ -31,7 +33,8 @@ export function DiscoveryPage() {
31
33
  setInfo(r);
32
34
  setName((r.config && r.config.name) || "");
33
35
  setType((r.config && r.config.type) || "http");
34
- setEnabled(r.enabled !== false);
36
+ setEnabled(r.enabled === true);
37
+ setIface((r.config && r.config.interface) || "");
35
38
  } catch (err) {
36
39
  toast(err.message, "error");
37
40
  }
@@ -60,6 +63,22 @@ export function DiscoveryPage() {
60
63
  setToggling(false);
61
64
  }
62
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
+
63
82
  async function handleSave() {
64
83
  setSaving(true);
65
84
  try {
@@ -155,6 +174,8 @@ export function DiscoveryPage() {
155
174
  <a href=${`http://${self.host}:${self.port}`} target="_blank" rel="noreferrer">${self.host}:${self.port}</a>
156
175
  </span>
157
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>
158
179
  ${self.txt ? Object.entries(self.txt).map(([k, v]) => html`
159
180
  <span key=${k} className="text-muted">txt · ${k}</span><span>${String(v)}</span>`) : null}
160
181
  </div>`
@@ -163,9 +184,37 @@ export function DiscoveryPage() {
163
184
  ? html`Advertising failed: <span style=${{ color: "var(--danger, #e5484d)" }}>${self.error}</span>`
164
185
  : enabled
165
186
  ? "Not advertising yet — restart the app to start."
166
- : "Advertising is disabled (BONJOUR_ENABLED=off)."}
187
+ : "Advertising is off. Turn it on above (sets BONJOUR_ENABLED=on)."}
167
188
  </p>`}
168
189
 
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>`}
193
+
194
+ <!-- Which network to advertise on -->
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
+
169
218
  <div style=${{ borderTop: "1px solid var(--border)", margin: "4px 0 14px" }}></div>
170
219
 
171
220
  <div className="form-group" style=${{ opacity: enabled ? 1 : 0.55 }}>
@@ -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
- // cc, bcc, replyTo, from // optional
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: `import { sendMail } from '../services/mailer.js';
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("resend");
70
+ const [provider, setProvider] = useState("sancharak");
69
71
  const [form, setForm] = useState({
70
- apiKey: "", resendFrom: "",
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 || "resend");
100
- const rs = r.resend || {}, sm = r.smtp || {};
101
+ setProvider(r.provider || "sancharak");
102
+ const sk = r.sancharak || {}, sm = r.smtp || {};
101
103
  setForm((f) => ({
102
104
  ...f,
103
- resendFrom: rs.from || f.resendFrom,
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 === "resend") {
124
- if (!form.resendFrom.trim()) { toast("From address is required", "error"); return; }
125
- if (!form.apiKey.trim() && !(info.resend && info.resend.hasKey)) { toast("API key is required", "error"); return; }
126
- body = { provider: "resend", apiKey: form.apiKey.trim(), from: form.resendFrom.trim(), domainPassword };
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 === "resend" ? [form.resendFrom] : [form.host, form.user, form.smtpFrom];
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 IntraApp(Postmaster) or any custom SMTP server</div>
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
- ${provider === "resend"
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.resendFrom} onInput=${(e) => setField({ resendFrom: e.target.value })} placeholder="no-reply@yourdomain.com" />
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.resend && info.resend.hasKey ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved)</span>` : ""}</label>
202
- <input className="form-input" type="password" value=${form.apiKey} onInput=${(e) => setField({ apiKey: e.target.value })} placeholder=${info.resend && info.resend.hasKey ? "••••••••" : "re_..."} />
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 === "resend" && !form.resendFrom.trim()) || (isOfficial && !domainPassword)}>
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)" }}>
@@ -248,7 +248,7 @@ export function RoutesPage() {
248
248
  <div
249
249
  className="text-sm text-muted"
250
250
  style=${{
251
- fontFamily: "'SF Mono', 'Fira Code', monospace",
251
+ fontFamily: "var(--mono)",
252
252
  }}
253
253
  >
254
254
  src/routes/${selectedRoute}
@@ -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: "'SF Mono', monospace" }}>src/models/${model.name}.js</div>
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: "'SF Mono', monospace" }}>${f.name}</td>
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: "#a78bfa",
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: "'SF Mono', 'Fira Code', monospace",
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(124,58,237,0.15)',
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. Adjust rate limiting below.
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} />