@riceawa/dsh-lan-gateway 0.6.2 → 0.7.1
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/README.md +18 -15
- package/lib/client.js +83 -143
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +18 -2
- package/lib/index.js +70 -6
- package/package.json +21 -23
- package/src/client/index.ts +37 -26
- package/src/client/lan-gateway-card.tsx +173 -197
- package/src/gateway.ts +31 -5
- package/src/index.ts +19 -8
- package/src/request-policy.ts +36 -0
package/src/client/index.ts
CHANGED
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
* module scope. With TLS enabled the origin is secure and the shim is a
|
|
9
9
|
* no-op.
|
|
10
10
|
* 2. Settings card: registers the LAN gateway card into the official Plugins
|
|
11
|
-
* page
|
|
12
|
-
* from the
|
|
11
|
+
* page as THIS bundle's own configuration (`plugins.bundle.config`, keyed by
|
|
12
|
+
* the package name), so port, CIDRs, auth, and TLS stay adjustable from the
|
|
13
|
+
* GUI on the plugin's own page. `plugins.item` is deliberately not used: dsh
|
|
14
|
+
* reserves it for the official settings pages, one companion package per
|
|
15
|
+
* host-plane namespace, and a bundle's configuration belongs in its own
|
|
16
|
+
* config slot instead.
|
|
13
17
|
*/
|
|
14
18
|
|
|
15
19
|
/** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
|
|
@@ -60,21 +64,30 @@ export function installRandomUuidShim(): boolean {
|
|
|
60
64
|
// any RPC/session code runs. apply() re-runs it as a belt-and-braces re-check.
|
|
61
65
|
installRandomUuidShim()
|
|
62
66
|
|
|
63
|
-
import type {
|
|
64
|
-
// Type-only, and never bundled: the
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
67
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
68
|
+
// Type-only, and never bundled: the `slots` service declaration (owned by the
|
|
69
|
+
// renderer, which installs the registry), the Plugins page's slot contract
|
|
70
|
+
// (`plugins.bundle.config`), and the settings domain's `configForms` service all
|
|
71
|
+
// resolve from the web shell's frozen module table. Importing them is what
|
|
72
|
+
// subjects the registration below to the platform's own contract instead of a
|
|
73
|
+
// local copy that drifts the next time upstream renames a slot or a service.
|
|
74
|
+
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
69
75
|
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
|
70
76
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
71
|
-
import { LanGatewayCard
|
|
77
|
+
import { LanGatewayCard } from './lan-gateway-card.tsx'
|
|
72
78
|
|
|
73
79
|
export const name = 'dsh-lan-gateway'
|
|
74
80
|
|
|
75
81
|
/**
|
|
76
|
-
* The
|
|
77
|
-
*
|
|
82
|
+
* The package name dsh keys a bundle's own configuration by in
|
|
83
|
+
* `plugins.bundle.config`, and the name this profile's bundle row resolves to.
|
|
84
|
+
* It is the package's npm name — not the locale namespace or the row id.
|
|
85
|
+
*/
|
|
86
|
+
const PACKAGE_NAME = '@riceawa/dsh-lan-gateway'
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The profile entry id this plugin's bundle patch composes it under. dsh >=
|
|
90
|
+
* 0.1.7 addresses both settings writes and the serve gate by entry id.
|
|
78
91
|
*/
|
|
79
92
|
const ENTRY_ID = 'dsh-lan-gateway'
|
|
80
93
|
|
|
@@ -85,29 +98,27 @@ export const inject = ['slots', 'configForms']
|
|
|
85
98
|
* Mount the settings card and the UUID shim.
|
|
86
99
|
* @param ctx - the browser plugin context.
|
|
87
100
|
*/
|
|
88
|
-
export function apply(ctx:
|
|
101
|
+
export function apply(ctx: Context): void {
|
|
89
102
|
installRandomUuidShim()
|
|
90
103
|
|
|
91
|
-
// The card
|
|
92
|
-
// inject face and fetches its own loopback config route, so it depends
|
|
93
|
-
// settings, locale, or connection service.
|
|
104
|
+
// The card is this bundle's own configuration page. Like ModLens it registers
|
|
105
|
+
// with no inject face and fetches its own loopback config route, so it depends
|
|
106
|
+
// on no settings, locale, or connection service.
|
|
94
107
|
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// configuration page
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
108
|
+
// It rides `plugins.bundle.config`, keyed by the bundle's package name, which
|
|
109
|
+
// is the slot dsh declares for exactly this: dsh 0.1.7+ lists a bundle's own
|
|
110
|
+
// configuration on the bundle's page, and it reserves `plugins.item` for the
|
|
111
|
+
// official settings pages ("one companion package per host-plane namespace").
|
|
112
|
+
// Registering there made the card pose as an official plugin; the dsh 0.2
|
|
113
|
+
// Plugins page still declares that slot, so nothing failed loudly.
|
|
101
114
|
//
|
|
102
115
|
// `whileServed` keeps the entry off the page until the Host's settings mirror
|
|
103
116
|
// serves this plugin's entry. That is the one gate worth having: without a
|
|
104
117
|
// Loader entry there is nothing to write to, and the card would appear only
|
|
105
118
|
// to fail every save with the route's 409.
|
|
106
119
|
ctx.effect(() => ctx.configForms.whileServed([ENTRY_ID], () =>
|
|
107
|
-
ctx.slots.inject('plugins.
|
|
108
|
-
name: 'plugins.
|
|
109
|
-
|
|
110
|
-
order: 30,
|
|
111
|
-
label: () => cardTitle(),
|
|
120
|
+
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
|
|
121
|
+
name: 'plugins.bundle.config',
|
|
122
|
+
key: PACKAGE_NAME,
|
|
112
123
|
}, LanGatewayCard))))
|
|
113
124
|
}
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The lan-gateway
|
|
3
|
-
* through its `plugins.
|
|
2
|
+
* The lan-gateway configuration card, rendered by the official DSH Plugins page
|
|
3
|
+
* on this bundle's own page through its `plugins.bundle.config` slot.
|
|
4
4
|
*
|
|
5
5
|
* ModLens-style: the card carries NO injected services. It reads and writes
|
|
6
6
|
* the loopback-only `/lan-gateway/config` host route (the browser never sees
|
|
7
7
|
* the settings seam or any secret), so the only platform service it needs is
|
|
8
8
|
* the `slots` service every plugin already has.
|
|
9
9
|
*
|
|
10
|
+
* The page — not the card — draws the plugin's title, icon, and crumb, and the
|
|
11
|
+
* card is the page body: `view: 'summary'` renders nothing (bundle
|
|
12
|
+
* configuration is `page`-only).
|
|
13
|
+
*
|
|
10
14
|
* @module @riceawa/dsh-lan-gateway/client/card
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
|
|
14
18
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
15
|
-
// Type-only. The Plugins page owns the `plugins.
|
|
16
|
-
// doc says a registrant merges that contract with `import type` instead of
|
|
19
|
+
// Type-only. The Plugins page owns the `plugins.bundle.config` contract, and its
|
|
20
|
+
// own doc says a registrant merges that contract with `import type` instead of
|
|
17
21
|
// importing the package at runtime. Taking the contract from its owner is also
|
|
18
22
|
// what turns the next upstream rename of this slot into a compile error here,
|
|
19
23
|
// rather than a card that quietly stops rendering.
|
|
@@ -29,12 +33,12 @@ import {
|
|
|
29
33
|
} from '../config-fields.ts'
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
* Props the renderer binds for this card. The Plugins page asks
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* it fetches its own route.
|
|
36
|
+
* Props the renderer binds for this card. The Plugins page asks a bundle's
|
|
37
|
+
* configuration entry only for the body of its own page (`page`); the shared
|
|
38
|
+
* contract still carries `summary`, which bundle configuration never renders.
|
|
39
|
+
* The card needs no injected face — it fetches its own route.
|
|
36
40
|
*/
|
|
37
|
-
export type LanGatewayCardProps = PropsRuntime<'plugins.
|
|
41
|
+
export type LanGatewayCardProps = PropsRuntime<'plugins.bundle.config'>
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
44
|
* The card's field table and value codecs live in `config-fields.ts`, shared
|
|
@@ -97,6 +101,23 @@ export function passwordProblem(password: string, confirm: string): PasswordProb
|
|
|
97
101
|
return null
|
|
98
102
|
}
|
|
99
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Whether the config route's refusal came from the gateway rather than from dsh
|
|
106
|
+
* itself.
|
|
107
|
+
*
|
|
108
|
+
* The gateway owns the `/lan-gateway*` prefix and answers it with a bare 403
|
|
109
|
+
* `forbidden` whenever the client is not on the host; dsh, when it answers the
|
|
110
|
+
* route at all, does not produce that pair. Telling the two apart is what lets
|
|
111
|
+
* the card say "you are not on the host loopback" instead of the generic
|
|
112
|
+
* "cannot read the configuration".
|
|
113
|
+
* @param status - the HTTP status of the failed read.
|
|
114
|
+
* @param body - that response's body.
|
|
115
|
+
* @returns true when the gateway itself refused the read.
|
|
116
|
+
*/
|
|
117
|
+
export function refusedByGateway(status: number, body: string): boolean {
|
|
118
|
+
return status === 403 && body.trim() === 'forbidden'
|
|
119
|
+
}
|
|
120
|
+
|
|
100
121
|
/* ------------------------------------------------------------------ */
|
|
101
122
|
/* Bilingual copy (ModLens-style: two small sets, picked by browser) */
|
|
102
123
|
/* ------------------------------------------------------------------ */
|
|
@@ -110,6 +131,7 @@ interface Labels {
|
|
|
110
131
|
discard: string
|
|
111
132
|
reset: string
|
|
112
133
|
readOnly: string
|
|
134
|
+
readOnlyGateway: string
|
|
113
135
|
saveFailed: string
|
|
114
136
|
loadFailed: string
|
|
115
137
|
emptyMeansClear: string
|
|
@@ -146,7 +168,8 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
146
168
|
saving: '保存中…',
|
|
147
169
|
discard: '放弃',
|
|
148
170
|
reset: '重置',
|
|
149
|
-
readOnly: '
|
|
171
|
+
readOnly: '读不到网关配置:这个接口只在宿主机本机应答——Host 必须是回环地址且同源。请在宿主机上打开 dsh web 时修改,远程请改用 lan_gateway 工具。',
|
|
172
|
+
readOnlyGateway: '网关拒绝了这次读取:/lan-gateway/* 管理面由网关独占,只放行「TCP 来源为回环 且 地址写的是 127.0.0.1 / localhost」的浏览器。你现在不是从宿主机回环地址访问的——请在宿主机上用 127.0.0.1 或 localhost 打开本页(局域网 IP、域名都不算),或远程改用 lan_gateway 工具。',
|
|
150
173
|
saveFailed: '保存未生效,请检查输入后重试。',
|
|
151
174
|
loadFailed: '无法读取网关配置',
|
|
152
175
|
emptyMeansClear: '留空 = 使用默认',
|
|
@@ -211,7 +234,8 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
211
234
|
saving: 'Saving…',
|
|
212
235
|
discard: 'Discard',
|
|
213
236
|
reset: 'Reset',
|
|
214
|
-
readOnly: '
|
|
237
|
+
readOnly: 'Cannot read the configuration: the route answers on the host only — the Host must be a loopback address and same-origin. Change the settings where dsh web runs on the host, or use the lan_gateway tool remotely.',
|
|
238
|
+
readOnlyGateway: 'The gateway refused this read: it owns the /lan-gateway/* management plane and admits only browsers that are both loopback-sourced and using a 127.0.0.1 / localhost address. You are not on the host loopback — open this page on the host via 127.0.0.1 or localhost (a LAN IP or a hostname does not qualify), or use the lan_gateway tool remotely.',
|
|
215
239
|
saveFailed: 'The save did not land — check the inputs and retry.',
|
|
216
240
|
loadFailed: 'Cannot read the gateway configuration',
|
|
217
241
|
emptyMeansClear: 'Empty = default',
|
|
@@ -275,34 +299,24 @@ function labels(): Labels {
|
|
|
275
299
|
return lang.startsWith('zh') ? LABELS.zh : LABELS.en
|
|
276
300
|
}
|
|
277
301
|
|
|
278
|
-
/**
|
|
279
|
-
* The card's title in the browser's language, for the Plugins page's list
|
|
280
|
-
* entry. A thunk so the label follows the page's locale without re-registering.
|
|
281
|
-
* @returns the localized card title.
|
|
282
|
-
*/
|
|
283
|
-
export function cardTitle(): string {
|
|
284
|
-
return labels().title
|
|
285
|
-
}
|
|
286
|
-
|
|
287
302
|
/* ------------------------------------------------------------------ */
|
|
288
303
|
/* Card */
|
|
289
304
|
/* ------------------------------------------------------------------ */
|
|
290
305
|
|
|
291
306
|
/**
|
|
292
|
-
* Render the LAN gateway card. Self-loading: fetches the config
|
|
293
|
-
* mount, posts the edited config on save.
|
|
307
|
+
* Render the LAN gateway configuration card. Self-loading: fetches the config
|
|
308
|
+
* route on mount, posts the edited config on save.
|
|
294
309
|
*
|
|
295
|
-
* `
|
|
296
|
-
*
|
|
297
|
-
*
|
|
310
|
+
* `summary` renders nothing: dsh asks a bundle's own configuration for its page
|
|
311
|
+
* body only, and the card's former one-liner (a list entry on the official
|
|
312
|
+
* plugin card) went away with `plugins.item`.
|
|
298
313
|
* @param props - the view the Plugins page is asking for.
|
|
299
|
-
* @returns the
|
|
314
|
+
* @returns the page body, or nothing while the view is `summary`.
|
|
300
315
|
*/
|
|
301
316
|
export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
302
317
|
const t = labels()
|
|
303
|
-
const [open, setOpen] = useState(false)
|
|
304
318
|
const [route, setRoute] = useState<RouteState | null>(null)
|
|
305
|
-
const [
|
|
319
|
+
const [failure, setFailure] = useState<'gateway' | 'other' | null>(null)
|
|
306
320
|
const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
|
|
307
321
|
const [saving, setSaving] = useState(false)
|
|
308
322
|
const [failed, setFailed] = useState<string | null>(null)
|
|
@@ -320,38 +334,38 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
320
334
|
fetch('/lan-gateway/config')
|
|
321
335
|
.then(async (response) => {
|
|
322
336
|
if (cancelled) return
|
|
323
|
-
if (!response.ok)
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
// The body is only read to tell the gateway's own bare refusal from
|
|
339
|
+
// any other answer; it is never rendered.
|
|
340
|
+
const body = await response.text().catch(() => '')
|
|
341
|
+
if (!cancelled) setFailure(refusedByGateway(response.status, body) ? 'gateway' : 'other')
|
|
342
|
+
return
|
|
343
|
+
}
|
|
324
344
|
setRoute(await response.json() as RouteState)
|
|
325
345
|
})
|
|
326
346
|
.catch(() => {
|
|
327
|
-
if (!cancelled)
|
|
347
|
+
if (!cancelled) setFailure('other')
|
|
328
348
|
})
|
|
329
349
|
return () => { cancelled = true }
|
|
330
350
|
}, [])
|
|
331
351
|
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
if (props.view === 'summary') return
|
|
337
|
-
|
|
338
|
-
// A
|
|
339
|
-
// for the plugin's own prefix by design, so the route is
|
|
340
|
-
// where a user is most likely to go looking for the
|
|
341
|
-
// nothing left them with a blank entry and no way to tell a
|
|
342
|
-
// a broken one; say what is wrong and
|
|
343
|
-
|
|
352
|
+
// dsh asks a bundle for its own configuration under `view: 'page'` only, and
|
|
353
|
+
// the page draws the title, icon, and crumb itself — so the card is the page
|
|
354
|
+
// body and has no one-liner. Returning null keeps the contribution honest for
|
|
355
|
+
// a host that ever asks for the other view.
|
|
356
|
+
if (props.view === 'summary') return null
|
|
357
|
+
|
|
358
|
+
// A browser that is not on the host reaches this card through the gateway,
|
|
359
|
+
// which answers 403 for the plugin's own prefix by design, so the route is
|
|
360
|
+
// unreachable exactly where a user is most likely to go looking for the
|
|
361
|
+
// setting. Rendering nothing left them with a blank entry and no way to tell a
|
|
362
|
+
// missing card from a broken one; say what is wrong — and whether the gateway
|
|
363
|
+
// or the host refused — and where the card does work instead.
|
|
364
|
+
if (failure !== null) {
|
|
344
365
|
return (
|
|
345
366
|
<div style={styles.card}>
|
|
346
|
-
<
|
|
347
|
-
|
|
348
|
-
<span style={styles.name}>{t.title}</span>
|
|
349
|
-
</span>
|
|
350
|
-
<span style={styles.description}>{t.loadFailed}</span>
|
|
351
|
-
</div>
|
|
352
|
-
<div style={styles.body}>
|
|
353
|
-
<p style={styles.hint}>{t.readOnly}</p>
|
|
354
|
-
</div>
|
|
367
|
+
<p style={styles.status}>{t.loadFailed}</p>
|
|
368
|
+
<p style={styles.hint}>{failure === 'gateway' ? t.readOnlyGateway : t.readOnly}</p>
|
|
355
369
|
</div>
|
|
356
370
|
)
|
|
357
371
|
}
|
|
@@ -571,114 +585,103 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
571
585
|
const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`
|
|
572
586
|
|
|
573
587
|
return (
|
|
574
|
-
<div style={
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
<
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
{route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
|
|
593
|
-
<div style={styles.section}>
|
|
594
|
-
<div style={styles.sectionHead}>
|
|
595
|
-
<span style={styles.label}>{t.passwordSection}</span>
|
|
596
|
-
<span style={passwordBadge === 'unset' ? styles.badgeAlert : styles.badge}>
|
|
597
|
-
{passwordBadge === 'set'
|
|
598
|
-
? t.passwordSet
|
|
599
|
-
: passwordBadge === 'unset' ? t.passwordUnset : t.passwordUnknown}
|
|
600
|
-
</span>
|
|
601
|
-
</div>
|
|
602
|
-
<p style={styles.hint}>{t.passwordHint}</p>
|
|
603
|
-
{passwordBadge === 'unset' ? <p style={styles.error}>{t.passwordRequired}</p> : null}
|
|
604
|
-
{passwordBadge === 'unknown' ? <p style={styles.hint}>{t.passwordHostStale}</p> : null}
|
|
605
|
-
<div style={styles.passwordRow}>
|
|
606
|
-
<input
|
|
607
|
-
id="lan-gw-password"
|
|
608
|
-
type="password"
|
|
609
|
-
autoComplete="new-password"
|
|
610
|
-
aria-label={t.passwordNew}
|
|
611
|
-
style={passwordInput}
|
|
612
|
-
placeholder={t.passwordNew}
|
|
613
|
-
value={password}
|
|
614
|
-
disabled={passwordBusy}
|
|
615
|
-
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
616
|
-
setPasswordDraft(e.target.value)
|
|
617
|
-
setPasswordError(null)
|
|
618
|
-
setPasswordNotice(null)
|
|
619
|
-
}}
|
|
620
|
-
/>
|
|
621
|
-
<input
|
|
622
|
-
id="lan-gw-password-confirm"
|
|
623
|
-
type="password"
|
|
624
|
-
autoComplete="new-password"
|
|
625
|
-
aria-label={t.passwordConfirm}
|
|
626
|
-
style={passwordInput}
|
|
627
|
-
placeholder={t.passwordConfirm}
|
|
628
|
-
value={passwordConfirm}
|
|
629
|
-
disabled={passwordBusy}
|
|
630
|
-
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
631
|
-
setPasswordConfirmDraft(e.target.value)
|
|
632
|
-
setPasswordError(null)
|
|
633
|
-
setPasswordNotice(null)
|
|
634
|
-
}}
|
|
635
|
-
/>
|
|
636
|
-
</div>
|
|
637
|
-
<div style={styles.passwordFoot}>
|
|
638
|
-
{passwordError !== null
|
|
639
|
-
? <p style={styles.error} role="alert">{passwordError}</p>
|
|
640
|
-
: passwordNotice !== null
|
|
641
|
-
? <p style={styles.notice} role="status">{passwordNotice}</p>
|
|
642
|
-
: passwordTyping && passwordDraftProblem !== null
|
|
643
|
-
? (
|
|
644
|
-
<p style={styles.error} role="status">
|
|
645
|
-
{passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
|
|
646
|
-
</p>
|
|
647
|
-
)
|
|
648
|
-
: null}
|
|
649
|
-
<button
|
|
650
|
-
type="button"
|
|
651
|
-
style={styles.save}
|
|
652
|
-
disabled={passwordBusy || passwordDraftProblem !== null}
|
|
653
|
-
onClick={() => { void changePassword() }}
|
|
654
|
-
>
|
|
655
|
-
{passwordBusy ? t.saving : t.passwordChange}
|
|
656
|
-
</button>
|
|
657
|
-
</div>
|
|
658
|
-
</div>
|
|
659
|
-
{FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
|
|
660
|
-
<div style={styles.footer}>
|
|
661
|
-
{failed ? <p style={styles.error} role="status">{failed}</p> : null}
|
|
662
|
-
<button
|
|
663
|
-
type="button"
|
|
664
|
-
style={styles.discard}
|
|
665
|
-
disabled={!dirty || saving}
|
|
666
|
-
onClick={discard}
|
|
667
|
-
>
|
|
668
|
-
{t.discard}
|
|
669
|
-
</button>
|
|
670
|
-
<button
|
|
671
|
-
type="button"
|
|
672
|
-
style={styles.save}
|
|
673
|
-
disabled={!dirty || invalid() || saving}
|
|
674
|
-
onClick={() => { void save() }}
|
|
675
|
-
>
|
|
676
|
-
{saving ? t.saving : t.save}
|
|
677
|
-
</button>
|
|
678
|
-
</div>
|
|
588
|
+
<div style={styles.card}>
|
|
589
|
+
{/* The page above draws the plugin's title itself, so the card opens with
|
|
590
|
+
the live listener status and the unsaved marker — the two facts an
|
|
591
|
+
operator wants before touching a field. */}
|
|
592
|
+
<div style={styles.statusRow}>
|
|
593
|
+
<span style={styles.status} title={statusLine}>{statusLine}</span>
|
|
594
|
+
{dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
|
|
595
|
+
</div>
|
|
596
|
+
<div style={styles.body}>
|
|
597
|
+
{route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
|
|
598
|
+
<div style={styles.section}>
|
|
599
|
+
<div style={styles.sectionHead}>
|
|
600
|
+
<span style={styles.label}>{t.passwordSection}</span>
|
|
601
|
+
<span style={passwordBadge === 'unset' ? styles.badgeAlert : styles.badge}>
|
|
602
|
+
{passwordBadge === 'set'
|
|
603
|
+
? t.passwordSet
|
|
604
|
+
: passwordBadge === 'unset' ? t.passwordUnset : t.passwordUnknown}
|
|
605
|
+
</span>
|
|
679
606
|
</div>
|
|
680
|
-
|
|
681
|
-
|
|
607
|
+
<p style={styles.hint}>{t.passwordHint}</p>
|
|
608
|
+
{passwordBadge === 'unset' ? <p style={styles.error}>{t.passwordRequired}</p> : null}
|
|
609
|
+
{passwordBadge === 'unknown' ? <p style={styles.hint}>{t.passwordHostStale}</p> : null}
|
|
610
|
+
<div style={styles.passwordRow}>
|
|
611
|
+
<input
|
|
612
|
+
id="lan-gw-password"
|
|
613
|
+
type="password"
|
|
614
|
+
autoComplete="new-password"
|
|
615
|
+
aria-label={t.passwordNew}
|
|
616
|
+
style={passwordInput}
|
|
617
|
+
placeholder={t.passwordNew}
|
|
618
|
+
value={password}
|
|
619
|
+
disabled={passwordBusy}
|
|
620
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
621
|
+
setPasswordDraft(e.target.value)
|
|
622
|
+
setPasswordError(null)
|
|
623
|
+
setPasswordNotice(null)
|
|
624
|
+
}}
|
|
625
|
+
/>
|
|
626
|
+
<input
|
|
627
|
+
id="lan-gw-password-confirm"
|
|
628
|
+
type="password"
|
|
629
|
+
autoComplete="new-password"
|
|
630
|
+
aria-label={t.passwordConfirm}
|
|
631
|
+
style={passwordInput}
|
|
632
|
+
placeholder={t.passwordConfirm}
|
|
633
|
+
value={passwordConfirm}
|
|
634
|
+
disabled={passwordBusy}
|
|
635
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
636
|
+
setPasswordConfirmDraft(e.target.value)
|
|
637
|
+
setPasswordError(null)
|
|
638
|
+
setPasswordNotice(null)
|
|
639
|
+
}}
|
|
640
|
+
/>
|
|
641
|
+
</div>
|
|
642
|
+
<div style={styles.passwordFoot}>
|
|
643
|
+
{passwordError !== null
|
|
644
|
+
? <p style={styles.error} role="alert">{passwordError}</p>
|
|
645
|
+
: passwordNotice !== null
|
|
646
|
+
? <p style={styles.notice} role="status">{passwordNotice}</p>
|
|
647
|
+
: passwordTyping && passwordDraftProblem !== null
|
|
648
|
+
? (
|
|
649
|
+
<p style={styles.error} role="status">
|
|
650
|
+
{passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
|
|
651
|
+
</p>
|
|
652
|
+
)
|
|
653
|
+
: null}
|
|
654
|
+
<button
|
|
655
|
+
type="button"
|
|
656
|
+
style={styles.save}
|
|
657
|
+
disabled={passwordBusy || passwordDraftProblem !== null}
|
|
658
|
+
onClick={() => { void changePassword() }}
|
|
659
|
+
>
|
|
660
|
+
{passwordBusy ? t.saving : t.passwordChange}
|
|
661
|
+
</button>
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
{FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
|
|
665
|
+
<div style={styles.footer}>
|
|
666
|
+
{failed ? <p style={styles.error} role="status">{failed}</p> : null}
|
|
667
|
+
<button
|
|
668
|
+
type="button"
|
|
669
|
+
style={styles.discard}
|
|
670
|
+
disabled={!dirty || saving}
|
|
671
|
+
onClick={discard}
|
|
672
|
+
>
|
|
673
|
+
{t.discard}
|
|
674
|
+
</button>
|
|
675
|
+
<button
|
|
676
|
+
type="button"
|
|
677
|
+
style={styles.save}
|
|
678
|
+
disabled={!dirty || invalid() || saving}
|
|
679
|
+
onClick={() => { void save() }}
|
|
680
|
+
>
|
|
681
|
+
{saving ? t.saving : t.save}
|
|
682
|
+
</button>
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
682
685
|
</div>
|
|
683
686
|
)
|
|
684
687
|
}
|
|
@@ -697,13 +700,10 @@ function tk(token: string, fallback: string): string {
|
|
|
697
700
|
const L = {
|
|
698
701
|
border: tk('--dsw-alias-border-l2', 'rgba(127,127,127,0.35)'),
|
|
699
702
|
bg: tk('--dsw-alias-bg-layer-3', 'transparent'),
|
|
700
|
-
bgOpen: tk('--dsw-alias-bg-layer-2', 'transparent'),
|
|
701
703
|
labelPrimary: tk('--dsw-alias-label-primary', 'inherit'),
|
|
702
704
|
labelSecondary: tk('--dsw-alias-label-secondary', 'inherit'),
|
|
703
705
|
labelTertiary: tk('--dsw-alias-label-tertiary', 'rgba(127,127,127,0.8)'),
|
|
704
|
-
labelDimmed: tk('--dsw-alias-label-dimmed', 'rgba(127,127,127,0.6)'),
|
|
705
706
|
error: tk('--dsw-alias-label-error', '#d1242f'),
|
|
706
|
-
brand: tk('--dsw-alias-brand-primary', '#4f6ef7'),
|
|
707
707
|
badgeBg: tk('--dsw-alias-bg-module-platform', 'rgba(127,127,127,0.14)'),
|
|
708
708
|
}
|
|
709
709
|
|
|
@@ -713,36 +713,25 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
713
713
|
border: `1px solid ${L.border}`,
|
|
714
714
|
borderRadius: '12px',
|
|
715
715
|
background: L.bg,
|
|
716
|
-
|
|
716
|
+
display: 'flex',
|
|
717
|
+
flexDirection: 'column',
|
|
717
718
|
overflow: 'hidden',
|
|
718
719
|
},
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
},
|
|
723
|
-
header: {
|
|
720
|
+
// The page draws the plugin's title above this body, so the card leads with
|
|
721
|
+
// the live listener status and the unsaved marker instead.
|
|
722
|
+
statusRow: {
|
|
724
723
|
display: 'flex',
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
padding: '14px 16px',
|
|
730
|
-
border: 0,
|
|
731
|
-
background: 'none',
|
|
732
|
-
font: 'inherit',
|
|
733
|
-
color: 'inherit',
|
|
734
|
-
textAlign: 'left',
|
|
735
|
-
cursor: 'pointer',
|
|
724
|
+
alignItems: 'center',
|
|
725
|
+
gap: '12px',
|
|
726
|
+
padding: '12px 16px',
|
|
727
|
+
borderBottom: `1px solid ${L.border}`,
|
|
736
728
|
},
|
|
737
|
-
headerTop: { display: 'flex', alignItems: 'center', gap: '12px', width: '100%' },
|
|
738
|
-
name: { flex: '1 1 auto', minWidth: 0, fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
|
|
739
729
|
// The status carries a verbose TLS cert summary; cap it and ellipsize so it
|
|
740
|
-
// can never swallow the row
|
|
741
|
-
// caused the description to be pushed into a thin wrapping column).
|
|
730
|
+
// can never swallow the row.
|
|
742
731
|
status: {
|
|
743
732
|
flex: '0 1 auto',
|
|
744
733
|
minWidth: 0,
|
|
745
|
-
maxWidth: '
|
|
734
|
+
maxWidth: '80%',
|
|
746
735
|
fontSize: '11px',
|
|
747
736
|
lineHeight: 1.4,
|
|
748
737
|
color: L.labelTertiary,
|
|
@@ -750,15 +739,6 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
750
739
|
overflow: 'hidden',
|
|
751
740
|
textOverflow: 'ellipsis',
|
|
752
741
|
},
|
|
753
|
-
description: {
|
|
754
|
-
display: 'block',
|
|
755
|
-
fontSize: '13px',
|
|
756
|
-
lineHeight: 1.5,
|
|
757
|
-
color: L.labelTertiary,
|
|
758
|
-
whiteSpace: 'nowrap',
|
|
759
|
-
overflow: 'hidden',
|
|
760
|
-
textOverflow: 'ellipsis',
|
|
761
|
-
},
|
|
762
742
|
pending: {
|
|
763
743
|
flex: 'none',
|
|
764
744
|
borderRadius: '999px',
|
|
@@ -770,12 +750,8 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
770
750
|
background: L.badgeBg,
|
|
771
751
|
color: L.labelSecondary,
|
|
772
752
|
},
|
|
773
|
-
chevron: { flex: 'none', color: L.labelTertiary, fontSize: '12px', transition: 'transform .16s' },
|
|
774
|
-
chevronOpen: { transform: 'rotate(180deg)' },
|
|
775
753
|
body: {
|
|
776
|
-
|
|
777
|
-
margin: '0 16px',
|
|
778
|
-
paddingBottom: '8px',
|
|
754
|
+
padding: '4px 16px 8px',
|
|
779
755
|
display: 'flex',
|
|
780
756
|
flexDirection: 'column',
|
|
781
757
|
},
|