@arcblock/did-connect-service 4.0.6 → 4.0.7

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.
Files changed (67) hide show
  1. package/dist/_generated/asset-bytes.d.ts +3 -0
  2. package/dist/_generated/asset-bytes.d.ts.map +1 -0
  3. package/dist/_generated/asset-bytes.js +2 -0
  4. package/dist/_generated/asset-bytes.js.map +1 -0
  5. package/dist/_generated/asset-manifest.d.ts +3 -0
  6. package/dist/_generated/asset-manifest.d.ts.map +1 -0
  7. package/dist/_generated/asset-manifest.js +12 -0
  8. package/dist/_generated/asset-manifest.js.map +1 -0
  9. package/dist/asset-registry.d.ts +38 -0
  10. package/dist/asset-registry.d.ts.map +1 -0
  11. package/dist/asset-registry.js +73 -0
  12. package/dist/asset-registry.js.map +1 -0
  13. package/dist/assets/admin-core.c0b5af61.js +1393 -0
  14. package/dist/assets/admin-extra.7ca9c16b.js +2529 -0
  15. package/dist/assets/admin.c26bb17a.css +2219 -0
  16. package/dist/assets/design.99dc4ddc.css +97 -0
  17. package/dist/assets/did-address.7df30f28.js +51 -0
  18. package/dist/assets/header.94d9e46b.js +136 -0
  19. package/dist/assets/login.7b12c6dc.css +662 -0
  20. package/dist/assets/login.d3f05790.js +720 -0
  21. package/dist/assets/qr.c0d203ca.js +3 -0
  22. package/dist/handlers/auth-handler.d.ts.map +1 -1
  23. package/dist/handlers/auth-handler.js +10 -10
  24. package/dist/handlers/auth-handler.js.map +1 -1
  25. package/dist/pages/admin/index.d.ts.map +1 -1
  26. package/dist/pages/admin/index.js +25 -41
  27. package/dist/pages/admin/index.js.map +1 -1
  28. package/dist/pages/admin/tab-access.d.ts +1 -1
  29. package/dist/pages/admin/tab-access.d.ts.map +1 -1
  30. package/dist/pages/admin/tab-access.js +5 -2
  31. package/dist/pages/admin/tab-access.js.map +1 -1
  32. package/dist/pages/admin/tab-appearance.d.ts +1 -1
  33. package/dist/pages/admin/tab-appearance.d.ts.map +1 -1
  34. package/dist/pages/admin/tab-appearance.js +4 -2
  35. package/dist/pages/admin/tab-appearance.js.map +1 -1
  36. package/dist/pages/admin/tab-branding.d.ts.map +1 -1
  37. package/dist/pages/admin/tab-branding.js +4 -2
  38. package/dist/pages/admin/tab-branding.js.map +1 -1
  39. package/dist/pages/admin/tab-profile-accounts.d.ts.map +1 -1
  40. package/dist/pages/admin/tab-profile-accounts.js +4 -2
  41. package/dist/pages/admin/tab-profile-accounts.js.map +1 -1
  42. package/dist/pages/admin/tab-settings.d.ts.map +1 -1
  43. package/dist/pages/admin/tab-settings.js +4 -2
  44. package/dist/pages/admin/tab-settings.js.map +1 -1
  45. package/dist/pages/admin-instances-page.d.ts.map +1 -1
  46. package/dist/pages/admin-instances-page.js +4 -6
  47. package/dist/pages/admin-instances-page.js.map +1 -1
  48. package/dist/pages/error-page.d.ts.map +1 -1
  49. package/dist/pages/error-page.js +3 -2
  50. package/dist/pages/error-page.js.map +1 -1
  51. package/dist/pages/gen-access-key-page.d.ts.map +1 -1
  52. package/dist/pages/gen-access-key-page.js +3 -4
  53. package/dist/pages/gen-access-key-page.js.map +1 -1
  54. package/dist/pages/homepage.d.ts.map +1 -1
  55. package/dist/pages/homepage.js +4 -3
  56. package/dist/pages/homepage.js.map +1 -1
  57. package/dist/pages/invite-page.d.ts.map +1 -1
  58. package/dist/pages/invite-page.js +4 -4
  59. package/dist/pages/invite-page.js.map +1 -1
  60. package/dist/pages/login-page.d.ts.map +1 -1
  61. package/dist/pages/login-page.js +3 -4
  62. package/dist/pages/login-page.js.map +1 -1
  63. package/package.json +6 -4
  64. package/dist/identity/csrf.d.ts +0 -17
  65. package/dist/identity/csrf.d.ts.map +0 -1
  66. package/dist/identity/csrf.js +0 -56
  67. package/dist/identity/csrf.js.map +0 -1
