clay-server 5.1.0-beta.1 → 5.1.0-beta.3
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/LICENSES/README.md +4 -0
- package/LICENSES/dicebear-avatars.md +16 -0
- package/bin/cli.js +9 -91
- package/lib/builtin-mates.js +7 -7
- package/lib/daemon-project-access.js +7 -1
- package/lib/daemon-settings.js +11 -0
- package/lib/daemon.js +10 -37
- package/lib/dicebear-avatar.js +62 -0
- package/lib/knowledge-db-service.js +175 -0
- package/lib/knowledge-db-validation.js +0 -0
- package/lib/knowledge-document-index.js +49 -0
- package/lib/knowledge-draft-store.js +56 -0
- package/lib/knowledge-file-store.js +199 -0
- package/lib/knowledge-markdown.js +128 -0
- package/lib/linear-store.js +35 -8
- package/lib/mate-identity-file.js +39 -0
- package/lib/mate-knowledge-mcp-server.js +29 -6
- package/lib/mate-knowledge-service.js +23 -2
- package/lib/mate-knowledge-sync.js +37 -22
- package/lib/mate-ready-creation.js +2 -1
- package/lib/mate-workspace-color.js +4 -0
- package/lib/mates-identity.js +4 -4
- package/lib/mates-prompts.js +4 -3
- package/lib/mates.js +30 -11
- package/lib/os-users.js +5 -16
- package/lib/project-connection.js +36 -9
- package/lib/project-debate-proposal.js +6 -0
- package/lib/project-debate-workspace.js +43 -0
- package/lib/project-debate.js +4 -2
- package/lib/project-filesystem.js +21 -39
- package/lib/project-knowledge.js +315 -136
- package/lib/project-mate-creation-proposal.js +1 -0
- package/lib/project-mate-interaction.js +7 -6
- package/lib/project-server-settings.js +31 -0
- package/lib/project-sessions.js +92 -182
- package/lib/project-user-knowledge.js +60 -0
- package/lib/project.js +62 -21
- package/lib/public/app.js +79 -71
- package/lib/public/clay-studio-wordmark-bold.svg +20 -0
- package/lib/public/css/clay-workspace.css +105 -0
- package/lib/public/css/debates-workbench.css +47 -0
- package/lib/public/css/home-debate-live.css +26 -0
- package/lib/public/css/home-hub.css +3 -1
- package/lib/public/css/home-mate-model-picker.css +15 -131
- package/lib/public/css/home-mate-settings.css +168 -16
- package/lib/public/css/icon-strip.css +249 -32
- package/lib/public/css/input.css +3 -1
- package/lib/public/css/mate-conversation.css +176 -0
- package/lib/public/css/mate-knowledge-workbench.css +118 -0
- package/lib/public/css/mate-workspace-color.css +16 -0
- package/lib/public/css/mates.css +28 -28
- package/lib/public/css/menus.css +3 -0
- package/lib/public/css/messages.css +1 -114
- package/lib/public/css/mobile-nav.css +16 -16
- package/lib/public/css/overlays.css +15 -0
- package/lib/public/css/project-logs.css +7 -0
- package/lib/public/css/project-mate-creation.css +105 -0
- package/lib/public/css/project-settings-modal.css +56 -0
- package/lib/public/css/right-workbench.css +3 -3
- package/lib/public/css/server-settings-system.css +125 -0
- package/lib/public/css/session-folders.css +80 -21
- package/lib/public/css/session-github.css +25 -16
- package/lib/public/css/settings-ui.css +247 -22
- package/lib/public/css/shared-browser.css +7 -3
- package/lib/public/css/sidebar.css +183 -107
- package/lib/public/css/title-bar.css +156 -1
- package/lib/public/css/worker-delegation.css +47 -0
- package/lib/public/css/worker-proposal.css +72 -150
- package/lib/public/css/you.css +74 -0
- package/lib/public/index.html +206 -548
- package/lib/public/modules/account-island.js +232 -0
- package/lib/public/modules/admin-settings-email.js +197 -0
- package/lib/public/modules/admin-settings-invitations.js +183 -0
- package/lib/public/modules/admin-settings-projects.js +198 -0
- package/lib/public/modules/admin-settings-ui.js +100 -0
- package/lib/public/modules/admin-settings-users.js +331 -0
- package/lib/public/modules/admin.js +19 -949
- package/lib/public/modules/app-cursors.js +19 -43
- package/lib/public/modules/app-debate-ui.js +4 -3
- package/lib/public/modules/app-dm.js +0 -2
- package/lib/public/modules/app-header.js +106 -0
- package/lib/public/modules/app-message-router.js +12 -3
- package/lib/public/modules/app-messages.js +47 -61
- package/lib/public/modules/app-notifications.js +4 -7
- package/lib/public/modules/app-projects.js +5 -38
- package/lib/public/modules/app-rate-limit.js +70 -12
- package/lib/public/modules/app-rendering.js +12 -13
- package/lib/public/modules/avatar.js +30 -9
- package/lib/public/modules/chat-render-runtime.js +16 -4
- package/lib/public/modules/command-palette.js +2 -15
- package/lib/public/modules/context-sources.js +27 -18
- package/lib/public/modules/cursor-sharing-preference.js +3 -3
- package/lib/public/modules/debate-controls.js +299 -0
- package/lib/public/modules/debate.js +2 -0
- package/lib/public/modules/debates-workbench.js +339 -0
- package/lib/public/modules/default-ai.js +88 -65
- package/lib/public/modules/header-tui-font.js +10 -20
- package/lib/public/modules/home-debate-controls.js +18 -289
- package/lib/public/modules/home-mate-avatar-editor.js +228 -0
- package/lib/public/modules/home-mate-color-picker.js +66 -0
- package/lib/public/modules/home-mate-creation.js +1 -1
- package/lib/public/modules/home-mate-instructions.js +90 -0
- package/lib/public/modules/home-mate-model-picker.js +80 -271
- package/lib/public/modules/home-mate-profile-editor.js +72 -0
- package/lib/public/modules/home-mate-settings.js +118 -125
- package/lib/public/modules/home-shell.js +3 -0
- package/lib/public/modules/home-surface-boot.js +1 -9
- package/lib/public/modules/icon-strip-tooltip.js +95 -0
- package/lib/public/modules/knowledge-db-edit-state.js +35 -0
- package/lib/public/modules/knowledge-db-workbench.js +234 -0
- package/lib/public/modules/knowledge-history-state.js +55 -0
- package/lib/public/modules/knowledge-markdown-editor.js +57 -0
- package/lib/public/modules/knowledge-preview-links.js +70 -0
- package/lib/public/modules/knowledge-workbench-shell.js +21 -0
- package/lib/public/modules/linear-settings.js +3 -2
- package/lib/public/modules/markdown.js +5 -6
- package/lib/public/modules/mate-knowledge-workbench-state.js +174 -0
- package/lib/public/modules/mate-knowledge-workbench.js +500 -0
- package/lib/public/modules/mate-knowledge.js +11 -3
- package/lib/public/modules/mate-management.js +54 -10
- package/lib/public/modules/mate-sidebar.js +11 -1
- package/lib/public/modules/mate-workspace-color.js +33 -0
- package/lib/public/modules/mention.js +5 -13
- package/lib/public/modules/notification-settings.js +81 -0
- package/lib/public/modules/notifications.js +44 -28
- package/lib/public/modules/personal-settings-dialog.js +51 -0
- package/lib/public/modules/profile.js +66 -24
- package/lib/public/modules/project-chat-identity.js +79 -0
- package/lib/public/modules/project-debate-planning-workspace.js +250 -0
- package/lib/public/modules/project-debate-workspace.js +185 -0
- package/lib/public/modules/project-header-identity.js +127 -0
- package/lib/public/modules/project-logs-render.js +9 -4
- package/lib/public/modules/project-mate-creation-workspace.js +414 -0
- package/lib/public/modules/project-mate-navigation.js +238 -0
- package/lib/public/modules/project-session-navigation.js +29 -0
- package/lib/public/modules/project-settings-environment.js +106 -0
- package/lib/public/modules/project-settings.js +72 -628
- package/lib/public/modules/project-switcher.js +6 -8
- package/lib/public/modules/projects-hub-view.js +5 -6
- package/lib/public/modules/projects-hub.js +2 -6
- package/lib/public/modules/qrcode.js +27 -8
- package/lib/public/modules/search-clay-chat.js +8 -11
- package/lib/public/modules/server-settings-controls.js +316 -0
- package/lib/public/modules/server-settings-environment.js +194 -0
- package/lib/public/modules/server-settings-navigation.js +39 -0
- package/lib/public/modules/server-settings-state.js +54 -0
- package/lib/public/modules/server-settings.js +116 -788
- package/lib/public/modules/server-storage-settings.js +84 -0
- package/lib/public/modules/session-activity-tooltip.js +12 -0
- package/lib/public/modules/session-create-form.js +86 -14
- package/lib/public/modules/session-create.js +50 -7
- package/lib/public/modules/session-github.js +2 -1
- package/lib/public/modules/session-list-visibility.js +41 -0
- package/lib/public/modules/session-presence-ui.js +3 -0
- package/lib/public/modules/session-title-marquee.js +51 -0
- package/lib/public/modules/settings-env-values.js +34 -0
- package/lib/public/modules/settings-select.js +121 -0
- package/lib/public/modules/shared-browser.js +22 -12
- package/lib/public/modules/sidebar-mates.js +3 -50
- package/lib/public/modules/sidebar-mobile.js +63 -60
- package/lib/public/modules/sidebar-projects.js +84 -97
- package/lib/public/modules/sidebar-session-folders-ui.js +2 -2
- package/lib/public/modules/sidebar-session-hierarchy.js +149 -70
- package/lib/public/modules/sidebar-sessions.js +49 -50
- package/lib/public/modules/sidebar.js +11 -13
- package/lib/public/modules/stt-picker.js +14 -5
- package/lib/public/modules/terminal-prefs.js +22 -0
- package/lib/public/modules/theme.js +88 -52
- package/lib/public/modules/thinking-lifecycle.js +7 -6
- package/lib/public/modules/tool-palette-order.js +4 -3
- package/lib/public/modules/tool-palette.js +13 -3
- package/lib/public/modules/tools.js +22 -22
- package/lib/public/modules/user-settings-account.js +318 -0
- package/lib/public/modules/user-settings-appearance.js +55 -0
- package/lib/public/modules/user-settings-editors.js +24 -0
- package/lib/public/modules/user-settings-integrations.js +104 -0
- package/lib/public/modules/user-settings-layout.js +80 -0
- package/lib/public/modules/user-settings-preferences.js +48 -0
- package/lib/public/modules/user-settings-profile.js +53 -0
- package/lib/public/modules/user-settings-themes.js +38 -0
- package/lib/public/modules/user-settings-voice.js +75 -0
- package/lib/public/modules/user-settings.js +53 -480
- package/lib/public/modules/worker-proposal.js +42 -8
- package/lib/public/modules/worker-runtime-picker.js +115 -0
- package/lib/public/modules/you-activity.js +121 -0
- package/lib/public/modules/you.js +251 -0
- package/lib/public/style.css +20 -8
- package/lib/public/vendor/codemirror/LICENSE +21 -0
- package/lib/public/vendor/codemirror/codemirror.css +344 -0
- package/lib/public/vendor/codemirror/codemirror.js +9884 -0
- package/lib/public/vendor/codemirror/continuelist.js +101 -0
- package/lib/public/vendor/codemirror/markdown.js +886 -0
- package/lib/public/vendor/codemirror/show-hint.css +37 -0
- package/lib/public/vendor/codemirror/show-hint.js +523 -0
- package/lib/sdk-bridge.js +15 -3
- package/lib/sdk-message-processor.js +16 -4
- package/lib/sdk-quota-snapshot.js +37 -0
- package/lib/server-admin.js +11 -0
- package/lib/server-appearance-settings.js +71 -0
- package/lib/server-home-chat-events.js +1 -1
- package/lib/server-home-chat.js +25 -3
- package/lib/server-home-debate-planning.js +4 -1
- package/lib/server-home-debates.js +2 -0
- package/lib/server-home-mate-creation.js +4 -1
- package/lib/server-linear.js +14 -8
- package/lib/server-mate-instructions.js +44 -0
- package/lib/server-mates.js +14 -4
- package/lib/server-notification-settings.js +67 -0
- package/lib/server-palette.js +1 -1
- package/lib/server-settings.js +69 -1
- package/lib/server-storage.js +57 -0
- package/lib/server-user-knowledge.js +119 -0
- package/lib/server.js +31 -3
- package/lib/session-visibility.js +7 -2
- package/lib/sessions.js +35 -10
- package/lib/startup-projects.js +40 -0
- package/lib/storage-native-walk.js +85 -0
- package/lib/storage-usage.js +144 -0
- package/lib/user-knowledge-service.js +167 -0
- package/lib/users-permissions.js +7 -0
- package/lib/users-preferences.js +0 -74
- package/lib/users-project-mate-filter-preferences.js +39 -0
- package/lib/users.js +6 -4
- package/lib/workspace-query-access.js +4 -1
- package/lib/ws-schema.js +52 -4
- package/lib/yoke/acp-process-manager.js +2 -2
- package/lib/yoke/adapters/codex.js +12 -52
- package/lib/yoke/codex-app-server.js +2 -2
- package/lib/yoke/codex-model-catalog.js +52 -0
- package/lib/yoke/codex-rate-limits.js +26 -0
- package/package.json +12 -3
|
@@ -1,954 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
'<div class="admin-modal-body" style="padding:20px 16px 16px">' +
|
|
10
|
-
'<p class="admin-modal-desc" style="margin:0;font-size:14px;color:var(--text)">' + escapeHtml(message) + '</p>' +
|
|
11
|
-
'</div>' +
|
|
12
|
-
'<div class="admin-modal-footer">' +
|
|
13
|
-
'<button class="admin-modal-save admin-modal-confirm-danger">' + escapeHtml(actionLabel) + '</button>' +
|
|
14
|
-
'<button class="admin-modal-cancel">Cancel</button>' +
|
|
15
|
-
'</div></div>';
|
|
16
|
-
modal.innerHTML = html;
|
|
17
|
-
document.body.appendChild(modal);
|
|
18
|
-
refreshIcons(modal);
|
|
19
|
-
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
20
|
-
modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
|
|
21
|
-
modal.querySelector(".admin-modal-confirm-danger").addEventListener("click", function () {
|
|
22
|
-
modal.remove();
|
|
23
|
-
onConfirm();
|
|
24
|
-
});
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function showConfirmResetPin(name, onConfirm) {
|
|
28
|
-
var modal = document.createElement("div");
|
|
29
|
-
modal.className = "admin-modal-overlay";
|
|
30
|
-
var html = '<div class="admin-modal">' +
|
|
31
|
-
'<div class="admin-modal-body" style="padding:20px 16px 16px">' +
|
|
32
|
-
'<p class="admin-modal-desc" style="margin:0;font-size:14px;color:var(--text)">Reset PIN for <strong>' + escapeHtml(name) + '</strong>? A new temporary PIN will be generated and they will need to change it on next login.</p>' +
|
|
33
|
-
'</div>' +
|
|
34
|
-
'<div class="admin-modal-footer">' +
|
|
35
|
-
'<button class="admin-modal-save">Reset PIN</button>' +
|
|
36
|
-
'<button class="admin-modal-cancel">Cancel</button>' +
|
|
37
|
-
'</div></div>';
|
|
38
|
-
modal.innerHTML = html;
|
|
39
|
-
document.body.appendChild(modal);
|
|
40
|
-
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
41
|
-
modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
|
|
42
|
-
modal.querySelector(".admin-modal-save").addEventListener("click", function () {
|
|
43
|
-
modal.remove();
|
|
44
|
-
onConfirm();
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
var ctx = null;
|
|
49
|
-
var cachedUsers = [];
|
|
50
|
-
var cachedInvites = [];
|
|
51
|
-
var cachedProjects = [];
|
|
52
|
-
var meInfo = null;
|
|
53
|
-
|
|
54
|
-
// --- API helpers ---
|
|
55
|
-
function apiGet(url) {
|
|
56
|
-
return fetch(url).then(function (r) { return r.json(); });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function apiPost(url, body) {
|
|
60
|
-
return fetch(url, {
|
|
61
|
-
method: "POST",
|
|
62
|
-
headers: { "Content-Type": "application/json" },
|
|
63
|
-
body: JSON.stringify(body || {}),
|
|
64
|
-
}).then(function (r) { return r.json(); });
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function apiPut(url, body) {
|
|
68
|
-
return fetch(url, {
|
|
69
|
-
method: "PUT",
|
|
70
|
-
headers: { "Content-Type": "application/json" },
|
|
71
|
-
body: JSON.stringify(body),
|
|
72
|
-
}).then(function (r) { return r.json(); });
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function apiDelete(url) {
|
|
76
|
-
return fetch(url, { method: "DELETE" }).then(function (r) { return r.json(); });
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// --- Init ---
|
|
80
|
-
export function initAdmin(appCtx) {
|
|
81
|
-
ctx = appCtx;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Check if user is admin and multi-user mode is active
|
|
1
|
+
import { store } from './store.js';
|
|
2
|
+
import { apiGet } from './admin-settings-ui.js';
|
|
3
|
+
import { loadUsersTab } from './admin-settings-users.js';
|
|
4
|
+
import { loadInvitesTab } from './admin-settings-invitations.js';
|
|
5
|
+
import { loadSmtpTab } from './admin-settings-email.js';
|
|
6
|
+
import { loadProjectsTab } from './admin-settings-projects.js';
|
|
7
|
+
|
|
8
|
+
export function initAdmin() {}
|
|
85
9
|
export function checkAdminAccess() {
|
|
86
|
-
return apiGet(
|
|
87
|
-
|
|
88
|
-
return data.multiUser && data.user && data.user.role ===
|
|
10
|
+
return apiGet('/api/me').then(function (data) {
|
|
11
|
+
store.set({ adminMe: data });
|
|
12
|
+
return !!(data.multiUser && data.user && data.user.role === 'admin');
|
|
89
13
|
}).catch(function () { return false; });
|
|
90
14
|
}
|
|
91
|
-
|
|
92
|
-
// --- Load admin section into a given body element ---
|
|
93
15
|
export function loadAdminSection(section, body) {
|
|
94
|
-
body.
|
|
95
|
-
if (section ===
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
loadInvitesTab(
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
loadSmtpTab(body);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// --- Users ---
|
|
107
|
-
function loadUsersTab(body) {
|
|
108
|
-
apiGet("/api/admin/users").then(function (data) {
|
|
109
|
-
cachedUsers = data.users || [];
|
|
110
|
-
renderUsersTab(body);
|
|
111
|
-
}).catch(function () {
|
|
112
|
-
body.innerHTML = '<div class="admin-error">Failed to load users</div>';
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function renderUsersTab(body) {
|
|
117
|
-
var html = '<div class="admin-section-header">' +
|
|
118
|
-
'<div class="admin-header-btns">' +
|
|
119
|
-
'<button class="admin-action-btn" id="admin-add-user">' + iconHtml("user-plus") + ' Add User</button>' +
|
|
120
|
-
'</div></div>';
|
|
121
|
-
html += '<div class="admin-user-list">';
|
|
122
|
-
for (var i = 0; i < cachedUsers.length; i++) {
|
|
123
|
-
var u = cachedUsers[i];
|
|
124
|
-
var isMe = meInfo && meInfo.user && meInfo.user.id === u.id;
|
|
125
|
-
var created = new Date(u.createdAt).toLocaleDateString();
|
|
126
|
-
html += '<div class="admin-user-item">';
|
|
127
|
-
html += '<div class="admin-user-info">';
|
|
128
|
-
html += '<div class="admin-user-name">';
|
|
129
|
-
html += '<strong>' + escapeHtml(u.displayName || u.username) + '</strong>';
|
|
130
|
-
if (u.role === "admin") html += ' <span class="admin-badge">admin</span>';
|
|
131
|
-
if (isMe) html += ' <span class="admin-you-badge">you</span>';
|
|
132
|
-
html += '</div>';
|
|
133
|
-
html += '<div class="admin-user-meta">' + escapeHtml(u.username) + ' · joined ' + created + '</div>';
|
|
134
|
-
html += '</div>';
|
|
135
|
-
if (!isMe) {
|
|
136
|
-
html += '<div style="display:flex;align-items:center;gap:2px">';
|
|
137
|
-
if (u.role !== "admin") {
|
|
138
|
-
html += '<button class="admin-remove-btn admin-perms-btn" data-user-id="' + u.id + '" title="Permissions">' + iconHtml("shield") + '</button>';
|
|
139
|
-
}
|
|
140
|
-
html += '<button class="admin-remove-btn admin-reset-pin-btn" data-user-id="' + u.id + '" title="Reset PIN">' + iconHtml("key-round") + '</button>';
|
|
141
|
-
if (u.role !== "admin") {
|
|
142
|
-
html += '<button class="admin-remove-btn" data-user-id="' + u.id + '" title="Remove user">' + iconHtml("trash-2") + '</button>';
|
|
143
|
-
}
|
|
144
|
-
html += '</div>';
|
|
145
|
-
}
|
|
146
|
-
html += '</div>';
|
|
147
|
-
}
|
|
148
|
-
html += '</div>';
|
|
149
|
-
|
|
150
|
-
body.innerHTML = html;
|
|
151
|
-
refreshIcons(body);
|
|
152
|
-
|
|
153
|
-
// Bind add user button
|
|
154
|
-
var addUserBtn = body.querySelector("#admin-add-user");
|
|
155
|
-
if (addUserBtn) {
|
|
156
|
-
addUserBtn.addEventListener("click", function () {
|
|
157
|
-
showAddUserModal(body);
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Bind reset PIN buttons
|
|
162
|
-
var resetBtns = body.querySelectorAll(".admin-reset-pin-btn");
|
|
163
|
-
for (var j = 0; j < resetBtns.length; j++) {
|
|
164
|
-
resetBtns[j].addEventListener("click", function (e) {
|
|
165
|
-
e.stopPropagation();
|
|
166
|
-
var userId = this.dataset.userId;
|
|
167
|
-
var user = cachedUsers.find(function (u) { return u.id === userId; });
|
|
168
|
-
var name = user ? (user.displayName || user.username) : "this user";
|
|
169
|
-
showConfirmResetPin(name, function () {
|
|
170
|
-
apiPost("/api/admin/users/" + userId + "/reset-pin").then(function (data) {
|
|
171
|
-
if (data.ok) {
|
|
172
|
-
showTempPinModal({ username: user.username, displayName: user.displayName || user.username }, data.tempPin);
|
|
173
|
-
} else {
|
|
174
|
-
showToast(data.error || "Failed to reset PIN");
|
|
175
|
-
}
|
|
176
|
-
}).catch(function () {
|
|
177
|
-
showToast("Failed to reset PIN");
|
|
178
|
-
});
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Bind permissions buttons
|
|
184
|
-
var permsBtns = body.querySelectorAll(".admin-perms-btn");
|
|
185
|
-
for (var p = 0; p < permsBtns.length; p++) {
|
|
186
|
-
permsBtns[p].addEventListener("click", function (e) {
|
|
187
|
-
e.stopPropagation();
|
|
188
|
-
var userId = this.dataset.userId;
|
|
189
|
-
var user = cachedUsers.find(function (u) { return u.id === userId; });
|
|
190
|
-
if (user) showPermissionsModal(user, body);
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Bind remove buttons
|
|
195
|
-
var removeBtns = body.querySelectorAll(".admin-remove-btn:not(.admin-reset-pin-btn):not(.admin-perms-btn)");
|
|
196
|
-
for (var k = 0; k < removeBtns.length; k++) {
|
|
197
|
-
removeBtns[k].addEventListener("click", function () {
|
|
198
|
-
var userId = this.dataset.userId;
|
|
199
|
-
var user = cachedUsers.find(function (u) { return u.id === userId; });
|
|
200
|
-
var name = user ? (user.displayName || user.username) : "this user";
|
|
201
|
-
showConfirmDialog("Remove " + name + "? This cannot be undone.", "Remove user", function () {
|
|
202
|
-
removeUser(userId, body);
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
var PERM_LABELS = [
|
|
209
|
-
{ key: "terminal", label: "Terminal", desc: "Access the web terminal" },
|
|
210
|
-
{ key: "fileBrowser", label: "File Browser", desc: "Browse and manage files" },
|
|
211
|
-
{ key: "createProject", label: "Create Projects", desc: "Create or clone new projects" },
|
|
212
|
-
{ key: "deleteProject", label: "Delete Projects", desc: "Remove projects" },
|
|
213
|
-
{ key: "skills", label: "Skills", desc: "View and install skills" },
|
|
214
|
-
{ key: "sessionDelete", label: "Delete Sessions", desc: "Delete chat sessions" },
|
|
215
|
-
{ key: "scheduledTasks", label: "Scheduled Tasks", desc: "Create and manage scheduled tasks" },
|
|
216
|
-
{ key: "projectSettings", label: "Project Settings", desc: "Access project settings" },
|
|
217
|
-
];
|
|
218
|
-
|
|
219
|
-
var DEFAULT_PERMISSIONS = {
|
|
220
|
-
terminal: false,
|
|
221
|
-
fileBrowser: false,
|
|
222
|
-
createProject: true,
|
|
223
|
-
deleteProject: false,
|
|
224
|
-
skills: true,
|
|
225
|
-
sessionDelete: false,
|
|
226
|
-
scheduledTasks: false,
|
|
227
|
-
projectSettings: false,
|
|
228
|
-
};
|
|
229
|
-
|
|
230
|
-
function showPermissionsModal(user, body) {
|
|
231
|
-
var perms = {};
|
|
232
|
-
var keys = PERM_LABELS.map(function (p) { return p.key; });
|
|
233
|
-
for (var i = 0; i < keys.length; i++) {
|
|
234
|
-
var k = keys[i];
|
|
235
|
-
perms[k] = (user.permissions && user.permissions[k] !== undefined) ? user.permissions[k] : DEFAULT_PERMISSIONS[k];
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
var modal = document.createElement("div");
|
|
239
|
-
modal.className = "admin-modal-overlay";
|
|
240
|
-
var html = '<div class="admin-modal">' +
|
|
241
|
-
'<div class="admin-modal-header">' +
|
|
242
|
-
'<h3>Permissions: ' + escapeHtml(user.displayName || user.username) + '</h3>' +
|
|
243
|
-
'<button class="admin-modal-close">×</button>' +
|
|
244
|
-
'</div>' +
|
|
245
|
-
'<div class="admin-modal-body">' +
|
|
246
|
-
'<div class="admin-perms-list">';
|
|
247
|
-
|
|
248
|
-
for (var j = 0; j < PERM_LABELS.length; j++) {
|
|
249
|
-
var p = PERM_LABELS[j];
|
|
250
|
-
var checked = perms[p.key] ? " checked" : "";
|
|
251
|
-
html += '<label class="admin-perm-row">' +
|
|
252
|
-
'<div class="admin-perm-info">' +
|
|
253
|
-
'<span class="admin-perm-label">' + p.label + '</span>' +
|
|
254
|
-
'<span class="admin-perm-desc">' + p.desc + '</span>' +
|
|
255
|
-
'</div>' +
|
|
256
|
-
'<input type="checkbox" class="admin-perm-toggle" data-perm="' + p.key + '"' + checked + '>' +
|
|
257
|
-
'</label>';
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
html += '</div></div>' +
|
|
261
|
-
'<div class="admin-modal-footer">' +
|
|
262
|
-
'<button class="admin-modal-save" id="admin-perms-save">Save</button>' +
|
|
263
|
-
'<button class="admin-modal-cancel">Cancel</button>' +
|
|
264
|
-
'</div></div>';
|
|
265
|
-
|
|
266
|
-
modal.innerHTML = html;
|
|
267
|
-
document.body.appendChild(modal);
|
|
268
|
-
refreshIcons(modal);
|
|
269
|
-
|
|
270
|
-
modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
|
|
271
|
-
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
272
|
-
modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
|
|
273
|
-
|
|
274
|
-
modal.querySelector("#admin-perms-save").addEventListener("click", function () {
|
|
275
|
-
var toggles = modal.querySelectorAll(".admin-perm-toggle");
|
|
276
|
-
var newPerms = {};
|
|
277
|
-
for (var t = 0; t < toggles.length; t++) {
|
|
278
|
-
newPerms[toggles[t].dataset.perm] = toggles[t].checked;
|
|
279
|
-
}
|
|
280
|
-
apiPut("/api/admin/users/" + user.id + "/permissions", { permissions: newPerms }).then(function (data) {
|
|
281
|
-
if (data.ok) {
|
|
282
|
-
showToast("Permissions updated");
|
|
283
|
-
modal.remove();
|
|
284
|
-
// Update cached user
|
|
285
|
-
for (var c = 0; c < cachedUsers.length; c++) {
|
|
286
|
-
if (cachedUsers[c].id === user.id) {
|
|
287
|
-
cachedUsers[c].permissions = data.permissions;
|
|
288
|
-
break;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
} else {
|
|
292
|
-
showToast(data.error || "Failed to update permissions");
|
|
293
|
-
}
|
|
294
|
-
}).catch(function () {
|
|
295
|
-
showToast("Failed to update permissions");
|
|
296
|
-
});
|
|
297
|
-
});
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
function showAddUserModal(body) {
|
|
301
|
-
var modal = document.createElement("div");
|
|
302
|
-
modal.className = "admin-modal-overlay";
|
|
303
|
-
var html = '<div class="admin-modal">' +
|
|
304
|
-
'<div class="admin-modal-header">' +
|
|
305
|
-
'<h3>Add User</h3>' +
|
|
306
|
-
'<button class="admin-modal-close">' + iconHtml("x") + '</button>' +
|
|
307
|
-
'</div>' +
|
|
308
|
-
'<div class="admin-modal-body">' +
|
|
309
|
-
'<p class="admin-modal-desc">Create a new user account. A temporary 6-digit PIN will be generated automatically. The user must change it on first login.</p>' +
|
|
310
|
-
'<div class="admin-smtp-row"><label>Username</label>' +
|
|
311
|
-
'<input type="text" class="admin-smtp-input" id="admin-new-username" placeholder="username" autocomplete="off" maxlength="100"></div>' +
|
|
312
|
-
'<div class="admin-smtp-row"><label>Display Name</label>' +
|
|
313
|
-
'<input type="text" class="admin-smtp-input" id="admin-new-displayname" placeholder="Display Name (optional)" autocomplete="off" maxlength="30"></div>' +
|
|
314
|
-
'<div class="admin-smtp-row"><label>Email</label>' +
|
|
315
|
-
'<input type="email" class="admin-smtp-input" id="admin-new-email" placeholder="user@example.com (optional)" autocomplete="off"></div>' +
|
|
316
|
-
'<div class="admin-smtp-row"><label>Role</label>' +
|
|
317
|
-
'<select class="admin-smtp-input" id="admin-new-role"><option value="user">User</option><option value="admin">Admin</option></select></div>' +
|
|
318
|
-
'<div class="admin-smtp-error" id="admin-new-user-error"></div>' +
|
|
319
|
-
'</div>' +
|
|
320
|
-
'<div class="admin-modal-footer">' +
|
|
321
|
-
'<button class="admin-modal-save" id="admin-new-user-create">Create User</button>' +
|
|
322
|
-
'<button class="admin-modal-cancel">Cancel</button>' +
|
|
323
|
-
'</div></div>';
|
|
324
|
-
modal.innerHTML = html;
|
|
325
|
-
document.body.appendChild(modal);
|
|
326
|
-
refreshIcons(modal);
|
|
327
|
-
|
|
328
|
-
var usernameInput = modal.querySelector("#admin-new-username");
|
|
329
|
-
var displayNameInput = modal.querySelector("#admin-new-displayname");
|
|
330
|
-
var emailInput = modal.querySelector("#admin-new-email");
|
|
331
|
-
var roleSelect = modal.querySelector("#admin-new-role");
|
|
332
|
-
var createBtn = modal.querySelector("#admin-new-user-create");
|
|
333
|
-
var errorEl = modal.querySelector("#admin-new-user-error");
|
|
334
|
-
|
|
335
|
-
modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
|
|
336
|
-
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
337
|
-
modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
|
|
338
|
-
|
|
339
|
-
usernameInput.focus();
|
|
340
|
-
usernameInput.addEventListener("keydown", function (e) {
|
|
341
|
-
if (e.key === "Enter") createBtn.click();
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
createBtn.addEventListener("click", function () {
|
|
345
|
-
var username = usernameInput.value.trim();
|
|
346
|
-
if (!username) {
|
|
347
|
-
errorEl.textContent = "Username is required";
|
|
348
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
createBtn.disabled = true;
|
|
352
|
-
createBtn.textContent = "Creating...";
|
|
353
|
-
errorEl.textContent = "";
|
|
354
|
-
errorEl.className = "admin-smtp-error";
|
|
355
|
-
apiPost("/api/admin/users", {
|
|
356
|
-
username: username,
|
|
357
|
-
displayName: displayNameInput.value.trim() || username,
|
|
358
|
-
email: emailInput.value.trim() || null,
|
|
359
|
-
role: roleSelect.value,
|
|
360
|
-
}).then(function (data) {
|
|
361
|
-
if (data.ok) {
|
|
362
|
-
modal.remove();
|
|
363
|
-
showTempPinModal(data.user, data.tempPin);
|
|
364
|
-
loadUsersTab(body);
|
|
365
|
-
} else {
|
|
366
|
-
errorEl.textContent = data.error || "Failed to create user";
|
|
367
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
368
|
-
createBtn.disabled = false;
|
|
369
|
-
createBtn.textContent = "Create User";
|
|
370
|
-
}
|
|
371
|
-
}).catch(function () {
|
|
372
|
-
errorEl.textContent = "Failed to create user";
|
|
373
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
374
|
-
createBtn.disabled = false;
|
|
375
|
-
createBtn.textContent = "Create User";
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function showTempPinModal(user, tempPin) {
|
|
381
|
-
var modal = document.createElement("div");
|
|
382
|
-
modal.className = "admin-modal-overlay";
|
|
383
|
-
var html = '<div class="admin-modal">' +
|
|
384
|
-
'<div class="admin-modal-header">' +
|
|
385
|
-
'<h3>User Created</h3>' +
|
|
386
|
-
'<button class="admin-modal-close">' + iconHtml("x") + '</button>' +
|
|
387
|
-
'</div>' +
|
|
388
|
-
'<div class="admin-modal-body">' +
|
|
389
|
-
'<p class="admin-modal-desc">Account for <strong>' + escapeHtml(user.displayName || user.username) + '</strong> has been created. Share these credentials with the user:</p>' +
|
|
390
|
-
'<div class="admin-temp-pin-box">' +
|
|
391
|
-
'<div class="admin-temp-pin-row"><span class="admin-temp-pin-label">Username</span><code class="admin-temp-pin-value">' + escapeHtml(user.username) + '</code></div>' +
|
|
392
|
-
'<div class="admin-temp-pin-row"><span class="admin-temp-pin-label">Temporary PIN</span><code class="admin-temp-pin-value admin-temp-pin-highlight">' + escapeHtml(tempPin) + '</code></div>' +
|
|
393
|
-
'</div>' +
|
|
394
|
-
'<p class="admin-modal-desc" style="margin-top:12px;color:var(--text-secondary);font-size:12px;">This PIN is one-time use. The user will be prompted to set a new PIN on first login.</p>' +
|
|
395
|
-
'</div>' +
|
|
396
|
-
'<div class="admin-modal-footer">' +
|
|
397
|
-
'<button class="admin-modal-save" id="admin-copy-credentials">Copy Credentials</button>' +
|
|
398
|
-
'<button class="admin-modal-cancel">Close</button>' +
|
|
399
|
-
'</div></div>';
|
|
400
|
-
modal.innerHTML = html;
|
|
401
|
-
document.body.appendChild(modal);
|
|
402
|
-
refreshIcons(modal);
|
|
403
|
-
|
|
404
|
-
modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
|
|
405
|
-
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
406
|
-
modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
|
|
407
|
-
|
|
408
|
-
modal.querySelector("#admin-copy-credentials").addEventListener("click", function () {
|
|
409
|
-
var text = "Username: " + user.username + "\nTemporary PIN: " + tempPin;
|
|
410
|
-
copyToClipboard(text).then(function () {
|
|
411
|
-
showToast("Credentials copied to clipboard");
|
|
412
|
-
}).catch(function () {
|
|
413
|
-
showToast("Username: " + user.username + " / PIN: " + tempPin);
|
|
414
|
-
});
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function removeUser(userId, body) {
|
|
419
|
-
apiDelete("/api/admin/users/" + userId).then(function (data) {
|
|
420
|
-
if (data.ok) {
|
|
421
|
-
showToast("User removed");
|
|
422
|
-
loadUsersTab(body);
|
|
423
|
-
} else {
|
|
424
|
-
showToast(data.error || "Failed to remove user");
|
|
425
|
-
}
|
|
426
|
-
}).catch(function () {
|
|
427
|
-
showToast("Failed to remove user");
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// --- Invites ---
|
|
432
|
-
function loadInvitesTab(body) {
|
|
433
|
-
apiGet("/api/admin/invites").then(function (data) {
|
|
434
|
-
cachedInvites = (data.invites || []).filter(function (inv) {
|
|
435
|
-
return !inv.used && inv.expiresAt > Date.now();
|
|
436
|
-
});
|
|
437
|
-
renderInvitesTab(body);
|
|
438
|
-
}).catch(function () {
|
|
439
|
-
body.innerHTML = '<div class="admin-error">Failed to load invites</div>';
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
function renderInvitesTab(body) {
|
|
444
|
-
var smtpEnabled = meInfo && meInfo.smtpEnabled;
|
|
445
|
-
var html = '<div class="admin-section-header">' +
|
|
446
|
-
'<div class="admin-header-btns">';
|
|
447
|
-
if (smtpEnabled) {
|
|
448
|
-
html += '<button class="admin-action-btn" id="admin-email-invite">' + iconHtml("mail") + ' Email Invite</button>';
|
|
449
|
-
}
|
|
450
|
-
html += '<button class="admin-action-btn admin-action-btn-secondary" id="admin-create-invite">' + iconHtml("plus") + ' Generate Link</button>' +
|
|
451
|
-
'</div></div>';
|
|
452
|
-
|
|
453
|
-
if (cachedInvites.length === 0) {
|
|
454
|
-
html += '<div class="admin-empty">No active invites. Generate one to add a new user.</div>';
|
|
455
|
-
} else {
|
|
456
|
-
html += '<div class="admin-invite-list">';
|
|
457
|
-
for (var i = 0; i < cachedInvites.length; i++) {
|
|
458
|
-
var inv = cachedInvites[i];
|
|
459
|
-
var expiresIn = Math.max(0, Math.ceil((inv.expiresAt - Date.now()) / (60 * 60 * 1000)));
|
|
460
|
-
html += '<div class="admin-invite-item">';
|
|
461
|
-
html += '<div class="admin-invite-info">';
|
|
462
|
-
html += '<code class="admin-invite-code">' + escapeHtml(inv.code.substring(0, 8)) + '...</code>';
|
|
463
|
-
if (inv.email) html += '<span class="admin-invite-email">' + escapeHtml(inv.email) + '</span>';
|
|
464
|
-
html += '<span class="admin-invite-expiry">expires in ' + expiresIn + 'h</span>';
|
|
465
|
-
html += '</div>';
|
|
466
|
-
html += '<div class="admin-invite-actions">';
|
|
467
|
-
html += '<button class="admin-copy-link-btn" data-code="' + escapeHtml(inv.code) + '" title="Copy link">' + iconHtml("copy") + '</button>';
|
|
468
|
-
html += '<button class="admin-revoke-btn" data-code="' + escapeHtml(inv.code) + '" title="Revoke">' + iconHtml("x") + '</button>';
|
|
469
|
-
html += '</div>';
|
|
470
|
-
html += '</div>';
|
|
471
|
-
}
|
|
472
|
-
html += '</div>';
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
body.innerHTML = html;
|
|
476
|
-
refreshIcons(body);
|
|
477
|
-
|
|
478
|
-
// Generate invite link
|
|
479
|
-
var createBtn = body.querySelector("#admin-create-invite");
|
|
480
|
-
if (createBtn) {
|
|
481
|
-
createBtn.addEventListener("click", function () {
|
|
482
|
-
createInvite(body);
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// Email invite
|
|
487
|
-
var emailBtn = body.querySelector("#admin-email-invite");
|
|
488
|
-
if (emailBtn) {
|
|
489
|
-
emailBtn.addEventListener("click", function () {
|
|
490
|
-
showEmailInvitePrompt(body);
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// Copy link buttons
|
|
495
|
-
var copyBtns = body.querySelectorAll(".admin-copy-link-btn");
|
|
496
|
-
for (var j = 0; j < copyBtns.length; j++) {
|
|
497
|
-
copyBtns[j].addEventListener("click", function () {
|
|
498
|
-
var code = this.dataset.code;
|
|
499
|
-
var url = location.origin + "/invite/" + code;
|
|
500
|
-
copyToClipboard(url).then(function () {
|
|
501
|
-
showToast("Invite link copied");
|
|
502
|
-
});
|
|
503
|
-
});
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// Revoke buttons
|
|
507
|
-
var revokeBtns = body.querySelectorAll(".admin-revoke-btn");
|
|
508
|
-
for (var k = 0; k < revokeBtns.length; k++) {
|
|
509
|
-
revokeBtns[k].addEventListener("click", function () {
|
|
510
|
-
var code = this.dataset.code;
|
|
511
|
-
var btn = this;
|
|
512
|
-
showConfirmDialog("Revoke this invite? The link will no longer work.", "Revoke", function () {
|
|
513
|
-
btn.disabled = true;
|
|
514
|
-
fetch("/api/admin/invites/" + encodeURIComponent(code), { method: "DELETE" })
|
|
515
|
-
.then(function (r) { return r.json(); })
|
|
516
|
-
.then(function (d) {
|
|
517
|
-
if (d.ok) {
|
|
518
|
-
showToast("Invite revoked");
|
|
519
|
-
loadInvitesTab(body);
|
|
520
|
-
} else {
|
|
521
|
-
showToast(d.error || "Failed to revoke");
|
|
522
|
-
btn.disabled = false;
|
|
523
|
-
}
|
|
524
|
-
})
|
|
525
|
-
.catch(function () {
|
|
526
|
-
showToast("Failed to revoke invite");
|
|
527
|
-
btn.disabled = false;
|
|
528
|
-
});
|
|
529
|
-
});
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function createInvite(body) {
|
|
535
|
-
apiPost("/api/admin/invites").then(function (data) {
|
|
536
|
-
if (data.ok && data.url) {
|
|
537
|
-
copyToClipboard(data.url).then(function () {
|
|
538
|
-
showToast("Invite link created and copied!");
|
|
539
|
-
}).catch(function () {
|
|
540
|
-
showToast("Invite created: " + data.url);
|
|
541
|
-
});
|
|
542
|
-
loadInvitesTab(body);
|
|
543
|
-
} else {
|
|
544
|
-
showToast(data.error || "Failed to create invite");
|
|
545
|
-
}
|
|
546
|
-
}).catch(function () {
|
|
547
|
-
showToast("Failed to create invite");
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
function showEmailInvitePrompt(body) {
|
|
552
|
-
var modal = document.createElement("div");
|
|
553
|
-
modal.className = "admin-modal-overlay";
|
|
554
|
-
var html = '<div class="admin-modal">' +
|
|
555
|
-
'<div class="admin-modal-header">' +
|
|
556
|
-
'<h3>Send Email Invite</h3>' +
|
|
557
|
-
'<button class="admin-modal-close">' + iconHtml("x") + '</button>' +
|
|
558
|
-
'</div>' +
|
|
559
|
-
'<div class="admin-modal-body">' +
|
|
560
|
-
'<p class="admin-modal-desc">Enter the email address to send an invitation to:</p>' +
|
|
561
|
-
'<input type="email" class="admin-smtp-input" id="admin-invite-email" placeholder="user@example.com" autocomplete="off">' +
|
|
562
|
-
'<div class="admin-smtp-error" id="admin-invite-error"></div>' +
|
|
563
|
-
'</div>' +
|
|
564
|
-
'<div class="admin-modal-footer">' +
|
|
565
|
-
'<button class="admin-modal-save" id="admin-invite-send">Send Invite</button>' +
|
|
566
|
-
'<button class="admin-modal-cancel">Cancel</button>' +
|
|
567
|
-
'</div></div>';
|
|
568
|
-
modal.innerHTML = html;
|
|
569
|
-
document.body.appendChild(modal);
|
|
570
|
-
refreshIcons(modal);
|
|
571
|
-
|
|
572
|
-
var emailInput = modal.querySelector("#admin-invite-email");
|
|
573
|
-
var sendBtn = modal.querySelector("#admin-invite-send");
|
|
574
|
-
var errorEl = modal.querySelector("#admin-invite-error");
|
|
575
|
-
|
|
576
|
-
modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
|
|
577
|
-
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
578
|
-
modal.addEventListener("click", function (e) { if (e.target === modal) modal.remove(); });
|
|
579
|
-
|
|
580
|
-
emailInput.focus();
|
|
581
|
-
emailInput.addEventListener("keydown", function (e) {
|
|
582
|
-
if (e.key === "Enter") sendBtn.click();
|
|
583
|
-
});
|
|
584
|
-
|
|
585
|
-
sendBtn.addEventListener("click", function () {
|
|
586
|
-
var email = emailInput.value.trim();
|
|
587
|
-
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
588
|
-
errorEl.textContent = "Enter a valid email address";
|
|
589
|
-
return;
|
|
590
|
-
}
|
|
591
|
-
sendBtn.disabled = true;
|
|
592
|
-
sendBtn.textContent = "Sending...";
|
|
593
|
-
errorEl.textContent = "";
|
|
594
|
-
apiPost("/api/admin/invites/email", { email: email }).then(function (data) {
|
|
595
|
-
if (data.ok) {
|
|
596
|
-
showToast("Invite sent to " + email);
|
|
597
|
-
modal.remove();
|
|
598
|
-
loadInvitesTab(body);
|
|
599
|
-
} else {
|
|
600
|
-
errorEl.textContent = data.error || "Failed to send invite";
|
|
601
|
-
sendBtn.disabled = false;
|
|
602
|
-
sendBtn.textContent = "Send Invite";
|
|
603
|
-
}
|
|
604
|
-
}).catch(function () {
|
|
605
|
-
errorEl.textContent = "Failed to send invite";
|
|
606
|
-
sendBtn.disabled = false;
|
|
607
|
-
sendBtn.textContent = "Send Invite";
|
|
608
|
-
});
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
// --- SMTP Configuration ---
|
|
613
|
-
function loadSmtpTab(body) {
|
|
614
|
-
apiGet("/api/admin/smtp").then(function (data) {
|
|
615
|
-
renderSmtpTab(body, data.smtp);
|
|
616
|
-
}).catch(function () {
|
|
617
|
-
body.innerHTML = '<div class="admin-error">Failed to load SMTP settings</div>';
|
|
618
|
-
});
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function renderSmtpTab(body, cfg) {
|
|
622
|
-
var hasConfig = !!(cfg && cfg.host);
|
|
623
|
-
var html = '<div class="admin-smtp-form">';
|
|
624
|
-
|
|
625
|
-
var emailEnabled = !!(cfg && cfg.emailLoginEnabled);
|
|
626
|
-
if (hasConfig) {
|
|
627
|
-
html += '<div class="admin-smtp-status admin-smtp-status-ok">' + iconHtml("check-circle") + ' SMTP configured. Invite links and one-time login codes are sent via email.</div>';
|
|
628
|
-
} else {
|
|
629
|
-
html += '<div class="admin-smtp-status admin-smtp-status-off">' + iconHtml("mail-x") + ' SMTP not configured. Users log in with a PIN instead of email codes.</div>';
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
html += '<div class="admin-smtp-fields">';
|
|
633
|
-
html += '<div class="admin-smtp-row">' +
|
|
634
|
-
'<label>SMTP Host</label>' +
|
|
635
|
-
'<input type="text" id="smtp-host" class="admin-smtp-input" placeholder="smtp.gmail.com" value="' + escapeHtml((cfg && cfg.host) || "") + '">' +
|
|
636
|
-
'</div>';
|
|
637
|
-
html += '<div class="admin-smtp-row-half">' +
|
|
638
|
-
'<div class="admin-smtp-row">' +
|
|
639
|
-
'<label>Port</label>' +
|
|
640
|
-
'<input type="number" id="smtp-port" class="admin-smtp-input" placeholder="587" value="' + ((cfg && cfg.port) || 587) + '">' +
|
|
641
|
-
'</div>' +
|
|
642
|
-
'<div class="admin-smtp-row">' +
|
|
643
|
-
'<label>Secure (TLS)</label>' +
|
|
644
|
-
'<label class="admin-smtp-toggle"><input type="checkbox" id="smtp-secure"' + (cfg && cfg.secure ? " checked" : "") + '><span>Use TLS/SSL</span></label>' +
|
|
645
|
-
'</div></div>';
|
|
646
|
-
html += '<div class="admin-smtp-row">' +
|
|
647
|
-
'<label>Username</label>' +
|
|
648
|
-
'<input type="text" id="smtp-user" class="admin-smtp-input" placeholder="you@gmail.com" value="' + escapeHtml((cfg && cfg.user) || "") + '" autocomplete="off">' +
|
|
649
|
-
'</div>';
|
|
650
|
-
html += '<div class="admin-smtp-row">' +
|
|
651
|
-
'<label>Password</label>' +
|
|
652
|
-
'<input type="password" id="smtp-pass" class="admin-smtp-input" placeholder="App password" value="' + escapeHtml((cfg && cfg.pass) || "") + '" autocomplete="off">' +
|
|
653
|
-
'</div>';
|
|
654
|
-
html += '<div class="admin-smtp-row">' +
|
|
655
|
-
'<label>From Address</label>' +
|
|
656
|
-
'<input type="text" id="smtp-from" class="admin-smtp-input" placeholder="Clay <noreply@example.com>" value="' + escapeHtml((cfg && cfg.from) || "") + '">' +
|
|
657
|
-
'</div>';
|
|
658
|
-
html += '<div class="admin-smtp-row admin-smtp-row-otp">' +
|
|
659
|
-
'<label>Email Login (OTP)</label>' +
|
|
660
|
-
'<label class="admin-smtp-toggle"><input type="checkbox" id="smtp-email-login"' + (emailEnabled ? " checked" : "") + (hasConfig ? "" : " disabled") + '>' +
|
|
661
|
-
'<span>' + (hasConfig ? "Require email for user registration and enable OTP login" : "Configure SMTP first to enable") + '</span></label>' +
|
|
662
|
-
'</div>';
|
|
663
|
-
html += '</div>';
|
|
664
|
-
|
|
665
|
-
html += '<div class="admin-smtp-actions">';
|
|
666
|
-
html += '<button class="admin-action-btn" id="smtp-save">' + iconHtml("save") + ' Save</button>';
|
|
667
|
-
html += '<button class="admin-action-btn admin-action-btn-secondary" id="smtp-test">' + iconHtml("send") + ' Test Connection</button>';
|
|
668
|
-
if (hasConfig) {
|
|
669
|
-
html += '<button class="admin-action-btn admin-action-btn-danger" id="smtp-remove">' + iconHtml("trash-2") + ' Remove</button>';
|
|
670
|
-
}
|
|
671
|
-
html += '</div>';
|
|
672
|
-
html += '<div class="admin-smtp-error" id="smtp-error"></div>';
|
|
673
|
-
html += '<div class="admin-smtp-hint">For Gmail, use an <a href="https://support.google.com/accounts/answer/185833" target="_blank" rel="noopener">App Password</a>. Port 587 with TLS off uses STARTTLS. Port 465 with TLS on uses direct SSL.</div>';
|
|
674
|
-
html += '</div>';
|
|
675
|
-
|
|
676
|
-
body.innerHTML = html;
|
|
677
|
-
refreshIcons(body);
|
|
678
|
-
|
|
679
|
-
var errorEl = body.querySelector("#smtp-error");
|
|
680
|
-
|
|
681
|
-
function getFormData() {
|
|
682
|
-
return {
|
|
683
|
-
host: body.querySelector("#smtp-host").value.trim(),
|
|
684
|
-
port: parseInt(body.querySelector("#smtp-port").value, 10) || 587,
|
|
685
|
-
secure: body.querySelector("#smtp-secure").checked,
|
|
686
|
-
user: body.querySelector("#smtp-user").value.trim(),
|
|
687
|
-
pass: body.querySelector("#smtp-pass").value,
|
|
688
|
-
from: body.querySelector("#smtp-from").value.trim(),
|
|
689
|
-
emailLoginEnabled: body.querySelector("#smtp-email-login").checked,
|
|
690
|
-
};
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// Save
|
|
694
|
-
body.querySelector("#smtp-save").addEventListener("click", function () {
|
|
695
|
-
var formData = getFormData();
|
|
696
|
-
if (!formData.host || !formData.user || !formData.pass || !formData.from) {
|
|
697
|
-
errorEl.textContent = "All fields are required";
|
|
698
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
var btn = this;
|
|
702
|
-
btn.disabled = true;
|
|
703
|
-
errorEl.textContent = "";
|
|
704
|
-
errorEl.className = "admin-smtp-error";
|
|
705
|
-
apiPost("/api/admin/smtp", formData).then(function (data) {
|
|
706
|
-
if (data.ok) {
|
|
707
|
-
showToast("SMTP settings saved");
|
|
708
|
-
loadSmtpTab(body);
|
|
709
|
-
} else {
|
|
710
|
-
errorEl.textContent = data.error || "Failed to save";
|
|
711
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
712
|
-
btn.disabled = false;
|
|
713
|
-
}
|
|
714
|
-
}).catch(function () {
|
|
715
|
-
errorEl.textContent = "Failed to save settings";
|
|
716
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
717
|
-
btn.disabled = false;
|
|
718
|
-
});
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
// Test
|
|
722
|
-
body.querySelector("#smtp-test").addEventListener("click", function () {
|
|
723
|
-
var formData = getFormData();
|
|
724
|
-
if (!formData.host || !formData.user || !formData.pass || !formData.from) {
|
|
725
|
-
errorEl.textContent = "Fill in all fields first";
|
|
726
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
727
|
-
return;
|
|
728
|
-
}
|
|
729
|
-
var btn = this;
|
|
730
|
-
btn.disabled = true;
|
|
731
|
-
errorEl.textContent = "";
|
|
732
|
-
errorEl.className = "admin-smtp-error";
|
|
733
|
-
apiPost("/api/admin/smtp/test", formData).then(function (data) {
|
|
734
|
-
if (data.ok) {
|
|
735
|
-
showToast(data.message || "Test email sent!");
|
|
736
|
-
errorEl.textContent = "";
|
|
737
|
-
errorEl.className = "admin-smtp-error";
|
|
738
|
-
} else {
|
|
739
|
-
errorEl.textContent = data.error || "Test failed";
|
|
740
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
741
|
-
}
|
|
742
|
-
btn.disabled = false;
|
|
743
|
-
}).catch(function () {
|
|
744
|
-
errorEl.textContent = "Connection failed";
|
|
745
|
-
errorEl.className = "admin-smtp-error admin-smtp-error-visible";
|
|
746
|
-
btn.disabled = false;
|
|
747
|
-
});
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
// Remove
|
|
751
|
-
var removeBtn = body.querySelector("#smtp-remove");
|
|
752
|
-
if (removeBtn) {
|
|
753
|
-
removeBtn.addEventListener("click", function () {
|
|
754
|
-
showConfirmDialog("Remove SMTP configuration? Users will need to use PIN login.", "Remove SMTP", function () {
|
|
755
|
-
apiPost("/api/admin/smtp", { host: "", user: "", pass: "", from: "" }).then(function () {
|
|
756
|
-
showToast("SMTP configuration removed");
|
|
757
|
-
loadSmtpTab(body);
|
|
758
|
-
});
|
|
759
|
-
});
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
// --- Projects ---
|
|
765
|
-
function loadProjectsTab(body) {
|
|
766
|
-
var projectList = (ctx && ctx.projectList) || [];
|
|
767
|
-
// Exclude worktree and mate projects (mates are always private to their owner)
|
|
768
|
-
projectList = projectList.filter(function (p) { return !p.isWorktree && !p.isMate; });
|
|
769
|
-
cachedProjects = projectList;
|
|
770
|
-
|
|
771
|
-
if (projectList.length === 0) {
|
|
772
|
-
body.innerHTML = '<div class="admin-empty">No projects registered.</div>';
|
|
773
|
-
return;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
var accessPromises = projectList.map(function (p) {
|
|
777
|
-
return apiGet("/api/admin/projects/" + p.slug + "/access").then(function (access) {
|
|
778
|
-
return { slug: p.slug, title: p.title || p.project || p.slug, projectOwnerId: p.projectOwnerId || null, visibility: access.visibility || "public", allowedUsers: access.allowedUsers || [] };
|
|
779
|
-
}).catch(function () {
|
|
780
|
-
return { slug: p.slug, title: p.title || p.project || p.slug, projectOwnerId: p.projectOwnerId || null, visibility: "public", allowedUsers: [] };
|
|
781
|
-
});
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
Promise.all([
|
|
785
|
-
Promise.all(accessPromises),
|
|
786
|
-
apiGet("/api/admin/users").catch(function () { return { users: [] }; }),
|
|
787
|
-
]).then(function (results) {
|
|
788
|
-
renderProjectsTab(body, results[0], results[1].users || []);
|
|
789
|
-
});
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
function renderProjectsTab(body, projectAccessList, allUsers) {
|
|
793
|
-
var html = '<div class="admin-project-list">';
|
|
794
|
-
for (var i = 0; i < projectAccessList.length; i++) {
|
|
795
|
-
var p = projectAccessList[i];
|
|
796
|
-
var visClass = p.visibility === "private" ? "admin-vis-private" : "admin-vis-public";
|
|
797
|
-
html += '<div class="admin-project-item" data-slug="' + escapeHtml(p.slug) + '">';
|
|
798
|
-
html += '<div class="admin-project-info">';
|
|
799
|
-
html += '<div class="admin-project-name">' + escapeHtml(p.title) + '</div>';
|
|
800
|
-
html += '<div class="admin-project-slug">' + escapeHtml(p.slug) + '</div>';
|
|
801
|
-
html += '</div>';
|
|
802
|
-
html += '<div class="admin-project-controls">';
|
|
803
|
-
// Owner select
|
|
804
|
-
html += '<select class="admin-owner-select" data-slug="' + escapeHtml(p.slug) + '">';
|
|
805
|
-
html += '<option value=""' + (!p.projectOwnerId ? ' selected' : '') + '>No owner</option>';
|
|
806
|
-
for (var u = 0; u < allUsers.length; u++) {
|
|
807
|
-
var user = allUsers[u];
|
|
808
|
-
var sel = p.projectOwnerId === user.id ? ' selected' : '';
|
|
809
|
-
var label = escapeHtml(user.displayName || user.username);
|
|
810
|
-
if (user.linuxUser) label += ' (' + escapeHtml(user.linuxUser) + ')';
|
|
811
|
-
html += '<option value="' + escapeHtml(user.id) + '"' + sel + '>' + label + '</option>';
|
|
812
|
-
}
|
|
813
|
-
html += '</select>';
|
|
814
|
-
// Visibility select
|
|
815
|
-
html += '<select class="admin-vis-select ' + visClass + '" data-slug="' + escapeHtml(p.slug) + '">';
|
|
816
|
-
html += '<option value="public"' + (p.visibility === "public" ? " selected" : "") + '>Public</option>';
|
|
817
|
-
html += '<option value="private"' + (p.visibility === "private" ? " selected" : "") + '>Private</option>';
|
|
818
|
-
html += '</select>';
|
|
819
|
-
if (p.visibility === "private") {
|
|
820
|
-
html += '<button class="admin-manage-users-btn" data-slug="' + escapeHtml(p.slug) + '">' + iconHtml("users") + '</button>';
|
|
821
|
-
}
|
|
822
|
-
html += '</div>';
|
|
823
|
-
html += '</div>';
|
|
824
|
-
}
|
|
825
|
-
html += '</div>';
|
|
826
|
-
|
|
827
|
-
body.innerHTML = html;
|
|
828
|
-
refreshIcons(body);
|
|
829
|
-
|
|
830
|
-
// Bind owner selects
|
|
831
|
-
var ownerSelects = body.querySelectorAll(".admin-owner-select");
|
|
832
|
-
for (var oi = 0; oi < ownerSelects.length; oi++) {
|
|
833
|
-
ownerSelects[oi].addEventListener("change", function () {
|
|
834
|
-
var slug = this.dataset.slug;
|
|
835
|
-
var userId = this.value;
|
|
836
|
-
setProjectOwner(slug, userId, body);
|
|
837
|
-
});
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
// Bind visibility selects
|
|
841
|
-
var visSelects = body.querySelectorAll(".admin-vis-select");
|
|
842
|
-
for (var j = 0; j < visSelects.length; j++) {
|
|
843
|
-
visSelects[j].addEventListener("change", function () {
|
|
844
|
-
var slug = this.dataset.slug;
|
|
845
|
-
var visibility = this.value;
|
|
846
|
-
setProjectVisibility(slug, visibility, body);
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Bind manage users buttons
|
|
851
|
-
var manageUserBtns = body.querySelectorAll(".admin-manage-users-btn");
|
|
852
|
-
for (var k = 0; k < manageUserBtns.length; k++) {
|
|
853
|
-
manageUserBtns[k].addEventListener("click", function () {
|
|
854
|
-
var slug = this.dataset.slug;
|
|
855
|
-
showProjectUsersModal(slug, body);
|
|
856
|
-
});
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function setProjectOwner(slug, userId, body) {
|
|
861
|
-
apiPut("/api/admin/projects/" + slug + "/owner", { userId: userId || null }).then(function (data) {
|
|
862
|
-
if (data.ok) {
|
|
863
|
-
showToast("Project owner updated");
|
|
864
|
-
loadProjectsTab(body);
|
|
865
|
-
} else {
|
|
866
|
-
showToast(data.error || "Failed to update owner", "error");
|
|
867
|
-
}
|
|
868
|
-
}).catch(function () {
|
|
869
|
-
showToast("Failed to update owner", "error");
|
|
870
|
-
});
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
function setProjectVisibility(slug, visibility, body) {
|
|
874
|
-
apiPut("/api/admin/projects/" + slug + "/visibility", { visibility: visibility }).then(function (data) {
|
|
875
|
-
if (data.ok) {
|
|
876
|
-
showToast("Visibility updated");
|
|
877
|
-
loadProjectsTab(body);
|
|
878
|
-
} else {
|
|
879
|
-
showToast(data.error || "Failed to update visibility");
|
|
880
|
-
}
|
|
881
|
-
}).catch(function () {
|
|
882
|
-
showToast("Failed to update visibility");
|
|
883
|
-
});
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
function showProjectUsersModal(slug, parentBody) {
|
|
887
|
-
Promise.all([
|
|
888
|
-
apiGet("/api/admin/users"),
|
|
889
|
-
apiGet("/api/admin/projects/" + slug + "/access"),
|
|
890
|
-
]).then(function (results) {
|
|
891
|
-
var allUsers = results[0].users || [];
|
|
892
|
-
var access = results[1];
|
|
893
|
-
var allowed = access.allowedUsers || [];
|
|
894
|
-
|
|
895
|
-
var modal = document.createElement("div");
|
|
896
|
-
modal.className = "admin-modal-overlay";
|
|
897
|
-
|
|
898
|
-
var html = '<div class="admin-modal">';
|
|
899
|
-
html += '<div class="admin-modal-header">';
|
|
900
|
-
html += '<h3>Manage Access: ' + escapeHtml(slug) + '</h3>';
|
|
901
|
-
html += '<button class="admin-modal-close">' + iconHtml("x") + '</button>';
|
|
902
|
-
html += '</div>';
|
|
903
|
-
html += '<div class="admin-modal-body">';
|
|
904
|
-
html += '<p class="admin-modal-desc">Select users who can access this private project:</p>';
|
|
905
|
-
|
|
906
|
-
for (var i = 0; i < allUsers.length; i++) {
|
|
907
|
-
var u = allUsers[i];
|
|
908
|
-
if (u.role === "admin") continue;
|
|
909
|
-
var checked = allowed.indexOf(u.id) >= 0 ? " checked" : "";
|
|
910
|
-
html += '<label class="admin-user-check">';
|
|
911
|
-
html += '<input type="checkbox" value="' + u.id + '"' + checked + '>';
|
|
912
|
-
html += '<span>' + escapeHtml(u.displayName || u.username) + ' <small>' + escapeHtml(u.username) + '</small></span>';
|
|
913
|
-
html += '</label>';
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
html += '</div>';
|
|
917
|
-
html += '<div class="admin-modal-footer">';
|
|
918
|
-
html += '<button class="admin-modal-save">Save</button>';
|
|
919
|
-
html += '<button class="admin-modal-cancel">Cancel</button>';
|
|
920
|
-
html += '</div>';
|
|
921
|
-
html += '</div>';
|
|
922
|
-
|
|
923
|
-
modal.innerHTML = html;
|
|
924
|
-
document.body.appendChild(modal);
|
|
925
|
-
refreshIcons(modal);
|
|
926
|
-
|
|
927
|
-
modal.querySelector(".admin-modal-close").addEventListener("click", function () { modal.remove(); });
|
|
928
|
-
modal.querySelector(".admin-modal-cancel").addEventListener("click", function () { modal.remove(); });
|
|
929
|
-
modal.addEventListener("click", function (e) {
|
|
930
|
-
if (e.target === modal) modal.remove();
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
modal.querySelector(".admin-modal-save").addEventListener("click", function () {
|
|
934
|
-
var checkboxes = modal.querySelectorAll('input[type="checkbox"]');
|
|
935
|
-
var selectedUsers = [];
|
|
936
|
-
for (var ci = 0; ci < checkboxes.length; ci++) {
|
|
937
|
-
if (checkboxes[ci].checked) selectedUsers.push(checkboxes[ci].value);
|
|
938
|
-
}
|
|
939
|
-
apiPut("/api/admin/projects/" + slug + "/users", { allowedUsers: selectedUsers }).then(function (data) {
|
|
940
|
-
if (data.ok) {
|
|
941
|
-
showToast("Project access updated");
|
|
942
|
-
modal.remove();
|
|
943
|
-
loadProjectsTab(parentBody);
|
|
944
|
-
} else {
|
|
945
|
-
showToast(data.error || "Failed to update access");
|
|
946
|
-
}
|
|
947
|
-
}).catch(function () {
|
|
948
|
-
showToast("Failed to update access");
|
|
949
|
-
});
|
|
950
|
-
});
|
|
951
|
-
}).catch(function () {
|
|
952
|
-
showToast("Failed to load project access info");
|
|
953
|
-
});
|
|
16
|
+
body.textContent = 'Loading…';
|
|
17
|
+
if (section === 'admin-users') {
|
|
18
|
+
var invitations = document.getElementById('admin-invites-body');
|
|
19
|
+
invitations.textContent = 'Loading…';
|
|
20
|
+
loadInvitesTab(invitations);
|
|
21
|
+
}
|
|
22
|
+
var loaders = { 'admin-users': loadUsersTab, 'admin-invites': loadInvitesTab, 'admin-smtp': loadSmtpTab, 'admin-projects': loadProjectsTab };
|
|
23
|
+
if (loaders[section]) loaders[section](body);
|
|
954
24
|
}
|