@qnote/q-ai-note 1.0.6 → 1.0.8

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 (50) hide show
  1. package/README.md +11 -14
  2. package/dist/cli.js +18 -31
  3. package/dist/cli.js.map +1 -1
  4. package/dist/server/accessControl.d.ts +29 -0
  5. package/dist/server/accessControl.d.ts.map +1 -0
  6. package/dist/server/accessControl.js +161 -0
  7. package/dist/server/accessControl.js.map +1 -0
  8. package/dist/server/api/accessHelpers.d.ts +11 -0
  9. package/dist/server/api/accessHelpers.d.ts.map +1 -0
  10. package/dist/server/api/accessHelpers.js +45 -0
  11. package/dist/server/api/accessHelpers.js.map +1 -0
  12. package/dist/server/api/chat.d.ts.map +1 -1
  13. package/dist/server/api/chat.js +31 -0
  14. package/dist/server/api/chat.js.map +1 -1
  15. package/dist/server/api/diary.d.ts.map +1 -1
  16. package/dist/server/api/diary.js +61 -1
  17. package/dist/server/api/diary.js.map +1 -1
  18. package/dist/server/api/nodeEntities.d.ts.map +1 -1
  19. package/dist/server/api/nodeEntities.js +31 -0
  20. package/dist/server/api/nodeEntities.js.map +1 -1
  21. package/dist/server/api/projectSettings.d.ts +3 -0
  22. package/dist/server/api/projectSettings.d.ts.map +1 -0
  23. package/dist/server/api/projectSettings.js +29 -0
  24. package/dist/server/api/projectSettings.js.map +1 -0
  25. package/dist/server/api/sandbox.d.ts.map +1 -1
  26. package/dist/server/api/sandbox.js +35 -1
  27. package/dist/server/api/sandbox.js.map +1 -1
  28. package/dist/server/api/settings.d.ts.map +1 -1
  29. package/dist/server/api/settings.js +25 -1
  30. package/dist/server/api/settings.js.map +1 -1
  31. package/dist/server/api/workItem.d.ts.map +1 -1
  32. package/dist/server/api/workItem.js +59 -0
  33. package/dist/server/api/workItem.js.map +1 -1
  34. package/dist/server/config.d.ts +3 -2
  35. package/dist/server/config.d.ts.map +1 -1
  36. package/dist/server/config.js +6 -1
  37. package/dist/server/config.js.map +1 -1
  38. package/dist/server/index.d.ts +2 -1
  39. package/dist/server/index.d.ts.map +1 -1
  40. package/dist/server/index.js +51 -11
  41. package/dist/server/index.js.map +1 -1
  42. package/dist/server/projectConfig.d.ts +37 -0
  43. package/dist/server/projectConfig.d.ts.map +1 -0
  44. package/dist/server/projectConfig.js +180 -0
  45. package/dist/server/projectConfig.js.map +1 -0
  46. package/dist/web/app.js +618 -39
  47. package/dist/web/index.html +103 -59
  48. package/dist/web/styles.css +194 -4
  49. package/dist/web/vueRenderers.js +7 -5
  50. package/package.json +2 -3