@@ -0,0 +1,2529 @@
1
+
2
+ var membersPage = 1;
3
+ var membersPageSize = 20;
4
+ var memberSearchTimeout = null;
5
+ var currentMembersList = [];
6
+ var selectedMember = null;
7
+ var __membersAggregated = false;
8
+ // Sort state: empty string = default (server decides + self-first on client).
9
+ // Three-state click cycle: (none) → desc → asc → (none).
10
+ // Persisted in location.search as sortBy/sortOrder so it survives refresh + share.
11
+ var membersSortBy = "";
12
+ var membersSortOrder = "";
13
+ var MEMBERS_SORT_VALID = ["name", "role", "status", "source", "joined"];
14
+
15
+ /** Populate module sort state from the current URL query string. Idempotent. */
16
+ function readMembersSortFromURL() {
17
+ membersSortBy = "";
18
+ membersSortOrder = "";
19
+ var params = new URLSearchParams(location.search);
20
+ var by = params.get("sortBy");
21
+ var order = params.get("sortOrder");
22
+ if (by && MEMBERS_SORT_VALID.indexOf(by) >= 0) {
23
+ membersSortBy = by;
24
+ membersSortOrder = (order === "asc" || order === "desc") ? order : "desc";
25
+ }
26
+ }
27
+
28
+ /** Write current sort state back to location.search without reloading. */
29
+ function writeMembersSortToURL() {
30
+ var params = new URLSearchParams(location.search);
31
+ if (membersSortBy) {
32
+ params.set("sortBy", membersSortBy);
33
+ params.set("sortOrder", membersSortOrder || "desc");
34
+ } else {
35
+ params.delete("sortBy");
36
+ params.delete("sortOrder");
37
+ }
38
+ var qs = params.toString();
39
+ var newUrl = location.pathname + (qs ? "?" + qs : "") + location.hash;
40
+ history.replaceState(null, "", newUrl);
41
+ }
42
+
43
+ /** Cycle sort state for a column key and reload. */
44
+ function toggleSort(key) {
45
+ if (membersSortBy !== key) {
46
+ membersSortBy = key;
47
+ membersSortOrder = "desc";
48
+ } else if (membersSortOrder === "desc") {
49
+ membersSortOrder = "asc";
50
+ } else {
51
+ // Third click — back to default.
52
+ membersSortBy = "";
53
+ membersSortOrder = "";
54
+ }
55
+ writeMembersSortToURL();
56
+ loadMembers(1);
57
+ }
58
+
59
+ /** Render a <th> with click-to-sort behavior. */
60
+ function sortableTh(key, label, extraClass) {
61
+ var isActive = membersSortBy === key;
62
+ var arrow;
63
+ if (isActive) {
64
+ arrow = membersSortOrder === "asc"
65
+ ? '<span class="sort-arrow sort-arrow-active">\u2191</span>'
66
+ : '<span class="sort-arrow sort-arrow-active">\u2193</span>';
67
+ } else {
68
+ arrow = '<span class="sort-arrow">\u2195</span>';
69
+ }
70
+ var cls = "sortable" + (isActive ? " sort-active" : "") + (extraClass ? " " + extraClass : "");
71
+ return '<th class="' + cls + '" onclick="toggleSort(\'' + key + '\')">' + label + arrow + '</th>';
72
+ }
73
+
74
+ function providerLabel(sp) {
75
+ if (!sp) return "\u2014";
76
+ var key = "members.providers." + sp;
77
+ var val = __t(key);
78
+ return val !== key ? val : sp;
79
+ }
80
+
81
+ function debouncedMemberSearch() {
82
+ clearTimeout(memberSearchTimeout);
83
+ memberSearchTimeout = setTimeout(function() { loadMembers(1); }, 300);
84
+ }
85
+
86
+ // Load registered instances for the filter dropdown (system admin only)
87
+ var __instanceNames = {};
88
+ var __instanceFilterInitialized = false;
89
+ async function initInstanceFilter() {
90
+ if (__instanceFilterInitialized) return;
91
+ try {
92
+ var data = await api("GET", "/../admin/instances");
93
+ if (data.ok && data.instances && data.instances.length > 0) {
94
+ __instanceFilterInitialized = true;
95
+ var sel = document.getElementById("members-instance-filter");
96
+ data.instances.forEach(function(inst) {
97
+ __instanceNames[inst.instanceDid] = inst.appName || inst.instanceDid.substring(0, 16);
98
+ var opt = document.createElement("option");
99
+ opt.value = inst.instanceDid;
100
+ opt.textContent = __instanceNames[inst.instanceDid];
101
+ sel.appendChild(opt);
102
+ });
103
+ sel.style.display = "";
104
+ }
105
+ } catch(e) { /* not admin or no instances */ }
106
+ }
107
+
108
+ // Also support specific instance filter via query param
109
+ // When ?instance=z1xxx, team handler needs to use that instanceDid
110
+
111
+ async function loadMembers(page) {
112
+ membersPage = page || 1;
113
+ var instanceFilter = document.getElementById("members-instance-filter").value;
114
+ var qs = "?page=" + membersPage + "&pageSize=" + membersPageSize;
115
+ // System view ("") → use defaultInstanceDid so we query membership, not global users
116
+ if (instanceFilter === "" && __pageData.instanceDid) {
117
+ qs += "&instance=" + encodeURIComponent(__pageData.instanceDid);
118
+ } else if (instanceFilter) {
119
+ qs += "&instance=" + encodeURIComponent(instanceFilter);
120
+ }
121
+ var search = document.getElementById("members-search").value.trim();
122
+ if (search) qs += "&search=" + encodeURIComponent(search);
123
+ var role = document.getElementById("members-role-filter").value;
124
+ if (role) qs += "&role=" + encodeURIComponent(role);
125
+ var status = document.getElementById("members-status-filter").value;
126
+ if (status !== "") qs += "&approved=" + status;
127
+ var source = document.getElementById("members-source-filter").value;
128
+ if (source) qs += "&sourceProvider=" + encodeURIComponent(source);
129
+ if (membersSortBy) {
130
+ qs += "&sortBy=" + encodeURIComponent(membersSortBy);
131
+ qs += "&sortOrder=" + encodeURIComponent(membersSortOrder || "desc");
132
+ }
133
+
134
+ document.getElementById("members-table-wrap").innerHTML = skeletonTable(8, 6);
135
+ document.getElementById("members-pagination").innerHTML = "";
136
+ var data = await api("GET", "/members" + qs);
137
+ if (!data.ok) {
138
+ document.getElementById("members-table-wrap").innerHTML = '<div class="empty-state"><div class="empty-state-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg></div><div class="empty-state-title">' + __t("members.loadFailed") + '</div></div>';
139
+ return;
140
+ }
141
+ __membersAggregated = !!data.aggregated;
142
+ // Only pin the caller to the top when using the default (server) order.
143
+ // Under explicit sort, honor the user's intent without re-shuffling.
144
+ if (membersSortBy) {
145
+ currentMembersList = data.users || [];
146
+ } else {
147
+ currentMembersList = (data.users || []).slice().sort(function(a, b) {
148
+ if (a.did === __caller.did) return -1;
149
+ if (b.did === __caller.did) return 1;
150
+ return (a.createdAt || 0) - (b.createdAt || 0);
151
+ });
152
+ }
153
+ renderMembersTable(currentMembersList, data.paging);
154
+ updateTransferSection(data.users);
155
+ }
156
+
157
+ function renderMembersTable(users, paging) {
158
+ var wrap = document.getElementById("members-table-wrap");
159
+ if (!users || users.length === 0) {
160
+ wrap.innerHTML = '<div class="empty-state"><div class="empty-state-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg></div><div class="empty-state-title">' + __t("members.noMembers") + '</div><div class="empty-state-desc">' + __t("members.noMembersHint") + '</div></div>';
161
+ document.getElementById("members-pagination").innerHTML = "";
162
+ return;
163
+ }
164
+
165
+ var instanceSel = document.getElementById("members-instance-filter");
166
+ var instanceVisible = instanceSel && instanceSel.style.display !== 'none';
167
+ var showInstance = instanceVisible && instanceSel.value === "all";
168
+ var html = '<table class="table"><thead><tr>';
169
+ html += sortableTh("name", __t("members.col.member"));
170
+ html += sortableTh("role", __t("members.col.role"));
171
+ if (showInstance) html += '<th>' + __t("members.col.instance") + '</th>';
172
+ html += sortableTh("source", __t("members.col.source"), "col-hide-sm");
173
+ html += sortableTh("status", __t("members.col.status"), "col-hide-sm");
174
+ html += sortableTh("joined", __t("members.col.joined"), "col-hide-md");
175
+ // Registered/Last Login From columns removed — domain info not meaningful in Worker context
176
+ html += '<th></th></tr></thead><tbody>';
177
+ users.forEach(function(u) {
178
+ var userDid = u.did || u.user_did || '';
179
+ var av = renderAvatar(u.avatar, u.fullName, "sm");
180
+ var name = u.fullName ? escapeHtml(u.fullName) : '<span class="text-muted">(unnamed)</span>';
181
+ var did = '<did-address did="' + escapeHtml(userDid) + '" compact></did-address>';
182
+ var roleBadge = '<span class="badge badge-' + (u.role || "guest") + '">' + __t("common." + (u.role || "guest")) + '</span>';
183
+ var statusBadge = u.approved !== undefined ? (u.approved ? '<span class="badge badge-active">' + __t("common.active") + '</span>' : '<span class="badge badge-blocked">' + __t("common.blocked") + '</span>') : '<span class="text-muted">—</span>';
184
+ var joined = (u.createdAt || u.joined_at) ? '<span class="cell-date">' + escapeHtml(relativeTime(u.createdAt || u.joined_at)) + '</span>' : '<span class="text-muted">—</span>';
185
+ html += '<tr style="cursor:pointer" onclick="openMemberDetail(\'' + escapeHtml(userDid) + '\')">';
186
+ html += '<td><div class="member-identity">' + av + '<div><div class="member-name">' + name + '</div><div class="member-email">' + did + '</div></div></div></td>';
187
+ html += '<td>' + roleBadge + '</td>';
188
+ if (showInstance) {
189
+ if (__membersAggregated && u.instance_count > 1) {
190
+ html += '<td><a href="javascript:void(0)" onclick="event.stopPropagation();openMemberDetail(\'' + escapeHtml(userDid) + '\')" style="color:var(--blue);font-size:14px;text-decoration:none">' + u.instance_count + ' ' + __t("members.instances") + '</a></td>';
191
+ } else {
192
+ var instDid = (__membersAggregated && u.instances && u.instances.length === 1) ? u.instances[0].instance_did : (u.instance_did || '');
193
+ var instName = __instanceNames[instDid] || '';
194
+ if (!instName) instName = instDid ? __t("members.currentInstance") : '—';
195
+ html += '<td><div>' + escapeHtml(instName);
196
+ if (instDid) html += '<div style="font-size:11px;color:var(--text-secondary);margin-top:2px" onclick="event.stopPropagation()"><did-address did="' + escapeHtml(instDid) + '" compact></did-address></div>';
197
+ html += '</div></td>';
198
+ }
199
+ }
200
+ var sourceBadge = '<span class="text-muted">' + escapeHtml(providerLabel(u.sourceProvider)) + '</span>';
201
+ html += '<td class="col-hide-sm">' + sourceBadge + '</td>';
202
+ html += '<td class="col-hide-sm">' + statusBadge + '</td>';
203
+ html += '<td class="col-hide-md">' + joined + '</td>';
204
+ // Actions: only for current instance members, not cross-instance
205
+ var canOperate = !instanceVisible || (instanceVisible && instanceSel.value === '');
206
+ // In aggregated mode, only allow actions on users whose instance matches current system
207
+ if (__membersAggregated) canOperate = false;
208
+ html += '<td class="action-cell">' + (canOperate ? renderMemberActions(u) : '') + '</td>';
209
+ html += '</tr>';
210
+ });
211
+ html += '</tbody></table>';
212
+ wrap.innerHTML = html;
213
+
214
+ renderPagination("members-pagination", paging, loadMembers);
215
+ }
216
+
217
+ function renderMemberActions(u) {
218
+ var isOwner = __caller.role === "owner";
219
+ var isAdmin = __caller.role === "admin";
220
+ var isSelf = u.did === __caller.did;
221
+ var ROLE_LEVEL = { owner: 3, admin: 2, member: 1, guest: 0 };
222
+ var callerLevel = ROLE_LEVEL[__caller.role] || 0;
223
+ var targetLevel = ROLE_LEVEL[u.role] || 0;
224
+ // Can only act on members with lower privilege
225
+ var canManage = !isSelf && targetLevel < callerLevel;
226
+ var items = [];
227
+
228
+ // Change role: can manage lower-level members
229
+ if (canManage) {
230
+ items.push('<button class="action-menu-item" onclick="event.stopPropagation();openChangeRoleForDid(\'' + escapeHtml(u.did) + '\')">' + __t("members.changeRole") + '</button>');
231
+ }
232
+ if (canManage && u.approved) {
233
+ items.push('<button class="action-menu-item" onclick="event.stopPropagation();blockMember(\'' + escapeHtml(u.did) + '\',\'' + escapeHtml(u.fullName || u.did) + '\')">' + __t("members.block") + '</button>');
234
+ }
235
+ if (canManage && !u.approved) {
236
+ items.push('<button class="action-menu-item" onclick="event.stopPropagation();unblockMember(\'' + escapeHtml(u.did) + '\',\'' + escapeHtml(u.fullName || u.did) + '\')">' + __t("members.unblock") + '</button>');
237
+ }
238
+ if (items.length > 0 && canManage) {
239
+ items.push('<div class="action-menu-sep"></div>');
240
+ }
241
+ if (canManage) {
242
+ items.push('<button class="action-menu-item danger" onclick="event.stopPropagation();removeMember(\'' + escapeHtml(u.did) + '\',\'' + escapeHtml(u.fullName || u.did) + '\')">' + __t("members.remove") + '</button>');
243
+ }
244
+
245
+ if (items.length === 0) return "";
246
+ return '<button class="action-trigger" onclick="event.stopPropagation();toggleActionMenu(this)">⋯</button><div class="action-menu">' + items.join("") + '</div>';
247
+ }
248
+
249
+ function toggleActionMenu(trigger) {
250
+ var menu = trigger.nextElementSibling;
251
+ var wasOpen = menu.classList.contains("open");
252
+ // Close all menus first
253
+ document.querySelectorAll(".action-menu.open").forEach(function(m) { m.classList.remove("open"); });
254
+ if (!wasOpen) menu.classList.toggle("open");
255
+ }
256
+
257
+ async function openMemberDetail(did) {
258
+ var u = currentMembersList.find(function(m) { return m.did === did; });
259
+ if (!u) return;
260
+ selectedMember = u;
261
+
262
+ // Fetch full member info (with connectedAccounts) from API.
263
+ // Falls back to in-memory list data if the request fails.
264
+ var detail = u;
265
+ try {
266
+ var resp = await api("GET", "/members/" + encodeURIComponent(did));
267
+ if (resp && resp.ok && resp.user) {
268
+ // Merge: in-memory list has fields the API may omit (instances, passkeyCount in some paths).
269
+ detail = Object.assign({}, u, resp.user);
270
+ selectedMember = detail;
271
+ }
272
+ } catch (e) {}
273
+
274
+ var body = document.getElementById("member-detail-body");
275
+ var html = '<div class="profile-header">' + renderAvatar(detail.avatar, detail.fullName) + '<div>';
276
+ html += '<div style="font-weight:500;font-size:16px;color:var(--text-white)">' + escapeHtml(detail.fullName || "(unnamed)") + '</div>';
277
+ html += '<did-address did="' + escapeHtml(detail.did) + '" compact></did-address>';
278
+ html += '</div></div>';
279
+
280
+ html += '<div class="settings-card" style="margin-top:16px">';
281
+ html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailRole") + '</span><span class="settings-value"><span class="badge badge-' + (detail.role || "guest") + '">' + __t("common." + (detail.role || "guest")) + '</span></span></div>';
282
+ html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailStatus") + '</span><span class="settings-value">' + (detail.approved ? '<span class="badge badge-active">' + __t("common.active") + '</span>' : '<span class="badge badge-blocked">' + __t("common.blocked") + '</span>') + '</span></div>';
283
+ html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailSource") + '</span><span class="settings-value">' + escapeHtml(providerLabel(detail.sourceProvider)) + '</span></div>';
284
+ if (detail.email) html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailEmail") + '</span><span class="settings-value">' + escapeHtml(detail.email) + '</span></div>';
285
+ if (detail.inviterName) html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailInvitedBy") + '</span><span class="settings-value">' + escapeHtml(detail.inviterName) + '</span></div>';
286
+ if (detail.createdAt) html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailJoined") + '</span><span class="settings-value">' + escapeHtml(absoluteTime(detail.createdAt)) + '</span></div>';
287
+ if (detail.lastLoginAt) html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailLastLogin") + '</span><span class="settings-value">' + escapeHtml(absoluteTime(detail.lastLoginAt)) + '</span></div>';
288
+ if (detail.sourceDomain) html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailRegisteredFrom") + '</span><span class="settings-value">' + escapeHtml(detail.sourceDomain) + '</span></div>';
289
+ if (detail.lastLoginDomain) html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailLastLoginFrom") + '</span><span class="settings-value">' + escapeHtml(detail.lastLoginDomain) + '</span></div>';
290
+ if (detail.passkeyCount !== undefined) html += '<div class="settings-row"><span class="settings-label">' + __t("members.detailPasskeys") + '</span><span class="settings-value">' + detail.passkeyCount + '</span></div>';
291
+ html += '</div>';
292
+
293
+ // Connected Accounts (read-only)
294
+ if (detail.connectedAccounts && detail.connectedAccounts.length > 0) {
295
+ html += '<div class="settings-card" style="margin-top:16px">';
296
+ html += '<div class="settings-card-header"><span>' + __t("members.detailConnectedAccounts") + ' (' + detail.connectedAccounts.length + ')</span></div>';
297
+ html += '<div style="max-height:240px;overflow-y:auto">';
298
+ detail.connectedAccounts.forEach(function(a) {
299
+ var label = providerLabel(a.provider);
300
+ var info = '';
301
+ if (a.userInfo && a.userInfo.name) info = a.userInfo.name;
302
+ else if (a.userInfo && a.userInfo.email) info = a.userInfo.email;
303
+ else if (a.id) info = a.id;
304
+ html += '<div class="settings-row" style="gap:12px;align-items:flex-start">';
305
+ html += '<div style="flex:1;min-width:0">';
306
+ html += '<div style="font-size:14px;font-weight:500;color:var(--text)">' + escapeHtml(label);
307
+ if (a.isMain) html += ' <span class="badge badge-active" style="font-size:10px;padding:1px 6px;">' + __t("profile.mainAccount") + '</span>';
308
+ html += '</div>';
309
+ if (info) html += '<div style="font-size:12px;color:var(--text-secondary);margin-top:2px">' + escapeHtml(info) + '</div>';
310
+ if (a.did) html += '<div style="font-size:11px;color:var(--text-secondary);margin-top:2px"><did-address did="' + escapeHtml(a.did) + '" compact></did-address></div>';
311
+ html += '</div>';
312
+ if (a.lastLoginAt) html += '<div style="font-size:12px;color:var(--text-secondary);flex-shrink:0">' + escapeHtml(relativeTime(a.lastLoginAt)) + '</div>';
313
+ html += '</div>';
314
+ });
315
+ html += '</div></div>';
316
+ }
317
+
318
+ // Instance memberships (aggregated mode)
319
+ if (detail.instances && detail.instances.length > 0) {
320
+ html += '<div class="settings-card" style="margin-top:16px">';
321
+ html += '<div class="settings-card-header"><span>' + __t("members.instanceMemberships") + ' (' + detail.instances.length + ')</span></div>';
322
+ html += '<div style="max-height:240px;overflow-y:auto">';
323
+ detail.instances.forEach(function(inst) {
324
+ var instName = __instanceNames[inst.instance_did] || '';
325
+ var instDid = inst.instance_did || '';
326
+ html += '<div class="settings-row" style="gap:12px">';
327
+ html += '<div style="flex:1;min-width:0"><div style="font-size:14px;font-weight:500;color:var(--text)">' + escapeHtml(instName || __t("members.currentInstance")) + '</div>';
328
+ if (instDid) html += '<div style="font-size:11px;color:var(--text-secondary);margin-top:2px"><did-address did="' + escapeHtml(instDid) + '" compact></did-address></div>';
329
+ html += '</div>';
330
+ html += '<div style="display:flex;align-items:center;gap:8px;flex-shrink:0"><span class="badge badge-' + inst.role + '">' + __t("common." + inst.role) + '</span>';
331
+ if (inst.joined_at) html += '<span style="font-size:12px;color:var(--text-secondary)">' + escapeHtml(relativeTime(inst.joined_at)) + '</span>';
332
+ html += '</div></div>';
333
+ });
334
+ html += '</div></div>';
335
+ }
336
+
337
+ body.innerHTML = html;
338
+
339
+ // Change role: mirror list's canOperate + renderMemberActions logic exactly.
340
+ // Use post-merge detail so role/did reflect the latest API response.
341
+ var changeRoleBtn = document.getElementById("member-detail-change-role");
342
+ var instanceSel = document.getElementById("members-instance-filter");
343
+ var instanceVisible = instanceSel && instanceSel.style.display !== 'none';
344
+ var canOperate = !instanceVisible || (instanceVisible && instanceSel.value === '');
345
+ if (__membersAggregated) canOperate = false;
346
+ var isSelf = detail.did === __caller.did;
347
+ var ROLE_LEVEL = { owner: 3, admin: 2, member: 1, guest: 0 };
348
+ var callerLevel = ROLE_LEVEL[__caller.role] || 0;
349
+ var targetLevel = ROLE_LEVEL[detail.role] || 0;
350
+ var canChangeRole = canOperate && !isSelf && targetLevel < callerLevel;
351
+ if (canChangeRole) {
352
+ changeRoleBtn.classList.remove("hidden");
353
+ } else {
354
+ changeRoleBtn.classList.add("hidden");
355
+ }
356
+
357
+ showDialog("member-detail-dialog");
358
+ }
359
+
360
+ function openChangeRoleForDid(did) {
361
+ var u = currentMembersList.find(function(m) { return m.did === did; });
362
+ if (!u) return;
363
+ selectedMember = u;
364
+ openChangeRoleDialog();
365
+ }
366
+
367
+ function openChangeRoleDialog() {
368
+ if (!selectedMember) return;
369
+ document.getElementById("change-role-title").textContent = __t("members.changeRole");
370
+ document.getElementById("change-role-subtitle").textContent = __t("members.changeRoleFor", { name: selectedMember.fullName || truncateDid(selectedMember.did) });
371
+ // Show/hide role options based on caller's level (only owner can assign admin)
372
+ var radios = document.querySelectorAll('input[name="new-role"]');
373
+ radios.forEach(function(r) {
374
+ r.checked = r.value === selectedMember.role;
375
+ var label = r.closest('label');
376
+ if (label) {
377
+ if (r.value === 'admin' && __caller.role !== 'owner') label.style.display = 'none';
378
+ else label.style.display = '';
379
+ }
380
+ });
381
+ closeDialog("member-detail-dialog");
382
+ showDialog("change-role-dialog");
383
+ }
384
+
385
+ async function saveRoleChange() {
386
+ if (!selectedMember) return;
387
+ var selected = document.querySelector('input[name="new-role"]:checked');
388
+ if (!selected) return;
389
+ var newRole = selected.value;
390
+ var data = await api("PUT", "/members/" + encodeURIComponent(selectedMember.did) + "/role", { role: newRole });
391
+ if (data.ok) {
392
+ showToast(__t("members.roleUpdated"), "success");
393
+ closeDialog("change-role-dialog");
394
+ loadMembers(membersPage);
395
+ }
396
+ }
397
+
398
+ async function blockMember(did, name) {
399
+ var ok = await confirmDialog({ title: __t("members.blockTitle"), message: __t("members.blockMsg", { name: name }), confirmLabel: __t("members.blockBtn"), danger: true });
400
+ if (!ok) return;
401
+ var data = await api("PUT", "/members/" + encodeURIComponent(did) + "/approval", { approved: false });
402
+ if (data.ok) { showToast(__t("members.blockSuccess"), "success"); loadMembers(membersPage); }
403
+ }
404
+
405
+ async function unblockMember(did, name) {
406
+ var ok = await confirmDialog({ title: __t("members.unblockTitle"), message: __t("members.unblockMsg", { name: name }), confirmLabel: __t("members.unblock") });
407
+ if (!ok) return;
408
+ var data = await api("PUT", "/members/" + encodeURIComponent(did) + "/approval", { approved: true });
409
+ if (data.ok) { showToast(__t("members.unblockSuccess"), "success"); loadMembers(membersPage); }
410
+ }
411
+
412
+ async function removeMember(did, name) {
413
+ var ok = await confirmDialog({ title: __t("members.removeTitle"), message: __t("members.removeMsg", { name: name }), confirmLabel: __t("members.remove"), danger: true });
414
+ if (!ok) return;
415
+ var data = await api("DELETE", "/members/" + encodeURIComponent(did));
416
+ if (data.ok) { showToast(__t("members.removeSuccess"), "success"); loadMembers(membersPage); }
417
+ }
418
+
419
+ function updateTransferSection(users) {
420
+ var section = document.getElementById("ownership-transfer-section");
421
+ if (__caller.role !== "owner") {
422
+ section.classList.add("hidden");
423
+ return;
424
+ }
425
+ section.classList.remove("hidden");
426
+ var select = document.getElementById("transfer-target");
427
+ var html = '<option value="">' + __t("members.transferSelect") + '</option>';
428
+ users.forEach(function(u) {
429
+ if (u.did !== __caller.did && u.approved && u.role !== "owner") {
430
+ html += '<option value="' + escapeHtml(u.did) + '">' + escapeHtml(u.fullName || truncateDid(u.did)) + ' (' + u.role + ')</option>';
431
+ }
432
+ });
433
+ select.innerHTML = html;
434
+ }
435
+
436
+ async function handleTransferOwnership() {
437
+ var target = document.getElementById("transfer-target").value;
438
+ if (!target) { showToast(__t("members.selectMemberFirst"), "error"); return; }
439
+ var targetUser = currentMembersList.find(function(u) { return u.did === target; });
440
+ var name = targetUser ? (targetUser.fullName || truncateDid(target)) : truncateDid(target);
441
+ var ok = await confirmDialog({
442
+ title: __t("members.transferTitle"),
443
+ message: __t("members.transferMsg", { name: name }),
444
+ confirmLabel: __t("members.transferConfirm"),
445
+ danger: true,
446
+ });
447
+ if (!ok) return;
448
+ var data = await api("POST", "/transfer-ownership", { targetDid: target });
449
+ if (data.ok) {
450
+ showToast(__t("members.transferSuccess"), "success");
451
+ setTimeout(function() { location.reload(); }, 1000);
452
+ }
453
+ }
454
+
455
+ // Close action menus on outside click
456
+ document.addEventListener("click", function() {
457
+ document.querySelectorAll(".action-menu.open").forEach(function(m) { m.classList.remove("open"); });
458
+ });
459
+
460
+ registerTab("members", function() {
461
+ initInstanceFilter().then(function() {
462
+ // Auto-select instance from URL query (e.g., ?instance=z1xxx)
463
+ var urlInstance = new URLSearchParams(window.location.search).get("instance");
464
+ if (urlInstance) {
465
+ var sel = document.getElementById("members-instance-filter");
466
+ if (sel) sel.value = urlInstance;
467
+ }
468
+ // Restore sort state from URL (sortBy / sortOrder) on tab enter.
469
+ readMembersSortFromURL();
470
+ loadMembers(1);
471
+ });
472
+ });
473
+
474
+ ;
475
+
476
+ var invitationsPage = 1;
477
+ var invitationsPageSize = 20;
478
+
479
+
480
+ function updateRemarkCounter() {
481
+ document.getElementById("remark-counter").textContent = document.getElementById("invite-remark").value.length;
482
+ }
483
+
484
+ async function loadInvitations(page) {
485
+ invitationsPage = page || 1;
486
+ var qs = "?page=" + invitationsPage + "&pageSize=" + invitationsPageSize;
487
+ document.getElementById("invitations-table-wrap").innerHTML = skeletonTable(6, 4);
488
+ document.getElementById("invitations-pagination").innerHTML = "";
489
+ var data = await api("GET", "/invitations" + qs);
490
+ if (!data.ok) {
491
+ document.getElementById("invitations-table-wrap").innerHTML = '<div class="empty-state"><div class="empty-state-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg></div><div class="empty-state-title">' + __t("invitations.loadFailed") + '</div></div>';
492
+ return;
493
+ }
494
+ renderInvitationsTable(data.invitations, data.paging);
495
+ }
496
+
497
+ function renderInvitationsTable(invitations, paging) {
498
+ var wrap = document.getElementById("invitations-table-wrap");
499
+ if (!invitations || invitations.length === 0) {
500
+ wrap.innerHTML = '<div class="empty-state"><div class="empty-state-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg></div><div class="empty-state-title">' + __t("invitations.noInvitations") + '</div><div class="empty-state-desc">' + __t("invitations.noInvitationsHint") + '</div></div>';
501
+ document.getElementById("invitations-pagination").innerHTML = "";
502
+ return;
503
+ }
504
+
505
+ var html = '<table class="table"><thead><tr><th>' + __t("invitations.col.role") + '</th><th class="col-hide-sm">' + __t("invitations.col.remark") + '</th><th>' + __t("invitations.col.uses") + '</th><th>' + __t("invitations.col.expires") + '</th><th class="col-hide-md">' + __t("invitations.col.createdBy") + '</th><th></th></tr></thead><tbody>';
506
+ invitations.forEach(function(inv) {
507
+ var roleBadge = '<span class="badge badge-' + inv.role + '">' + __t("common." + inv.role) + '</span>';
508
+ var remark = inv.remark ? escapeHtml(inv.remark.length > 30 ? inv.remark.slice(0, 30) + "..." : inv.remark) : '<span class="text-muted">—</span>';
509
+ var uses = inv.useCount + " / " + inv.maxUses;
510
+ if (inv.useCount >= inv.maxUses) uses += ' <span class="badge badge-blocked">' + __t("invitations.closed") + '</span>';
511
+ var expired = new Date(inv.expireAt) < new Date();
512
+ var expireText = expired ? '<span class="badge badge-expired">expired</span>' : '<span class="cell-date">' + escapeHtml(relativeTime(inv.expireAt)) + '</span>';
513
+ var isActive = !expired && inv.status !== "closed" && inv.useCount < inv.maxUses;
514
+ var inviter = inv.inviterName ? escapeHtml(inv.inviterName) : '<span class="text-muted">—</span>';
515
+
516
+ var actions = '';
517
+ if (isActive) {
518
+ actions += '<button class="action-menu-item" onclick="event.stopPropagation();copyToClipboard(\'' + escapeHtml(inv.link) + '\')">' + __t("invitations.copyLink") + '</button>';
519
+ }
520
+ var canDelete = __caller.role === "owner" || inv.inviterDid === __caller.did;
521
+ if (canDelete) {
522
+ if (actions) actions += '<div class="action-menu-sep"></div>';
523
+ actions += '<button class="action-menu-item danger" onclick="event.stopPropagation();deleteInvitation(\'' + escapeHtml(inv.id) + '\')">' + __t("common.delete") + '</button>';
524
+ }
525
+ var actionMenu = actions ? '<td class="action-cell"><button class="action-trigger" onclick="event.stopPropagation();toggleActionMenu(this)">⋯</button><div class="action-menu">' + actions + '</div></td>' : '<td></td>';
526
+
527
+ html += '<tr><td>' + roleBadge + '</td><td class="col-hide-sm">' + remark + '</td><td>' + uses + '</td><td>' + expireText + '</td><td class="col-hide-md">' + inviter + '</td>' + actionMenu + '</tr>';
528
+ });
529
+ html += '</tbody></table>';
530
+ wrap.innerHTML = html;
531
+
532
+ renderPagination("invitations-pagination", paging, loadInvitations);
533
+ }
534
+
535
+ async function createInvitation() {
536
+ var btn = document.getElementById("create-invite-btn");
537
+ btn.disabled = true;
538
+
539
+ var role = document.getElementById("invite-role").value;
540
+ var remark = document.getElementById("invite-remark").value.trim();
541
+ var expireHours = parseInt(document.getElementById("invite-expire").value, 10);
542
+ var maxUses = parseInt(document.getElementById("invite-max-uses").value, 10) || 1;
543
+ maxUses = Math.min(100, Math.max(1, maxUses));
544
+
545
+ var data = await api("POST", "/invitations", { role: role, remark: remark, expireHours: expireHours, maxUses: maxUses });
546
+ btn.disabled = false;
547
+
548
+ if (data.ok && data.invitation) {
549
+ closeDialog("create-invitation-dialog");
550
+ copyToClipboard(data.invitation.link);
551
+ showToast(__t("invitations.created"), "success");
552
+ // Reset form
553
+ document.getElementById("invite-remark").value = "";
554
+ document.getElementById("invite-max-uses").value = "1";
555
+ updateRemarkCounter();
556
+ loadInvitations(invitationsPage);
557
+ }
558
+ }
559
+
560
+ async function deleteInvitation(id) {
561
+ var ok = await confirmDialog({ title: __t("invitations.deleteTitle"), message: __t("invitations.deleteMsg"), confirmLabel: __t("common.delete"), danger: true });
562
+ if (!ok) return;
563
+ var data = await api("DELETE", "/invitations/" + encodeURIComponent(id));
564
+ if (data.ok) { showToast(__t("invitations.deleted"), "success"); loadInvitations(invitationsPage); }
565
+ }
566
+
567
+ // Initialize role options based on caller role
568
+ function initInviteRoleOptions() {
569
+ var select = document.getElementById("invite-role");
570
+ if (__caller.role === "owner") {
571
+ select.innerHTML = '<option value="guest">Guest</option><option value="member">Member</option><option value="admin">Admin</option>';
572
+ }
573
+ }
574
+
575
+ registerTab("invitations", function() {
576
+ initInviteRoleOptions();
577
+ loadInvitations(1);
578
+ });
579
+
580
+ ;
581
+
582
+ var auditPage = 1;
583
+ var auditPageSize = 50;
584
+
585
+ var AUDIT_ACTION_I18N = {
586
+ "login": "audit.actions.login",
587
+ "user.register": "audit.actions.register",
588
+ "user.login": "audit.actions.login",
589
+ "user.accept_invitation": "audit.actions.acceptInvitation",
590
+ "user.remove": "audit.actions.removeMember",
591
+ "user.block": "audit.actions.blockMember",
592
+ "user.unblock": "audit.actions.unblockMember",
593
+ "user.role_change": "audit.actions.roleChange",
594
+ "user.transfer_ownership": "audit.actions.transferOwnership",
595
+ "user.profile_update": "audit.actions.profileUpdate",
596
+ "user.avatar_update": "audit.actions.avatarUpdate",
597
+ "user.avatar_delete": "audit.actions.avatarDelete",
598
+ "user.email_change": "audit.actions.emailChange",
599
+ "user.register_blocked": "audit.actions.registerBlocked",
600
+ "invitation.create": "audit.actions.createInvitation",
601
+ "invitation.delete": "audit.actions.deleteInvitation",
602
+ "oauth.bind": "audit.actions.oauthBind",
603
+ "oauth.unbind": "audit.actions.oauthUnbind",
604
+ "accessKey.create": "audit.actions.accessKeyCreate",
605
+ "accessKey.update": "audit.actions.accessKeyUpdate",
606
+ "accessKey.delete": "audit.actions.accessKeyDelete",
607
+ "membership.create": "audit.actions.membershipCreate",
608
+ "membership.update_role": "audit.actions.membershipUpdateRole",
609
+ "membership.remove": "audit.actions.membershipRemove",
610
+ "passkey.connect": "audit.actions.passkeyConnect",
611
+ "passkey.disconnect": "audit.actions.passkeyDisconnect",
612
+ "access_policy.create": "audit.actions.accessPolicyCreate",
613
+ "access_policy.update": "audit.actions.accessPolicyUpdate",
614
+ "access_policy.delete": "audit.actions.accessPolicyDelete",
615
+ "security_rule.create": "audit.actions.securityRuleCreate",
616
+ "security_rule.update": "audit.actions.securityRuleUpdate",
617
+ "security_rule.delete": "audit.actions.securityRuleDelete",
618
+ "settings.oauth_update": "audit.actions.settingsOauthUpdate",
619
+ "settings.oauth_delete": "audit.actions.settingsOauthDelete",
620
+ "settings.builtin_providers_update": "audit.actions.settingsBuiltinProvidersUpdate",
621
+ "settings.session_update": "audit.actions.settingsSessionUpdate",
622
+ "settings.email_update": "audit.actions.settingsEmailUpdate",
623
+ };
624
+
625
+ function translateAction(action) {
626
+ var key = AUDIT_ACTION_I18N[action];
627
+ return key ? __t(key) : action;
628
+ }
629
+
630
+ var _ICONS = {
631
+ login: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>',
632
+ register: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>',
633
+ block: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>',
634
+ unblock: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>',
635
+ remove: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="22" y1="11" x2="16" y2="11"/></svg>',
636
+ trash: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>',
637
+ edit: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>',
638
+ create: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/><line x1="8" y1="12" x2="16" y2="12"/></svg>',
639
+ key: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>',
640
+ link: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>',
641
+ unlink: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/><line x1="2" y1="2" x2="22" y2="22"/></svg>',
642
+ mail: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>',
643
+ settings: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>',
644
+ shield: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>',
645
+ lock: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>',
646
+ image: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>',
647
+ at: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.92 7.94"/></svg>',
648
+ user: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>',
649
+ crown: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4l4.5 9L12 4l5.5 9L22 4l-3 13H5L2 4z"/><line x1="5" y1="17" x2="19" y2="17"/></svg>',
650
+ role: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 16V4m0 0L3 8m4-4 4 4"/><path d="M17 8v12m0 0 4-4m-4 4-4-4"/></svg>',
651
+ users: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
652
+ };
653
+
654
+ function actionIcon(action) {
655
+ if (!action) return "";
656
+ switch (action) {
657
+ case "login":
658
+ case "user.login": return _ICONS.login;
659
+ case "user.register": return _ICONS.register;
660
+ case "user.register_blocked":
661
+ case "user.block": return _ICONS.block;
662
+ case "user.unblock": return _ICONS.unblock;
663
+ case "user.remove":
664
+ case "membership.remove": return _ICONS.remove;
665
+ case "user.role_change":
666
+ case "membership.update_role": return _ICONS.role;
667
+ case "user.transfer_ownership": return _ICONS.crown;
668
+ case "user.profile_update": return _ICONS.user;
669
+ case "user.avatar_update":
670
+ case "user.avatar_delete": return _ICONS.image;
671
+ case "user.email_change": return _ICONS.at;
672
+ case "user.accept_invitation":
673
+ case "invitation.create":
674
+ case "invitation.delete": return _ICONS.mail;
675
+ case "membership.create": return _ICONS.users;
676
+ case "oauth.bind": return _ICONS.link;
677
+ case "oauth.unbind": return _ICONS.unlink;
678
+ case "passkey.connect":
679
+ case "passkey.disconnect":
680
+ case "accessKey.create":
681
+ case "accessKey.update":
682
+ case "accessKey.delete": return _ICONS.key;
683
+ case "access_policy.create":
684
+ case "access_policy.update":
685
+ case "access_policy.delete": return _ICONS.shield;
686
+ case "security_rule.create":
687
+ case "security_rule.update":
688
+ case "security_rule.delete": return _ICONS.lock;
689
+ case "settings.oauth_update":
690
+ case "settings.oauth_delete":
691
+ case "settings.builtin_providers_update":
692
+ case "settings.session_update":
693
+ case "settings.email_update": return _ICONS.settings;
694
+ default:
695
+ if (action.indexOf("delete") !== -1) return _ICONS.trash;
696
+ if (action.indexOf("create") !== -1) return _ICONS.create;
697
+ if (action.indexOf("update") !== -1) return _ICONS.edit;
698
+ return "";
699
+ }
700
+ }
701
+
702
+ function actionBadgeClass(action) {
703
+ if (!action) return "badge-neutral";
704
+ var a = action.toLowerCase();
705
+ // Destructive: delete / remove / block / unbind / disconnect / register_blocked
706
+ if (a.indexOf("delete") !== -1 || a.indexOf("remove") !== -1 ||
707
+ a.indexOf(".block") !== -1 || a.indexOf("unbind") !== -1 ||
708
+ a.indexOf("disconnect") !== -1 || a.indexOf("register_blocked") !== -1) {
709
+ return "badge-blocked";
710
+ }
711
+ // Auth events: login / register
712
+ if (a === "login" || a === "user.login" || a === "user.register") {
713
+ return "badge-member";
714
+ }
715
+ // Restore: unblock
716
+ if (a.indexOf("unblock") !== -1) {
717
+ return "badge-admin";
718
+ }
719
+ // Modifications: update / change / role_change / transfer / settings.*
720
+ if (a.indexOf("update") !== -1 || a.indexOf("change") !== -1 ||
721
+ a.indexOf("role_change") !== -1 || a.indexOf("transfer") !== -1 ||
722
+ a.indexOf("settings.") !== -1) {
723
+ return "badge-guest";
724
+ }
725
+ // Create / bind / connect / accept / add
726
+ return "badge-owner";
727
+ }
728
+
729
+ async function loadAuditLogs(page) {
730
+ auditPage = page || 1;
731
+ var action = document.getElementById("audit-action-filter").value;
732
+ var qs = "?page=" + auditPage + "&pageSize=" + auditPageSize;
733
+ if (action) qs += "&action=" + encodeURIComponent(action);
734
+ document.getElementById("audit-table-wrap").innerHTML = skeletonTable(4, 8);
735
+ document.getElementById("audit-pagination").innerHTML = "";
736
+ var data = await api("GET", "/audit-logs" + qs);
737
+ if (!data.ok) {
738
+ document.getElementById("audit-table-wrap").innerHTML = '<div class="empty-state"><div class="empty-state-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 8v4m0 4h.01"/></svg></div><div class="empty-state-title">' + __t("audit.loadFailed") + '</div></div>';
739
+ return;
740
+ }
741
+ renderAuditLogs(data.logs, data.paging);
742
+ }
743
+
744
+ function renderAuditLogs(logs, paging) {
745
+ var wrap = document.getElementById("audit-table-wrap");
746
+ if (!logs || logs.length === 0) {
747
+ wrap.innerHTML = '<div class="empty-state"><div class="empty-state-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></div><div class="empty-state-title">' + __t("audit.noLogs") + '</div><div class="empty-state-desc">' + __t("audit.noLogsHint") + '</div></div>';
748
+ document.getElementById("audit-pagination").innerHTML = "";
749
+ return;
750
+ }
751
+
752
+ var html = '<table class="table"><thead><tr><th>' + __t("audit.col.time") + '</th><th>' + __t("audit.col.action") + '</th><th>' + __t("audit.col.operator") + '</th><th>' + __t("audit.col.target") + '</th></tr></thead><tbody>';
753
+ logs.forEach(function(log) {
754
+ var time = relativeTime(log.createdAt);
755
+ var absTime = absoluteTime(log.createdAt);
756
+ var operatorName = log.operatorName
757
+ ? escapeHtml(log.operatorName)
758
+ : (log.operatorDid ? '<did-address did="' + escapeHtml(log.operatorDid) + '" compact></did-address>' : "—");
759
+ var operatorRoleBadge = log.operatorRole
760
+ ? ' <span class="badge badge-' + escapeHtml(log.operatorRole) + '" style="font-size:10px;padding:1px 6px;">' + escapeHtml(__t("common." + log.operatorRole)) + '</span>'
761
+ : "";
762
+ var target = log.targetName
763
+ ? escapeHtml(log.targetName)
764
+ : (log.targetDid ? '<did-address did="' + escapeHtml(log.targetDid) + '" compact></did-address>' : "—");
765
+
766
+ // For register_blocked: show IP, method, reason in detail column
767
+ var detail = "";
768
+ if (log.action === "user.register_blocked") {
769
+ var meta = {};
770
+ try { meta = typeof log.metadata === "string" ? JSON.parse(log.metadata) : (log.metadata || {}); } catch(e) {}
771
+ var parts = [];
772
+ if (log.ip) parts.push('<span class="badge badge-neutral" style="font-size:10px;padding:1px 6px;">' + escapeHtml(log.ip) + '</span>');
773
+ if (meta.source) parts.push('<span style="color:var(--fg-muted);font-size:12px">' + escapeHtml(meta.source) + '</span>');
774
+ if (meta.method) parts.push('<span class="badge ' + (meta.method === "invite" ? "badge-guest" : "badge-blocked") + '" style="font-size:10px;padding:1px 6px;">' + escapeHtml(meta.method) + '</span>');
775
+ if (meta.reason) parts.push('<span style="color:var(--fg-muted);font-size:12px">' + escapeHtml(meta.reason) + '</span>');
776
+ detail = parts.length > 0 ? parts.join(" ") : "—";
777
+ }
778
+
779
+ html += '<tr><td><span class="cell-date" title="' + escapeHtml(absTime) + '">' + escapeHtml(time) + '</span></td>';
780
+ html += '<td><span class="badge ' + actionBadgeClass(log.action) + '" style="gap:3px">' + actionIcon(log.action) + escapeHtml(translateAction(log.action)) + '</span></td>';
781
+ html += '<td>' + operatorName + operatorRoleBadge + '</td>';
782
+ html += '<td>' + (detail || target) + '</td></tr>';
783
+ });
784
+ html += '</tbody></table>';
785
+ wrap.innerHTML = html;
786
+
787
+ renderPagination("audit-pagination", paging, loadAuditLogs);
788
+ }
789
+
790
+ registerTab("audit-logs", function() { loadAuditLogs(1); });
791
+
792
+ ;
793
+
794
+ // ─── Access Tab State ──────────────────────────────────────────────────────
795
+ var apPolicies = [];
796
+ var apRules = [];
797
+ var apDefaultRule = null;
798
+ var apEditingPolicyId = null;
799
+ var apEditingRuleId = null;
800
+
801
+ // ─── Icon constants ────────────────────────────────────────────────────────
802
+ var AP_ICON_EDIT = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/></svg>';
803
+ var AP_ICON_DELETE = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>';
804
+ var AP_ICON_LOCK = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
805
+
806
+ // ─── Access Type helpers ───────────────────────────────────────────────────
807
+ var AP_TYPE_LABELS = {
808
+ public: __t("access.typeLabels.public"),
809
+ invited: __t("access.typeLabels.invited"),
810
+ owner: __t("access.typeLabels.owner"),
811
+ admin: __t("access.typeLabels.admin"),
812
+ roles: __t("access.typeLabels.roles"),
813
+ roles_reverse: __t("access.typeLabels.rolesReverse")
814
+ };
815
+
816
+ var AP_TYPE_DESCS = {
817
+ public: __t("access.typeDescs.public"),
818
+ invited: __t("access.typeDescs.invited"),
819
+ admin: __t("access.typeDescs.admin"),
820
+ owner: __t("access.typeDescs.owner")
821
+ };
822
+
823
+ function apAccessTypeLabel(policy) {
824
+ if (policy.accessType === "roles" || policy.accessType === "roles_reverse") {
825
+ return AP_TYPE_LABELS[policy.accessType] + ": " + (policy.roles || []).join(", ");
826
+ }
827
+ return AP_TYPE_LABELS[policy.accessType] || policy.accessType;
828
+ }
829
+
830
+ function apIsBuiltin(policy) {
831
+ return !!policy.isProtected;
832
+ }
833
+
834
+ function apPolicyAccessType(policyId) {
835
+ var p = apPolicies.find(function(x) { return x.id === policyId; });
836
+ return p ? p.accessType : "public";
837
+ }
838
+
839
+ function apIconBtn(icon, title, onclick, variant) {
840
+ var cls = variant === "danger" ? "ap-icon-btn ap-icon-btn-danger" : "ap-icon-btn";
841
+ return '<button class="' + cls + '" title="' + escapeHtml(title) + '" onclick="event.stopPropagation();' + onclick + '">' + icon + '</button>';
842
+ }
843
+
844
+ // ─── Load Data ─────────────────────────────────────────────────────────────
845
+ async function apLoadAll() {
846
+ document.getElementById("ap-policies-wrap").innerHTML = skeletonTable(3, 3);
847
+ document.getElementById("ap-rules-wrap").innerHTML = skeletonTable(5, 2);
848
+
849
+ var pRes = await api("GET", "/access-policies");
850
+ if (pRes.ok) apPolicies = pRes.policies;
851
+
852
+ var rRes = await api("GET", "/security-rules");
853
+ if (rRes.ok) apRules = rRes.rules;
854
+
855
+ // Find the default rule
856
+ apDefaultRule = apRules.find(function(r) { return r.id.indexOf("default") === 0; }) || null;
857
+
858
+ apRenderPolicies();
859
+ apRenderRules();
860
+ }
861
+
862
+ // ─── Render All Policies (built-in + custom in one table) ─────────────────
863
+ function apRenderPolicies() {
864
+ var wrap = document.getElementById("ap-policies-wrap");
865
+
866
+ if (!apPolicies.length) {
867
+ wrap.innerHTML = '<div class="empty-state" style="padding:16px"><div class="empty-state-title">' + __t("access.noPolicies") + '</div></div>';
868
+ return;
869
+ }
870
+
871
+ // Built-in first, then custom
872
+ var builtins = apPolicies.filter(function(p) { return apIsBuiltin(p); });
873
+ var customs = apPolicies.filter(function(p) { return !apIsBuiltin(p); });
874
+ var sorted = builtins.concat(customs);
875
+
876
+ var html = '<table class="table"><thead><tr>'
877
+ + '<th>' + __t("access.name") + '</th>'
878
+ + '<th>' + __t("access.accessType") + '</th>'
879
+ + '<th style="width:80px;text-align:center">' + __t("common.actions") + '</th>'
880
+ + '</tr></thead><tbody>';
881
+
882
+ builtins.forEach(function(p) {
883
+ html += "<tr>";
884
+ html += '<td><span class="ap-lock-name">' + AP_ICON_LOCK + ' ' + escapeHtml(p.name) + '</span>';
885
+ if (p.description) html += '<div class="text-muted" style="font-size:12px;margin-top:2px">' + escapeHtml(p.description) + '</div>';
886
+ html += "</td>";
887
+ html += "<td>" + escapeHtml(apAccessTypeLabel(p)) + "</td>";
888
+ html += "<td></td>";
889
+ html += "</tr>";
890
+ });
891
+
892
+ if (builtins.length > 0 && customs.length > 0) {
893
+ html += '<tr class="ap-separator"><td colspan="3"></td></tr>';
894
+ }
895
+
896
+ customs.forEach(function(p) {
897
+ html += "<tr>";
898
+ html += "<td>" + escapeHtml(p.name);
899
+ if (p.description) html += '<div class="text-muted" style="font-size:12px;margin-top:2px">' + escapeHtml(p.description) + '</div>';
900
+ html += "</td>";
901
+ html += "<td>" + escapeHtml(apAccessTypeLabel(p)) + "</td>";
902
+ html += '<td class="ap-actions">';
903
+ html += apIconBtn(AP_ICON_EDIT, __t("common.edit"), "openPolicyDialog('edit','" + escapeHtml(p.id) + "')");
904
+ html += apIconBtn(AP_ICON_DELETE, __t("common.delete"), "apDeletePolicy('" + escapeHtml(p.id) + "')", "danger");
905
+ html += "</td></tr>";
906
+ });
907
+
908
+ html += "</tbody></table>";
909
+ wrap.innerHTML = html;
910
+ }
911
+
912
+ // ─── Render Path Rules ────────────────────────────────────────────────────
913
+ function apRenderRules() {
914
+ var wrap = document.getElementById("ap-rules-wrap");
915
+ var nonDefaultRules = apRules.filter(function(r) { return r.id.indexOf("default") !== 0; });
916
+
917
+ // Sort non-default rules by priority ascending
918
+ nonDefaultRules.sort(function(a, b) { return a.priority - b.priority; });
919
+
920
+ var html = '<table class="table"><thead><tr>'
921
+ + '<th style="width:80px">#</th>'
922
+ + '<th>' + __t("access.urlPattern") + '</th>'
923
+ + '<th>' + __t("access.accessPolicy") + '</th>'
924
+ + '<th style="width:80px">' + __t("common.status") + '</th>'
925
+ + '<th style="width:80px;text-align:center">' + __t("common.actions") + '</th>'
926
+ + '</tr></thead><tbody>';
927
+
928
+ // Default rule — first row, special style
929
+ if (apDefaultRule) {
930
+ var policyOptions = apPolicies.map(function(p) {
931
+ var label = escapeHtml(p.name) + ' \u2014 ' + escapeHtml(apAccessTypeLabel(p));
932
+ var selected = p.id === apDefaultRule.accessPolicyId ? ' selected' : '';
933
+ return '<option value="' + escapeHtml(p.id) + '"' + selected + '>' + label + '</option>';
934
+ }).join("");
935
+
936
+ html += '<tr style="background:var(--bg-elevated)">';
937
+ html += '<td><span class="badge badge-active">' + __t("access.defaultLabel") + '</span></td>';
938
+ html += '<td><code>*</code></td>';
939
+ html += '<td><select class="select select-sm" onchange="apSaveDefaultRulePolicy(this.value)" style="max-width:280px">' + policyOptions + '</select></td>';
940
+ html += '<td></td>';
941
+ html += '<td></td>';
942
+ html += '</tr>';
943
+ }
944
+
945
+ // Empty state for non-default rules
946
+ if (nonDefaultRules.length === 0 && apDefaultRule) {
947
+ html += '<tr><td colspan="5" class="text-muted" style="text-align:center;padding:16px">' + __t("access.noRules") + '</td></tr>';
948
+ }
949
+
950
+ nonDefaultRules.forEach(function(r, i) {
951
+ html += "<tr>";
952
+ html += "<td>#" + (i + 1) + "</td>";
953
+ html += "<td><code>" + escapeHtml(r.pathPattern) + "</code></td>";
954
+ html += "<td>" + escapeHtml(r.accessPolicyName) + "</td>";
955
+ html += '<td><label class="ap-switch" title="' + (r.enabled ? __t("access.clickToDisable") : __t("access.clickToEnable")) + '"><input type="checkbox"' + (r.enabled ? ' checked' : '') + ' onchange="apToggleRule(&#39;' + escapeHtml(r.id) + '&#39;,' + (r.enabled ? 0 : 1) + ')"><span class="ap-switch-slider"></span></label></td>';
956
+ html += '<td class="ap-actions">';
957
+ html += apIconBtn(AP_ICON_EDIT, __t("common.edit"), "openRuleDialog('edit','" + escapeHtml(r.id) + "')");
958
+ html += apIconBtn(AP_ICON_DELETE, __t("common.delete"), "apDeleteRule('" + escapeHtml(r.id) + "')", "danger");
959
+ html += "</td></tr>";
960
+ });
961
+
962
+ html += "</tbody></table>";
963
+ wrap.innerHTML = html;
964
+ }
965
+
966
+ async function apSaveDefaultRulePolicy(policyId) {
967
+ if (!apDefaultRule) return;
968
+ var res = await api("PUT", "/security-rules/" + apDefaultRule.id, {
969
+ accessPolicyId: policyId,
970
+ remark: apDefaultRule.remark || "Default fallback rule"
971
+ });
972
+ if (res.ok) {
973
+ showToast(__t("access.ruleSaved"), "success");
974
+ apDefaultRule.accessPolicyId = policyId;
975
+ }
976
+ }
977
+
978
+ async function apToggleRule(id, enabled) {
979
+ var r = apRules.find(function(x) { return x.id === id; });
980
+ if (!r) return;
981
+ var res = await api("PUT", "/security-rules/" + id, {
982
+ accessPolicyId: r.accessPolicyId,
983
+ pathPattern: r.pathPattern,
984
+ priority: r.priority,
985
+ enabled: enabled,
986
+ remark: r.remark || ""
987
+ });
988
+ if (res.ok) {
989
+ r.enabled = enabled;
990
+ apRenderRules();
991
+ showToast(__t("access.ruleSaved"), "success");
992
+ }
993
+ }
994
+
995
+ // ─── Policy Dialog (custom only) ──────────────────────────────────────────
996
+ function openPolicyDialog(mode, id) {
997
+ apEditingPolicyId = (mode === "edit") ? id : null;
998
+ document.getElementById("ap-policy-dialog-title").textContent = mode === "edit" ? __t("access.editPolicyTitle") : __t("access.policyTitle");
999
+
1000
+ if (mode === "edit") {
1001
+ var p = apPolicies.find(function(x) { return x.id === id; });
1002
+ if (!p) return;
1003
+ document.getElementById("ap-policy-name").value = p.name;
1004
+ document.getElementById("ap-policy-type").value = p.accessType || "roles";
1005
+ document.getElementById("ap-policy-desc").value = p.description || "";
1006
+ document.querySelectorAll(".ap-role-cb").forEach(function(cb) {
1007
+ cb.checked = (p.roles || []).includes(cb.value);
1008
+ });
1009
+ } else {
1010
+ document.getElementById("ap-policy-name").value = "";
1011
+ document.getElementById("ap-policy-type").value = "roles";
1012
+ document.getElementById("ap-policy-desc").value = "";
1013
+ document.querySelectorAll(".ap-role-cb").forEach(function(cb) { cb.checked = false; });
1014
+ }
1015
+ showDialog("ap-policy-dialog");
1016
+ }
1017
+
1018
+ async function apSavePolicy() {
1019
+ var body = {
1020
+ name: document.getElementById("ap-policy-name").value.trim(),
1021
+ accessType: document.getElementById("ap-policy-type").value,
1022
+ description: document.getElementById("ap-policy-desc").value.trim(),
1023
+ };
1024
+ body.roles = [];
1025
+ document.querySelectorAll(".ap-role-cb:checked").forEach(function(cb) { body.roles.push(cb.value); });
1026
+ if (body.roles.length === 0) { showToast(__t("access.roleRequired"), "error"); return; }
1027
+ if (!body.name) { showToast(__t("access.nameRequired"), "error"); return; }
1028
+
1029
+ var res;
1030
+ if (apEditingPolicyId) {
1031
+ res = await api("PUT", "/access-policies/" + apEditingPolicyId, body);
1032
+ } else {
1033
+ res = await api("POST", "/access-policies", body);
1034
+ }
1035
+ if (res.ok) {
1036
+ showToast(__t("access.policySaved"), "success");
1037
+ closeDialog("ap-policy-dialog");
1038
+ apLoadAll();
1039
+ }
1040
+ }
1041
+
1042
+ async function apDeletePolicy(id) {
1043
+ var ok = await confirmDialog({ title: __t("access.deletePolicy"), message: __t("access.deletePolicyMsg"), danger: true });
1044
+ if (!ok) return;
1045
+ var res = await api("DELETE", "/access-policies/" + id);
1046
+ if (res.ok) { showToast(__t("access.policyDeleted"), "success"); apLoadAll(); }
1047
+ }
1048
+
1049
+ // ─── Rule Dialog ───────────────────────────────────────────────────────────
1050
+ function openRuleDialog(mode, id) {
1051
+ apEditingRuleId = (mode === "edit") ? id : null;
1052
+ document.getElementById("ap-rule-dialog-title").textContent = mode === "edit" ? __t("access.editRuleTitle") : __t("access.ruleTitle");
1053
+
1054
+ // Populate policy dropdown with all policies
1055
+ var sel = document.getElementById("ap-rule-policy");
1056
+ sel.innerHTML = apPolicies.map(function(p) {
1057
+ return '<option value="' + escapeHtml(p.id) + '">' + escapeHtml(p.name) + ' \u2014 ' + escapeHtml(apAccessTypeLabel(p)) + '</option>';
1058
+ }).join("");
1059
+
1060
+ document.getElementById("ap-rule-pattern").disabled = false;
1061
+ document.getElementById("ap-rule-priority").disabled = false;
1062
+
1063
+ if (mode === "edit") {
1064
+ var r = apRules.find(function(x) { return x.id === id; });
1065
+ if (!r) return;
1066
+ document.getElementById("ap-rule-pattern").value = r.pathPattern;
1067
+ document.getElementById("ap-rule-priority").value = r.priority;
1068
+ document.getElementById("ap-rule-remark").value = r.remark || "";
1069
+ sel.value = r.accessPolicyId;
1070
+ } else {
1071
+ document.getElementById("ap-rule-pattern").value = "";
1072
+ document.getElementById("ap-rule-priority").value = "0";
1073
+ document.getElementById("ap-rule-remark").value = "";
1074
+ }
1075
+ showDialog("ap-rule-dialog");
1076
+ }
1077
+
1078
+ async function apSaveRule() {
1079
+ var body = {
1080
+ accessPolicyId: document.getElementById("ap-rule-policy").value,
1081
+ remark: document.getElementById("ap-rule-remark").value.trim(),
1082
+ pathPattern: document.getElementById("ap-rule-pattern").value.trim(),
1083
+ priority: parseInt(document.getElementById("ap-rule-priority").value, 10) || 0,
1084
+ };
1085
+ if (!body.pathPattern) { showToast(__t("access.patternRequired"), "error"); return; }
1086
+
1087
+ var res;
1088
+ if (apEditingRuleId) {
1089
+ res = await api("PUT", "/security-rules/" + apEditingRuleId, body);
1090
+ } else {
1091
+ res = await api("POST", "/security-rules", body);
1092
+ }
1093
+ if (res.ok) {
1094
+ showToast(__t("access.ruleSaved"), "success");
1095
+ closeDialog("ap-rule-dialog");
1096
+ apLoadAll();
1097
+ }
1098
+ }
1099
+
1100
+ async function apDeleteRule(id) {
1101
+ var ok = await confirmDialog({ title: __t("access.deleteRule"), message: __t("access.deleteRuleMsg"), danger: true });
1102
+ if (!ok) return;
1103
+ var res = await api("DELETE", "/security-rules/" + id);
1104
+ if (res.ok) { showToast(__t("access.ruleDeleted"), "success"); apLoadAll(); }
1105
+ }
1106
+
1107
+ // ─── Register Tab ──────────────────────────────────────────────────────────
1108
+ registerTab("access", function() { apLoadAll(); });
1109
+
1110
+ ;
1111
+
1112
+ // ─── Crop aspect ratios per logo type ──────────────────────────
1113
+ var CROP_RATIOS = {
1114
+ 'square': 1, 'square-dark': 1, 'favicon': 1,
1115
+ 'rect': NaN, 'rect-dark': NaN,
1116
+ 'splash-portrait': 9/16, 'splash-landscape': 16/9,
1117
+ 'og-image': 40/21
1118
+ };
1119
+ // Target dimensions and output format per logo type
1120
+ var CROP_OUTPUT = {
1121
+ 'square': { maxW: 512, maxH: 512, fmt: 'image/webp', q: 0.9, ext: 'webp' },
1122
+ 'square-dark': { maxW: 512, maxH: 512, fmt: 'image/webp', q: 0.9, ext: 'webp' },
1123
+ 'favicon': { maxW: 128, maxH: 128, fmt: 'image/png', q: 1, ext: 'png' },
1124
+ 'rect': { maxW: 800, maxH: 400, fmt: 'image/webp', q: 0.9, ext: 'webp' },
1125
+ 'rect-dark': { maxW: 800, maxH: 400, fmt: 'image/webp', q: 0.9, ext: 'webp' },
1126
+ 'og-image': { maxW: 1200, maxH: 630, fmt: 'image/jpeg', q: 0.85, ext: 'jpg' },
1127
+ 'splash-portrait': { maxW: 1080, maxH: 1920, fmt: 'image/webp', q: 0.85, ext: 'webp' },
1128
+ 'splash-landscape': { maxW: 1920, maxH: 1080, fmt: 'image/webp', q: 0.85, ext: 'webp' }
1129
+ };
1130
+ var MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
1131
+ var ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
1132
+
1133
+ // ─── Lazy-load cropperjs from CDN ──────────────────────────────
1134
+ var __cropperLoaded = false;
1135
+ function loadCropperJS() {
1136
+ if (__cropperLoaded) return Promise.resolve();
1137
+ return new Promise(function(resolve, reject) {
1138
+ var link = document.createElement('link');
1139
+ link.rel = 'stylesheet';
1140
+ link.href = 'https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.css';
1141
+ document.head.appendChild(link);
1142
+ var script = document.createElement('script');
1143
+ script.src = 'https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.6.2/cropper.min.js';
1144
+ script.onload = function() { __cropperLoaded = true; resolve(); };
1145
+ script.onerror = function() { reject(new Error('CDN load failed')); };
1146
+ document.head.appendChild(script);
1147
+ });
1148
+ }
1149
+
1150
+ // ─── Cropper state ─────────────────────────────────────────────
1151
+ var __cropper = null;
1152
+ var __cropType = null;
1153
+
1154
+ // ─── Handle file selection (entry point from branding tab) ─────
1155
+ function handleLogoFile(type, input) {
1156
+ var file = input.files && input.files[0];
1157
+ input.value = ''; // reset so same file can be re-selected
1158
+ if (!file) return;
1159
+
1160
+ // Validate file size
1161
+ if (file.size > MAX_FILE_SIZE) {
1162
+ showToast(__t('branding.fileTooLarge'), 'error');
1163
+ return;
1164
+ }
1165
+
1166
+ // Validate file type
1167
+ if (ALLOWED_TYPES.indexOf(file.type) === -1) {
1168
+ showToast(__t('branding.invalidFormat'), 'error');
1169
+ return;
1170
+ }
1171
+
1172
+ // SVG: skip cropper, upload directly
1173
+ if (file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')) {
1174
+ doUploadLogo(type, file);
1175
+ return;
1176
+ }
1177
+
1178
+ // Open cropper
1179
+ openCropper(type, file);
1180
+ }
1181
+
1182
+ function openCropper(type, file) {
1183
+ __cropType = type;
1184
+
1185
+ loadCropperJS().then(function() {
1186
+ var reader = new FileReader();
1187
+ reader.onload = function(e) {
1188
+ var dialog = document.getElementById('crop-dialog');
1189
+ var img = document.getElementById('crop-image');
1190
+ if (!dialog || !img) return;
1191
+
1192
+ img.src = e.target.result;
1193
+
1194
+ // Destroy previous cropper instance if any
1195
+ if (__cropper) { __cropper.destroy(); __cropper = null; }
1196
+
1197
+ dialog.showModal();
1198
+
1199
+ // Initialize cropperjs after dialog is visible (needs layout)
1200
+ setTimeout(function() {
1201
+ var ratio = CROP_RATIOS[type];
1202
+ __cropper = new Cropper(img, {
1203
+ aspectRatio: isNaN(ratio) ? NaN : ratio,
1204
+ viewMode: 1,
1205
+ autoCrop: true,
1206
+ autoCropArea: 1,
1207
+ responsive: true,
1208
+ background: false,
1209
+ guides: true,
1210
+ center: true,
1211
+ highlight: true,
1212
+ cropBoxMovable: true,
1213
+ cropBoxResizable: true,
1214
+ toggleDragModeOnDblclick: false,
1215
+ });
1216
+ }, 100);
1217
+ };
1218
+ reader.readAsDataURL(file);
1219
+ }).catch(function(err) {
1220
+ // CDN load failed — fallback to direct upload
1221
+ console.warn('Cropper load failed:', err);
1222
+ showToast(__t('branding.cropperLoadFailed'), 'error');
1223
+ doUploadLogo(type, file);
1224
+ });
1225
+ }
1226
+
1227
+ function confirmCrop() {
1228
+ if (!__cropper || !__cropType) return;
1229
+ var output = CROP_OUTPUT[__cropType] || { maxW: 512, maxH: 512, fmt: 'image/webp', q: 0.9, ext: 'webp' };
1230
+
1231
+ // Get cropped canvas at target dimensions (resize during crop for best quality)
1232
+ var canvas = __cropper.getCroppedCanvas({
1233
+ maxWidth: output.maxW,
1234
+ maxHeight: output.maxH,
1235
+ imageSmoothingEnabled: true,
1236
+ imageSmoothingQuality: 'high',
1237
+ });
1238
+ if (!canvas) {
1239
+ showToast(__t('branding.uploadError'), 'error');
1240
+ closeCropDialog();
1241
+ return;
1242
+ }
1243
+
1244
+ // Show uploading state in dialog
1245
+ var actions = document.querySelector('#crop-dialog .dialog-actions');
1246
+ var content = document.querySelector('#crop-dialog .crop-toolbar');
1247
+ if (actions) actions.innerHTML = '<div style="display:flex;align-items:center;gap:12px;width:100%;justify-content:center;"><div class="crop-progress" style="flex:1;max-width:200px;"><div class="crop-progress-bar"></div></div><span style="font-size:13px;color:var(--text-secondary);">' + __t('common.upload') + '...</span></div>';
1248
+ if (content) content.style.opacity = '0.5';
1249
+
1250
+ canvas.toBlob(function(blob) {
1251
+ if (!blob) {
1252
+ showToast(__t('branding.uploadError'), 'error');
1253
+ closeCropDialog();
1254
+ return;
1255
+ }
1256
+ var file = new File([blob], __cropType + '.' + output.ext, { type: output.fmt });
1257
+ doUploadLogo(__cropType, file).then(function() {
1258
+ closeCropDialog();
1259
+ }).catch(function() {
1260
+ closeCropDialog();
1261
+ });
1262
+ }, output.fmt, output.q);
1263
+ }
1264
+
1265
+ function cancelCrop() {
1266
+ closeCropDialog();
1267
+ }
1268
+
1269
+ function closeCropDialog() {
1270
+ var dialog = document.getElementById('crop-dialog');
1271
+ if (dialog && dialog.open) dialog.close();
1272
+ if (__cropper) { __cropper.destroy(); __cropper = null; }
1273
+ __cropType = null;
1274
+ // Restore dialog actions and toolbar to initial state
1275
+ var actions = document.querySelector('#crop-dialog .dialog-actions');
1276
+ if (actions) actions.innerHTML = '<button class="btn btn-secondary" onclick="cancelCrop()">' + __t('branding.cropCancel') + '</button><button class="btn" onclick="confirmCrop()">' + __t('branding.cropConfirm') + '</button>';
1277
+ var toolbar = document.querySelector('#crop-dialog .crop-toolbar');
1278
+ if (toolbar) toolbar.style.opacity = '';
1279
+ }
1280
+
1281
+ // ─── Toolbar actions ───────────────────────────────────────────
1282
+ function cropZoomIn() { if (__cropper) __cropper.zoom(0.1); }
1283
+ function cropZoomOut() { if (__cropper) __cropper.zoom(-0.1); }
1284
+ function cropRotateLeft() { if (__cropper) __cropper.rotate(-90); }
1285
+ function cropRotateRight() { if (__cropper) __cropper.rotate(90); }
1286
+ function cropFlipH() {
1287
+ if (!__cropper) return;
1288
+ var d = __cropper.getData();
1289
+ __cropper.scaleX(d.scaleX === -1 ? 1 : -1);
1290
+ }
1291
+ function cropFlipV() {
1292
+ if (!__cropper) return;
1293
+ var d = __cropper.getData();
1294
+ __cropper.scaleY(d.scaleY === -1 ? 1 : -1);
1295
+ }
1296
+
1297
+ ;
1298
+
1299
+ // ─── Branding Tab ────────────────────────────────────────────────────
1300
+
1301
+ var __brandingApiBase = "/.well-known/service/api";
1302
+
1303
+ async function brandingApi(method, path, body) {
1304
+ try {
1305
+ var opts = { method: method, headers: {} };
1306
+ if (body !== undefined) {
1307
+ opts.headers["Content-Type"] = "application/json";
1308
+ opts.body = JSON.stringify(body);
1309
+ }
1310
+ var res = await fetch(__brandingApiBase + path, opts);
1311
+ if (!res.ok) {
1312
+ var data = await res.json().catch(function() { return {}; });
1313
+ showToast(data.error || __t("common.requestFailed"), "error");
1314
+ return { ok: false, error: data.error };
1315
+ }
1316
+ return await res.json();
1317
+ } catch (err) {
1318
+ showToast(__t("common.networkError"), "error");
1319
+ return { ok: false, error: err.message };
1320
+ }
1321
+ }
1322
+
1323
+ var __brandingData = {};
1324
+ var __brandingDirty = false;
1325
+ var __pendingLogos = {}; // { camelType: r2Key } for new uploads, { camelType: null } for deletions
1326
+
1327
+ function markBrandingDirty() {
1328
+ __brandingDirty = true;
1329
+ var bar = document.getElementById("br-save-bar");
1330
+ if (bar) bar.classList.add("visible");
1331
+ }
1332
+
1333
+ function hideSaveBar() {
1334
+ __brandingDirty = false;
1335
+ var bar = document.getElementById("br-save-bar");
1336
+ if (bar) bar.classList.remove("visible");
1337
+ }
1338
+
1339
+ async function discardBrandingChanges() {
1340
+ __pendingLogos = {};
1341
+ await loadBranding();
1342
+ }
1343
+
1344
+ async function loadBranding() {
1345
+ var data = await brandingApi("GET", "/branding");
1346
+ if (!data) return;
1347
+ __brandingData = data;
1348
+
1349
+ // App Info
1350
+ var br = data.branding || {};
1351
+ document.getElementById("br-name").value = br.name || "";
1352
+ document.getElementById("br-desc").value = br.description || "";
1353
+ document.getElementById("br-url").value = br.url || "";
1354
+ var pc = br.passportColor || "#1DC1C7";
1355
+ document.getElementById("br-passport-color").value = pc;
1356
+ document.getElementById("br-copyright-owner").value = (br.copyright && br.copyright.owner) || "";
1357
+ document.getElementById("br-copyright-year").value = (br.copyright && br.copyright.year) || "";
1358
+
1359
+ // Accent color
1360
+ updateAccentUI(pc);
1361
+
1362
+ // Logos
1363
+ var logos = data.logos || {};
1364
+ var LOGO_URLS = { "square": "/logo", "square-dark": "/logo-dark", "rect": "/logo-rect", "rect-dark": "/logo-rect-dark", "favicon": "/logo-favicon", "og-image": "/og-image", "splash-portrait": "/splash/portrait", "splash-landscape": "/splash/landscape" };
1365
+ [{"type":"square","nameKey":"branding.logoAppLight","subKey":"branding.subLight","icon":"<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"12\" cy=\"12\" r=\"5\"/><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/></svg>","spec":"512×512"},{"type":"square-dark","nameKey":"branding.logoAppDark","subKey":"branding.subDark","icon":"<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/></svg>","spec":"512×512"},{"type":"rect","nameKey":"branding.logoWebLight","subKey":"branding.subLight","icon":"<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"12\" cy=\"12\" r=\"5\"/><line x1=\"12\" y1=\"1\" x2=\"12\" y2=\"3\"/><line x1=\"12\" y1=\"21\" x2=\"12\" y2=\"23\"/><line x1=\"4.22\" y1=\"4.22\" x2=\"5.64\" y2=\"5.64\"/><line x1=\"18.36\" y1=\"18.36\" x2=\"19.78\" y2=\"19.78\"/><line x1=\"1\" y1=\"12\" x2=\"3\" y2=\"12\"/><line x1=\"21\" y1=\"12\" x2=\"23\" y2=\"12\"/><line x1=\"4.22\" y1=\"19.78\" x2=\"5.64\" y2=\"18.36\"/><line x1=\"18.36\" y1=\"5.64\" x2=\"19.78\" y2=\"4.22\"/></svg>","spec":"SVG/PNG"},{"type":"rect-dark","nameKey":"branding.logoWebDark","subKey":"branding.subDark","icon":"<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z\"/></svg>","spec":"SVG/PNG"},{"type":"favicon","nameKey":"branding.logoFavicon","subKey":"branding.subBrowser","icon":"<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><path d=\"M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"/></svg>","spec":"32×32 ICO"},{"type":"splash-portrait","nameKey":"branding.logoSplashP","subKey":"branding.subPortrait","icon":"<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"5\" y=\"2\" width=\"14\" height=\"20\" rx=\"2\"/><circle cx=\"12\" cy=\"18\" r=\"1\" fill=\"currentColor\" stroke=\"none\"/></svg>","spec":"9:16"},{"type":"splash-landscape","nameKey":"branding.logoSplashL","subKey":"branding.subLandscape","icon":"<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"/><circle cx=\"18\" cy=\"12\" r=\"1\" fill=\"currentColor\" stroke=\"none\"/></svg>","spec":"16:9"},{"type":"og-image","nameKey":"branding.logoOg","subKey":"branding.subSocial","icon":"<svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"><circle cx=\"12\" cy=\"12\" r=\"10\"/><line x1=\"2\" y1=\"12\" x2=\"22\" y2=\"12\"/><path d=\"M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z\"/></svg>","spec":"1200×630"}].forEach(function(l) {
1366
+ var key = l.type.replace(/-([a-z])/g, function(_, c) { return c.toUpperCase(); });
1367
+ var preview = document.getElementById("br-logo-preview-" + l.type);
1368
+ var actions = document.getElementById("br-logo-actions-" + l.type);
1369
+ var item = document.getElementById("br-logo-" + l.type);
1370
+ // Remove any existing uploaded overlay
1371
+ var oldOverlay = item && item.querySelector(".logo-uploaded-overlay");
1372
+ if (oldOverlay) oldOverlay.remove();
1373
+
1374
+ if (logos[key]) {
1375
+ var url = "/.well-known/service/blocklet" + (LOGO_URLS[l.type] || "/logo");
1376
+ preview.innerHTML = '<img src="' + url + '?t=' + Date.now() + '" />';
1377
+ // Add hover overlay with Replace + Discard buttons
1378
+ if (item) {
1379
+ var overlay = document.createElement("div");
1380
+ overlay.className = "logo-uploaded-overlay";
1381
+ // Build with DOM to avoid escaping issues
1382
+ var replaceLabel = document.createElement("label");
1383
+ replaceLabel.className = "logo-overlay-btn logo-overlay-btn-replace";
1384
+ replaceLabel.textContent = __t("branding.replace");
1385
+ var replaceInput = document.createElement("input");
1386
+ replaceInput.type = "file";
1387
+ replaceInput.accept = "image/png,image/jpeg,image/webp,image/svg+xml";
1388
+ replaceInput.style.display = "none";
1389
+ (function(logoType) {
1390
+ replaceInput.onchange = function() { handleLogoFile(logoType, replaceInput); };
1391
+ })(l.type);
1392
+ replaceLabel.appendChild(replaceInput);
1393
+ var discardBtn = document.createElement("button");
1394
+ discardBtn.className = "logo-overlay-btn logo-overlay-btn-discard";
1395
+ discardBtn.textContent = __t("branding.removeLogo");
1396
+ (function(logoType) {
1397
+ discardBtn.onclick = function() { deleteLogo(logoType); };
1398
+ })(l.type);
1399
+ overlay.appendChild(replaceLabel);
1400
+ overlay.appendChild(discardBtn);
1401
+ item.appendChild(overlay);
1402
+ }
1403
+ } else {
1404
+ preview.innerHTML = '';
1405
+ }
1406
+ });
1407
+
1408
+ // Languages
1409
+ var langs = data.languages || [];
1410
+ var codes = langs.map(function(l) { return l.code; });
1411
+ document.querySelectorAll(".lang-toggle").forEach(function(btn) {
1412
+ if (codes.indexOf(btn.dataset.lang) !== -1) btn.classList.add("active");
1413
+ else btn.classList.remove("active");
1414
+ });
1415
+
1416
+ // Apple App IDs
1417
+ var appleInput = document.getElementById("br-apple-app-ids");
1418
+ if (appleInput) {
1419
+ var appleIds = data.appleAppIds || [];
1420
+ appleInput.value = Array.isArray(appleIds) ? appleIds.join(", ") : "";
1421
+ }
1422
+
1423
+ hideSaveBar();
1424
+ }
1425
+
1426
+ // Language toggle — changes are saved with the main Save Configuration button
1427
+ function toggleLang(code) {
1428
+ var btn = document.querySelector('.lang-toggle[data-lang="' + code + '"]');
1429
+ if (!btn) return;
1430
+ // If deselecting, ensure at least one language remains active
1431
+ if (btn.classList.contains("active")) {
1432
+ var activeCount = document.querySelectorAll(".lang-toggle.active").length;
1433
+ if (activeCount <= 1) {
1434
+ showToast("At least one language is required", "error");
1435
+ return;
1436
+ }
1437
+ }
1438
+ btn.classList.toggle("active");
1439
+ markBrandingDirty();
1440
+ }
1441
+
1442
+ // Accent color (compact UI)
1443
+ function updateAccentUI(color) {
1444
+ var picker = document.getElementById("br-accent-picker");
1445
+ var hex = document.getElementById("br-accent-hex");
1446
+ var hidden = document.getElementById("br-passport-color");
1447
+ if (picker) picker.value = color;
1448
+ if (hex) hex.value = color;
1449
+ if (hidden) hidden.value = color;
1450
+ document.querySelectorAll(".accent-dot").forEach(function(d) {
1451
+ if (d.dataset.color === color) d.classList.add("selected");
1452
+ else d.classList.remove("selected");
1453
+ });
1454
+ }
1455
+
1456
+ function onAccentInput(color) {
1457
+ if (!color || !color.match(/^#[0-9a-fA-F]{6}$/)) return;
1458
+ updateAccentUI(color);
1459
+ markBrandingDirty();
1460
+ }
1461
+
1462
+ function pickAccent(color) {
1463
+ updateAccentUI(color);
1464
+ markBrandingDirty();
1465
+ }
1466
+
1467
+ async function saveBrandingInfo() {
1468
+ var copyright = {};
1469
+ var owner = document.getElementById("br-copyright-owner").value.trim();
1470
+ var year = document.getElementById("br-copyright-year").value.trim();
1471
+ if (owner) copyright.owner = owner;
1472
+ if (year) copyright.year = year;
1473
+
1474
+ var body = {
1475
+ branding: {
1476
+ name: document.getElementById("br-name").value.trim(),
1477
+ description: document.getElementById("br-desc").value.trim(),
1478
+ url: document.getElementById("br-url").value.trim(),
1479
+ passportColor: document.getElementById("br-passport-color").value,
1480
+ copyright: copyright
1481
+ }
1482
+ };
1483
+
1484
+ // Also save languages
1485
+ var langs = [];
1486
+ var allLangs = [{"code":"en","name":"English"},{"code":"zh","name":"简体中文"}];
1487
+ document.querySelectorAll(".lang-toggle.active").forEach(function(btn) {
1488
+ var found = allLangs.find(function(l) { return l.code === btn.dataset.lang; });
1489
+ if (found) langs.push(found);
1490
+ });
1491
+ body.languages = langs;
1492
+
1493
+ // Include pending logo changes
1494
+ if (Object.keys(__pendingLogos).length > 0) {
1495
+ body.logos = __pendingLogos;
1496
+ }
1497
+
1498
+ // Apple App IDs
1499
+ var appleRaw = (document.getElementById("br-apple-app-ids").value || "").trim();
1500
+ if (appleRaw) {
1501
+ body.appleAppIds = appleRaw.split(",").map(function(s) { return s.trim(); }).filter(Boolean);
1502
+ } else {
1503
+ body.appleAppIds = [];
1504
+ }
1505
+
1506
+ var res = await brandingApi("PUT", "/branding", body);
1507
+ if (res && res.ok) {
1508
+ __pendingLogos = {};
1509
+ showToast(__t("branding.updated"));
1510
+ // Refresh favicon and header logo with cache-busting
1511
+ var cacheBust = "?t=" + Date.now();
1512
+ var icon = document.querySelector('link[rel="icon"]');
1513
+ if (icon) icon.href = "/.well-known/service/blocklet/logo-favicon" + cacheBust;
1514
+ var headerLogo = document.querySelector('.admin-header-left img');
1515
+ if (headerLogo) headerLogo.src = headerLogo.src.split('?')[0] + cacheBust;
1516
+ await loadBranding();
1517
+ } else {
1518
+ showToast(__t("branding.updateFailed"), "error");
1519
+ }
1520
+ }
1521
+
1522
+ async function doUploadLogo(type, file) {
1523
+ if (!file) return;
1524
+ var fd = new FormData();
1525
+ fd.append("file", file);
1526
+ try {
1527
+ var resp = await fetch(__brandingApiBase + "/branding/logo/" + type, {
1528
+ method: "POST",
1529
+ body: fd,
1530
+ credentials: "same-origin"
1531
+ });
1532
+ var data = await resp.json();
1533
+ if (data.ok && data.r2Key) {
1534
+ // Store in pending state — not saved to D1 yet
1535
+ var camelType = type.replace(/-([a-z])/g, function(_, c) { return c.toUpperCase(); });
1536
+ __pendingLogos[camelType] = data.r2Key;
1537
+ // Show preview using the logo serve endpoint (R2 key is now uploaded)
1538
+ // We need to temporarily update the logos display
1539
+ var LOGO_URLS = { "square": "/logo", "square-dark": "/logo-dark", "rect": "/logo-rect", "rect-dark": "/logo-rect-dark", "favicon": "/logo-favicon", "og-image": "/og-image", "splash-portrait": "/splash/portrait", "splash-landscape": "/splash/landscape" };
1540
+ var preview = document.getElementById("br-logo-preview-" + type);
1541
+ if (preview) {
1542
+ // Use a blob URL from the uploaded file for instant preview
1543
+ var blobUrl = URL.createObjectURL(file);
1544
+ preview.innerHTML = '<img src="' + blobUrl + '" />';
1545
+ }
1546
+ // Add uploaded overlay if not exists
1547
+ var item = document.getElementById("br-logo-" + type);
1548
+ if (item) {
1549
+ var oldOverlay = item.querySelector(".logo-uploaded-overlay");
1550
+ if (oldOverlay) oldOverlay.remove();
1551
+ var overlay = document.createElement("div");
1552
+ overlay.className = "logo-uploaded-overlay";
1553
+ var rl = document.createElement("label");
1554
+ rl.className = "logo-overlay-btn logo-overlay-btn-replace";
1555
+ rl.textContent = __t("branding.replace");
1556
+ var ri = document.createElement("input");
1557
+ ri.type = "file"; ri.accept = "image/png,image/jpeg,image/webp,image/svg+xml"; ri.style.display = "none";
1558
+ (function(t) { ri.onchange = function() { handleLogoFile(t, ri); }; })(type);
1559
+ rl.appendChild(ri);
1560
+ var db = document.createElement("button");
1561
+ db.className = "logo-overlay-btn logo-overlay-btn-discard";
1562
+ db.textContent = __t("branding.removeLogo");
1563
+ (function(t) { db.onclick = function() { deleteLogo(t); }; })(type);
1564
+ overlay.appendChild(rl);
1565
+ overlay.appendChild(db);
1566
+ item.appendChild(overlay);
1567
+ }
1568
+ showToast(__t("branding.logoUploaded"));
1569
+ markBrandingDirty();
1570
+ } else {
1571
+ showToast(data.error || "Upload failed", "error");
1572
+ }
1573
+ } catch(e) {
1574
+ showToast(__t("branding.uploadError"), "error");
1575
+ }
1576
+ }
1577
+
1578
+ async function deleteLogo(type) {
1579
+ if (!confirm(__t("branding.deleteLogo"))) return;
1580
+ var camelType = type.replace(/-([a-z])/g, function(_, c) { return c.toUpperCase(); });
1581
+ __pendingLogos[camelType] = null; // mark for deletion on save
1582
+ // Clear preview
1583
+ var preview = document.getElementById("br-logo-preview-" + type);
1584
+ if (preview) preview.innerHTML = '';
1585
+ var item = document.getElementById("br-logo-" + type);
1586
+ if (item) {
1587
+ var overlay = item.querySelector(".logo-uploaded-overlay");
1588
+ if (overlay) overlay.remove();
1589
+ }
1590
+ showToast(__t("branding.logoDeleted"));
1591
+ markBrandingDirty();
1592
+ }
1593
+
1594
+ async function saveBrandingLanguages() {
1595
+ var langs = [];
1596
+ var allLangs = [{"code":"en","name":"English"},{"code":"zh","name":"简体中文"}];
1597
+ document.querySelectorAll(".lang-toggle.active").forEach(function(btn) {
1598
+ var found = allLangs.find(function(l) { return l.code === btn.dataset.lang; });
1599
+ if (found) langs.push(found);
1600
+ });
1601
+ var res = await brandingApi("PUT", "/branding", { languages: langs });
1602
+ if (res && res.ok) showToast(__t("branding.languagesUpdated"));
1603
+ else showToast(__t("branding.langUpdateFailed"), "error");
1604
+ }
1605
+
1606
+ registerTab("branding", loadBranding);
1607
+
1608
+ ;
1609
+
1610
+ // ─── Appearance Tab ──────────────────────────────────────────────────
1611
+
1612
+ var __appearanceApiBase = "/.well-known/service/api";
1613
+
1614
+ async function appearanceApi(method, path, body) {
1615
+ try {
1616
+ var opts = { method: method, headers: {} };
1617
+ if (body !== undefined) {
1618
+ opts.headers["Content-Type"] = "application/json";
1619
+ opts.body = JSON.stringify(body);
1620
+ }
1621
+ var res = await fetch(__appearanceApiBase + path, opts);
1622
+ if (!res.ok) {
1623
+ var data = await res.json().catch(function() { return {}; });
1624
+ showToast(data.error || __t("common.requestFailed"), "error");
1625
+ return { ok: false, error: data.error };
1626
+ }
1627
+ return await res.json();
1628
+ } catch (err) {
1629
+ showToast(__t("common.networkError"), "error");
1630
+ return { ok: false, error: err.message };
1631
+ }
1632
+ }
1633
+
1634
+ var __themeData = {};
1635
+ var __themeJsonOriginal = "";
1636
+ var __navItems = [];
1637
+
1638
+ async function loadAppearance() {
1639
+ document.getElementById("ap-nav-list").innerHTML = skeletonRows(3);
1640
+ // Load theme
1641
+ var tRes = await appearanceApi("GET", "/theme");
1642
+ if (tRes) {
1643
+ __themeData = tRes.theme || {};
1644
+ populateThemeForm(__themeData);
1645
+ }
1646
+
1647
+ // Load navigation
1648
+ var nRes = await appearanceApi("GET", "/navigation");
1649
+ if (nRes) {
1650
+ __navItems = nRes.navigation || [];
1651
+ renderNavList();
1652
+ }
1653
+ }
1654
+
1655
+ function populateThemeForm(theme) {
1656
+ // Try to extract from concepts-based format
1657
+ var concept = null;
1658
+ if (theme.concepts && theme.currentConceptId) {
1659
+ concept = theme.concepts.find(function(c) { return c.id === theme.currentConceptId; });
1660
+ }
1661
+
1662
+ var prefer = concept ? (concept.prefer || "system") : (theme.prefer || "system");
1663
+ document.getElementById("ap-theme-mode").value = prefer;
1664
+
1665
+ var light = concept ? (concept.themeConfig && concept.themeConfig.light || {}) : (theme.light || {});
1666
+ var dark = concept ? (concept.themeConfig && concept.themeConfig.dark || {}) : (theme.dark || {});
1667
+
1668
+ // Light palette
1669
+ setColorSafe("ap-light-primary", light.palette && light.palette.primary && light.palette.primary.main, "#1976d2");
1670
+ setColorSafe("ap-light-secondary", light.palette && light.palette.secondary && light.palette.secondary.main, "#9c27b0");
1671
+ setColorSafe("ap-light-bg", light.palette && light.palette.background && light.palette.background.default, "#ffffff");
1672
+ setColorSafe("ap-light-text", light.palette && light.palette.text && light.palette.text.primary, "#212121");
1673
+
1674
+ // Dark palette
1675
+ setColorSafe("ap-dark-primary", dark.palette && dark.palette.primary && dark.palette.primary.main, "#90caf9");
1676
+ setColorSafe("ap-dark-secondary", dark.palette && dark.palette.secondary && dark.palette.secondary.main, "#ce93d8");
1677
+ setColorSafe("ap-dark-bg", dark.palette && dark.palette.background && dark.palette.background.default, "#121212");
1678
+ setColorSafe("ap-dark-text", dark.palette && dark.palette.text && dark.palette.text.primary, "#ffffff");
1679
+
1680
+ // Lock
1681
+ document.getElementById("ap-theme-lock").checked = !!(theme.meta && theme.meta.locked);
1682
+
1683
+ // JSON editor
1684
+ var jsonStr = JSON.stringify(theme, null, 2);
1685
+ document.getElementById("ap-theme-json").value = jsonStr;
1686
+ __themeJsonOriginal = jsonStr;
1687
+ }
1688
+
1689
+ function setColorSafe(id, value, fallback) {
1690
+ var el = document.getElementById(id);
1691
+ if (!el) return;
1692
+ // color inputs need hex format
1693
+ if (value && value.charAt(0) === "#" && (value.length === 4 || value.length === 7)) {
1694
+ el.value = value;
1695
+ } else {
1696
+ el.value = fallback;
1697
+ }
1698
+ }
1699
+
1700
+ function buildThemeFromForm() {
1701
+ var prefer = document.getElementById("ap-theme-mode").value;
1702
+ var locked = document.getElementById("ap-theme-lock").checked;
1703
+
1704
+ // Only use JSON editor if user manually modified it
1705
+ var jsonText = document.getElementById("ap-theme-json").value.trim();
1706
+ var jsonModified = jsonText && jsonText !== __themeJsonOriginal;
1707
+ if (jsonModified) {
1708
+ try {
1709
+ var parsed = JSON.parse(jsonText);
1710
+ // Add prefer and locked from form controls
1711
+ if (parsed.concepts && parsed.currentConceptId) {
1712
+ var c = parsed.concepts.find(function(c) { return c.id === parsed.currentConceptId; });
1713
+ if (c) c.prefer = prefer;
1714
+ } else {
1715
+ parsed.prefer = prefer;
1716
+ }
1717
+ parsed.meta = { locked: locked };
1718
+ return parsed;
1719
+ } catch(e) {
1720
+ showToast(__t("appearance.invalidJson"), "error");
1721
+ return null;
1722
+ }
1723
+ }
1724
+
1725
+ // Build concepts-based theme from color pickers
1726
+ var conceptId = (__themeData.currentConceptId) || "default";
1727
+ return {
1728
+ concepts: [{
1729
+ id: conceptId,
1730
+ name: "Custom",
1731
+ prefer: prefer,
1732
+ themeConfig: {
1733
+ light: {
1734
+ palette: {
1735
+ primary: { main: document.getElementById("ap-light-primary").value },
1736
+ secondary: { main: document.getElementById("ap-light-secondary").value },
1737
+ background: { default: document.getElementById("ap-light-bg").value },
1738
+ text: { primary: document.getElementById("ap-light-text").value }
1739
+ }
1740
+ },
1741
+ dark: {
1742
+ palette: {
1743
+ primary: { main: document.getElementById("ap-dark-primary").value },
1744
+ secondary: { main: document.getElementById("ap-dark-secondary").value },
1745
+ background: { default: document.getElementById("ap-dark-bg").value },
1746
+ text: { primary: document.getElementById("ap-dark-text").value }
1747
+ }
1748
+ },
1749
+ common: {}
1750
+ }
1751
+ }],
1752
+ currentConceptId: conceptId,
1753
+ meta: { locked: locked }
1754
+ };
1755
+ }
1756
+
1757
+ async function saveTheme() {
1758
+ var theme = buildThemeFromForm();
1759
+ if (!theme) return;
1760
+ var res = await appearanceApi("PUT", "/theme", { theme: theme });
1761
+ if (res && res.ok) showToast(__t("appearance.themeUpdated"));
1762
+ else showToast((res && res.error) || "Failed to update theme", "error");
1763
+ }
1764
+
1765
+ // ─── Navigation ──────────────────────────────────────────────────────
1766
+
1767
+ function renderNavList() {
1768
+ var list = document.getElementById("ap-nav-list");
1769
+ if (!list) return;
1770
+ if (__navItems.length === 0) {
1771
+ list.innerHTML = '<p style="color:var(--color-text-muted);font-size:13px;padding:8px 0;">' + __t("appearance.noNavItems") + '</p>';
1772
+ return;
1773
+ }
1774
+ list.innerHTML = __navItems.map(function(item, i) {
1775
+ return '<div class="nav-item" style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--color-border);">' +
1776
+ '<span style="min-width:24px;text-align:center;color:var(--color-text-muted);">' + (i+1) + '</span>' +
1777
+ '<span style="flex:1;font-size:14px;">' + escapeHtml(item.title || "(untitled)") + '</span>' +
1778
+ '<span style="color:var(--color-text-muted);font-size:12px;">' + escapeHtml(item.link || "") + '</span>' +
1779
+ '<button class="btn btn-sm" onclick="editNavItem(' + i + ')" style="padding:2px 8px;">' + __t("common.edit") + '</button>' +
1780
+ '<button class="btn btn-sm" onclick="moveNavItem(' + i + ',-1)" style="padding:2px 6px;" ' + (i === 0 ? "disabled" : "") + '>' + __t("appearance.up") + '</button>' +
1781
+ '<button class="btn btn-sm" onclick="moveNavItem(' + i + ',1)" style="padding:2px 6px;" ' + (i === __navItems.length - 1 ? "disabled" : "") + '>' + __t("appearance.down") + '</button>' +
1782
+ '<button class="btn btn-sm btn-danger" onclick="removeNavItem(' + i + ')" style="padding:2px 8px;">' + __t("common.delete") + '</button>' +
1783
+ '</div>';
1784
+ }).join("");
1785
+ }
1786
+
1787
+ function escapeHtml(str) {
1788
+ return String(str).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
1789
+ }
1790
+
1791
+ function addNavItem() {
1792
+ var title = prompt(__t("appearance.navTitlePrompt"));
1793
+ if (title === null) return;
1794
+ var link = prompt(__t("appearance.navLinkPrompt"), "/");
1795
+ if (link === null) return;
1796
+ __navItems.push({ title: title, link: link });
1797
+ renderNavList();
1798
+ }
1799
+
1800
+ function editNavItem(i) {
1801
+ var item = __navItems[i];
1802
+ if (!item) return;
1803
+ var title = prompt(__t("appearance.navTitlePrompt"), item.title || "");
1804
+ if (title === null) return;
1805
+ var link = prompt(__t("appearance.navLinkPrompt"), item.link || "");
1806
+ if (link === null) return;
1807
+ item.title = title;
1808
+ item.link = link;
1809
+ renderNavList();
1810
+ }
1811
+
1812
+ function removeNavItem(i) {
1813
+ if (!confirm(__t("appearance.deleteNavItem"))) return;
1814
+ __navItems.splice(i, 1);
1815
+ renderNavList();
1816
+ }
1817
+
1818
+ function moveNavItem(i, dir) {
1819
+ var j = i + dir;
1820
+ if (j < 0 || j >= __navItems.length) return;
1821
+ var tmp = __navItems[i];
1822
+ __navItems[i] = __navItems[j];
1823
+ __navItems[j] = tmp;
1824
+ renderNavList();
1825
+ }
1826
+
1827
+ async function saveNavigation() {
1828
+ var res = await appearanceApi("PUT", "/navigation", { navigation: __navItems });
1829
+ if (res && res.ok) showToast(__t("appearance.navUpdated"));
1830
+ else showToast((res && res.error) || "Failed to update navigation", "error");
1831
+ }
1832
+
1833
+ registerTab("appearance", loadAppearance);
1834
+
1835
+ ;
1836
+
1837
+ // ─── Settings tab ─────────────────────────────────────────────────
1838
+
1839
+ var __settingsData = null;
1840
+ var __oauthEditMode = false;
1841
+ var __providerMeta = {"google":{"name":"Google","icon":"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 256 262\"><path fill=\"#4285F4\" d=\"M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027\"/><path fill=\"#34A853\" d=\"M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1\"/><path fill=\"#FBBC05\" d=\"M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z\"/><path fill=\"#EB4335\" d=\"M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251\"/></svg>"},"github":{"name":"GitHub","icon":"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 256 250\" fill=\"currentColor\"><path d=\"M128.001 0C57.317 0 0 57.307 0 128.001c0 56.554 36.676 104.535 87.535 121.46c6.397 1.185 8.746-2.777 8.746-6.158c0-3.052-.12-13.135-.174-23.83c-35.61 7.742-43.124-15.103-43.124-15.103c-5.823-14.795-14.213-18.73-14.213-18.73c-11.613-7.944.876-7.78.876-7.78c12.853.902 19.621 13.19 19.621 13.19c11.417 19.568 29.945 13.911 37.249 10.64c1.149-8.272 4.466-13.92 8.127-17.116c-28.431-3.236-58.318-14.212-58.318-63.258c0-13.975 5-25.394 13.188-34.358c-1.329-3.224-5.71-16.242 1.24-33.874c0 0 10.749-3.44 35.21 13.121c10.21-2.836 21.16-4.258 32.038-4.307c10.878.049 21.837 1.47 32.066 4.307c24.431-16.56 35.165-13.12 35.165-13.12c6.967 17.63 2.584 30.65 1.255 33.873c8.207 8.964 13.173 20.383 13.173 34.358c0 49.163-29.944 59.988-58.447 63.157c4.591 3.972 8.682 11.762 8.682 23.704c0 17.126-.148 30.91-.148 35.126c0 3.407 2.304 7.398 8.792 6.14C219.37 232.5 256 184.537 256 128.002C256 57.307 198.691 0 128.001 0\"/></svg>"},"apple":{"name":"Apple","icon":"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 256 315\" fill=\"currentColor\"><path d=\"M213.803 167.03c.442 47.58 41.74 63.413 42.197 63.615c-.35 1.116-6.599 22.563-21.757 44.716c-13.104 19.153-26.705 38.235-48.13 38.63c-21.05.388-27.82-12.483-51.888-12.483c-24.061 0-31.582 12.088-51.51 12.871c-20.68.783-36.428-20.71-49.64-39.793c-27-39.033-47.633-110.3-19.928-158.406c13.763-23.89 38.36-39.017 65.056-39.405c20.307-.388 39.475 13.675 51.889 13.675c12.406 0 35.699-16.895 60.186-14.414c10.25.427 39.026 4.14 57.503 31.186c-1.49.923-34.335 20.044-33.978 59.808M174.24 50.199c10.98-13.29 18.369-31.79 16.353-50.199c-15.826.636-34.962 10.546-46.314 23.828c-10.173 11.763-19.082 30.589-16.678 48.633c17.64 1.365 35.66-8.964 46.639-22.262\"/></svg>"},"twitter":{"name":"Twitter","icon":"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 256 256\" fill=\"currentColor\"><path d=\"M149.079 108.399L242.33 0h-22.098l-80.97 94.12L74.59 0H0l97.796 142.328L0 256h22.1l85.507-99.395L175.905 256h74.59L149.073 108.399zM118.81 143.58l-9.909-14.172l-78.84-112.773h33.943l63.625 91.011l9.909 14.173l82.705 118.3H186.3l-67.49-96.533z\"/></svg>"},"auth0":{"name":"Auth0","icon":"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"18\" height=\"18\" viewBox=\"0 0 256 256\"><path fill=\"#EB5424\" d=\"M203.97 200.38L167.47 88l-39.5 112.38h75.98zm0-144.76L167.47 168l75.97-26.16l19.76-60.82c6.64-20.4-1.94-42.72-21.18-52.76l-38.06 27.36zM52.03 55.62L88.54 168l39.5-112.38H52.02zM52.03 200.38L88.54 88L12.56 114.16l-5.92 18.2c-6.64 20.4 1.94 42.72 21.18 52.76l24.2-17.38l.01.01v-.01l-.01.01.01-.37z\"/></svg>"}};
1842
+
1843
+ function loadSettings() {
1844
+ document.getElementById("st-oauth-list").innerHTML = skeletonRows(3);
1845
+ api("GET", "/settings").then(function(data) {
1846
+ if (!data.ok) return;
1847
+ __settingsData = data.settings;
1848
+ renderOAuthProviders();
1849
+ renderBuiltinProviders();
1850
+ renderSessionConfig();
1851
+ renderEmailConfig();
1852
+ fedLoad();
1853
+ });
1854
+ }
1855
+
1856
+ // ─── OAuth Providers (card grid) ─────────────────────────────────
1857
+
1858
+ function renderOAuthProviders() {
1859
+ var wrap = document.getElementById("st-oauth-list");
1860
+ if (!wrap || !__settingsData) return;
1861
+ var providers = __settingsData.oauthProviders || {};
1862
+ var keys = Object.keys(providers);
1863
+ if (keys.length === 0) {
1864
+ wrap.innerHTML = '<div class="settings-row" style="color:var(--text-tertiary)">' + __t("settings.noOAuth") + '</div>';
1865
+ return;
1866
+ }
1867
+ keys.sort(function(a, b) {
1868
+ return (providers[a].order || 999) - (providers[b].order || 999);
1869
+ });
1870
+ var html = "";
1871
+ for (var i = 0; i < keys.length; i++) {
1872
+ var name = keys[i];
1873
+ var p = providers[name];
1874
+ var meta = __providerMeta[name];
1875
+ var displayName = meta ? meta.name : (name.charAt(0).toUpperCase() + name.slice(1));
1876
+ var iconHtml = meta && meta.icon ? '<span class="st-oauth-card-icon">' + meta.icon + '</span>' : "";
1877
+ var statusDot = p.enabled !== false
1878
+ ? '<span class="st-oauth-dot st-oauth-dot-on"></span>'
1879
+ : '<span class="st-oauth-dot st-oauth-dot-off"></span>';
1880
+ html += '<div class="st-oauth-card" onclick="openOAuthDialog(\'edit\',\'' + escapeHtml(name) + '\')" title="' + escapeHtml(displayName) + '">'
1881
+ + iconHtml
1882
+ + '<span class="st-oauth-card-name">' + escapeHtml(displayName) + '</span>'
1883
+ + statusDot
1884
+ + '</div>';
1885
+ }
1886
+ wrap.innerHTML = html;
1887
+ }
1888
+
1889
+ function escapeHtml(s) {
1890
+ var d = document.createElement("div");
1891
+ d.textContent = s;
1892
+ return d.innerHTML;
1893
+ }
1894
+
1895
+ function renderProviderPicker(selectedProvider) {
1896
+ var picker = document.getElementById("st-oauth-provider-picker");
1897
+ if (!picker) return;
1898
+ var configuredProviders = (__settingsData && __settingsData.oauthProviders) ? __settingsData.oauthProviders : {};
1899
+ var html = "";
1900
+ var keys = Object.keys(__providerMeta);
1901
+ for (var i = 0; i < keys.length; i++) {
1902
+ var id = keys[i];
1903
+ var meta = __providerMeta[id];
1904
+ var isSelected = id === selectedProvider;
1905
+ var isAdded = !__oauthEditMode && !!configuredProviders[id];
1906
+ var cls = "provider-card";
1907
+ if (isSelected) cls += " selected";
1908
+ if (isAdded) cls += " added";
1909
+ var badge = isAdded ? '<span class="provider-added-badge">' + __t("settings.added") + '</span>' : "";
1910
+ var onclick = isAdded ? "" : 'onclick="selectOAuthProvider(\'' + id + '\')"';
1911
+ html += '<div class="' + cls + '" data-provider="' + id + '" ' + onclick + '>'
1912
+ + '<span class="provider-icon">' + meta.icon + '</span>'
1913
+ + '<span class="provider-name">' + escapeHtml(meta.name) + '</span>'
1914
+ + badge
1915
+ + '</div>';
1916
+ }
1917
+ picker.innerHTML = html;
1918
+ }
1919
+
1920
+ function selectOAuthProvider(id) {
1921
+ document.getElementById("st-oauth-provider").value = id;
1922
+ renderProviderPicker(id);
1923
+ onOAuthProviderChange();
1924
+ }
1925
+
1926
+ function openOAuthDialog(mode, providerName) {
1927
+ __oauthEditMode = mode === "edit";
1928
+ var title = document.getElementById("st-oauth-dialog-title");
1929
+ title.textContent = __oauthEditMode ? __t("settings.editOAuthTitle") : __t("settings.addOAuthTitle");
1930
+
1931
+ var defaultProvider = "google";
1932
+ if (__oauthEditMode && providerName) {
1933
+ defaultProvider = providerName;
1934
+ } else if (__settingsData && __settingsData.oauthProviders) {
1935
+ var configured = __settingsData.oauthProviders;
1936
+ var keys = Object.keys(__providerMeta);
1937
+ for (var i = 0; i < keys.length; i++) {
1938
+ if (!configured[keys[i]]) { defaultProvider = keys[i]; break; }
1939
+ }
1940
+ }
1941
+ document.getElementById("st-oauth-provider").value = defaultProvider;
1942
+
1943
+ var picker = document.getElementById("st-oauth-provider-picker");
1944
+ var display = document.getElementById("st-oauth-provider-display");
1945
+ if (__oauthEditMode) {
1946
+ picker.style.display = "none";
1947
+ var meta = __providerMeta[defaultProvider] || { name: defaultProvider, icon: "" };
1948
+ display.style.display = "flex";
1949
+ display.innerHTML = '<span style="display:inline-flex;width:20px;height:20px">' + meta.icon + '</span>'
1950
+ + '<span style="font-size:14px;font-weight:500;color:var(--text)">' + escapeHtml(meta.name) + '</span>';
1951
+ } else {
1952
+ picker.style.display = "";
1953
+ display.style.display = "none";
1954
+ renderProviderPicker(defaultProvider);
1955
+ }
1956
+
1957
+ document.getElementById("st-oauth-clientid").value = "";
1958
+ document.getElementById("st-oauth-secret").value = "";
1959
+ document.getElementById("st-oauth-teamid").value = "";
1960
+ document.getElementById("st-oauth-keyid").value = "";
1961
+ document.getElementById("st-oauth-privatekey").value = "";
1962
+ document.getElementById("st-oauth-serviceid").value = "";
1963
+ document.getElementById("st-oauth-bundleid").value = "";
1964
+ document.getElementById("st-oauth-domain").value = "";
1965
+ document.getElementById("st-oauth-enabled").checked = true;
1966
+ document.getElementById("st-oauth-order").value = "0";
1967
+
1968
+ if (__oauthEditMode && providerName && __settingsData) {
1969
+ var p = __settingsData.oauthProviders[providerName];
1970
+ if (p) {
1971
+ document.getElementById("st-oauth-clientid").value = p.clientId || "";
1972
+ document.getElementById("st-oauth-secret").value = p.clientSecret || "";
1973
+ document.getElementById("st-oauth-teamid").value = p.teamId || "";
1974
+ document.getElementById("st-oauth-keyid").value = p.keyId || "";
1975
+ document.getElementById("st-oauth-privatekey").value = p.privateKey || "";
1976
+ document.getElementById("st-oauth-serviceid").value = p.serviceId || "";
1977
+ document.getElementById("st-oauth-bundleid").value = p.bundleId || "";
1978
+ document.getElementById("st-oauth-domain").value = p.domain || "";
1979
+ document.getElementById("st-oauth-enabled").checked = p.enabled !== false;
1980
+ document.getElementById("st-oauth-order").value = String(p.order ?? 0);
1981
+ }
1982
+ }
1983
+
1984
+ onOAuthProviderChange();
1985
+ updateCallbackUrl();
1986
+ showDialog("st-oauth-dialog");
1987
+ }
1988
+
1989
+ function onOAuthProviderChange() {
1990
+ var provider = document.getElementById("st-oauth-provider").value;
1991
+ var appleFields = document.querySelectorAll('[data-oauth-field="apple"]');
1992
+ var auth0Fields = document.querySelectorAll('[data-oauth-field="auth0"]');
1993
+ for (var i = 0; i < appleFields.length; i++) {
1994
+ appleFields[i].style.display = provider === "apple" ? "" : "none";
1995
+ }
1996
+ for (var i = 0; i < auth0Fields.length; i++) {
1997
+ auth0Fields[i].style.display = provider === "auth0" ? "" : "none";
1998
+ }
1999
+ updateCallbackUrl();
2000
+ }
2001
+
2002
+ function updateCallbackUrl() {
2003
+ var provider = document.getElementById("st-oauth-provider").value;
2004
+ var el = document.getElementById("st-oauth-callback");
2005
+ if (el) el.textContent = location.origin + "/.well-known/service/oauth/callback/" + provider;
2006
+ }
2007
+
2008
+ function copyCallbackUrl() {
2009
+ var el = document.getElementById("st-oauth-callback");
2010
+ var btn = document.getElementById("st-copy-callback-btn");
2011
+ if (el && btn && navigator.clipboard) {
2012
+ var originalText = btn.textContent;
2013
+ navigator.clipboard.writeText(el.textContent).then(function() {
2014
+ btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" style="vertical-align:middle"><path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg> ' + __t("common.copied");
2015
+ btn.disabled = true;
2016
+ setTimeout(function() { btn.textContent = originalText; btn.disabled = false; }, 2000);
2017
+ });
2018
+ }
2019
+ }
2020
+
2021
+ function saveOAuthProvider() {
2022
+ var provider = document.getElementById("st-oauth-provider").value;
2023
+ var body = {
2024
+ clientId: document.getElementById("st-oauth-clientid").value,
2025
+ clientSecret: document.getElementById("st-oauth-secret").value,
2026
+ enabled: document.getElementById("st-oauth-enabled").checked,
2027
+ order: parseInt(document.getElementById("st-oauth-order").value, 10) || 0,
2028
+ };
2029
+
2030
+ if (provider === "apple") {
2031
+ body.teamId = document.getElementById("st-oauth-teamid").value;
2032
+ body.keyId = document.getElementById("st-oauth-keyid").value;
2033
+ body.privateKey = document.getElementById("st-oauth-privatekey").value;
2034
+ body.serviceId = document.getElementById("st-oauth-serviceid").value;
2035
+ body.bundleId = document.getElementById("st-oauth-bundleid").value;
2036
+ }
2037
+ if (provider === "auth0") {
2038
+ body.domain = document.getElementById("st-oauth-domain").value;
2039
+ }
2040
+
2041
+ api("PUT", "/settings/oauth/" + provider, body).then(function(data) {
2042
+ if (data.ok) {
2043
+ closeDialog("st-oauth-dialog");
2044
+ showToast(__t("settings.oauthSaved"), "success");
2045
+ loadSettings();
2046
+ }
2047
+ });
2048
+ }
2049
+
2050
+ async function deleteOAuthProvider(name) {
2051
+ var ok = await confirmDialog({ title: __t("settings.deleteProvider", { name: name }), message: __t("settings.deleteProviderMsg"), confirmLabel: __t("common.delete"), danger: true });
2052
+ if (!ok) return;
2053
+ var data = await api("DELETE", "/settings/oauth/" + name);
2054
+ if (data.ok) {
2055
+ showToast(__t("settings.providerDeleted"), "success");
2056
+ loadSettings();
2057
+ }
2058
+ }
2059
+
2060
+ // ─── Built-in Providers ──────────────────────────────────────────
2061
+
2062
+ function renderBuiltinProviders() {
2063
+ if (!__settingsData) return;
2064
+ var bp = __settingsData.builtinProviders || {};
2065
+ var email = __settingsData.email || {};
2066
+ var emailConfigured = !!(email.resendApiKey && email.fromAddress);
2067
+
2068
+ document.getElementById("st-bp-passkey").checked = bp.passkey ? bp.passkey.enabled !== false : true;
2069
+ document.getElementById("st-bp-email").checked = bp.email ? bp.email.enabled !== false : true;
2070
+ document.getElementById("st-bp-wallet").checked = bp.wallet ? bp.wallet.enabled !== false : true;
2071
+
2072
+ // Show hint if email not configured
2073
+ var hint = document.getElementById("st-email-hint");
2074
+ var emailToggle = document.getElementById("st-bp-email");
2075
+ if (hint && emailToggle) {
2076
+ if (!emailConfigured) {
2077
+ hint.classList.remove("hidden");
2078
+ emailToggle.disabled = true;
2079
+ emailToggle.checked = false;
2080
+ } else {
2081
+ hint.classList.add("hidden");
2082
+ emailToggle.disabled = false;
2083
+ }
2084
+ }
2085
+ }
2086
+
2087
+ function saveBuiltinProviders() {
2088
+ var body = {
2089
+ passkey: { enabled: document.getElementById("st-bp-passkey").checked },
2090
+ email: { enabled: document.getElementById("st-bp-email").checked },
2091
+ wallet: { enabled: document.getElementById("st-bp-wallet").checked },
2092
+ };
2093
+ api("PUT", "/settings/builtin-providers", body).then(function(data) {
2094
+ if (data.ok) showToast(__t("settings.saved"), "success");
2095
+ });
2096
+ }
2097
+
2098
+ // ─── Session ─────────────────────────────────────────────────────
2099
+
2100
+ function updateSessionLabel() {
2101
+ var ttl = document.getElementById("st-session-ttl").value;
2102
+ document.getElementById("st-session-lifetime-label").textContent = __t("settings.jwtLifetime", { days: ttl });
2103
+ document.getElementById("st-session-ttl").title = __t("settings.jwtLifetime", { days: ttl });
2104
+ }
2105
+
2106
+ function renderSessionConfig() {
2107
+ if (!__settingsData) return;
2108
+ var s = __settingsData.session || {};
2109
+ var ttl = s.jwtTtlDays || 7;
2110
+ document.getElementById("st-session-ttl").value = ttl;
2111
+ updateSessionLabel();
2112
+ }
2113
+
2114
+ function saveSessionConfig() {
2115
+ var ttl = parseInt(document.getElementById("st-session-ttl").value, 10);
2116
+ api("PUT", "/settings/session", { jwtTtlDays: ttl }).then(function(data) {
2117
+ if (data.ok) showToast(__t("settings.sessionSaved"), "success");
2118
+ });
2119
+ }
2120
+
2121
+ // ─── Email ───────────────────────────────────────────────────────
2122
+
2123
+ function renderEmailConfig() {
2124
+ if (!__settingsData) return;
2125
+ var e = __settingsData.email || {};
2126
+ document.getElementById("st-email-apikey").value = e.resendApiKey || "";
2127
+ document.getElementById("st-email-from").value = e.fromAddress || "";
2128
+ }
2129
+
2130
+ function saveEmailConfig() {
2131
+ var body = {
2132
+ resendApiKey: document.getElementById("st-email-apikey").value,
2133
+ fromAddress: document.getElementById("st-email-from").value,
2134
+ };
2135
+ api("PUT", "/settings/email", body).then(function(data) {
2136
+ if (data.ok) {
2137
+ showToast(__t("settings.emailSaved"), "success");
2138
+ // Re-render builtin providers to update email toggle state
2139
+ loadSettings();
2140
+ }
2141
+ });
2142
+ }
2143
+
2144
+ registerTab("settings", loadSettings);
2145
+
2146
+
2147
+ // ─── Federation (Unified Login) ──────────────────────────────
2148
+
2149
+ var __fedData = null;
2150
+ var __fedLoading = false;
2151
+
2152
+ var FED_ICON_REMOVE = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>';
2153
+ var FED_ICON_REFRESH = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M21 21v-5h-5"/></svg>';
2154
+ var FED_ICON_CHECK = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
2155
+ var FED_ICON_UNLINK = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 7h3a5 5 0 0 1 0 10h-3m-6 0H6a5 5 0 0 1 0-10h3"/><line x1="2" y1="2" x2="22" y2="22"/></svg>';
2156
+ var FED_ICON_DISABLE = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>';
2157
+ var FED_ICON_ENABLE = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>';
2158
+
2159
+ function fedIconBtn(icon, title, onclick, variant) {
2160
+ var cls = variant === "danger" ? "ap-icon-btn ap-icon-btn-danger" : variant === "success" ? "ap-icon-btn ap-icon-btn-success" : "ap-icon-btn";
2161
+ return '<button class="' + cls + '" title="' + fedEscAttr(title) + '" onclick="' + onclick + '">' + icon + '</button>';
2162
+ }
2163
+
2164
+ function fedNormalizeUrl(input) {
2165
+ var s = (input || "").trim();
2166
+ if (!s) return "";
2167
+ if (!/^https?:\/\//i.test(s)) s = "https://" + s;
2168
+ try { return new URL(s).origin; } catch(e) { return s; }
2169
+ }
2170
+
2171
+ function fedFormatUrl(el) {
2172
+ clearTimeout(el._fedTimer);
2173
+ el._fedTimer = setTimeout(function() {
2174
+ var v = el.value.trim();
2175
+ if (!v) return;
2176
+ var norm = fedNormalizeUrl(v);
2177
+ if (norm && norm !== v && norm !== "https://") {
2178
+ el.value = norm;
2179
+ }
2180
+ }, 800);
2181
+ }
2182
+
2183
+ function fedLoad() {
2184
+ api("GET", "/admin/federation").then(function(data) {
2185
+ if (!data || data.error) {
2186
+ __fedData = { role: null, enabled: false, sites: [], delegation: { exists: false } };
2187
+ } else {
2188
+ __fedData = data;
2189
+ }
2190
+ fedRender();
2191
+ });
2192
+ }
2193
+
2194
+ function fedRender() {
2195
+ var el = document.getElementById("fed-section");
2196
+ if (!el || !__fedData) return;
2197
+ if (!__fedData.role) {
2198
+ el.innerHTML = fedRenderUnconfigured();
2199
+ } else if (__fedData.role === "master") {
2200
+ el.innerHTML = fedRenderMaster();
2201
+ } else {
2202
+ el.innerHTML = fedRenderMember();
2203
+ }
2204
+ }
2205
+
2206
+ function fedRenderUnconfigured() {
2207
+ return '<p class="settings-card-desc">' + __t("federation.desc") + '</p>'
2208
+ + '<div class="fed-init-buttons">'
2209
+ + '<button class="btn" id="fed-enable-btn" onclick="fedEnable()"' + (__fedLoading ? ' disabled' : '') + '>'
2210
+ + (__fedLoading ? '<span class="spinner"></span> ' : '') + __t("federation.enableMaster") + '</button>'
2211
+ + '<button class="btn btn-secondary" onclick="fedOpenJoin()"' + (__fedLoading ? ' disabled' : '') + '>'
2212
+ + __t("federation.joinMember") + '</button>'
2213
+ + '</div>';
2214
+ }
2215
+
2216
+ function fedRenderMaster() {
2217
+ var html = '<div style="padding:12px 20px;display:flex;align-items:center;gap:8px">'
2218
+ + '<span class="fed-status-badge master">&#9733; ' + __t("federation.masterSite") + '</span>'
2219
+ + '<span style="font-size:13px;color:var(--text-secondary)">' + __t("federation.masterDesc") + '</span>'
2220
+ + '</div>';
2221
+
2222
+ // Sites header with refresh button
2223
+ html += '<div style="padding:0 20px 8px;display:flex;justify-content:space-between;align-items:center">'
2224
+ + '<span style="font-size:13px;font-weight:600;color:var(--text)">' + __t("federation.sites") + '</span>'
2225
+ + '<button class="ap-icon-btn" title="' + __t("federation.refresh") + '" onclick="fedRefresh()" id="fed-refresh-btn">' + FED_ICON_REFRESH + '</button>'
2226
+ + '</div>';
2227
+
2228
+ // Sort: master first, then approved, then pending
2229
+ var sites = (__fedData.sites || []).slice().sort(function(a, b) {
2230
+ if (a.isMaster) return -1;
2231
+ if (b.isMaster) return 1;
2232
+ if (a.status === "pending" && b.status !== "pending") return 1;
2233
+ if (b.status === "pending" && a.status !== "pending") return -1;
2234
+ return 0;
2235
+ });
2236
+ var currentOrigin = location.origin;
2237
+ for (var i = 0; i < sites.length; i++) {
2238
+ var s = sites[i];
2239
+ var star = s.isMaster ? '<span class="fed-star">&#9733;</span> ' : '';
2240
+ var isMe = s.url && fedNormalizeUrl(s.url) === currentOrigin;
2241
+ var meTag = isMe ? ' <span style="font-size:10px;padding:1px 5px;border-radius:999px;background:var(--blue-light,#dbeafe);color:var(--blue-muted,#1d4ed8);font-weight:500">' + __t("federation.currentSite") + '</span>' : '';
2242
+ var isPending = s.status === "pending";
2243
+ var statusBadge = isPending
2244
+ ? ' <span style="font-size:11px;padding:1px 6px;border-radius:999px;background:var(--yellow-light,#fef3c7);color:var(--yellow-text,#92400e);font-weight:500">' + __t("federation.pending") + '</span>'
2245
+ : '';
2246
+
2247
+ var actions = '';
2248
+ if (s.isMaster) {
2249
+ // Disabled placeholder buttons for alignment with member rows
2250
+ actions = '<button class="ap-icon-btn" disabled style="opacity:0.25;cursor:default">' + FED_ICON_DISABLE + '</button>'
2251
+ + '<button class="ap-icon-btn" disabled style="opacity:0.25;cursor:default">' + FED_ICON_UNLINK + '</button>';
2252
+ } else if (isPending) {
2253
+ actions = fedIconBtn(FED_ICON_CHECK, __t("federation.approve"), "fedApproveSite(&#39;" + s.did + "&#39;)", "success")
2254
+ + fedIconBtn(FED_ICON_REMOVE, __t("federation.reject"), "fedRejectSite(&#39;" + s.did + "&#39;,&#39;" + fedEscAttr(s.name) + "&#39;)", "danger");
2255
+ } else {
2256
+ // Approved member: disable/enable toggle + unbind
2257
+ if (s.enabled === false) {
2258
+ actions = fedIconBtn(FED_ICON_ENABLE, __t("federation.enable"), "fedToggleMember(&#39;" + s.did + "&#39;,true)")
2259
+ + fedIconBtn(FED_ICON_UNLINK, __t("federation.unbind"), "fedUnbindSite(&#39;" + s.did + "&#39;,&#39;" + fedEscAttr(s.name) + "&#39;)", "danger");
2260
+ } else {
2261
+ actions = fedIconBtn(FED_ICON_DISABLE, __t("federation.disable"), "fedToggleMember(&#39;" + s.did + "&#39;,false)", "danger")
2262
+ + fedIconBtn(FED_ICON_UNLINK, __t("federation.unbind"), "fedUnbindSite(&#39;" + s.did + "&#39;,&#39;" + fedEscAttr(s.name) + "&#39;)", "danger");
2263
+ }
2264
+ }
2265
+ // Disabled badge for approved but disabled members
2266
+ if (!isPending && !s.isMaster && s.enabled === false) {
2267
+ statusBadge = ' <span style="font-size:11px;padding:1px 6px;border-radius:999px;background:var(--bg-tertiary,#e5e7eb);color:var(--text-tertiary)">' + __t("federation.disabled") + '</span>';
2268
+ }
2269
+
2270
+ html += '<div class="fed-site-row">'
2271
+ + '<div class="fed-site-name">' + star + '<span class="fed-site-label">' + fedEscHtml(s.name) + '</span>' + meTag + statusBadge + '</div>'
2272
+ + '<div class="fed-site-url" title="' + fedEscAttr(s.url) + '"><a href="' + fedEscAttr(s.url) + '" target="_blank">' + fedEscHtml(s.url) + '</a></div>'
2273
+ + '<div class="fed-site-actions">' + actions + '</div>'
2274
+ + '</div>';
2275
+ }
2276
+
2277
+ // Disband
2278
+ html += '<div style="padding:16px 20px;border-top:1px solid var(--border)">'
2279
+ + '<button class="btn btn-danger btn-sm" style="width:auto" onclick="fedDisband()">' + __t("federation.disband") + '</button>'
2280
+ + '</div>';
2281
+
2282
+ return html;
2283
+ }
2284
+
2285
+ function fedRenderMember() {
2286
+ var enabled = __fedData.enabled;
2287
+ var isPending = !enabled && __fedData.role === "member";
2288
+
2289
+ // Header: badge + status + toggle (no toggle for pending)
2290
+ var badgeClass = isPending ? 'style="background:var(--yellow-light,#fef3c7);color:var(--yellow-text,#92400e)"' : '';
2291
+ var badgeText = isPending ? __t("federation.pending") : __t("federation.member");
2292
+ var statusText = isPending ? __t("federation.pendingDesc") : (enabled ? __t("federation.enabled") : __t("federation.disabled"));
2293
+
2294
+ var html = '<div style="padding:12px 20px;display:flex;align-items:center;gap:12px;justify-content:space-between">'
2295
+ + '<div style="display:flex;align-items:center;gap:8px">'
2296
+ + '<span class="fed-status-badge member" ' + badgeClass + '>' + badgeText + '</span>'
2297
+ + '<span style="font-size:13px;color:var(--text-secondary)">' + statusText + '</span>'
2298
+ + '</div>';
2299
+ if (!isPending) {
2300
+ html += '<label class="toggle-switch">'
2301
+ + '<input type="checkbox" ' + (enabled ? 'checked' : '') + ' onchange="fedToggle(this.checked)" />'
2302
+ + '<span class="toggle-slider"></span>'
2303
+ + '</label>';
2304
+ }
2305
+ html += '</div>';
2306
+
2307
+ // Master URL
2308
+ if (__fedData.masterUrl) {
2309
+ html += '<div style="padding:0 20px 8px;font-size:13px;color:var(--text-secondary)">'
2310
+ + __t("federation.delegatedTo") + ' <a href="' + fedEscAttr(__fedData.masterUrl) + '" target="_blank" style="color:var(--blue)">' + fedEscHtml(__fedData.masterUrl) + '</a>'
2311
+ + '</div>';
2312
+ }
2313
+
2314
+ // Site list — show all sites with role labels; pending sites get "pending" instead of "member"
2315
+ if (__fedData.sites && __fedData.sites.length > 0) {
2316
+ // Sort: master first, then approved, then pending
2317
+ var memberSites = __fedData.sites.slice().sort(function(a, b) {
2318
+ if (a.isMaster) return -1;
2319
+ if (b.isMaster) return 1;
2320
+ if (a.status === "pending" && b.status !== "pending") return 1;
2321
+ if (b.status === "pending" && a.status !== "pending") return -1;
2322
+ return 0;
2323
+ });
2324
+ html += '<div style="padding:8px 20px 4px;display:flex;justify-content:space-between;align-items:center">'
2325
+ + '<span style="font-size:13px;font-weight:600;color:var(--text)">' + __t("federation.sites") + '</span>'
2326
+ + '<button class="ap-icon-btn" title="' + __t("federation.refresh") + '" onclick="fedRefresh()" id="fed-refresh-btn">' + FED_ICON_REFRESH + '</button>'
2327
+ + '</div>';
2328
+ var currentOrigin = location.origin;
2329
+ for (var i = 0; i < memberSites.length; i++) {
2330
+ var s = memberSites[i];
2331
+ var star = s.isMaster ? '<span class="fed-star">&#9733;</span> ' : '';
2332
+ var isMe = s.url && fedNormalizeUrl(s.url) === currentOrigin;
2333
+ var meTag = isMe ? ' <span style="font-size:10px;padding:1px 5px;border-radius:999px;background:var(--blue-light,#dbeafe);color:var(--blue-muted,#1d4ed8);font-weight:500">' + __t("federation.currentSite") + '</span>' : '';
2334
+ var isPendingSite = s.status === "pending";
2335
+ var role = s.isMaster ? __t("federation.master")
2336
+ : (isPendingSite
2337
+ ? '<span style="color:var(--yellow-text,#92400e);font-weight:500">' + __t("federation.pending") + '</span>'
2338
+ : __t("federation.member"));
2339
+ html += '<div class="fed-site-row">'
2340
+ + '<div class="fed-site-name">' + star + '<span class="fed-site-label">' + fedEscHtml(s.name) + '</span>' + meTag + '</div>'
2341
+ + '<div class="fed-site-url" title="' + fedEscAttr(s.url) + '"><a href="' + fedEscAttr(s.url) + '" target="_blank">' + fedEscHtml(s.url) + '</a></div>'
2342
+ + '<div class="fed-site-role">' + role + '</div>'
2343
+ + '</div>';
2344
+ }
2345
+ }
2346
+
2347
+ html += '<div style="padding:16px 20px;border-top:1px solid var(--border)">'
2348
+ + '<button class="btn btn-danger btn-sm" style="width:auto" onclick="fedLeave()">' + __t("federation.leave") + '</button>'
2349
+ + '</div>';
2350
+
2351
+ return html;
2352
+ }
2353
+
2354
+ function fedEscHtml(str) {
2355
+ if (!str) return '';
2356
+ return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
2357
+ }
2358
+ function fedEscAttr(str) {
2359
+ if (!str) return '';
2360
+ return str.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
2361
+ }
2362
+
2363
+ function fedSetLoading(loading) {
2364
+ __fedLoading = loading;
2365
+ fedRender();
2366
+ }
2367
+
2368
+ function fedEnable() {
2369
+ confirmDialog({
2370
+ title: __t("federation.enableConfirm"),
2371
+ message: __t("federation.enableMasterDesc"),
2372
+ confirmLabel: __t("federation.enableMaster"),
2373
+ }).then(function(ok) {
2374
+ if (!ok) return;
2375
+ fedSetLoading(true);
2376
+ api("POST", "/admin/federation/enable", { url: location.origin, name: document.title || "App" }).then(function(data) {
2377
+ __fedLoading = false;
2378
+ if (data && !data.error) { __fedData = data; fedRender(); showToast(__t("federation.enabled"), "success"); }
2379
+ else { showToast(data.error || "Failed", "error"); fedRender(); }
2380
+ });
2381
+ });
2382
+ }
2383
+
2384
+ function fedOpenJoin() {
2385
+ document.getElementById("fed-join-url").value = "";
2386
+ showDialog("fed-join-dialog");
2387
+ }
2388
+
2389
+ function fedJoinSubmit() {
2390
+ var masterUrl = fedNormalizeUrl(document.getElementById("fed-join-url").value);
2391
+ if (!masterUrl) { showToast(__t("federation.masterUrl") + " is required", "error"); return; }
2392
+
2393
+ var btn = document.getElementById("fed-join-btn");
2394
+ btn.disabled = true;
2395
+ btn.innerHTML = '<span class="spinner"></span>';
2396
+
2397
+ api("POST", "/admin/federation/join", { masterUrl: masterUrl }).then(function(data) {
2398
+ btn.disabled = false;
2399
+ btn.textContent = __t("federation.joinMember");
2400
+ if (data && !data.error) {
2401
+ __fedData = data;
2402
+ fedRender();
2403
+ closeDialog("fed-join-dialog");
2404
+ showToast(__t("federation.pendingApproval"), "success");
2405
+ } else {
2406
+ showToast(data.error || __t("federation.targetNotEnabled"), "error");
2407
+ }
2408
+ });
2409
+ }
2410
+
2411
+ function fedToggle(enabled) {
2412
+ if (!enabled) {
2413
+ confirmDialog({
2414
+ title: __t("federation.disableTitle"),
2415
+ message: __t("federation.disableDesc") + " " + __t("federation.walletSafe") + " " + __t("federation.sessionWarning"),
2416
+ confirmLabel: __t("common.confirm"),
2417
+ danger: true,
2418
+ }).then(function(ok) {
2419
+ if (!ok) { fedRender(); return; }
2420
+ api("PUT", "/admin/federation/toggle", { enabled: false }).then(function(data) {
2421
+ if (data && !data.error) { __fedData = data; fedRender(); }
2422
+ });
2423
+ });
2424
+ var cb = document.querySelector("#fed-section input[type=checkbox]");
2425
+ if (cb) cb.checked = true;
2426
+ } else {
2427
+ api("PUT", "/admin/federation/toggle", { enabled: true }).then(function(data) {
2428
+ if (data && !data.error) { __fedData = data; fedRender(); }
2429
+ });
2430
+ }
2431
+ }
2432
+
2433
+ function fedApproveSite(did) {
2434
+ api("POST", "/admin/federation/sites/" + encodeURIComponent(did) + "/approve").then(function(data) {
2435
+ if (data && !data.error) {
2436
+ __fedData = data;
2437
+ fedRender();
2438
+ showToast(__t("federation.approved"), "success");
2439
+ } else {
2440
+ showToast(data.error || "Failed", "error");
2441
+ }
2442
+ });
2443
+ }
2444
+
2445
+ function fedRejectSite(did, name) {
2446
+ confirmDialog({
2447
+ title: __t("federation.rejectTitle"),
2448
+ message: __t("federation.rejectDesc"),
2449
+ confirmLabel: __t("federation.reject"),
2450
+ danger: true,
2451
+ }).then(function(ok) {
2452
+ if (!ok) return;
2453
+ api("DELETE", "/admin/federation/sites/" + encodeURIComponent(did)).then(function(data) {
2454
+ if (data && !data.error) { __fedData = data; fedRender(); showToast(__t("federation.reject") + " \u2713", "success"); }
2455
+ });
2456
+ });
2457
+ }
2458
+
2459
+ function fedRefresh() {
2460
+ var btn = document.getElementById("fed-refresh-btn");
2461
+ if (btn) { btn.style.opacity = "0.5"; btn.style.pointerEvents = "none"; }
2462
+ fedLoad();
2463
+ setTimeout(function() { if (btn) { btn.style.opacity = ""; btn.style.pointerEvents = ""; } }, 500);
2464
+ }
2465
+
2466
+ // ─── Actions ─────────────────────────────────────────────────
2467
+
2468
+ function fedToggleMember(did, enabled) {
2469
+ if (!enabled) {
2470
+ confirmDialog({
2471
+ title: __t("federation.disableTitle"),
2472
+ message: __t("federation.disableDesc") + " " + __t("federation.walletSafe") + " " + __t("federation.sessionWarning"),
2473
+ confirmLabel: __t("federation.disable"),
2474
+ danger: true,
2475
+ }).then(function(ok) {
2476
+ if (!ok) return;
2477
+ api("PUT", "/admin/federation/sites/" + encodeURIComponent(did) + "/toggle", { enabled: false }).then(function(data) {
2478
+ if (data && !data.error) { __fedData = data; fedRender(); showToast(__t("federation.disabled"), "success"); }
2479
+ });
2480
+ });
2481
+ } else {
2482
+ api("PUT", "/admin/federation/sites/" + encodeURIComponent(did) + "/toggle", { enabled: true }).then(function(data) {
2483
+ if (data && !data.error) { __fedData = data; fedRender(); showToast(__t("federation.enabled"), "success"); }
2484
+ });
2485
+ }
2486
+ }
2487
+
2488
+ function fedUnbindSite(did, name) {
2489
+ confirmDialog({
2490
+ title: __t("federation.unbindTitle"),
2491
+ message: __t("federation.unbindDesc") + " " + __t("federation.walletSafe") + " " + __t("federation.sessionWarning"),
2492
+ confirmLabel: __t("federation.unbind"),
2493
+ danger: true,
2494
+ }).then(function(ok) {
2495
+ if (!ok) return;
2496
+ api("DELETE", "/admin/federation/sites/" + encodeURIComponent(did)).then(function(data) {
2497
+ if (data && !data.error) { __fedData = data; fedRender(); showToast(__t("federation.unbind") + " \u2713", "success"); }
2498
+ });
2499
+ });
2500
+ }
2501
+
2502
+ function fedDisband() {
2503
+ confirmDialog({
2504
+ title: __t("federation.disbandTitle"),
2505
+ message: __t("federation.disbandDesc") + " " + __t("federation.sessionWarning"),
2506
+ confirmLabel: __t("federation.disband"),
2507
+ danger: true,
2508
+ }).then(function(ok) {
2509
+ if (!ok) return;
2510
+ api("POST", "/admin/federation/disband").then(function(data) {
2511
+ if (data && !data.error) { __fedData = data; fedRender(); showToast(__t("federation.disband") + " \u2713", "success"); }
2512
+ });
2513
+ });
2514
+ }
2515
+
2516
+ function fedLeave() {
2517
+ confirmDialog({
2518
+ title: __t("federation.leaveTitle"),
2519
+ message: __t("federation.leaveDesc") + " " + __t("federation.sessionWarning"),
2520
+ confirmLabel: __t("federation.leave"),
2521
+ danger: true,
2522
+ }).then(function(ok) {
2523
+ if (!ok) return;
2524
+ api("POST", "/admin/federation/leave").then(function(data) {
2525
+ if (data && !data.error) { __fedData = data; fedRender(); showToast(__t("federation.leave") + " \u2713", "success"); }
2526
+ });
2527
+ });
2528
+ }
2529
+