@riceawa/dsh-lan-gateway 0.5.5 → 0.6.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 +43 -22
- package/lib/client.js +271 -33
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +86 -4
- package/lib/index.js +204 -88
- package/package.json +27 -15
- package/skills/lan-gateway.md +15 -1
- package/src/client/index.ts +39 -29
- package/src/client/lan-gateway-card.tsx +276 -36
- package/src/config-fields.ts +13 -0
- package/src/index.ts +284 -119
|
@@ -1,19 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The lan-gateway settings card
|
|
3
|
-
*
|
|
2
|
+
* The lan-gateway settings card, rendered by the official DSH Plugins page
|
|
3
|
+
* through its `plugins.item` 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
|
-
* the settings seam or any secret), so the
|
|
8
|
-
* the `slots` service
|
|
7
|
+
* the settings seam or any secret), so the only platform service it needs is
|
|
8
|
+
* the `slots` service every plugin already has.
|
|
9
9
|
*
|
|
10
10
|
* @module @riceawa/dsh-lan-gateway/client/card
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
|
|
14
14
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
15
|
+
// Type-only. The Plugins page owns the `plugins.item` contract, and its own
|
|
16
|
+
// doc says a registrant merges that contract with `import type` instead of
|
|
17
|
+
// importing the package at runtime. Taking the contract from its owner is also
|
|
18
|
+
// what turns the next upstream rename of this slot into a compile error here,
|
|
19
|
+
// rather than a card that quietly stops rendering.
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
|
15
21
|
import {
|
|
16
22
|
FIELDS,
|
|
23
|
+
MIN_PASSWORD_LENGTH,
|
|
17
24
|
TRISTATE_OPTIONS,
|
|
18
25
|
formatValue,
|
|
19
26
|
parseValue,
|
|
@@ -22,23 +29,12 @@ import {
|
|
|
22
29
|
} from '../config-fields.ts'
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* real; this only restores the compile-time table.
|
|
32
|
+
* Props the renderer binds for this card. The Plugins page asks for either the
|
|
33
|
+
* card's one-liner (`summary`) or the body of its own page (`page`), and draws
|
|
34
|
+
* the page's title, icon, and crumb itself. The card needs no injected face —
|
|
35
|
+
* it fetches its own route.
|
|
30
36
|
*/
|
|
31
|
-
|
|
32
|
-
interface SlotMap {
|
|
33
|
-
/** One plugin's card inside the plugin configuration section. */
|
|
34
|
-
'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: { children?: never } }
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Props the renderer binds for this card (unused — the card is self-loading).
|
|
40
|
-
*/
|
|
41
|
-
export type LanGatewayCardProps = PropsRuntime<'settings.plugin.item'>
|
|
37
|
+
export type LanGatewayCardProps = PropsRuntime<'plugins.item'>
|
|
42
38
|
|
|
43
39
|
/**
|
|
44
40
|
* The card's field table and value codecs live in `config-fields.ts`, shared
|
|
@@ -57,6 +53,27 @@ interface RouteState {
|
|
|
57
53
|
port: number
|
|
58
54
|
tls: string
|
|
59
55
|
lastError: string | null
|
|
56
|
+
/** Whether a login credential exists. Never the credential itself. */
|
|
57
|
+
passwordSet?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Why a password draft cannot be submitted yet. */
|
|
61
|
+
export type PasswordProblem = 'tooShort' | 'mismatch'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Judge a password draft the way the host's password route will, so a draft the
|
|
65
|
+
* card enables is never answered with a 400. The length bound is the shared
|
|
66
|
+
* {@link MIN_PASSWORD_LENGTH}; the confirmation is a UI concern and is checked
|
|
67
|
+
* here rather than server-side (the host is told one password, and storing only
|
|
68
|
+
* what was typed twice is the browser's job).
|
|
69
|
+
* @param password - the new password draft.
|
|
70
|
+
* @param confirm - the confirmation draft.
|
|
71
|
+
* @returns the reason it cannot be submitted, or `null` when it can.
|
|
72
|
+
*/
|
|
73
|
+
export function passwordProblem(password: string, confirm: string): PasswordProblem | null {
|
|
74
|
+
if (password.length < MIN_PASSWORD_LENGTH) return 'tooShort'
|
|
75
|
+
if (password !== confirm) return 'mismatch'
|
|
76
|
+
return null
|
|
60
77
|
}
|
|
61
78
|
|
|
62
79
|
/* ------------------------------------------------------------------ */
|
|
@@ -79,6 +96,19 @@ interface Labels {
|
|
|
79
96
|
stopped: string
|
|
80
97
|
tls: string
|
|
81
98
|
lastError: string
|
|
99
|
+
passwordSection: string
|
|
100
|
+
passwordHint: string
|
|
101
|
+
passwordNew: string
|
|
102
|
+
passwordConfirm: string
|
|
103
|
+
passwordSet: string
|
|
104
|
+
passwordUnset: string
|
|
105
|
+
passwordRequired: string
|
|
106
|
+
passwordChange: string
|
|
107
|
+
passwordChanged: string
|
|
108
|
+
passwordFailed: string
|
|
109
|
+
passwordStaleHost: string
|
|
110
|
+
passwordTooShort: string
|
|
111
|
+
passwordMismatch: string
|
|
82
112
|
[key: `field.${string}`]: string
|
|
83
113
|
[key: `hint.${string}`]: string
|
|
84
114
|
[key: `opt.${string}`]: string
|
|
@@ -101,6 +131,19 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
101
131
|
stopped: '已停止',
|
|
102
132
|
tls: 'TLS',
|
|
103
133
|
lastError: '上次错误',
|
|
134
|
+
passwordSection: '登录密码',
|
|
135
|
+
passwordHint: '当前密码不会显示(也读不出来)。输入两次新密码后直接覆盖原密码;改密会递增会话代次,所有已登录会话与已建立的 WebSocket 立即失效。仅在从本机 loopback 打开 dsh web 时可改。',
|
|
136
|
+
passwordNew: '新密码(至少 8 位)',
|
|
137
|
+
passwordConfirm: '再次输入新密码',
|
|
138
|
+
passwordSet: '已设置',
|
|
139
|
+
passwordUnset: '未设置',
|
|
140
|
+
passwordRequired: '未设置密码时网关拒绝启动。',
|
|
141
|
+
passwordChange: '修改密码',
|
|
142
|
+
passwordChanged: '密码已更新:旧密码立即失效,所有已登录会话已作废。',
|
|
143
|
+
passwordFailed: '修改密码失败,请重试。',
|
|
144
|
+
passwordStaleHost: '宿主端没有响应这个接口:可能还没重启 dsh web 加载新版本。重启后再试;重启前仍可用 lan_gateway 工具改密。',
|
|
145
|
+
passwordTooShort: '密码至少 8 位。',
|
|
146
|
+
passwordMismatch: '两次输入不一致。',
|
|
104
147
|
'field.enabled': '启用网关',
|
|
105
148
|
'hint.enabled': '启动时监听 0.0.0.0 网关端口',
|
|
106
149
|
'field.gatewayPort': '网关端口',
|
|
@@ -151,6 +194,19 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
151
194
|
stopped: 'Stopped',
|
|
152
195
|
tls: 'TLS',
|
|
153
196
|
lastError: 'Last error',
|
|
197
|
+
passwordSection: 'Login password',
|
|
198
|
+
passwordHint: 'The current password is never shown (it cannot be read back). Enter a new one twice to overwrite it; changing it advances the session epoch, so every signed-in session and live WebSocket is invalidated at once. Only changeable where dsh web runs on loopback.',
|
|
199
|
+
passwordNew: 'New password (min 8 chars)',
|
|
200
|
+
passwordConfirm: 'Repeat new password',
|
|
201
|
+
passwordSet: 'Set',
|
|
202
|
+
passwordUnset: 'Not set',
|
|
203
|
+
passwordRequired: 'The gateway refuses to start without a password.',
|
|
204
|
+
passwordChange: 'Change password',
|
|
205
|
+
passwordChanged: 'Password updated: the old one no longer works and every signed-in session was revoked.',
|
|
206
|
+
passwordFailed: 'The password change failed — retry.',
|
|
207
|
+
passwordStaleHost: 'The host did not answer this endpoint — it may still be running the previous build. Restart dsh web and retry; the lan_gateway tool can change the password meanwhile.',
|
|
208
|
+
passwordTooShort: 'At least 8 characters.',
|
|
209
|
+
passwordMismatch: 'The two entries do not match.',
|
|
154
210
|
'field.enabled': 'Enable gateway',
|
|
155
211
|
'hint.enabled': 'Listen on the gateway port at boot',
|
|
156
212
|
'field.gatewayPort': 'Gateway port',
|
|
@@ -192,6 +248,15 @@ function labels(): Labels {
|
|
|
192
248
|
return lang.startsWith('zh') ? LABELS.zh : LABELS.en
|
|
193
249
|
}
|
|
194
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The card's title in the browser's language, for the Plugins page's list
|
|
253
|
+
* entry. A thunk so the label follows the page's locale without re-registering.
|
|
254
|
+
* @returns the localized card title.
|
|
255
|
+
*/
|
|
256
|
+
export function cardTitle(): string {
|
|
257
|
+
return labels().title
|
|
258
|
+
}
|
|
259
|
+
|
|
195
260
|
/* ------------------------------------------------------------------ */
|
|
196
261
|
/* Card */
|
|
197
262
|
/* ------------------------------------------------------------------ */
|
|
@@ -199,10 +264,14 @@ function labels(): Labels {
|
|
|
199
264
|
/**
|
|
200
265
|
* Render the LAN gateway card. Self-loading: fetches the config route on
|
|
201
266
|
* mount, posts the edited config on save.
|
|
202
|
-
*
|
|
203
|
-
*
|
|
267
|
+
*
|
|
268
|
+
* `view` swaps between the card's one-liner and its page body, so the branch
|
|
269
|
+
* sits after the hooks: the Plugins page re-renders one contribution under the
|
|
270
|
+
* other view when the card is opened.
|
|
271
|
+
* @param props - the view the Plugins page is asking for.
|
|
272
|
+
* @returns the one-liner, the card, or nothing while the route is unreachable.
|
|
204
273
|
*/
|
|
205
|
-
export function LanGatewayCard(
|
|
274
|
+
export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
206
275
|
const t = labels()
|
|
207
276
|
const [open, setOpen] = useState(false)
|
|
208
277
|
const [route, setRoute] = useState<RouteState | null>(null)
|
|
@@ -210,6 +279,14 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
210
279
|
const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
|
|
211
280
|
const [saving, setSaving] = useState(false)
|
|
212
281
|
const [failed, setFailed] = useState<string | null>(null)
|
|
282
|
+
// The password section is its own submit path: a password is not a settings
|
|
283
|
+
// key, so it must not ride the config patch (nor light up the card's "未保存"
|
|
284
|
+
// badge for every keystroke in a password box).
|
|
285
|
+
const [password, setPasswordDraft] = useState('')
|
|
286
|
+
const [passwordConfirm, setPasswordConfirmDraft] = useState('')
|
|
287
|
+
const [passwordBusy, setPasswordBusy] = useState(false)
|
|
288
|
+
const [passwordError, setPasswordError] = useState<string | null>(null)
|
|
289
|
+
const [passwordNotice, setPasswordNotice] = useState<string | null>(null)
|
|
213
290
|
|
|
214
291
|
useEffect(() => {
|
|
215
292
|
let cancelled = false
|
|
@@ -225,6 +302,12 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
225
302
|
return () => { cancelled = true }
|
|
226
303
|
}, [])
|
|
227
304
|
|
|
305
|
+
// The Plugins page lists this plugin as one card and opens its own page on
|
|
306
|
+
// demand: `summary` is the one-liner the list shows, `page` the body. The
|
|
307
|
+
// hooks above run for both views, because the same contribution flips
|
|
308
|
+
// between them.
|
|
309
|
+
if (props.view === 'summary') return t.description
|
|
310
|
+
|
|
228
311
|
// A remote browser reaches this card through the gateway, which answers 403
|
|
229
312
|
// for the plugin's own prefix by design, so the route is unreachable exactly
|
|
230
313
|
// where a user is most likely to go looking for the setting. Rendering
|
|
@@ -232,7 +315,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
232
315
|
// a broken one; say what is wrong and where the card does work instead.
|
|
233
316
|
if (loadFailed) {
|
|
234
317
|
return (
|
|
235
|
-
<
|
|
318
|
+
<div style={styles.card}>
|
|
236
319
|
<div style={styles.header}>
|
|
237
320
|
<span style={styles.headerTop}>
|
|
238
321
|
<span style={styles.name}>{t.title}</span>
|
|
@@ -242,7 +325,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
242
325
|
<div style={styles.body}>
|
|
243
326
|
<p style={styles.hint}>{t.readOnly}</p>
|
|
244
327
|
</div>
|
|
245
|
-
</
|
|
328
|
+
</div>
|
|
246
329
|
)
|
|
247
330
|
}
|
|
248
331
|
if (route === null) return null
|
|
@@ -276,6 +359,64 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
276
359
|
})
|
|
277
360
|
|
|
278
361
|
const dirty = Object.keys(drafts).length > 0
|
|
362
|
+
const passwordDraftProblem = passwordProblem(password, passwordConfirm)
|
|
363
|
+
const passwordTyping = password !== '' || passwordConfirm !== ''
|
|
364
|
+
|
|
365
|
+
/** Adopt the host's post-write snapshot, keeping the last known values. */
|
|
366
|
+
const adopt = (body: Partial<RouteState>): void => {
|
|
367
|
+
if (body.config === undefined) return
|
|
368
|
+
setRoute({
|
|
369
|
+
config: body.config,
|
|
370
|
+
running: body.running ?? false,
|
|
371
|
+
port: body.port ?? 0,
|
|
372
|
+
tls: body.tls ?? '',
|
|
373
|
+
lastError: body.lastError ?? null,
|
|
374
|
+
passwordSet: body.passwordSet === true,
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Overwrite the login password. Deliberately writes only to
|
|
380
|
+
* `/lan-gateway/password`: the credential is not part of the config patch, so
|
|
381
|
+
* a change here can never disturb an unsaved settings draft, and the old
|
|
382
|
+
* password is neither sent nor requested.
|
|
383
|
+
*/
|
|
384
|
+
const changePassword = async (): Promise<void> => {
|
|
385
|
+
if (passwordBusy || passwordProblem(password, passwordConfirm) !== null) return
|
|
386
|
+
setPasswordBusy(true)
|
|
387
|
+
setPasswordError(null)
|
|
388
|
+
setPasswordNotice(null)
|
|
389
|
+
try {
|
|
390
|
+
const response = await fetch('/lan-gateway/password', {
|
|
391
|
+
method: 'POST',
|
|
392
|
+
headers: { 'content-type': 'application/json' },
|
|
393
|
+
body: JSON.stringify({ password }),
|
|
394
|
+
})
|
|
395
|
+
// A host that predates this route answers the SPA fallback (HTML, 200),
|
|
396
|
+
// not a 404 — so "not our JSON" is the signal, and it must not read as a
|
|
397
|
+
// wrong password or a network failure.
|
|
398
|
+
const body = await response.json().catch(() => null) as (Partial<RouteState> & { error?: string }) | null
|
|
399
|
+
if (!response.ok) {
|
|
400
|
+
setPasswordError(body?.error ?? (response.status === 404 ? t.passwordStaleHost : `HTTP ${response.status}`))
|
|
401
|
+
return
|
|
402
|
+
}
|
|
403
|
+
if (body === null) {
|
|
404
|
+
setPasswordError(t.passwordStaleHost)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
// Clear the boxes the moment the write lands: the new credential is now
|
|
408
|
+
// the stored one, and leaving it on screen is the one thing a password
|
|
409
|
+
// field should not do.
|
|
410
|
+
setPasswordDraft('')
|
|
411
|
+
setPasswordConfirmDraft('')
|
|
412
|
+
setPasswordNotice(t.passwordChanged)
|
|
413
|
+
adopt(body)
|
|
414
|
+
} catch {
|
|
415
|
+
setPasswordError(t.passwordFailed)
|
|
416
|
+
} finally {
|
|
417
|
+
setPasswordBusy(false)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
279
420
|
|
|
280
421
|
const save = async (): Promise<void> => {
|
|
281
422
|
if (!dirty || saving || invalid()) return
|
|
@@ -304,15 +445,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
304
445
|
setFailed(body.error ?? `HTTP ${response.status}`)
|
|
305
446
|
return
|
|
306
447
|
}
|
|
307
|
-
|
|
308
|
-
setRoute({
|
|
309
|
-
config: body.config,
|
|
310
|
-
running: body.running ?? false,
|
|
311
|
-
port: body.port ?? 0,
|
|
312
|
-
tls: body.tls ?? '',
|
|
313
|
-
lastError: body.lastError ?? null,
|
|
314
|
-
})
|
|
315
|
-
}
|
|
448
|
+
adopt(body)
|
|
316
449
|
setDrafts({})
|
|
317
450
|
} catch {
|
|
318
451
|
setFailed(t.saveFailed)
|
|
@@ -407,7 +540,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
407
540
|
const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`
|
|
408
541
|
|
|
409
542
|
return (
|
|
410
|
-
<
|
|
543
|
+
<div style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
|
|
411
544
|
<button
|
|
412
545
|
type="button"
|
|
413
546
|
style={styles.header}
|
|
@@ -426,6 +559,69 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
426
559
|
? (
|
|
427
560
|
<div style={styles.body}>
|
|
428
561
|
{route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
|
|
562
|
+
<div style={styles.section}>
|
|
563
|
+
<div style={styles.sectionHead}>
|
|
564
|
+
<span style={styles.label}>{t.passwordSection}</span>
|
|
565
|
+
<span style={route.passwordSet === true ? styles.badge : styles.badgeAlert}>
|
|
566
|
+
{route.passwordSet === true ? t.passwordSet : t.passwordUnset}
|
|
567
|
+
</span>
|
|
568
|
+
</div>
|
|
569
|
+
<p style={styles.hint}>{t.passwordHint}</p>
|
|
570
|
+
{route.passwordSet === true ? null : <p style={styles.error}>{t.passwordRequired}</p>}
|
|
571
|
+
<div style={styles.passwordRow}>
|
|
572
|
+
<input
|
|
573
|
+
id="lan-gw-password"
|
|
574
|
+
type="password"
|
|
575
|
+
autoComplete="new-password"
|
|
576
|
+
aria-label={t.passwordNew}
|
|
577
|
+
style={passwordInput}
|
|
578
|
+
placeholder={t.passwordNew}
|
|
579
|
+
value={password}
|
|
580
|
+
disabled={passwordBusy}
|
|
581
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
582
|
+
setPasswordDraft(e.target.value)
|
|
583
|
+
setPasswordError(null)
|
|
584
|
+
setPasswordNotice(null)
|
|
585
|
+
}}
|
|
586
|
+
/>
|
|
587
|
+
<input
|
|
588
|
+
id="lan-gw-password-confirm"
|
|
589
|
+
type="password"
|
|
590
|
+
autoComplete="new-password"
|
|
591
|
+
aria-label={t.passwordConfirm}
|
|
592
|
+
style={passwordInput}
|
|
593
|
+
placeholder={t.passwordConfirm}
|
|
594
|
+
value={passwordConfirm}
|
|
595
|
+
disabled={passwordBusy}
|
|
596
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
597
|
+
setPasswordConfirmDraft(e.target.value)
|
|
598
|
+
setPasswordError(null)
|
|
599
|
+
setPasswordNotice(null)
|
|
600
|
+
}}
|
|
601
|
+
/>
|
|
602
|
+
</div>
|
|
603
|
+
<div style={styles.passwordFoot}>
|
|
604
|
+
{passwordError !== null
|
|
605
|
+
? <p style={styles.error} role="alert">{passwordError}</p>
|
|
606
|
+
: passwordNotice !== null
|
|
607
|
+
? <p style={styles.notice} role="status">{passwordNotice}</p>
|
|
608
|
+
: passwordTyping && passwordDraftProblem !== null
|
|
609
|
+
? (
|
|
610
|
+
<p style={styles.error} role="status">
|
|
611
|
+
{passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
|
|
612
|
+
</p>
|
|
613
|
+
)
|
|
614
|
+
: null}
|
|
615
|
+
<button
|
|
616
|
+
type="button"
|
|
617
|
+
style={styles.save}
|
|
618
|
+
disabled={passwordBusy || passwordDraftProblem !== null}
|
|
619
|
+
onClick={() => { void changePassword() }}
|
|
620
|
+
>
|
|
621
|
+
{passwordBusy ? t.saving : t.passwordChange}
|
|
622
|
+
</button>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
429
625
|
{FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
|
|
430
626
|
<div style={styles.footer}>
|
|
431
627
|
{failed ? <p style={styles.error} role="status">{failed}</p> : null}
|
|
@@ -449,7 +645,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
449
645
|
</div>
|
|
450
646
|
)
|
|
451
647
|
: null}
|
|
452
|
-
</
|
|
648
|
+
</div>
|
|
453
649
|
)
|
|
454
650
|
}
|
|
455
651
|
|
|
@@ -549,6 +745,47 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
549
745
|
display: 'flex',
|
|
550
746
|
flexDirection: 'column',
|
|
551
747
|
},
|
|
748
|
+
// The password block sits inside the card body, ahead of the config fields:
|
|
749
|
+
// it is the setting an operator comes here for, and it owns its own submit
|
|
750
|
+
// button (a password is not part of the settings patch), so it is separated
|
|
751
|
+
// by a rule rather than merged into the field list.
|
|
752
|
+
section: {
|
|
753
|
+
display: 'flex',
|
|
754
|
+
flexDirection: 'column',
|
|
755
|
+
gap: '8px',
|
|
756
|
+
padding: '14px 0',
|
|
757
|
+
borderBottom: `1px solid ${L.border}`,
|
|
758
|
+
},
|
|
759
|
+
sectionHead: { display: 'flex', alignItems: 'center', gap: '8px' },
|
|
760
|
+
badge: {
|
|
761
|
+
borderRadius: '999px',
|
|
762
|
+
padding: '1px 8px',
|
|
763
|
+
fontSize: '11px',
|
|
764
|
+
lineHeight: '17px',
|
|
765
|
+
fontWeight: 500,
|
|
766
|
+
whiteSpace: 'nowrap',
|
|
767
|
+
background: L.badgeBg,
|
|
768
|
+
color: L.labelSecondary,
|
|
769
|
+
},
|
|
770
|
+
badgeAlert: {
|
|
771
|
+
borderRadius: '999px',
|
|
772
|
+
padding: '1px 8px',
|
|
773
|
+
fontSize: '11px',
|
|
774
|
+
lineHeight: '17px',
|
|
775
|
+
fontWeight: 500,
|
|
776
|
+
whiteSpace: 'nowrap',
|
|
777
|
+
background: L.badgeBg,
|
|
778
|
+
color: L.error,
|
|
779
|
+
},
|
|
780
|
+
notice: { flex: 1, minWidth: 0, margin: 0, fontSize: '12px', lineHeight: 1.5, color: L.labelSecondary },
|
|
781
|
+
passwordRow: { display: 'flex', flexWrap: 'wrap', gap: '8px' },
|
|
782
|
+
passwordFoot: {
|
|
783
|
+
display: 'flex',
|
|
784
|
+
alignItems: 'center',
|
|
785
|
+
justifyContent: 'flex-end',
|
|
786
|
+
gap: '8px',
|
|
787
|
+
marginTop: '2px',
|
|
788
|
+
},
|
|
552
789
|
field: {
|
|
553
790
|
display: 'flex',
|
|
554
791
|
flexDirection: 'column',
|
|
@@ -614,3 +851,6 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
614
851
|
cursor: 'pointer',
|
|
615
852
|
},
|
|
616
853
|
}
|
|
854
|
+
|
|
855
|
+
/** The shared input style plus the growth two side-by-side password boxes need. */
|
|
856
|
+
const passwordInput: React.CSSProperties = { ...styles.input, flex: '1 1 160px' }
|
package/src/config-fields.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* side and unknown on the other, which is what let the card silently rewrite
|
|
10
10
|
* keys it never showed.
|
|
11
11
|
*
|
|
12
|
+
* It also carries `MIN_PASSWORD_LENGTH`: the login password is deliberately not
|
|
13
|
+
* a field here (it is a secret in `state.json`, not a settings key), but its
|
|
14
|
+
* length bound is a contract the card's form and the host's password route
|
|
15
|
+
* must not disagree about.
|
|
16
|
+
*
|
|
12
17
|
* Because both halves import it, this module must stay dependency-free and
|
|
13
18
|
* side-effect-free: no schemastery (the host schema remains the validating
|
|
14
19
|
* authority, not this table), no node built-ins, no DOM, no I/O.
|
|
@@ -43,6 +48,14 @@ export interface LanGatewaySettings {
|
|
|
43
48
|
/** A settings key this table knows how to edit. */
|
|
44
49
|
export type ConfigFieldKey = keyof LanGatewaySettings
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Minimum login-password length, in characters. The credential is not a config
|
|
53
|
+
* field (it lives in `state.json`, never in the schema), but the bound belongs
|
|
54
|
+
* here all the same: the card's password form and the host's password route
|
|
55
|
+
* both read it, so a draft the card accepts is never answered with a 400.
|
|
56
|
+
*/
|
|
57
|
+
export const MIN_PASSWORD_LENGTH = 8
|
|
58
|
+
|
|
46
59
|
/** How a field is rendered and parsed. */
|
|
47
60
|
export type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
|
|
48
61
|
|