package/dist/web/app.js CHANGED
@@ -10,6 +10,19 @@ const state = {
10
10
  chats: [],
11
11
  settings: {},
12
12
  readonly: false,
13
+ fullAccess: false,
14
+ canAccessSystemSettings: false,
15
+ pageAccess: {
16
+ sandboxes: true,
17
+ diaries: true,
18
+ changes: true,
19
+ settings: false,
20
+ },
21
+ sandboxAccessAll: 'read',
22
+ sandboxReadIds: [],
23
+ sandboxWriteIds: [],
24
+ currentSandboxWritable: false,
25
+ projectSettingsDraft: null,
13
26
  pendingAction: null,
14
27
  nodeEntities: [],
15
28
  nodeEntityStats: null,
@@ -86,9 +99,10 @@ function applySandboxChatVisibility() {
86
99
  const layout = document.getElementById('sandbox-layout');
87
100
  const toggleBtn = document.getElementById('toggle-sandbox-chat-btn');
88
101
  if (!layout || !toggleBtn) return;
89
- const shouldShow = !state.readonly && Boolean(state.sandboxChatVisible);
102
+ const chatAllowed = !state.readonly && state.currentSandboxWritable;
103
+ const shouldShow = chatAllowed && Boolean(state.sandboxChatVisible);
90
104
  layout.classList.toggle('show-chat', shouldShow);
91
- toggleBtn.classList.toggle('hidden', state.readonly);
105
+ toggleBtn.classList.toggle('hidden', !chatAllowed);
92
106
  toggleBtn.innerHTML = shouldShow
93
107
  ? '<span class="icon" aria-hidden="true">🤖</span><span>隐藏 AI 助手</span>'
94
108
  : '<span class="icon" aria-hidden="true">🤖</span><span>显示 AI 助手</span>';
@@ -99,11 +113,47 @@ async function loadRuntimeMode() {
99
113
  try {
100
114
  const runtime = await apiRequest(`${API_BASE}/runtime`);
101
115
  state.readonly = Boolean(runtime?.readonly);
116
+ state.fullAccess = Boolean(runtime?.full_access);
117
+ state.canAccessSystemSettings = Boolean(runtime?.can_access_system_settings);
118
+ state.pageAccess = {
119
+ sandboxes: Boolean(runtime?.page_access?.sandboxes),
120
+ diaries: Boolean(runtime?.page_access?.diaries),
121
+ changes: Boolean(runtime?.page_access?.changes),
122
+ settings: Boolean(runtime?.page_access?.settings),
123
+ };
124
+ state.sandboxAccessAll = String(runtime?.sandbox_access_all || 'none');
125
+ state.sandboxReadIds = Array.isArray(runtime?.sandbox_read_ids) ? runtime.sandbox_read_ids.map((id) => String(id)) : [];
126
+ state.sandboxWriteIds = Array.isArray(runtime?.sandbox_write_ids) ? runtime.sandbox_write_ids.map((id) => String(id)) : [];
127
+ state.currentSandboxWritable = !state.readonly && (state.fullAccess || state.sandboxAccessAll === 'write');
102
128
  } catch {
103
129
  state.readonly = false;
130
+ state.fullAccess = true;
131
+ state.canAccessSystemSettings = true;
132
+ state.pageAccess = { sandboxes: true, diaries: true, changes: true, settings: true };
133
+ state.sandboxAccessAll = 'write';
134
+ state.sandboxReadIds = [];
135
+ state.sandboxWriteIds = [];
136
+ state.currentSandboxWritable = true;
104
137
  }
105
138
  }
106
139
 
140
+ function canReadSandboxById(sandboxId) {
141
+ const id = String(sandboxId || '');
142
+ if (!id) return false;
143
+ if (state.fullAccess) return true;
144
+ if (state.sandboxAccessAll === 'read' || state.sandboxAccessAll === 'write') return true;
145
+ return state.sandboxReadIds.includes(id) || state.sandboxWriteIds.includes(id);
146
+ }
147
+
148
+ function canWriteSandboxById(sandboxId) {
149
+ const id = String(sandboxId || '');
150
+ if (!id) return false;
151
+ if (state.readonly) return false;
152
+ if (state.fullAccess) return true;
153
+ if (state.sandboxAccessAll === 'write') return true;
154
+ return state.sandboxWriteIds.includes(id);
155
+ }
156
+
107
157
  function setHiddenById(id, hidden = true) {
108
158
  const el = document.getElementById(id);
109
159
  if (!el) return;
@@ -112,15 +162,30 @@ function setHiddenById(id, hidden = true) {
112
162
 
113
163
  function applyReadonlyMode() {
114
164
  document.body.classList.toggle('readonly-mode', state.readonly);
115
- const settingsNav = document.querySelector('[data-nav="settings"]');
116
- if (settingsNav instanceof HTMLElement) {
117
- settingsNav.classList.toggle('hidden', state.readonly);
118
- }
119
- setHiddenById('page-settings', state.readonly);
120
- setHiddenById('add-sandbox-btn', state.readonly);
121
- setHiddenById('add-item-btn', state.readonly);
122
- setHiddenById('toggle-node-entity-form-btn', state.readonly);
123
- setHiddenById('drawer-diary-save-btn', state.readonly);
165
+ const sandboxesNav = document.querySelector('[data-nav="sandboxes"]');
166
+ if (sandboxesNav instanceof HTMLElement) {
167
+ sandboxesNav.classList.toggle('hidden', !state.pageAccess.sandboxes && !state.fullAccess);
168
+ }
169
+ const diariesNav = document.querySelector('[data-nav="diaries"]');
170
+ if (diariesNav instanceof HTMLElement) {
171
+ diariesNav.classList.toggle('hidden', !state.pageAccess.diaries && !state.fullAccess);
172
+ }
173
+ const changesNav = document.querySelector('[data-nav="changes"]');
174
+ if (changesNav instanceof HTMLElement) {
175
+ changesNav.classList.toggle('hidden', !state.pageAccess.changes && !state.fullAccess);
176
+ }
177
+ const projectSettingsNav = document.querySelector('[data-nav="settings"]');
178
+ if (projectSettingsNav instanceof HTMLElement) {
179
+ projectSettingsNav.classList.toggle('hidden', !state.pageAccess.settings);
180
+ }
181
+ const systemSettingsNav = document.querySelector('[data-nav="system-settings"]');
182
+ if (systemSettingsNav instanceof HTMLElement) {
183
+ systemSettingsNav.classList.toggle('hidden', !state.canAccessSystemSettings);
184
+ }
185
+ setHiddenById('add-sandbox-btn', state.readonly || !state.fullAccess);
186
+ setHiddenById('add-item-btn', state.readonly || !state.currentSandboxWritable);
187
+ setHiddenById('toggle-node-entity-form-btn', state.readonly || !state.currentSandboxWritable);
188
+ setHiddenById('drawer-diary-save-btn', state.readonly || !state.currentSandboxWritable);
124
189
  setHiddenById('save-diary-btn', state.readonly);
125
190
  const diaryForm = document.querySelector('#page-diaries .diary-form');
126
191
  if (diaryForm instanceof HTMLElement) {
@@ -128,7 +193,7 @@ function applyReadonlyMode() {
128
193
  }
129
194
  const drawerDiaryForm = document.querySelector('#node-entity-drawer .drawer-diary-quick-form');
130
195
  if (drawerDiaryForm instanceof HTMLElement) {
131
- drawerDiaryForm.classList.toggle('hidden', state.readonly);
196
+ drawerDiaryForm.classList.toggle('hidden', state.readonly || !state.currentSandboxWritable);
132
197
  }
133
198
  if (state.readonly) {
134
199
  state.sandboxChatVisible = false;
@@ -302,6 +367,7 @@ function applyWorkItemElementPreviewMode() {
302
367
  function renderWorkTree() {
303
368
  const tree = document.getElementById('work-tree');
304
369
  if (!tree || !state.currentSandbox) return;
370
+ const treeReadonly = state.readonly || !state.currentSandboxWritable;
305
371
 
306
372
  const allItems = state.currentSandbox.items || [];
307
373
  const items = applyWorkItemFilters(allItems);
@@ -317,7 +383,7 @@ function renderWorkTree() {
317
383
  }
318
384
 
319
385
  if (allItems.length === 0) {
320
- tree.innerHTML = `<div class="empty-state"><p>${state.readonly ? '暂无任务' : '点击上方"添加"按钮创建第一个任务'}</p></div>`;
386
+ tree.innerHTML = `<div class="empty-state"><p>${treeReadonly ? '暂无任务' : '点击上方"添加"按钮创建第一个任务'}</p></div>`;
321
387
  return;
322
388
  }
323
389
 
@@ -333,7 +399,7 @@ function renderWorkTree() {
333
399
  }
334
400
  renderWorkTree();
335
401
  },
336
- onAddChild: state.readonly ? undefined : (parentId) => {
402
+ onAddChild: treeReadonly ? undefined : (parentId) => {
337
403
  document.getElementById('item-dialog-title').textContent = '添加子任务';
338
404
  document.getElementById('item-dialog').dataset.editId = '';
339
405
  document.getElementById('new-item-name').value = '';
@@ -344,14 +410,14 @@ function renderWorkTree() {
344
410
  document.getElementById('new-item-parent').value = parentId;
345
411
  document.getElementById('item-dialog').showModal();
346
412
  },
347
- onAddDiary: state.readonly ? undefined : (nodeId) => {
413
+ onAddDiary: treeReadonly ? undefined : (nodeId) => {
348
414
  showNodeEntityDrawer(nodeId, 'diary');
349
415
  const textarea = document.getElementById('drawer-diary-content');
350
416
  if (textarea instanceof HTMLTextAreaElement) {
351
417
  textarea.focus();
352
418
  }
353
419
  },
354
- onEdit: state.readonly ? undefined : (id) => {
420
+ onEdit: treeReadonly ? undefined : (id) => {
355
421
  editWorkItem(id);
356
422
  },
357
423
  onSelect: (id) => {
@@ -360,7 +426,7 @@ function renderWorkTree() {
360
426
  onSelectEntity: (nodeId, entityType) => {
361
427
  showNodeEntityDrawer(nodeId, entityType || 'all');
362
428
  },
363
- onMoveNode: state.readonly ? undefined : async (dragNodeId, newParentId) => {
429
+ onMoveNode: treeReadonly ? undefined : async (dragNodeId, newParentId) => {
364
430
  if (!state.currentSandbox) return;
365
431
  if (!dragNodeId || dragNodeId === newParentId) return;
366
432
  const byId = new Map((state.currentSandbox.items || []).map((item) => [item.id, item]));
@@ -388,7 +454,7 @@ function renderWorkTree() {
388
454
  });
389
455
  await loadSandbox(state.currentSandbox.id);
390
456
  },
391
- onReorderSiblings: state.readonly ? undefined : async (dragNodeId, targetNodeId, position) => {
457
+ onReorderSiblings: treeReadonly ? undefined : async (dragNodeId, targetNodeId, position) => {
392
458
  if (!state.currentSandbox) return;
393
459
  if (!dragNodeId || !targetNodeId || dragNodeId === targetNodeId) return;
394
460
  const byId = new Map((state.currentSandbox.items || []).map((item) => [item.id, item]));
@@ -421,7 +487,7 @@ function renderWorkTree() {
421
487
  });
422
488
  await loadSandbox(state.currentSandbox.id);
423
489
  },
424
- onReorderLanes: state.readonly ? undefined : async (dragRootId, targetRootId) => {
490
+ onReorderLanes: treeReadonly ? undefined : async (dragRootId, targetRootId) => {
425
491
  if (!state.currentSandbox) return;
426
492
  if (!dragRootId || !targetRootId || dragRootId === targetRootId) return;
427
493
  const roots = (state.currentSandbox.items || []).filter((item) => !item.parent_id);
@@ -446,20 +512,20 @@ function renderWorkTree() {
446
512
  });
447
513
  await loadSandbox(state.currentSandbox.id);
448
514
  },
449
- onDelete: state.readonly ? undefined : async (id) => {
515
+ onDelete: treeReadonly ? undefined : async (id) => {
450
516
  if (confirm('确定删除此任务?')) {
451
517
  await apiRequest(`${API_BASE}/items/${id}`, { method: 'DELETE' });
452
518
  await loadSandbox(state.currentSandbox.id);
453
519
  }
454
520
  },
455
- onQuickChat: state.readonly ? undefined : (id, el) => {
521
+ onQuickChat: treeReadonly ? undefined : (id, el) => {
456
522
  openQuickChatPopover(id, el);
457
523
  },
458
524
  renderMode: state.workTreeViewMode === 'dense' ? 'dense' : 'card',
459
525
  showAssignee: state.workItemShowAssignee,
460
526
  elementPreviewMode: state.workItemElementPreviewMode,
461
527
  entityRowsByNodeId,
462
- readonly: state.readonly,
528
+ readonly: treeReadonly,
463
529
  selectedId: state.selectedNodeId || '',
464
530
  });
465
531
 
@@ -570,12 +636,17 @@ function compareSiblingOrder(a, b, keyName = 'order_key') {
570
636
  return String(a?.created_at || '').localeCompare(String(b?.created_at || ''));
571
637
  }
572
638
 
573
- function populateParentSelect(items) {
639
+ function populateParentSelect(items, preferredParentId = null) {
574
640
  const select = document.getElementById('new-item-parent');
575
641
  if (!select) return;
576
-
642
+ const expectedValue = preferredParentId === null || preferredParentId === undefined
643
+ ? String(select.value || '')
644
+ : String(preferredParentId || '');
645
+
577
646
  select.innerHTML = '<option value="">无(顶级)</option>' +
578
647
  items.map(i => `<option value="${i.id}">${safeText(i.name)}</option>`).join('');
648
+ const hasExpectedValue = Array.from(select.options).some((option) => option.value === expectedValue);
649
+ select.value = hasExpectedValue ? expectedValue : '';
579
650
  }
580
651
 
581
652
  function renderMarkdownSnippet(text) {
@@ -1285,6 +1356,13 @@ function openQuickChatPopover(nodeId, anchorEl) {
1285
1356
  }
1286
1357
 
1287
1358
  async function loadSandboxes() {
1359
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
1360
+ state.sandboxes = [];
1361
+ renderSandboxes();
1362
+ renderSandboxesSummary();
1363
+ updateSandboxSelect();
1364
+ return;
1365
+ }
1288
1366
  state.sandboxes = await apiRequest(`${API_BASE}/sandboxes`);
1289
1367
  renderSandboxes();
1290
1368
  renderSandboxesSummary();
@@ -1317,18 +1395,19 @@ function renderSandboxes() {
1317
1395
  onOpen: (id) => {
1318
1396
  window.location.hash = `/sandbox/${id}`;
1319
1397
  },
1320
- onDelete: state.readonly ? undefined : async (id) => {
1398
+ onDelete: (state.readonly || !state.fullAccess) ? undefined : async (id) => {
1321
1399
  if (confirm('确定删除此沙盘?')) {
1322
1400
  await apiRequest(`${API_BASE}/sandboxes/${id}`, { method: 'DELETE' });
1323
1401
  await loadSandboxes();
1324
1402
  }
1325
1403
  },
1326
- readonly: state.readonly,
1404
+ readonly: state.readonly || !state.fullAccess,
1327
1405
  });
1328
1406
  }
1329
1407
 
1330
1408
  async function loadSandbox(id) {
1331
1409
  closeQuickChatPopover();
1410
+ state.currentSandboxWritable = canWriteSandboxById(id);
1332
1411
  const [sandbox, items, diaries, entities, entityStats] = await Promise.all([
1333
1412
  apiRequest(`${API_BASE}/sandboxes/${id}`),
1334
1413
  apiRequest(`${API_BASE}/sandboxes/${id}/items`),
@@ -1350,7 +1429,8 @@ async function loadSandbox(id) {
1350
1429
  renderSandboxOverview();
1351
1430
  renderWorkTree();
1352
1431
  applySandboxLayoutHeight();
1353
- if (state.readonly) {
1432
+ applyReadonlyMode();
1433
+ if (state.readonly || !state.currentSandboxWritable) {
1354
1434
  state.chats = [];
1355
1435
  const messages = document.getElementById('sandbox-chat-messages');
1356
1436
  if (messages) messages.innerHTML = '';
@@ -1847,6 +1927,252 @@ async function loadSettings() {
1847
1927
  apiKeyInput.placeholder = state.settings.has_api_key ? '已配置,留空表示不修改' : 'sk-...';
1848
1928
  document.getElementById('setting-model').value = state.settings.model || '';
1849
1929
  renderSettingHeadersRows(state.settings.headers || {});
1930
+ const editableIpInput = document.getElementById('setting-editable-ips');
1931
+ if (editableIpInput instanceof HTMLTextAreaElement) {
1932
+ editableIpInput.value = Array.isArray(state.settings.editable_ip_allowlist)
1933
+ ? state.settings.editable_ip_allowlist.join('\n')
1934
+ : '';
1935
+ }
1936
+ }
1937
+
1938
+ function normalizeProjectSettingsDraft(config) {
1939
+ const users = Array.isArray(config?.users) ? config.users : [];
1940
+ const profiles = Array.isArray(config?.profiles) ? config.profiles : [];
1941
+ const bindings = Array.isArray(config?.bindings) ? config.bindings : [];
1942
+ return {
1943
+ users: users.map((user, idx) => ({
1944
+ id: String(user?.id || `user-${idx + 1}`).trim(),
1945
+ name: String(user?.name || '').trim(),
1946
+ ips: Array.isArray(user?.ips) ? user.ips.map((ip) => String(ip || '').trim()).filter((ip) => Boolean(ip)) : [],
1947
+ })),
1948
+ profiles: profiles.map((profile, idx) => ({
1949
+ id: String(profile?.id || `profile-${idx + 1}`).trim(),
1950
+ name: String(profile?.name || '').trim(),
1951
+ apply_to_all_users: Boolean(profile?.apply_to_all_users),
1952
+ page_access: {
1953
+ sandboxes: Boolean(profile?.page_access?.sandboxes),
1954
+ diaries: Boolean(profile?.page_access?.diaries),
1955
+ changes: Boolean(profile?.page_access?.changes),
1956
+ settings: Boolean(profile?.page_access?.settings),
1957
+ },
1958
+ sandbox_access: {
1959
+ all: String(profile?.sandbox_access?.all || 'none'),
1960
+ items: Array.isArray(profile?.sandbox_access?.items)
1961
+ ? profile.sandbox_access.items.map((item) => ({
1962
+ sandbox_id: String(item?.sandbox_id || '').trim(),
1963
+ access: String(item?.access || 'read'),
1964
+ }))
1965
+ : [],
1966
+ },
1967
+ })),
1968
+ bindings: bindings.map((binding) => ({
1969
+ user_id: String(binding?.user_id || '').trim(),
1970
+ profile_ids: Array.isArray(binding?.profile_ids)
1971
+ ? binding.profile_ids.map((id) => String(id || '').trim()).filter((id) => Boolean(id))
1972
+ : [],
1973
+ })),
1974
+ };
1975
+ }
1976
+
1977
+ function renderProjectUsersEditor() {
1978
+ const container = document.getElementById('project-users-list');
1979
+ if (!(container instanceof HTMLElement)) return;
1980
+ const users = state.projectSettingsDraft?.users || [];
1981
+ if (!users.length) {
1982
+ container.innerHTML = '<div class="project-empty">暂无用户,点击上方“添加用户”。</div>';
1983
+ return;
1984
+ }
1985
+ container.innerHTML = users.map((user, idx) => `
1986
+ <div class="project-row">
1987
+ <input type="text" data-role="project-user-id" data-idx="${idx}" placeholder="用户 ID(唯一)" value="${escapeHtml(user.id)}">
1988
+ <input type="text" data-role="project-user-name" data-idx="${idx}" placeholder="显示名称" value="${escapeHtml(user.name)}">
1989
+ <div class="project-ip-editor">
1990
+ <div class="project-ip-chips">
1991
+ ${(user.ips || []).map((ip, ipIdx) => `
1992
+ <span class="project-ip-chip">
1993
+ <span>${escapeHtml(ip)}</span>
1994
+ <button class="project-ip-remove" type="button" title="删除 IP" data-role="remove-project-user-ip" data-idx="${idx}" data-ip-idx="${ipIdx}">×</button>
1995
+ </span>
1996
+ `).join('')}
1997
+ </div>
1998
+ <div class="project-ip-input-row">
1999
+ <input type="text" data-role="project-user-ip-input" data-idx="${idx}" placeholder="输入 IP 后回车或点击添加">
2000
+ <button class="btn btn-secondary btn-sm" data-role="add-project-user-ip" data-idx="${idx}" type="button">添加 IP</button>
2001
+ </div>
2002
+ </div>
2003
+ <button class="btn btn-secondary btn-sm" data-role="remove-project-user" data-idx="${idx}" type="button">删除</button>
2004
+ </div>
2005
+ `).join('');
2006
+ }
2007
+
2008
+ function addUserIpAtIndex(userIdx, rawIp) {
2009
+ if (!state.projectSettingsDraft) return;
2010
+ const idx = Number(userIdx);
2011
+ if (!Number.isFinite(idx) || idx < 0) return;
2012
+ const user = state.projectSettingsDraft.users[idx];
2013
+ if (!user) return;
2014
+ const ip = String(rawIp || '').trim();
2015
+ if (!ip) return;
2016
+ if (ip.includes('/')) {
2017
+ alert('仅支持精确 IP,不支持 CIDR。');
2018
+ return;
2019
+ }
2020
+ if ((user.ips || []).includes(ip)) return;
2021
+ user.ips = [...(user.ips || []), ip];
2022
+ renderProjectSettingsEditor();
2023
+ }
2024
+
2025
+ function renderProjectProfilesEditor() {
2026
+ const container = document.getElementById('project-profiles-list');
2027
+ if (!(container instanceof HTMLElement)) return;
2028
+ const profiles = state.projectSettingsDraft?.profiles || [];
2029
+ const sandboxOptions = (state.sandboxes || []).map((sandbox) => ({
2030
+ id: String(sandbox.id || ''),
2031
+ name: String(sandbox.name || ''),
2032
+ }));
2033
+ if (!profiles.length) {
2034
+ container.innerHTML = '<div class="project-empty">暂无 Profile,点击上方“添加 Profile”。</div>';
2035
+ return;
2036
+ }
2037
+ container.innerHTML = profiles.map((profile, idx) => `
2038
+ <div class="project-profile-card">
2039
+ <div class="project-profile-grid">
2040
+ <input type="text" data-role="project-profile-id" data-idx="${idx}" placeholder="Profile ID(唯一)" value="${escapeHtml(profile.id)}">
2041
+ <input type="text" data-role="project-profile-name" data-idx="${idx}" placeholder="Profile 名称" value="${escapeHtml(profile.name)}">
2042
+ <select data-role="project-profile-all-access" data-idx="${idx}">
2043
+ <option value="none" ${profile.sandbox_access.all === 'none' ? 'selected' : ''}>默认沙盘权限:none</option>
2044
+ <option value="read" ${profile.sandbox_access.all === 'read' ? 'selected' : ''}>默认沙盘权限:read</option>
2045
+ <option value="write" ${profile.sandbox_access.all === 'write' ? 'selected' : ''}>默认沙盘权限:write</option>
2046
+ </select>
2047
+ <label class="inline-checkbox">
2048
+ <input type="checkbox" data-role="project-profile-apply-all" data-idx="${idx}" ${profile.apply_to_all_users ? 'checked' : ''}>
2049
+ <span>apply_to_all_users</span>
2050
+ </label>
2051
+ </div>
2052
+ <div class="project-profile-pages">
2053
+ <label class="inline-checkbox"><input type="checkbox" data-role="project-profile-page" data-idx="${idx}" data-page="sandboxes" ${profile.page_access.sandboxes ? 'checked' : ''}><span>沙盘</span></label>
2054
+ <label class="inline-checkbox"><input type="checkbox" data-role="project-profile-page" data-idx="${idx}" data-page="diaries" ${profile.page_access.diaries ? 'checked' : ''}><span>日记</span></label>
2055
+ <label class="inline-checkbox"><input type="checkbox" data-role="project-profile-page" data-idx="${idx}" data-page="changes" ${profile.page_access.changes ? 'checked' : ''}><span>变化</span></label>
2056
+ <label class="inline-checkbox"><input type="checkbox" data-role="project-profile-page" data-idx="${idx}" data-page="settings" ${profile.page_access.settings ? 'checked' : ''}><span>项目设置</span></label>
2057
+ </div>
2058
+ <div class="project-sandbox-items">
2059
+ ${(profile.sandbox_access.items || []).map((item, itemIdx) => `
2060
+ <div class="project-row compact">
2061
+ <select data-role="project-profile-sandbox-id" data-idx="${idx}" data-item-idx="${itemIdx}">
2062
+ <option value="">选择沙盘</option>
2063
+ ${sandboxOptions.map((sandbox) => `
2064
+ <option value="${escapeHtml(sandbox.id)}" ${item.sandbox_id === sandbox.id ? 'selected' : ''}>
2065
+ ${escapeHtml(sandbox.name || sandbox.id)} (${escapeHtml(sandbox.id)})
2066
+ </option>
2067
+ `).join('')}
2068
+ ${item.sandbox_id && !sandboxOptions.some((sandbox) => sandbox.id === item.sandbox_id) ? `
2069
+ <option value="${escapeHtml(item.sandbox_id)}" selected>
2070
+ 已不存在的沙盘 (${escapeHtml(item.sandbox_id)})
2071
+ </option>
2072
+ ` : ''}
2073
+ </select>
2074
+ <select data-role="project-profile-sandbox-access" data-idx="${idx}" data-item-idx="${itemIdx}">
2075
+ <option value="none" ${item.access === 'none' ? 'selected' : ''}>none</option>
2076
+ <option value="read" ${item.access === 'read' ? 'selected' : ''}>read</option>
2077
+ <option value="write" ${item.access === 'write' ? 'selected' : ''}>write</option>
2078
+ </select>
2079
+ <button class="btn btn-secondary btn-sm" data-role="remove-project-profile-sandbox-item" data-idx="${idx}" data-item-idx="${itemIdx}" type="button">删除</button>
2080
+ </div>
2081
+ `).join('')}
2082
+ <button class="btn btn-secondary btn-sm" data-role="add-project-profile-sandbox-item" data-idx="${idx}" type="button">+ 添加沙盘白名单规则</button>
2083
+ </div>
2084
+ <div class="project-profile-footer">
2085
+ <button class="btn btn-secondary btn-sm" data-role="remove-project-profile" data-idx="${idx}" type="button">删除 Profile</button>
2086
+ </div>
2087
+ </div>
2088
+ `).join('');
2089
+ }
2090
+
2091
+ function renderProjectBindingsEditor() {
2092
+ const container = document.getElementById('project-bindings-list');
2093
+ if (!(container instanceof HTMLElement)) return;
2094
+ const bindings = state.projectSettingsDraft?.bindings || [];
2095
+ const users = state.projectSettingsDraft?.users || [];
2096
+ const profiles = state.projectSettingsDraft?.profiles || [];
2097
+ if (!bindings.length) {
2098
+ container.innerHTML = '<div class="project-empty">暂无绑定,点击上方“添加绑定”。</div>';
2099
+ return;
2100
+ }
2101
+ container.innerHTML = bindings.map((binding, idx) => `
2102
+ <div class="project-binding-card">
2103
+ <div class="project-row compact">
2104
+ <select data-role="project-binding-user" data-idx="${idx}">
2105
+ <option value="">选择用户</option>
2106
+ ${users.map((user) => `<option value="${escapeHtml(user.id)}" ${binding.user_id === user.id ? 'selected' : ''}>${escapeHtml(user.name || user.id)} (${escapeHtml(user.id)})</option>`).join('')}
2107
+ </select>
2108
+ <button class="btn btn-secondary btn-sm" data-role="remove-project-binding" data-idx="${idx}" type="button">删除</button>
2109
+ </div>
2110
+ <div class="project-binding-profiles">
2111
+ ${profiles.map((profile) => `
2112
+ <label class="inline-checkbox">
2113
+ <input type="checkbox" data-role="project-binding-profile" data-idx="${idx}" data-profile-id="${escapeHtml(profile.id)}" ${binding.profile_ids.includes(profile.id) ? 'checked' : ''}>
2114
+ <span>${escapeHtml(profile.name || profile.id)}</span>
2115
+ </label>
2116
+ `).join('')}
2117
+ </div>
2118
+ </div>
2119
+ `).join('');
2120
+ }
2121
+
2122
+ function renderProjectSettingsEditor() {
2123
+ renderProjectUsersEditor();
2124
+ renderProjectProfilesEditor();
2125
+ renderProjectBindingsEditor();
2126
+ }
2127
+
2128
+ async function loadProjectSettings() {
2129
+ if (state.fullAccess || state.pageAccess.sandboxes) {
2130
+ try {
2131
+ await loadSandboxes();
2132
+ } catch {
2133
+ // Ignore sandbox list failures; project settings can still be edited.
2134
+ }
2135
+ }
2136
+ const config = await apiRequest(`${API_BASE}/project-settings`);
2137
+ state.projectSettingsDraft = normalizeProjectSettingsDraft(config);
2138
+ renderProjectSettingsEditor();
2139
+ }
2140
+
2141
+ function collectProjectSettingsPayload() {
2142
+ const draft = state.projectSettingsDraft || { users: [], profiles: [], bindings: [] };
2143
+ return {
2144
+ users: draft.users.map((user) => ({
2145
+ id: String(user.id || '').trim(),
2146
+ name: String(user.name || '').trim(),
2147
+ ips: Array.from(new Set((user.ips || []).map((ip) => String(ip || '').trim()).filter((ip) => Boolean(ip)))),
2148
+ })),
2149
+ profiles: draft.profiles.map((profile) => ({
2150
+ id: String(profile.id || '').trim(),
2151
+ name: String(profile.name || '').trim(),
2152
+ apply_to_all_users: Boolean(profile.apply_to_all_users),
2153
+ page_access: {
2154
+ sandboxes: Boolean(profile.page_access?.sandboxes),
2155
+ diaries: Boolean(profile.page_access?.diaries),
2156
+ changes: Boolean(profile.page_access?.changes),
2157
+ settings: Boolean(profile.page_access?.settings),
2158
+ },
2159
+ sandbox_access: {
2160
+ all: String(profile.sandbox_access?.all || 'none'),
2161
+ items: (profile.sandbox_access?.items || [])
2162
+ .map((item) => ({
2163
+ sandbox_id: String(item.sandbox_id || '').trim(),
2164
+ access: String(item.access || 'none'),
2165
+ }))
2166
+ .filter((item) => Boolean(item.sandbox_id)),
2167
+ },
2168
+ })),
2169
+ bindings: draft.bindings
2170
+ .map((binding) => ({
2171
+ user_id: String(binding.user_id || '').trim(),
2172
+ profile_ids: Array.from(new Set((binding.profile_ids || []).map((id) => String(id || '').trim()).filter((id) => Boolean(id)))),
2173
+ }))
2174
+ .filter((binding) => Boolean(binding.user_id)),
2175
+ };
1850
2176
  }
1851
2177
 
1852
2178
  function createSettingHeaderRow(key = '', value = '') {
@@ -1899,7 +2225,8 @@ function showPage(pageId) {
1899
2225
  }
1900
2226
 
1901
2227
  document.querySelectorAll('.nav-list a').forEach(a => a.classList.remove('active'));
1902
- const nav = document.querySelector(`[data-nav="${pageId}"]`);
2228
+ const navPageId = pageId === 'sandbox-detail' ? 'sandboxes' : pageId;
2229
+ const nav = document.querySelector(`[data-nav="${navPageId}"]`);
1903
2230
  if (nav) nav.classList.add('active');
1904
2231
  }
1905
2232
 
@@ -1913,6 +2240,7 @@ function editWorkItem(id) {
1913
2240
  document.getElementById('new-item-assignee').value = item.assignee;
1914
2241
  document.getElementById('new-item-status').value = item.status;
1915
2242
  document.getElementById('new-item-priority').value = item.priority;
2243
+ document.getElementById('new-item-parent').value = String(item.parent_id || '');
1916
2244
 
1917
2245
  document.getElementById('item-dialog').dataset.editId = id;
1918
2246
  document.getElementById('item-dialog').showModal();
@@ -2364,11 +2692,13 @@ async function initApp() {
2364
2692
  });
2365
2693
 
2366
2694
  document.getElementById('save-settings-btn')?.addEventListener('click', async () => {
2367
- if (state.readonly) return;
2695
+ if (state.readonly || !state.canAccessSystemSettings) return;
2368
2696
  const api_url = document.getElementById('setting-api-url').value;
2369
2697
  const api_key = document.getElementById('setting-api-key').value;
2370
2698
  const model = document.getElementById('setting-model').value;
2371
2699
  const headers = collectSettingHeadersFromUI();
2700
+ const editableIpsRaw = document.getElementById('setting-editable-ips')?.value || '';
2701
+ const editableIps = String(editableIpsRaw).split('\n').map((ip) => ip.trim()).filter((ip) => Boolean(ip));
2372
2702
 
2373
2703
  await apiRequest(`${API_BASE}/settings/api_url`, {
2374
2704
  method: 'PUT',
@@ -2388,18 +2718,239 @@ async function initApp() {
2388
2718
  method: 'PUT',
2389
2719
  body: JSON.stringify({ value: headers }),
2390
2720
  });
2721
+ await apiRequest(`${API_BASE}/settings/editable_ip_allowlist`, {
2722
+ method: 'PUT',
2723
+ body: JSON.stringify({ value: editableIps }),
2724
+ });
2391
2725
 
2392
2726
  alert('设置已保存');
2727
+ await loadRuntimeMode();
2728
+ applyReadonlyMode();
2393
2729
  await loadSettings();
2394
2730
  });
2395
2731
 
2396
2732
  document.getElementById('add-setting-header-btn')?.addEventListener('click', () => {
2397
- if (state.readonly) return;
2733
+ if (state.readonly || !state.canAccessSystemSettings) return;
2398
2734
  const list = document.getElementById('setting-headers-list');
2399
2735
  if (!(list instanceof HTMLElement)) return;
2400
2736
  list.appendChild(createSettingHeaderRow('', ''));
2401
2737
  });
2402
2738
 
2739
+ document.getElementById('add-project-user-btn')?.addEventListener('click', () => {
2740
+ if (!state.projectSettingsDraft) return;
2741
+ state.projectSettingsDraft.users.push({ id: '', name: '', ips: [] });
2742
+ renderProjectSettingsEditor();
2743
+ });
2744
+
2745
+ document.getElementById('add-project-profile-btn')?.addEventListener('click', () => {
2746
+ if (!state.projectSettingsDraft) return;
2747
+ state.projectSettingsDraft.profiles.push({
2748
+ id: '',
2749
+ name: '',
2750
+ apply_to_all_users: false,
2751
+ page_access: { sandboxes: true, diaries: true, changes: true, settings: false },
2752
+ sandbox_access: { all: 'none', items: [] },
2753
+ });
2754
+ renderProjectSettingsEditor();
2755
+ });
2756
+
2757
+ document.getElementById('add-project-binding-btn')?.addEventListener('click', () => {
2758
+ if (!state.projectSettingsDraft) return;
2759
+ state.projectSettingsDraft.bindings.push({ user_id: '', profile_ids: [] });
2760
+ renderProjectSettingsEditor();
2761
+ });
2762
+
2763
+ document.getElementById('project-users-list')?.addEventListener('click', (event) => {
2764
+ const target = event.target;
2765
+ if (!(target instanceof HTMLElement)) return;
2766
+ if (!state.projectSettingsDraft) return;
2767
+ if (target.dataset.role === 'add-project-user-ip') {
2768
+ const idx = Number(target.dataset.idx);
2769
+ const input = document.querySelector(`[data-role="project-user-ip-input"][data-idx="${idx}"]`);
2770
+ if (input instanceof HTMLInputElement) {
2771
+ addUserIpAtIndex(idx, input.value);
2772
+ }
2773
+ return;
2774
+ }
2775
+ if (target.dataset.role === 'remove-project-user-ip') {
2776
+ const idx = Number(target.dataset.idx);
2777
+ const ipIdx = Number(target.dataset.ipIdx);
2778
+ if (!Number.isFinite(idx) || idx < 0 || !Number.isFinite(ipIdx) || ipIdx < 0) return;
2779
+ const user = state.projectSettingsDraft.users[idx];
2780
+ if (!user) return;
2781
+ user.ips.splice(ipIdx, 1);
2782
+ renderProjectSettingsEditor();
2783
+ return;
2784
+ }
2785
+ if (target.dataset.role !== 'remove-project-user') return;
2786
+ const idx = Number(target.dataset.idx);
2787
+ if (!Number.isFinite(idx) || idx < 0) return;
2788
+ const removed = state.projectSettingsDraft.users[idx];
2789
+ state.projectSettingsDraft.users.splice(idx, 1);
2790
+ if (removed?.id) {
2791
+ state.projectSettingsDraft.bindings = (state.projectSettingsDraft.bindings || []).filter((binding) => binding.user_id !== removed.id);
2792
+ }
2793
+ renderProjectSettingsEditor();
2794
+ });
2795
+
2796
+ document.getElementById('project-users-list')?.addEventListener('input', (event) => {
2797
+ const target = event.target;
2798
+ if (!(target instanceof HTMLInputElement) || !state.projectSettingsDraft) return;
2799
+ const idx = Number(target.dataset.idx);
2800
+ if (!Number.isFinite(idx) || idx < 0) return;
2801
+ const user = state.projectSettingsDraft.users[idx];
2802
+ if (!user) return;
2803
+ if (target.dataset.role === 'project-user-id') {
2804
+ user.id = target.value.trim();
2805
+ } else if (target.dataset.role === 'project-user-name') {
2806
+ user.name = target.value.trim();
2807
+ }
2808
+ });
2809
+
2810
+ document.getElementById('project-users-list')?.addEventListener('keydown', (event) => {
2811
+ const target = event.target;
2812
+ if (!(target instanceof HTMLInputElement) || !state.projectSettingsDraft) return;
2813
+ if (target.dataset.role !== 'project-user-ip-input') return;
2814
+ if (event.key !== 'Enter') return;
2815
+ event.preventDefault();
2816
+ const idx = Number(target.dataset.idx);
2817
+ addUserIpAtIndex(idx, target.value);
2818
+ });
2819
+
2820
+ document.getElementById('project-profiles-list')?.addEventListener('click', (event) => {
2821
+ const target = event.target;
2822
+ if (!(target instanceof HTMLElement) || !state.projectSettingsDraft) return;
2823
+ const profileIdx = Number(target.dataset.idx);
2824
+ if (!Number.isFinite(profileIdx) || profileIdx < 0) return;
2825
+ if (target.dataset.role === 'remove-project-profile') {
2826
+ const removed = state.projectSettingsDraft.profiles[profileIdx];
2827
+ state.projectSettingsDraft.profiles.splice(profileIdx, 1);
2828
+ if (removed?.id) {
2829
+ state.projectSettingsDraft.bindings.forEach((binding) => {
2830
+ binding.profile_ids = (binding.profile_ids || []).filter((id) => id !== removed.id);
2831
+ });
2832
+ }
2833
+ renderProjectSettingsEditor();
2834
+ return;
2835
+ }
2836
+ if (target.dataset.role === 'add-project-profile-sandbox-item') {
2837
+ const profile = state.projectSettingsDraft.profiles[profileIdx];
2838
+ if (!profile) return;
2839
+ profile.sandbox_access.items.push({ sandbox_id: '', access: 'read' });
2840
+ renderProjectSettingsEditor();
2841
+ return;
2842
+ }
2843
+ if (target.dataset.role === 'remove-project-profile-sandbox-item') {
2844
+ const profile = state.projectSettingsDraft.profiles[profileIdx];
2845
+ if (!profile) return;
2846
+ const itemIdx = Number(target.dataset.itemIdx);
2847
+ if (!Number.isFinite(itemIdx) || itemIdx < 0) return;
2848
+ profile.sandbox_access.items.splice(itemIdx, 1);
2849
+ renderProjectSettingsEditor();
2850
+ }
2851
+ });
2852
+
2853
+ document.getElementById('project-profiles-list')?.addEventListener('input', (event) => {
2854
+ const target = event.target;
2855
+ if (!(target instanceof HTMLInputElement) || !state.projectSettingsDraft) return;
2856
+ const profileIdx = Number(target.dataset.idx);
2857
+ if (!Number.isFinite(profileIdx) || profileIdx < 0) return;
2858
+ const profile = state.projectSettingsDraft.profiles[profileIdx];
2859
+ if (!profile) return;
2860
+ if (target.dataset.role === 'project-profile-id') {
2861
+ profile.id = target.value.trim();
2862
+ return;
2863
+ }
2864
+ if (target.dataset.role === 'project-profile-name') {
2865
+ profile.name = target.value.trim();
2866
+ return;
2867
+ }
2868
+ if (target.dataset.role === 'project-profile-apply-all') {
2869
+ profile.apply_to_all_users = target.checked;
2870
+ return;
2871
+ }
2872
+ if (target.dataset.role === 'project-profile-page') {
2873
+ const page = target.dataset.page;
2874
+ if (page === 'sandboxes' || page === 'diaries' || page === 'changes' || page === 'settings') {
2875
+ profile.page_access[page] = target.checked;
2876
+ }
2877
+ return;
2878
+ }
2879
+ });
2880
+
2881
+ document.getElementById('project-profiles-list')?.addEventListener('change', (event) => {
2882
+ const target = event.target;
2883
+ if (!(target instanceof HTMLSelectElement) || !state.projectSettingsDraft) return;
2884
+ const profileIdx = Number(target.dataset.idx);
2885
+ if (!Number.isFinite(profileIdx) || profileIdx < 0) return;
2886
+ const profile = state.projectSettingsDraft.profiles[profileIdx];
2887
+ if (!profile) return;
2888
+ if (target.dataset.role === 'project-profile-all-access') {
2889
+ profile.sandbox_access.all = target.value || 'none';
2890
+ return;
2891
+ }
2892
+ if (target.dataset.role === 'project-profile-sandbox-id') {
2893
+ const itemIdx = Number(target.dataset.itemIdx);
2894
+ if (!Number.isFinite(itemIdx) || itemIdx < 0) return;
2895
+ const item = profile.sandbox_access.items[itemIdx];
2896
+ if (item) item.sandbox_id = String(target.value || '').trim();
2897
+ return;
2898
+ }
2899
+ if (target.dataset.role === 'project-profile-sandbox-access') {
2900
+ const itemIdx = Number(target.dataset.itemIdx);
2901
+ if (!Number.isFinite(itemIdx) || itemIdx < 0) return;
2902
+ const item = profile.sandbox_access.items[itemIdx];
2903
+ if (item) item.access = target.value || 'none';
2904
+ }
2905
+ });
2906
+
2907
+ document.getElementById('project-bindings-list')?.addEventListener('click', (event) => {
2908
+ const target = event.target;
2909
+ if (!(target instanceof HTMLElement) || !state.projectSettingsDraft) return;
2910
+ if (target.dataset.role !== 'remove-project-binding') return;
2911
+ const idx = Number(target.dataset.idx);
2912
+ if (!Number.isFinite(idx) || idx < 0) return;
2913
+ state.projectSettingsDraft.bindings.splice(idx, 1);
2914
+ renderProjectSettingsEditor();
2915
+ });
2916
+
2917
+ document.getElementById('project-bindings-list')?.addEventListener('change', (event) => {
2918
+ const target = event.target;
2919
+ if (!state.projectSettingsDraft) return;
2920
+ const idx = Number(target instanceof HTMLElement ? target.dataset.idx : NaN);
2921
+ if (!Number.isFinite(idx) || idx < 0) return;
2922
+ const binding = state.projectSettingsDraft.bindings[idx];
2923
+ if (!binding) return;
2924
+ if (target instanceof HTMLSelectElement && target.dataset.role === 'project-binding-user') {
2925
+ binding.user_id = target.value || '';
2926
+ return;
2927
+ }
2928
+ if (target instanceof HTMLInputElement && target.dataset.role === 'project-binding-profile') {
2929
+ const profileId = String(target.dataset.profileId || '').trim();
2930
+ const set = new Set(binding.profile_ids || []);
2931
+ if (target.checked) set.add(profileId);
2932
+ else set.delete(profileId);
2933
+ binding.profile_ids = Array.from(set);
2934
+ }
2935
+ });
2936
+
2937
+ document.getElementById('save-project-settings-btn')?.addEventListener('click', async () => {
2938
+ if (state.readonly || (!state.fullAccess && !state.pageAccess.settings)) return;
2939
+ try {
2940
+ const payload = collectProjectSettingsPayload();
2941
+ await apiRequest(`${API_BASE}/project-settings`, {
2942
+ method: 'PUT',
2943
+ body: JSON.stringify(payload),
2944
+ });
2945
+ alert('项目设置已保存');
2946
+ await loadRuntimeMode();
2947
+ applyReadonlyMode();
2948
+ await loadProjectSettings();
2949
+ } catch (error) {
2950
+ alert(`项目设置保存失败: ${error?.message || error}`);
2951
+ }
2952
+ });
2953
+
2403
2954
  document.getElementById('work-item-search')?.addEventListener('input', (e) => {
2404
2955
  state.workItemSearch = e.target.value || '';
2405
2956
  renderWorkTree();
@@ -2555,13 +3106,31 @@ async function initApp() {
2555
3106
  }
2556
3107
 
2557
3108
  if (pathHash === '/') {
2558
- showPage('sandboxes');
2559
- await loadSandboxes();
3109
+ if (state.pageAccess.sandboxes || state.fullAccess) {
3110
+ showPage('sandboxes');
3111
+ await loadSandboxes();
3112
+ } else if (state.pageAccess.diaries) {
3113
+ window.location.hash = '/diaries';
3114
+ } else if (state.pageAccess.changes) {
3115
+ window.location.hash = '/changes';
3116
+ } else if (state.pageAccess.settings) {
3117
+ window.location.hash = '/settings';
3118
+ } else if (state.canAccessSystemSettings) {
3119
+ window.location.hash = '/system-settings';
3120
+ }
2560
3121
  } else if (pathHash === '/sandboxes') {
3122
+ if (!state.pageAccess.sandboxes && !state.fullAccess) {
3123
+ window.location.hash = '/';
3124
+ return;
3125
+ }
2561
3126
  showPage('sandboxes');
2562
3127
  await loadSandboxes();
2563
3128
  } else if (pathHash.startsWith('/sandbox/')) {
2564
3129
  const id = decodeURIComponent(pathHash.split('/')[2] || '');
3130
+ if (!canReadSandboxById(id)) {
3131
+ window.location.hash = '/';
3132
+ return;
3133
+ }
2565
3134
  showPage('sandbox-detail');
2566
3135
  await loadSandbox(id);
2567
3136
  const targetNodeId = String(query.get('node_id') || '').trim();
@@ -2571,24 +3140,34 @@ async function initApp() {
2571
3140
  showNodeEntityDrawer(targetNodeId, preferredFilter);
2572
3141
  }
2573
3142
  } else if (pathHash === '/diaries') {
3143
+ if (!state.pageAccess.diaries && !state.fullAccess) {
3144
+ window.location.hash = '/';
3145
+ return;
3146
+ }
2574
3147
  showPage('diaries');
2575
3148
  await loadDiaries();
2576
3149
  await loadSandboxes();
2577
3150
  } else if (pathHash === '/changes') {
3151
+ if (!state.pageAccess.changes && !state.fullAccess) {
3152
+ window.location.hash = '/';
3153
+ return;
3154
+ }
2578
3155
  showPage('changes');
2579
3156
  await loadSandboxes();
2580
3157
  await loadChanges();
2581
3158
  } else if (pathHash === '/settings') {
2582
- if (state.readonly) {
2583
- if (window.location.hash !== '#/sandboxes') {
2584
- window.location.hash = '/sandboxes';
2585
- return;
2586
- }
2587
- showPage('sandboxes');
2588
- await loadSandboxes();
3159
+ if (!state.pageAccess.settings && !state.fullAccess) {
3160
+ window.location.hash = '/';
2589
3161
  return;
2590
3162
  }
2591
3163
  showPage('settings');
3164
+ await loadProjectSettings();
3165
+ } else if (pathHash === '/system-settings') {
3166
+ if (!state.canAccessSystemSettings) {
3167
+ window.location.hash = '/';
3168
+ return;
3169
+ }
3170
+ showPage('system-settings');
2592
3171
  await loadSettings();
2593
3172
  }
2594
3173
  }