Add provider-aware OAuth accounts to onboarding
Load OAuth account providers from the status API on the path, cloud, and setup steps instead of relying on a Codex-only strip. Support provider-specific setup fields, device-code and browser callback flows, model loading by provider, the local DeepSeek icon, and the polished account heading copy.
Alessandro committed
Jun 2, 2026 at 16:18 UTC
4018a8124951dc6795bf8dfa188868d718e45e45
6 files changed
+502
-78
plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html
+1
-1
@@ -53,7 +53,7 @@
53
</div>
54
55
56
- <template x-for="card in $store.discoveryStore.heroCards.filter(card => card.id !== 'discovery-codex-oauth')" :key="card.id">
56
+ <template x-for="card in $store.discoveryStore.heroCards.filter(card => !['discovery-codex-oauth', 'discovery-oauth-accounts'].includes(card.id))" :key="card.id">
57
<article class="discovery-hero"
58
role="button"
59
tabindex="0"
plugins/_onboarding/webui/assets/provider-logos/deepseek.svg
new
+5
@@ -0,0 +1,5 @@
1
+<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <title>DeepSeek</title>
3
+ <!-- Source: Wikimedia Commons File:DeepSeek-icon.svg. Copyright DeepSeek, MIT/Expat licensed. -->
4
+ <path d="M27.501 8.46875C27.249 8.3457 27.1406 8.58008 26.9932 8.69922C26.9434 8.73828 26.9004 8.78906 26.8584 8.83398C26.4902 9.22852 26.0605 9.48633 25.5 9.45508C24.6787 9.41016 23.9785 9.66797 23.3594 10.2969C23.2275 9.52148 22.79 9.05859 22.125 8.76172C21.7764 8.60742 21.4238 8.45312 21.1807 8.11719C21.0098 7.87891 20.9639 7.61328 20.8779 7.35156C20.8242 7.19336 20.7695 7.03125 20.5879 7.00391C20.3906 6.97266 20.3135 7.13867 20.2363 7.27734C19.9258 7.84375 19.8066 8.46875 19.8174 9.10156C19.8447 10.5234 20.4453 11.6562 21.6367 12.4629C21.7725 12.5547 21.8076 12.6484 21.7646 12.7832C21.6836 13.0605 21.5869 13.3301 21.501 13.6074C21.4473 13.7852 21.3662 13.8242 21.1768 13.7461C20.5225 13.4727 19.957 13.0684 19.458 12.5781C18.6104 11.7578 17.8438 10.8516 16.8877 10.1426C16.6631 9.97656 16.4395 9.82227 16.207 9.67578C15.2314 8.72656 16.335 7.94727 16.5898 7.85547C16.8574 7.75977 16.6826 7.42773 15.8193 7.43164C14.957 7.43555 14.167 7.72461 13.1611 8.10938C13.0137 8.16797 12.8594 8.21094 12.7002 8.24414C11.7871 8.07227 10.8389 8.0332 9.84766 8.14453C7.98242 8.35352 6.49219 9.23633 5.39648 10.7441C4.08105 12.5547 3.77148 14.6133 4.15039 16.7617C4.54883 19.0234 5.70215 20.8984 7.47559 22.3633C9.31348 23.8809 11.4307 24.625 13.8457 24.4824C15.3125 24.3984 16.9463 24.2012 18.7881 22.6406C19.2529 22.8711 19.7402 22.9629 20.5498 23.0332C21.1729 23.0918 21.7725 23.002 22.2373 22.9062C22.9648 22.752 22.9141 22.0781 22.6514 21.9531C20.5186 20.959 20.9863 21.3633 20.5605 21.0371C21.6445 19.752 23.2783 18.418 23.917 14.0977C23.9668 13.7539 23.9238 13.5391 23.917 13.2598C23.9131 13.0918 23.9512 13.0254 24.1445 13.0059C24.6787 12.9453 25.1973 12.7988 25.6738 12.5352C27.0557 11.7793 27.6123 10.5391 27.7441 9.05078C27.7637 8.82422 27.7402 8.58789 27.501 8.46875ZM15.46 21.8613C13.3926 20.2344 12.3906 19.6992 11.9766 19.7227C11.5898 19.7441 11.6592 20.1875 11.7441 20.4766C11.833 20.7617 11.9492 20.959 12.1123 21.209C12.2246 21.375 12.3018 21.623 12 21.8066C11.334 22.2207 10.1768 21.668 10.1221 21.6406C8.77539 20.8477 7.64941 19.7988 6.85547 18.3652C6.08984 16.9844 5.64453 15.5039 5.57129 13.9238C5.55176 13.541 5.66406 13.4062 6.04297 13.3379C6.54199 13.2461 7.05762 13.2266 7.55664 13.2988C9.66602 13.6074 11.4619 14.5527 12.9668 16.0469C13.8262 16.9004 14.4766 17.918 15.1465 18.9121C15.8584 19.9688 16.625 20.9746 17.6006 21.7988C17.9443 22.0879 18.2197 22.3086 18.4824 22.4707C17.6895 22.5586 16.3652 22.5781 15.46 21.8613ZM16.4502 15.4805C16.4502 15.3105 16.5859 15.1758 16.7568 15.1758C16.7949 15.1758 16.8301 15.1836 16.8613 15.1953C16.9033 15.2109 16.9424 15.2344 16.9727 15.2695C17.0273 15.3223 17.0586 15.4004 17.0586 15.4805C17.0586 15.6504 16.9229 15.7852 16.7529 15.7852C16.582 15.7852 16.4502 15.6504 16.4502 15.4805ZM19.5273 17.0625C19.3301 17.1426 19.1328 17.2129 18.9434 17.2207C18.6494 17.2344 18.3281 17.1152 18.1533 16.9688C17.8828 16.7422 17.6895 16.6152 17.6074 16.2168C17.5732 16.0469 17.5928 15.7852 17.623 15.6348C17.6934 15.3105 17.6152 15.1035 17.3877 14.9141C17.2012 14.7598 16.9658 14.7188 16.7061 14.7188C16.6094 14.7188 16.5205 14.6758 16.4541 14.6406C16.3457 14.5859 16.2568 14.4512 16.3418 14.2852C16.3691 14.2324 16.501 14.1016 16.5322 14.0781C16.8838 13.877 17.29 13.9434 17.666 14.0938C18.0146 14.2363 18.2773 14.498 18.6562 14.8672C19.0439 15.3145 19.1133 15.4395 19.334 15.7734C19.5078 16.0371 19.667 16.3066 19.7754 16.6152C19.8408 16.8066 19.7559 16.9648 19.5273 17.0625Z" fill="#4D6BFE" fill-rule="nonzero"/>
5
+</svg>
plugins/_onboarding/webui/onboarding-providers.js
+1
-1
@@ -71,7 +71,7 @@ export const ONBOARDING_PROVIDER_OVERRIDES = {
71
short_description: "Multi-model API gateway.",
72
},
73
deepseek: {
74
- logo: "https://www.deepseek.com/favicon.ico",
74
+ logo: "/plugins/_onboarding/webui/assets/provider-logos/deepseek.svg",
75
setup_url: "https://platform.deepseek.com/",
76
api_key_url: "https://platform.deepseek.com/api_keys",
77
docs_url: "https://platform.deepseek.com/api_keys",
plugins/_onboarding/webui/onboarding-store.js
+278
-54
@@ -11,25 +11,23 @@ import {
11
12
const MODEL_CONFIG_API = "/plugins/_model_config";
13
const OAUTH_STATUS_API = "/plugins/_oauth/status";
14
-const OAUTH_START_API = "/plugins/_oauth/start_device_login";
14
+const OAUTH_START_API = "/plugins/_oauth/start_login";
15
const OAUTH_POLL_API = "/plugins/_oauth/poll_device_login";
16
+const OAUTH_MANUAL_CALLBACK_API = "/plugins/_oauth/manual_callback";
17
const OAUTH_MODELS_API = "/plugins/_oauth/models";
18
const MAX_OAUTH_POLL_MS = 120000;
19
20
const TOP_CLOUD_IDS = TOP_CLOUD_PROVIDER_IDS;
21
const MORE_CLOUD_IDS = MORE_CLOUD_PROVIDER_IDS;
22
23
+const OAUTH_MARKS = {
24
+ github: "terminal",
25
+ google: "cloud",
26
+ openai: "key",
27
+ xai: "neurology",
28
+};
29
+
30
const FALLBACKS = {
23
- codex_oauth: {
24
- id: "codex_oauth",
25
- name: "ChatGPT/Codex Account",
26
- logo: "https://openai.com/favicon.ico",
27
- onboarding_category: "account",
28
- api_key_mode: "oauth",
29
- short_description: "Use your connected ChatGPT or Codex account.",
30
- setup_url: "https://chatgpt.com/",
31
- docs_url: "https://platform.openai.com/docs/codex",
32
- },
31
other: {
32
id: "other",
33
name: "Other OpenAI-compatible",
@@ -102,10 +100,13 @@ export const store = createStore("onboarding", {
100
oauthStatus: null,
101
oauthLoading: false,
102
oauthConnecting: false,
103
+ oauthConnectingProvider: "",
104
oauthDevice: null,
105
oauthPollTimer: null,
106
+ oauthCallbackPollTimer: null,
107
oauthPollStartedAt: 0,
108
- oauthModels: [],
108
+ oauthModels: {},
109
+ oauthProviderUi: {},
110
111
steps: [
112
{ step: "path", label: "Choose path" },
@@ -138,9 +139,12 @@ export const store = createStore("onboarding", {
139
this.oauthStatus = null;
140
this.oauthLoading = false;
141
this.oauthConnecting = false;
142
+ this.oauthConnectingProvider = "";
143
this.oauthDevice = null;
142
- this.oauthModels = [];
144
+ this.oauthModels = {};
145
+ this.oauthProviderUi = {};
146
this.stopOauthPolling();
147
+ this.stopOauthCallbackPolling();
148
},
149
150
async onOpen() {
@@ -181,6 +185,26 @@ export const store = createStore("onboarding", {
185
const fromDetails = this.providerDetails[providerId] || {};
186
const fallback = FALLBACKS[providerId] || {};
187
const override = ONBOARDING_PROVIDER_OVERRIDES[providerId] || {};
188
+ const oauth = this.oauthProviderStatus(providerId);
189
+ if (oauth) {
190
+ const defaultModels = Array.isArray(oauth.default_models) ? oauth.default_models : [];
191
+ return {
192
+ ...fallback,
193
+ ...fromDetails,
194
+ ...override,
195
+ id: providerId,
196
+ name: oauth.display_name || fromDetails.name || override.name || providerId,
197
+ short_description: oauth.connected
198
+ ? (oauth.account_label || "Connected")
199
+ : (oauth.note || oauth.warning || "Connect this account-backed provider."),
200
+ logo: override.logo || fromDetails.logo || "/public/darkSymbol.svg",
201
+ api_key_mode: "oauth",
202
+ default_chat_model: oauth.default_model || defaultModels[0] || fromDetails.default_chat_model || "",
203
+ default_utility_model: defaultModels[1] || oauth.default_model || fromDetails.default_utility_model || "",
204
+ model_list_autoload: Boolean(oauth.connected),
205
+ auth_flow: oauth.auth_flow || "",
206
+ };
207
+ }
208
return {
209
...fallback,
210
...fromDetails,
@@ -227,12 +251,56 @@ export const store = createStore("onboarding", {
251
});
252
},
253
230
- accountMeta() {
231
- return this.providerMeta("codex_oauth");
254
+ oauthProviderStatus(providerId) {
255
+ const key = String(providerId || "").trim();
256
+ if (!key) return null;
257
+ return this.oauthStatus?.provider_map?.[key] || null;
258
+ },
259
+
260
+ oauthProviderCards() {
261
+ const providers = Array.isArray(this.oauthStatus?.providers) ? this.oauthStatus.providers : [];
262
+ return providers.filter((provider) => provider?.provider_id);
263
},
264
234
- accountActionLabel() {
235
- return this.oauthConnected() ? "Use connected account" : "Connect via device code";
265
+ oauthProviderUiFor(providerId) {
266
+ const key = String(providerId || "").trim();
267
+ if (!key) return {};
268
+ if (!this.oauthProviderUi[key]) {
269
+ this.oauthProviderUi = {
270
+ ...this.oauthProviderUi,
271
+ [key]: {
272
+ enterprise_domain: "",
273
+ client_id: "",
274
+ client_secret: "",
275
+ quota_project_id: "",
276
+ manualCallback: "",
277
+ },
278
+ };
279
+ }
280
+ return this.oauthProviderUi[key];
281
+ },
282
+
283
+ oauthMark(provider) {
284
+ return provider?.connected ? "check" : (provider?.mark || OAUTH_MARKS[provider?.icon] || "account_circle");
285
+ },
286
+
287
+ oauthCardTitle(provider) {
288
+ return provider?.display_name || provider?.short_name || provider?.provider_id || "Account";
289
+ },
290
+
291
+ oauthCardSubtitle(provider) {
292
+ if (this.oauthLoading) return "Checking";
293
+ if (provider?.connected) return provider.account_label || provider.email || "Connected";
294
+ if (!this.oauthSetupReady(provider?.provider_id)) return "Needs OAuth client details";
295
+ if (provider?.warning) return "Available with restrictions";
296
+ return "Not connected";
297
+ },
298
+
299
+ oauthAccountActionLabel(providerId) {
300
+ const provider = this.oauthProviderStatus(providerId);
301
+ if (provider?.connected) return "Use account";
302
+ if (this.oauthConnectingProvider === providerId) return "Waiting for sign-in";
303
+ return this.oauthSetupReady(providerId) ? "Connect account" : "Configure";
304
},
305
306
selectedProvider() {
@@ -310,11 +378,15 @@ export const store = createStore("onboarding", {
378
primaryDisabled() {
379
if (this.loading || this.saving) return true;
380
if (this.step === "setup") {
313
- if (this.isOAuthProvider() && !this.oauthConnected()) return true;
381
+ if (this.isOAuthProvider() && !this.oauthConnected(this.selectedProviderId)) return true;
382
if (this.providerNeedsKey(this.selectedProviderId) && !this.hasProviderKey(this.selectedProviderId)) return true;
383
return !this.config?.chat_model?.provider || !this.config?.chat_model?.name;
384
}
317
- if (this.step === "utility") return !this.config?.utility_model?.provider || !this.config?.utility_model?.name;
385
+ if (this.step === "utility") {
386
+ const utilityProvider = this.config?.utility_model?.provider || "";
387
+ if (this.isOAuthProvider(utilityProvider) && !this.oauthConnected(utilityProvider)) return true;
388
+ return !utilityProvider || !this.config?.utility_model?.name;
389
+ }
390
return false;
391
},
392
@@ -341,18 +413,22 @@ export const store = createStore("onboarding", {
413
this.pathChoice = origin;
414
const meta = this.providerMeta(providerId);
415
this.applyProviderToSlot("chat_model", providerId, meta, { forceApiBase: origin === "local" });
344
- if (providerId === "codex_oauth") {
416
+ if (this.isOAuthProvider(providerId)) {
417
await this.loadOauthStatus({ silent: true });
418
}
419
this.step = "setup";
348
- if (meta.model_list_autoload !== false) {
420
+ if (this.isOAuthProvider(providerId)) {
421
+ if (this.oauthConnected(providerId)) {
422
+ await this.loadModels("chat_model", { openDropdown: false });
423
+ }
424
+ } else if (meta.model_list_autoload !== false) {
425
await this.loadModels("chat_model", { openDropdown: false });
426
}
427
},
428
353
- async selectCodexAccount() {
429
+ async selectOAuthProvider(providerId) {
430
this.pathChoice = "cloud";
355
- await this.selectProvider("codex_oauth", "cloud");
431
+ await this.selectProvider(providerId, "cloud");
432
},
433
434
applyProviderToSlot(slotKey, providerId, meta, options = {}) {
@@ -386,7 +462,7 @@ export const store = createStore("onboarding", {
462
},
463
464
setupPurpose() {
389
- if (this.isOAuthProvider()) return "Connect once, then Agent Zero can use the local Codex/ChatGPT account bridge without an API key.";
465
+ if (this.isOAuthProvider()) return "Connect this account, then choose the account-backed model Agent Zero should use.";
466
if (this.selectedProviderOrigin === "local") return "Choose a local model and confirm where Agent Zero can reach it.";
467
return "Choose a model and add the key Agent Zero will use for this provider.";
468
},
@@ -421,21 +497,38 @@ export const store = createStore("onboarding", {
497
return Boolean(draft.trim() || modelConfigStore.apiKeyStatus?.[providerId]);
498
},
499
424
- isOAuthProvider() {
425
- return this.selectedProviderId === "codex_oauth" || this.config?.chat_model?.provider === "codex_oauth";
500
+ isOAuthProvider(providerId = "") {
501
+ const key = String(providerId || this.selectedProviderId || this.config?.chat_model?.provider || "").trim();
502
+ if (!key) return false;
503
+ return Boolean(this.oauthProviderStatus(key) || this.providerMeta(key).api_key_mode === "oauth");
504
},
505
428
- oauthConnected() {
429
- return Boolean(this.oauthStatus?.codex?.connected);
506
+ oauthConnected(providerId = "") {
507
+ const key = String(providerId || this.selectedProviderId || this.config?.chat_model?.provider || "").trim();
508
+ return Boolean(this.oauthProviderStatus(key)?.connected);
509
},
510
432
- oauthEmail() {
433
- return this.oauthStatus?.codex?.email || this.oauthStatus?.codex?.account_email || this.oauthStatus?.codex?.account_id || "";
511
+ oauthEmail(providerId = "") {
512
+ const status = this.oauthProviderStatus(providerId || this.selectedProviderId) || {};
513
+ return status.account_label || status.email || status.account_email || status.account_id || "";
514
},
515
436
- oauthStatusLabel() {
516
+ oauthStatusLabel(providerId = "") {
517
if (this.oauthLoading) return "Checking";
438
- return this.oauthConnected() ? "Connected" : "Not connected";
518
+ const status = this.oauthProviderStatus(providerId || this.selectedProviderId);
519
+ if (!status) return "Not connected";
520
+ if (status.connected) return this.oauthEmail(status.provider_id) || "Connected";
521
+ if (!this.oauthSetupReady(status.provider_id)) return "Needs OAuth client details";
522
+ return "Not connected";
523
+ },
524
+
525
+ oauthSetupReady(providerId = "") {
526
+ const status = this.oauthProviderStatus(providerId || this.selectedProviderId);
527
+ if (!status?.supports_oauth_client_config) return true;
528
+ const ui = this.oauthProviderUiFor(status.provider_id);
529
+ const clientId = ui.client_id || status.client_id || "";
530
+ const hasSecret = Boolean(ui.client_secret || status.client_secret_configured);
531
+ return Boolean(String(clientId).trim() && hasSecret);
532
},
533
534
async loadOauthStatus({ silent = false } = {}) {
@@ -443,6 +536,12 @@ export const store = createStore("onboarding", {
536
this.oauthLoading = true;
537
try {
538
this.oauthStatus = await callJsonApi(OAUTH_STATUS_API, {});
539
+ for (const provider of this.oauthProviderCards()) {
540
+ const ui = this.oauthProviderUiFor(provider.provider_id);
541
+ if (provider.enterprise_domain && !ui.enterprise_domain) ui.enterprise_domain = provider.enterprise_domain;
542
+ if (provider.client_id && !ui.client_id) ui.client_id = provider.client_id;
543
+ if (provider.quota_project_id && !ui.quota_project_id) ui.quota_project_id = provider.quota_project_id;
544
+ }
545
} catch (error) {
546
if (!silent) globalThis.justToast?.("Could not check account connection", "error");
547
} finally {
@@ -450,37 +549,74 @@ export const store = createStore("onboarding", {
549
}
550
},
551
453
- async connectCodex() {
454
- if (this.oauthConnecting) return;
552
+ async connectOAuth(providerId = "") {
553
+ const selectedProvider = String(providerId || this.selectedProviderId || "").trim();
554
+ if (!selectedProvider || this.oauthConnecting) return;
555
this.oauthConnecting = true;
556
+ this.oauthConnectingProvider = selectedProvider;
557
const popup = window.open("about:blank", "_blank");
558
if (popup) popup.opener = null;
559
try {
459
- const response = await callJsonApi(OAUTH_START_API, {});
460
- if (!response?.ok || !response.verification_url || !response.attempt_id) {
560
+ const payload = this.oauthLoginPayload(selectedProvider);
561
+ const response = await callJsonApi(OAUTH_START_API, payload);
562
+ if (!response?.ok) {
563
throw new Error(response?.error || "Could not start account connection.");
564
}
565
this.oauthDevice = response;
464
- if (popup && !popup.closed) {
465
- popup.location.assign(response.verification_url);
466
- } else {
467
- window.open(response.verification_url, "_blank", "noopener,noreferrer");
566
+ if (response.flow === "device_code" && response.verification_url && response.attempt_id) {
567
+ if (popup && !popup.closed) {
568
+ popup.location.assign(response.verification_url);
569
+ } else {
570
+ window.open(response.verification_url, "_blank", "noopener,noreferrer");
571
+ }
572
+ this.startOauthPolling(selectedProvider);
573
+ return;
574
}
469
- this.startOauthPolling();
575
+ if (response.flow === "browser_pkce" && response.auth_url) {
576
+ if (popup && !popup.closed) {
577
+ popup.location.assign(response.auth_url);
578
+ } else {
579
+ window.open(response.auth_url, "_blank", "noopener,noreferrer");
580
+ }
581
+ this.startOauthCallbackPolling(selectedProvider);
582
+ return;
583
+ }
584
+ throw new Error(response?.error || "Could not start account connection.");
585
} catch (error) {
586
if (popup && !popup.closed) popup.close();
587
this.oauthConnecting = false;
588
+ this.oauthConnectingProvider = "";
589
globalThis.justToast?.(error?.message || "Could not connect account", "error");
590
}
591
},
592
477
- startOauthPolling() {
593
+ oauthLoginPayload(providerId) {
594
+ const status = this.oauthProviderStatus(providerId) || {};
595
+ const ui = this.oauthProviderUiFor(providerId);
596
+ const payload = { provider_id: providerId };
597
+ if (status.supports_enterprise_domain) {
598
+ payload.enterprise_domain = ui.enterprise_domain || "";
599
+ }
600
+ if (status.supports_oauth_client_config) {
601
+ payload.client_id = ui.client_id || "";
602
+ payload.client_secret = ui.client_secret || "";
603
+ }
604
+ if (status.supports_quota_project) {
605
+ payload.quota_project_id = ui.quota_project_id || "";
606
+ }
607
+ return payload;
608
+ },
609
+
610
+ startOauthPolling(providerId = "") {
611
this.stopOauthPolling();
612
this.oauthPollStartedAt = Date.now();
613
const tick = async () => {
614
if (!this.oauthDevice?.attempt_id) return;
615
try {
483
- const response = await callJsonApi(OAUTH_POLL_API, { attempt_id: this.oauthDevice.attempt_id });
616
+ const response = await callJsonApi(OAUTH_POLL_API, {
617
+ provider_id: providerId,
618
+ attempt_id: this.oauthDevice.attempt_id,
619
+ });
620
if (!response?.ok) {
621
if (response?.expired) {
622
this.oauthDevice = null;
@@ -489,21 +625,24 @@ export const store = createStore("onboarding", {
625
}
626
if (response.completed) {
627
this.oauthConnecting = false;
628
+ this.oauthConnectingProvider = "";
629
this.oauthDevice = null;
630
this.stopOauthPolling();
631
await this.loadOauthStatus();
495
- this.applyProviderToSlot("chat_model", "codex_oauth", this.providerMeta("codex_oauth"));
496
- await this.loadOauthModels();
632
+ this.applyProviderToSlot("chat_model", providerId, this.providerMeta(providerId));
633
+ await this.loadOauthModels(providerId, "chat_model");
634
return;
635
}
636
} catch (error) {
637
this.oauthConnecting = false;
638
+ this.oauthConnectingProvider = "";
639
this.stopOauthPolling();
640
globalThis.justToast?.(error?.message || "Could not connect account", "error");
641
return;
642
}
643
if (Date.now() - this.oauthPollStartedAt > MAX_OAUTH_POLL_MS) {
644
this.oauthConnecting = false;
645
+ this.oauthConnectingProvider = "";
646
this.oauthDevice = null;
647
this.stopOauthPolling();
648
}
@@ -520,28 +659,113 @@ export const store = createStore("onboarding", {
659
this.oauthPollTimer = null;
660
},
661
523
- async loadOauthModels() {
662
+ startOauthCallbackPolling(providerId = "") {
663
+ this.stopOauthCallbackPolling();
664
+ this.oauthPollStartedAt = Date.now();
665
+ const tick = async () => {
666
+ await this.loadOauthStatus({ silent: true });
667
+ if (this.oauthConnected(providerId)) {
668
+ this.oauthConnecting = false;
669
+ this.oauthConnectingProvider = "";
670
+ this.oauthDevice = null;
671
+ this.stopOauthCallbackPolling();
672
+ this.applyProviderToSlot("chat_model", providerId, this.providerMeta(providerId));
673
+ await this.loadOauthModels(providerId, "chat_model");
674
+ return;
675
+ }
676
+ if (Date.now() - this.oauthPollStartedAt > MAX_OAUTH_POLL_MS) {
677
+ this.oauthConnecting = false;
678
+ this.oauthConnectingProvider = "";
679
+ this.oauthDevice = null;
680
+ this.stopOauthCallbackPolling();
681
+ }
682
+ };
683
+ void tick();
684
+ this.oauthCallbackPollTimer = window.setInterval(tick, 2500);
685
+ },
686
+
687
+ stopOauthCallbackPolling() {
688
+ if (this.oauthCallbackPollTimer) window.clearInterval(this.oauthCallbackPollTimer);
689
+ this.oauthCallbackPollTimer = null;
690
+ },
691
+
692
+ async submitOAuthManualCallback(providerId = "") {
693
+ const selectedProvider = String(providerId || this.selectedProviderId || "").trim();
694
+ const callback = String(this.oauthProviderUiFor(selectedProvider).manualCallback || "").trim();
695
+ if (!selectedProvider || !callback) {
696
+ globalThis.justToast?.("Paste callback URL, query string, or code.", "error");
697
+ return;
698
+ }
699
+ this.oauthConnecting = true;
700
+ this.oauthConnectingProvider = selectedProvider;
701
try {
525
- const response = await callJsonApi(OAUTH_MODELS_API, {});
526
- this.oauthModels = Array.isArray(response?.models) ? response.models : [];
527
- if (this.oauthModels.length && !this.userTouchedModel.chat_model) {
528
- this.config.chat_model.name = this.oauthModels[0];
702
+ const response = await callJsonApi(OAUTH_MANUAL_CALLBACK_API, {
703
+ provider_id: selectedProvider,
704
+ callback,
705
+ });
706
+ if (!response?.ok) {
707
+ throw new Error(response?.error || "Could not finish account connection.");
708
}
530
- this.modelDropdown.chat_model.models = this.oauthModels;
531
- this.modelDropdown.chat_model.source = "oauth";
532
- } catch {
533
- this.oauthModels = [];
709
+ this.oauthProviderUiFor(selectedProvider).manualCallback = "";
710
+ this.oauthDevice = null;
711
+ this.stopOauthCallbackPolling();
712
+ await this.loadOauthStatus();
713
+ this.applyProviderToSlot("chat_model", selectedProvider, this.providerMeta(selectedProvider));
714
+ await this.loadOauthModels(selectedProvider, "chat_model");
715
+ } catch (error) {
716
+ globalThis.justToast?.(error?.message || "Could not connect account", "error");
717
+ } finally {
718
+ this.oauthConnecting = false;
719
+ this.oauthConnectingProvider = "";
720
+ }
721
+ },
722
+
723
+ async loadOauthModels(providerId = "", slotKey = "chat_model", { openDropdown = true } = {}) {
724
+ const selectedProvider = String(providerId || this.config?.[slotKey]?.provider || this.selectedProviderId || "").trim();
725
+ if (!selectedProvider) return;
726
+ const dropdown = this.modelDropdown[slotKey];
727
+ dropdown.loading = true;
728
+ dropdown.error = "";
729
+ dropdown.source = "";
730
+ try {
731
+ const response = await callJsonApi(OAUTH_MODELS_API, { provider_id: selectedProvider });
732
+ if (!response?.ok) {
733
+ throw new Error(response?.error || "Could not load account models.");
734
+ }
735
+ const models = Array.isArray(response?.models) ? response.models : [];
736
+ this.oauthModels = { ...this.oauthModels, [selectedProvider]: models };
737
+ dropdown.models = models;
738
+ dropdown.source = "oauth";
739
+ dropdown.open = openDropdown && models.length > 0;
740
+ if (models.length && !this.userTouchedModel[slotKey]) {
741
+ const meta = this.providerMeta(selectedProvider);
742
+ const preferred = slotKey === "utility_model" ? meta.default_utility_model : meta.default_chat_model;
743
+ this.config[slotKey].name = preferred && models.includes(preferred) ? preferred : models[0];
744
+ }
745
+ } catch (error) {
746
+ this.oauthModels = { ...this.oauthModels, [selectedProvider]: [] };
747
+ dropdown.models = [];
748
+ dropdown.error = error?.message || "Could not load account models.";
749
+ dropdown.open = false;
750
+ } finally {
751
+ dropdown.loading = false;
752
}
753
},
754
755
cancelOauthConnect() {
756
this.oauthConnecting = false;
757
+ this.oauthConnectingProvider = "";
758
this.oauthDevice = null;
759
this.stopOauthPolling();
760
+ this.stopOauthCallbackPolling();
761
},
762
763
async loadModels(slotKey, { openDropdown = true } = {}) {
764
if (!this.config?.[slotKey]?.provider) return;
765
+ if (this.isOAuthProvider(this.config[slotKey].provider)) {
766
+ await this.loadOauthModels(this.config[slotKey].provider, slotKey, { openDropdown });
767
+ return;
768
+ }
769
const dropdown = this.modelDropdown[slotKey];
770
dropdown.loading = true;
771
dropdown.error = "";
plugins/_onboarding/webui/onboarding.html
+199
-17
@@ -175,6 +175,109 @@
175
.section-title { color: var(--onboard-ink); font-weight: 900; }
176
.setup-hero .section-title { font-weight: 720; }
177
.account-subtitle, .section-description { color: var(--onboard-muted); line-height: 1.45; }
178
+ .oauth-account-section {
179
+ display: grid;
180
+ gap: 10px;
181
+ margin-top: 14px;
182
+ }
183
+ .path-oauth-account-section {
184
+ margin-top: 24px;
185
+ }
186
+ .path-oauth-account-section .oauth-account-section-head {
187
+ justify-content: center;
188
+ text-align: center;
189
+ }
190
+ .path-oauth-account-section .oauth-account-section-head span {
191
+ font-weight: 760;
192
+ }
193
+ .oauth-account-section-head {
194
+ display: flex;
195
+ align-items: baseline;
196
+ justify-content: space-between;
197
+ gap: 12px;
198
+ color: var(--onboard-ink);
199
+ }
200
+ .oauth-account-section-head span {
201
+ font-weight: 900;
202
+ }
203
+ .oauth-account-section-head small {
204
+ color: var(--onboard-muted);
205
+ font-weight: 650;
206
+ }
207
+ .oauth-account-grid {
208
+ display: grid;
209
+ grid-template-columns: repeat(2, minmax(0, 1fr));
210
+ gap: 10px;
211
+ }
212
+ .oauth-account-card {
213
+ min-height: 76px;
214
+ display: grid;
215
+ grid-template-columns: auto minmax(0, 1fr) auto;
216
+ align-items: center;
217
+ gap: 10px;
218
+ padding: 12px;
219
+ border: 1px solid var(--onboard-line);
220
+ border-radius: 15px;
221
+ background: color-mix(in srgb, var(--color-panel) 68%, transparent);
222
+ color: var(--onboard-ink);
223
+ text-align: left;
224
+ cursor: pointer;
225
+ transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
226
+ }
227
+ .oauth-account-card:hover,
228
+ .oauth-account-card:focus-visible {
229
+ border-color: var(--color-border);
230
+ background: color-mix(in srgb, var(--color-background-hover) 62%, var(--color-panel));
231
+ outline: none;
232
+ }
233
+ .oauth-account-card.connected {
234
+ border-color: color-mix(in srgb, #8fd8a8 44%, var(--onboard-line));
235
+ }
236
+ .oauth-account-mark {
237
+ width: 36px;
238
+ height: 36px;
239
+ display: grid;
240
+ place-items: center;
241
+ border-radius: 10px;
242
+ background: color-mix(in srgb, var(--color-background) 50%, transparent);
243
+ color: var(--onboard-muted);
244
+ }
245
+ .oauth-account-mark.connected {
246
+ color: #0c1b11;
247
+ background: #8fd8a8;
248
+ }
249
+ .oauth-account-copy {
250
+ display: grid;
251
+ min-width: 0;
252
+ gap: 3px;
253
+ }
254
+ .oauth-account-title,
255
+ .oauth-account-subtitle {
256
+ overflow: hidden;
257
+ text-overflow: ellipsis;
258
+ white-space: nowrap;
259
+ }
260
+ .oauth-account-title {
261
+ font-weight: 900;
262
+ line-height: 1.2;
263
+ }
264
+ .oauth-account-subtitle {
265
+ color: var(--onboard-muted);
266
+ font-size: 0.84rem;
267
+ }
268
+ .oauth-account-action {
269
+ min-height: 28px;
270
+ display: inline-flex;
271
+ align-items: center;
272
+ justify-content: center;
273
+ padding: 0 10px;
274
+ border-radius: 8px;
275
+ background: color-mix(in srgb, var(--color-background) 54%, transparent);
276
+ color: var(--onboard-ink);
277
+ font-size: 0.78rem;
278
+ font-weight: 900;
279
+ white-space: nowrap;
280
+ }
281
.provider-grid {
282
display: grid;
283
grid-template-columns: repeat(5, minmax(0, 1fr));
@@ -487,6 +590,25 @@
590
line-height: 1.4;
591
}
592
.oauth-device-note .material-symbols-outlined { font-size: 19px; color: #d9ad68; }
593
+ .oauth-provider-fields {
594
+ display: grid;
595
+ grid-template-columns: repeat(2, minmax(0, 1fr));
596
+ gap: 10px;
597
+ }
598
+ .oauth-provider-fields .field {
599
+ display: grid;
600
+ gap: 6px;
601
+ }
602
+ .oauth-provider-fields .field span {
603
+ color: var(--onboard-ink);
604
+ font-weight: 850;
605
+ }
606
+ .oauth-manual-callback {
607
+ display: grid;
608
+ grid-template-columns: minmax(0, 1fr) auto;
609
+ align-items: center;
610
+ gap: 8px;
611
+ }
612
.status-pill {
613
display: inline-flex;
614
align-items: center;
@@ -502,6 +624,7 @@
624
@media (max-width: 980px) {
625
.provider-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
626
.more-provider-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
627
+ .oauth-account-grid { grid-template-columns: 1fr; }
628
.setup-layout { grid-template-columns: 1fr; }
629
}
630
@media (max-width: 760px) {
@@ -513,8 +636,14 @@
636
.more-provider-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
637
.path-card { aspect-ratio: 21 / 10; }
638
.account-strip { align-items: flex-start; flex-direction: column; }
639
+ .oauth-account-card { grid-template-columns: auto minmax(0, 1fr); }
640
+ .oauth-account-action { grid-column: 2; justify-self: start; }
641
.oauth-connect-main { align-items: stretch; flex-direction: column; }
642
.oauth-connect-actions { justify-content: flex-start; }
643
+ .oauth-provider-fields,
644
+ .oauth-manual-callback {
645
+ grid-template-columns: 1fr;
646
+ }
647
}
648
@media (max-width: 480px) {
649
.provider-grid { grid-template-columns: 1fr; }
@@ -600,6 +729,25 @@
729
</span>
730
</button>
731
</div>
732
+ <div class="oauth-account-section path-oauth-account-section" x-show="$store.onboarding.oauthProviderCards().length">
733
+ <div class="oauth-account-section-head">
734
+ <span>or connect your AI account</span>
735
+ </div>
736
+ <div class="oauth-account-grid">
737
+ <template x-for="provider in $store.onboarding.oauthProviderCards()" :key="`path-${provider.provider_id}`">
738
+ <button type="button" class="oauth-account-card" :class="{ connected: provider.connected }" @click="$store.onboarding.selectOAuthProvider(provider.provider_id)">
739
+ <span class="oauth-account-mark" :class="{ connected: provider.connected }">
740
+ <span class="material-symbols-outlined" x-text="$store.onboarding.oauthMark(provider)"></span>
741
+ </span>
742
+ <span class="oauth-account-copy">
743
+ <span class="oauth-account-title" x-text="$store.onboarding.oauthCardTitle(provider)"></span>
744
+ <span class="oauth-account-subtitle" x-text="$store.onboarding.oauthCardSubtitle(provider)"></span>
745
+ </span>
746
+ <span class="oauth-account-action" x-text="$store.onboarding.oauthAccountActionLabel(provider.provider_id)"></span>
747
+ </button>
748
+ </template>
749
+ </div>
750
+ </div>
751
</section>
752
753
<section x-show="$store.onboarding.isStep('cloud')" x-transition.opacity>
@@ -613,15 +761,25 @@
761
</button>
762
</template>
763
</div>
616
- <div class="account-strip">
617
- <div class="account-strip-main">
618
- <img :src="$store.onboarding.accountMeta().logo" alt="OpenAI logo">
619
- <div>
620
- <div class="account-title">Connect ChatGPT/Codex Account</div>
621
- <div class="account-subtitle" x-text="$store.onboarding.oauthStatusLabel()"></div>
622
- </div>
764
+ <div class="oauth-account-section" x-show="$store.onboarding.oauthProviderCards().length">
765
+ <div class="oauth-account-section-head">
766
+ <span>Accounts</span>
767
+ <small>Account-backed providers can be used without API keys.</small>
768
+ </div>
769
+ <div class="oauth-account-grid">
770
+ <template x-for="provider in $store.onboarding.oauthProviderCards()" :key="`cloud-${provider.provider_id}`">
771
+ <button type="button" class="oauth-account-card" :class="{ connected: provider.connected }" @click="$store.onboarding.selectOAuthProvider(provider.provider_id)">
772
+ <span class="oauth-account-mark" :class="{ connected: provider.connected }">
773
+ <span class="material-symbols-outlined" x-text="$store.onboarding.oauthMark(provider)"></span>
774
+ </span>
775
+ <span class="oauth-account-copy">
776
+ <span class="oauth-account-title" x-text="$store.onboarding.oauthCardTitle(provider)"></span>
777
+ <span class="oauth-account-subtitle" x-text="$store.onboarding.oauthCardSubtitle(provider)"></span>
778
+ </span>
779
+ <span class="oauth-account-action" x-text="$store.onboarding.oauthAccountActionLabel(provider.provider_id)"></span>
780
+ </button>
781
+ </template>
782
</div>
624
- <button type="button" class="btn btn-secondary" @click="$store.onboarding.selectCodexAccount()" x-text="$store.onboarding.accountActionLabel()"></button>
783
</div>
784
<div class="more-providers onboarding-panel">
785
<button type="button"
@@ -703,31 +861,55 @@
861
<div class="oauth-connect-panel">
862
<div class="oauth-connect-main">
863
<div class="oauth-status-group">
706
- <span class="oauth-status-mark" :class="{ connected: $store.onboarding.oauthConnected() }">
707
- <span class="material-symbols-outlined" x-text="$store.onboarding.oauthConnected() ? 'check_circle' : 'lock_open'"></span>
864
+ <span class="oauth-status-mark" :class="{ connected: $store.onboarding.oauthConnected($store.onboarding.selectedProviderId) }">
865
+ <span class="material-symbols-outlined" x-text="$store.onboarding.oauthConnected($store.onboarding.selectedProviderId) ? 'check_circle' : $store.onboarding.oauthMark($store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId))"></span>
866
</span>
867
<span class="oauth-status-copy">
710
- <span class="oauth-status-label">ChatGPT/Codex account</span>
868
+ <span class="oauth-status-label" x-text="$store.onboarding.selectedProviderName()"></span>
869
<span class="oauth-status-detail">
712
- <span x-text="$store.onboarding.oauthStatusLabel()"></span>
713
- <span x-show="$store.onboarding.oauthEmail()"> - <span x-text="$store.onboarding.oauthEmail()"></span></span>
870
+ <span x-text="$store.onboarding.oauthStatusLabel($store.onboarding.selectedProviderId)"></span>
871
</span>
872
</span>
873
</div>
874
<div class="oauth-connect-actions">
718
- <button type="button" class="btn btn-ok" x-show="!$store.onboarding.oauthConnected()" @click="$store.onboarding.connectCodex()" :disabled="$store.onboarding.oauthConnecting">
719
- <span x-text="$store.onboarding.oauthConnecting ? 'Waiting for sign-in' : 'Connect account'"></span>
875
+ <button type="button" class="btn btn-ok" x-show="!$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)" @click="$store.onboarding.connectOAuth($store.onboarding.selectedProviderId)" :disabled="$store.onboarding.oauthConnecting || !$store.onboarding.oauthSetupReady($store.onboarding.selectedProviderId)">
876
+ <span x-text="$store.onboarding.oauthAccountActionLabel($store.onboarding.selectedProviderId)"></span>
877
</button>
878
<button type="button" class="btn btn-cancel" x-show="$store.onboarding.oauthConnecting" @click="$store.onboarding.cancelOauthConnect()">Cancel</button>
722
- <span class="status-pill connected" x-show="$store.onboarding.oauthConnected()">
879
+ <span class="status-pill connected" x-show="$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
880
<span class="material-symbols-outlined">check_circle</span>
881
<span>Connected</span>
882
</span>
883
</div>
884
</div>
885
+ <div class="oauth-provider-fields" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_enterprise_domain && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
886
+ <label class="field">
887
+ <span>Enterprise domain</span>
888
+ <input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).enterprise_domain" placeholder="github.com">
889
+ </label>
890
+ </div>
891
+ <div class="oauth-provider-fields" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_oauth_client_config && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
892
+ <label class="field">
893
+ <span>OAuth client ID</span>
894
+ <input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).client_id" placeholder="Google Cloud OAuth client ID">
895
+ </label>
896
+ <label class="field">
897
+ <span>OAuth client secret</span>
898
+ <input type="password" autocomplete="off" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).client_secret" placeholder="Google Cloud OAuth client secret">
899
+ </label>
900
+ <label class="field" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_quota_project">
901
+ <span>Quota project</span>
902
+ <input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).quota_project_id" placeholder="Optional Google Cloud project ID">
903
+ </label>
904
+ </div>
905
<div class="oauth-device-note" x-show="$store.onboarding.oauthDevice">
906
<span class="material-symbols-outlined">key</span>
730
- <span>Device code: <b x-text="$store.onboarding.oauthDevice?.user_code"></b></span>
907
+ <span x-show="$store.onboarding.oauthDevice?.user_code">Device code: <b x-text="$store.onboarding.oauthDevice?.user_code"></b></span>
908
+ <span x-show="!$store.onboarding.oauthDevice?.user_code">Finish sign-in in the opened browser tab.</span>
909
+ </div>
910
+ <div class="oauth-manual-callback" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_manual_callback && $store.onboarding.oauthDevice?.flow === 'browser_pkce' && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
911
+ <input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).manualCallback" placeholder="Paste callback URL, query string, or code">
912
+ <button type="button" class="btn btn-secondary" @click="$store.onboarding.submitOAuthManualCallback($store.onboarding.selectedProviderId)">Submit</button>
913
</div>
914
</div>
915
</template>
tests/test_onboarding_static.py
+18
-5
@@ -17,15 +17,26 @@ def test_onboarding_contains_guided_cloud_local_flow():
17
assert "Choose your local LLM provider" in html + store
18
assert "cloud-card.webp" in html
19
assert "local-card.webp" in html
20
- assert "Connect ChatGPT/Codex Account" in html
20
+ assert "oauthProviderCards()" in html + store
21
+ assert "selectOAuthProvider(provider.provider_id)" in html
22
+ assert "path-oauth-account-section" in html
23
+ assert "or connect your AI account" in html
24
+ assert "Connect one or more account-backed providers." not in html
25
+ assert ".path-oauth-account-section .oauth-account-section-head" in html
26
+ assert "font-weight: 760;" in html
27
+ assert "Connect ChatGPT/Codex Account" not in html
28
assert "Main model" in html
29
assert "Refresh model list" in html
30
assert "Search or enter Utility Model" in html
31
assert "Advanced Settings" in html
32
assert "selectedProviderName() + ' Docs'" in html
33
assert "openSelectedProviderDocs" in html + store
27
- assert "Connect via device code" in html + store
28
- assert "accountActionLabel" in html + store
34
+ assert "Connect account" in html + store
35
+ assert "oauthAccountActionLabel" in html + store
36
+ assert "connectOAuth" in html + store
37
+ assert "submitOAuthManualCallback" in html + store
38
+ assert 'const OAUTH_START_API = "/plugins/_oauth/start_login";' in store
39
+ assert "/plugins/_oauth/start_device_login" not in store
40
assert "Click here if you don't see your provider" in html
41
assert "provider-description" not in html
42
assert "!$store.onboarding.isStep('cloud')" in html
@@ -99,6 +110,7 @@ def test_onboarding_provider_grid_names_are_present_in_metadata():
110
assert forbidden not in provider_yaml
111
112
for logo in [
113
+ "deepseek.svg",
114
"google-gemini.svg",
115
"groq.svg",
116
"sambanova.png",
@@ -131,11 +143,12 @@ def test_discovery_auto_modal_extension_contains_required_guards():
143
assert "auto_modal_surfaces" in content
144
145
134
-def test_onboarding_success_filters_codex_discovery_card():
146
+def test_onboarding_success_filters_oauth_discovery_cards():
147
content = (
148
PROJECT_ROOT
149
/ "plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html"
150
).read_text(encoding="utf-8")
151
152
assert "discovery-codex-oauth" in content
141
- assert "filter(card => card.id !== 'discovery-codex-oauth')" in content
153
+ assert "discovery-oauth-accounts" in content
154
+ assert "includes(card.id)" in content