@dsh-plus/secret-env 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,385 @@
1
+ /**
2
+ * 会话密钥面板(复用件):会话级密钥的列表/增删 + 全局与继承变量的
3
+ * 会话内屏蔽开关。两个宿主共用——overlay 槽的面板宿主(/secret 命令唤起,
4
+ * 传 onClose)与设置页的会话管理区(选择会话后内嵌,无头栏)。
5
+ * 值只经同源端点下行,绝不回显。
6
+ * @module secret-env/client/panel
7
+ */
8
+
9
+ import {
10
+ IconCheckOutline16,
11
+ IconCloseOutline16,
12
+ IconCopyOutline16,
13
+ IconEye,
14
+ IconEyeOff,
15
+ IconRefreshOutline16,
16
+ IconTrashOutline16,
17
+ } from '@dsh-plus/shared/client'
18
+ import { type ReactElement, useEffect, useRef, useState } from 'react'
19
+
20
+ import {
21
+ fetchSecrets,
22
+ type GlobalWireEntry,
23
+ type InheritedWireEntry,
24
+ type SecretList,
25
+ type SessionWireEntry,
26
+ setMask,
27
+ setSession,
28
+ unsetSession,
29
+ } from './api.ts'
30
+ import { copyText, errorText, liveName, nameErrorOf } from './common.ts'
31
+
32
+ export interface SessionSecretsPanelProps {
33
+ sessionId: string
34
+ t(key: string): string
35
+ /** 提供时渲染头栏(标题/刷新/关闭),供 overlay 弹层模式使用。 */
36
+ onClose?(): void
37
+ }
38
+
39
+ interface FormState {
40
+ name: string
41
+ value: string
42
+ description: string
43
+ once: boolean
44
+ }
45
+
46
+ const EMPTY_FORM: FormState = { name: '', value: '', description: '', once: false }
47
+
48
+ /** 复制按钮(复制成功短暂亮勾)。 */
49
+ function CopyButton(props: { t(key: string): string; text: string }): ReactElement {
50
+ const { t } = props
51
+ const [copied, setCopied] = useState(false)
52
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
53
+ useEffect(
54
+ () => () => {
55
+ if (timer.current !== null) clearTimeout(timer.current)
56
+ },
57
+ [],
58
+ )
59
+ return (
60
+ <button
61
+ type="button"
62
+ className="dse-iconBtn"
63
+ title={t('copy')}
64
+ aria-label={t('copy')}
65
+ onClick={() => {
66
+ void copyText(props.text).then((ok) => {
67
+ if (!ok) return
68
+ setCopied(true)
69
+ if (timer.current !== null) clearTimeout(timer.current)
70
+ timer.current = setTimeout(() => setCopied(false), 1500)
71
+ })
72
+ }}
73
+ >
74
+ {copied ? <IconCheckOutline16 size={14} /> : <IconCopyOutline16 size={14} />}
75
+ </button>
76
+ )
77
+ }
78
+
79
+ /** 会话内屏蔽开关(眼睛语义:可见=注入,划掉=本会话屏蔽)。 */
80
+ function MaskToggle(props: {
81
+ t(key: string): string
82
+ masked: boolean
83
+ onToggle(): void
84
+ }): ReactElement {
85
+ const { t, masked } = props
86
+ return (
87
+ <button
88
+ type="button"
89
+ className={`dse-iconBtn dse-maskBtn${masked ? ' dse-maskBtnOn' : ''}`}
90
+ title={masked ? t('unmaskSession') : t('maskSession')}
91
+ aria-label={masked ? t('unmaskSession') : t('maskSession')}
92
+ aria-pressed={masked}
93
+ onClick={props.onToggle}
94
+ >
95
+ {masked ? <IconEyeOff size={14} /> : <IconEye size={14} />}
96
+ </button>
97
+ )
98
+ }
99
+
100
+ function SessionRow(props: {
101
+ t: SessionSecretsPanelProps['t']
102
+ sessionId: string
103
+ entry: SessionWireEntry
104
+ onChanged(): void
105
+ onError(text: string): void
106
+ }): ReactElement {
107
+ const { t, entry } = props
108
+ return (
109
+ <div className="dse-row">
110
+ <div className="dse-rowMain">
111
+ <div className="dse-env">
112
+ <span className="dse-envName">${entry.envName}</span>
113
+ <CopyButton t={t} text={`$${entry.envName}`} />
114
+ </div>
115
+ {entry.description !== '' ? <span className="dse-note">{entry.description}</span> : null}
116
+ </div>
117
+ <div className="dse-badges">
118
+ {entry.once ? <span className="dse-badge">{t('scopeOnce')}</span> : null}
119
+ <button
120
+ type="button"
121
+ className="dse-iconBtn dse-delBtn"
122
+ title={t('delete')}
123
+ aria-label={t('delete')}
124
+ onClick={() => {
125
+ unsetSession(props.sessionId, entry.name)
126
+ .then(() => props.onChanged())
127
+ .catch((error: unknown) => props.onError(errorText(t, error)))
128
+ }}
129
+ >
130
+ <IconTrashOutline16 size={14} />
131
+ </button>
132
+ </div>
133
+ </div>
134
+ )
135
+ }
136
+
137
+ function GlobalRow(props: {
138
+ t: SessionSecretsPanelProps['t']
139
+ sessionId: string
140
+ entry: GlobalWireEntry
141
+ onChanged(): void
142
+ onError(text: string): void
143
+ }): ReactElement {
144
+ const { t, entry } = props
145
+ return (
146
+ <div className="dse-row">
147
+ <div className="dse-rowMain">
148
+ <div className="dse-env">
149
+ <span className={`dse-envName${entry.masked ? ' dse-envNameDim' : ''}`}>
150
+ ${entry.envName}
151
+ </span>
152
+ <CopyButton t={t} text={`$${entry.envName}`} />
153
+ </div>
154
+ {entry.description !== '' ? <span className="dse-note">{entry.description}</span> : null}
155
+ </div>
156
+ <div className="dse-badges">
157
+ {entry.masked ? <span className="dse-badge">{t('maskedBadge')}</span> : null}
158
+ <MaskToggle
159
+ t={t}
160
+ masked={entry.masked}
161
+ onToggle={() => {
162
+ setMask(entry.name, !entry.masked, props.sessionId)
163
+ .then(() => props.onChanged())
164
+ .catch((error: unknown) => props.onError(errorText(t, error)))
165
+ }}
166
+ />
167
+ </div>
168
+ </div>
169
+ )
170
+ }
171
+
172
+ function InheritedRow(props: {
173
+ t: SessionSecretsPanelProps['t']
174
+ sessionId: string
175
+ entry: InheritedWireEntry
176
+ onChanged(): void
177
+ onError(text: string): void
178
+ }): ReactElement {
179
+ const { t, entry } = props
180
+ return (
181
+ <div className="dse-row">
182
+ <div className="dse-rowMain">
183
+ <div className="dse-env">
184
+ <span className={`dse-envName${entry.masked ? ' dse-envNameDim' : ''}`}>
185
+ ${entry.envName}
186
+ </span>
187
+ <CopyButton t={t} text={`$${entry.envName}`} />
188
+ </div>
189
+ <span className="dse-note">{t('inheritedNote')}</span>
190
+ </div>
191
+ <div className="dse-badges">
192
+ {entry.globallyMasked ? (
193
+ <span className="dse-badge dse-badgeDim">{t('maskedGlobalBadge')}</span>
194
+ ) : (
195
+ <>
196
+ {entry.masked ? <span className="dse-badge">{t('maskedBadge')}</span> : null}
197
+ <MaskToggle
198
+ t={t}
199
+ masked={entry.masked}
200
+ onToggle={() => {
201
+ setMask(entry.name, !entry.masked, props.sessionId)
202
+ .then(() => props.onChanged())
203
+ .catch((error: unknown) => props.onError(errorText(t, error)))
204
+ }}
205
+ />
206
+ </>
207
+ )}
208
+ </div>
209
+ </div>
210
+ )
211
+ }
212
+
213
+ export function SessionSecretsPanel(props: SessionSecretsPanelProps): ReactElement {
214
+ const { sessionId, t } = props
215
+ const [data, setData] = useState<SecretList | null>(null)
216
+ const [form, setForm] = useState<FormState>(EMPTY_FORM)
217
+ const [saving, setSaving] = useState(false)
218
+ const [status, setStatus] = useState<string | null>(null)
219
+
220
+ const load = (): void => {
221
+ fetchSecrets(sessionId)
222
+ .then((list) => setData(list))
223
+ .catch(() => setStatus(errorText(t, new Error('internal'))))
224
+ }
225
+
226
+ // biome-ignore lint/correctness/useExhaustiveDependencies: load 为闭包稳定函数,仅需随会话切换重取
227
+ useEffect(() => {
228
+ load()
229
+ }, [sessionId])
230
+
231
+ const onSave = (): void => {
232
+ setSaving(true)
233
+ setStatus(null)
234
+ setSession(sessionId, form.name, form.value, form.description, form.once)
235
+ .then(() => {
236
+ setForm(EMPTY_FORM)
237
+ load()
238
+ })
239
+ .catch((error: unknown) => setStatus(errorText(t, error)))
240
+ .finally(() => setSaving(false))
241
+ }
242
+
243
+ const onError = (text: string): void => setStatus(text === '' ? null : text)
244
+ const nameError = nameErrorOf(form.name)
245
+ const canSave = form.name.trim() !== '' && nameError === null && form.value !== '' && !saving
246
+
247
+ return (
248
+ <div className="dse-panel">
249
+ {props.onClose !== undefined ? (
250
+ <div className="dse-popHead">
251
+ <p className="dse-popTitle">{t('sessionSecrets')}</p>
252
+ <button
253
+ type="button"
254
+ className="dse-iconBtn"
255
+ title={t('refresh')}
256
+ aria-label={t('refresh')}
257
+ onClick={load}
258
+ >
259
+ <IconRefreshOutline16 size={14} />
260
+ </button>
261
+ <button
262
+ type="button"
263
+ className="dse-iconBtn"
264
+ title={t('close')}
265
+ aria-label={t('close')}
266
+ onClick={props.onClose}
267
+ >
268
+ <IconCloseOutline16 size={14} />
269
+ </button>
270
+ </div>
271
+ ) : null}
272
+ <p className="dse-popHint">{t('sessionHint')}</p>
273
+ {status !== null ? <p className="dse-status dse-statusError">{status}</p> : null}
274
+
275
+ <h4 className="dse-groupLabel">{t('sessionList')}</h4>
276
+ <div className="dse-popList">
277
+ {data === null || data.session.length === 0 ? (
278
+ <p className="dse-empty">{t('sessionEmpty')}</p>
279
+ ) : (
280
+ data.session.map((entry) => (
281
+ <SessionRow
282
+ key={entry.name}
283
+ t={t}
284
+ sessionId={sessionId}
285
+ entry={entry}
286
+ onChanged={load}
287
+ onError={onError}
288
+ />
289
+ ))
290
+ )}
291
+ </div>
292
+ <div className="dse-popForm">
293
+ <div className="dse-field">
294
+ <div className="dse-head">
295
+ <label className="dse-label" htmlFor="dse-s-name">
296
+ {t('addSession')}
297
+ </label>
298
+ </div>
299
+ <input
300
+ id="dse-s-name"
301
+ className={`dse-input${nameError !== null ? ' dse-inputError' : ''}`}
302
+ placeholder="API_TOKEN"
303
+ value={form.name}
304
+ aria-invalid={nameError !== null}
305
+ onChange={(event) => setForm({ ...form, name: liveName(event.target.value) })}
306
+ />
307
+ {nameError !== null ? (
308
+ <p className="dse-hint dse-hintError">{t('error.invalid-name')}</p>
309
+ ) : null}
310
+ <input
311
+ className="dse-input"
312
+ type="password"
313
+ autoComplete="off"
314
+ placeholder={t('valueLabel')}
315
+ value={form.value}
316
+ onChange={(event) => setForm({ ...form, value: event.target.value })}
317
+ />
318
+ <input
319
+ className="dse-input"
320
+ placeholder={t('descLabel')}
321
+ value={form.description}
322
+ onChange={(event) => setForm({ ...form, description: event.target.value })}
323
+ />
324
+ <div className="dse-checkRow">
325
+ <input
326
+ id="dse-s-once"
327
+ type="checkbox"
328
+ role="switch"
329
+ aria-checked={form.once}
330
+ checked={form.once}
331
+ onChange={(event) => setForm({ ...form, once: event.target.checked })}
332
+ />
333
+ <label htmlFor="dse-s-once">{t('onceLabel')}</label>
334
+ </div>
335
+ </div>
336
+ <div className="dse-foot">
337
+ <button
338
+ type="button"
339
+ className="dse-btn dse-btnPrimary"
340
+ disabled={!canSave}
341
+ onClick={onSave}
342
+ >
343
+ {saving ? t('saving') : t('save')}
344
+ </button>
345
+ </div>
346
+ </div>
347
+
348
+ {data !== null && data.global.length > 0 ? (
349
+ <>
350
+ <h4 className="dse-groupLabel">{t('globalInSession')}</h4>
351
+ <div className="dse-popList">
352
+ {data.global.map((entry) => (
353
+ <GlobalRow
354
+ key={entry.name}
355
+ t={t}
356
+ sessionId={sessionId}
357
+ entry={entry}
358
+ onChanged={load}
359
+ onError={onError}
360
+ />
361
+ ))}
362
+ </div>
363
+ </>
364
+ ) : null}
365
+
366
+ {data !== null && data.inherited.length > 0 ? (
367
+ <>
368
+ <h4 className="dse-groupLabel">{t('inheritedInSession')}</h4>
369
+ <div className="dse-popList">
370
+ {data.inherited.map((entry) => (
371
+ <InheritedRow
372
+ key={entry.name}
373
+ t={t}
374
+ sessionId={sessionId}
375
+ entry={entry}
376
+ onChanged={load}
377
+ onError={onError}
378
+ />
379
+ ))}
380
+ </div>
381
+ </>
382
+ ) : null}
383
+ </div>
384
+ )
385
+ }
@@ -1,16 +1,46 @@
1
1
  /**
2
- * 密钥变量设置页(settings.section 官方插槽):全局密钥的列表与增删。
3
- * 只展示元数据与模型可见的完整变量名——值写入后不可回读(端点不出值)。
4
- * 响应式:≤767px 行转堆叠卡、表单转单列。
2
+ * 环境变量设置页(settings.section 官方插槽):
3
+ * - 全局变量:列表与增删(值写入后不可回读,端点不出值);
4
+ * - 继承变量:识别宿主进程环境的 DSH_VAR_*(默认纳入注入),可全局屏蔽;
5
+ * - 会话变量:经会话选择器内嵌会话面板(会话级/一次性变量的增删与屏蔽)。
6
+ * 只展示元数据与模型可见的完整变量名。响应式:≤767px 行转堆叠卡、表单单列。
5
7
  * @module secret-env/client/section
6
8
  */
