4bnode 4.1.5 → 4.1.6
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-api.js +5 -3
- package/add-email.js +118 -28
- package/add-login.js +35 -16
- package/add-mongo-schema.js +13 -5
- package/lib/codegen.js +191 -57
- package/lib/codegen.test.js +87 -15
- package/lib/names.js +1 -1
- package/package.json +1 -1
- package/skeleton/.4bnode/assets/js/pages/ApiTesterPage.js +54 -9
- package/skeleton/.4bnode/assets/js/pages/LogsPage.js +49 -16
- package/skeleton/.4bnode/assets/js/pages/MailPage.js +110 -50
- package/skeleton/.4bnode/codegen.js +191 -57
- package/skeleton/.4bnode/dev-api.js +284 -56
- package/skeleton/.env.example +17 -3
- package/skeleton/_gitignore +1 -0
- package/skeleton/index.js +7 -2
- package/skeleton/src/middleware/apiKey.js +14 -1
- package/skeleton/src/middleware/errorHandler.js +7 -2
- package/app-1/.4bnode/.passkey +0 -1
- 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 +0 -139
- package/app-1/.4bnode/assets/js/Root.js +0 -40
- package/app-1/.4bnode/assets/js/components/Modal.js +0 -52
- package/app-1/.4bnode/assets/js/components/Sidebar.js +0 -73
- package/app-1/.4bnode/assets/js/components/ToastContainer.js +0 -15
- package/app-1/.4bnode/assets/js/context.js +0 -7
- package/app-1/.4bnode/assets/js/lib.js +0 -113
- package/app-1/.4bnode/assets/js/main.js +0 -5
- package/app-1/.4bnode/assets/js/pages/AiBuilderPage.js +0 -268
- package/app-1/.4bnode/assets/js/pages/ApiTesterPage.js +0 -1879
- package/app-1/.4bnode/assets/js/pages/DashboardPage.js +0 -435
- package/app-1/.4bnode/assets/js/pages/DatabasePage.js +0 -300
- package/app-1/.4bnode/assets/js/pages/DocsPage.js +0 -150
- package/app-1/.4bnode/assets/js/pages/EnvironmentPage.js +0 -103
- package/app-1/.4bnode/assets/js/pages/LogsPage.js +0 -205
- package/app-1/.4bnode/assets/js/pages/MailPage.js +0 -224
- package/app-1/.4bnode/assets/js/pages/RealtimePage.js +0 -186
- package/app-1/.4bnode/assets/js/pages/RoutesPage.js +0 -1485
- package/app-1/.4bnode/assets/js/pages/SchemasPage.js +0 -838
- package/app-1/.4bnode/assets/js/pages/SecurityPage.js +0 -74
- package/app-1/.4bnode/assets/js/pages/SerialPortPage.js +0 -170
- package/app-1/.4bnode/assets/js/screens/PasskeyScreen.js +0 -105
- package/app-1/.4bnode/codegen.js +0 -1440
- package/app-1/.4bnode/dev-api.js +0 -3747
- package/app-1/.4bnode/ui.html +0 -1578
- package/app-1/.env.example +0 -11
- package/app-1/README.md +0 -47
- package/app-1/ecosystem.config.cjs +0 -25
- package/app-1/index.js +0 -188
- package/app-1/package.json +0 -37
- package/app-1/public/index.html +0 -292
- package/app-1/src/db.js +0 -13
- package/app-1/src/docs.js +0 -84
- package/app-1/src/middleware/apiKey.js +0 -36
- package/app-1/src/middleware/auth.js +0 -17
- package/app-1/src/middleware/errorHandler.js +0 -15
- package/app-1/src/middleware/roles.js +0 -14
- package/app-1/src/middleware/validate.js +0 -20
- package/app-1/src/models/user.js +0 -11
- package/app-1/src/openapi.json +0 -80
- package/app-1/src/routes/upload-resume.js +0 -21
- package/app-1/src/services/mailer.js +0 -52
- package/app-1/src/validators/upload-resume.js +0 -8
|
@@ -25,33 +25,66 @@ export function LogsPage() {
|
|
|
25
25
|
const [connected, setConnected] = useState(false);
|
|
26
26
|
const bottomRef = useRef(null);
|
|
27
27
|
const containerRef = useRef(null);
|
|
28
|
-
const eventSourceRef = useRef(null);
|
|
29
28
|
|
|
30
29
|
// Load existing logs
|
|
31
30
|
useEffect(() => {
|
|
32
31
|
api("/logs").then(setLogs).catch(() => {});
|
|
33
32
|
}, []);
|
|
34
33
|
|
|
35
|
-
// SSE stream for real-time logs
|
|
34
|
+
// SSE stream for real-time logs.
|
|
35
|
+
// Uses fetch() so the passkey travels in the x-passkey header instead of the
|
|
36
|
+
// query string (EventSource can't set headers, which leaked it into logs).
|
|
36
37
|
useEffect(() => {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
eventSourceRef.current = es;
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
let aborted = false;
|
|
40
|
+
let retryTimer = null;
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
async function stream() {
|
|
43
|
+
const passkey = getStoredPasskey();
|
|
44
44
|
try {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return next.length > 500 ? next.slice(-500) : next;
|
|
45
|
+
const res = await fetch(API + "/logs/stream", {
|
|
46
|
+
headers: passkey ? { "x-passkey": passkey } : {},
|
|
47
|
+
signal: controller.signal,
|
|
49
48
|
});
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
if (!res.ok || !res.body) throw new Error("stream failed");
|
|
50
|
+
setConnected(true);
|
|
51
|
+
const reader = res.body.getReader();
|
|
52
|
+
const decoder = new TextDecoder();
|
|
53
|
+
let buffer = "";
|
|
54
|
+
while (true) {
|
|
55
|
+
const { value, done } = await reader.read();
|
|
56
|
+
if (done) break;
|
|
57
|
+
buffer += decoder.decode(value, { stream: true });
|
|
58
|
+
let idx;
|
|
59
|
+
while ((idx = buffer.indexOf("\n")) !== -1) {
|
|
60
|
+
const line = buffer.slice(0, idx);
|
|
61
|
+
buffer = buffer.slice(idx + 1);
|
|
62
|
+
if (!line.startsWith("data: ")) continue;
|
|
63
|
+
try {
|
|
64
|
+
const entry = JSON.parse(line.slice(6));
|
|
65
|
+
setLogs((prev) => {
|
|
66
|
+
const next = [...prev, entry];
|
|
67
|
+
return next.length > 500 ? next.slice(-500) : next;
|
|
68
|
+
});
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// network/parse error — fall through to reconnect below
|
|
74
|
+
}
|
|
75
|
+
if (!aborted) {
|
|
76
|
+
setConnected(false);
|
|
77
|
+
retryTimer = setTimeout(stream, 2000);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
53
80
|
|
|
54
|
-
|
|
81
|
+
stream();
|
|
82
|
+
|
|
83
|
+
return () => {
|
|
84
|
+
aborted = true;
|
|
85
|
+
controller.abort();
|
|
86
|
+
if (retryTimer) clearTimeout(retryTimer);
|
|
87
|
+
};
|
|
55
88
|
}, []);
|
|
56
89
|
|
|
57
90
|
// Auto-scroll to bottom
|
|
@@ -65,9 +65,14 @@ await sendMail({ to: user.email, subject: 'Welcome', html });
|
|
|
65
65
|
export function MailPage() {
|
|
66
66
|
const { toast, refresh } = useApp();
|
|
67
67
|
const [info, setInfo] = useState(null);
|
|
68
|
-
const [provider, setProvider] = useState("
|
|
69
|
-
const [form, setForm] = useState({
|
|
68
|
+
const [provider, setProvider] = useState("resend");
|
|
69
|
+
const [form, setForm] = useState({
|
|
70
|
+
apiKey: "", resendFrom: "",
|
|
71
|
+
host: "", port: 587, secure: false, user: "", pass: "", smtpFrom: "",
|
|
72
|
+
});
|
|
70
73
|
const [busy, setBusy] = useState(false);
|
|
74
|
+
const [configError, setConfigError] = useState("");
|
|
75
|
+
const [domainPassword, setDomainPassword] = useState("");
|
|
71
76
|
const [testTo, setTestTo] = useState("");
|
|
72
77
|
const [testing, setTesting] = useState(false);
|
|
73
78
|
const [copied, setCopied] = useState(false);
|
|
@@ -87,13 +92,20 @@ export function MailPage() {
|
|
|
87
92
|
try {
|
|
88
93
|
const r = await api("/mail");
|
|
89
94
|
setInfo(r);
|
|
95
|
+
// Persisted failure from the last save survives reloads until it's fixed.
|
|
96
|
+
setConfigError(r.configured && r.verified === false
|
|
97
|
+
? "The saved email configuration failed its last connection test. Update the details and save again."
|
|
98
|
+
: "");
|
|
99
|
+
setProvider(r.provider || "resend");
|
|
100
|
+
const rs = r.resend || {}, sm = r.smtp || {};
|
|
90
101
|
setForm((f) => ({
|
|
91
102
|
...f,
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
103
|
+
resendFrom: rs.from || f.resendFrom,
|
|
104
|
+
host: sm.host || f.host,
|
|
105
|
+
port: sm.port || f.port || 587,
|
|
106
|
+
secure: !!sm.secure,
|
|
107
|
+
user: sm.user || f.user,
|
|
108
|
+
smtpFrom: sm.from || f.smtpFrom,
|
|
97
109
|
}));
|
|
98
110
|
} catch (err) { toast(err.message, "error"); }
|
|
99
111
|
}, [toast]);
|
|
@@ -102,20 +114,35 @@ export function MailPage() {
|
|
|
102
114
|
|
|
103
115
|
function setField(patch) { setForm((f) => ({ ...f, ...patch })); }
|
|
104
116
|
|
|
105
|
-
function pickProvider(p) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
117
|
+
function pickProvider(p) { setProvider(p.id); }
|
|
118
|
+
// TLS drives the SMTP port: on → 465 (implicit TLS), off → 587 (STARTTLS).
|
|
119
|
+
function setTls(on) { setForm((f) => ({ ...f, secure: on, port: on ? 465 : 587 })); }
|
|
109
120
|
|
|
110
121
|
async function save() {
|
|
111
|
-
|
|
122
|
+
let body;
|
|
123
|
+
if (provider === "resend") {
|
|
124
|
+
if (!form.apiKey.trim() && !(info.resend && info.resend.hasKey)) { toast("API key is required", "error"); return; }
|
|
125
|
+
body = { provider: "resend", apiKey: form.apiKey.trim(), from: form.resendFrom.trim(), domainPassword };
|
|
126
|
+
} else {
|
|
127
|
+
if (!form.host.trim()) { toast("SMTP host is required", "error"); return; }
|
|
128
|
+
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 };
|
|
129
|
+
}
|
|
112
130
|
setBusy(true);
|
|
113
131
|
try {
|
|
114
|
-
const r = await api("/mail/setup", { method: "POST", body
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
const r = await api("/mail/setup", { method: "POST", body });
|
|
133
|
+
await load(); // refresh info (and the persisted verify status)
|
|
134
|
+
if (r && r.verified === false) {
|
|
135
|
+
setConfigError(r.error || "Configuration failed. Check the details and try again.");
|
|
136
|
+
toast(r.message || "Configuration test failed", "error");
|
|
137
|
+
} else {
|
|
138
|
+
setConfigError("");
|
|
139
|
+
toast(r.message, "success");
|
|
140
|
+
}
|
|
117
141
|
refresh().catch(() => {});
|
|
118
|
-
} catch (err) {
|
|
142
|
+
} catch (err) {
|
|
143
|
+
setConfigError(err.message || "Configuration failed.");
|
|
144
|
+
toast(err.message, "error");
|
|
145
|
+
}
|
|
119
146
|
setBusy(false);
|
|
120
147
|
}
|
|
121
148
|
|
|
@@ -131,9 +158,16 @@ export function MailPage() {
|
|
|
131
158
|
|
|
132
159
|
if (!info) return html`<div className="text-muted" style=${{ padding: "16px" }}>Loading…</div>`;
|
|
133
160
|
|
|
134
|
-
const current = info.providers.find((p) => p.id === provider) || {};
|
|
135
|
-
|
|
136
|
-
|
|
161
|
+
const current = (info.providers || []).find((p) => p.id === provider) || {};
|
|
162
|
+
const providers = info.providers || [];
|
|
163
|
+
|
|
164
|
+
// 4brains.in is an official domain — it can't be used directly; an authorization
|
|
165
|
+
// password is required and its use is reported to the domain owner. The effective
|
|
166
|
+
// SMTP sender is the From, or the User when From is blank — check both, matching
|
|
167
|
+
// the server (from || user).
|
|
168
|
+
const officialRe = /4brains\.in/i;
|
|
169
|
+
const officialValues = provider === "resend" ? [form.resendFrom] : [form.host, form.user, form.smtpFrom];
|
|
170
|
+
const isOfficial = officialValues.some((v) => officialRe.test(String(v || "")));
|
|
137
171
|
|
|
138
172
|
const labelStyle = { fontSize: "11px", fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.5px", color: "var(--text-muted)", marginBottom: "8px" };
|
|
139
173
|
|
|
@@ -141,7 +175,7 @@ export function MailPage() {
|
|
|
141
175
|
<div className="flex items-center justify-between mb-4">
|
|
142
176
|
<div>
|
|
143
177
|
<div className="card-title">Email</div>
|
|
144
|
-
<div className="card-subtitle">Transactional email via
|
|
178
|
+
<div className="card-subtitle">Transactional email via IntraApp(Postmaster) or any custom SMTP server</div>
|
|
145
179
|
</div>
|
|
146
180
|
${info.configured && html`<span className="badge badge-success">Configured</span>`}
|
|
147
181
|
</div>
|
|
@@ -157,36 +191,55 @@ export function MailPage() {
|
|
|
157
191
|
</div>
|
|
158
192
|
${current.hint && html`<div className="text-sm text-muted" style=${{ marginBottom: "14px" }}>${current.hint}</div>`}
|
|
159
193
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
194
|
+
${provider === "resend"
|
|
195
|
+
? html`<div className="form-group">
|
|
196
|
+
<label className="form-label">From address</label>
|
|
197
|
+
<input className="form-input" value=${form.resendFrom} onInput=${(e) => setField({ resendFrom: e.target.value })} placeholder="no-reply@yourdomain.com" />
|
|
198
|
+
</div>
|
|
199
|
+
<div className="form-group">
|
|
200
|
+
<label className="form-label">API Key ${info.resend && info.resend.hasKey ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved)</span>` : ""}</label>
|
|
201
|
+
<input className="form-input" type="password" value=${form.apiKey} onInput=${(e) => setField({ apiKey: e.target.value })} placeholder=${info.resend && info.resend.hasKey ? "••••••••" : "re_..."} />
|
|
202
|
+
</div>`
|
|
203
|
+
: html`<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
204
|
+
<div className="form-group" style=${{ flex: "1 1 200px" }}>
|
|
205
|
+
<label className="form-label">SMTP Host</label>
|
|
206
|
+
<input className="form-input" value=${form.host} onInput=${(e) => setField({ host: e.target.value })} placeholder="smtp.example.com" />
|
|
207
|
+
</div>
|
|
208
|
+
<div className="form-group" style=${{ flex: "0 0 90px" }}>
|
|
209
|
+
<label className="form-label">Port</label>
|
|
210
|
+
<input className="form-input" type="number" value=${form.port} onInput=${(e) => setField({ port: e.target.value })} />
|
|
211
|
+
</div>
|
|
212
|
+
<div className="form-group" style=${{ flex: "0 0 110px", display: "flex", flexDirection: "column", justifyContent: "flex-end" }}>
|
|
213
|
+
<label style=${{ display: "flex", alignItems: "center", gap: "6px", fontSize: "13px", color: "var(--text-secondary)", cursor: "pointer", paddingBottom: "10px" }}>
|
|
214
|
+
<input type="checkbox" checked=${form.secure} onChange=${(e) => setTls(e.target.checked)} /> TLS
|
|
215
|
+
</label>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
<div style=${{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
219
|
+
<div className="form-group" style=${{ flex: "1 1 180px" }}>
|
|
220
|
+
<label className="form-label">User</label>
|
|
221
|
+
<input className="form-input" value=${form.user} onInput=${(e) => setField({ user: e.target.value })} placeholder="SMTP username" />
|
|
222
|
+
</div>
|
|
223
|
+
<div className="form-group" style=${{ flex: "1 1 180px" }}>
|
|
224
|
+
<label className="form-label">Password ${info.smtp && info.smtp.hasPass ? html`<span className="text-muted" style=${{ fontWeight: 400 }}>(saved)</span>` : ""}</label>
|
|
225
|
+
<input className="form-input" type="password" value=${form.pass} onInput=${(e) => setField({ pass: e.target.value })} placeholder=${info.smtp && info.smtp.hasPass ? "••••••••" : "secret"} />
|
|
226
|
+
</div>
|
|
227
|
+
</div>
|
|
228
|
+
<div className="form-group">
|
|
229
|
+
<label className="form-label">From address</label>
|
|
230
|
+
<input className="form-input" value=${form.smtpFrom} onInput=${(e) => setField({ smtpFrom: e.target.value })} placeholder="no-reply@yourdomain.com" />
|
|
231
|
+
</div>`}
|
|
232
|
+
${isOfficial && html`<div className="form-group" style=${{ border: "1px solid #f59e0b", borderRadius: "8px", padding: "12px", background: "rgba(245,158,11,0.08)" }}>
|
|
233
|
+
<div className="text-sm" style=${{ color: "#f59e0b", marginBottom: "8px", fontWeight: 600 }}>
|
|
234
|
+
4brains.in is an official email domain — you can't use it directly.
|
|
179
235
|
</div>
|
|
180
|
-
<div className="
|
|
181
|
-
|
|
182
|
-
<input className="form-input" type="password" value=${form.pass} onInput=${(e) => setField({ pass: e.target.value })} placeholder=${info.hasPass ? "••••••••" : "secret"} />
|
|
236
|
+
<div className="text-sm text-muted" style=${{ marginBottom: "8px" }}>
|
|
237
|
+
Enter the authorization password to continue. Its use will be reported to the domain owner.
|
|
183
238
|
</div>
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
</div>
|
|
189
|
-
<button className="btn btn-primary" onClick=${save} disabled=${busy || !form.host.trim()}>
|
|
239
|
+
<label className="form-label">Authorization password</label>
|
|
240
|
+
<input className="form-input" type="password" value=${domainPassword} onInput=${(e) => setDomainPassword(e.target.value)} placeholder="Required for 4brains.in" />
|
|
241
|
+
</div>`}
|
|
242
|
+
<button className="btn btn-primary" onClick=${save} disabled=${busy || (provider === "smtp" && !form.host.trim()) || (isOfficial && !domainPassword)}>
|
|
190
243
|
${busy ? html`<span className="spinner" />` : html`<${LuCheck} size=${16} />`}
|
|
191
244
|
${busy ? "Saving..." : "Save & install"}
|
|
192
245
|
</button>
|
|
@@ -205,8 +258,9 @@ export function MailPage() {
|
|
|
205
258
|
</div>`}
|
|
206
259
|
</div>
|
|
207
260
|
|
|
208
|
-
<!-- RIGHT: code
|
|
209
|
-
<div
|
|
261
|
+
<!-- RIGHT: code card + (optional) error card, stacked -->
|
|
262
|
+
<div style=${{ flex: "1 1 360px", minWidth: 0, display: "flex", flexDirection: "column", gap: "16px" }}>
|
|
263
|
+
<div className="card">
|
|
210
264
|
<div className="flex items-center justify-between mb-2">
|
|
211
265
|
<div style=${{ display: "flex", gap: "4px", flexWrap: "wrap" }}>
|
|
212
266
|
${USAGE_TABS.map((t) => html`<button key=${t.id}
|
|
@@ -219,6 +273,12 @@ export function MailPage() {
|
|
|
219
273
|
</div>
|
|
220
274
|
<pre style=${{ background: "#0a0c12", border: "1px solid var(--border)", borderRadius: "8px", padding: "14px", fontSize: "12px", lineHeight: 1.55, color: "#d5d8e3", overflow: "auto", margin: 0, fontFamily: "ui-monospace,Menlo,monospace", whiteSpace: "pre" }}>${activeTab.code}</pre>
|
|
221
275
|
</div>
|
|
276
|
+
|
|
277
|
+
${configError && html`<div className="card" style=${{ border: "1px solid #ef4444", background: "rgba(239,68,68,0.08)" }}>
|
|
278
|
+
<div style=${{ color: "#ef4444", fontWeight: 600, marginBottom: "6px" }}>⚠ Configuration failed</div>
|
|
279
|
+
<div className="text-sm" style=${{ color: "var(--text-secondary)", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>${configError}</div>
|
|
280
|
+
</div>`}
|
|
281
|
+
</div>
|
|
222
282
|
</div>
|
|
223
283
|
</div>`;
|
|
224
284
|
}
|