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.
- 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 +46 -5
- 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
|
@@ -483,6 +483,7 @@ export function ApiTesterPage() {
|
|
|
483
483
|
{ key: 'Content-Type', value: 'application/json', id: 1 },
|
|
484
484
|
]);
|
|
485
485
|
const [body, setBody] = useState('');
|
|
486
|
+
const bodyRef = useRef(null);
|
|
486
487
|
const [response, setResponse] = useState(null);
|
|
487
488
|
const [loading, setLoading] = useState(false);
|
|
488
489
|
const [activeTab, setActiveTab] = useState('body');
|
|
@@ -504,11 +505,151 @@ export function ApiTesterPage() {
|
|
|
504
505
|
const [lastSharedAt, setLastSharedAt] = useState(null);
|
|
505
506
|
const [sharedSnapshot, setSharedSnapshot] = useState(null);
|
|
506
507
|
|
|
508
|
+
// Auto-grow the request-body textarea to fit its content (capped, then scrolls).
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
const ta = bodyRef.current;
|
|
511
|
+
if (ta) { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight + 2, 600) + 'px'; }
|
|
512
|
+
}, [body, bodyType, activeTab]);
|
|
513
|
+
|
|
514
|
+
// ── Environments / variables + auth (persisted per-browser in localStorage) ──
|
|
515
|
+
const [variables, setVariables] = useState([]);
|
|
516
|
+
const [showVars, setShowVars] = useState(false);
|
|
517
|
+
const [autoToken, setAutoToken] = useState(true);
|
|
518
|
+
const [auth, setAuth] = useState({ type: 'none', token: '', username: '', password: '', apiKeyKey: 'x-api-key', apiKeyValue: '' });
|
|
519
|
+
|
|
520
|
+
useEffect(() => {
|
|
521
|
+
try {
|
|
522
|
+
const v = JSON.parse(localStorage.getItem('_4bnode_vars') || '[]');
|
|
523
|
+
if (Array.isArray(v)) setVariables(v);
|
|
524
|
+
const a = JSON.parse(localStorage.getItem('_4bnode_auth') || 'null');
|
|
525
|
+
if (a && typeof a === 'object') setAuth(prev => ({ ...prev, ...a }));
|
|
526
|
+
const at = localStorage.getItem('_4bnode_autotoken');
|
|
527
|
+
if (at !== null) setAutoToken(at === '1');
|
|
528
|
+
} catch {}
|
|
529
|
+
}, []);
|
|
530
|
+
useEffect(() => { try { localStorage.setItem('_4bnode_vars', JSON.stringify(variables)); } catch {} }, [variables]);
|
|
531
|
+
useEffect(() => { try { localStorage.setItem('_4bnode_auth', JSON.stringify(auth)); } catch {} }, [auth]);
|
|
532
|
+
useEffect(() => { try { localStorage.setItem('_4bnode_autotoken', autoToken ? '1' : '0'); } catch {} }, [autoToken]);
|
|
533
|
+
|
|
534
|
+
// Replace {{name}} tokens with variable values.
|
|
535
|
+
function subst(str) {
|
|
536
|
+
if (typeof str !== 'string' || str.indexOf('{{') === -1) return str;
|
|
537
|
+
return str.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (m, k) => {
|
|
538
|
+
const v = variables.find(x => (x.key || '').trim() === k);
|
|
539
|
+
return v ? v.value : m;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
function setVar(key, value) {
|
|
543
|
+
setVariables(prev => {
|
|
544
|
+
const i = prev.findIndex(x => (x.key || '').trim() === key);
|
|
545
|
+
if (i === -1) return [...prev, { key, value, id: Date.now() }];
|
|
546
|
+
const next = prev.slice(); next[i] = { ...next[i], value }; return next;
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Request tabs (each tab holds a snapshot; live state = active tab's buffer) ──
|
|
551
|
+
const [tabs, setTabs] = useState([{ id: 1, snap: null }]);
|
|
552
|
+
const [activeTabId, setActiveTabId] = useState(1);
|
|
553
|
+
|
|
554
|
+
function captureSnap() {
|
|
555
|
+
return { method, url, headers, body, bodyType, params, urlParams, formFields, response, activeTab };
|
|
556
|
+
}
|
|
557
|
+
function loadSnap(s) {
|
|
558
|
+
// Only protect a restored request that actually has data; blank tabs should
|
|
559
|
+
// still auto-fill from a matching route when the user types a URL.
|
|
560
|
+
suppressAutofillRef.current = !!(s && s.url);
|
|
561
|
+
setMethod((s && s.method) || 'GET');
|
|
562
|
+
setUrl((s && s.url) || '');
|
|
563
|
+
setHeaders((s && s.headers) || [{ key: 'Content-Type', value: 'application/json', id: 1 }]);
|
|
564
|
+
setBody((s && s.body) || '');
|
|
565
|
+
setBodyType((s && s.bodyType) || 'json');
|
|
566
|
+
setParams((s && s.params) || []);
|
|
567
|
+
setUrlParams((s && s.urlParams) || []);
|
|
568
|
+
setFormFields((s && s.formFields) || []);
|
|
569
|
+
setResponse((s && s.response) || null);
|
|
570
|
+
setActiveTab((s && s.activeTab) || 'body');
|
|
571
|
+
setBodyError(null);
|
|
572
|
+
setSelectedSaved(null);
|
|
573
|
+
setSelectedResponse(null);
|
|
574
|
+
}
|
|
575
|
+
function switchTab(id) {
|
|
576
|
+
if (id === activeTabId) return;
|
|
577
|
+
const snap = captureSnap();
|
|
578
|
+
const target = tabs.find(t => t.id === id);
|
|
579
|
+
setTabs(prev => prev.map(t => t.id === activeTabId ? { ...t, snap } : t));
|
|
580
|
+
loadSnap(target && target.snap);
|
|
581
|
+
setActiveTabId(id);
|
|
582
|
+
}
|
|
583
|
+
function newTab() {
|
|
584
|
+
const snap = captureSnap();
|
|
585
|
+
const id = Math.max(0, ...tabs.map(t => t.id)) + 1;
|
|
586
|
+
setTabs(prev => [...prev.map(t => t.id === activeTabId ? { ...t, snap } : t), { id, snap: null }]);
|
|
587
|
+
loadSnap(null);
|
|
588
|
+
setActiveTabId(id);
|
|
589
|
+
}
|
|
590
|
+
function closeTab(id) {
|
|
591
|
+
const remaining = tabs.filter(t => t.id !== id);
|
|
592
|
+
if (remaining.length === 0) {
|
|
593
|
+
const nid = Math.max(0, ...tabs.map(t => t.id)) + 1;
|
|
594
|
+
setTabs([{ id: nid, snap: null }]);
|
|
595
|
+
loadSnap(null);
|
|
596
|
+
setActiveTabId(nid);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
setTabs(remaining);
|
|
600
|
+
if (id === activeTabId) {
|
|
601
|
+
const next = remaining[remaining.length - 1];
|
|
602
|
+
loadSnap(next.snap);
|
|
603
|
+
setActiveTabId(next.id);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function tabLabel(t) {
|
|
607
|
+
const s = t.id === activeTabId ? { method, url } : (t.snap || {});
|
|
608
|
+
if (!s.url) return 'New Request';
|
|
609
|
+
let path = s.url;
|
|
610
|
+
try { path = new URL(s.url).pathname || '/'; } catch {}
|
|
611
|
+
return (s.method || 'GET') + ' ' + path;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// ── Request history (persisted per-browser) ──
|
|
615
|
+
const [history, setHistory] = useState([]);
|
|
616
|
+
const [showHistory, setShowHistory] = useState(false);
|
|
617
|
+
useEffect(() => {
|
|
618
|
+
try { const h = JSON.parse(localStorage.getItem('_4bnode_history') || '[]'); if (Array.isArray(h)) setHistory(h); } catch {}
|
|
619
|
+
}, []);
|
|
620
|
+
function pushHistory(entry) {
|
|
621
|
+
setHistory(prev => {
|
|
622
|
+
const next = [entry, ...prev].slice(0, 50);
|
|
623
|
+
try { localStorage.setItem('_4bnode_history', JSON.stringify(next)); } catch {}
|
|
624
|
+
return next;
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
function clearHistory() {
|
|
628
|
+
setHistory([]);
|
|
629
|
+
try { localStorage.removeItem('_4bnode_history'); } catch {}
|
|
630
|
+
}
|
|
631
|
+
function loadHistory(h) {
|
|
632
|
+
setMethod(h.method);
|
|
633
|
+
setUrl(h.url);
|
|
634
|
+
setResponse(null);
|
|
635
|
+
setSelectedSaved(null);
|
|
636
|
+
setSelectedResponse(null);
|
|
637
|
+
setShowHistory(false);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Latest-committed mirrors of tests/responses. Persistence reads these so a PUT
|
|
641
|
+
// always sends the LATEST tests AND responses — never a stale half from an older
|
|
642
|
+
// closure (which previously could wipe saved API data when saving from sockets).
|
|
643
|
+
const savedTestsRef = useRef(savedTests);
|
|
644
|
+
const savedResponsesRef = useRef(savedResponses);
|
|
645
|
+
useEffect(() => { savedTestsRef.current = savedTests; }, [savedTests]);
|
|
646
|
+
useEffect(() => { savedResponsesRef.current = savedResponses; }, [savedResponses]);
|
|
647
|
+
|
|
507
648
|
// Load saved tests and responses from server
|
|
508
649
|
useEffect(() => {
|
|
509
650
|
api('/api-tester/data').then(data => {
|
|
510
|
-
if (data.tests) setSavedTests(data.tests);
|
|
511
|
-
if (data.responses) setSavedResponses(data.responses);
|
|
651
|
+
if (data.tests) { setSavedTests(data.tests); savedTestsRef.current = data.tests; }
|
|
652
|
+
if (data.responses) { setSavedResponses(data.responses); savedResponsesRef.current = data.responses; }
|
|
512
653
|
if (data.lastSharedAt) { setLastSharedAt(data.lastSharedAt); setSharedSnapshot(data.sharedSnapshot || null); }
|
|
513
654
|
}).catch(() => {});
|
|
514
655
|
}, []);
|
|
@@ -551,9 +692,12 @@ export function ApiTesterPage() {
|
|
|
551
692
|
// Load fields from server when route matches
|
|
552
693
|
const lastMatchRef = useRef(null);
|
|
553
694
|
const fieldsLoadedRef = useRef(false);
|
|
695
|
+
const suppressAutofillRef = useRef(false);
|
|
554
696
|
useEffect(() => {
|
|
555
697
|
if (!matchedEndpoint) { lastMatchRef.current = null; return; }
|
|
556
698
|
const matchKey = matchedEndpoint.fullPath + ':' + matchedEndpoint.method;
|
|
699
|
+
// When a tab/history/saved request is restored, don't let route auto-fill clobber it.
|
|
700
|
+
if (suppressAutofillRef.current) { suppressAutofillRef.current = false; lastMatchRef.current = matchKey; return; }
|
|
557
701
|
if (lastMatchRef.current === matchKey) return;
|
|
558
702
|
lastMatchRef.current = matchKey;
|
|
559
703
|
fieldsLoadedRef.current = false;
|
|
@@ -614,13 +758,15 @@ export function ApiTesterPage() {
|
|
|
614
758
|
const shareNeeded = lastSharedAt && sharedSnapshot !== null && sharedSnapshot !== JSON.stringify({ t: savedTests.length, r: Object.keys(savedResponses).length, ids: savedTests.map(t => t.id).join(',') });
|
|
615
759
|
|
|
616
760
|
function persistTests(tests) {
|
|
761
|
+
savedTestsRef.current = tests;
|
|
617
762
|
setSavedTests(tests);
|
|
618
|
-
api('/api-tester/data', { method: 'PUT', body: { tests, responses:
|
|
763
|
+
api('/api-tester/data', { method: 'PUT', body: { tests, responses: savedResponsesRef.current } }).catch(() => {});
|
|
619
764
|
}
|
|
620
765
|
|
|
621
766
|
function persistResponses(resp) {
|
|
767
|
+
savedResponsesRef.current = resp;
|
|
622
768
|
setSavedResponses(resp);
|
|
623
|
-
api('/api-tester/data', { method: 'PUT', body: { tests:
|
|
769
|
+
api('/api-tester/data', { method: 'PUT', body: { tests: savedTestsRef.current, responses: resp } }).catch(() => {});
|
|
624
770
|
}
|
|
625
771
|
|
|
626
772
|
function saveResponse() {
|
|
@@ -709,8 +855,16 @@ export function ApiTesterPage() {
|
|
|
709
855
|
setSelectedResponse(null);
|
|
710
856
|
try {
|
|
711
857
|
const hdrs = {};
|
|
712
|
-
headers.forEach(h => { if (h.key?.trim() && h.value?.trim()) hdrs[h.key] = h.value; });
|
|
713
|
-
|
|
858
|
+
headers.forEach(h => { if (h.key?.trim() && h.value?.trim()) hdrs[h.key] = subst(h.value); });
|
|
859
|
+
// Apply the Auth tab (variables supported via {{...}})
|
|
860
|
+
if (auth.type === 'bearer' && auth.token.trim()) {
|
|
861
|
+
hdrs['Authorization'] = 'Bearer ' + subst(auth.token).trim();
|
|
862
|
+
} else if (auth.type === 'basic' && (auth.username || auth.password)) {
|
|
863
|
+
try { hdrs['Authorization'] = 'Basic ' + btoa(subst(auth.username) + ':' + subst(auth.password)); } catch {}
|
|
864
|
+
} else if (auth.type === 'apikey' && auth.apiKeyKey.trim()) {
|
|
865
|
+
hdrs[auth.apiKeyKey.trim()] = subst(auth.apiKeyValue);
|
|
866
|
+
}
|
|
867
|
+
const finalUrl = subst(buildDisplayUrl());
|
|
714
868
|
// For base64 send-as, convert form field value into body JSON
|
|
715
869
|
let sendBody = undefined;
|
|
716
870
|
let sendBodyType = bodyType;
|
|
@@ -728,12 +882,12 @@ export function ApiTesterPage() {
|
|
|
728
882
|
if (f.type === 'file' && !f.fileData && !f.value) {
|
|
729
883
|
return null; // Skip empty file fields
|
|
730
884
|
}
|
|
731
|
-
return { key: f.key, value: f.value || '' };
|
|
885
|
+
return { key: f.key, value: subst(f.value || '') };
|
|
732
886
|
}).filter(Boolean);
|
|
733
887
|
} else if (bodyType === 'binary') {
|
|
734
888
|
sendBody = body; // base64 encoded
|
|
735
889
|
} else {
|
|
736
|
-
sendBody = body;
|
|
890
|
+
sendBody = subst(body);
|
|
737
891
|
}
|
|
738
892
|
}
|
|
739
893
|
|
|
@@ -759,12 +913,19 @@ export function ApiTesterPage() {
|
|
|
759
913
|
time: result.time,
|
|
760
914
|
size: result.size,
|
|
761
915
|
});
|
|
916
|
+
// Auto-capture an auth token from the response into {{token}}
|
|
917
|
+
if (autoToken && parsed && typeof parsed === 'object') {
|
|
918
|
+
const tok = parsed.token || parsed.accessToken || parsed.access_token || (parsed.data && parsed.data.token);
|
|
919
|
+
if (tok && typeof tok === 'string') { setVar('token', tok); toast('Captured token → {{token}}', 'success'); }
|
|
920
|
+
}
|
|
921
|
+
pushHistory({ id: Date.now(), method, url, status: result.status, time: result.time, ts: Date.now() });
|
|
762
922
|
} catch (err) {
|
|
763
923
|
setResponse({
|
|
764
924
|
status: 0, statusText: 'Error',
|
|
765
925
|
body: err.message, isJson: false,
|
|
766
926
|
time: 0, size: 0, headers: {},
|
|
767
927
|
});
|
|
928
|
+
pushHistory({ id: Date.now(), method, url, status: 0, time: 0, ts: Date.now() });
|
|
768
929
|
}
|
|
769
930
|
setLoading(false);
|
|
770
931
|
}
|
|
@@ -923,7 +1084,15 @@ export function ApiTesterPage() {
|
|
|
923
1084
|
setUrlParams([]);
|
|
924
1085
|
}
|
|
925
1086
|
|
|
926
|
-
//
|
|
1087
|
+
// Populate keys for testing — both the JSON body and the form-data rows, so
|
|
1088
|
+
// fields are pre-filled regardless of which body mode is used.
|
|
1089
|
+
const formRows = [
|
|
1090
|
+
...((ep.bodyFields || []).map((f, i) => ({ key: f.name, value: '', type: 'text', id: Date.now() + i, fromRoute: true }))),
|
|
1091
|
+
...((ep.fileFields || []).map((f, i) => ({ key: f.name, value: '', type: 'file', id: Date.now() + 500 + i, fromRoute: true }))),
|
|
1092
|
+
];
|
|
1093
|
+
setFormFields(formRows);
|
|
1094
|
+
|
|
1095
|
+
const hasFiles = ep.fileFields?.length > 0;
|
|
927
1096
|
if (ep.bodyFields?.length > 0 && ['POST','PUT','PATCH'].includes(ep.method)) {
|
|
928
1097
|
const bodyObj = {};
|
|
929
1098
|
ep.bodyFields.forEach(f => {
|
|
@@ -935,6 +1104,10 @@ export function ApiTesterPage() {
|
|
|
935
1104
|
} else {
|
|
936
1105
|
setBody('');
|
|
937
1106
|
}
|
|
1107
|
+
if (hasFiles && ['POST','PUT','PATCH'].includes(ep.method)) {
|
|
1108
|
+
setBodyType('form-data');
|
|
1109
|
+
setActiveTab('body');
|
|
1110
|
+
}
|
|
938
1111
|
|
|
939
1112
|
// Set query params for GET/DELETE
|
|
940
1113
|
if (ep.queryFields?.length > 0 && ['GET','DELETE'].includes(ep.method)) {
|
|
@@ -1213,6 +1386,23 @@ export function ApiTesterPage() {
|
|
|
1213
1386
|
onSaveLog=${handleSaveLog} />`}
|
|
1214
1387
|
|
|
1215
1388
|
${testerMode === 'rest' && html`<div>
|
|
1389
|
+
<!-- Request tabs -->
|
|
1390
|
+
<div style=${{ display: 'flex', alignItems: 'center', gap: '4px', marginBottom: '10px', flexWrap: 'wrap' }}>
|
|
1391
|
+
${tabs.map(t => { const active = t.id === activeTabId; return html`<div key=${t.id}
|
|
1392
|
+
onClick=${() => switchTab(t.id)}
|
|
1393
|
+
style=${{ display: 'inline-flex', alignItems: 'center', gap: '6px', cursor: 'pointer',
|
|
1394
|
+
border: '1px solid ' + (active ? 'var(--accent)' : 'var(--border)'),
|
|
1395
|
+
background: active ? 'var(--accent-glow)' : 'transparent',
|
|
1396
|
+
color: active ? 'var(--accent-light)' : 'var(--text-secondary)',
|
|
1397
|
+
borderRadius: '6px', padding: '5px 10px', maxWidth: '220px', fontSize: '12px' }}>
|
|
1398
|
+
<span style=${{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>${tabLabel(t)}</span>
|
|
1399
|
+
${tabs.length > 1 && html`<span onClick=${(e) => { e.stopPropagation(); closeTab(t.id); }}
|
|
1400
|
+
style=${{ cursor: 'pointer', fontSize: '15px', lineHeight: 1, opacity: 0.6 }}>×</span>`}
|
|
1401
|
+
</div>`; })}
|
|
1402
|
+
<button onClick=${newTab} title="New request tab"
|
|
1403
|
+
style=${{ border: '1px solid var(--border)', background: 'transparent', color: 'var(--text-secondary)', borderRadius: '6px', padding: '4px 11px', cursor: 'pointer', fontSize: '16px', lineHeight: 1 }}>+</button>
|
|
1404
|
+
</div>
|
|
1405
|
+
|
|
1216
1406
|
<!-- Request bar -->
|
|
1217
1407
|
<div className="api-tester-bar">
|
|
1218
1408
|
<select className="api-tester-method"
|
|
@@ -1233,6 +1423,16 @@ export function ApiTesterPage() {
|
|
|
1233
1423
|
${loading ? html`<${LuLoader} size=${14} className="spin" />` : html`<${LuSend} size=${14} />`}
|
|
1234
1424
|
Send
|
|
1235
1425
|
</button>
|
|
1426
|
+
<button className="api-tester-send" onClick=${() => { setShowVars(v => !v); setShowHistory(false); }}
|
|
1427
|
+
title="Environment variables"
|
|
1428
|
+
style=${{ background: showVars ? 'var(--accent-glow)' : 'transparent', border: '1px solid var(--border)', color: showVars ? 'var(--accent-light)' : 'var(--text-primary)' }}>
|
|
1429
|
+
Variables${variables.filter(v => v.key?.trim()).length ? ` (${variables.filter(v => v.key?.trim()).length})` : ''}
|
|
1430
|
+
</button>
|
|
1431
|
+
<button className="api-tester-send" onClick=${() => { setShowHistory(h => !h); setShowVars(false); }}
|
|
1432
|
+
title="Request history"
|
|
1433
|
+
style=${{ background: showHistory ? 'var(--accent-glow)' : 'transparent', border: '1px solid var(--border)', color: showHistory ? 'var(--accent-light)' : 'var(--text-primary)' }}>
|
|
1434
|
+
History
|
|
1435
|
+
</button>
|
|
1236
1436
|
${!savedTests.some(t => t.type !== 'socketio' && t.type !== 'websocket' && t.method === method && t.url === url) && html`<button className="api-tester-send" onClick=${saveTest} disabled=${!url.trim()}
|
|
1237
1437
|
style=${{ background: 'transparent', border: '1px solid var(--border)', color: 'var(--text-primary)' }}>
|
|
1238
1438
|
<${LuSave} size=${14} /> Save
|
|
@@ -1254,6 +1454,47 @@ export function ApiTesterPage() {
|
|
|
1254
1454
|
borderRadius: '6px', marginBottom: '8px', wordBreak: 'break-all',
|
|
1255
1455
|
}}>${displayUrl}</div>`}
|
|
1256
1456
|
|
|
1457
|
+
${showVars && html`<div className="card" style=${{ padding: '12px 14px', marginBottom: '12px' }}>
|
|
1458
|
+
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
|
|
1459
|
+
<span style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>Environment Variables</span>
|
|
1460
|
+
<label style=${{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: 'var(--text-secondary)', cursor: 'pointer' }}>
|
|
1461
|
+
<input type="checkbox" checked=${autoToken} onChange=${e => setAutoToken(e.target.checked)} /> Auto-capture token from response
|
|
1462
|
+
</label>
|
|
1463
|
+
</div>
|
|
1464
|
+
${variables.map((v, i) => html`<div key=${v.id || i} className="api-tester-kv-row">
|
|
1465
|
+
<input placeholder="name" value=${v.key}
|
|
1466
|
+
onInput=${e => setVariables(prev => prev.map((x, j) => j === i ? { ...x, key: e.target.value } : x))} />
|
|
1467
|
+
<input placeholder="value" value=${v.value}
|
|
1468
|
+
onInput=${e => setVariables(prev => prev.map((x, j) => j === i ? { ...x, value: e.target.value } : x))} />
|
|
1469
|
+
<button className="btn btn-ghost" style=${{ padding: '4px', opacity: 0.5 }}
|
|
1470
|
+
onClick=${() => setVariables(prev => prev.filter((_, j) => j !== i))}><${LuTrash2} size=${12} /></button>
|
|
1471
|
+
</div>`)}
|
|
1472
|
+
<div style=${{ padding: '8px 0 0' }}>
|
|
1473
|
+
<button className="btn btn-ghost btn-sm" onClick=${() => setVariables(prev => [...prev, { key: '', value: '', id: Date.now() }])}>
|
|
1474
|
+
<${LuPlus} size=${12} /> Add variable
|
|
1475
|
+
</button>
|
|
1476
|
+
</div>
|
|
1477
|
+
<div className="text-sm text-muted" style=${{ marginTop: '6px' }}>
|
|
1478
|
+
Reference anywhere with <span style=${{ fontFamily: "'SF Mono', monospace", color: 'var(--accent-light)' }}>{{name}}</span> — URL, headers, body, or Auth.
|
|
1479
|
+
</div>
|
|
1480
|
+
</div>`}
|
|
1481
|
+
|
|
1482
|
+
${showHistory && html`<div className="card" style=${{ padding: 0, marginBottom: '12px', overflow: 'hidden' }}>
|
|
1483
|
+
<div style=${{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', borderBottom: '1px solid var(--border)' }}>
|
|
1484
|
+
<span style=${{ fontSize: '11px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px', color: 'var(--text-muted)' }}>History</span>
|
|
1485
|
+
${history.length > 0 && html`<button className="btn btn-ghost btn-sm" onClick=${clearHistory} style=${{ fontSize: '11px' }}>Clear</button>`}
|
|
1486
|
+
</div>
|
|
1487
|
+
${history.length === 0
|
|
1488
|
+
? html`<div style=${{ padding: '16px', color: 'var(--text-muted)', fontSize: '12px' }}>No requests yet — sent requests show up here.</div>`
|
|
1489
|
+
: history.map(h => html`<div key=${h.id}
|
|
1490
|
+
onClick=${() => loadHistory(h)}
|
|
1491
|
+
style=${{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px 14px', borderBottom: '1px solid var(--border)', cursor: 'pointer', fontSize: '12px' }}>
|
|
1492
|
+
<span style=${{ fontWeight: 600, color: methodColors[h.method] || 'var(--text-secondary)', minWidth: '46px' }}>${h.method}</span>
|
|
1493
|
+
<span style=${{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontFamily: "'SF Mono', monospace" }}>${h.url}</span>
|
|
1494
|
+
<span style=${{ fontWeight: 600, color: h.status >= 200 && h.status < 300 ? 'var(--success)' : h.status === 0 ? 'var(--text-muted)' : 'var(--warning)' }}>${h.status || '—'}</span>
|
|
1495
|
+
</div>`)}
|
|
1496
|
+
</div>`}
|
|
1497
|
+
|
|
1257
1498
|
<!-- URL Params (:id, etc.) -->
|
|
1258
1499
|
${urlParams.length > 0 && html`<div className="card" style=${{ padding: 0, overflow: 'hidden', marginBottom: '16px' }}>
|
|
1259
1500
|
<div style=${{ padding: '8px 14px', borderBottom: '1px solid var(--border)', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
|
@@ -1285,8 +1526,32 @@ export function ApiTesterPage() {
|
|
|
1285
1526
|
onClick=${() => setActiveTab('body')}>
|
|
1286
1527
|
Body
|
|
1287
1528
|
</button>`}
|
|
1529
|
+
<button className=${`api-tester-tab${activeTab === 'auth' ? ' active' : ''}`}
|
|
1530
|
+
onClick=${() => setActiveTab('auth')}>
|
|
1531
|
+
Auth${auth.type !== 'none' ? html` <span style=${{ color: 'var(--success)', marginLeft: '2px' }}>●</span>` : ''}
|
|
1532
|
+
</button>
|
|
1288
1533
|
</div>
|
|
1289
1534
|
|
|
1535
|
+
${activeTab === 'auth' && html`<div style=${{ padding: '12px 14px' }}>
|
|
1536
|
+
<div style=${{ display: 'flex', gap: '6px', marginBottom: '12px', flexWrap: 'wrap' }}>
|
|
1537
|
+
${[['none', 'No Auth'], ['bearer', 'Bearer Token'], ['basic', 'Basic'], ['apikey', 'API Key']].map(([t, label]) => html`<button key=${t}
|
|
1538
|
+
onClick=${() => setAuth(a => ({ ...a, type: t }))}
|
|
1539
|
+
className="badge"
|
|
1540
|
+
style=${{ cursor: 'pointer', border: '1px solid var(--border)', background: auth.type === t ? 'var(--accent-glow)' : 'var(--bg-input)', color: auth.type === t ? 'var(--accent-light)' : 'var(--text-secondary)' }}>${label}</button>`)}
|
|
1541
|
+
</div>
|
|
1542
|
+
${auth.type === 'bearer' && html`<input className="form-input" placeholder="Token (e.g. {{token}})"
|
|
1543
|
+
value=${auth.token} onInput=${e => setAuth(a => ({ ...a, token: e.target.value }))} />`}
|
|
1544
|
+
${auth.type === 'basic' && html`<div style=${{ display: 'flex', gap: '8px' }}>
|
|
1545
|
+
<input className="form-input" placeholder="Username" value=${auth.username} onInput=${e => setAuth(a => ({ ...a, username: e.target.value }))} />
|
|
1546
|
+
<input className="form-input" placeholder="Password" value=${auth.password} onInput=${e => setAuth(a => ({ ...a, password: e.target.value }))} />
|
|
1547
|
+
</div>`}
|
|
1548
|
+
${auth.type === 'apikey' && html`<div style=${{ display: 'flex', gap: '8px' }}>
|
|
1549
|
+
<input className="form-input" placeholder="Header name" value=${auth.apiKeyKey} onInput=${e => setAuth(a => ({ ...a, apiKeyKey: e.target.value }))} style=${{ flex: '0 0 170px' }} />
|
|
1550
|
+
<input className="form-input" placeholder="Value (e.g. {{apiKey}})" value=${auth.apiKeyValue} onInput=${e => setAuth(a => ({ ...a, apiKeyValue: e.target.value }))} />
|
|
1551
|
+
</div>`}
|
|
1552
|
+
${auth.type === 'none' && html`<div className="text-sm text-muted">No authentication will be sent with this request.</div>`}
|
|
1553
|
+
</div>`}
|
|
1554
|
+
|
|
1290
1555
|
${activeTab === 'params' && html`<div>
|
|
1291
1556
|
${params.map(p => html`<div key=${p.id} className="api-tester-kv-row">
|
|
1292
1557
|
<input placeholder="Param name" value=${p.key}
|
|
@@ -1351,6 +1616,7 @@ export function ApiTesterPage() {
|
|
|
1351
1616
|
|
|
1352
1617
|
${(bodyType === 'json' || bodyType === 'raw') && html`<div>
|
|
1353
1618
|
<textarea
|
|
1619
|
+
ref=${bodyRef}
|
|
1354
1620
|
className="api-tester-body-editor"
|
|
1355
1621
|
placeholder=${bodyType === 'json' ? '{\n "key": "value"\n}' : 'Enter raw body...'}
|
|
1356
1622
|
value=${body}
|