7
- import { type ReactElement, useEffect, useRef, useState } from 'react'
8
9
 
9
- import { ApiError, fetchSecrets, type SecretList, setGlobal, unsetGlobal } from './api.ts'
10
- import { copyText, errorText } from './common.ts'
10
+ import {
11
+ IconCheckOutline16,
12
+ IconCopyOutline16,
13
+ IconEye,
14
+ IconEyeOff,
15
+ IconTrashOutline16,
16
+ } from '@dsh-plus/shared/client'
17
+ import { type ReactElement, useEffect, useRef, useState, useSyncExternalStore } from 'react'
18
+
19
+ import {
20
+ ApiError,
21
+ fetchSecrets,
22
+ type GlobalWireEntry,
23
+ type InheritedWireEntry,
24
+ type SecretList,
25
+ setGlobal,
26
+ setMask,
27
+ unsetGlobal,
28
+ } from './api.ts'
29
+ import { copyText, errorText, liveName, nameErrorOf } from './common.ts'
30
+ import { SessionSecretsPanel } from './panel.tsx'
31
+
32
+ /** sessions 服务的列表快照面(结构子集;ObservableSnapshot 契约)。 */
33
+ export interface SessionsListLike {
34
+ list: {
35
+ getSnapshot(): { current?: string; byId: Record<string, { id: string; displayTitle: string }> }
36
+ subscribe(listener: () => void): () => void
37
+ }
38
+ refresh(): Promise<void>
39
+ }
11
40
 
