dsh-oauth-login 0.2.4

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.
Files changed (64) hide show
  1. package/INSTALL.md +59 -0
  2. package/INSTALL.zh.md +45 -0
  3. package/LICENSE +201 -0
  4. package/README.en.md +104 -0
  5. package/README.md +104 -0
  6. package/README.zh.md +3 -0
  7. package/SECURITY.md +33 -0
  8. package/cordis.patch.yml +5 -0
  9. package/docs/network-proxy.md +45 -0
  10. package/docs/openrouter-sync.md +84 -0
  11. package/docs/screenshots/01-providers-signed-out.png +0 -0
  12. package/docs/screenshots/03-copilot-signing-in.png +0 -0
  13. package/docs/screenshots/04-mixed-live-states.png +0 -0
  14. package/docs/screenshots/05-copilot-sign-in.gif +0 -0
  15. package/docs/screenshots/06-cli-status-store.png +0 -0
  16. package/lib/bin.d.ts +5 -0
  17. package/lib/bin.js +2132 -0
  18. package/lib/client.js +1915 -0
  19. package/lib/client.js.map +1 -0
  20. package/lib/index.d.ts +482 -0
  21. package/lib/index.js +3105 -0
  22. package/lib/invariant.d.ts +7 -0
  23. package/lib/invariant.js +8 -0
  24. package/package.json +180 -0
  25. package/src/adapter.ts +207 -0
  26. package/src/auth-routes.ts +654 -0
  27. package/src/auth.ts +76 -0
  28. package/src/bin.ts +185 -0
  29. package/src/catalog.ts +129 -0
  30. package/src/client/OpenRouterSyncStatus.tsx +66 -0
  31. package/src/client/PiLoginSettings.tsx +470 -0
  32. package/src/client/ProxySettings.tsx +123 -0
  33. package/src/client/SearchableModelSelect.tsx +595 -0
  34. package/src/client/draft-input.ts +12 -0
  35. package/src/client/index.tsx +71 -0
  36. package/src/client/locales.ts +176 -0
  37. package/src/client/login-input-copy.ts +34 -0
  38. package/src/client/login-window.ts +24 -0
  39. package/src/client/model-filter.ts +45 -0
  40. package/src/client/openrouter-store.ts +122 -0
  41. package/src/client/proxy-draft.ts +57 -0
  42. package/src/extra-models.ts +201 -0
  43. package/src/hosted-capture.ts +41 -0
  44. package/src/hosted-images.ts +154 -0
  45. package/src/http.ts +241 -0
  46. package/src/ids.ts +17 -0
  47. package/src/index.ts +183 -0
  48. package/src/invariant.ts +12 -0
  49. package/src/model-error-hint.ts +98 -0
  50. package/src/native-tools.ts +442 -0
  51. package/src/oauth-refresh.ts +49 -0
  52. package/src/openrouter-catalog.ts +245 -0
  53. package/src/openrouter-free.ts +76 -0
  54. package/src/openrouter-models.ts +155 -0
  55. package/src/openrouter-types.ts +40 -0
  56. package/src/plugin-config.ts +30 -0
  57. package/src/provider.ts +137 -0
  58. package/src/proxy-config.ts +61 -0
  59. package/src/proxy-store.ts +45 -0
  60. package/src/proxy-transport.ts +220 -0
  61. package/src/redact.ts +22 -0
  62. package/src/responses-tap.ts +97 -0
  63. package/src/session.ts +104 -0
  64. package/src/store.ts +234 -0
