@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.
@@ -1,19 +1,26 @@
1
1
  /**
2
- * The lan-gateway settings card shown in the official DSH Settings → Plugins
3
- * page (the `settings.plugin.item` slot).
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 client bundle's only dependency is
8
- * the `slots` service that every plugin already has.
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
- * The official Settings → Plugins page declares the `settings.plugin.item`
26
- * slot keyed by the settings namespace each card edits (newer DSH releases;
27
- * older releases dispatched it as a list slot by `id`). The published package
28
- * ships no `src/`, so the entry is re-declared here — the runtime slot is
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
- declare module '@deepseek-ai/dsh-client-ui-slots' {
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
- * @param _props - unused; the card needs no injected face.
203
- * @returns the card, or nothing while the route is unreachable.
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(_props: LanGatewayCardProps): ReactNode {
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
- <li style={styles.card}>
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
- </li>
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
- if (body.config !== undefined) {
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
- <li style={open ? { ...styles.card, ...styles.cardOpen } : styles.card}>
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
- </li>
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' }
@@ -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