@intranefr/superbackend 1.4.4 → 1.5.0
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/index.js +16 -1
- package/package.json +5 -2
- package/public/sdk/ui-components.iife.js +191 -0
- package/sdk/ui-components/browser/src/index.js +228 -0
- package/src/controllers/admin.controller.js +89 -0
- package/src/controllers/adminHeadless.controller.js +82 -0
- package/src/controllers/adminScripts.controller.js +229 -0
- package/src/controllers/adminTerminals.controller.js +39 -0
- package/src/controllers/adminUiComponents.controller.js +315 -0
- package/src/controllers/adminUiComponentsAi.controller.js +34 -0
- package/src/controllers/orgAdmin.controller.js +286 -0
- package/src/controllers/uiComponentsPublic.controller.js +118 -0
- package/src/middleware/auth.js +7 -0
- package/src/middleware.js +115 -0
- package/src/models/HeadlessModelDefinition.js +10 -0
- package/src/models/ScriptDefinition.js +42 -0
- package/src/models/ScriptRun.js +22 -0
- package/src/models/UiComponent.js +29 -0
- package/src/models/UiComponentProject.js +26 -0
- package/src/models/UiComponentProjectComponent.js +18 -0
- package/src/routes/admin.routes.js +1 -0
- package/src/routes/adminHeadless.routes.js +6 -0
- package/src/routes/adminScripts.routes.js +21 -0
- package/src/routes/adminTerminals.routes.js +13 -0
- package/src/routes/adminUiComponents.routes.js +29 -0
- package/src/routes/llmUi.routes.js +26 -0
- package/src/routes/orgAdmin.routes.js +5 -0
- package/src/routes/uiComponentsPublic.routes.js +9 -0
- package/src/services/headlessExternalModels.service.js +292 -0
- package/src/services/headlessModels.service.js +26 -6
- package/src/services/scriptsRunner.service.js +259 -0
- package/src/services/terminals.service.js +152 -0
- package/src/services/terminalsWs.service.js +100 -0
- package/src/services/uiComponentsAi.service.js +312 -0
- package/src/services/uiComponentsCrypto.service.js +39 -0
- package/views/admin-headless.ejs +294 -24
- package/views/admin-organizations.ejs +365 -9
- package/views/admin-scripts.ejs +497 -0
- package/views/admin-terminals.ejs +328 -0
- package/views/admin-ui-components.ejs +709 -0
- package/views/admin-users.ejs +261 -4
- package/views/partials/dashboard/nav-items.ejs +3 -0
package/views/admin-users.ejs
CHANGED
|
@@ -153,6 +153,37 @@
|
|
|
153
153
|
<option value="trialing">trialing</option>
|
|
154
154
|
</select>
|
|
155
155
|
</div>
|
|
156
|
+
|
|
157
|
+
<!-- Password Section -->
|
|
158
|
+
<div class="border-t pt-4">
|
|
159
|
+
<div class="flex items-center justify-between mb-3">
|
|
160
|
+
<label class="text-sm font-medium text-gray-700">Password Management</label>
|
|
161
|
+
<input type="checkbox" id="edit-reset-password" class="rounded text-blue-600 focus:ring-blue-500">
|
|
162
|
+
<label for="edit-reset-password" class="text-sm text-gray-600 ml-2">Reset Password</label>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div id="password-fields" class="space-y-3 hidden">
|
|
166
|
+
<div>
|
|
167
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
|
|
168
|
+
<div class="relative">
|
|
169
|
+
<input id="edit-password" type="password" class="w-full border rounded px-3 py-2 pr-10" placeholder="Enter new password" minlength="6">
|
|
170
|
+
<button type="button" id="toggle-edit-password" class="absolute right-2 top-2 text-gray-500 hover:text-gray-700">
|
|
171
|
+
<svg id="edit-eye-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
172
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
173
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
|
174
|
+
</svg>
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
<div id="password-strength" class="mt-1 text-xs"></div>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div>
|
|
181
|
+
<label class="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label>
|
|
182
|
+
<input id="edit-confirm-password" type="password" class="w-full border rounded px-3 py-2" placeholder="Confirm new password">
|
|
183
|
+
<div id="password-match" class="mt-1 text-xs"></div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
156
187
|
</div>
|
|
157
188
|
<div class="flex justify-end gap-2 mt-6">
|
|
158
189
|
<button id="btn-modal-cancel" class="bg-gray-100 text-gray-800 px-4 py-2 rounded hover:bg-gray-200">Cancel</button>
|
|
@@ -342,10 +373,12 @@
|
|
|
342
373
|
<td class="px-4 py-3 text-sm text-gray-700 whitespace-nowrap">
|
|
343
374
|
<button class="text-blue-600 hover:text-blue-800 mr-2" data-edit="${escapeHtml(u._id)}" data-name="${escapeHtml(u.name || '')}" data-role="${escapeHtml(u.role)}" data-plan="${escapeHtml(u.currentPlan)}" data-subscription="${escapeHtml(u.subscriptionStatus)}">Edit</button>
|
|
344
375
|
<button class="text-green-600 hover:text-green-800 mr-2" data-notify="${escapeHtml(u._id)}" data-email="${escapeHtml(u.email)}">Notify</button>
|
|
376
|
+
<button class="text-purple-600 hover:text-purple-800 mr-2" data-get-jwt="${escapeHtml(u._id)}" data-email="${escapeHtml(u.email)}">Get JWT</button>
|
|
345
377
|
${u.disabled
|
|
346
|
-
? `<button class="text-green-600 hover:text-green-800" data-enable="${escapeHtml(u._id)}">Enable</button>`
|
|
347
|
-
: `<button class="text-red-600 hover:text-red-800" data-disable="${escapeHtml(u._id)}">Disable</button>`
|
|
378
|
+
? `<button class="text-green-600 hover:text-green-800 mr-2" data-enable="${escapeHtml(u._id)}">Enable</button>`
|
|
379
|
+
: `<button class="text-red-600 hover:text-red-800 mr-2" data-disable="${escapeHtml(u._id)}">Disable</button>`
|
|
348
380
|
}
|
|
381
|
+
<button class="text-red-600 hover:text-red-800 font-semibold" data-delete="${escapeHtml(u._id)}" data-email="${escapeHtml(u.email)}">Delete</button>
|
|
349
382
|
</td>
|
|
350
383
|
</tr>
|
|
351
384
|
`;
|
|
@@ -359,6 +392,18 @@
|
|
|
359
392
|
btn.addEventListener('click', () => openNotifyModal(btn.dataset.notify, btn.dataset.email));
|
|
360
393
|
});
|
|
361
394
|
|
|
395
|
+
tbody.querySelectorAll('[data-get-jwt]').forEach(btn => {
|
|
396
|
+
btn.addEventListener('click', async () => {
|
|
397
|
+
await getJwtForUser(btn.dataset.getJwt, btn.dataset.email);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
tbody.querySelectorAll('[data-delete]').forEach(btn => {
|
|
402
|
+
btn.addEventListener('click', async () => {
|
|
403
|
+
await deleteUser(btn.dataset.delete, btn.dataset.email);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
362
407
|
tbody.querySelectorAll('[data-disable]').forEach(btn => {
|
|
363
408
|
btn.addEventListener('click', async () => {
|
|
364
409
|
if (!confirm('Disable this user?')) return;
|
|
@@ -394,6 +439,15 @@
|
|
|
394
439
|
document.getElementById('edit-role').value = role || 'user';
|
|
395
440
|
document.getElementById('edit-plan').value = plan || 'free';
|
|
396
441
|
document.getElementById('edit-subscription').value = subscription || 'none';
|
|
442
|
+
|
|
443
|
+
// Reset password fields
|
|
444
|
+
document.getElementById('edit-reset-password').checked = false;
|
|
445
|
+
document.getElementById('password-fields').classList.add('hidden');
|
|
446
|
+
document.getElementById('edit-password').value = '';
|
|
447
|
+
document.getElementById('edit-confirm-password').value = '';
|
|
448
|
+
document.getElementById('password-strength').textContent = '';
|
|
449
|
+
document.getElementById('password-match').textContent = '';
|
|
450
|
+
|
|
397
451
|
document.getElementById('modal-edit').classList.remove('hidden');
|
|
398
452
|
}
|
|
399
453
|
|
|
@@ -401,22 +455,135 @@
|
|
|
401
455
|
document.getElementById('modal-edit').classList.add('hidden');
|
|
402
456
|
}
|
|
403
457
|
|
|
458
|
+
async function getJwtForUser(userId, email) {
|
|
459
|
+
try {
|
|
460
|
+
// Show loading state
|
|
461
|
+
showToast('Generating JWT...', 'success');
|
|
462
|
+
|
|
463
|
+
const res = await fetch(`${API_BASE}/api/admin/generate-token`, {
|
|
464
|
+
method: 'POST',
|
|
465
|
+
headers: { 'Content-Type': 'application/json' },
|
|
466
|
+
body: JSON.stringify({ userId }),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const data = await res.json();
|
|
470
|
+
|
|
471
|
+
if (!res.ok) {
|
|
472
|
+
showToast(data?.error || 'Failed to generate JWT', 'error');
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Copy JWT to clipboard
|
|
477
|
+
try {
|
|
478
|
+
await navigator.clipboard.writeText(data.token);
|
|
479
|
+
showToast(`JWT for ${email} copied to clipboard!`, 'success');
|
|
480
|
+
} catch (clipboardError) {
|
|
481
|
+
// Fallback for browsers that don't support clipboard API
|
|
482
|
+
const textArea = document.createElement('textarea');
|
|
483
|
+
textArea.value = data.token;
|
|
484
|
+
textArea.style.position = 'fixed';
|
|
485
|
+
textArea.style.opacity = '0';
|
|
486
|
+
document.body.appendChild(textArea);
|
|
487
|
+
textArea.select();
|
|
488
|
+
document.execCommand('copy');
|
|
489
|
+
document.body.removeChild(textArea);
|
|
490
|
+
showToast(`JWT for ${email} copied to clipboard!`, 'success');
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Also log the token for debugging (in production, you might want to remove this)
|
|
494
|
+
console.log(`Generated JWT for ${email}:`, data.token);
|
|
495
|
+
|
|
496
|
+
} catch (e) {
|
|
497
|
+
showToast(e.message || 'Failed to generate JWT', 'error');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function deleteUser(userId, email) {
|
|
502
|
+
// Double confirmation with clear warnings
|
|
503
|
+
const confirm1 = confirm(`Are you sure you want to delete user ${email}?`);
|
|
504
|
+
if (!confirm1) return;
|
|
505
|
+
|
|
506
|
+
const confirm2 = confirm(
|
|
507
|
+
'WARNING: This will permanently delete the user and ALL their data including:\n' +
|
|
508
|
+
'• Organizations they own (if no other members)\n' +
|
|
509
|
+
'• All their assets and files\n' +
|
|
510
|
+
'• Organization memberships\n' +
|
|
511
|
+
'• Notifications and invites\n' +
|
|
512
|
+
'• Email logs and form submissions\n\n' +
|
|
513
|
+
'Activity logs and audit events will be preserved.\n' +
|
|
514
|
+
'This action cannot be undone. Continue?'
|
|
515
|
+
);
|
|
516
|
+
if (!confirm2) return;
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
showToast('Deleting user and cleaning up data...', 'success');
|
|
520
|
+
|
|
521
|
+
const res = await fetch(`${API_BASE}/api/admin/users/${encodeURIComponent(userId)}`, {
|
|
522
|
+
method: 'DELETE'
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const data = await res.json();
|
|
526
|
+
|
|
527
|
+
if (!res.ok) {
|
|
528
|
+
showToast(data?.error || 'Failed to delete user', 'error');
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
showToast(`User ${email} deleted permanently`, 'success');
|
|
533
|
+
await Promise.all([loadUsers(), loadStats()]);
|
|
534
|
+
} catch (e) {
|
|
535
|
+
showToast(e.message || 'Failed to delete user', 'error');
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
404
539
|
async function saveUser() {
|
|
405
540
|
const userId = document.getElementById('edit-user-id').value;
|
|
406
541
|
const name = document.getElementById('edit-name').value.trim();
|
|
407
542
|
const role = document.getElementById('edit-role').value;
|
|
408
543
|
const currentPlan = document.getElementById('edit-plan').value;
|
|
409
544
|
const subscriptionStatus = document.getElementById('edit-subscription').value;
|
|
545
|
+
|
|
546
|
+
// Password handling
|
|
547
|
+
const resetPassword = document.getElementById('edit-reset-password').checked;
|
|
548
|
+
const password = document.getElementById('edit-password').value;
|
|
549
|
+
const confirmPassword = document.getElementById('edit-confirm-password').value;
|
|
550
|
+
|
|
551
|
+
// Validate password fields if reset is checked
|
|
552
|
+
if (resetPassword) {
|
|
553
|
+
if (!password) {
|
|
554
|
+
showToast('Password is required when reset is enabled', 'error');
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (password.length < 6) {
|
|
559
|
+
showToast('Password must be at least 6 characters', 'error');
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (password !== confirmPassword) {
|
|
564
|
+
showToast('Passwords do not match', 'error');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
410
568
|
|
|
411
569
|
try {
|
|
570
|
+
const updateData = { name, role, currentPlan, subscriptionStatus };
|
|
571
|
+
|
|
572
|
+
// Only include password if reset is checked
|
|
573
|
+
if (resetPassword && password) {
|
|
574
|
+
updateData.passwordHash = password;
|
|
575
|
+
}
|
|
576
|
+
|
|
412
577
|
const res = await fetch(`${API_BASE}/api/admin/users/${encodeURIComponent(userId)}`, {
|
|
413
578
|
method: 'PATCH',
|
|
414
579
|
headers: { 'Content-Type': 'application/json' },
|
|
415
|
-
body: JSON.stringify(
|
|
580
|
+
body: JSON.stringify(updateData),
|
|
416
581
|
});
|
|
417
582
|
const data = await res.json();
|
|
418
583
|
if (!res.ok) { showToast(data?.error || 'Failed to update user', 'error'); return; }
|
|
419
|
-
|
|
584
|
+
|
|
585
|
+
const message = resetPassword ? 'User and password updated' : 'User updated';
|
|
586
|
+
showToast(message, 'success');
|
|
420
587
|
closeEditModal();
|
|
421
588
|
await Promise.all([loadUsers(), loadStats()]);
|
|
422
589
|
} catch (e) { showToast(e.message, 'error'); }
|
|
@@ -584,6 +751,78 @@
|
|
|
584
751
|
return window.location.origin + endpoint;
|
|
585
752
|
}
|
|
586
753
|
|
|
754
|
+
// Password validation functions
|
|
755
|
+
function checkPasswordStrength(password) {
|
|
756
|
+
if (!password) return '';
|
|
757
|
+
|
|
758
|
+
let strength = 0;
|
|
759
|
+
const feedback = [];
|
|
760
|
+
|
|
761
|
+
if (password.length >= 6) strength++;
|
|
762
|
+
else feedback.push('at least 6 characters');
|
|
763
|
+
|
|
764
|
+
if (password.length >= 10) strength++;
|
|
765
|
+
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
|
766
|
+
if (/\d/.test(password)) strength++;
|
|
767
|
+
if (/[^a-zA-Z\d]/.test(password)) strength++;
|
|
768
|
+
|
|
769
|
+
const strengthText = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong'][strength] || 'Very Weak';
|
|
770
|
+
const strengthColor = ['text-red-500', 'text-orange-500', 'text-yellow-500', 'text-blue-500', 'text-green-500'][strength] || 'text-red-500';
|
|
771
|
+
|
|
772
|
+
return { strength, strengthText, strengthColor, feedback };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function updatePasswordStrength() {
|
|
776
|
+
const password = document.getElementById('edit-password').value;
|
|
777
|
+
const strengthDiv = document.getElementById('password-strength');
|
|
778
|
+
|
|
779
|
+
if (!password) {
|
|
780
|
+
strengthDiv.textContent = '';
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
const { strengthText, strengthColor } = checkPasswordStrength(password);
|
|
785
|
+
strengthDiv.textContent = `Strength: ${strengthText}`;
|
|
786
|
+
strengthDiv.className = `mt-1 text-xs ${strengthColor}`;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function updatePasswordMatch() {
|
|
790
|
+
const password = document.getElementById('edit-password').value;
|
|
791
|
+
const confirmPassword = document.getElementById('edit-confirm-password').value;
|
|
792
|
+
const matchDiv = document.getElementById('password-match');
|
|
793
|
+
|
|
794
|
+
if (!confirmPassword) {
|
|
795
|
+
matchDiv.textContent = '';
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (password === confirmPassword) {
|
|
800
|
+
matchDiv.textContent = '✓ Passwords match';
|
|
801
|
+
matchDiv.className = 'mt-1 text-xs text-green-500';
|
|
802
|
+
} else {
|
|
803
|
+
matchDiv.textContent = '✗ Passwords do not match';
|
|
804
|
+
matchDiv.className = 'mt-1 text-xs text-red-500';
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function toggleEditPasswordVisibility() {
|
|
809
|
+
const passwordInput = document.getElementById('edit-password');
|
|
810
|
+
const eyeIcon = document.getElementById('edit-eye-icon');
|
|
811
|
+
|
|
812
|
+
if (passwordInput.type === 'password') {
|
|
813
|
+
passwordInput.type = 'text';
|
|
814
|
+
eyeIcon.innerHTML = `
|
|
815
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
|
816
|
+
`;
|
|
817
|
+
} else {
|
|
818
|
+
passwordInput.type = 'password';
|
|
819
|
+
eyeIcon.innerHTML = `
|
|
820
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
821
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
|
822
|
+
`;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
587
826
|
function bindEvents() {
|
|
588
827
|
document.getElementById('btn-refresh').onclick = () => Promise.all([loadUsers(), loadStats()]);
|
|
589
828
|
document.getElementById('btn-apply').onclick = () => { state.offset = 0; loadUsers(); };
|
|
@@ -603,6 +842,24 @@
|
|
|
603
842
|
document.getElementById('btn-notify-cancel').onclick = closeNotifyModal;
|
|
604
843
|
document.getElementById('btn-notify-send').onclick = sendNotification;
|
|
605
844
|
|
|
845
|
+
// Edit modal password events
|
|
846
|
+
document.getElementById('edit-reset-password').onchange = function() {
|
|
847
|
+
const passwordFields = document.getElementById('password-fields');
|
|
848
|
+
if (this.checked) {
|
|
849
|
+
passwordFields.classList.remove('hidden');
|
|
850
|
+
} else {
|
|
851
|
+
passwordFields.classList.add('hidden');
|
|
852
|
+
document.getElementById('edit-password').value = '';
|
|
853
|
+
document.getElementById('edit-confirm-password').value = '';
|
|
854
|
+
document.getElementById('password-strength').textContent = '';
|
|
855
|
+
document.getElementById('password-match').textContent = '';
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
document.getElementById('toggle-edit-password').onclick = toggleEditPasswordVisibility;
|
|
860
|
+
document.getElementById('edit-password').oninput = updatePasswordStrength;
|
|
861
|
+
document.getElementById('edit-confirm-password').oninput = updatePasswordMatch;
|
|
862
|
+
|
|
606
863
|
// Registration modal events
|
|
607
864
|
document.getElementById('btn-register-user').onclick = openRegisterModal;
|
|
608
865
|
document.getElementById('btn-register-cancel').onclick = closeRegisterModal;
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
{ id: 'json', label: 'JSON Configs', path: adminPath + '/json-configs', icon: 'ti-braces' },
|
|
26
26
|
{ id: 'seo', label: 'SEO Config', path: adminPath + '/seo-config', icon: 'ti-search' },
|
|
27
27
|
{ id: 'assets', label: 'Assets', path: adminPath + '/assets', icon: 'ti-photo' },
|
|
28
|
+
{ id: 'ui-components', label: 'UI Components', path: adminPath + '/ui-components', icon: 'ti-components' },
|
|
28
29
|
{ id: 'headless', label: 'Headless CMS', path: adminPath + '/headless', icon: 'ti-table' },
|
|
29
30
|
]
|
|
30
31
|
},
|
|
@@ -59,6 +60,8 @@
|
|
|
59
60
|
title: 'Automation',
|
|
60
61
|
items: [
|
|
61
62
|
{ id: 'workflows', label: 'Workflows', path: adminPath + '/workflows/all', icon: 'ti-robot' },
|
|
63
|
+
{ id: 'scripts', label: 'Scripts', path: adminPath + '/scripts', icon: 'ti-terminal-2' },
|
|
64
|
+
{ id: 'terminals', label: 'Terminals', path: adminPath + '/terminals', icon: 'ti-terminal' },
|
|
62
65
|
]
|
|
63
66
|
}
|
|
64
67
|
];
|