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