4bnode 4.0.9 → 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 +41 -3
- 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
|
@@ -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
|
-
|
|
8
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 ||
|
|
58
|
+
return `mongodb://${auth}${h || "localhost"}:${p || "27017"}/${db || appName}`;
|
|
67
59
|
}
|
|
68
60
|
|
|
69
|
-
function
|
|
61
|
+
function getUri() {
|
|
70
62
|
return inputMode === "url"
|
|
71
|
-
? (uri || "").trim() ||
|
|
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
|
|
118
|
-
|
|
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:
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
+
}
|