4bnode 4.1.2 → 4.1.4
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-security.js +0 -18
- package/app-1/.4bnode/.passkey +1 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Bold.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Light.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Medium.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-Regular.woff2 +0 -0
- package/app-1/.4bnode/assets/fonts/NotoSans-SemiBold.woff2 +0 -0
- package/app-1/.4bnode/assets/images/mongo.png +0 -0
- package/app-1/.4bnode/assets/js/App.js +139 -0
- package/app-1/.4bnode/assets/js/Root.js +40 -0
- package/app-1/.4bnode/assets/js/components/Modal.js +52 -0
- package/app-1/.4bnode/assets/js/components/Sidebar.js +73 -0
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +15 -0
- package/app-1/.4bnode/assets/js/context.js +7 -0
- package/app-1/.4bnode/assets/js/lib.js +113 -0
- package/app-1/.4bnode/assets/js/main.js +5 -0
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +268 -0
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +1879 -0
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +435 -0
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +300 -0
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +150 -0
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +103 -0
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +205 -0
- package/app-1/.4bnode/assets/js/pages/MailPage.js +134 -0
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +186 -0
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +1436 -0
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +838 -0
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +74 -0
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +170 -0
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +105 -0
- package/app-1/.4bnode/codegen.js +1249 -0
- package/app-1/.4bnode/dev-api.js +3621 -0
- package/app-1/.4bnode/ui.html +1578 -0
- package/app-1/README.md +47 -0
- package/app-1/ecosystem.config.cjs +25 -0
- package/app-1/index.js +145 -0
- package/app-1/package.json +35 -0
- package/app-1/public/index.html +292 -0
- package/app-1/src/db.js +13 -0
- package/app-1/src/docs.js +84 -0
- package/app-1/src/middleware/errorHandler.js +15 -0
- package/app-1/src/middleware/roles.js +14 -0
- package/app-1/src/middleware/validate.js +20 -0
- package/app-1/src/models/login.js +9 -0
- package/app-1/src/openapi.json +62 -0
- package/app-1/src/routes/products.js +26 -0
- package/app-1/src/validators/products.js +7 -0
- package/app-1/src/validators/user.js +7 -0
- package/index.js +2 -17
- package/lib/codegen.js +91 -44
- package/lib/codegen.test.js +23 -3
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/App.js +0 -2
- package/skeleton/.4bnode/assets/js/components/Sidebar.js +2 -3
- package/skeleton/.4bnode/assets/js/lib.js +0 -3
- package/skeleton/.4bnode/assets/js/pages/DocsPage.js +29 -21
- package/skeleton/.4bnode/assets/js/pages/RoutesPage.js +12 -0
- package/skeleton/.4bnode/codegen.js +91 -44
- package/skeleton/.4bnode/dev-api.js +173 -87
- package/skeleton/_gitignore +0 -1
- package/skeleton/index.js +4 -8
- package/skeleton/.4bnode/assets/js/pages/ApiKeysPage.js +0 -164
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { html, api } from "../lib.js";
|
|
3
|
+
import { useApp } from "../context.js";
|
|
4
|
+
|
|
5
|
+
export function DatabasePage() {
|
|
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";
|
|
11
|
+
const [inputMode, setInputMode] = useState("fields"); // 'fields' or 'url'
|
|
12
|
+
const [uri, setUri] = useState("");
|
|
13
|
+
const [host, setHost] = useState("localhost");
|
|
14
|
+
const [port, setPort] = useState("27017");
|
|
15
|
+
const [dbName, setDbName] = useState("");
|
|
16
|
+
const [dbNameTouched, setDbNameTouched] = useState(false);
|
|
17
|
+
const [username, setUsername] = useState("");
|
|
18
|
+
const [password, setPassword] = useState("");
|
|
19
|
+
const [loading, setLoading] = useState(false);
|
|
20
|
+
const [editing, setEditing] = useState(false);
|
|
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
|
+
|
|
30
|
+
// Parse existing URI when entering edit mode
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (data.mongoConfigured && data.mongoUri) {
|
|
33
|
+
setUri(data.mongoUri);
|
|
34
|
+
parseUriToFields(data.mongoUri);
|
|
35
|
+
}
|
|
36
|
+
}, [data.mongoUri]);
|
|
37
|
+
|
|
38
|
+
function parseUriToFields(uriStr) {
|
|
39
|
+
try {
|
|
40
|
+
const match = uriStr.match(
|
|
41
|
+
/^mongodb(?:\+srv)?:\/\/(?:([^:]+):([^@]+)@)?([^:\/]+)(?::(\d+))?\/(.+?)(?:\?.*)?$/,
|
|
42
|
+
);
|
|
43
|
+
if (match) {
|
|
44
|
+
setUsername(match[1] || "");
|
|
45
|
+
setPassword(match[2] || "");
|
|
46
|
+
setHost(match[3] || "localhost");
|
|
47
|
+
setPort(match[4] || "27017");
|
|
48
|
+
setDbName(match[5] || appName);
|
|
49
|
+
}
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function buildUri(h, p, db, user, pass) {
|
|
54
|
+
const auth =
|
|
55
|
+
user && pass
|
|
56
|
+
? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@`
|
|
57
|
+
: "";
|
|
58
|
+
return `mongodb://${auth}${h || "localhost"}:${p || "27017"}/${db || appName}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getUri() {
|
|
62
|
+
return inputMode === "url"
|
|
63
|
+
? (uri || "").trim() || `mongodb://localhost:27017/${appName}`
|
|
64
|
+
: buildUri(host, port, dbName, username, password);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function handleSetup() {
|
|
68
|
+
const connUri = getUri();
|
|
69
|
+
if (!connUri.startsWith("mongodb://") && !connUri.startsWith("mongodb+srv://")) {
|
|
70
|
+
toast("Invalid MongoDB URI. Must start with mongodb:// or mongodb+srv://", "error");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
setLoading(true);
|
|
74
|
+
try {
|
|
75
|
+
const isFirstTime = !data.mongoConfigured;
|
|
76
|
+
const result = await api("/add-mongo", {
|
|
77
|
+
method: "POST",
|
|
78
|
+
body: { uri: connUri },
|
|
79
|
+
});
|
|
80
|
+
toast(result.message, "success");
|
|
81
|
+
setEditing(false);
|
|
82
|
+
if (isFirstTime) {
|
|
83
|
+
// Server restarts after first-time setup — wait then retry refresh
|
|
84
|
+
setTimeout(async () => {
|
|
85
|
+
for (let i = 0; i < 10; i++) {
|
|
86
|
+
try {
|
|
87
|
+
await refresh();
|
|
88
|
+
setLoading(false);
|
|
89
|
+
return;
|
|
90
|
+
} catch {}
|
|
91
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
92
|
+
}
|
|
93
|
+
setLoading(false);
|
|
94
|
+
}, 2000);
|
|
95
|
+
} else {
|
|
96
|
+
refresh().catch(() => {});
|
|
97
|
+
setLoading(false);
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
toast(err.message, "error");
|
|
101
|
+
setLoading(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Mask password in URI for display
|
|
106
|
+
function maskUri(u) {
|
|
107
|
+
if (!u) return "";
|
|
108
|
+
return u.replace(/:([^@\/]+)@/, ":****@");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const showForm = !data.mongoConfigured || editing;
|
|
112
|
+
|
|
113
|
+
const inputModeToggle = html`<div className="flex gap-2 mb-4">
|
|
114
|
+
<button
|
|
115
|
+
className=${`btn btn-sm ${inputMode === "fields" ? "btn-primary" : "btn-ghost"}`}
|
|
116
|
+
onClick=${() => setInputMode("fields")}
|
|
117
|
+
>
|
|
118
|
+
Configuration
|
|
119
|
+
</button>
|
|
120
|
+
<button
|
|
121
|
+
className=${`btn btn-sm ${inputMode === "url" ? "btn-primary" : "btn-ghost"}`}
|
|
122
|
+
onClick=${() => setInputMode("url")}
|
|
123
|
+
>
|
|
124
|
+
Connection URI
|
|
125
|
+
</button>
|
|
126
|
+
</div>`;
|
|
127
|
+
|
|
128
|
+
const urlInputs = html`<fragment>
|
|
129
|
+
<div className="form-group">
|
|
130
|
+
<label className="form-label">Connection URI</label>
|
|
131
|
+
<input
|
|
132
|
+
className="form-input"
|
|
133
|
+
value=${uri}
|
|
134
|
+
onInput=${(e) => setUri(e.target.value)}
|
|
135
|
+
placeholder="mongodb://username:password@localhost:27017/myapp"
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
</fragment>`;
|
|
139
|
+
|
|
140
|
+
const fieldInputs = html`<fragment>
|
|
141
|
+
<div className="grid-2 mb-2">
|
|
142
|
+
<div className="form-group">
|
|
143
|
+
<label className="form-label">Host</label>
|
|
144
|
+
<input
|
|
145
|
+
className="form-input"
|
|
146
|
+
value=${host}
|
|
147
|
+
onInput=${(e) => setHost(e.target.value)}
|
|
148
|
+
placeholder="localhost"
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
<div className="form-group">
|
|
152
|
+
<label className="form-label">Port</label>
|
|
153
|
+
<input
|
|
154
|
+
className="form-input"
|
|
155
|
+
value=${port}
|
|
156
|
+
onInput=${(e) => setPort(e.target.value)}
|
|
157
|
+
placeholder="27017"
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="form-group">
|
|
162
|
+
<label className="form-label">Database Name</label>
|
|
163
|
+
<input
|
|
164
|
+
className="form-input"
|
|
165
|
+
value=${dbName}
|
|
166
|
+
onInput=${(e) => { setDbNameTouched(true); setDbName(e.target.value); }}
|
|
167
|
+
placeholder=${appName}
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
<div className="grid-2 mb-4">
|
|
171
|
+
<div className="form-group">
|
|
172
|
+
<label className="form-label">Username (optional)</label>
|
|
173
|
+
<input
|
|
174
|
+
className="form-input"
|
|
175
|
+
value=${username}
|
|
176
|
+
onInput=${(e) => setUsername(e.target.value)}
|
|
177
|
+
placeholder="admin"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
<div className="form-group">
|
|
181
|
+
<label className="form-label">Password (optional)</label>
|
|
182
|
+
<input
|
|
183
|
+
className="form-input"
|
|
184
|
+
type="password"
|
|
185
|
+
value=${password}
|
|
186
|
+
onInput=${(e) => setPassword(e.target.value)}
|
|
187
|
+
placeholder="********"
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</fragment>`;
|
|
192
|
+
|
|
193
|
+
// Preview the generated URI when in fields mode
|
|
194
|
+
const previewUri =
|
|
195
|
+
inputMode === "fields"
|
|
196
|
+
? html`<div className="form-group" style=${{ marginTop: "8px" }}>
|
|
197
|
+
<label
|
|
198
|
+
className="form-label text-muted"
|
|
199
|
+
style=${{ fontSize: "11px" }}
|
|
200
|
+
>Generated URI</label
|
|
201
|
+
>
|
|
202
|
+
<div
|
|
203
|
+
className="text-sm text-muted"
|
|
204
|
+
style=${{
|
|
205
|
+
fontFamily: "monospace",
|
|
206
|
+
padding: "8px 12px",
|
|
207
|
+
background: "rgba(255,255,255,0.03)",
|
|
208
|
+
borderRadius: "6px",
|
|
209
|
+
wordBreak: "break-all",
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
${maskUri(getUri())}
|
|
213
|
+
</div>
|
|
214
|
+
</div>`
|
|
215
|
+
: null;
|
|
216
|
+
|
|
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
|
+
|
|
223
|
+
${data.mongoConfigured &&
|
|
224
|
+
!editing &&
|
|
225
|
+
html`<div
|
|
226
|
+
className="card mb-6"
|
|
227
|
+
style=${{ borderColor: "rgba(34,197,94,0.3)" }}
|
|
228
|
+
>
|
|
229
|
+
<div className="flex items-center gap-3">
|
|
230
|
+
<img
|
|
231
|
+
src="/_dev/assets/images/mongo.png"
|
|
232
|
+
alt="MongoDB"
|
|
233
|
+
style=${{ width: "28px", height: "28px", objectFit: "contain" }}
|
|
234
|
+
/>
|
|
235
|
+
<div style=${{ flex: 1, minWidth: 0 }}>
|
|
236
|
+
<div style=${{ fontWeight: 600, fontSize: "14px" }}>
|
|
237
|
+
MongoDB Connected
|
|
238
|
+
</div>
|
|
239
|
+
<div
|
|
240
|
+
className="text-sm text-muted"
|
|
241
|
+
style=${{ fontFamily: "monospace", wordBreak: "break-all" }}
|
|
242
|
+
>
|
|
243
|
+
${maskUri(data.mongoUri || "")}
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
<button
|
|
247
|
+
className="btn btn-sm btn-ghost"
|
|
248
|
+
onClick=${() => {
|
|
249
|
+
if (data.mongoUri) {
|
|
250
|
+
setUri(data.mongoUri);
|
|
251
|
+
parseUriToFields(data.mongoUri);
|
|
252
|
+
}
|
|
253
|
+
setEditing(true);
|
|
254
|
+
}}
|
|
255
|
+
style=${{ marginLeft: "auto" }}
|
|
256
|
+
>
|
|
257
|
+
Edit
|
|
258
|
+
</button>
|
|
259
|
+
<span className="badge badge-success">✓ Active</span>
|
|
260
|
+
</div>
|
|
261
|
+
</div>`}
|
|
262
|
+
|
|
263
|
+
${showForm &&
|
|
264
|
+
html`<div className="card">
|
|
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
|
+
/>
|
|
271
|
+
<div className="card-title" style=${{ margin: 0 }}>
|
|
272
|
+
${editing ? "Edit Connection" : "MongoDB Setup"}
|
|
273
|
+
</div>
|
|
274
|
+
<div style=${{ marginLeft: "auto", display: "flex", gap: "8px" }}>
|
|
275
|
+
${editing &&
|
|
276
|
+
html`<button
|
|
277
|
+
className="btn btn-sm btn-ghost"
|
|
278
|
+
onClick=${() => setEditing(false)}
|
|
279
|
+
>
|
|
280
|
+
Cancel
|
|
281
|
+
</button>`}
|
|
282
|
+
<button
|
|
283
|
+
className="btn btn-sm btn-primary"
|
|
284
|
+
onClick=${handleSetup}
|
|
285
|
+
disabled=${loading}
|
|
286
|
+
>
|
|
287
|
+
${loading ? html`<span className="spinner" />` : null}
|
|
288
|
+
${loading
|
|
289
|
+
? "Configuring..."
|
|
290
|
+
: editing
|
|
291
|
+
? "Save Changes"
|
|
292
|
+
: "Connect Database"}
|
|
293
|
+
</button>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
${inputModeToggle} ${inputMode === "url" ? urlInputs : fieldInputs}
|
|
297
|
+
${previewUri}
|
|
298
|
+
</div>`}
|
|
299
|
+
</div>`;
|
|
300
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
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 } = 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
|
+
// Docs setup/regenerate writes src/docs.js → nodemon restart. Retry across the
|
|
15
|
+
// restart window so the re-fetch doesn't surface as a "NetworkError".
|
|
16
|
+
let lastErr;
|
|
17
|
+
for (let i = 0; i < 8; i++) {
|
|
18
|
+
try { setInfo(await api("/docs")); return; }
|
|
19
|
+
catch (err) { lastErr = err; await new Promise((r) => setTimeout(r, 500)); }
|
|
20
|
+
}
|
|
21
|
+
if (lastErr) toast(lastErr.message, "error");
|
|
22
|
+
}, [toast]);
|
|
23
|
+
|
|
24
|
+
useEffect(() => { load(); }, [load]);
|
|
25
|
+
|
|
26
|
+
const docsUrl = (() => {
|
|
27
|
+
const port = window.location.port || "3000";
|
|
28
|
+
return window.location.protocol + "//" + window.location.hostname + ":" + port + "/docs";
|
|
29
|
+
})();
|
|
30
|
+
|
|
31
|
+
async function setup() {
|
|
32
|
+
if (pw && pw.length < 6) {
|
|
33
|
+
toast("Password must be at least 6 characters (or leave blank for public docs)", "error");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
setBusy(true);
|
|
37
|
+
try {
|
|
38
|
+
// Set the password first (writes docs-auth.json), then create the docs route —
|
|
39
|
+
// so /docs is protected from the moment it goes live.
|
|
40
|
+
if (pw) await api("/docs/password", { method: "POST", body: { password: pw } });
|
|
41
|
+
await api("/docs/setup", { method: "POST" });
|
|
42
|
+
toast(pw ? "API docs set up and password-protected." : "API docs set up at /docs.", "success");
|
|
43
|
+
setPw("");
|
|
44
|
+
await load();
|
|
45
|
+
refresh().catch(() => {});
|
|
46
|
+
} catch (err) { toast(err.message, "error"); }
|
|
47
|
+
setBusy(false);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function regenerate() {
|
|
51
|
+
setBusy(true);
|
|
52
|
+
try {
|
|
53
|
+
const r = await api("/docs/regenerate", { method: "POST" });
|
|
54
|
+
toast(r.message, "success");
|
|
55
|
+
await load();
|
|
56
|
+
} catch (err) { toast(err.message, "error"); }
|
|
57
|
+
setBusy(false);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function setPassword() {
|
|
61
|
+
if (pw.length < 6) { toast("Password must be at least 6 characters", "error"); return; }
|
|
62
|
+
setPwBusy(true);
|
|
63
|
+
try {
|
|
64
|
+
const r = await api("/docs/password", { method: "POST", body: { password: pw } });
|
|
65
|
+
toast(r.message, "success");
|
|
66
|
+
setPw("");
|
|
67
|
+
await load();
|
|
68
|
+
} catch (err) { toast(err.message, "error"); }
|
|
69
|
+
setPwBusy(false);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
|
|
73
|
+
|
|
74
|
+
return html`<div>
|
|
75
|
+
<div className="flex items-center justify-between mb-4">
|
|
76
|
+
<div>
|
|
77
|
+
<div className="card-title">API Documentation</div>
|
|
78
|
+
<div className="card-subtitle">Interactive API docs generated from your routes and models</div>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
${!info.configured
|
|
83
|
+
? html`<div className="card" style=${{ maxWidth: "560px" }}>
|
|
84
|
+
<div className="flex items-center gap-3 mb-4">
|
|
85
|
+
<${LuFileText} size=${20} color="var(--accent)" />
|
|
86
|
+
<div>
|
|
87
|
+
<div style=${{ fontWeight: 600 }}>Set up API docs</div>
|
|
88
|
+
<div className="text-sm text-muted">Generates a spec from your ${info.endpointCount} endpoint${info.endpointCount === 1 ? "" : "s"} and serves an interactive docs page at <span style=${{ fontFamily: "monospace" }}>/docs</span>.</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
<div className="form-group">
|
|
92
|
+
<label className="form-label">Docs password</label>
|
|
93
|
+
<input className="form-input" type="password" value=${pw}
|
|
94
|
+
placeholder="Protect /docs (min 6 chars) — leave blank for public docs"
|
|
95
|
+
onInput=${(e) => setPw(e.target.value)}
|
|
96
|
+
onKeyDown=${(e) => { if (e.key === "Enter") setup(); }} />
|
|
97
|
+
</div>
|
|
98
|
+
<button className="btn btn-primary" onClick=${setup} disabled=${busy}>
|
|
99
|
+
${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
|
|
100
|
+
${busy ? "Setting up..." : "Set up API docs"}
|
|
101
|
+
</button>
|
|
102
|
+
</div>`
|
|
103
|
+
: html`<div>
|
|
104
|
+
<div className="card mb-4">
|
|
105
|
+
<div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
|
|
106
|
+
<${LuFileText} size=${20} color="var(--success)" />
|
|
107
|
+
<div style=${{ flex: 1, minWidth: "180px" }}>
|
|
108
|
+
<div style=${{ fontWeight: 600 }}>Docs are live</div>
|
|
109
|
+
<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>
|
|
110
|
+
</div>
|
|
111
|
+
<a className="btn" href=${docsUrl} target="_blank" rel="noreferrer" style=${{ textDecoration: "none" }}>
|
|
112
|
+
<${LuExternalLink} size=${14} /> Open /docs
|
|
113
|
+
</a>
|
|
114
|
+
<button className="btn btn-primary" onClick=${regenerate} disabled=${busy}>
|
|
115
|
+
${busy ? html`<span className="spinner" />` : html`<${LuRefreshCw} size=${14} />`}
|
|
116
|
+
Regenerate
|
|
117
|
+
</button>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="card mb-4">
|
|
121
|
+
<div className="card-title mb-1">Docs access</div>
|
|
122
|
+
<div className="card-subtitle mb-4">
|
|
123
|
+
By default <span style=${{ fontFamily: "monospace" }}>/docs</span> is public — anyone who can reach the app can read it.
|
|
124
|
+
Set a password to require a login before the docs are shown.
|
|
125
|
+
</div>
|
|
126
|
+
${info.passwordProtected
|
|
127
|
+
? html`<div className="flex items-center gap-3" style=${{ flexWrap: "wrap" }}>
|
|
128
|
+
<span className="badge badge-success">🔒 Password protected</span>
|
|
129
|
+
<div style=${{ display: "flex", gap: "8px", marginLeft: "auto", flexWrap: "wrap" }}>
|
|
130
|
+
<input className="form-input" type="password" placeholder="New password" value=${pw}
|
|
131
|
+
onInput=${(e) => setPw(e.target.value)} style=${{ width: "200px" }} />
|
|
132
|
+
<button className="btn btn-sm btn-primary" onClick=${setPassword} disabled=${pwBusy || pw.length < 6}>Change</button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>`
|
|
135
|
+
: html`<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
136
|
+
<input className="form-input" type="password" placeholder="Password (min 6 chars)" value=${pw}
|
|
137
|
+
onInput=${(e) => setPw(e.target.value)}
|
|
138
|
+
onKeyDown=${(e) => { if (e.key === "Enter") setPassword(); }}
|
|
139
|
+
style=${{ flex: 1, minWidth: "220px" }} />
|
|
140
|
+
<button className="btn btn-primary" onClick=${setPassword} disabled=${pwBusy || pw.length < 6}>
|
|
141
|
+
${pwBusy ? html`<span className="spinner" />` : null} Set password
|
|
142
|
+
</button>
|
|
143
|
+
</div>`}
|
|
144
|
+
</div>
|
|
145
|
+
<div className="card text-sm text-muted">
|
|
146
|
+
The spec stays in sync automatically — it regenerates whenever you add or change routes and models. Use Regenerate to force a refresh.
|
|
147
|
+
</div>
|
|
148
|
+
</div>`}
|
|
149
|
+
</div>`;
|
|
150
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { motion } from "framer-motion";
|
|
3
|
+
import { html, api } from "../lib.js";
|
|
4
|
+
import { useApp } from "../context.js";
|
|
5
|
+
|
|
6
|
+
export function EnvironmentPage() {
|
|
7
|
+
const { toast } = useApp();
|
|
8
|
+
const [content, setContent] = useState("");
|
|
9
|
+
const [loading, setLoading] = useState(true);
|
|
10
|
+
const [saving, setSaving] = useState(false);
|
|
11
|
+
const [dirty, setDirty] = useState(false);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
api("/env")
|
|
15
|
+
.then((res) => {
|
|
16
|
+
setContent(res.content || "");
|
|
17
|
+
setLoading(false);
|
|
18
|
+
})
|
|
19
|
+
.catch(() => setLoading(false));
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
function handleChange(e) {
|
|
23
|
+
setContent(e.target.value);
|
|
24
|
+
setDirty(true);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function handleSave() {
|
|
28
|
+
setSaving(true);
|
|
29
|
+
try {
|
|
30
|
+
const result = await api("/env", {
|
|
31
|
+
method: "PUT",
|
|
32
|
+
body: { content },
|
|
33
|
+
});
|
|
34
|
+
toast(result.message, "success");
|
|
35
|
+
setDirty(false);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
toast(err.message, "error");
|
|
38
|
+
}
|
|
39
|
+
setSaving(false);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function countVars(c) {
|
|
43
|
+
if (!c) return 0;
|
|
44
|
+
return c.split('\n').filter(l => l.trim() && !l.trim().startsWith('#') && l.includes('=')).length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return html`<div>
|
|
48
|
+
<div className="flex items-center justify-between mb-4">
|
|
49
|
+
<div>
|
|
50
|
+
<div className="card-title">Environment</div>
|
|
51
|
+
<div className="card-subtitle">Manage environment variables</div>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="card mb-4">
|
|
56
|
+
<div className="flex items-center justify-between mb-3">
|
|
57
|
+
<div>
|
|
58
|
+
<div style=${{ fontWeight: 600, fontSize: '16px', marginBottom: '2px' }}>.env</div>
|
|
59
|
+
<div className="text-sm text-muted">${() => countVars(content)} variable${() => countVars(content) !== 1 ? 's' : ''} defined</div>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="flex gap-2 items-center">
|
|
62
|
+
${() => dirty && html`<span className="text-sm text-muted" style=${{ fontSize: '11px' }}>Unsaved</span>`}
|
|
63
|
+
<button className="btn btn-sm btn-primary" onClick=${handleSave} disabled=${saving || !dirty}>
|
|
64
|
+
${saving ? html`<span className="spinner" />` : null}
|
|
65
|
+
${saving ? "Saving..." : "Save"}
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div className="card" style=${{ padding: 0, overflow: 'hidden' }}>
|
|
72
|
+
<div style=${{ padding: '12px 16px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
73
|
+
<span style=${{ fontSize: '12px', fontWeight: 600, color: 'var(--text-secondary)' }}>Editor</span>
|
|
74
|
+
<span className="field-count" style=${{ fontSize: '11px' }}>Cmd+S to save</span>
|
|
75
|
+
</div>
|
|
76
|
+
<${motion.div} initial=${{ opacity: 0, y: 6 }} animate=${{ opacity: 1, y: 0 }} transition=${{ duration: 0.12 }}>
|
|
77
|
+
${loading
|
|
78
|
+
? html`<div className="text-sm text-muted" style=${{ padding: '40px 0', textAlign: 'center' }}>Loading...</div>`
|
|
79
|
+
: html`<textarea
|
|
80
|
+
className="env-editor"
|
|
81
|
+
value=${content}
|
|
82
|
+
onInput=${handleChange}
|
|
83
|
+
placeholder=${"# Add environment variables\n# KEY=value"}
|
|
84
|
+
spellCheck=${false}
|
|
85
|
+
style=${{ borderRadius: 0, border: 'none' }}
|
|
86
|
+
onKeyDown=${(e) => {
|
|
87
|
+
if (e.key === "Tab") {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
const start = e.target.selectionStart;
|
|
90
|
+
const end = e.target.selectionEnd;
|
|
91
|
+
setContent(e.target.value.substring(0, start) + " " + e.target.value.substring(end));
|
|
92
|
+
setTimeout(() => { e.target.selectionStart = e.target.selectionEnd = start + 2; }, 0);
|
|
93
|
+
}
|
|
94
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
if (dirty) handleSave();
|
|
97
|
+
}
|
|
98
|
+
}}
|
|
99
|
+
/>`}
|
|
100
|
+
<//>
|
|
101
|
+
</div>
|
|
102
|
+
</div>`;
|
|
103
|
+
}
|