@dsh-plus/access-gate 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 +718 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.js +663 -0
- package/package.json +63 -0
- package/src/client/card.tsx +432 -0
- package/src/client/client.ts +156 -0
- package/src/client/i18n.ts +103 -0
- package/src/client/scope.ts +53 -0
- package/src/client/styles.ts +74 -0
- package/src/config.ts +61 -0
- package/src/decision.ts +137 -0
- package/src/index.ts +73 -0
- package/src/interceptor.ts +215 -0
- package/src/ip.ts +143 -0
- package/src/login-page.ts +77 -0
- package/src/ns.ts +7 -0
- package/src/routes.ts +201 -0
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dsh-plus/access-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "dsh-plus service+ui plugin: dsh web 全量访问围栏——本机直连永久放行,远程按白名单 IP(XFF 还原)或 token(登录页 + cookie)放行",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"default": "./lib/client.js"
|
|
15
|
+
},
|
|
16
|
+
"./src/*": "./src/*",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"lib",
|
|
21
|
+
"src"
|
|
22
|
+
],
|
|
23
|
+
"dsh": {
|
|
24
|
+
"client": {
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
27
|
+
"@deepseek-ai/dsh-client-locale",
|
|
28
|
+
"@deepseek-ai/dsh-client-connection",
|
|
29
|
+
"@deepseek-ai/dsh-api-remotes"
|
|
30
|
+
],
|
|
31
|
+
"platform": "web"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
37
|
+
"@deepseek-ai/dsh-home-paths": "^0.1.0-rc.8",
|
|
38
|
+
"@deepseek-ai/dsh-settings": "^0.1.0-rc.8",
|
|
39
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
43
|
+
"@deepseek-ai/dsh-home-paths": "0.1.1-rc.2",
|
|
44
|
+
"@deepseek-ai/dsh-settings": "0.1.1-rc.2",
|
|
45
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.1-rc.2",
|
|
46
|
+
"@deepseek-ai/schemastery": "3.18.1",
|
|
47
|
+
"@types/react": "~18.3.1",
|
|
48
|
+
"react": "^18.2.0"
|
|
49
|
+
},
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/A-G-guy/dsh-plus.git",
|
|
57
|
+
"directory": "packages/access-gate"
|
|
58
|
+
},
|
|
59
|
+
"scripts": {
|
|
60
|
+
"build": "tsdown",
|
|
61
|
+
"watch": "tsdown --watch"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「访问控制」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
|
|
3
|
+
* 交互对齐官方卡片:折叠/展开、staged draft、未保存标记、保存/放弃。
|
|
4
|
+
* 配置读写走官方 settingsScope 传输(scope.ts):value 为 schema 解析后的
|
|
5
|
+
* 脱敏视图(token 不出现,tokenConfigured 经 describe 的 secrets 探测),
|
|
6
|
+
* 保存经 settings.update 深合并(空 token 剔除 = 保持不变)。
|
|
7
|
+
* 卡片顶部「当前页面诊断」读 /dsh-plus/gate/status(本页放行原因/客户端 IP)。
|
|
8
|
+
* @module access-gate/client/card
|
|
9
|
+
*/
|
|
10
|
+
import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
|
|
11
|
+
|
|
12
|
+
import { SETTINGS_NS } from '../ns.ts'
|
|
13
|
+
import type { Scope, SettingsApi } from './scope.ts'
|
|
14
|
+
|
|
15
|
+
export interface CardProps {
|
|
16
|
+
t(key: string): string
|
|
17
|
+
scope: Scope
|
|
18
|
+
api: SettingsApi
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** settings 命名空间的脱敏解析值(token 被脱敏剥除)。 */
|
|
22
|
+
export interface ConfigValue {
|
|
23
|
+
enabled: boolean
|
|
24
|
+
allowedIps: string[]
|
|
25
|
+
trustForwardedFor: boolean
|
|
26
|
+
cookieMaxAgeHours: number
|
|
27
|
+
loginFailLimit: number
|
|
28
|
+
loginCooldownMs: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Draft {
|
|
32
|
+
enabled: boolean
|
|
33
|
+
token: string
|
|
34
|
+
allowedIpsText: string
|
|
35
|
+
trustForwardedFor: boolean
|
|
36
|
+
cookieMaxAgeHours: string
|
|
37
|
+
loginFailLimit: string
|
|
38
|
+
loginCooldownMs: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Status {
|
|
42
|
+
kind: 'idle' | 'ok' | 'error'
|
|
43
|
+
text: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Diag {
|
|
47
|
+
enabled: boolean
|
|
48
|
+
verdict: string
|
|
49
|
+
clientIp: string | null
|
|
50
|
+
reason: string | null
|
|
51
|
+
invalidEntries: string[]
|
|
52
|
+
tokenConfigured: boolean
|
|
53
|
+
allowedCount: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const IDLE_STATUS: Status = { kind: 'idle', text: '' }
|
|
57
|
+
|
|
58
|
+
function draftFromValue(value: ConfigValue): Draft {
|
|
59
|
+
return {
|
|
60
|
+
enabled: value.enabled,
|
|
61
|
+
token: '',
|
|
62
|
+
allowedIpsText: value.allowedIps.join('\n'),
|
|
63
|
+
trustForwardedFor: value.trustForwardedFor,
|
|
64
|
+
cookieMaxAgeHours: String(value.cookieMaxAgeHours),
|
|
65
|
+
loginFailLimit: String(value.loginFailLimit),
|
|
66
|
+
loginCooldownMs: String(value.loginCooldownMs),
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isPositiveInt(text: string): boolean {
|
|
71
|
+
return /^[0-9]+$/.test(text) && Number(text) > 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function toPatch(draft: Draft): Record<string, unknown> {
|
|
75
|
+
return {
|
|
76
|
+
enabled: draft.enabled,
|
|
77
|
+
allowedIps: draft.allowedIpsText
|
|
78
|
+
.split('\n')
|
|
79
|
+
.map((s) => s.trim())
|
|
80
|
+
.filter((s) => s.length > 0),
|
|
81
|
+
trustForwardedFor: draft.trustForwardedFor,
|
|
82
|
+
cookieMaxAgeHours: Number(draft.cookieMaxAgeHours),
|
|
83
|
+
loginFailLimit: Number(draft.loginFailLimit),
|
|
84
|
+
loginCooldownMs: Number(draft.loginCooldownMs),
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** reason 枚举 → 文案 key。 */
|
|
89
|
+
const REASON_KEYS: Record<string, string> = {
|
|
90
|
+
local: 'diagLocal',
|
|
91
|
+
'allowed-ip': 'diagAllowedIp',
|
|
92
|
+
token: 'diagToken',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function verdictLabel(t: (key: string) => string, diag: Diag): string {
|
|
96
|
+
if (!diag.enabled) return t('diagOff')
|
|
97
|
+
if (diag.verdict === 'pass') return t('diagPass')
|
|
98
|
+
if (diag.verdict === 'login') return t('diagLogin')
|
|
99
|
+
return t('diagBlock')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function AccessGateCard(props: CardProps): ReactElement | null {
|
|
103
|
+
const { t, scope, api } = props
|
|
104
|
+
const snapshot = useSyncExternalStore(
|
|
105
|
+
(listener: () => void) => scope.subscribe(listener),
|
|
106
|
+
() => scope.getSnapshot(),
|
|
107
|
+
)
|
|
108
|
+
const value = snapshot.value as ConfigValue | undefined
|
|
109
|
+
const [open, setOpen] = useState(false)
|
|
110
|
+
const [draft, setDraft] = useState<Draft | null>(null)
|
|
111
|
+
const [tokenConfigured, setTokenConfigured] = useState(false)
|
|
112
|
+
const [diag, setDiag] = useState<Diag | null>(null)
|
|
113
|
+
const [saving, setSaving] = useState(false)
|
|
114
|
+
const [status, setStatus] = useState<Status>(IDLE_STATUS)
|
|
115
|
+
|
|
116
|
+
// 首次拿到解析值后播种草稿;后续 Host 更新不覆盖在途编辑(与官方 staged 表单一致)。
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (value === undefined || draft !== null) return
|
|
119
|
+
setDraft(draftFromValue(value))
|
|
120
|
+
}, [value, draft])
|
|
121
|
+
|
|
122
|
+
// tokenConfigured 探测:经 describe 的 secrets 列表判断。
|
|
123
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: snapshot 变化(保存后 revision 推进)时刻意重探 secrets 状态
|
|
124
|
+
useEffect(() => {
|
|
125
|
+
let alive = true
|
|
126
|
+
api
|
|
127
|
+
.describe({})
|
|
128
|
+
.then((response) => {
|
|
129
|
+
if (!alive || !response.result.ok) return
|
|
130
|
+
const view = response.result.value?.namespaces.find((ns) => ns.ns === SETTINGS_NS)
|
|
131
|
+
setTokenConfigured(
|
|
132
|
+
view?.secrets.some((secret) => secret.path.join('.') === 'token' && secret.set) ?? false,
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
.catch(() => {})
|
|
136
|
+
return () => {
|
|
137
|
+
alive = false
|
|
138
|
+
}
|
|
139
|
+
}, [api, snapshot])
|
|
140
|
+
|
|
141
|
+
// 本页诊断:读围栏状态端点(配置保存后 snapshot 推进时重读)。
|
|
142
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: 配置保存后刻意重取诊断
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
let alive = true
|
|
145
|
+
fetch('/dsh-plus/gate/status')
|
|
146
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
147
|
+
.then((body: Diag | null) => {
|
|
148
|
+
if (alive && body !== null) setDiag(body)
|
|
149
|
+
})
|
|
150
|
+
.catch(() => {})
|
|
151
|
+
return () => {
|
|
152
|
+
alive = false
|
|
153
|
+
}
|
|
154
|
+
}, [snapshot])
|
|
155
|
+
|
|
156
|
+
const failClosed = useMemo(
|
|
157
|
+
() => value?.enabled === true && value.allowedIps.length === 0 && !tokenConfigured,
|
|
158
|
+
[value, tokenConfigured],
|
|
159
|
+
)
|
|
160
|
+
const dirty = useMemo(
|
|
161
|
+
() =>
|
|
162
|
+
value !== undefined &&
|
|
163
|
+
draft !== null &&
|
|
164
|
+
JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
|
|
165
|
+
[value, draft],
|
|
166
|
+
)
|
|
167
|
+
const invalid = useMemo(
|
|
168
|
+
() =>
|
|
169
|
+
draft !== null &&
|
|
170
|
+
(!isPositiveInt(draft.cookieMaxAgeHours) ||
|
|
171
|
+
!isPositiveInt(draft.loginFailLimit) ||
|
|
172
|
+
!isPositiveInt(draft.loginCooldownMs)),
|
|
173
|
+
[draft],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if (value === undefined || draft === null) {
|
|
177
|
+
return (
|
|
178
|
+
<li className="dag-card">
|
|
179
|
+
<p className="dag-readOnly">{t('loading')}</p>
|
|
180
|
+
</li>
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
const edit = <K extends keyof Draft>(key: K, editValue: Draft[K]): void => {
|
|
184
|
+
setDraft({ ...draft, [key]: editValue })
|
|
185
|
+
setStatus(IDLE_STATUS)
|
|
186
|
+
}
|
|
187
|
+
const onSave = (): void => {
|
|
188
|
+
setSaving(true)
|
|
189
|
+
const patch = toPatch(draft)
|
|
190
|
+
if (draft.token !== '') patch.token = draft.token
|
|
191
|
+
const revision = scope.getSnapshot().revision
|
|
192
|
+
api
|
|
193
|
+
.update({
|
|
194
|
+
ns: SETTINGS_NS,
|
|
195
|
+
patch,
|
|
196
|
+
...(revision !== undefined ? { expectedRevision: revision } : {}),
|
|
197
|
+
})
|
|
198
|
+
.then(async (response) => {
|
|
199
|
+
if (!response.result.ok) {
|
|
200
|
+
throw new Error(response.result.error?.message ?? t('saveFailed'))
|
|
201
|
+
}
|
|
202
|
+
await scope.load()
|
|
203
|
+
const next = scope.getSnapshot().value as ConfigValue | undefined
|
|
204
|
+
if (next !== undefined) setDraft(draftFromValue(next))
|
|
205
|
+
setStatus(IDLE_STATUS)
|
|
206
|
+
})
|
|
207
|
+
.catch((error: unknown) => {
|
|
208
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
209
|
+
setStatus({ kind: 'error', text: `${t('saveFailed')}${message}` })
|
|
210
|
+
})
|
|
211
|
+
.finally(() => setSaving(false))
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const disabled = !snapshot.writable
|
|
215
|
+
return (
|
|
216
|
+
<li className={`dag-card${open ? ' dag-cardOpen' : ''}`}>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
className="dag-header"
|
|
220
|
+
aria-expanded={open}
|
|
221
|
+
aria-label={`${t(open ? 'collapse' : 'expand')}: ${t('title')}`}
|
|
222
|
+
onClick={() => setOpen(!open)}
|
|
223
|
+
>
|
|
224
|
+
<span className="dag-headText">
|
|
225
|
+
<span className="dag-name">{t('title')}</span>
|
|
226
|
+
<span className="dag-description">{t('description')}</span>
|
|
227
|
+
</span>
|
|
228
|
+
{dirty ? <span className="dag-pending">{t('unsaved')}</span> : null}
|
|
229
|
+
<span className={`dag-chevron${open ? ' dag-chevronOpen' : ''}`}>▾</span>
|
|
230
|
+
</button>
|
|
231
|
+
{open ? (
|
|
232
|
+
<div className="dag-body">
|
|
233
|
+
{disabled ? (
|
|
234
|
+
<p className="dag-readOnly" role="status">
|
|
235
|
+
{t('readOnly')}
|
|
236
|
+
</p>
|
|
237
|
+
) : null}
|
|
238
|
+
{failClosed ? <p className="dag-warn">{t('warnFailClosed')}</p> : null}
|
|
239
|
+
<div className="dag-field">
|
|
240
|
+
<div className="dag-head">
|
|
241
|
+
<label className="dag-label" htmlFor="dag-enabled">
|
|
242
|
+
{t('enabled')}
|
|
243
|
+
</label>
|
|
244
|
+
</div>
|
|
245
|
+
<div className="dag-checkRow">
|
|
246
|
+
<input
|
|
247
|
+
id="dag-enabled"
|
|
248
|
+
type="checkbox"
|
|
249
|
+
checked={draft.enabled}
|
|
250
|
+
disabled={disabled}
|
|
251
|
+
onChange={(event) => edit('enabled', event.target.checked)}
|
|
252
|
+
/>
|
|
253
|
+
<label htmlFor="dag-enabled">{t('enabledHint')}</label>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
<div className="dag-field">
|
|
257
|
+
<div className="dag-head">
|
|
258
|
+
<label className="dag-label" htmlFor="dag-token">
|
|
259
|
+
{t('token')}
|
|
260
|
+
</label>
|
|
261
|
+
<span className={`dag-badge ${tokenConfigured ? 'dag-badgeSet' : 'dag-badgeUnset'}`}>
|
|
262
|
+
{t(tokenConfigured ? 'tokenSet' : 'tokenUnset')}
|
|
263
|
+
</span>
|
|
264
|
+
</div>
|
|
265
|
+
<input
|
|
266
|
+
id="dag-token"
|
|
267
|
+
className="dag-input"
|
|
268
|
+
type="password"
|
|
269
|
+
autoComplete="off"
|
|
270
|
+
value={draft.token}
|
|
271
|
+
disabled={disabled}
|
|
272
|
+
onChange={(event) => edit('token', event.target.value)}
|
|
273
|
+
/>
|
|
274
|
+
<p className="dag-hint">{t('tokenHint')}</p>
|
|
275
|
+
</div>
|
|
276
|
+
<div className="dag-field">
|
|
277
|
+
<div className="dag-head">
|
|
278
|
+
<label className="dag-label" htmlFor="dag-allowed">
|
|
279
|
+
{t('allowedIps')}
|
|
280
|
+
</label>
|
|
281
|
+
</div>
|
|
282
|
+
<textarea
|
|
283
|
+
id="dag-allowed"
|
|
284
|
+
className="dag-textarea"
|
|
285
|
+
rows={Math.min(8, Math.max(3, draft.allowedIpsText.split('\n').length))}
|
|
286
|
+
value={draft.allowedIpsText}
|
|
287
|
+
disabled={disabled}
|
|
288
|
+
onChange={(event) => edit('allowedIpsText', event.target.value)}
|
|
289
|
+
/>
|
|
290
|
+
<p className="dag-hint">{t('allowedIpsHint')}</p>
|
|
291
|
+
</div>
|
|
292
|
+
<div className="dag-field">
|
|
293
|
+
<div className="dag-head">
|
|
294
|
+
<label className="dag-label" htmlFor="dag-xff">
|
|
295
|
+
{t('trustForwardedFor')}
|
|
296
|
+
</label>
|
|
297
|
+
</div>
|
|
298
|
+
<div className="dag-checkRow">
|
|
299
|
+
<input
|
|
300
|
+
id="dag-xff"
|
|
301
|
+
type="checkbox"
|
|
302
|
+
checked={draft.trustForwardedFor}
|
|
303
|
+
disabled={disabled}
|
|
304
|
+
onChange={(event) => edit('trustForwardedFor', event.target.checked)}
|
|
305
|
+
/>
|
|
306
|
+
<label htmlFor="dag-xff">{t('trustForwardedForHint')}</label>
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
<div className="dag-field">
|
|
310
|
+
<div className="dag-head">
|
|
311
|
+
<label className="dag-label" htmlFor="dag-cookie">
|
|
312
|
+
{t('cookieMaxAgeHours')}
|
|
313
|
+
</label>
|
|
314
|
+
</div>
|
|
315
|
+
<input
|
|
316
|
+
id="dag-cookie"
|
|
317
|
+
className={`dag-input${isPositiveInt(draft.cookieMaxAgeHours) ? '' : ' dag-inputInvalid'}`}
|
|
318
|
+
type="text"
|
|
319
|
+
inputMode="numeric"
|
|
320
|
+
value={draft.cookieMaxAgeHours}
|
|
321
|
+
disabled={disabled}
|
|
322
|
+
aria-invalid={!isPositiveInt(draft.cookieMaxAgeHours) || undefined}
|
|
323
|
+
onChange={(event) => edit('cookieMaxAgeHours', event.target.value)}
|
|
324
|
+
/>
|
|
325
|
+
<p className={isPositiveInt(draft.cookieMaxAgeHours) ? 'dag-hint' : 'dag-invalid'}>
|
|
326
|
+
{isPositiveInt(draft.cookieMaxAgeHours)
|
|
327
|
+
? t('cookieMaxAgeHoursHint')
|
|
328
|
+
: t('invalidNumber')}
|
|
329
|
+
</p>
|
|
330
|
+
</div>
|
|
331
|
+
<div className="dag-field">
|
|
332
|
+
<div className="dag-head">
|
|
333
|
+
<label className="dag-label" htmlFor="dag-faillimit">
|
|
334
|
+
{t('loginFailLimit')}
|
|
335
|
+
</label>
|
|
336
|
+
</div>
|
|
337
|
+
<input
|
|
338
|
+
id="dag-faillimit"
|
|
339
|
+
className={`dag-input${isPositiveInt(draft.loginFailLimit) ? '' : ' dag-inputInvalid'}`}
|
|
340
|
+
type="text"
|
|
341
|
+
inputMode="numeric"
|
|
342
|
+
value={draft.loginFailLimit}
|
|
343
|
+
disabled={disabled}
|
|
344
|
+
aria-invalid={!isPositiveInt(draft.loginFailLimit) || undefined}
|
|
345
|
+
onChange={(event) => edit('loginFailLimit', event.target.value)}
|
|
346
|
+
/>
|
|
347
|
+
<p className={isPositiveInt(draft.loginFailLimit) ? 'dag-hint' : 'dag-invalid'}>
|
|
348
|
+
{isPositiveInt(draft.loginFailLimit) ? t('loginFailLimitHint') : t('invalidNumber')}
|
|
349
|
+
</p>
|
|
350
|
+
</div>
|
|
351
|
+
<div className="dag-field">
|
|
352
|
+
<div className="dag-head">
|
|
353
|
+
<label className="dag-label" htmlFor="dag-cooldown">
|
|
354
|
+
{t('loginCooldownMs')}
|
|
355
|
+
</label>
|
|
356
|
+
</div>
|
|
357
|
+
<input
|
|
358
|
+
id="dag-cooldown"
|
|
359
|
+
className={`dag-input${isPositiveInt(draft.loginCooldownMs) ? '' : ' dag-inputInvalid'}`}
|
|
360
|
+
type="text"
|
|
361
|
+
inputMode="numeric"
|
|
362
|
+
value={draft.loginCooldownMs}
|
|
363
|
+
disabled={disabled}
|
|
364
|
+
aria-invalid={!isPositiveInt(draft.loginCooldownMs) || undefined}
|
|
365
|
+
onChange={(event) => edit('loginCooldownMs', event.target.value)}
|
|
366
|
+
/>
|
|
367
|
+
<p className={isPositiveInt(draft.loginCooldownMs) ? 'dag-hint' : 'dag-invalid'}>
|
|
368
|
+
{isPositiveInt(draft.loginCooldownMs) ? t('loginCooldownMsHint') : t('invalidNumber')}
|
|
369
|
+
</p>
|
|
370
|
+
</div>
|
|
371
|
+
{diag !== null ? (
|
|
372
|
+
<div className={`dag-diag${diag.invalidEntries.length > 0 ? ' dag-diagWarn' : ''}`}>
|
|
373
|
+
<p className="dag-diagTitle">{t('diagTitle')}</p>
|
|
374
|
+
<div className="dag-diagRow">
|
|
375
|
+
<span className="dag-diagKey">{t('diagVerdict')}</span>
|
|
376
|
+
<span className="dag-diagVal">{verdictLabel(t, diag)}</span>
|
|
377
|
+
</div>
|
|
378
|
+
<div className="dag-diagRow">
|
|
379
|
+
<span className="dag-diagKey">{t('diagClientIp')}</span>
|
|
380
|
+
<span className="dag-diagVal">{diag.clientIp ?? '—'}</span>
|
|
381
|
+
</div>
|
|
382
|
+
<div className="dag-diagRow">
|
|
383
|
+
<span className="dag-diagKey">{t('diagReason')}</span>
|
|
384
|
+
<span className="dag-diagVal">
|
|
385
|
+
{diag.reason !== null ? t(REASON_KEYS[diag.reason] ?? diag.reason) : '—'}
|
|
386
|
+
</span>
|
|
387
|
+
</div>
|
|
388
|
+
{diag.invalidEntries.length > 0 ? (
|
|
389
|
+
<div className="dag-diagRow">
|
|
390
|
+
<span className="dag-diagKey">!</span>
|
|
391
|
+
<span className="dag-diagVal">
|
|
392
|
+
{t('diagInvalid')}
|
|
393
|
+
{diag.invalidEntries.join('、')}
|
|
394
|
+
</span>
|
|
395
|
+
</div>
|
|
396
|
+
) : null}
|
|
397
|
+
</div>
|
|
398
|
+
) : null}
|
|
399
|
+
<div className="dag-footer">
|
|
400
|
+
{status.kind !== 'idle' ? (
|
|
401
|
+
<p
|
|
402
|
+
className={`dag-status${status.kind === 'error' ? ' dag-statusError' : ''}`}
|
|
403
|
+
role="status"
|
|
404
|
+
>
|
|
405
|
+
{status.text}
|
|
406
|
+
</p>
|
|
407
|
+
) : null}
|
|
408
|
+
<button
|
|
409
|
+
type="button"
|
|
410
|
+
className="dag-btn dag-btnGhost"
|
|
411
|
+
disabled={!dirty || saving}
|
|
412
|
+
onClick={() => {
|
|
413
|
+
setDraft(draftFromValue(value))
|
|
414
|
+
setStatus(IDLE_STATUS)
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
{t('discard')}
|
|
418
|
+
</button>
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
className="dag-btn dag-btnPrimary"
|
|
422
|
+
disabled={!dirty || invalid || saving || disabled}
|
|
423
|
+
onClick={onSave}
|
|
424
|
+
>
|
|
425
|
+
{t(saving ? 'saving' : 'save')}
|
|
426
|
+
</button>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
) : null}
|
|
430
|
+
</li>
|
|
431
|
+
)
|
|
432
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半入口:注册 locale 字典 + 向 settings.plugin.item 插槽注册「访问控制」卡片。
|
|
3
|
+
* 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
|
|
4
|
+
* (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
|
|
5
|
+
*
|
|
6
|
+
* 类型说明:浏览器半只用到 slots/locale/connection/remote 的很窄一面,
|
|
7
|
+
* 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
|
|
8
|
+
* client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
|
|
9
|
+
* settings.plugin.item 插槽与 dsh-client-connection 的 settings RPC 面为准。
|
|
10
|
+
* rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
|
|
11
|
+
* (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
|
|
12
|
+
* 官方配置页按此 key 与 Host 已注册命名空间配对分发。
|
|
13
|
+
* 配置读写经官方 settings RPC 直连(rc7 起第三方命名空间全量开放;
|
|
14
|
+
* 不复用 settingsScope 服务——非 loopback 页面下它无数据,见 scope.ts)。
|
|
15
|
+
* @module @dsh-plus/access-gate/client
|
|
16
|
+
*/
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
+
|
|
19
|
+
import { SETTINGS_NS } from '../ns.ts'
|
|
20
|
+
import { AccessGateCard } from './card.tsx'
|
|
21
|
+
import { en, NS, zh } from './i18n.ts'
|
|
22
|
+
import type { Scope, ScopeSnapshot, SettingsApi } from './scope.ts'
|
|
23
|
+
import { injectStyle } from './styles.ts'
|
|
24
|
+
|
|
25
|
+
export const name = 'dsh-plus-access-gate'
|
|
26
|
+
|
|
27
|
+
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
28
|
+
export const inject = ['slots', 'locale', 'connection', 'remote'] as const
|
|
29
|
+
|
|
30
|
+
interface SlotsLike {
|
|
31
|
+
inject(key: string, callback: () => unknown): unknown
|
|
32
|
+
register(options: Record<string, unknown>, component: unknown): () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface LocaleLike {
|
|
36
|
+
register(ns: string, dict: { zh: Record<string, string>; en: Record<string, string> }): () => void
|
|
37
|
+
bind(ns: string): (key: string) => string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface RemoteLike {
|
|
41
|
+
$on(event: string, listener: (payload?: unknown) => void): () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ConnectionLike {
|
|
45
|
+
api: { settings: SettingsApi }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ClientContext {
|
|
49
|
+
slots: SlotsLike
|
|
50
|
+
locale: LocaleLike
|
|
51
|
+
get(key: 'connection'): ConnectionLike
|
|
52
|
+
get(key: 'remote'): RemoteLike
|
|
53
|
+
on(event: string, listener: () => void): () => void
|
|
54
|
+
effect(execute: () => () => void, label?: string): unknown
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
|
|
59
|
+
* 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
|
|
60
|
+
* 刷新:remote `settings/document-updated`(按命名空间过滤)与
|
|
61
|
+
* `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
|
|
62
|
+
* (loopback 或 tailnet 信任域名)下行为一致。
|
|
63
|
+
*/
|
|
64
|
+
function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
|
|
65
|
+
let state: ScopeSnapshot = {
|
|
66
|
+
status: 'loading',
|
|
67
|
+
value: undefined,
|
|
68
|
+
revision: undefined,
|
|
69
|
+
writable: false,
|
|
70
|
+
}
|
|
71
|
+
const listeners = new Set<() => void>()
|
|
72
|
+
let generation = 0
|
|
73
|
+
const publish = (next: ScopeSnapshot): void => {
|
|
74
|
+
state = next
|
|
75
|
+
for (const listener of [...listeners]) listener()
|
|
76
|
+
}
|
|
77
|
+
const load = async (): Promise<void> => {
|
|
78
|
+
const gen = ++generation
|
|
79
|
+
let response: Awaited<ReturnType<SettingsApi['describe']>>
|
|
80
|
+
try {
|
|
81
|
+
response = await api.describe({})
|
|
82
|
+
} catch {
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (gen !== generation || !response.result.ok) return
|
|
86
|
+
const writable = response.result.value?.writable ?? false
|
|
87
|
+
const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns)
|
|
88
|
+
if (view === undefined) {
|
|
89
|
+
publish({
|
|
90
|
+
status: 'unavailable',
|
|
91
|
+
value: undefined,
|
|
92
|
+
revision: undefined,
|
|
93
|
+
writable,
|
|
94
|
+
})
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
publish({
|
|
98
|
+
status: 'ready',
|
|
99
|
+
value: view.value,
|
|
100
|
+
revision: view.revision,
|
|
101
|
+
writable,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
const refresh = (namespace?: unknown): void => {
|
|
105
|
+
if (namespace !== undefined && namespace !== ns) return
|
|
106
|
+
void load()
|
|
107
|
+
}
|
|
108
|
+
const disposers = [
|
|
109
|
+
c.get('remote').$on('settings/document-updated', refresh),
|
|
110
|
+
c.on('connection/reset', () => {
|
|
111
|
+
void load()
|
|
112
|
+
}),
|
|
113
|
+
]
|
|
114
|
+
c.effect(
|
|
115
|
+
() => () => {
|
|
116
|
+
for (const dispose of disposers) dispose()
|
|
117
|
+
},
|
|
118
|
+
'access-gate: settings scope',
|
|
119
|
+
)
|
|
120
|
+
void load()
|
|
121
|
+
return {
|
|
122
|
+
getSnapshot: () => state,
|
|
123
|
+
subscribe: (listener: () => void) => {
|
|
124
|
+
listeners.add(listener)
|
|
125
|
+
return () => {
|
|
126
|
+
listeners.delete(listener)
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
load,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function apply(ctx: Context): void {
|
|
134
|
+
const c = ctx as unknown as ClientContext
|
|
135
|
+
const tag = injectStyle()
|
|
136
|
+
c.effect(
|
|
137
|
+
() => () => {
|
|
138
|
+
tag?.remove()
|
|
139
|
+
},
|
|
140
|
+
'access-gate: style',
|
|
141
|
+
)
|
|
142
|
+
c.effect(() => c.locale.register(NS, { zh, en }), 'access-gate: locale')
|
|
143
|
+
const api = c.get('connection').api.settings
|
|
144
|
+
const scope = createApiScope(api, SETTINGS_NS, c)
|
|
145
|
+
c.slots.inject('settings.plugin.item', () =>
|
|
146
|
+
c.slots.register(
|
|
147
|
+
{
|
|
148
|
+
name: 'settings.plugin.item',
|
|
149
|
+
key: SETTINGS_NS,
|
|
150
|
+
locale: NS,
|
|
151
|
+
inject: () => ({ t: c.locale.bind(NS), scope, api }),
|
|
152
|
+
},
|
|
153
|
+
AccessGateCard,
|
|
154
|
+
),
|
|
155
|
+
)
|
|
156
|
+
}
|