@@ -0,0 +1,470 @@
1
+ /** Plugin-owned Pi login page inside the dsh Settings shell. */
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { applyDraftChange } from './draft-input.ts'
5
+ import type { Drafts } from './draft-input.ts'
6
+ import type { PiLoginKey } from './locales.ts'
7
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
8
+ import type { OpenRouterCatalogClient } from './openrouter-store.ts'
9
+ import { OpenRouterSyncStatus } from './OpenRouterSyncStatus.tsx'
10
+ import { ProxySettings } from './ProxySettings.tsx'
11
+ import { loginInputCopy } from './login-input-copy.ts'
12
+ import { openLoginChallenge } from './login-window.ts'
13
+
14
+ const STATUS_PATH = '/plugins/dsh-oauth-login/auth/status'
15
+ const LOGIN_PATH = '/plugins/dsh-oauth-login/auth/login'
16
+ const CANCEL_PATH = '/plugins/dsh-oauth-login/auth/cancel'
17
+ const COMPLETE_PATH = '/plugins/dsh-oauth-login/auth/complete'
18
+ const LOGOUT_PATH = '/plugins/dsh-oauth-login/auth/logout'
19
+ const POLL_INTERVAL_MS = 1_000
20
+ const STYLE_ID = 'dsh-pi-login-settings-theme'
21
+
22
+ type AccountState =
23
+ | { status: 'signed-out' }
24
+ | {
25
+ status: 'signing-in'
26
+ kind?: 'browser' | 'input'
27
+ url?: string
28
+ userCode?: string
29
+ input?: LoginInputChallenge
30
+ }
31
+ | { status: 'signed-in'; models?: string[]; expiresAt?: string }
32
+ | { status: 'error'; message: string }
33
+
34
+ interface LoginInputChallenge {
35
+ type: 'secret' | 'text' | 'manual_code'
36
+ message: string
37
+ placeholder?: string
38
+ }
39
+
40
+ interface ProviderStatus {
41
+ id: string
42
+ route: string
43
+ displayName: string
44
+ shortName: string
45
+ authType: 'oauth' | 'api_key'
46
+ account: AccountState
47
+ }
48
+
49
+ interface LoginChallenge {
50
+ provider: string
51
+ kind: 'browser' | 'input'
52
+ url?: string
53
+ userCode?: string
54
+ input?: LoginInputChallenge
55
+ }
56
+
57
+ export interface PiLoginSettingsInjected {
58
+ t: (key: PiLoginKey, params?: Record<string, unknown>) => string
59
+ ts: TranslateNS<'model-search'>
60
+ catalog: OpenRouterCatalogClient
61
+ }
62
+
63
+ export type PiLoginSettingsProps = Partial<PiLoginSettingsInjected>
64
+
65
+ /**
66
+ * Theme tokens that track light/dark. Hardcoded white/layer fills break dark mode.
67
+ * Mirrors ModelsSection button vocabulary so OAuth cards match the rest of Settings.
68
+ */
69
+ const SETTINGS_CSS = `
70
+ .dsh-pi-login-page { display:flex; flex-direction:column; gap:16px; max-width:640px; color:var(--dsw-alias-label-primary); }
71
+ .dsh-pi-login-title { margin:0; font-size:20px; line-height:28px; font-weight:600; color:var(--dsw-alias-label-primary); }
72
+ .dsh-pi-login-body { margin:0; font-size:13px; line-height:20px; color:var(--dsw-alias-label-secondary); }
73
+ .dsh-pi-login-error { margin:0; font-size:13px; line-height:20px; color:var(--dsw-alias-state-error-primary); }
74
+ .dsh-pi-login-stack { display:flex; flex-direction:column; gap:10px; }
75
+ .dsh-pi-login-card {
76
+ display:flex; flex-direction:column; gap:8px; padding:14px 16px;
77
+ border:1px solid var(--dsw-alias-border-l2); border-radius:12px;
78
+ background:var(--dsw-alias-bg-module-platform);
79
+ }
80
+ .dsh-pi-login-row { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; }
81
+ .dsh-pi-login-row-actions { display:flex; align-items:center; flex-wrap:wrap; justify-content:flex-end; gap:8px; }
82
+ .dsh-pi-login-name { margin:0; font-size:15px; font-weight:600; color:var(--dsw-alias-label-primary); }
83
+ .dsh-pi-login-status { display:flex; align-items:center; flex-wrap:wrap; gap:6px; font-size:13px; color:var(--dsw-alias-label-secondary); }
84
+ .dsh-pi-login-dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; background:var(--dsw-alias-label-dimmed, #9aa0a6); }
85
+ .dsh-pi-login-dot.is-signed-in { background:var(--dsw-alias-state-success-primary, #22a06b); }
86
+ .dsh-pi-login-dot.is-error { background:var(--dsw-alias-state-error-primary, #d92d20); }
87
+ .dsh-pi-login-dot.is-signing-in { background:var(--dsw-alias-brand-primary, #1677ff); }
88
+ .dsh-pi-login-btn {
89
+ box-sizing:border-box; display:inline-flex; align-items:center; justify-content:center;
90
+ min-height:32px; padding:4px 14px; border-radius:16px; font:inherit; font-size:13px; line-height:20px; cursor:pointer;
91
+ }
92
+ .dsh-pi-login-btn:disabled { opacity:0.55; cursor:not-allowed; }
93
+ .dsh-pi-login-btn-secondary {
94
+ border:1px solid var(--dsw-alias-border-l2);
95
+ background:transparent;
96
+ color:var(--dsw-alias-label-primary);
97
+ }
98
+ .dsh-pi-login-btn-secondary:hover:not(:disabled) {
99
+ background:var(--dsw-alias-interactive-bg-hover);
100
+ }
101
+ .dsh-pi-login-btn-primary {
102
+ border:none;
103
+ background:var(--dsw-alias-button-primary-fill, var(--dsw-alias-brand-primary));
104
+ color:var(--dsw-alias-label-primary-foreground, #fff);
105
+ }
106
+ .dsh-pi-login-btn-primary:hover:not(:disabled) {
107
+ background:var(--dsw-alias-button-primary-hover, var(--dsw-alias-brand-primary));
108
+ }
109
+ .dsh-pi-login-code {
110
+ font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
111
+ font-size:18px; letter-spacing:0.08em; font-weight:600; color:var(--dsw-alias-label-primary);
112
+ }
113
+ .dsh-pi-login-link { color:var(--dsw-alias-brand-primary); word-break:break-all; }
114
+ .dsh-pi-login-form { display:flex; flex-direction:column; gap:8px; }
115
+ .dsh-pi-login-input {
116
+ box-sizing:border-box; width:100%; min-height:36px; padding:7px 10px;
117
+ border:1px solid var(--dsw-alias-border-l2); border-radius:8px;
118
+ background:var(--dsw-alias-bg-page-primary, transparent);
119
+ color:var(--dsw-alias-label-primary); font:inherit; font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
120
+ }
121
+ .dsh-pi-login-input:focus {
122
+ outline:2px solid color-mix(in srgb, var(--dsw-alias-brand-primary) 28%, transparent);
123
+ border-color:var(--dsw-alias-brand-primary);
124
+ }
125
+ .dsh-pi-login-actions { display:flex; justify-content:flex-end; }
126
+ .dsh-oauth-proxy-channel { min-width:0; margin:8px 0 0; padding:12px 0 0; border:0; border-top:1px solid var(--dsw-alias-border-l2); display:flex; flex-direction:column; gap:8px; }
127
+ .dsh-oauth-proxy-channel legend { padding:0 8px 0 0; font-size:13px; }
128
+ .dsh-oauth-proxy-switch { display:flex; align-items:center; gap:8px; font-size:13px; cursor:pointer; }
129
+ .dsh-oauth-proxy-switch input { width:16px; height:16px; margin:0; accent-color:var(--dsw-alias-brand-primary); }
130
+ .dsh-oauth-proxy-fields { display:grid; grid-template-columns:minmax(0, 1fr) 100px; gap:12px; }
131
+ .dsh-oauth-proxy-fields label { min-width:0; display:flex; flex-direction:column; gap:6px; font-size:12px; }
132
+ .dsh-oauth-proxy-fields input { font-size:13px; }
133
+ .dsh-oauth-proxy-fields input:disabled { opacity:0.55; }
134
+ .dsh-oauth-proxy-actions { justify-content:flex-end; }
135
+ @media (max-width:360px) { .dsh-oauth-proxy-fields { grid-template-columns:minmax(0, 1fr); } }
136
+ `
137
+
138
+ function ensureThemeStyles(): void {
139
+ if (typeof document === 'undefined') return
140
+ if (document.getElementById(STYLE_ID) !== null) return
141
+ const style = document.createElement('style')
142
+ style.id = STYLE_ID
143
+ style.textContent = SETTINGS_CSS
144
+ document.head.appendChild(style)
145
+ }
146
+
147
+ async function jsonRequest<T>(path: string, method = 'GET', body?: unknown, signal?: AbortSignal): Promise<T> {
148
+ const response = await fetch(path, {
149
+ method,
150
+ headers: { accept: 'application/json', ...body === undefined ? {} : { 'content-type': 'application/json' } },
151
+ credentials: 'same-origin',
152
+ signal,
153
+ ...body === undefined ? {} : { body: JSON.stringify(body) },
154
+ })
155
+ const value: unknown = await response.json().catch(() => undefined)
156
+ if (!response.ok) {
157
+ const message = typeof value === 'object' && value !== null && 'error' in value && typeof value.error === 'string'
158
+ ? value.error
159
+ : `HTTP ${response.status}`
160
+ throw new Error(message)
161
+ }
162
+ return value as T
163
+ }
164
+
165
+ export function PiLoginSettings({ t, ts, catalog }: PiLoginSettingsProps) {
166
+ if (t === undefined) throw new Error('Pi login settings requires its translation function')
167
+ const [providers, setProviders] = useState<ProviderStatus[] | undefined>(undefined)
168
+ const [error, setError] = useState<string | undefined>(undefined)
169
+ const [busy, setBusy] = useState<string | undefined>(undefined)
170
+ const [drafts, setDrafts] = useState<Drafts>({})
171
+ const [challengeUrls, setChallengeUrls] = useState<Record<string, string>>({})
172
+ const [cancellable, setCancellable] = useState<Record<string, boolean>>({})
173
+ const [cancelling, setCancelling] = useState<string | undefined>(undefined)
174
+ const operationSequence = useRef(0)
175
+ const statusSequence = useRef(0)
176
+ const operations = useRef(new Map<string, { readonly id: number; readonly controller: AbortController; readonly popup?: Window }>())
177
+
178
+ const startOperation = (provider: string, canCancel: boolean, popup?: Window): { readonly id: number; readonly controller: AbortController } => {
179
+ const previous = operations.current.get(provider)
180
+ previous?.controller.abort()
181
+ previous?.popup?.close()
182
+ const operation = { id: ++operationSequence.current, controller: new AbortController(), ...popup === undefined ? {} : { popup } }
183
+ operations.current.set(provider, operation)
184
+ setBusy(provider)
185
+ setCancellable(current => ({ ...current, [provider]: canCancel }))
186
+ return operation
187
+ }
188
+ const isCurrent = (provider: string, operation: { readonly id: number }): boolean => operations.current.get(provider)?.id === operation.id
189
+ const finishOperation = (provider: string, operation: { readonly id: number }): void => {
190
+ if (!isCurrent(provider, operation)) return
191
+ operations.current.delete(provider)
192
+ setBusy(undefined)
193
+ setCancellable(current => {
194
+ const { [provider]: _removed, ...next } = current
195
+ return next
196
+ })
197
+ }
198
+
199
+ useEffect(() => { ensureThemeStyles() }, [])
200
+
201
+ const refresh = useCallback(async () => {
202
+ const requestId = ++statusSequence.current
203
+ try {
204
+ const next = await jsonRequest<ProviderStatus[]>(STATUS_PATH)
205
+ if (requestId !== statusSequence.current) return
206
+ setProviders(next)
207
+ void catalog?.load()
208
+ setError(undefined)
209
+ } catch (caught: unknown) {
210
+ if (requestId !== statusSequence.current) return
211
+ setError(caught instanceof Error ? caught.message : t('requestFailed'))
212
+ }
213
+ }, [t, catalog])
214
+
215
+ useEffect(() => { void refresh() }, [refresh])
216
+ const signing = providers?.some(provider => provider.account.status === 'signing-in') ?? false
217
+ useEffect(() => {
218
+ if (!signing) return
219
+ const timer = window.setInterval(() => { void refresh() }, POLL_INTERVAL_MS)
220
+ return () => { window.clearInterval(timer) }
221
+ }, [refresh, signing])
222
+
223
+ const signIn = async (id: string): Promise<void> => {
224
+ const popup = window.open('about:blank', '_blank')
225
+ if (popup !== null) popup.opener = null
226
+ setChallengeUrls(current => {
227
+ const { [id]: _removed, ...next } = current
228
+ return next
229
+ })
230
+ const operation = startOperation(id, true, popup ?? undefined)
231
+ try {
232
+ const challenge = await jsonRequest<LoginChallenge>(LOGIN_PATH, 'POST', { provider: id }, operation.controller.signal)
233
+ if (!isCurrent(id, operation)) return
234
+ if (challenge.url !== undefined) {
235
+ setChallengeUrls(current => ({ ...current, [id]: challenge.url! }))
236
+ }
237
+ openLoginChallenge(popup, challenge.url, url => {
238
+ window.open(url, '_blank', 'noopener,noreferrer')
239
+ })
240
+ await refresh()
241
+ } catch (caught: unknown) {
242
+ popup?.close()
243
+ if (isCurrent(id, operation) && !operation.controller.signal.aborted) setError(caught instanceof Error ? caught.message : t('requestFailed'))
244
+ } finally {
245
+ finishOperation(id, operation)
246
+ }
247
+ }
248
+
249
+ const submitInput = async (provider: ProviderStatus): Promise<void> => {
250
+ const value = drafts[provider.id]?.trim() ?? ''
251
+ if (value.length === 0) {
252
+ const challenge = provider.account.status === 'signing-in' ? provider.account.input : undefined
253
+ setError(t(challenge === undefined ? 'authInputRequired' : loginInputCopy(provider.authType, challenge.type).required))
254
+ return
255
+ }
256
+ const operation = startOperation(provider.id, true)
257
+ try {
258
+ await jsonRequest<{ ok: true }>(COMPLETE_PATH, 'POST', { provider: provider.id, value }, operation.controller.signal)
259
+ if (!isCurrent(provider.id, operation)) return
260
+ setChallengeUrls(current => {
261
+ const { [provider.id]: _removed, ...next } = current
262
+ return next
263
+ })
264
+ setDrafts(current => {
265
+ const { [provider.id]: _removed, ...next } = current
266
+ return next
267
+ })
268
+ await refresh()
269
+ } catch (caught: unknown) {
270
+ if (isCurrent(provider.id, operation) && !operation.controller.signal.aborted) setError(caught instanceof Error ? caught.message : t('requestFailed'))
271
+ } finally {
272
+ finishOperation(provider.id, operation)
273
+ }
274
+ }
275
+
276
+ const cancelSignIn = async (id: string): Promise<void> => {
277
+ const operation = startOperation(id, false)
278
+ setCancelling(id)
279
+ try {
280
+ await jsonRequest<{ ok: true }>(CANCEL_PATH, 'POST', { provider: id }, operation.controller.signal)
281
+ if (!isCurrent(id, operation)) return
282
+ setChallengeUrls(current => {
283
+ const { [id]: _removed, ...next } = current
284
+ return next
285
+ })
286
+ setDrafts(current => {
287
+ const { [id]: _removed, ...next } = current
288
+ return next
289
+ })
290
+ await refresh()
291
+ } catch (caught: unknown) {
292
+ if (isCurrent(id, operation) && !operation.controller.signal.aborted) setError(caught instanceof Error ? caught.message : t('requestFailed'))
293
+ } finally {
294
+ if (isCurrent(id, operation)) setCancelling(undefined)
295
+ finishOperation(id, operation)
296
+ }
297
+ }
298
+
299
+ const reopenSignIn = (url: string): void => {
300
+ openLoginChallenge(null, url, next => {
301
+ window.open(next, '_blank', 'noopener,noreferrer')
302
+ })
303
+ }
304
+
305
+ const signOut = async (id: string): Promise<void> => {
306
+ setBusy(id)
307
+ try {
308
+ await jsonRequest<{ ok: true }>(LOGOUT_PATH, 'POST', { provider: id })
309
+ setChallengeUrls(current => {
310
+ const { [id]: _removed, ...next } = current
311
+ return next
312
+ })
313
+ await refresh()
314
+ } catch (caught: unknown) {
315
+ setError(caught instanceof Error ? caught.message : t('requestFailed'))
316
+ } finally {
317
+ setBusy(undefined)
318
+ }
319
+ }
320
+
321
+ return (
322
+ <section className="dsh-pi-login-page" aria-labelledby="pi-login-settings-title">
323
+ <h2 id="pi-login-settings-title" className="dsh-pi-login-title">{t('title')}</h2>
324
+ <ProxySettings t={t} />
325
+ {error !== undefined ? <p className="dsh-pi-login-error">{error}</p> : null}
326
+ {providers === undefined
327
+ ? <p className="dsh-pi-login-body">{t('loadingAccount')}</p>
328
+ : (
329
+ <div className="dsh-pi-login-stack">
330
+ {providers.map(provider => {
331
+ const account = provider.account
332
+ const inputCopy = account.status === 'signing-in' && account.input !== undefined
333
+ ? loginInputCopy(provider.authType, account.input.type)
334
+ : undefined
335
+ const challengeUrl = account.status === 'signing-in'
336
+ ? account.url ?? challengeUrls[provider.id]
337
+ : undefined
338
+ const label = account.status === 'signed-in'
339
+ ? t('signedIn')
340
+ : account.status === 'signing-in'
341
+ ? inputCopy === undefined ? t('signingIn') : t(inputCopy.waiting)
342
+ : account.status === 'error'
343
+ ? t('requestFailed')
344
+ : t('signedOut')
345
+ const dotClass = account.status === 'signed-in'
346
+ ? 'dsh-pi-login-dot is-signed-in'
347
+ : account.status === 'error'
348
+ ? 'dsh-pi-login-dot is-error'
349
+ : account.status === 'signing-in'
350
+ ? 'dsh-pi-login-dot is-signing-in'
351
+ : 'dsh-pi-login-dot'
352
+ return (
353
+ <article key={provider.id} className="dsh-pi-login-card">
354
+ <div className="dsh-pi-login-row">
355
+ <p className="dsh-pi-login-name">{provider.displayName}</p>
356
+ {account.status === 'signed-in'
357
+ ? (
358
+ <button
359
+ type="button"
360
+ className="dsh-pi-login-btn dsh-pi-login-btn-secondary"
361
+ disabled={busy !== undefined}
362
+ onClick={() => { void signOut(provider.id) }}
363
+ >
364
+ {busy === provider.id ? t('working') : t('logout')}
365
+ </button>
366
+ )
367
+ : account.status === 'signing-in' || cancellable[provider.id] === true
368
+ ? (
369
+ <div className="dsh-pi-login-row-actions">
370
+ {challengeUrl !== undefined
371
+ ? (
372
+ <button
373
+ type="button"
374
+ className="dsh-pi-login-btn dsh-pi-login-btn-primary"
375
+ disabled={busy !== undefined}
376
+ onClick={() => { reopenSignIn(challengeUrl) }}
377
+ >
378
+ {t('reopenAuthorization')}
379
+ </button>
380
+ )
381
+ : null}
382
+ <button
383
+ type="button"
384
+ className="dsh-pi-login-btn dsh-pi-login-btn-secondary"
385
+ disabled={cancelling === provider.id || (busy !== undefined && busy !== provider.id)}
386
+ onClick={() => { void cancelSignIn(provider.id) }}
387
+ >
388
+ {cancelling === provider.id ? t('working') : t('cancelLogin')}
389
+ </button>
390
+ </div>
391
+ )
392
+ : (
393
+ <button
394
+ type="button"
395
+ className="dsh-pi-login-btn dsh-pi-login-btn-primary"
396
+ disabled={busy !== undefined}
397
+ onClick={() => { void signIn(provider.id) }}
398
+ >
399
+ {busy === provider.id
400
+ ? t('working')
401
+ : account.status === 'error'
402
+ ? t('loginAgain')
403
+ : provider.authType === 'api_key'
404
+ ? t('connectPlan')
405
+ : t('login')}
406
+ </button>
407
+ )}
408
+ </div>
409
+ <div className="dsh-pi-login-status" role="status">
410
+ <span aria-hidden="true" className={dotClass} />
411
+ <span>{label}</span>
412
+ </div>
413
+ {provider.id === 'openrouter' && catalog !== undefined && ts !== undefined
414
+ && <OpenRouterSyncStatus catalog={catalog} ts={ts} details />}
415
+ {account.status === 'error' ? <p className="dsh-pi-login-error">{account.message}</p> : null}
416
+ {account.status === 'signing-in' && account.userCode !== undefined
417
+ ? <p className="dsh-pi-login-body">{t('userCode')} <span className="dsh-pi-login-code">{account.userCode}</span></p>
418
+ : null}
419
+ {account.status === 'signing-in' && challengeUrl !== undefined
420
+ ? (
421
+ <p className="dsh-pi-login-body">
422
+ {provider.authType === 'api_key' && account.input !== undefined ? t('openPlanPage') : t('openUrl')}
423
+ {' '}
424
+ <a href={challengeUrl} target="_blank" rel="noreferrer" className="dsh-pi-login-link">{challengeUrl}</a>
425
+ </p>
426
+ )
427
+ : null}
428
+ {account.status === 'signing-in' && account.input !== undefined
429
+ ? (
430
+ <form
431
+ className="dsh-pi-login-form"
432
+ onSubmit={(event) => {
433
+ event.preventDefault()
434
+ void submitInput(provider)
435
+ }}
436
+ >
437
+ <p className="dsh-pi-login-body">{t(inputCopy?.help ?? 'authInputHelp')}</p>
438
+ <input
439
+ type={account.input.type === 'secret' ? 'password' : 'text'}
440
+ className="dsh-pi-login-input"
441
+ aria-label={account.input.message}
442
+ autoComplete="off"
443
+ spellCheck={false}
444
+ placeholder={t(inputCopy?.placeholder ?? 'authInputPlaceholder')}
445
+ value={drafts[provider.id] ?? ''}
446
+ disabled={busy !== undefined}
447
+ onChange={(event) => {
448
+ applyDraftChange(provider.id, event, setDrafts)
449
+ }}
450
+ />
451
+ <div className="dsh-pi-login-actions">
452
+ <button
453
+ type="submit"
454
+ className="dsh-pi-login-btn dsh-pi-login-btn-primary"
455
+ disabled={busy !== undefined || (drafts[provider.id]?.trim().length ?? 0) === 0}
456
+ >
457
+ {busy === provider.id ? t('working') : t(inputCopy?.action ?? 'submitAuthInput')}
458
+ </button>
459
+ </div>
460
+ </form>
461
+ )
462
+ : null}
463
+ </article>
464
+ )
465
+ })}
466
+ </div>
467
+ )}
468
+ </section>
469
+ )
470
+ }
@@ -0,0 +1,123 @@
1
+ import { useCallback, useEffect, useId, useState } from 'react'
2
+ import { PROXY_SETTINGS_PATH, parseProxySettings } from '../proxy-config.ts'
3
+ import type { ProxySettingsSnapshot } from '../proxy-config.ts'
4
+ import { DEFAULT_PROXY_ADDRESS, isAutomaticProxyDraft, proxyDraft, proxyDraftSettings } from './proxy-draft.ts'
5
+ import type { ProxyChannelDraft, ProxyDraft } from './proxy-draft.ts'
6
+ import type { PiLoginKey } from './locales.ts'
7
+
8
+ type Translate = (key: PiLoginKey) => string
9
+
10
+ /** Independent of account loading, so a broken network never hides its settings. */
11
+ export function ProxySettings({ t }: { t: Translate }) {
12
+ const id = useId()
13
+ const [settings, setSettings] = useState<ProxySettingsSnapshot>()
14
+ const [draft, setDraft] = useState<ProxyDraft>()
15
+ const [busy, setBusy] = useState(false)
16
+ const [error, setError] = useState<PiLoginKey>()
17
+ const [saved, setSaved] = useState(false)
18
+
19
+ const load = useCallback(async (signal?: AbortSignal) => {
20
+ setBusy(true)
21
+ setError(undefined)
22
+ setSaved(false)
23
+ try {
24
+ const response = await fetch(PROXY_SETTINGS_PATH, { credentials: 'same-origin', signal })
25
+ if (!response.ok) throw new Error('Settings unavailable')
26
+ const value = parseProxySettings(await response.json())
27
+ if (signal?.aborted) return
28
+ setSettings(value)
29
+ setDraft(proxyDraft(value))
30
+ } catch {
31
+ if (!signal?.aborted) setError('proxyLoadFailed')
32
+ } finally {
33
+ if (!signal?.aborted) setBusy(false)
34
+ }
35
+ }, [])
36
+
37
+ useEffect(() => {
38
+ const controller = new AbortController()
39
+ void load(controller.signal)
40
+ return () => { controller.abort() }
41
+ }, [load])
42
+
43
+ const change = (channel: keyof ProxyDraft, patch: Partial<ProxyChannelDraft>) => {
44
+ setDraft(current => current === undefined ? current : { ...current, [channel]: { ...current[channel], ...patch } })
45
+ setError(undefined)
46
+ setSaved(false)
47
+ }
48
+
49
+ const save = async () => {
50
+ if (draft === undefined || settings === undefined) return
51
+ let value: ProxySettingsSnapshot
52
+ try { value = proxyDraftSettings(draft, settings.revision) } catch {
53
+ setError('proxyInvalid')
54
+ return
55
+ }
56
+ setBusy(true)
57
+ setError(undefined)
58
+ setSaved(false)
59
+ try {
60
+ const response = await fetch(PROXY_SETTINGS_PATH, {
61
+ method: 'POST', credentials: 'same-origin',
62
+ headers: { 'content-type': 'application/json' }, body: JSON.stringify(value),
63
+ })
64
+ if (response.status === 409) { setError('proxyConflict'); return }
65
+ if (!response.ok) throw new Error('Settings save failed')
66
+ const updated = parseProxySettings(await response.json())
67
+ setSettings(updated)
68
+ setDraft(proxyDraft(updated))
69
+ setSaved(true)
70
+ } catch { setError('proxySaveFailed') } finally { setBusy(false) }
71
+ }
72
+
73
+ const dirty = settings !== undefined && draft !== undefined
74
+ && JSON.stringify(draft) !== JSON.stringify(proxyDraft(settings))
75
+ return (
76
+ <section className="dsh-pi-login-card dsh-oauth-network" aria-labelledby={`${id}-title`}>
77
+ <h3 id={`${id}-title`} className="dsh-pi-login-name">{t('proxyTitle')}</h3>
78
+ <p className="dsh-pi-login-body">{t('proxyScope')}</p>
79
+ {draft === undefined && busy ? <p className="dsh-pi-login-body">{t('proxyLoading')}</p> : null}
80
+ {draft === undefined ? null : (['http', 'websocket'] as const).map(channel => (
81
+ <fieldset className="dsh-oauth-proxy-channel" key={channel} disabled={busy}>
82
+ <legend className="dsh-pi-login-name">{channel === 'http' ? 'HTTP / HTTPS' : 'WebSocket (WS / WSS)'}</legend>
83
+ <label className="dsh-oauth-proxy-switch" htmlFor={`${id}-${channel}-enabled`}>
84
+ <input id={`${id}-${channel}-enabled`} type="checkbox" role="switch"
85
+ checked={draft[channel].enabled}
86
+ onChange={event => change(channel, { enabled: event.currentTarget.checked })} />
87
+ {t(channel === 'http' ? 'proxyHttpEnabled' : 'proxyWsEnabled')}
88
+ </label>
89
+ <p className="dsh-pi-login-body">{t(channel === 'http' ? 'proxyHttpHelp' : 'proxyWsHelp')}</p>
90
+ <div className="dsh-oauth-proxy-fields">
91
+ <label htmlFor={`${id}-${channel}-address`}>
92
+ <span>{t('proxyAddress')}</span>
93
+ <input className="dsh-pi-login-input" id={`${id}-${channel}-address`}
94
+ autoComplete="off" spellCheck={false} placeholder={DEFAULT_PROXY_ADDRESS}
95
+ value={draft[channel].address} disabled={!draft[channel].enabled}
96
+ onChange={event => change(channel, { address: event.currentTarget.value })} />
97
+ </label>
98
+ <label htmlFor={`${id}-${channel}-port`}>
99
+ <span>{t('proxyPort')}</span>
100
+ <input className="dsh-pi-login-input" id={`${id}-${channel}-port`}
101
+ autoComplete="off" inputMode="numeric" placeholder={t('proxyPortPlaceholder')}
102
+ value={draft[channel].port} disabled={!draft[channel].enabled}
103
+ onChange={event => change(channel, { port: event.currentTarget.value })} />
104
+ </label>
105
+ </div>
106
+ <p className="dsh-pi-login-body">{t(!draft[channel].enabled ? 'proxyDirect'
107
+ : isAutomaticProxyDraft(draft[channel]) ? 'proxyAuto' : 'proxyExplicit')}</p>
108
+ </fieldset>
109
+ ))}
110
+ <p className="dsh-pi-login-body">{t('proxyApplyHelp')}</p>
111
+ <div aria-live="polite">
112
+ {error === undefined ? null : <p className="dsh-pi-login-error" role="alert">{t(error)}</p>}
113
+ {saved ? <p className="dsh-pi-login-body">{t('proxySaved')}</p> : null}
114
+ </div>
115
+ <div className="dsh-pi-login-row dsh-oauth-proxy-actions">
116
+ <button type="button" className="dsh-pi-login-btn dsh-pi-login-btn-secondary" disabled={busy}
117
+ onClick={() => { void load() }}>{t('proxyReload')}</button>
118
+ <button type="button" className="dsh-pi-login-btn dsh-pi-login-btn-primary"
119
+ disabled={busy || !dirty} onClick={() => { void save() }}>{t(busy ? 'working' : 'proxySave')}</button>
120
+ </div>
121
+ </section>
122
+ )
123
+ }