@dsh-plus/secret-env 0.1.1 → 0.1.2
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/lib/client.js +1180 -633
- package/lib/index.d.ts +40 -8
- package/lib/index.js +211 -57
- package/package.json +6 -4
- package/src/api.ts +24 -1
- package/src/client/api.ts +18 -0
- package/src/client/client.ts +28 -21
- package/src/client/command.ts +62 -0
- package/src/client/common.ts +14 -1
- package/src/client/i18n.ts +66 -37
- package/src/client/menu-core.ts +2 -2
- package/src/client/menu.tsx +24 -8
- package/src/client/panel-bus.ts +23 -0
- package/src/client/panel-host.tsx +63 -0
- package/src/client/panel.tsx +385 -0
- package/src/client/section.tsx +194 -38
- package/src/client/styles.ts +10 -13
- package/src/config.ts +4 -0
- package/src/contributors.ts +115 -0
- package/src/index.ts +1 -1
- package/src/inventory.ts +101 -0
- package/src/names.ts +3 -3
- package/src/service.ts +107 -94
- package/src/client/composer.tsx +0 -260
|
@@ -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
|
+
}
|
package/src/client/section.tsx
CHANGED
|
@@ -1,16 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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 {
|
|
10
|
-
|
|
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
|
|
26
|
-
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
<
|
|
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
|
|
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=
|
|
355
|
+
className={`dse-input${nameError !== null ? ' dse-inputError' : ''}`}
|
|
203
356
|
value={form.name}
|
|
204
357
|
placeholder="GITHUB_TOKEN"
|
|
205
|
-
|
|
358
|
+
aria-invalid={nameError !== null}
|
|
359
|
+
onChange={(event) => setForm({ ...form, name: liveName(event.target.value) })}
|
|
206
360
|
/>
|
|
207
|
-
<p className=
|
|
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">
|