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.
Files changed (45) hide show
  1. package/README.md +25 -206
  2. package/add-crud.js +6 -106
  3. package/add-docs.js +91 -0
  4. package/add-email.js +63 -0
  5. package/add-security.js +143 -0
  6. package/index.js +76 -8
  7. package/lib/codegen.js +1223 -0
  8. package/lib/codegen.test.js +480 -0
  9. package/lib/indexFile.js +10 -12
  10. package/lib/names.js +4 -21
  11. package/lib/routeFile.js +9 -27
  12. package/lib/sync-skeleton.js +47 -0
  13. package/lib/ui.js +15 -2
  14. package/package.json +4 -2
  15. package/scripts/audit-dev-surface.mjs +153 -0
  16. package/scripts/reveal-passkey.mjs +35 -0
  17. package/skeleton/.4bnode/assets/js/App.js +31 -9
  18. package/skeleton/.4bnode/assets/js/Root.js +1 -5
  19. package/skeleton/.4bnode/assets/js/components/Sidebar.js +9 -5
  20. package/skeleton/.4bnode/assets/js/lib.js +18 -5
  21. package/skeleton/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
  22. package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +164 -0
  23. package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +275 -9
  24. package/skeleton/.4bnode/assets/js/pages/DatabasePage.js +44 -205
  25. package/skeleton/.4bnode/assets/js/pages/DocsPage.js +142 -0
  26. package/skeleton/.4bnode/assets/js/pages/MailPage.js +134 -0
  27. package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +5 -266
  28. package/skeleton/.4bnode/assets/js/pages/SchemasPage.js +23 -213
  29. package/skeleton/.4bnode/assets/js/pages/SecurityPage.js +74 -0
  30. package/skeleton/.4bnode/assets/js/screens/PasskeyScreen.js +61 -61
  31. package/skeleton/.4bnode/codegen.js +1223 -0
  32. package/skeleton/.4bnode/dev-api.js +634 -728
  33. package/skeleton/.4bnode/ui.html +62 -40
  34. package/skeleton/_gitignore +7 -0
  35. package/skeleton/index.js +46 -5
  36. package/skeleton/package.json +4 -1
  37. package/skeleton/src/middleware/apiKey.js +36 -0
  38. package/skeleton/src/middleware/errorHandler.js +15 -0
  39. package/skeleton/src/middleware/roles.js +14 -0
  40. package/skeleton/src/middleware/validate.js +20 -0
  41. package/skeleton/.4bnode/assets/images/mysql.png +0 -0
  42. package/skeleton/.4bnode/assets/images/postgresql.png +0 -0
  43. package/skeleton/.4bnode/assets/images/sqlite.png +0 -0
  44. package/skeleton/.4bnode/assets/js/pages/ReportsPage.js +0 -667
  45. package/skeleton/.4bnode/assets/js/screens/SplashScreen.js +0 -66
@@ -3,57 +3,49 @@ import { html, api } from "../lib.js";
3
3
  import { useApp } from "../context.js";
4
4
 