12
41
  export interface SectionProps {
13
42
  t(key: string): string
43
+ sessions?: SessionsListLike
14
44
  }
15
45
 
16
46
  interface FormState {
@@ -21,33 +51,46 @@ interface FormState {
21
51
 
22
52
  const EMPTY_FORM: FormState = { name: '', value: '', description: '' }
23
53
 
24
- /** 单行:完整变量名(复制)+ 描述 + 状态徽标 + 两步删除。 */
25
- function GlobalRow(props: {
26
- t: SectionProps['t']
27
- entry: SecretList['global'][number]
28
- onDeleted(): void
29
- onError(text: string): void
30
- }): ReactElement {
31
- const { t, entry } = props
32
- const [confirming, setConfirming] = useState(false)
54
+ /** 复制按钮(复制成功短暂亮勾)。 */
55
+ function CopyButton(props: { t(key: string): string; text: string }): ReactElement {
56
+ const { t } = props
33
57
  const [copied, setCopied] = useState(false)
34
58
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
35
-
36
59
  useEffect(
37
60
  () => () => {
38
61
  if (timer.current !== null) clearTimeout(timer.current)
39
62
  },
40
63
  [],
41
64
  )
65
+ return (
66
+ <button
67
+ type="button"
68
+ className="dse-iconBtn"
69
+ title={t('copy')}
70
+ aria-label={t('copy')}
71
+ onClick={() => {
72
+ void copyText(props.text).then((ok) => {
73
+ if (!ok) return
74
+ setCopied(true)
75
+ if (timer.current !== null) clearTimeout(timer.current)
76
+ timer.current = setTimeout(() => setCopied(false), 1500)
77
+ })
78
+ }}
79
+ >
80
+ {copied ? <IconCheckOutline16 size={14} /> : <IconCopyOutline16 size={14} />}
81
+ </button>
82
+ )
83
+ }
42
84
 
43
- const onCopy = (): void => {
44
- void copyText(`$${entry.envName}`).then((ok) => {
45
- if (!ok) return
46
- setCopied(true)
47
- if (timer.current !== null) clearTimeout(timer.current)
48
- timer.current = setTimeout(() => setCopied(false), 1500)
49
- })
50
- }
85
+ /** 全局变量行:完整变量名(复制)+ 描述 + 状态徽标 + 两步删除。 */
86
+ function GlobalRow(props: {
87
+ t: SectionProps['t']
88
+ entry: GlobalWireEntry
89
+ onDeleted(): void
90
+ onError(text: string): void
91
+ }): ReactElement {
92
+ const { t, entry } = props
93
+ const [confirming, setConfirming] = useState(false)
51
94
 
52
95
  const onDelete = (): void => {
53
96
  unsetGlobal(entry.name)
@@ -60,15 +103,7 @@ function GlobalRow(props: {
60
103
  <div className="dse-rowMain">
61
104
  <div className="dse-env">
62
105
  <span className="dse-envName">${entry.envName}</span>
63
- <button
64
- type="button"
65
- className="dse-iconBtn"
66
- title={t('copy')}
67
- aria-label={t('copy')}
68
- onClick={onCopy}
69
- >
70
- {copied ? '✓' : '⧉'}
71
- </button>
106
+ <CopyButton t={t} text={`$${entry.envName}`} />
72
107
  </div>
73
108
  {entry.description !== '' ? <span className="dse-note">{entry.description}</span> : null}
74
109
  </div>
@@ -102,7 +137,7 @@ function GlobalRow(props: {
102
137
  aria-label={t('delete')}
103
138
  onClick={() => setConfirming(true)}
104
139
  >
105
-
140
+ <IconTrashOutline16 size={14} />
106
141
  </button>
107
142
  )}
108
143
  </div>
@@ -110,6 +145,104 @@ function GlobalRow(props: {
110
145
  )
111
146
  }
112
147
 
148
+ /** 继承变量行:来源说明 + 全局屏蔽开关(眼睛语义)。 */
149
+ function InheritedRow(props: {
150
+ t: SectionProps['t']
151
+ entry: InheritedWireEntry
152
+ onChanged(): void
153
+ onError(text: string): void
154
+ }): ReactElement {
155
+ const { t, entry } = props
156
+ return (
157
+ <div className="dse-row">
158
+ <div className="dse-rowMain">
159
+ <div className="dse-env">
160
+ <span className={`dse-envName${entry.masked ? ' dse-envNameDim' : ''}`}>
161
+ ${entry.envName}
162
+ </span>
163
+ <CopyButton t={t} text={`$${entry.envName}`} />
164
+ </div>
165
+ <span className="dse-note">{t('inheritedNote')}</span>
166
+ </div>
167
+ <div className="dse-badges">
168
+ {entry.masked ? <span className="dse-badge">{t('maskedBadge')}</span> : null}
169
+ <button
170
+ type="button"
171
+ className={`dse-iconBtn dse-maskBtn${entry.masked ? ' dse-maskBtnOn' : ''}`}
172
+ title={entry.masked ? t('unmaskGlobal') : t('maskGlobal')}
173
+ aria-label={entry.masked ? t('unmaskGlobal') : t('maskGlobal')}
174
+ aria-pressed={entry.masked}
175
+ onClick={() => {
176
+ setMask(entry.name, !entry.masked)
177
+ .then(() => props.onChanged())
178
+ .catch((error: unknown) => props.onError(errorText(t, error)))
179
+ }}
180
+ >
181
+ {entry.masked ? <IconEyeOff size={14} /> : <IconEye size={14} />}
182
+ </button>
183
+ </div>
184
+ </div>
185
+ )
186
+ }
187
+
188
+ /** 会话变量管理块:会话选择器 + 内嵌会话面板。 */
189
+ function SessionManageBlock(props: {
190
+ t: SectionProps['t']
191
+ sessions: SessionsListLike
192
+ }): ReactElement {
193
+ const { t, sessions } = props
194
+ const snapshot = useSyncExternalStore(
195
+ (listener) => sessions.list.subscribe(listener),
196
+ () => sessions.list.getSnapshot(),
197
+ )
198
+ const [picked, setPicked] = useState<string>('')
199
+
200
+ // 首载刷新一次宿主权威列表。
201
+ // biome-ignore lint/correctness/useExhaustiveDependencies: 仅需挂载时一次
202
+ useEffect(() => {
203
+ void sessions.refresh().catch(() => {})
204
+ }, [])
205
+
206
+ const rows = Object.values(snapshot.byId)
207
+ const current = rows.find((row) => row.id === picked)?.id ?? snapshot.current ?? rows[0]?.id ?? ''
208
+
209
+ return (
210
+ <>
211
+ <h3 className="dse-groupLabel">{t('sessionManage')}</h3>
212
+ <div className="dse-form">
213
+ {rows.length === 0 ? (
214
+ <p className="dse-empty">{t('sessionPickEmpty')}</p>
215
+ ) : (
216
+ <>
217
+ <div className="dse-field">
218
+ <div className="dse-head">
219
+ <label className="dse-label" htmlFor="dse-session-pick">
220
+ {t('sessionPick')}
221
+ </label>
222
+ </div>
223
+ <select
224
+ id="dse-session-pick"
225
+ className="dse-input"
226
+ value={current}
227
+ onChange={(event) => setPicked(event.target.value)}
228
+ >
229
+ {rows.map((row) => (
230
+ <option key={row.id} value={row.id}>
231
+ {row.displayTitle}
232
+ </option>
233
+ ))}
234
+ </select>
235
+ </div>
236
+ {current !== '' ? (
237
+ <SessionSecretsPanel key={current} sessionId={current} t={t} />
238
+ ) : null}
239
+ </>
240
+ )}
241
+ </div>
242
+ </>
243
+ )
244
+ }
245
+
113
246
  export function SecretsSection(props: SectionProps): ReactElement {
114
247
  const { t } = props
115
248
  const [data, setData] = useState<SecretList | null>(null)
@@ -163,7 +296,8 @@ export function SecretsSection(props: SectionProps): ReactElement {
163
296
  )
164
297
  }
165
298
 
166
- const canSave = form.name.trim() !== '' && form.value !== '' && !saving
299
+ const nameError = nameErrorOf(form.name)
300
+ const canSave = form.name.trim() !== '' && nameError === null && form.value !== '' && !saving
167
301
  return (
168
302
  <div className="dse-section">
169
303
  <header className="dse-head">
@@ -188,6 +322,25 @@ export function SecretsSection(props: SectionProps): ReactElement {
188
322
  )}
189
323
  </div>
190
324
 
325
+ <h3 className="dse-groupLabel">{t('inheritedList')}</h3>
326
+ <div className="dse-rows">
327
+ {data.inherited.length === 0 ? (
328
+ <p className="dse-empty">{t('inheritedEmpty')}</p>
329
+ ) : (
330
+ data.inherited.map((entry) => (
331
+ <InheritedRow
332
+ key={entry.name}
333
+ t={t}
334
+ entry={entry}
335
+ onChanged={load}
336
+ onError={(text) => setStatus({ kind: 'err', text })}
337
+ />
338
+ ))
339
+ )}
340
+ </div>
341
+
342
+ {props.sessions !== undefined ? <SessionManageBlock t={t} sessions={props.sessions} /> : null}
343
+
191
344
  <h3 className="dse-groupLabel">{t('addGlobal')}</h3>
192
345
  <div className="dse-form">
193
346
  <div className="dse-formGrid">
@@ -199,12 +352,15 @@ export function SecretsSection(props: SectionProps): ReactElement {
199
352
  </div>
200
353
  <input
201
354
  id="dse-name"
202
- className="dse-input"
355
+ className={`dse-input${nameError !== null ? ' dse-inputError' : ''}`}
203
356
  value={form.name}
204
357
  placeholder="GITHUB_TOKEN"
205
- onChange={(event) => setForm({ ...form, name: event.target.value })}
358
+ aria-invalid={nameError !== null}
359
+ onChange={(event) => setForm({ ...form, name: liveName(event.target.value) })}
206
360
  />
207
- <p className="dse-hint">{t('nameHint')}</p>
361
+ <p className={`dse-hint${nameError !== null ? ' dse-hintError' : ''}`}>
362
+ {nameError !== null ? t('error.invalid-name') : t('nameHint')}
363
+ </p>
208
364
  </div>
209
365
  <div className="dse-field">
210
366
  <div className="dse-head">