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.
- package/INSTALL.md +59 -0
- package/INSTALL.zh.md +45 -0
- package/LICENSE +201 -0
- package/README.en.md +104 -0
- package/README.md +104 -0
- package/README.zh.md +3 -0
- package/SECURITY.md +33 -0
- package/cordis.patch.yml +5 -0
- package/docs/network-proxy.md +45 -0
- package/docs/openrouter-sync.md +84 -0
- package/docs/screenshots/01-providers-signed-out.png +0 -0
- package/docs/screenshots/03-copilot-signing-in.png +0 -0
- package/docs/screenshots/04-mixed-live-states.png +0 -0
- package/docs/screenshots/05-copilot-sign-in.gif +0 -0
- package/docs/screenshots/06-cli-status-store.png +0 -0
- package/lib/bin.d.ts +5 -0
- package/lib/bin.js +2132 -0
- package/lib/client.js +1915 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +482 -0
- package/lib/index.js +3105 -0
- package/lib/invariant.d.ts +7 -0
- package/lib/invariant.js +8 -0
- package/package.json +180 -0
- package/src/adapter.ts +207 -0
- package/src/auth-routes.ts +654 -0
- package/src/auth.ts +76 -0
- package/src/bin.ts +185 -0
- package/src/catalog.ts +129 -0
- package/src/client/OpenRouterSyncStatus.tsx +66 -0
- package/src/client/PiLoginSettings.tsx +470 -0
- package/src/client/ProxySettings.tsx +123 -0
- package/src/client/SearchableModelSelect.tsx +595 -0
- package/src/client/draft-input.ts +12 -0
- package/src/client/index.tsx +71 -0
- package/src/client/locales.ts +176 -0
- package/src/client/login-input-copy.ts +34 -0
- package/src/client/login-window.ts +24 -0
- package/src/client/model-filter.ts +45 -0
- package/src/client/openrouter-store.ts +122 -0
- package/src/client/proxy-draft.ts +57 -0
- package/src/extra-models.ts +201 -0
- package/src/hosted-capture.ts +41 -0
- package/src/hosted-images.ts +154 -0
- package/src/http.ts +241 -0
- package/src/ids.ts +17 -0
- package/src/index.ts +183 -0
- package/src/invariant.ts +12 -0
- package/src/model-error-hint.ts +98 -0
- package/src/native-tools.ts +442 -0
- package/src/oauth-refresh.ts +49 -0
- package/src/openrouter-catalog.ts +245 -0
- package/src/openrouter-free.ts +76 -0
- package/src/openrouter-models.ts +155 -0
- package/src/openrouter-types.ts +40 -0
- package/src/plugin-config.ts +30 -0
- package/src/provider.ts +137 -0
- package/src/proxy-config.ts +61 -0
- package/src/proxy-store.ts +45 -0
- package/src/proxy-transport.ts +220 -0
- package/src/redact.ts +22 -0
- package/src/responses-tap.ts +97 -0
- package/src/session.ts +104 -0
- 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
|
+
}
|