@dsh-plus/secret-env 0.1.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/LICENSE +21 -0
- package/lib/client.js +992 -0
- package/lib/index.d.ts +100 -0
- package/lib/index.js +428 -0
- package/package.json +62 -0
- package/src/api.ts +154 -0
- package/src/client/api.ts +76 -0
- package/src/client/client.ts +77 -0
- package/src/client/common.ts +32 -0
- package/src/client/composer.tsx +260 -0
- package/src/client/i18n.ts +112 -0
- package/src/client/section.tsx +258 -0
- package/src/client/styles.ts +68 -0
- package/src/config.ts +34 -0
- package/src/errors.ts +12 -0
- package/src/index.ts +33 -0
- package/src/names.ts +45 -0
- package/src/ns.ts +6 -0
- package/src/service.ts +332 -0
package/src/api.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* secret-env HTTP 端点:GET list / POST global.set / global.unset / session.set / session.unset。
|
|
3
|
+
* 与 dsh web 同源(webServer 默认 loopback / 反代信任域),无独立鉴权
|
|
4
|
+
* (与 usage-panel/notify-email 自定义端点同一暴露面约定)。
|
|
5
|
+
* 值只在「浏览器 → 本端点 → 服务」链路出现,响应永不回显值。
|
|
6
|
+
* @module secret-env/api
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
12
|
+
import { SecretEnvError } from './errors.ts'
|
|
13
|
+
import { normalizeSuffix, validateSuffix } from './names.ts'
|
|
14
|
+
import type { SecretEnvService } from './service.ts'
|
|
15
|
+
|
|
16
|
+
const ROUTE = '/dsh-plus/secret-env'
|
|
17
|
+
|
|
18
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
19
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
20
|
+
res.end(JSON.stringify(body))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function readBody(req: IncomingMessage): Promise<string> {
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
const chunks: Buffer[] = []
|
|
26
|
+
req.on('data', (chunk: Buffer) => chunks.push(chunk))
|
|
27
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
28
|
+
req.on('error', reject)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 解析并校验请求里的变量名后缀;失败抛 SecretEnvError。 */
|
|
33
|
+
function readSuffix(raw: unknown): string {
|
|
34
|
+
if (typeof raw !== 'string') throw new SecretEnvError('invalid-name', 'name must be a string')
|
|
35
|
+
const suffix = normalizeSuffix(raw)
|
|
36
|
+
const failure = validateSuffix(suffix)
|
|
37
|
+
if (failure !== null) throw new SecretEnvError('invalid-name', `invalid name: ${failure}`)
|
|
38
|
+
return suffix
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readString(raw: unknown): string {
|
|
42
|
+
return typeof raw === 'string' ? raw : ''
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 错误 → HTTP 映射(值类错误 400,遮蔽/冲突 409,其余 500;均只带错误码与消息)。 */
|
|
46
|
+
function sendError(res: ServerResponse, error: unknown): void {
|
|
47
|
+
if (error instanceof SecretEnvError) {
|
|
48
|
+
const status = error.code === 'shadowed' || error.code === 'conflict' ? 409 : 400
|
|
49
|
+
sendJson(res, status, { ok: false, error: error.code, message: error.message })
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
sendJson(res, 500, { ok: false, error: 'internal' })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 注册端点(webServer 缺席时由调用方保证不调用)。 */
|
|
56
|
+
export function registerSecretEnvApi(ctx: Context, service: SecretEnvService): void {
|
|
57
|
+
const logger = ctx.logger('secret-env')
|
|
58
|
+
const guard = (label: string, run: () => Promise<void>, res: ServerResponse): void => {
|
|
59
|
+
run().catch((error: unknown) => {
|
|
60
|
+
if (!(error instanceof SecretEnvError)) {
|
|
61
|
+
logger.warn(`${label} failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
62
|
+
}
|
|
63
|
+
sendError(res, error)
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ctx.webServer.register({
|
|
68
|
+
kind: 'prefix',
|
|
69
|
+
path: `${ROUTE}/list`,
|
|
70
|
+
handler: async (req, res) => {
|
|
71
|
+
const url = new URL(req.url ?? '', 'http://localhost')
|
|
72
|
+
const sessionId = url.searchParams.get('sessionId') ?? undefined
|
|
73
|
+
guard('list', async () => sendJson(res, 200, await service.list(sessionId)), res)
|
|
74
|
+
},
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
ctx.webServer.register({
|
|
78
|
+
kind: 'prefix',
|
|
79
|
+
path: `${ROUTE}/global/set`,
|
|
80
|
+
handler: async (req, res) => {
|
|
81
|
+
guard(
|
|
82
|
+
'global/set',
|
|
83
|
+
async () => {
|
|
84
|
+
if (req.method !== 'POST') throw new SecretEnvError('method', 'POST only')
|
|
85
|
+
const body = JSON.parse(await readBody(req)) as Record<string, unknown>
|
|
86
|
+
await service.setGlobal(
|
|
87
|
+
readSuffix(body.name),
|
|
88
|
+
readString(body.value),
|
|
89
|
+
readString(body.description),
|
|
90
|
+
)
|
|
91
|
+
sendJson(res, 200, { ok: true })
|
|
92
|
+
},
|
|
93
|
+
res,
|
|
94
|
+
)
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
ctx.webServer.register({
|
|
99
|
+
kind: 'prefix',
|
|
100
|
+
path: `${ROUTE}/global/unset`,
|
|
101
|
+
handler: async (req, res) => {
|
|
102
|
+
guard(
|
|
103
|
+
'global/unset',
|
|
104
|
+
async () => {
|
|
105
|
+
if (req.method !== 'POST') throw new SecretEnvError('method', 'POST only')
|
|
106
|
+
const body = JSON.parse(await readBody(req)) as Record<string, unknown>
|
|
107
|
+
await service.unsetGlobal(readSuffix(body.name))
|
|
108
|
+
sendJson(res, 200, { ok: true })
|
|
109
|
+
},
|
|
110
|
+
res,
|
|
111
|
+
)
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
ctx.webServer.register({
|
|
116
|
+
kind: 'prefix',
|
|
117
|
+
path: `${ROUTE}/session/set`,
|
|
118
|
+
handler: async (req, res) => {
|
|
119
|
+
guard(
|
|
120
|
+
'session/set',
|
|
121
|
+
async () => {
|
|
122
|
+
if (req.method !== 'POST') throw new SecretEnvError('method', 'POST only')
|
|
123
|
+
const body = JSON.parse(await readBody(req)) as Record<string, unknown>
|
|
124
|
+
service.setSession(
|
|
125
|
+
readString(body.sessionId),
|
|
126
|
+
readSuffix(body.name),
|
|
127
|
+
readString(body.value),
|
|
128
|
+
readString(body.description),
|
|
129
|
+
body.once === true,
|
|
130
|
+
)
|
|
131
|
+
sendJson(res, 200, { ok: true })
|
|
132
|
+
},
|
|
133
|
+
res,
|
|
134
|
+
)
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
ctx.webServer.register({
|
|
139
|
+
kind: 'prefix',
|
|
140
|
+
path: `${ROUTE}/session/unset`,
|
|
141
|
+
handler: async (req, res) => {
|
|
142
|
+
guard(
|
|
143
|
+
'session/unset',
|
|
144
|
+
async () => {
|
|
145
|
+
if (req.method !== 'POST') throw new SecretEnvError('method', 'POST only')
|
|
146
|
+
const body = JSON.parse(await readBody(req)) as Record<string, unknown>
|
|
147
|
+
service.unsetSession(readString(body.sessionId), readSuffix(body.name))
|
|
148
|
+
sendJson(res, 200, { ok: true })
|
|
149
|
+
},
|
|
150
|
+
res,
|
|
151
|
+
)
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数据端点通道:GET list / POST set/unset(同源 fetch,值不上行到任何对话通道)。
|
|
3
|
+
* @module secret-env/client/api
|
|
4
|
+
*/
|
|
5
|
+
import { getJson, postJson } from '@dsh-plus/shared/client'
|
|
6
|
+
|
|
7
|
+
export interface GlobalWireEntry {
|
|
8
|
+
name: string
|
|
9
|
+
envName: string
|
|
10
|
+
description: string
|
|
11
|
+
configured: boolean
|
|
12
|
+
source?: string
|
|
13
|
+
writable: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SessionWireEntry {
|
|
17
|
+
name: string
|
|
18
|
+
envName: string
|
|
19
|
+
description: string
|
|
20
|
+
once: boolean
|
|
21
|
+
createdAt: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface SecretList {
|
|
25
|
+
global: GlobalWireEntry[]
|
|
26
|
+
session: SessionWireEntry[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface WriteResult {
|
|
30
|
+
ok: boolean
|
|
31
|
+
error?: string
|
|
32
|
+
message?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 端点错误:携带结构化错误码,UI 按码映射文案。 */
|
|
36
|
+
export class ApiError extends Error {
|
|
37
|
+
constructor(readonly code: string) {
|
|
38
|
+
super(code)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function fetchSecrets(sessionId?: string): Promise<SecretList> {
|
|
43
|
+
const query = sessionId === undefined ? '' : `?sessionId=${encodeURIComponent(sessionId)}`
|
|
44
|
+
return getJson<SecretList>(`/dsh-plus/secret-env/list${query}`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function write(path: string, body: Record<string, unknown>): Promise<void> {
|
|
48
|
+
try {
|
|
49
|
+
// 非 2xx 时 postJson 抛 Error(body.error),message 即结构化错误码。
|
|
50
|
+
await postJson<WriteResult>(`/dsh-plus/secret-env/${path}`, body)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new ApiError(error instanceof Error ? error.message : 'internal')
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function setGlobal(name: string, value: string, description: string): Promise<void> {
|
|
57
|
+
return write('global/set', { name, value, description })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function unsetGlobal(name: string): Promise<void> {
|
|
61
|
+
return write('global/unset', { name })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function setSession(
|
|
65
|
+
sessionId: string,
|
|
66
|
+
name: string,
|
|
67
|
+
value: string,
|
|
68
|
+
description: string,
|
|
69
|
+
once: boolean,
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
return write('session/set', { sessionId, name, value, description, once })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function unsetSession(sessionId: string, name: string): Promise<void> {
|
|
75
|
+
return write('session/unset', { sessionId, name })
|
|
76
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半入口:
|
|
3
|
+
* - settings.section 官方插槽注册「密钥变量」独立设置页(全局管理);
|
|
4
|
+
* - conversation.input.right session 作用域插槽注册 composer 钥匙胶囊
|
|
5
|
+
* (会话级密钥面板;会话 id 由插槽 inject 工厂直接供给)。
|
|
6
|
+
* 数据均经同源 HTTP 端点(值不上行经任何对话通道)。
|
|
7
|
+
* @module @dsh-plus/secret-env/client
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
10
|
+
|
|
11
|
+
import { ComposerSecret } from './composer.tsx'
|
|
12
|
+
import { en, NS, zh } from './i18n.ts'
|
|
13
|
+
import { SecretsSection } from './section.tsx'
|
|
14
|
+
import { injectSecretEnvStyle } from './styles.ts'
|
|
15
|
+
|
|
16
|
+
export const name = 'dsh-plus-secret-env'
|
|
17
|
+
|
|
18
|
+
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
19
|
+
export const inject = ['slots', 'locale'] as const
|
|
20
|
+
|
|
21
|
+
interface SlotsLike {
|
|
22
|
+
inject(key: string, callback: () => unknown): unknown
|
|
23
|
+
register(options: Record<string, unknown>, component: unknown): () => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface LocaleLike {
|
|
27
|
+
register(ns: string, dict: { zh: Record<string, string>; en: Record<string, string> }): () => void
|
|
28
|
+
bind(ns: string): (key: string) => string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ClientContext {
|
|
32
|
+
slots: SlotsLike
|
|
33
|
+
locale: LocaleLike
|
|
34
|
+
effect(execute: () => () => void, label?: string): unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function apply(ctx: Context): void {
|
|
38
|
+
const c = ctx as unknown as ClientContext
|
|
39
|
+
const styleTag = injectSecretEnvStyle('@dsh-plus/secret-env')
|
|
40
|
+
c.effect(
|
|
41
|
+
() => () => {
|
|
42
|
+
styleTag?.remove()
|
|
43
|
+
},
|
|
44
|
+
'secret-env: style',
|
|
45
|
+
)
|
|
46
|
+
c.effect(() => c.locale.register(NS, { zh, en }), 'secret-env: locale')
|
|
47
|
+
|
|
48
|
+
const t = c.locale.bind(NS)
|
|
49
|
+
// 独立设置页(settings.section:官方设置导航;order 16 排在用量统计之后)。
|
|
50
|
+
c.slots.inject('settings.section', () =>
|
|
51
|
+
c.slots.register(
|
|
52
|
+
{
|
|
53
|
+
name: 'settings.section',
|
|
54
|
+
id: 'dsh-plus-secret-env',
|
|
55
|
+
order: 16,
|
|
56
|
+
label: () => t('nav'),
|
|
57
|
+
inject: () => ({ t }),
|
|
58
|
+
},
|
|
59
|
+
SecretsSection,
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
// composer 工具行钥匙胶囊(conversation.input.right:session 作用域 list 槽,
|
|
64
|
+
// inject 工厂收框架解析的 sessionId)。
|
|
65
|
+
c.slots.inject('conversation.input.right', () =>
|
|
66
|
+
c.slots.register(
|
|
67
|
+
{
|
|
68
|
+
name: 'conversation.input.right',
|
|
69
|
+
id: 'dsh-plus-secret-env',
|
|
70
|
+
order: 40,
|
|
71
|
+
locale: NS,
|
|
72
|
+
inject: (sessionId: string) => ({ sessionId }),
|
|
73
|
+
},
|
|
74
|
+
ComposerSecret,
|
|
75
|
+
),
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半共享小件:错误码文案映射与复制助手(设置页与会话控件共用)。
|
|
3
|
+
* @module secret-env/client/common
|
|
4
|
+
*/
|
|
5
|
+
import { ApiError } from './api.ts'
|
|
6
|
+
|
|
7
|
+
/** 端点错误 → 本地化文案(未知码回落 internal)。 */
|
|
8
|
+
export function errorText(t: (key: string) => string, error: unknown): string {
|
|
9
|
+
const code = error instanceof ApiError ? error.code : 'internal'
|
|
10
|
+
const text = t(`error.${code}`)
|
|
11
|
+
// locale.bind 对缺失键原样回显键名,据此判断回落。
|
|
12
|
+
return text === `error.${code}` ? t('error.internal') : text
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 复制文本到剪贴板(clipboard API 缺席时退 execCommand)。 */
|
|
16
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
await navigator.clipboard.writeText(text)
|
|
19
|
+
return true
|
|
20
|
+
} catch {
|
|
21
|
+
// 回落:非安全上下文(如非 localhost 直连)无 clipboard API。
|
|
22
|
+
const area = document.createElement('textarea')
|
|
23
|
+
area.value = text
|
|
24
|
+
area.style.position = 'fixed'
|
|
25
|
+
area.style.opacity = '0'
|
|
26
|
+
document.body.appendChild(area)
|
|
27
|
+
area.select()
|
|
28
|
+
const ok = document.execCommand('copy')
|
|
29
|
+
area.remove()
|
|
30
|
+
return ok
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话注入控件(conversation.input.right 官方插槽,session 作用域):
|
|
3
|
+
* composer 工具行的钥匙胶囊,弹出本会话的会话级密钥面板(列表 + 增删)。
|
|
4
|
+
* 会话 id 由插槽 inject 工厂直接供给(框架解析的严格 session 作用域)。
|
|
5
|
+
* 窄屏(≤767px)popover 转底部抽屉;桌面点外部或 Esc 关闭。
|
|
6
|
+
* @module secret-env/client/composer
|
|
7
|
+
*/
|
|
8
|
+
import { type ReactElement, useEffect, useRef, useState } from 'react'
|
|
9
|
+
|
|
10
|
+
import { fetchSecrets, type SessionWireEntry, setSession, unsetSession } from './api.ts'
|
|
11
|
+
import { copyText, errorText } from './common.ts'
|
|
12
|
+
|
|
13
|
+
export interface ComposerSecretProps {
|
|
14
|
+
/** 插槽 inject 工厂注入的当前会话 id(严格 session 作用域,必存在)。 */
|
|
15
|
+
sessionId: string
|
|
16
|
+
t(key: string): string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface FormState {
|
|
20
|
+
name: string
|
|
21
|
+
value: string
|
|
22
|
+
description: string
|
|
23
|
+
once: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EMPTY_FORM: FormState = { name: '', value: '', description: '', once: false }
|
|
27
|
+
|
|
28
|
+
function SessionRow(props: {
|
|
29
|
+
t: ComposerSecretProps['t']
|
|
30
|
+
sessionId: string
|
|
31
|
+
entry: SessionWireEntry
|
|
32
|
+
onDeleted(): void
|
|
33
|
+
onError(text: string): void
|
|
34
|
+
}): ReactElement {
|
|
35
|
+
const { t, entry } = props
|
|
36
|
+
const [copied, setCopied] = useState(false)
|
|
37
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
38
|
+
|
|
39
|
+
useEffect(
|
|
40
|
+
() => () => {
|
|
41
|
+
if (timer.current !== null) clearTimeout(timer.current)
|
|
42
|
+
},
|
|
43
|
+
[],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="dse-row">
|
|
48
|
+
<div className="dse-rowMain">
|
|
49
|
+
<div className="dse-env">
|
|
50
|
+
<span className="dse-envName">${entry.envName}</span>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
className="dse-iconBtn"
|
|
54
|
+
title={t('copy')}
|
|
55
|
+
aria-label={t('copy')}
|
|
56
|
+
onClick={() => {
|
|
57
|
+
void copyText(`$${entry.envName}`).then((ok) => {
|
|
58
|
+
if (!ok) return
|
|
59
|
+
setCopied(true)
|
|
60
|
+
if (timer.current !== null) clearTimeout(timer.current)
|
|
61
|
+
timer.current = setTimeout(() => setCopied(false), 1500)
|
|
62
|
+
})
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{copied ? '✓' : '⧉'}
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
{entry.description !== '' ? <span className="dse-note">{entry.description}</span> : null}
|
|
69
|
+
</div>
|
|
70
|
+
<div className="dse-badges">
|
|
71
|
+
{entry.once ? <span className="dse-badge">{t('scopeOnce')}</span> : null}
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
className="dse-iconBtn dse-delBtn"
|
|
75
|
+
title={t('delete')}
|
|
76
|
+
aria-label={t('delete')}
|
|
77
|
+
onClick={() => {
|
|
78
|
+
unsetSession(props.sessionId, entry.name)
|
|
79
|
+
.then(() => props.onDeleted())
|
|
80
|
+
.catch((error: unknown) => props.onError(errorText(t, error)))
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
✕
|
|
84
|
+
</button>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function ComposerSecret(props: ComposerSecretProps): ReactElement {
|
|
91
|
+
const { sessionId, t } = props
|
|
92
|
+
const [open, setOpen] = useState(false)
|
|
93
|
+
const [items, setItems] = useState<SessionWireEntry[] | null>(null)
|
|
94
|
+
const [form, setForm] = useState<FormState>(EMPTY_FORM)
|
|
95
|
+
const [saving, setSaving] = useState(false)
|
|
96
|
+
const [status, setStatus] = useState<string | null>(null)
|
|
97
|
+
const wrapRef = useRef<HTMLSpanElement | null>(null)
|
|
98
|
+
|
|
99
|
+
const load = (): void => {
|
|
100
|
+
fetchSecrets(sessionId)
|
|
101
|
+
.then((list) => setItems(list.session))
|
|
102
|
+
.catch(() => setStatus(errorText(t, new Error('internal'))))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 打开面板与会话切换时拉取;闭合计时清零。
|
|
106
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: load 为闭包稳定函数,仅需随 open/sessionId 触发
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (!open) return
|
|
109
|
+
load()
|
|
110
|
+
}, [open, sessionId])
|
|
111
|
+
|
|
112
|
+
// 桌面:点外部 / Esc 关闭。
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!open) return
|
|
115
|
+
const onDown = (event: MouseEvent): void => {
|
|
116
|
+
if (wrapRef.current !== null && !wrapRef.current.contains(event.target as Node)) {
|
|
117
|
+
setOpen(false)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
121
|
+
if (event.key === 'Escape') setOpen(false)
|
|
122
|
+
}
|
|
123
|
+
document.addEventListener('mousedown', onDown)
|
|
124
|
+
document.addEventListener('keydown', onKey)
|
|
125
|
+
return () => {
|
|
126
|
+
document.removeEventListener('mousedown', onDown)
|
|
127
|
+
document.removeEventListener('keydown', onKey)
|
|
128
|
+
}
|
|
129
|
+
}, [open])
|
|
130
|
+
|
|
131
|
+
const onSave = (): void => {
|
|
132
|
+
setSaving(true)
|
|
133
|
+
setStatus(null)
|
|
134
|
+
setSession(sessionId, form.name, form.value, form.description, form.once)
|
|
135
|
+
.then(() => {
|
|
136
|
+
setForm(EMPTY_FORM)
|
|
137
|
+
load()
|
|
138
|
+
})
|
|
139
|
+
.catch((error: unknown) => setStatus(errorText(t, error)))
|
|
140
|
+
.finally(() => setSaving(false))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const count = items?.length ?? 0
|
|
144
|
+
const canSave = form.name.trim() !== '' && form.value !== '' && !saving
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<span className="dse-wrap" ref={wrapRef}>
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
className="dse-chip"
|
|
151
|
+
aria-expanded={open}
|
|
152
|
+
aria-label={t('sessionSecrets')}
|
|
153
|
+
title={t('sessionSecrets')}
|
|
154
|
+
onClick={() => setOpen(!open)}
|
|
155
|
+
>
|
|
156
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
157
|
+
<path
|
|
158
|
+
d="M14.5 2a7.5 7.5 0 0 0-7.36 9.04L2 20.19V22h1.81l1.5-1.5v-1.81h1.81v-1.81h1.81l1.22-1.22A7.5 7.5 0 1 0 14.5 2Zm2 5.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3Z"
|
|
159
|
+
fill="currentColor"
|
|
160
|
+
/>
|
|
161
|
+
</svg>
|
|
162
|
+
{count > 0 ? <span className="dse-count">{count}</span> : null}
|
|
163
|
+
</button>
|
|
164
|
+
{open ? (
|
|
165
|
+
<div className="dse-pop" role="dialog" aria-label={t('sessionSecrets')}>
|
|
166
|
+
<div className="dse-popHead">
|
|
167
|
+
<p className="dse-popTitle">{t('sessionSecrets')}</p>
|
|
168
|
+
<button
|
|
169
|
+
type="button"
|
|
170
|
+
className="dse-iconBtn"
|
|
171
|
+
title={t('refresh')}
|
|
172
|
+
aria-label={t('refresh')}
|
|
173
|
+
onClick={load}
|
|
174
|
+
>
|
|
175
|
+
⟳
|
|
176
|
+
</button>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
className="dse-iconBtn"
|
|
180
|
+
title={t('close')}
|
|
181
|
+
aria-label={t('close')}
|
|
182
|
+
onClick={() => setOpen(false)}
|
|
183
|
+
>
|
|
184
|
+
✕
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
<p className="dse-popHint">{t('sessionHint')}</p>
|
|
188
|
+
<div className="dse-popList">
|
|
189
|
+
{items === null || items.length === 0 ? (
|
|
190
|
+
<p className="dse-empty">{t('sessionEmpty')}</p>
|
|
191
|
+
) : (
|
|
192
|
+
items.map((entry) => (
|
|
193
|
+
<SessionRow
|
|
194
|
+
key={entry.name}
|
|
195
|
+
t={t}
|
|
196
|
+
sessionId={sessionId}
|
|
197
|
+
entry={entry}
|
|
198
|
+
onDeleted={load}
|
|
199
|
+
onError={(text) => setStatus(text === '' ? null : text)}
|
|
200
|
+
/>
|
|
201
|
+
))
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
<div className="dse-popForm">
|
|
205
|
+
<div className="dse-field">
|
|
206
|
+
<div className="dse-head">
|
|
207
|
+
<label className="dse-label" htmlFor="dse-s-name">
|
|
208
|
+
{t('addSession')}
|
|
209
|
+
</label>
|
|
210
|
+
</div>
|
|
211
|
+
<input
|
|
212
|
+
id="dse-s-name"
|
|
213
|
+
className="dse-input"
|
|
214
|
+
placeholder="API_TOKEN"
|
|
215
|
+
value={form.name}
|
|
216
|
+
onChange={(event) => setForm({ ...form, name: event.target.value })}
|
|
217
|
+
/>
|
|
218
|
+
<input
|
|
219
|
+
className="dse-input"
|
|
220
|
+
type="password"
|
|
221
|
+
autoComplete="off"
|
|
222
|
+
placeholder={t('valueLabel')}
|
|
223
|
+
value={form.value}
|
|
224
|
+
onChange={(event) => setForm({ ...form, value: event.target.value })}
|
|
225
|
+
/>
|
|
226
|
+
<input
|
|
227
|
+
className="dse-input"
|
|
228
|
+
placeholder={t('descLabel')}
|
|
229
|
+
value={form.description}
|
|
230
|
+
onChange={(event) => setForm({ ...form, description: event.target.value })}
|
|
231
|
+
/>
|
|
232
|
+
<div className="dse-checkRow">
|
|
233
|
+
<input
|
|
234
|
+
id="dse-s-once"
|
|
235
|
+
type="checkbox"
|
|
236
|
+
role="switch"
|
|
237
|
+
aria-checked={form.once}
|
|
238
|
+
checked={form.once}
|
|
239
|
+
onChange={(event) => setForm({ ...form, once: event.target.checked })}
|
|
240
|
+
/>
|
|
241
|
+
<label htmlFor="dse-s-once">{t('onceLabel')}</label>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
<div className="dse-foot">
|
|
245
|
+
{status !== null ? <p className="dse-status dse-statusError">{status}</p> : null}
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
className="dse-btn dse-btnPrimary"
|
|
249
|
+
disabled={!canSave}
|
|
250
|
+
onClick={onSave}
|
|
251
|
+
>
|
|
252
|
+
{saving ? t('saving') : t('save')}
|
|
253
|
+
</button>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
) : null}
|
|
258
|
+
</span>
|
|
259
|
+
)
|
|
260
|
+
}
|