5
5
  export function DatabasePage() {
6
- const { data, toast, refresh } = useApp();
7
- const [selectedDb, setSelectedDb] = useState(
8
- data.mongoConfigured ? "mongodb" : null,
9
- );
6
+ const { data, status, toast, refresh } = useApp();
7
+ // MongoDB is the only database, so the page shows its configuration directly
8
+ // (no database picker).
9
+ // Default the database name to the app's folder name (provided by the server).
10
+ const appName = status?.appName || "myapp";
10
11
  const [inputMode, setInputMode] = useState("fields"); // 'fields' or 'url'
11
12
  const [uri, setUri] = useState("");
12
- const [prodUri, setProdUri] = useState("");
13
13
  const [host, setHost] = useState("localhost");
14
14
  const [port, setPort] = useState("27017");
15
- const [dbName, setDbName] = useState("myapp");
15
+ const [dbName, setDbName] = useState("");
16
+ const [dbNameTouched, setDbNameTouched] = useState(false);
16
17
  const [username, setUsername] = useState("");
17
18
  const [password, setPassword] = useState("");
18
- const [prodHost, setProdHost] = useState("");
19
- const [prodPort, setProdPort] = useState("");
20
- const [prodDbName, setProdDbName] = useState("");
21
- const [prodUsername, setProdUsername] = useState("");
22
- const [prodPassword, setProdPassword] = useState("");
23
19
  const [loading, setLoading] = useState(false);
24
20
  const [editing, setEditing] = useState(false);
25
21
 
22
+ // Prefill the db name with the folder name once status loads — unless the user
23
+ // edited it, or we're editing an already-configured connection.
24
+ useEffect(() => {
25
+ if (!dbNameTouched && !data.mongoConfigured && status?.appName) {
26
+ setDbName(status.appName);
27
+ }
28
+ }, [status?.appName, data.mongoConfigured, dbNameTouched]);
29
+
26
30
  // Parse existing URI when entering edit mode
27
31
  useEffect(() => {
28
32
  if (data.mongoConfigured && data.mongoUri) {
29
33
  setUri(data.mongoUri);
30
- setProdUri(data.mongoProdUri || "");
31
- parseUriToFields(data.mongoUri, false);
32
- if (data.mongoProdUri && data.mongoProdUri !== data.mongoUri) {
33
- parseUriToFields(data.mongoProdUri, true);
34
- }
34
+ parseUriToFields(data.mongoUri);
35
35
  }
36
36
  }, [data.mongoUri]);
37
37
 
38
- function parseUriToFields(uriStr, isProd) {
38
+ function parseUriToFields(uriStr) {
39
39
  try {
40
40
  const match = uriStr.match(
41
41
  /^mongodb(?:\+srv)?:\/\/(?:([^:]+):([^@]+)@)?([^:\/]+)(?::(\d+))?\/(.+?)(?:\?.*)?$/,
42
42
  );
43
43
  if (match) {
44
- if (isProd) {
45
- setProdUsername(match[1] || "");
46
- setProdPassword(match[2] || "");
47
- setProdHost(match[3] || "");
48
- setProdPort(match[4] || "27017");
49
- setProdDbName(match[5] || "");
50
- } else {
51
- setUsername(match[1] || "");
52
- setPassword(match[2] || "");
53
- setHost(match[3] || "localhost");
54
- setPort(match[4] || "27017");
55
- setDbName(match[5] || "myapp");
56
- }
44
+ setUsername(match[1] || "");
45
+ setPassword(match[2] || "");
46
+ setHost(match[3] || "localhost");
47
+ setPort(match[4] || "27017");
48
+ setDbName(match[5] || appName);
57
49
  }
58
50
  } catch {}
59
51
  }
@@ -63,60 +55,18 @@ export function DatabasePage() {
63
55
  user && pass
64
56
  ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@`
65
57
  : "";
66
- return `mongodb://${auth}${h || "localhost"}:${p || "27017"}/${db || "myapp"}`;
58
+ return `mongodb://${auth}${h || "localhost"}:${p || "27017"}/${db || appName}`;
67
59
  }
68
60
 
69
- function getDevUri() {
61
+ function getUri() {
70
62
  return inputMode === "url"
71
- ? (uri || "").trim() || "mongodb://localhost:27017/myapp"
63
+ ? (uri || "").trim() || `mongodb://localhost:27017/${appName}`
72
64
  : buildUri(host, port, dbName, username, password);
73
65
  }
74
66
 
75
- function getProdUri() {
76
- if (inputMode === "url") return (prodUri || "").trim() || getDevUri();
77
- const ph = prodHost || host,
78
- pp = prodPort || port,
79
- pd = prodDbName || dbName;
80
- const pu = prodUsername || username,
81
- ppw = prodPassword || password;
82
- return buildUri(ph, pp, pd, pu, ppw);
83
- }
84
-
85
- const databases = [
86
- {
87
- id: "mongodb",
88
- name: "MongoDB",
89
- desc: "Document database",
90
- icon: "/_dev/assets/images/mongo.png",
91
- available: true,
92
- },
93
- {
94
- id: "postgresql",
95
- name: "PostgreSQL",
96
- desc: "Relational database",
97
- icon: "/_dev/assets/images/postgresql.png",
98
- available: false,
99
- },
100
- {
101
- id: "mysql",
102
- name: "MySQL",
103
- desc: "Relational database",
104
- icon: "/_dev/assets/images/mysql.png",
105
- available: false,
106
- },
107
- {
108
- id: "sqlite",
109
- name: "SQLite",
110
- desc: "Embedded database",
111
- icon: "/_dev/assets/images/sqlite.png",
112
- available: false,
113
- },
114
- ];
115
-
116
67
  async function handleSetup() {
117
- const devUri = getDevUri();
118
- const pUri = getProdUri();
119
- if (!devUri.startsWith("mongodb://") && !devUri.startsWith("mongodb+srv://")) {
68
+ const connUri = getUri();
69
+ if (!connUri.startsWith("mongodb://") && !connUri.startsWith("mongodb+srv://")) {
120
70
  toast("Invalid MongoDB URI. Must start with mongodb:// or mongodb+srv://", "error");
121
71
  return;
122
72
  }
@@ -125,7 +75,7 @@ export function DatabasePage() {
125
75
  const isFirstTime = !data.mongoConfigured;
126
76
  const result = await api("/add-mongo", {
127
77
  method: "POST",
128
- body: { uri: devUri, prodUri: pUri },
78
+ body: { uri: connUri },
129
79
  });
130
80
  toast(result.message, "success");
131
81
  setEditing(false);
@@ -158,8 +108,7 @@ export function DatabasePage() {
158
108
  return u.replace(/:([^@\/]+)@/, ":****@");
159
109
  }
160
110
 
161
- const showForm =
162
- (selectedDb === "mongodb" && !data.mongoConfigured) || editing;
111
+ const showForm = !data.mongoConfigured || editing;
163
112
 
164
113
  const inputModeToggle = html`<div className="flex gap-2 mb-4">
165
114
  <button
@@ -178,7 +127,7 @@ export function DatabasePage() {
178
127
 
179
128
  const urlInputs = html`<fragment>
180
129
  <div className="form-group">
181
- <label className="form-label">Connection URI (Development)</label>
130
+ <label className="form-label">Connection URI</label>
182
131
  <input
183
132
  className="form-input"
184
133
  value=${uri}
@@ -186,30 +135,9 @@ export function DatabasePage() {
186
135
  placeholder="mongodb://username:password@localhost:27017/myapp"
187
136
  />
188
137
  </div>
189
- <div className="form-group">
190
- <label className="form-label"
191
- >Connection URI (Production) — optional</label
192
- >
193
- <input
194
- className="form-input"
195
- value=${prodUri}
196
- onInput=${(e) => setProdUri(e.target.value)}
197
- placeholder="Same as development if empty"
198
- />
199
- </div>
200
138
  </fragment>`;
201
139
 
202
140
  const fieldInputs = html`<fragment>
203
- <div
204
- className="card-subtitle mb-3"
205
- style=${{
206
- fontSize: "12px",
207
- textTransform: "uppercase",
208
- letterSpacing: "0.05em",
209
- }}
210
- >
211
- Development
212
- </div>
213
141
  <div className="grid-2 mb-2">
214
142
  <div className="form-group">
215
143
  <label className="form-label">Host</label>
@@ -235,8 +163,8 @@ export function DatabasePage() {
235
163
  <input
236
164
  className="form-input"
237
165
  value=${dbName}
238
- onInput=${(e) => setDbName(e.target.value)}
239
- placeholder="myapp"
166
+ onInput=${(e) => { setDbNameTouched(true); setDbName(e.target.value); }}
167
+ placeholder=${appName}
240
168
  />
241
169
  </div>
242
170
  <div className="grid-2 mb-4">
@@ -260,69 +188,6 @@ export function DatabasePage() {
260
188
  />
261
189
  </div>
262
190
  </div>
263
-
264
- <div
265
- className="card-subtitle mb-3"
266
- style=${{
267
- fontSize: "12px",
268
- textTransform: "uppercase",
269
- letterSpacing: "0.05em",
270
- borderTop: "1px solid var(--border)",
271
- paddingTop: "16px",
272
- }}
273
- >
274
- Production (optional — defaults to dev values)
275
- </div>
276
- <div className="grid-2 mb-2">
277
- <div className="form-group">
278
- <label className="form-label">Host</label>
279
- <input
280
- className="form-input"
281
- value=${prodHost}
282
- onInput=${(e) => setProdHost(e.target.value)}
283
- placeholder=${host || "localhost"}
284
- />
285
- </div>
286
- <div className="form-group">
287
- <label className="form-label">Port</label>
288
- <input
289
- className="form-input"
290
- value=${prodPort}
291
- onInput=${(e) => setProdPort(e.target.value)}
292
- placeholder=${port || "27017"}
293
- />
294
- </div>
295
- </div>
296
- <div className="form-group">
297
- <label className="form-label">Database Name</label>
298
- <input
299
- className="form-input"
300
- value=${prodDbName}
301
- onInput=${(e) => setProdDbName(e.target.value)}
302
- placeholder=${dbName || "myapp"}
303
- />
304
- </div>
305
- <div className="grid-2">
306
- <div className="form-group">
307
- <label className="form-label">Username</label>
308
- <input
309
- className="form-input"
310
- value=${prodUsername}
311
- onInput=${(e) => setProdUsername(e.target.value)}
312
- placeholder=${username || ""}
313
- />
314
- </div>
315
- <div className="form-group">
316
- <label className="form-label">Password</label>
317
- <input
318
- className="form-input"
319
- type="password"
320
- value=${prodPassword}
321
- onInput=${(e) => setProdPassword(e.target.value)}
322
- placeholder="********"
323
- />
324
- </div>
325
- </div>
326
191
  </fragment>`;
327
192
 
328
193
  // Preview the generated URI when in fields mode
@@ -344,12 +209,17 @@ export function DatabasePage() {
344
209
  wordBreak: "break-all",
345
210
  }}
346
211
  >
347
- ${maskUri(getDevUri())}
212
+ ${maskUri(getUri())}
348
213
  </div>
349
214
  </div>`
350
215
  : null;
351
216
 
352
217
  return html`<div>
218
+ <div className="card-title mb-1">Database</div>
219
+ <div className="card-subtitle mb-4">
220
+ Configure the MongoDB connection for your project
221
+ </div>
222
+
353
223
  ${data.mongoConfigured &&
354
224
  !editing &&
355
225
  html`<div
@@ -378,12 +248,7 @@ export function DatabasePage() {
378
248
  onClick=${() => {
379
249
  if (data.mongoUri) {
380
250
  setUri(data.mongoUri);
381
- parseUriToFields(data.mongoUri, false);
382
- }
383
- if (data.mongoProdUri) {
384
- setProdUri(data.mongoProdUri);
385
- if (data.mongoProdUri !== data.mongoUri)
386
- parseUriToFields(data.mongoProdUri, true);
251
+ parseUriToFields(data.mongoUri);
387
252
  }
388
253
  setEditing(true);
389
254
  }}
@@ -395,40 +260,14 @@ export function DatabasePage() {
395
260
  </div>
396
261
  </div>`}
397
262
 
398
- <div className="card-title mb-3">Select Database</div>
399
- <div className="card-subtitle mb-4">
400
- Select which database to use for your project
401
- </div>
402
- <div className="grid-4 mb-6">
403
- ${databases.map(
404
- (db) =>
405
- html`<div
406
- key=${db.id}
407
- className=${`db-card ${selectedDb === db.id ? "active" : ""} ${!db.available ? "disabled" : ""}`}
408
- onClick=${() => db.available && setSelectedDb(db.id)}
409
- >
410
- ${!db.available &&
411
- html`<span className="coming-soon">Coming Soon</span>`}
412
- <div className="db-icon">
413
- <img
414
- src=${db.icon}
415
- alt=${db.name}
416
- style=${{
417
- width: "36px",
418
- height: "36px",
419
- objectFit: "contain",
420
- }}
421
- />
422
- </div>
423
- <div className="db-name">${db.name}</div>
424
- <div className="db-desc">${db.desc}</div>
425
- </div>`,
426
- )}
427
- </div>
428
-
429
263
  ${showForm &&
430
264
  html`<div className="card">
431
265
  <div className="flex items-center gap-3 mb-4">
266
+ <img
267
+ src="/_dev/assets/images/mongo.png"
268
+ alt="MongoDB"
269
+ style=${{ width: "24px", height: "24px", objectFit: "contain" }}
270
+ />
432
271
  <div className="card-title" style=${{ margin: 0 }}>
433
272
  ${editing ? "Edit Connection" : "MongoDB Setup"}
434
273
  </div>
@@ -0,0 +1,142 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { LuFileText, LuCheck, LuRefreshCw, LuExternalLink } from "react-icons/lu";
3
+ import { html, api } from "../lib.js";
4
+ import { useApp } from "../context.js";
5
+
6
+ export function DocsPage() {
7
+ const { toast, refresh, showConfirm } = useApp();
8
+ const [info, setInfo] = useState(null);
9
+ const [busy, setBusy] = useState(false);
10
+ const [pw, setPw] = useState("");
11
+ const [pwBusy, setPwBusy] = useState(false);
12
+
13
+ const load = useCallback(async () => {
14
+ try { setInfo(await api("/docs")); } catch (err) { toast(err.message, "error"); }
15
+ }, [toast]);
16
+
17
+ useEffect(() => { load(); }, [load]);
18
+
19
+ const docsUrl = (() => {
20
+ const port = window.location.port || "3000";
21
+ return window.location.protocol + "//" + window.location.hostname + ":" + port + "/docs";
22
+ })();
23
+
24
+ async function setup() {
25
+ setBusy(true);
26
+ try {
27
+ const r = await api("/docs/setup", { method: "POST" });
28
+ toast(r.message, "success");
29
+ await load();
30
+ refresh().catch(() => {});
31
+ } catch (err) { toast(err.message, "error"); }
32
+ setBusy(false);
33
+ }
34
+
35
+ async function regenerate() {
36
+ setBusy(true);
37
+ try {
38
+ const r = await api("/docs/regenerate", { method: "POST" });
39
+ toast(r.message, "success");
40
+ await load();
41
+ } catch (err) { toast(err.message, "error"); }
42
+ setBusy(false);
43
+ }
44
+
45
+ async function setPassword() {
46
+ if (pw.length < 6) { toast("Password must be at least 6 characters", "error"); return; }
47
+ setPwBusy(true);
48
+ try {
49
+ const r = await api("/docs/password", { method: "POST", body: { password: pw } });
50
+ toast(r.message, "success");
51
+ setPw("");
52
+ await load();
53
+ } catch (err) { toast(err.message, "error"); }
54
+ setPwBusy(false);
55
+ }
56
+
57
+ async function removePassword() {
58
+ const ok = await showConfirm("Remove the docs password? /docs will be publicly accessible.", { danger: true, confirmLabel: "Remove" });
59
+ if (!ok) return;
60
+ setPwBusy(true);
61
+ try {
62
+ const r = await api("/docs/password", { method: "DELETE" });
63
+ toast(r.message, "success");
64
+ await load();
65
+ } catch (err) { toast(err.message, "error"); }
66
+ setPwBusy(false);
67
+ }
68
+
69
+ if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
70
+
71
+ return html`<div>
72
+ <div className="flex items-center justify-between mb-4">
73
+ <div>
74
+ <div className="card-title">API Documentation</div>
75
+ <div className="card-subtitle">OpenAPI 3 spec + interactive Swagger UI, generated from your routes</div>
76
+ </div>
77
+ </div>
78
+
79
+ ${!info.configured
80
+ ? html`<div className="card" style=${{ maxWidth: "560px" }}>
81
+ <div className="flex items-center gap-3 mb-4">
82
+ <${LuFileText} size=${20} color="var(--accent)" />
83
+ <div>
84
+ <div style=${{ fontWeight: 600 }}>Set up API docs</div>
85
+ <div className="text-sm text-muted">Adds a /docs route (Swagger UI) and generates the OpenAPI spec from your ${info.endpointCount} endpoint${info.endpointCount === 1 ? "" : "s"}.</div>
86
+ </div>
87
+ </div>
88
+ <button className="btn btn-primary" onClick=${setup} disabled=${busy}>
89
+ ${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
90
+ ${busy ? "Setting up..." : "Set up API docs"}
91
+ </button>
92
+ </div>`
93
+ : html`<div>
94
+ <div className="card mb-4">
95
+ <div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
96
+ <${LuFileText} size=${20} color="var(--success)" />
97
+ <div style=${{ flex: 1, minWidth: "180px" }}>
98
+ <div style=${{ fontWeight: 600 }}>Docs are live</div>
99
+ <div className="text-sm text-muted">${info.endpointCount} endpoint${info.endpointCount === 1 ? "" : "s"} documented · spec at <span style=${{ fontFamily: "monospace" }}>src/openapi.json</span></div>
100
+ </div>
101
+ <a className="btn" href=${docsUrl} target="_blank" rel="noreferrer" style=${{ textDecoration: "none" }}>
102
+ <${LuExternalLink} size=${14} /> Open /docs
103
+ </a>
104
+ <button className="btn btn-primary" onClick=${regenerate} disabled=${busy}>
105
+ ${busy ? html`<span className="spinner" />` : html`<${LuRefreshCw} size=${14} />`}
106
+ Regenerate
107
+ </button>
108
+ </div>
109
+ </div>
110
+ <div className="card mb-4">
111
+ <div className="card-title mb-1">Docs access</div>
112
+ <div className="card-subtitle mb-4">
113
+ By default <span style=${{ fontFamily: "monospace" }}>/docs</span> is public — anyone who can reach the app can read it.
114
+ Set a password to require HTTP Basic Auth (applies immediately, no restart).
115
+ </div>
116
+ ${info.passwordProtected
117
+ ? html`<div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
118
+ <span className="badge badge-success">🔒 Password protected</span>
119
+ <button className="btn btn-sm btn-ghost" onClick=${removePassword} disabled=${pwBusy}
120
+ style=${{ color: "var(--danger, #ef4444)" }}>Remove password</button>
121
+ <div style=${{ display: "flex", gap: "8px", marginLeft: "auto", flexWrap: "wrap" }}>
122
+ <input className="form-input" type="password" placeholder="New password" value=${pw}
123
+ onInput=${(e) => setPw(e.target.value)} style=${{ width: "200px" }} />
124
+ <button className="btn btn-sm btn-primary" onClick=${setPassword} disabled=${pwBusy || pw.length < 6}>Change</button>
125
+ </div>
126
+ </div>`
127
+ : html`<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
128
+ <input className="form-input" type="password" placeholder="Password (min 6 chars)" value=${pw}
129
+ onInput=${(e) => setPw(e.target.value)}
130
+ onKeyDown=${(e) => { if (e.key === "Enter") setPassword(); }}
131
+ style=${{ flex: 1, minWidth: "220px" }} />
132
+ <button className="btn btn-primary" onClick=${setPassword} disabled=${pwBusy || pw.length < 6}>
133
+ ${pwBusy ? html`<span className="spinner" />` : null} Set password
134
+ </button>
135
+ </div>`}
136
+ </div>
137
+ <div className="card text-sm text-muted">
138
+ The spec stays in sync automatically — it regenerates whenever you add or change routes and models. Use Regenerate to force a refresh.
139
+ </div>
140
+ </div>`}
141
+ </div>`;
142
+ }
@@ -0,0 +1,134 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { LuMail, LuCheck, LuSend } from "react-icons/lu";
3
+ import { html, api } from "../lib.js";
4
+ import { useApp } from "../context.js";
5
+
6
+ export function MailPage() {
7
+ const { toast, refresh } = useApp();
8
+ const [info, setInfo] = useState(null);
9
+ const [provider, setProvider] = useState("brevo");
10
+ const [form, setForm] = useState({ host: "", port: 587, secure: false, user: "", pass: "", from: "" });
11
+ const [busy, setBusy] = useState(false);
12
+ const [testTo, setTestTo] = useState("");
13
+ const [testing, setTesting] = useState(false);
14
+
15
+ const load = useCallback(async () => {
16
+ try {
17
+ const r = await api("/mail");
18
+ setInfo(r);
19
+ setForm((f) => ({
20
+ ...f,
21
+ host: r.host || f.host,
22
+ port: r.port || f.port || 587,
23
+ secure: !!r.secure,
24
+ user: r.user || f.user,
25
+ from: r.from || f.from,
26
+ }));
27
+ } catch (err) { toast(err.message, "error"); }
28
+ }, [toast]);
29
+
30
+ useEffect(() => { load(); }, [load]);
31
+
32
+ function setField(patch) { setForm((f) => ({ ...f, ...patch })); }
33
+
34
+ function pickProvider(p) {
35
+ setProvider(p.id);
36
+ if (p.id !== "smtp") setField({ host: p.host, port: p.port, secure: p.secure, user: p.user || form.user });
37
+ }
38
+
39
+ async function save() {
40
+ if (!form.host.trim()) { toast("SMTP host is required", "error"); return; }
41
+ setBusy(true);
42
+ try {
43
+ const r = await api("/mail/setup", { method: "POST", body: { provider, ...form } });
44
+ toast(r.message, "success");
45
+ await load();
46
+ refresh().catch(() => {});
47
+ } catch (err) { toast(err.message, "error"); }
48
+ setBusy(false);
49
+ }
50
+
51
+ async function sendTest() {
52
+ if (!testTo.trim()) { toast("Enter a recipient", "error"); return; }
53
+ setTesting(true);
54
+ try {
55
+ const r = await api("/mail/test", { method: "POST", body: { to: testTo.trim() } });
56
+ toast(r.message, "success");
57
+ } catch (err) { toast(err.message, "error"); }
58
+ setTesting(false);
59
+ }
60
+
61
+ if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
62
+
63
+ const current = info.providers.find((p) => p.id === provider) || {};
64
+
65
+ return html`<div>
66
+ <div className="flex items-center justify-between mb-4">
67
+ <div>
68
+ <div className="card-title">Email</div>
69
+ <div className="card-subtitle">Send transactional email via Brevo, SendGrid, Mailgun, Gmail, or any SMTP</div>
70
+ </div>
71
+ ${info.configured && html`<span className="badge badge-success">Configured</span>`}
72
+ </div>
73
+
74
+ <div className="card mb-4">
75
+ <div style=${{ fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" }}>Provider</div>
76
+ <div style=${{ display: "flex", gap: "6px", flexWrap: "wrap", marginBottom: "8px" }}>
77
+ ${info.providers.map((p) => html`<button key=${p.id} className="badge"
78
+ onClick=${() => pickProvider(p)}
79
+ 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>`)}
80
+ </div>
81
+ ${current.hint && html`<div className="text-sm text-muted">${current.hint}</div>`}
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>
98
+ </div>
99
+ </div>
100
+ <div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
101
+ <div className="form-group" style=${{ flex: "1 1 240px" }}>
102
+ <label className="form-label">User</label>
103
+ <input className="form-input" value=${form.user} onInput=${(e) => setField({ user: e.target.value })} placeholder="login / apikey" />
104
+ </div>
105
+ <div className="form-group" style=${{ flex: "1 1 240px" }}>
106
+ <label className="form-label">Password / API key ${info.hasPass ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved — leave blank to keep)</span>` : ""}</label>
107
+ <input className="form-input" type="password" value=${form.pass} onInput=${(e) => setField({ pass: e.target.value })} placeholder=${info.hasPass ? "••••••••" : "secret"} />
108
+ </div>
109
+ </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
+
120
+ ${info.configured && html`<div className="card">
121
+ <div className="card-title mb-2">Send test email</div>
122
+ <div style=${{ display: "flex", gap: "8px", alignItems: "flex-end", flexWrap: "wrap" }}>
123
+ <div className="form-group" style=${{ flex: "1 1 260px", marginBottom: 0 }}>
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>
131
+ </div>
132
+ </div>`}
133
+ </div>`;
134
+ }