dsh-computers 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -55,6 +55,10 @@ window.__ModuleLoader__.load({
55
55
  runtimeDisconnect: 'api/dsh-computers/runtime/disconnect',
56
56
  /* Per-session target computer for the composer chip (task-9 host side). */
57
57
  selection: 'api/dsh-computers/selection',
58
+ /* Access rules (task-11 host side): which computers a session may use,
59
+ * and which agent presets may use a computer. */
60
+ access: 'api/dsh-computers/access',
61
+ accessComputer: 'api/dsh-computers/access/computer',
58
62
  };
59
63
 
60
64
  // #region locales
@@ -65,7 +69,7 @@ window.__ModuleLoader__.load({
65
69
  title: '电脑',
66
70
  description: '把你自己电脑接进来,agent 就能在它上面直接执行命令。',
67
71
  'connect.action': '连接电脑',
68
- 'badge.bots': '{used}/{total} Bot',
72
+ 'badge.bots': '{used}/{total} 个 Bot',
69
73
  online: '在线',
70
74
  offline: '离线',
71
75
  'rename.action': '重命名',
@@ -168,6 +172,35 @@ window.__ModuleLoader__.load({
168
172
  'target.menuLabel': '选择这台会话使用的电脑',
169
173
  'target.chipLabel': '这台对话使用的电脑:{name}',
170
174
  'target.manage': '在 设置 → 电脑 中管理',
175
+ 'access.manage': '管理访问权',
176
+ 'access.session.title': '这台对话',
177
+ 'access.session.subtitle': '它可以使用哪些电脑?',
178
+ 'access.manageComputers': '管理电脑',
179
+ 'access.done': '完成',
180
+ 'access.cloudHint': '服务器本机',
181
+ 'access.noneHint': '关闭全部后,这台对话不能使用任何远程电脑',
182
+ 'access.computer.subtitle': '哪些 Bot 可以使用它?',
183
+ 'access.defaultPreset': '默认',
184
+ 'access.loading': '正在读取权限…',
185
+ 'access.loadFailed': '无法读取权限设置',
186
+ 'access.saveFailed': '保存失败:{message}',
187
+ 'access.badgeAria': '管理「{name}」的使用权限',
188
+ 'access.presetsEmpty': '还没有可用的 Bot 预设',
189
+ 'access.computersEmpty': '还没有连接的电脑',
190
+ 'access.computer.titleFallback': '这台电脑',
191
+ 'access.presetDenied': '该电脑的 Bot 权限未包含当前预设',
192
+ 'cap.exec': '执行',
193
+ 'cap.fs': '文件',
194
+ 'cap.pty': '终端',
195
+ 'drift.chip': '可升级',
196
+ 'drift.title': '升级这台电脑的 agent',
197
+ 'drift.body': '这台电脑运行的是 agent {current},需要更新的版本才能使用全部能力。',
198
+ 'drift.expected': '当前服务器使用 agent {version}。',
199
+ 'drift.commandLabel': '在要升级的电脑上运行',
200
+ 'drift.hint': '升级会重启这台电脑上的 agent 服务,正在执行的命令会中断。',
201
+ 'drift.close': '关闭',
202
+ 'drift.unknownVersion': '未知版本',
203
+ 'drift.runtimeHint': '本机 agent 版本落后于服务器,升级 DSH 后重启生效',
171
204
  'target.offline': '离线',
172
205
  'target.failed': '切换失败:{message}',
173
206
  };
@@ -280,6 +313,35 @@ window.__ModuleLoader__.load({
280
313
  'target.menuLabel': 'Choose the computer this session uses',
281
314
  'target.chipLabel': 'Computer for this chat: {name}',
282
315
  'target.manage': 'Manage in Settings → Computers',
316
+ 'access.manage': 'Manage access',
317
+ 'access.session.title': 'This chat',
318
+ 'access.session.subtitle': 'Which computers may it use?',
319
+ 'access.manageComputers': 'Manage computers',
320
+ 'access.done': 'Done',
321
+ 'access.cloudHint': 'Server itself',
322
+ 'access.noneHint': 'With everything off, this chat cannot use any remote computer',
323
+ 'access.computer.subtitle': 'Which Bots may use it?',
324
+ 'access.defaultPreset': 'Default',
325
+ 'access.loading': 'Reading access rules…',
326
+ 'access.loadFailed': 'Could not read the access rules',
327
+ 'access.saveFailed': 'Could not save: {message}',
328
+ 'access.badgeAria': 'Manage who may use “{name}”',
329
+ 'access.presetsEmpty': 'No Bot presets available yet',
330
+ 'access.computersEmpty': 'No computers connected yet',
331
+ 'access.computer.titleFallback': 'This computer',
332
+ 'access.presetDenied': 'This computer’s Bot access does not include the current preset',
333
+ 'cap.exec': 'Exec',
334
+ 'cap.fs': 'Files',
335
+ 'cap.pty': 'Terminal',
336
+ 'drift.chip': 'Upgradable',
337
+ 'drift.title': 'Upgrade this computer’s agent',
338
+ 'drift.body': 'This computer runs agent {current}; a newer version is needed for every capability.',
339
+ 'drift.expected': 'This server uses agent {version}.',
340
+ 'drift.commandLabel': 'Run this on the computer you want to upgrade',
341
+ 'drift.hint': 'Upgrading restarts the agent service on that computer; running commands are interrupted.',
342
+ 'drift.close': 'Close',
343
+ 'drift.unknownVersion': 'unknown version',
344
+ 'drift.runtimeHint': 'This computer’s agent is behind the server; update DSH and restart',
283
345
  'target.offline': 'Offline',
284
346
  'target.failed': 'Could not switch: {message}',
285
347
  };
@@ -579,6 +641,21 @@ window.__ModuleLoader__.load({
579
641
  font-weight: 500;
580
642
  white-space: nowrap;
581
643
  }
644
+ .dshc-badgeBtn {
645
+ border: none;
646
+ font-family: inherit;
647
+ cursor: pointer;
648
+ }
649
+ @media (hover: hover) {
650
+ .dshc-badgeBtn:hover {
651
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
652
+ color: var(--dsw-alias-label-primary);
653
+ }
654
+ }
655
+ .dshc-badgeBtn:focus-visible {
656
+ outline: 2px solid var(--dsw-alias-brand-primary);
657
+ outline-offset: 1px;
658
+ }
582
659
  .dshc-status {
583
660
  flex: none;
584
661
  display: inline-flex;
@@ -760,6 +837,9 @@ window.__ModuleLoader__.load({
760
837
  color: var(--dsw-alias-label-secondary);
761
838
  }
762
839
  .dshc-textBtn {
840
+ display: inline-flex;
841
+ align-items: center;
842
+ gap: 5px;
763
843
  flex: none;
764
844
  padding: 4px 9px;
765
845
  border: none;
@@ -1036,6 +1116,129 @@ window.__ModuleLoader__.load({
1036
1116
  gap: 8px;
1037
1117
  margin-top: 20px;
1038
1118
  }
1119
+ .dshc-footerSplit { justify-content: space-between; }
1120
+
1121
+ /* Access dialogs (task-12): one row per computer / per Bot preset. */
1122
+ .dshc-accessList {
1123
+ max-height: min(320px, calc(100vh - 280px));
1124
+ overflow-y: auto;
1125
+ margin: 2px -4px 0;
1126
+ padding: 0 4px;
1127
+ }
1128
+ .dshc-accessRow {
1129
+ display: flex;
1130
+ align-items: center;
1131
+ gap: 10px;
1132
+ min-height: 44px;
1133
+ padding: 6px 0;
1134
+ }
1135
+ .dshc-accessRow + .dshc-accessRow { border-top: 1px solid var(--dsw-alias-border-l1); }
1136
+ .dshc-accessIcon {
1137
+ flex: none;
1138
+ display: flex;
1139
+ color: var(--dsw-alias-label-secondary);
1140
+ }
1141
+ .dshc-accessText {
1142
+ display: flex;
1143
+ align-items: center;
1144
+ gap: 7px;
1145
+ min-width: 0;
1146
+ flex: 1 1 auto;
1147
+ }
1148
+ .dshc-accessName {
1149
+ min-width: 0;
1150
+ overflow: hidden;
1151
+ text-overflow: ellipsis;
1152
+ white-space: nowrap;
1153
+ font-size: 13px;
1154
+ line-height: 20px;
1155
+ font-weight: 500;
1156
+ color: var(--dsw-alias-label-primary);
1157
+ }
1158
+ .dshc-accessHint {
1159
+ flex: none;
1160
+ color: var(--dsw-alias-label-secondary);
1161
+ font-size: 11.5px;
1162
+ line-height: 16px;
1163
+ }
1164
+ .dshc-accessStatus {
1165
+ flex: none;
1166
+ display: inline-flex;
1167
+ align-items: center;
1168
+ gap: 5px;
1169
+ color: var(--dsw-alias-label-secondary);
1170
+ font-size: 11.5px;
1171
+ line-height: 16px;
1172
+ white-space: nowrap;
1173
+ }
1174
+ .dshc-accessEmpty {
1175
+ margin: 6px 0 0;
1176
+ color: var(--dsw-alias-label-secondary);
1177
+ font-size: 12.5px;
1178
+ line-height: 18px;
1179
+ }
1180
+ .dshc-switch:disabled { opacity: 0.6; cursor: not-allowed; }
1181
+
1182
+ /* Row capability chips + the amber upgrade chip (task-15). */
1183
+ .dshc-capChip {
1184
+ flex: none;
1185
+ padding: 0 5px;
1186
+ border-radius: 5px;
1187
+ background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2));
1188
+ color: var(--dsw-alias-label-secondary);
1189
+ font-size: 10.5px;
1190
+ line-height: 15px;
1191
+ font-weight: 500;
1192
+ white-space: nowrap;
1193
+ }
1194
+ .dshc-driftChip {
1195
+ flex: none;
1196
+ display: inline-flex;
1197
+ align-items: center;
1198
+ padding: 0 5px;
1199
+ border: 1px solid currentColor;
1200
+ border-radius: 5px;
1201
+ background: transparent;
1202
+ color: var(--dsw-alias-state-warn-primary);
1203
+ font: inherit;
1204
+ font-size: 10.5px;
1205
+ line-height: 15px;
1206
+ font-weight: 500;
1207
+ white-space: nowrap;
1208
+ cursor: pointer;
1209
+ }
1210
+ @media (hover: hover) {
1211
+ .dshc-driftChip:hover { background: var(--dsw-alias-interactive-bg-hover, var(--dsw-alias-bg-layer-2)); }
1212
+ }
1213
+ .dshc-driftChip:focus-visible { outline: 2px solid var(--dsw-alias-state-warn-primary); outline-offset: 1px; }
1214
+ .dshc-driftHint {
1215
+ margin: 8px 0 0;
1216
+ color: var(--dsw-alias-state-warn-primary);
1217
+ font-size: 11.5px;
1218
+ line-height: 16px;
1219
+ }
1220
+ .dshc-runtimeAgent {
1221
+ margin-top: 2px;
1222
+ color: var(--dsw-alias-label-secondary);
1223
+ font-size: 11.5px;
1224
+ line-height: 16px;
1225
+ overflow-wrap: anywhere;
1226
+ }
1227
+ .dshc-accessReason {
1228
+ margin: 2px 0 4px 25px;
1229
+ color: var(--dsw-alias-label-secondary);
1230
+ font-size: 11.5px;
1231
+ line-height: 16px;
1232
+ }
1233
+
1234
+ /* Composer menu: the manage-access row replaces the old muted footer. */
1235
+ .dshc-menuSep {
1236
+ height: 1px;
1237
+ margin: 4px 4px;
1238
+ background: var(--dsw-alias-border-l1);
1239
+ }
1240
+ .dshc-menuManage { color: var(--dsw-alias-label-primary); }
1241
+ .dshc-targetError[data-kind="info"] { color: var(--dsw-alias-label-secondary); }
1039
1242
 
1040
1243
  .dshc-seg {
1041
1244
  display: flex;
@@ -1337,10 +1540,22 @@ window.__ModuleLoader__.load({
1337
1540
  .dshc-copyRowKey {
1338
1541
  flex: 0 0 auto;
1339
1542
  min-width: 56px;
1543
+ white-space: nowrap;
1340
1544
  color: var(--dsw-alias-label-secondary);
1341
1545
  font-size: 11.5px;
1342
1546
  line-height: 16px;
1343
1547
  }
1548
+ /* Long label + long command (upgrade dialog): label on its own line, copy
1549
+ * pinned to the card's top-right corner. */
1550
+ .dshc-copyRow--stacked { position: relative; padding-right: 36px; }
1551
+ .dshc-copyRow--stacked .dshc-copyRowTop { flex-wrap: wrap; }
1552
+ .dshc-copyRow--stacked .dshc-copyRowBtn { position: absolute; top: 6px; right: 6px; }
1553
+ .dshc-copyRow--stacked .dshc-copyRowKey {
1554
+ flex: 0 0 100%;
1555
+ min-width: 0;
1556
+ margin-bottom: 2px;
1557
+ }
1558
+ .dshc-copyRow--stacked .dshc-copyRowHint { padding-left: 0; }
1344
1559
  .dshc-copyRowHint {
1345
1560
  margin: 0;
1346
1561
  padding-left: 64px;
@@ -1539,8 +1754,35 @@ window.__ModuleLoader__.load({
1539
1754
  body: { sessionId, computerId },
1540
1755
  signal,
1541
1756
  }),
1757
+ readAccess: (sessionId, signal) => requestJson(
1758
+ `${API.access}?sessionId=${encodeURIComponent(sessionId)}`,
1759
+ { signal },
1760
+ ),
1761
+ setAccess: (sessionId, allowedComputers, signal) => requestJson(API.access, {
1762
+ method: 'POST',
1763
+ body: { sessionId, allowedComputers },
1764
+ signal,
1765
+ }),
1766
+ readComputerAccess: (computerId, signal) => requestJson(
1767
+ `${API.accessComputer}?computerId=${encodeURIComponent(computerId)}`,
1768
+ { signal },
1769
+ ),
1770
+ setComputerAccess: (computerId, allowedPresets, signal) => requestJson(API.accessComputer, {
1771
+ method: 'POST',
1772
+ body: { computerId, allowedPresets },
1773
+ signal,
1774
+ }),
1542
1775
  };
1543
1776
 
1777
+ /* Capability ids → localized chip labels; unknown ids render raw. */
1778
+ const CAPABILITY_KEYS = { exec: 'cap.exec', fs: 'cap.fs', pty: 'cap.pty' };
1779
+
1780
+ /** @param {string} capability - capability id. @returns {string} chip text. */
1781
+ function capabilityLabel(capability) {
1782
+ const key = CAPABILITY_KEYS[capability];
1783
+ return key === undefined ? String(capability) : t(key);
1784
+ }
1785
+
1544
1786
  /** @param {object|null} data - a /state payload. @returns {string} 'hub' | 'runtime' | 'both'. */
1545
1787
  function roleOf(data) {
1546
1788
  if (data && typeof data.role === 'string' && data.role !== '') return data.role;
@@ -1802,7 +2044,7 @@ window.__ModuleLoader__.load({
1802
2044
  }
1803
2045
  };
1804
2046
  const action = t('connect.copyField', { field: props.label });
1805
- return h('div', { className: 'dshc-copyRow' },
2047
+ return h('div', { className: props.stacked === true ? 'dshc-copyRow dshc-copyRow--stacked' : 'dshc-copyRow' },
1806
2048
  h('div', { className: 'dshc-copyRowTop' },
1807
2049
  h('span', { className: 'dshc-copyRowKey' }, props.label),
1808
2050
  h('span', { className: 'dshc-copyRowValue' }, typeof props.value === 'string' && props.value !== '' ? props.value : '—'),
@@ -1835,16 +2077,41 @@ window.__ModuleLoader__.load({
1835
2077
  function ComputerRow(props) {
1836
2078
  const computer = props.computer;
1837
2079
  const online = computer.online === true;
1838
- const bots = computer.bots && typeof computer.bots === 'object' ? computer.bots : null;
2080
+ const bots = props.counts
2081
+ || (computer.bots && typeof computer.bots === 'object' ? computer.bots : null);
1839
2082
  const used = bots && typeof bots.used === 'number' ? bots.used : 1;
1840
2083
  const total = bots && typeof bots.total === 'number' ? bots.total : 1;
1841
2084
  const name = typeof computer.name === 'string' && computer.name !== ''
1842
2085
  ? computer.name
1843
2086
  : (typeof computer.hostname === 'string' ? computer.hostname : '');
2087
+ const capabilities = Array.isArray(computer.capabilities)
2088
+ ? computer.capabilities.filter((capability) => typeof capability === 'string' && capability !== '')
2089
+ : [];
2090
+ const drift = computer.updateRequired === true || computer.drift === true;
1844
2091
  return h('div', { className: 'dshc-row', 'data-online': online ? 'true' : 'false' },
1845
2092
  h('div', { className: 'dshc-rowMain' },
1846
2093
  h('span', { className: 'dshc-name', title: name }, name),
1847
- h('span', { className: 'dshc-badge' }, t('badge.bots', { used, total }))),
2094
+ h('button', {
2095
+ type: 'button',
2096
+ className: 'dshc-badge dshc-badgeBtn',
2097
+ 'aria-label': t('access.badgeAria', { name }),
2098
+ title: t('access.badgeAria', { name }),
2099
+ onClick: () => props.onAccess(computer),
2100
+ }, t('badge.bots', { used, total })),
2101
+ capabilities.map((capability) => h('span', {
2102
+ key: `cap-${capability}`,
2103
+ className: 'dshc-capChip',
2104
+ }, capabilityLabel(capability))),
2105
+ drift
2106
+ ? h('button', {
2107
+ key: 'drift',
2108
+ type: 'button',
2109
+ className: 'dshc-driftChip',
2110
+ 'aria-label': t('drift.title'),
2111
+ title: t('drift.title'),
2112
+ onClick: () => props.onUpgrade(computer),
2113
+ }, t('drift.chip'))
2114
+ : null),
1848
2115
  h('span', { className: 'dshc-status' },
1849
2116
  h('span', { className: 'dshc-dot', 'aria-hidden': 'true' }),
1850
2117
  online ? t('online') : t('offline')),
@@ -1913,7 +2180,9 @@ window.__ModuleLoader__.load({
1913
2180
  }, h(IconClose, { size: 16 }))),
1914
2181
  props.subtitle ? h('p', { className: 'dshc-cardSub' }, props.subtitle) : null,
1915
2182
  h('div', null, children),
1916
- footer.length > 0 ? h('div', { className: 'dshc-footer' }, footer) : null)));
2183
+ footer.length > 0
2184
+ ? h('div', { className: props.footerClassName ? `dshc-footer ${props.footerClassName}` : 'dshc-footer' }, footer)
2185
+ : null)));
1917
2186
  }
1918
2187
 
1919
2188
  function RenameDialog(props) {
@@ -2414,8 +2683,21 @@ window.__ModuleLoader__.load({
2414
2683
  h('p', { className: 'dshc-hint' }, t('runtime.hint')));
2415
2684
  } else {
2416
2685
  const name = typeof status.name === 'string' && status.name !== '' ? status.name : t('runtime.nameUnknown');
2686
+ const agentVersion = typeof status.agentVersion === 'string' && status.agentVersion !== ''
2687
+ ? status.agentVersion
2688
+ : '';
2689
+ const capabilities = Array.isArray(status.capabilities)
2690
+ ? status.capabilities.filter((capability) => typeof capability === 'string' && capability !== '')
2691
+ : [];
2692
+ const agentParts = [];
2693
+ if (agentVersion !== '') agentParts.push(`agent ${agentVersion}`);
2694
+ if (capabilities.length > 0) agentParts.push(capabilities.map(capabilityLabel).join(' / '));
2695
+ const drift = status.updateRequired === true || status.drift === true;
2417
2696
  body = h('div', null,
2418
2697
  h('div', { className: 'dshc-runtimeName', title: name }, name),
2698
+ agentParts.length > 0
2699
+ ? h('div', { className: 'dshc-runtimeAgent' }, agentParts.join(' · '))
2700
+ : null,
2419
2701
  h('div', { className: 'dshc-runtimeMeta' },
2420
2702
  h('span', { className: 'dshc-runtimeMetaKey' }, t('runtime.target')),
2421
2703
  h('span', { className: 'dshc-runtimeMetaValue' }, typeof status.serverUrl === 'string' ? status.serverUrl : '')),
@@ -2437,6 +2719,7 @@ window.__ModuleLoader__.load({
2437
2719
  disabled: busy,
2438
2720
  onClick: () => setConfirming(true),
2439
2721
  }, t('runtime.disconnect'))),
2722
+ drift ? h('p', { className: 'dshc-driftHint' }, t('drift.runtimeHint')) : null,
2440
2723
  status.lastError
2441
2724
  ? h('p', { className: 'dshc-formError', role: 'status' },
2442
2725
  t('runtime.lastError', { message: String(status.lastError) }))
@@ -2532,6 +2815,9 @@ window.__ModuleLoader__.load({
2532
2815
  const scope = React.useRef(null);
2533
2816
  const noticeTimer = React.useRef(null);
2534
2817
  const runtimeRequested = React.useRef(false);
2818
+ const accessCounts = React.useRef(new Map());
2819
+ const accessRequested = React.useRef(new Set());
2820
+ const [, setCountsVersion] = React.useState(0);
2535
2821
  if (scope.current === null) scope.current = createScope();
2536
2822
 
2537
2823
  const loadRuntime = React.useCallback(async () => {
@@ -2622,6 +2908,11 @@ window.__ModuleLoader__.load({
2622
2908
  const data = view.data;
2623
2909
  const role = data === null ? null : roleOf(data);
2624
2910
  const computers = data && Array.isArray(data.computers) ? data.computers : null;
2911
+
2912
+ const countsFor = (computer) => {
2913
+ const id = computer && computer.id ? String(computer.id) : '';
2914
+ return id === '' ? null : (accessCounts.current.get(id) || null);
2915
+ };
2625
2916
  const showRuntime = role !== null && role !== 'hub';
2626
2917
  const showComputers = role !== 'runtime';
2627
2918
  const canConnect = role !== 'runtime';
@@ -2642,8 +2933,11 @@ window.__ModuleLoader__.load({
2642
2933
  body = h('div', { className: 'dshc-list' }, computers.map((computer) => h(ComputerRow, {
2643
2934
  key: computer.id || computer.name,
2644
2935
  computer,
2936
+ counts: countsFor(computer),
2645
2937
  onRename: (target) => setDialog({ kind: 'rename', computer: target }),
2646
2938
  onDelete: (target) => setDialog({ kind: 'delete', computer: target }),
2939
+ onAccess: (target) => setDialog({ kind: 'access', computer: target }),
2940
+ onUpgrade: (target) => setDialog({ kind: 'upgrade', computer: target }),
2647
2941
  })));
2648
2942
  }
2649
2943
 
@@ -2660,6 +2954,24 @@ window.__ModuleLoader__.load({
2660
2954
  onClose: () => setDialog(null),
2661
2955
  onRenamed: handleRenamed,
2662
2956
  });
2957
+ } else if (dialog !== null && dialog.kind === 'access' && dialog.computer) {
2958
+ layer = h(ComputerAccessDialog, {
2959
+ computer: dialog.computer,
2960
+ onClose: () => setDialog(null),
2961
+ onChanged: (counts) => {
2962
+ const id = dialog.computer && dialog.computer.id ? String(dialog.computer.id) : '';
2963
+ if (id === '' || !counts) return;
2964
+ accessCounts.current.set(id, counts);
2965
+ setCountsVersion((value) => value + 1);
2966
+ },
2967
+ });
2968
+ } else if (dialog !== null && dialog.kind === 'upgrade' && dialog.computer) {
2969
+ layer = h(UpgradeDialog, {
2970
+ computer: dialog.computer,
2971
+ serverUrl: data && typeof data.serverUrl === 'string' ? data.serverUrl : '',
2972
+ expectedVersion: data && typeof data.agentVersion === 'string' ? data.agentVersion : '',
2973
+ onClose: () => setDialog(null),
2974
+ });
2663
2975
  } else if (dialog !== null && dialog.kind === 'delete' && dialog.computer) {
2664
2976
  layer = h(DeleteDialog, {
2665
2977
  computer: dialog.computer,
@@ -2668,6 +2980,33 @@ window.__ModuleLoader__.load({
2668
2980
  });
2669
2981
  }
2670
2982
 
2983
+ /* Real badge counts: one light GET per computer, cached for the life of
2984
+ * the page and refreshed by the access dialog. /state's sugar stays as
2985
+ * the fallback until the first read lands. Keyed by the id list so the
2986
+ * 5 s /state poll does not re-request. */
2987
+ React.useEffect(() => {
2988
+ if (computers === null) return;
2989
+ for (const computer of computers) {
2990
+ const id = computer && computer.id ? String(computer.id) : '';
2991
+ if (id === '' || accessCounts.current.has(id) || accessRequested.current.has(id)) continue;
2992
+ accessRequested.current.add(id);
2993
+ void (async () => {
2994
+ try {
2995
+ const payload = await scope.current.run((signal) => api.readComputerAccess(id, signal));
2996
+ const presets = payload && Array.isArray(payload.presets) ? payload.presets : null;
2997
+ if (presets === null || !alive.current) return;
2998
+ accessCounts.current.set(id, {
2999
+ used: presets.filter((preset) => preset.allowed === true).length,
3000
+ total: presets.length,
3001
+ });
3002
+ setCountsVersion((value) => value + 1);
3003
+ } catch (error) {
3004
+ /* Keep the /state fallback count for this computer. */
3005
+ }
3006
+ })();
3007
+ }
3008
+ }, [computers === null ? '' : computers.map((computer) => (computer && computer.id ? computer.id : '')).join(',')]);
3009
+
2671
3010
  return h('section', { className: 'dshc-root', 'aria-label': t('nav') },
2672
3011
  h('div', { className: 'dshc-head' },
2673
3012
  h('div', { className: 'dshc-headMain' },
@@ -2698,6 +3037,363 @@ window.__ModuleLoader__.load({
2698
3037
 
2699
3038
  // #endregion
2700
3039
 
3040
+ // #region access dialogs
3041
+
3042
+ /**
3043
+ * The on/off switch used by the access dialogs.
3044
+ * @param {object} props - `checked`, `label`, `onChange`, optional `disabled`.
3045
+ * @returns {object} switch button.
3046
+ */
3047
+ function SwitchControl(props) {
3048
+ return h('button', {
3049
+ type: 'button',
3050
+ className: 'dshc-switch',
3051
+ role: 'switch',
3052
+ 'aria-checked': props.checked === true ? 'true' : 'false',
3053
+ 'aria-label': props.label,
3054
+ disabled: props.disabled === true,
3055
+ onClick: props.disabled === true ? undefined : props.onChange,
3056
+ }, h('span', { className: 'dshc-switchThumb' }));
3057
+ }
3058
+
3059
+ /**
3060
+ * One dialog row: icon, name (+ hint / online state), trailing switch.
3061
+ * @param {object} props - `icon`, `name`, `hint`, `online`, `switch`.
3062
+ * @returns {object} row element.
3063
+ */
3064
+ function AccessRow(props) {
3065
+ return h('div', { className: 'dshc-accessRow' },
3066
+ props.icon ? h('span', { className: 'dshc-accessIcon', 'aria-hidden': 'true' }, props.icon) : null,
3067
+ h('div', { className: 'dshc-accessText' },
3068
+ h('span', { className: 'dshc-accessName', title: props.name }, props.name),
3069
+ props.hint ? h('span', { className: 'dshc-accessHint' }, props.hint) : null,
3070
+ props.online === undefined
3071
+ ? null
3072
+ : h('span', { className: 'dshc-accessStatus' },
3073
+ h('span', {
3074
+ className: 'dshc-dot',
3075
+ 'data-state': props.online === true ? 'running' : 'stopped',
3076
+ 'aria-hidden': 'true',
3077
+ }),
3078
+ props.online === true ? t('online') : t('offline'))),
3079
+ props.switch,
3080
+ props.reason ? h('p', { className: 'dshc-accessReason' }, props.reason) : null);
3081
+ }
3082
+
3083
+ /**
3084
+ * Agent upgrade dialog: the manual one-liner for a machine whose agent is
3085
+ * behind the server (task-14 drift flag).
3086
+ * @param {object} props - `computer`, `serverUrl`, `expectedVersion`, `onClose`.
3087
+ * @returns {object} dialog layer.
3088
+ */
3089
+ function UpgradeDialog(props) {
3090
+ const computer = props.computer || {};
3091
+ const serverUrl = typeof props.serverUrl === 'string' && props.serverUrl !== '' ? props.serverUrl : '';
3092
+ const command = `node ~/.dsh-computers/agent.mjs service stop && curl -fsSL '${serverUrl}/api/dsh-computers/agent.mjs' -o ~/.dsh-computers/agent.mjs && node ~/.dsh-computers/agent.mjs service start`;
3093
+ const current = typeof computer.agentVersion === 'string' && computer.agentVersion !== ''
3094
+ ? computer.agentVersion
3095
+ : t('drift.unknownVersion');
3096
+ const expected = typeof props.expectedVersion === 'string' && props.expectedVersion !== ''
3097
+ ? props.expectedVersion
3098
+ : '';
3099
+ return h(Dialog, {
3100
+ title: t('drift.title'),
3101
+ size: 'sm',
3102
+ onClose: props.onClose,
3103
+ footer: [
3104
+ h('button', {
3105
+ key: 'close',
3106
+ type: 'button',
3107
+ className: 'dshc-primary',
3108
+ onClick: props.onClose,
3109
+ }, t('drift.close')),
3110
+ ],
3111
+ },
3112
+ h('p', { className: 'dshc-dialogBody' }, t('drift.body', { current })),
3113
+ expected !== ''
3114
+ ? h('p', { className: 'dshc-dialogBody', style: { marginTop: '4px' } }, t('drift.expected', { version: expected }))
3115
+ : null,
3116
+ h('div', { className: 'dshc-copyStack', style: { marginTop: '12px' } },
3117
+ h(CopyRow, { label: t('drift.commandLabel'), value: command, stacked: true })),
3118
+ h('p', { className: 'dshc-hint' }, t('drift.hint')));
3119
+ }
3120
+
3121
+ /**
3122
+ * Session access dialog — which computers this conversation may use
3123
+ * (PROTOCOL access API, task-11). Toggles POST the whole allow-list.
3124
+ * @param {object} props - `sessionId`, `onClose`, `onSaved(payload)`, `onManageComputers()`.
3125
+ * @returns {object} dialog layer.
3126
+ */
3127
+ function SessionAccessDialog(props) {
3128
+ const [view, setView] = React.useState({ phase: 'loading', data: null });
3129
+ const [busy, setBusy] = React.useState(false);
3130
+ const [failure, setFailure] = React.useState(null);
3131
+ const alive = React.useRef(true);
3132
+ const scope = React.useRef(null);
3133
+ if (scope.current === null) scope.current = createScope();
3134
+
3135
+ React.useEffect(() => {
3136
+ alive.current = true;
3137
+ scope.current.run((signal) => api.readAccess(props.sessionId, signal))
3138
+ .then((payload) => {
3139
+ if (!alive.current) return;
3140
+ if (payload && typeof payload === 'object') setView({ phase: 'ready', data: payload });
3141
+ else setView({ phase: 'error', data: null });
3142
+ })
3143
+ .catch((error) => {
3144
+ if (!alive.current || (error && error.name === 'AbortError')) return;
3145
+ setView({ phase: 'error', data: null, error });
3146
+ });
3147
+ return () => {
3148
+ alive.current = false;
3149
+ scope.current.abortAll();
3150
+ };
3151
+ }, [props.sessionId]);
3152
+
3153
+ const computers = view.data && Array.isArray(view.data.computers) ? view.data.computers : null;
3154
+ const allowedCount = computers === null ? 0 : computers.filter((entry) => entry.allowed === true).length;
3155
+
3156
+ const toggle = async (computer) => {
3157
+ if (computers === null || busy) return;
3158
+ const previous = view.data;
3159
+ /* The switch acts on the SESSION rule: `sessionAllowed` is the raw
3160
+ * rule (null = all), while each row's `allowed` is the effective
3161
+ * (session AND preset) state. */
3162
+ const rawRule = Array.isArray(previous.sessionAllowed)
3163
+ ? previous.sessionAllowed
3164
+ : computers.map((entry) => entry.id);
3165
+ const ruleSet = new Set(rawRule);
3166
+ if (ruleSet.has(computer.id)) ruleSet.delete(computer.id);
3167
+ else ruleSet.add(computer.id);
3168
+ const nextIds = Array.from(ruleSet).sort();
3169
+ /* Allowing every known computer posts null ("all, including future
3170
+ * machines"); an explicit list would freeze the set. */
3171
+ const allOn = computers.length > 0 && nextIds.length === computers.length;
3172
+ const optimistic = {
3173
+ ...previous,
3174
+ sessionAllowed: allOn ? null : nextIds,
3175
+ computers: computers.map((entry) => ({
3176
+ ...entry,
3177
+ sessionAllowed: ruleSet.has(entry.id),
3178
+ allowed: ruleSet.has(entry.id) && entry.presetAllowed !== false,
3179
+ })),
3180
+ };
3181
+ setFailure(null);
3182
+ setView({ phase: 'ready', data: optimistic });
3183
+ setBusy(true);
3184
+ try {
3185
+ const result = await scope.current.run((signal) => api.setAccess(props.sessionId, allOn ? null : nextIds, signal));
3186
+ if (!alive.current) return;
3187
+ const next = result && typeof result === 'object' ? result : optimistic;
3188
+ setView({ phase: 'ready', data: next });
3189
+ if (typeof props.onSaved === 'function') props.onSaved(next);
3190
+ } catch (error) {
3191
+ if (!alive.current) return;
3192
+ if (error && error.name === 'AbortError') return;
3193
+ setView({ phase: 'ready', data: previous });
3194
+ setFailure(t('access.saveFailed', { message: messageOf(error) }));
3195
+ } finally {
3196
+ if (alive.current) setBusy(false);
3197
+ }
3198
+ };
3199
+
3200
+ let body;
3201
+ if (computers === null && view.phase === 'loading') {
3202
+ body = h('div', { className: 'dshc-accessRow' }, h(Spinner), h('span', { className: 'dshc-accessHint' }, t('access.loading')));
3203
+ } else if (computers === null) {
3204
+ body = h('div', { className: 'dshc-errorCard', role: 'alert' },
3205
+ h('span', { className: 'dshc-errorText' },
3206
+ t('error.loadDetail', { title: t('access.loadFailed'), message: view.error ? messageOf(view.error) : '' })));
3207
+ } else {
3208
+ const rows = [h(AccessRow, {
3209
+ key: 'cloud',
3210
+ icon: h(IconMonitor, { size: 15 }),
3211
+ name: t('target.cloud'),
3212
+ hint: t('access.cloudHint'),
3213
+ switch: h(SwitchControl, { checked: true, disabled: true, label: t('target.cloud') }),
3214
+ })].concat(computers.map((computer) => h(AccessRow, {
3215
+ key: computer.id || computer.name,
3216
+ icon: h(IconMonitor, { size: 15 }),
3217
+ name: typeof computer.name === 'string' && computer.name !== '' ? computer.name : String(computer.id || ''),
3218
+ online: computer.online === true,
3219
+ reason: computer.allowed !== true && computer.sessionAllowed === true && computer.presetAllowed === false
3220
+ ? t('access.presetDenied')
3221
+ : null,
3222
+ switch: h(SwitchControl, {
3223
+ checked: computer.allowed === true,
3224
+ disabled: busy,
3225
+ label: t('access.badgeAria', { name: computer.name || '' }),
3226
+ onChange: () => { void toggle(computer); },
3227
+ }),
3228
+ })));
3229
+ body = h('div', null,
3230
+ h('div', { className: 'dshc-accessList' }, rows),
3231
+ computers.length === 0 ? h('p', { className: 'dshc-accessEmpty' }, t('access.computersEmpty')) : null,
3232
+ computers.length > 0 && allowedCount === 0
3233
+ ? h('p', { className: 'dshc-hint' }, t('access.noneHint'))
3234
+ : null);
3235
+ }
3236
+
3237
+ return h(Dialog, {
3238
+ title: t('access.session.title'),
3239
+ subtitle: t('access.session.subtitle'),
3240
+ size: 'sm',
3241
+ footerClassName: 'dshc-footerSplit',
3242
+ onClose: props.onClose,
3243
+ footer: [
3244
+ h('button', {
3245
+ key: 'manage',
3246
+ type: 'button',
3247
+ className: 'dshc-textBtn',
3248
+ onClick: () => {
3249
+ props.onClose();
3250
+ if (typeof props.onManageComputers === 'function') props.onManageComputers();
3251
+ },
3252
+ }, h(IconGear, { size: 13 }), t('access.manageComputers')),
3253
+ h('button', {
3254
+ key: 'done',
3255
+ type: 'button',
3256
+ className: 'dshc-primary',
3257
+ onClick: props.onClose,
3258
+ }, t('access.done')),
3259
+ ],
3260
+ },
3261
+ body,
3262
+ failure !== null ? h('p', { className: 'dshc-formError', role: 'alert' }, failure) : null);
3263
+ }
3264
+
3265
+ /**
3266
+ * Computer access dialog — which agent presets ("Bots") may use this
3267
+ * computer (PROTOCOL access/computer, task-11).
3268
+ * @param {object} props - `computer`, `onClose`, `onChanged(counts)`.
3269
+ * @returns {object} dialog layer.
3270
+ */
3271
+ function ComputerAccessDialog(props) {
3272
+ const computerId = props.computer && props.computer.id ? String(props.computer.id) : '';
3273
+ const [view, setView] = React.useState({ phase: 'loading', data: null });
3274
+ const [busy, setBusy] = React.useState(false);
3275
+ const [failure, setFailure] = React.useState(null);
3276
+ const alive = React.useRef(true);
3277
+ const scope = React.useRef(null);
3278
+ if (scope.current === null) scope.current = createScope();
3279
+
3280
+ const report = (payload) => {
3281
+ if (typeof props.onChanged !== 'function') return;
3282
+ const list = payload && Array.isArray(payload.presets) ? payload.presets : null;
3283
+ if (list === null) return;
3284
+ props.onChanged({
3285
+ used: list.filter((entry) => entry.allowed === true).length,
3286
+ total: list.length,
3287
+ });
3288
+ };
3289
+
3290
+ React.useEffect(() => {
3291
+ alive.current = true;
3292
+ scope.current.run((signal) => api.readComputerAccess(computerId, signal))
3293
+ .then((payload) => {
3294
+ if (!alive.current) return;
3295
+ if (payload && typeof payload === 'object') {
3296
+ setView({ phase: 'ready', data: payload });
3297
+ report(payload);
3298
+ } else {
3299
+ setView({ phase: 'error', data: null });
3300
+ }
3301
+ })
3302
+ .catch((error) => {
3303
+ if (!alive.current || (error && error.name === 'AbortError')) return;
3304
+ setView({ phase: 'error', data: null, error });
3305
+ });
3306
+ return () => {
3307
+ alive.current = false;
3308
+ scope.current.abortAll();
3309
+ };
3310
+ }, [computerId]);
3311
+
3312
+ const presets = view.data && Array.isArray(view.data.presets) ? view.data.presets : null;
3313
+
3314
+ const toggle = async (preset) => {
3315
+ if (presets === null || busy) return;
3316
+ const previous = view.data;
3317
+ const rawRule = Array.isArray(previous.allowedPresets)
3318
+ ? previous.allowedPresets
3319
+ : presets.map((entry) => entry.id);
3320
+ const ruleSet = new Set(rawRule);
3321
+ if (ruleSet.has(preset.id)) ruleSet.delete(preset.id);
3322
+ else ruleSet.add(preset.id);
3323
+ const nextIds = Array.from(ruleSet).sort();
3324
+ /* All presets on posts null ("all, including presets added later"). */
3325
+ const allOn = presets.length > 0 && nextIds.length === presets.length;
3326
+ const optimistic = {
3327
+ ...previous,
3328
+ allowedPresets: allOn ? null : nextIds,
3329
+ presets: presets.map((entry) => ({ ...entry, allowed: ruleSet.has(entry.id) })),
3330
+ };
3331
+ setFailure(null);
3332
+ setView({ phase: 'ready', data: optimistic });
3333
+ setBusy(true);
3334
+ try {
3335
+ const result = await scope.current.run((signal) => api.setComputerAccess(computerId, allOn ? null : nextIds, signal));
3336
+ if (!alive.current) return;
3337
+ const next = result && typeof result === 'object' ? result : optimistic;
3338
+ setView({ phase: 'ready', data: next });
3339
+ report(next);
3340
+ } catch (error) {
3341
+ if (!alive.current) return;
3342
+ if (error && error.name === 'AbortError') return;
3343
+ setView({ phase: 'ready', data: previous });
3344
+ setFailure(t('access.saveFailed', { message: messageOf(error) }));
3345
+ } finally {
3346
+ if (alive.current) setBusy(false);
3347
+ }
3348
+ };
3349
+
3350
+ let body;
3351
+ if (presets === null && view.phase === 'loading') {
3352
+ body = h('div', { className: 'dshc-accessRow' }, h(Spinner), h('span', { className: 'dshc-accessHint' }, t('access.loading')));
3353
+ } else if (presets === null) {
3354
+ body = h('div', { className: 'dshc-errorCard', role: 'alert' },
3355
+ h('span', { className: 'dshc-errorText' },
3356
+ t('error.loadDetail', { title: t('access.loadFailed'), message: view.error ? messageOf(view.error) : '' })));
3357
+ } else if (presets.length === 0) {
3358
+ body = h('p', { className: 'dshc-accessEmpty' }, t('access.presetsEmpty'));
3359
+ } else {
3360
+ body = h('div', { className: 'dshc-accessList' }, presets.map((preset) => {
3361
+ const name = preset.id === 'default'
3362
+ ? t('access.defaultPreset')
3363
+ : (typeof preset.name === 'string' && preset.name !== '' ? preset.name : String(preset.id || ''));
3364
+ return h(AccessRow, {
3365
+ key: preset.id || preset.name,
3366
+ name,
3367
+ switch: h(SwitchControl, {
3368
+ checked: preset.allowed === true,
3369
+ disabled: busy,
3370
+ label: t('access.badgeAria', { name }),
3371
+ onChange: () => { void toggle(preset); },
3372
+ }),
3373
+ });
3374
+ }));
3375
+ }
3376
+
3377
+ return h(Dialog, {
3378
+ title: (props.computer && (props.computer.name || props.computer.hostname)) || t('access.computer.titleFallback', { id: computerId }),
3379
+ subtitle: t('access.computer.subtitle'),
3380
+ size: 'sm',
3381
+ onClose: props.onClose,
3382
+ footer: [
3383
+ h('button', {
3384
+ key: 'done',
3385
+ type: 'button',
3386
+ className: 'dshc-primary',
3387
+ onClick: props.onClose,
3388
+ }, t('access.done')),
3389
+ ],
3390
+ },
3391
+ body,
3392
+ failure !== null ? h('p', { className: 'dshc-formError', role: 'alert' }, failure) : null);
3393
+ }
3394
+
3395
+ // #endregion
3396
+
2701
3397
  // #region composer target chip
2702
3398
 
2703
3399
  /**
@@ -2718,6 +3414,9 @@ window.__ModuleLoader__.load({
2718
3414
  const [open, setOpen] = React.useState(false);
2719
3415
  const [anchor, setAnchor] = React.useState(null);
2720
3416
  const [failure, setFailure] = React.useState(null);
3417
+ const [allowedIds, setAllowedIds] = React.useState(null);
3418
+ const [accessOpen, setAccessOpen] = React.useState(false);
3419
+ const [notice, setNotice] = React.useState(null);
2721
3420
  const alive = React.useRef(true);
2722
3421
  const scope = React.useRef(null);
2723
3422
  const chipRef = React.useRef(null);
@@ -2725,6 +3424,7 @@ window.__ModuleLoader__.load({
2725
3424
  const pending = React.useRef(false);
2726
3425
  const initialFocus = React.useRef(0);
2727
3426
  const failureTimer = React.useRef(null);
3427
+ const noticeTimer = React.useRef(null);
2728
3428
  if (scope.current === null) scope.current = createScope();
2729
3429
 
2730
3430
  const applySelection = React.useCallback((result) => {
@@ -2734,6 +3434,23 @@ window.__ModuleLoader__.load({
2734
3434
  });
2735
3435
  }, []);
2736
3436
 
3437
+ /* Which computers this session may use. Unknown → show everything; the
3438
+ * host still enforces, and a denied computer is never offered. */
3439
+ const loadAccess = React.useCallback(async () => {
3440
+ if (sessionId === '') return;
3441
+ try {
3442
+ const payload = await scope.current.run((signal) => api.readAccess(sessionId, signal));
3443
+ if (!alive.current) return;
3444
+ const list = payload && Array.isArray(payload.computers) ? payload.computers : null;
3445
+ setAllowedIds(list === null ? null : list
3446
+ .filter((entry) => entry.allowed === true)
3447
+ .map((entry) => entry.id));
3448
+ } catch (error) {
3449
+ if (!alive.current) return;
3450
+ setAllowedIds(null);
3451
+ }
3452
+ }, [sessionId]);
3453
+
2737
3454
  /* One /state + /selection round per tick; a state failure hides the chip
2738
3455
  * (there is no honest target to display). */
2739
3456
  const load = React.useCallback(async () => {
@@ -2772,7 +3489,9 @@ window.__ModuleLoader__.load({
2772
3489
  setView({ phase: 'hidden', role: null, computers: [] });
2773
3490
  return () => { alive.current = false; };
2774
3491
  }
3492
+ setAllowedIds(null);
2775
3493
  void load();
3494
+ void loadAccess();
2776
3495
  const poll = setInterval(() => {
2777
3496
  if (alive.current) void load();
2778
3497
  }, STATE_POLL_MS);
@@ -2783,9 +3502,13 @@ window.__ModuleLoader__.load({
2783
3502
  clearTimeout(failureTimer.current);
2784
3503
  failureTimer.current = null;
2785
3504
  }
3505
+ if (noticeTimer.current !== null) {
3506
+ clearTimeout(noticeTimer.current);
3507
+ noticeTimer.current = null;
3508
+ }
2786
3509
  if (scope.current) scope.current.abortAll();
2787
3510
  };
2788
- }, [load, sessionId]);
3511
+ }, [load, loadAccess, sessionId]);
2789
3512
 
2790
3513
  const computers = view.computers;
2791
3514
  const selectedId = selection.computerId;
@@ -2805,7 +3528,9 @@ window.__ModuleLoader__.load({
2805
3528
  computer: null,
2806
3529
  offline: false,
2807
3530
  },
2808
- ].concat(computers.map((computer) => ({
3531
+ ].concat(computers
3532
+ .filter((computer) => allowedIds === null || allowedIds.indexOf(computer.id) >= 0)
3533
+ .map((computer) => ({
2809
3534
  key: String(computer.id || computer.name || ''),
2810
3535
  id: computer.id,
2811
3536
  label: typeof computer.name === 'string' && computer.name !== ''
@@ -2819,7 +3544,7 @@ window.__ModuleLoader__.load({
2819
3544
  const menuItems = () => {
2820
3545
  if (menuRef.current === null || typeof menuRef.current.querySelectorAll !== 'function') return [];
2821
3546
  try {
2822
- return Array.prototype.slice.call(menuRef.current.querySelectorAll('[role="menuitemradio"]'));
3547
+ return Array.prototype.slice.call(menuRef.current.querySelectorAll('[role="menuitemradio"], [role="menuitem"]'));
2823
3548
  } catch (error) {
2824
3549
  return [];
2825
3550
  }
@@ -2837,12 +3562,22 @@ window.__ModuleLoader__.load({
2837
3562
  };
2838
3563
 
2839
3564
  const openMenu = (preferLast) => {
3565
+ void loadAccess();
2840
3566
  const checked = menuRows.findIndex((row) => (row.id === null ? selectedId === null : row.id === selectedId));
2841
3567
  initialFocus.current = preferLast ? menuRows.length - 1 : (checked >= 0 ? checked : 0);
2842
3568
  setFailure(null);
2843
3569
  setOpen(true);
2844
3570
  };
2845
3571
 
3572
+ const showNotice = (text) => {
3573
+ setNotice(text);
3574
+ if (noticeTimer.current !== null) clearTimeout(noticeTimer.current);
3575
+ noticeTimer.current = setTimeout(() => {
3576
+ noticeTimer.current = null;
3577
+ if (alive.current) setNotice(null);
3578
+ }, 5000);
3579
+ };
3580
+
2846
3581
  const closeMenu = (restoreFocus) => {
2847
3582
  setOpen(false);
2848
3583
  if (!restoreFocus) return;
@@ -3031,7 +3766,17 @@ window.__ModuleLoader__.load({
3031
3766
  row.offline ? h('span', { className: 'dshc-menuHint' }, t('target.offline')) : null,
3032
3767
  checked ? h(IconCheck, { size: 13, className: 'dshc-menuCheck' }) : null);
3033
3768
  }),
3034
- h('div', { className: 'dshc-menuFooter' }, t('target.manage'))))
3769
+ h('div', { className: 'dshc-menuSep', role: 'separator' }),
3770
+ h('button', {
3771
+ key: 'manage-access',
3772
+ type: 'button',
3773
+ role: 'menuitem',
3774
+ className: 'dshc-menuItem dshc-menuManage',
3775
+ onClick: () => {
3776
+ setOpen(false);
3777
+ setAccessOpen(true);
3778
+ },
3779
+ }, h(IconGear, { size: 14 }), h('span', { className: 'dshc-menuName' }, t('access.manage')))))
3035
3780
  : null;
3036
3781
 
3037
3782
  return h('span', { className: 'dshc-targetWrap' },
@@ -3039,7 +3784,29 @@ window.__ModuleLoader__.load({
3039
3784
  failure !== null
3040
3785
  ? h('span', { className: 'dshc-targetError', role: 'status', title: failure }, failure)
3041
3786
  : null,
3042
- menu);
3787
+ notice !== null
3788
+ ? h('span', { className: 'dshc-targetError', 'data-kind': 'info', role: 'status', title: notice }, notice)
3789
+ : null,
3790
+ menu,
3791
+ accessOpen
3792
+ ? h(SessionAccessDialog, {
3793
+ sessionId,
3794
+ onClose: () => {
3795
+ setAccessOpen(false);
3796
+ try {
3797
+ if (chipRef.current && typeof chipRef.current.focus === 'function') chipRef.current.focus();
3798
+ } catch (error) {
3799
+ /* ignore */
3800
+ }
3801
+ },
3802
+ onSaved: (payload) => {
3803
+ const list = payload && Array.isArray(payload.computers) ? payload.computers : null;
3804
+ if (list === null) return;
3805
+ setAllowedIds(list.filter((entry) => entry.allowed === true).map((entry) => entry.id));
3806
+ },
3807
+ onManageComputers: () => showNotice(t('target.manage')),
3808
+ })
3809
+ : null);
3043
3810
  }
3044
3811
 
3045
3812
  // #endregion