Unify OAuth account management surfaces
Add a shared provider-registry-backed OAuth status summary for the status API and discovery cards. Redesign the OAuth settings modal around provider rows, inline usage, provider details, and account-backed model slots while keeping legacy Codex compatibility fields intact.
Alessandro committed
Jun 2, 2026 at 16:18 UTC
4aa701fcb27b3b7202ffc622114ff67ee346ae37
10 files changed
+716
-301
plugins/_discovery/extensions/python/banners/10_discovery_cards.py
+58
-45
@@ -5,42 +5,55 @@ from helpers import plugins
5
class DiscoveryCardsExtension(Extension):
6
"""Injects discovery cards into the banners list."""
7
8
- def _codex_oauth_status(self) -> dict:
8
+ def _oauth_summary(self) -> dict:
9
try:
10
- from plugins._oauth.helpers import codex
10
+ from plugins._oauth.helpers.providers import provider_registry
11
+ from plugins._oauth.helpers.route_bootstrap import is_installed
12
+ from plugins._oauth.helpers.summary import build_oauth_status_summary
13
12
- status = codex.status()
13
- return status if isinstance(status, dict) else {}
14
+ return build_oauth_status_summary(
15
+ provider_registry=provider_registry,
16
+ routes_installed=is_installed,
17
+ )
18
except Exception:
19
return {}
20
17
- def _codex_oauth_usage_windows(self, status: dict) -> list[dict]:
18
- usage = status.get("usage") if isinstance(status, dict) else {}
19
- if not isinstance(usage, dict) or not usage.get("available"):
20
- return []
21
-
21
+ def _oauth_usage_windows(self, summary: dict) -> list[dict]:
22
windows: list[dict] = []
23
- for key, title in (("primary", "Session"), ("secondary", "Week")):
24
- window = usage.get(key)
25
- if not isinstance(window, dict):
23
+ accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {}
24
+ connected = accounts.get("connected", []) if isinstance(accounts, dict) else []
25
+ for account in connected:
26
+ if not isinstance(account, dict):
27
continue
27
- remaining = window.get("remaining_percent")
28
- used = window.get("used_percent")
29
- if remaining is None and used is not None:
30
- try:
31
- remaining = max(0, min(100, 100 - float(used)))
32
- except (TypeError, ValueError):
33
- remaining = None
34
- if remaining is None:
28
+ provider_name = account.get("short_name") or account.get("display_name") or "Account"
29
+ for window in account.get("usage_windows") or []:
30
+ if not isinstance(window, dict):
31
+ continue
32
+ windows.append({
33
+ **window,
34
+ "key": f'{account.get("provider_id", "oauth")}-{window.get("key", "usage")}',
35
+ "title": f'{provider_name} {window.get("title") or "Usage"}',
36
+ })
37
+ return windows[:4]
38
+
39
+ def _oauth_account_chips(self, summary: dict) -> list[dict]:
40
+ accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {}
41
+ if not isinstance(accounts, dict):
42
+ return []
43
+ connected = accounts.get("connected") or []
44
+ available = accounts.get("available") or []
45
+ source = connected if connected else available
46
+ chips = []
47
+ for account in source[:4]:
48
+ if not isinstance(account, dict):
49
continue
36
- windows.append({
37
- "key": key,
38
- "title": title,
39
- "label": window.get("label") or "",
40
- "remaining_percent": remaining,
41
- "reset_at": window.get("reset_at") or 0,
50
+ chips.append({
51
+ "provider_id": account.get("provider_id") or "",
52
+ "label": account.get("short_name") or account.get("display_name") or account.get("provider_id"),
53
+ "detail": account.get("account_label") if account.get("connected") else "Available",
54
+ "connected": bool(account.get("connected")),
55
})
43
- return windows
56
+ return chips
57
58
async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
59
# Optional logic: only show specific cards if plugins aren't already configured.
@@ -49,8 +62,10 @@ class DiscoveryCardsExtension(Extension):
62
telegram_config = plugins.get_plugin_config("_telegram_integration") or {}
63
email_config = plugins.get_plugin_config("_email_integration") or {}
64
whatsapp_config = plugins.get_plugin_config("_whatsapp_integration") or {}
52
- codex_oauth_status = self._codex_oauth_status()
53
- codex_oauth_connected = bool(codex_oauth_status.get("connected"))
65
+ oauth_summary = self._oauth_summary()
66
+ oauth_accounts = oauth_summary.get("oauth_accounts") if isinstance(oauth_summary, dict) else {}
67
+ connected_count = int(oauth_accounts.get("connected_count") or 0) if isinstance(oauth_accounts, dict) else 0
68
+ total_count = int(oauth_accounts.get("total_count") or 0) if isinstance(oauth_accounts, dict) else 0
69
70
# 1. Plugin Hub Hero
71
banners.append({
@@ -117,25 +132,23 @@ class DiscoveryCardsExtension(Extension):
132
"show_in_onboarding": True
133
})
134
120
- # 5. Codex/ChatGPT OAuth
121
- codex_oauth_card = {
122
- "id": "discovery-codex-oauth",
135
+ # 5. OAuth account providers
136
+ oauth_card = {
137
+ "id": "discovery-oauth-accounts",
138
"type": "hero",
139
"placement": "after-features",
125
- "title": "Connect ChatGPT/Codex" if not codex_oauth_connected else "ChatGPT/Codex Connected",
126
- "description": "Link your account through the OAuth plugin to unlock account-backed Codex models locally."
127
- if not codex_oauth_connected
128
- else "Manage your OAuth connection and account-backed Codex models.",
129
- "thumbnail": "/plugins/_discovery/webui/assets/hero-openai-oauth.png",
130
- "icon": "key",
131
- "cta_text": "Connect Account" if not codex_oauth_connected else "Manage OAuth",
140
+ "title": "Your AI accounts",
141
+ "description": "Link account-backed providers such as ChatGPT/Codex, GitHub Copilot, Gemini API, and xAI Grok."
142
+ if not connected_count
143
+ else "",
144
+ "cta_text": "Connect accounts" if not connected_count else "Manage accounts",
145
"cta_action": "open-plugin-config:_oauth",
146
"dismissible": True,
147
"priority": 40,
135
- "show_in_onboarding": True
148
+ "show_in_onboarding": True,
149
+ "account_chips": self._oauth_account_chips(oauth_summary),
150
}
137
- if codex_oauth_connected:
138
- usage_windows = self._codex_oauth_usage_windows(codex_oauth_status)
139
- if usage_windows:
140
- codex_oauth_card["usage_windows"] = usage_windows
141
- banners.append(codex_oauth_card)
151
+ usage_windows = self._oauth_usage_windows(oauth_summary)
152
+ if usage_windows:
153
+ oauth_card["usage_windows"] = usage_windows
154
+ banners.append(oauth_card)
plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html
+60
-5
@@ -28,7 +28,15 @@
28
29
<div class="discovery-hero-content">
30
<h3 class="discovery-hero-title" x-text="card.title"></h3>
31
- <p class="discovery-hero-desc" x-text="card.description"></p>
31
+ <p class="discovery-hero-desc" x-show="card.description" x-text="card.description"></p>
32
+ <div class="discovery-account-chips" x-show="(card.account_chips || []).length">
33
+ <template x-for="chip in card.account_chips" :key="`${card.id}-${chip.provider_id}`">
34
+ <span class="discovery-account-chip" :class="{ connected: chip.connected }">
35
+ <span x-text="chip.label"></span>
36
+ <small x-show="chip.detail" x-text="chip.detail"></small>
37
+ </span>
38
+ </template>
39
+ </div>
40
<div class="discovery-usage" x-show="(card.usage_windows || []).length">
41
<template x-for="window in card.usage_windows" :key="`${card.id}-${window.key}`">
42
<div class="discovery-usage-window">
@@ -54,7 +62,7 @@
62
</button>
63
</div>
64
57
- <div class="discovery-hero-thumb">
65
+ <div class="discovery-hero-thumb" x-show="card.thumbnail || card.icon">
66
<template x-if="card.thumbnail">
67
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
68
</template>
@@ -124,7 +132,15 @@
132
133
<div class="discovery-hero-content">
134
<h3 class="discovery-hero-title" x-text="card.title"></h3>
127
- <p class="discovery-hero-desc" x-text="card.description"></p>
135
+ <p class="discovery-hero-desc" x-show="card.description" x-text="card.description"></p>
136
+ <div class="discovery-account-chips" x-show="(card.account_chips || []).length">
137
+ <template x-for="chip in card.account_chips" :key="`${card.id}-${chip.provider_id}`">
138
+ <span class="discovery-account-chip" :class="{ connected: chip.connected }">
139
+ <span x-text="chip.label"></span>
140
+ <small x-show="chip.detail" x-text="chip.detail"></small>
141
+ </span>
142
+ </template>
143
+ </div>
144
<div class="discovery-usage" x-show="(card.usage_windows || []).length">
145
<template x-for="window in card.usage_windows" :key="`${card.id}-${window.key}`">
146
<div class="discovery-usage-window">
@@ -150,7 +166,7 @@
166
</button>
167
</div>
168
153
- <div class="discovery-hero-thumb">
169
+ <div class="discovery-hero-thumb" x-show="card.thumbnail || card.icon">
170
<template x-if="card.thumbnail">
171
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
172
</template>
@@ -243,7 +259,7 @@
259
}
260
261
.discovery-hero-title {
246
- margin: 0 0 0.4rem;
262
+ margin: 0 0 var(--spacing-sm);
263
color: var(--color-text);
264
font-size: 1.08rem;
265
font-weight: 600;
@@ -266,6 +282,45 @@
282
margin: -0.15rem 0 1rem;
283
}
284
285
+ .discovery-account-chips {
286
+ display: flex;
287
+ flex-wrap: wrap;
288
+ gap: 0.45rem;
289
+ max-width: 560px;
290
+ margin: -0.25rem 0 0.95rem;
291
+ }
292
+
293
+ .discovery-account-chip {
294
+ display: inline-flex;
295
+ min-width: 0;
296
+ max-width: 100%;
297
+ align-items: center;
298
+ gap: 0.4rem;
299
+ padding: 0.28rem 0.5rem;
300
+ border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
301
+ border-radius: 999px;
302
+ background: color-mix(in srgb, var(--color-border) 28%, transparent);
303
+ color: var(--color-text);
304
+ font-size: 0.72rem;
305
+ font-weight: 750;
306
+ line-height: 1.15;
307
+ }
308
+
309
+ .discovery-account-chip.connected {
310
+ border-color: color-mix(in srgb, #35d07f 44%, var(--color-border));
311
+ background: color-mix(in srgb, #35d07f 13%, transparent);
312
+ }
313
+
314
+ .discovery-account-chip small {
315
+ overflow: hidden;
316
+ max-width: 18ch;
317
+ color: var(--color-secondary);
318
+ font-size: 0.68rem;
319
+ font-weight: 700;
320
+ text-overflow: ellipsis;
321
+ white-space: nowrap;
322
+ }
323
+
324
.discovery-usage-window {
325
display: grid;
326
min-width: 0;
plugins/_oauth/AGENTS.md
+3
@@ -13,6 +13,7 @@
13
- `conf/model_providers.yaml` owns OAuth-backed model provider definitions and their `api_key_mode: oauth` metadata.
14
- `api/` owns provider-aware settings modal endpoints such as status, login start, polling, manual callback, models, and disconnect.
15
- `helpers/providers/` owns provider implementations, provider metadata, registry wiring, token storage helpers, and provider-specific endpoint validation.
16
+- `helpers/summary.py` owns the shared provider-status/account summary shape consumed by the status API, discovery cards, onboarding, and OAuth settings UI.
17
- `helpers/routes.py` owns local OAuth callback and OpenAI-compatible proxy routes mounted by the route bootstrap extension.
18
- `webui/config.html` and `webui/oauth-config-store.js` own the OAuth Connections settings UI.
19
- `extensions/python/_functions/models/get_api_key/end/` owns the dummy API-key extension used by OAuth model providers.
@@ -23,6 +24,7 @@
24
- Core model code such as `models.py` must stay provider-agnostic. Do not add Codex, GitHub Copilot, Gemini, xAI, or other OAuth provider knowledge outside plugin-owned config or plugin hooks.
25
- Add OAuth model providers in `_oauth/conf/model_providers.yaml`, not `_model_config/provider_metadata.yaml`.
26
- Provider cards and model slot actions must be driven by backend provider status. Do not reintroduce hardcoded frontend provider lists or fallback provider catalogs.
27
+- OAuth account surfaces in settings, discovery, and onboarding must use the provider registry/status summary rather than Codex-only frontend state.
28
- `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
29
- Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect.
30
- API handlers should remain provider-aware. Missing or blank `provider_id` defaults to Codex only for existing backward compatibility; falsey non-string IDs must not silently default.
@@ -41,6 +43,7 @@
43
- Keep provider policy visible in each provider module; avoid abstracting away endpoint validation or billing/quota caveats.
44
- Prefer plugin-local imports such as `plugins._oauth.helpers...` for bundled plugin code.
45
- Keep `_oauth` account providers separate from API-key providers in core configuration.
46
+- Keep the OAuth settings page account-backed only. API-key and local provider setup belongs in model configuration and onboarding.
47
- Treat Gemini API OAuth as a Google Cloud OAuth-client flow. Do not conflate it with Antigravity, Gemini Code Assist, Gemini CLI, Google AI Pro, or Google AI Ultra subscription quota.
48
- Treat Claude Code subscription auth and Antigravity product auth as non-connectable unless their vendors provide an explicit third-party provider contract.
49
- Keep user-facing errors safe: report setup or tier restrictions without exposing tokens, callback secrets, or raw auth payloads.
plugins/_oauth/README.md
+4
@@ -4,6 +4,8 @@ Generic local OAuth bridge for Agent Zero.
4
5
Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on trusted local machines only. Do not configure `auth_file_path` to share a rotating refresh-token file with Codex CLI or another client.
6
7
+The settings UI groups providers as account-backed connections. More than one account provider can be connected at the same time, and the Main/Utility model slots can choose models from any connected OAuth provider.
8
+
9
## Providers
10
11
### Codex/ChatGPT (`codex_oauth`)
@@ -37,6 +39,8 @@ Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on t
39
40
The status API exposes `usage_plan_catalog` for subscription and billing context. It covers only connectable providers: Codex, GitHub Copilot, Google Gemini API, and xAI Grok.
41
42
+The same status response also includes `oauth_accounts`, a compact summary used by the settings modal, welcome discovery card, and onboarding wizard. Keep that summary provider-registry driven so new OAuth providers appear consistently across those surfaces.
43
+
44
## Remote xAI Callback
45
46
When Agent Zero is running on a remote host, the browser may complete the xAI authorization step somewhere other than the machine serving the local callback route. In that case, paste the callback value into the xAI card.
plugins/_oauth/api/status.py
+6
-23
@@ -2,31 +2,14 @@ from __future__ import annotations
2
3
from helpers.api import ApiHandler, Request
4
from plugins._oauth.helpers.route_bootstrap import is_installed
5
-from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, provider_registry
6
-from plugins._oauth.helpers.usage_plans import usage_plan_catalog
5
+from plugins._oauth.helpers.providers import provider_registry
6
+from plugins._oauth.helpers.summary import build_oauth_status_summary
7
8
9
class Status(ApiHandler):
10
async def process(self, input: dict, request: Request) -> dict:
11
del input, request
12
- providers = [_provider_status(provider) for provider in provider_registry().values()]
13
- provider_map = {provider["provider_id"]: provider for provider in providers}
14
- return {
15
- "ok": True,
16
- "routes_installed": is_installed(),
17
- "providers": providers,
18
- "provider_map": provider_map,
19
- "usage_plan_catalog": usage_plan_catalog(),
20
- "codex": provider_map.get(CODEX_PROVIDER_ID, {}),
21
- }
22
-
23
-
24
-def _provider_status(provider) -> dict:
25
- try:
26
- return provider.status()
27
- except Exception as exc:
28
- return {
29
- "provider_id": str(getattr(provider, "provider_id", "")),
30
- "connected": False,
31
- "error": str(exc),
32
- }
12
+ return build_oauth_status_summary(
13
+ provider_registry=provider_registry,
14
+ routes_installed=is_installed,
15
+ )
plugins/_oauth/helpers/summary.py
new
+128
@@ -0,0 +1,128 @@
1
+from __future__ import annotations
2
+
3
+from collections.abc import Callable, Mapping
4
+from typing import Any
5
+
6
+from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID
7
+from plugins._oauth.helpers.usage_plans import usage_plan_catalog
8
+
9
+
10
+ProviderRegistryFactory = Callable[[], Mapping[str, Any]]
11
+RoutesInstalledFactory = Callable[[], bool]
12
+
13
+
14
+def build_oauth_status_summary(
15
+ *,
16
+ provider_registry: ProviderRegistryFactory,
17
+ routes_installed: RoutesInstalledFactory,
18
+) -> dict[str, Any]:
19
+ """Return the shared OAuth account status payload for APIs and discovery cards."""
20
+
21
+ providers = [_provider_status(provider) for provider in provider_registry().values()]
22
+ provider_map = {
23
+ provider["provider_id"]: provider
24
+ for provider in providers
25
+ if provider.get("provider_id")
26
+ }
27
+ connected = [provider for provider in providers if provider.get("connected")]
28
+ available = [provider for provider in providers if not provider.get("connected")]
29
+ catalog = usage_plan_catalog()
30
+
31
+ return {
32
+ "ok": True,
33
+ "routes_installed": routes_installed(),
34
+ "providers": providers,
35
+ "provider_map": provider_map,
36
+ "usage_plan_catalog": catalog,
37
+ "connected_count": len(connected),
38
+ "available_count": len(available),
39
+ "oauth_accounts": {
40
+ "connected_count": len(connected),
41
+ "available_count": len(available),
42
+ "total_count": len(providers),
43
+ "connected": [_account_summary(provider, catalog) for provider in connected],
44
+ "available": [_account_summary(provider, catalog) for provider in available],
45
+ },
46
+ "codex": provider_map.get(CODEX_PROVIDER_ID, {}),
47
+ }
48
+
49
+
50
+def _provider_status(provider: Any) -> dict[str, Any]:
51
+ try:
52
+ status = provider.status()
53
+ if not isinstance(status, dict):
54
+ status = {}
55
+ provider_id = str(status.get("provider_id") or getattr(provider, "provider_id", ""))
56
+ status = {
57
+ **status,
58
+ "provider_id": provider_id,
59
+ "connected": bool(status.get("connected")),
60
+ }
61
+ usage_windows = _usage_windows(status)
62
+ if usage_windows:
63
+ status["usage_windows"] = usage_windows
64
+ return status
65
+ except Exception as exc:
66
+ return {
67
+ "provider_id": str(getattr(provider, "provider_id", "")),
68
+ "connected": False,
69
+ "error": str(exc),
70
+ }
71
+
72
+
73
+def _account_summary(provider: dict[str, Any], catalog: dict[str, Any]) -> dict[str, Any]:
74
+ provider_id = str(provider.get("provider_id") or "")
75
+ plan_entry = catalog.get(provider_id) if isinstance(catalog, dict) else None
76
+ plans = plan_entry.get("plans", []) if isinstance(plan_entry, dict) else []
77
+ return {
78
+ "provider_id": provider_id,
79
+ "display_name": provider.get("display_name") or provider_id,
80
+ "short_name": provider.get("short_name") or provider.get("display_name") or provider_id,
81
+ "connected": bool(provider.get("connected")),
82
+ "account_label": provider.get("account_label") or provider.get("email") or "",
83
+ "auth_flow": provider.get("auth_flow") or "",
84
+ "icon": provider.get("icon") or "",
85
+ "warning": provider.get("warning") or provider.get("models_warning") or "",
86
+ "usage_windows": list(provider.get("usage_windows") or []),
87
+ "plan_count": len(plans) if isinstance(plans, list) else 0,
88
+ }
89
+
90
+
91
+def _usage_windows(status: dict[str, Any]) -> list[dict[str, Any]]:
92
+ usage = status.get("usage") if isinstance(status, dict) else {}
93
+ if not isinstance(usage, dict) or not usage.get("available"):
94
+ return []
95
+
96
+ windows: list[dict[str, Any]] = []
97
+ for key, title in (("primary", "Session"), ("secondary", "Week")):
98
+ window = usage.get(key)
99
+ if not isinstance(window, dict):
100
+ continue
101
+ remaining = _remaining_percent(window)
102
+ if remaining is None:
103
+ continue
104
+ windows.append({
105
+ "key": key,
106
+ "title": title,
107
+ "label": window.get("label") or "",
108
+ "remaining_percent": remaining,
109
+ "reset_at": window.get("reset_at") or 0,
110
+ })
111
+ return windows
112
+
113
+
114
+def _remaining_percent(window: dict[str, Any]) -> float | None:
115
+ remaining = window.get("remaining_percent")
116
+ if remaining is not None:
117
+ try:
118
+ return max(0, min(100, float(remaining)))
119
+ except (TypeError, ValueError):
120
+ pass
121
+
122
+ used = window.get("used_percent")
123
+ if used is not None:
124
+ try:
125
+ return max(0, min(100, 100 - float(used)))
126
+ except (TypeError, ValueError):
127
+ return None
128
+ return None
plugins/_oauth/webui/config.html
+292
-219
@@ -15,35 +15,128 @@
15
x-effect="$store.oauthConfig.bindConfig(config)"
16
x-destroy="$store.oauthConfig.cleanup()"
17
>
18
- <section class="oauth-provider-grid">
19
- <template x-for="card in $store.oauthConfig.providerCards()" :key="card.provider_id">
20
- <article class="oauth-provider-card" :class="card.connected ? 'is-connected' : ''">
21
- <div class="oauth-provider-head">
22
- <div class="oauth-mark">
18
+ <section class="oauth-provider-list">
19
+ <div class="oauth-section-head">
20
+ <div>
21
+ <h3>Providers</h3>
22
+ <p>Each account can be connected independently.</p>
23
+ </div>
24
+ <button class="oauth-icon-button" type="button" @click="$store.oauthConfig.loadStatus()" title="Refresh status" aria-label="Refresh status">
25
+ <span class="material-symbols-outlined">refresh</span>
26
+ </button>
27
+ </div>
28
+
29
+ <div class="oauth-provider-rows">
30
+ <template x-for="card in $store.oauthConfig.providerCards()" :key="card.provider_id">
31
+ <article
32
+ class="oauth-provider-row"
33
+ :class="{ selected: $store.oauthConfig.selectedProviderId === card.provider_id, connected: card.connected }"
34
+ @click="$store.oauthConfig.selectProvider(card.provider_id)"
35
+ >
36
+ <span class="oauth-mark" :class="{ connected: card.connected }">
37
<span class="material-symbols-outlined" x-text="card.connected ? 'check' : card.mark"></span>
38
+ </span>
39
+ <div class="oauth-provider-row-copy">
40
+ <strong x-text="card.display_name"></strong>
41
+ <span x-text="$store.oauthConfig.providerReadinessLabel(card.provider_id)"></span>
42
+ </div>
43
+ <span class="oauth-account-state" :class="{ connected: card.connected }" x-text="card.connected ? 'Connected' : 'Available'"></span>
44
+ <div class="oauth-row-actions">
45
+ <button
46
+ class="oauth-connect compact"
47
+ type="button"
48
+ @click.stop="$store.oauthConfig.handleProviderPrimary(card.provider_id)"
49
+ :disabled="$store.oauthConfig.providerPrimaryDisabled(card.provider_id)"
50
+ x-show="!card.connected"
51
+ >
52
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'progress_activity' : ($store.oauthConfig.providerSetupReady(card.provider_id) ? 'login' : 'tune')"></span>
53
+ <span x-text="$store.oauthConfig.providerPrimaryLabel(card.provider_id)"></span>
54
+ </button>
55
+ <button
56
+ class="oauth-connect secondary compact"
57
+ type="button"
58
+ @click.stop="$store.oauthConfig.loadModels({ providerId: card.provider_id })"
59
+ :disabled="!card.connected || $store.oauthConfig.loadingModelsProvider === card.provider_id"
60
+ >
61
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === card.provider_id ? 'progress_activity' : 'search'"></span>
62
+ <span>Check models</span>
63
+ </button>
64
+ <button
65
+ class="oauth-connect danger compact"
66
+ type="button"
67
+ @click.stop="$store.oauthConfig.disconnectProvider(card.provider_id)"
68
+ :disabled="$store.oauthConfig.disconnectingProvider === card.provider_id"
69
+ x-show="card.connected"
70
+ >
71
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'progress_activity' : 'link_off'"></span>
72
+ <span x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'Disconnecting' : 'Disconnect'"></span>
73
+ </button>
74
+ </div>
75
+ <div class="oauth-provider-usage" x-show="$store.oauthConfig.usageWindows(card.provider_id).length">
76
+ <template x-for="window in $store.oauthConfig.usageWindows(card.provider_id)" :key="`${card.provider_id}-${window.key}`">
77
+ <div class="oauth-usage-window">
78
+ <div class="oauth-usage-head">
79
+ <span>
80
+ <span x-text="`${card.short_name || card.display_name || 'Account'} ${window.title}`"></span>
81
+ <small x-show="$store.oauthConfig.formatWindowLabel(window)" x-text="$store.oauthConfig.formatWindowLabel(window)"></small>
82
+ </span>
83
+ <strong x-text="$store.oauthConfig.formatRemainingPercent(window)"></strong>
84
+ </div>
85
+ <div class="oauth-usage-bar" aria-hidden="true">
86
+ <i :style="{ width: $store.oauthConfig.usageWidth(window) }"></i>
87
+ </div>
88
+ <p x-show="$store.oauthConfig.formatReset(window)" x-text="`Resets in ${$store.oauthConfig.formatReset(window)}`"></p>
89
+ </div>
90
+ </template>
91
+ </div>
92
+ </article>
93
+ </template>
94
+ </div>
95
+ </section>
96
+
97
+ <template x-if="$store.oauthConfig.selectedProvider() && !$store.oauthConfig.selectedProvider().connected">
98
+ <section class="oauth-provider-detail">
99
+ <div class="oauth-provider-detail-head">
100
+ <div class="oauth-provider-head">
101
+ <div class="oauth-mark" :class="{ connected: $store.oauthConfig.selectedProvider().connected }">
102
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.selectedProvider().connected ? 'check' : $store.oauthConfig.selectedProvider().mark"></span>
103
</div>
104
<div class="oauth-copy">
26
- <h2 x-text="card.display_name"></h2>
27
- <p x-text="$store.oauthConfig.providerStatusLabel(card.provider_id)"></p>
105
+ <h2 x-text="$store.oauthConfig.selectedProvider().display_name"></h2>
106
+ <p x-text="$store.oauthConfig.providerStatusLabel($store.oauthConfig.selectedProviderId)"></p>
107
</div>
108
</div>
109
+ <div class="oauth-primary">
110
+ <button
111
+ class="oauth-connect"
112
+ type="button"
113
+ @click="$store.oauthConfig.connectProvider($store.oauthConfig.selectedProviderId)"
114
+ :disabled="$store.oauthConfig.providerPrimaryDisabled($store.oauthConfig.selectedProviderId) || !$store.oauthConfig.providerSetupReady($store.oauthConfig.selectedProviderId)"
115
+ x-show="!$store.oauthConfig.selectedProvider().connected"
116
+ >
117
+ <span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === $store.oauthConfig.selectedProviderId ? 'progress_activity' : 'login'"></span>
118
+ <span x-text="$store.oauthConfig.providerPrimaryLabel($store.oauthConfig.selectedProviderId)"></span>
119
+ </button>
120
+ </div>
121
+ </div>
122
31
- <label class="oauth-provider-input" x-show="card.supports_enterprise_domain && !card.connected">
123
+ <div class="oauth-detail-grid">
124
+ <label class="oauth-provider-input" x-show="$store.oauthConfig.selectedProvider().supports_enterprise_domain && !$store.oauthConfig.selectedProvider().connected">
125
<span>Enterprise domain</span>
126
<input
127
type="text"
35
- x-model="$store.oauthConfig.providerUiFor(card.provider_id).enterprise_domain"
128
+ x-model="$store.oauthConfig.providerUiFor($store.oauthConfig.selectedProviderId).enterprise_domain"
129
name="enterprise_domain"
130
placeholder="github.com"
131
/>
132
</label>
133
41
- <div class="oauth-provider-fields" x-show="card.supports_oauth_client_config && !card.connected">
134
+ <div class="oauth-provider-fields" x-show="$store.oauthConfig.selectedProvider().supports_oauth_client_config && !$store.oauthConfig.selectedProvider().connected">
135
<label class="oauth-provider-input">
136
<span>OAuth client ID</span>
137
<input
138
type="text"
46
- x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_id"
139
+ x-model="$store.oauthConfig.providerUiFor($store.oauthConfig.selectedProviderId).client_id"
140
name="oauth_client_id"
141
placeholder="Google Cloud OAuth client ID"
142
/>
@@ -52,160 +145,66 @@
145
<span>OAuth client secret</span>
146
<input
147
type="password"
55
- x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_secret"
148
+ x-model="$store.oauthConfig.providerUiFor($store.oauthConfig.selectedProviderId).client_secret"
149
name="oauth_client_secret"
150
autocomplete="off"
151
placeholder="Google Cloud OAuth client secret"
152
/>
153
</label>
61
- <label class="oauth-provider-input" x-show="card.supports_quota_project">
154
+ <label class="oauth-provider-input" x-show="$store.oauthConfig.selectedProvider().supports_quota_project">
155
<span>Quota project</span>
156
<input
157
type="text"
65
- x-model="$store.oauthConfig.providerUiFor(card.provider_id).quota_project_id"
158
+ x-model="$store.oauthConfig.providerUiFor($store.oauthConfig.selectedProviderId).quota_project_id"
159
name="quota_project_id"
160
placeholder="Optional Google Cloud project ID"
161
/>
162
</label>
163
</div>
164
72
- <div class="oauth-device" x-show="$store.oauthConfig.devices[card.provider_id]?.user_code">
165
+ <div class="oauth-device" x-show="$store.oauthConfig.devices[$store.oauthConfig.selectedProviderId]?.user_code">
166
<span>Enter this code</span>
74
- <strong x-text="$store.oauthConfig.devices[card.provider_id]?.user_code"></strong>
75
- <button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
167
+ <strong x-text="$store.oauthConfig.devices[$store.oauthConfig.selectedProviderId]?.user_code"></strong>
168
+ <button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect($store.oauthConfig.selectedProviderId)">
169
<span class="material-symbols-outlined">close</span>
170
<span>Cancel</span>
171
</button>
172
</div>
173
81
- <div class="oauth-manual-callback" x-show="card.supports_manual_callback && $store.oauthConfig.devices[card.provider_id]?.flow === 'browser_pkce' && !card.connected">
174
+ <div class="oauth-manual-callback" x-show="$store.oauthConfig.selectedProvider().supports_manual_callback && $store.oauthConfig.devices[$store.oauthConfig.selectedProviderId]?.flow === 'browser_pkce' && !$store.oauthConfig.selectedProvider().connected">
175
<input
176
type="text"
84
- x-model="$store.oauthConfig.providerUiFor(card.provider_id).manualCallback"
177
+ x-model="$store.oauthConfig.providerUiFor($store.oauthConfig.selectedProviderId).manualCallback"
178
placeholder="Paste callback URL, query string, or code"
179
/>
87
- <button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.submitManualCallback(card.provider_id)">
180
+ <button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.submitManualCallback($store.oauthConfig.selectedProviderId)">
181
<span class="material-symbols-outlined">check</span>
182
<span>Submit</span>
183
</button>
91
- <button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
184
+ <button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.cancelConnect($store.oauthConfig.selectedProviderId)">
185
<span class="material-symbols-outlined">close</span>
186
<span>Cancel</span>
187
</button>
188
</div>
189
97
- <div class="oauth-auth-attempt" x-show="$store.oauthConfig.devices[card.provider_id] && !$store.oauthConfig.devices[card.provider_id]?.user_code && !card.supports_manual_callback">
190
+ <div class="oauth-auth-attempt" x-show="$store.oauthConfig.devices[$store.oauthConfig.selectedProviderId] && !$store.oauthConfig.devices[$store.oauthConfig.selectedProviderId]?.user_code && !$store.oauthConfig.selectedProvider().supports_manual_callback">
191
<span>Sign-in started</span>
99
- <button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
192
+ <button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect($store.oauthConfig.selectedProviderId)">
193
<span class="material-symbols-outlined">close</span>
194
<span>Cancel</span>
195
</button>
196
</div>
197
105
- <p class="oauth-provider-note" x-show="card.warning || card.note" x-text="card.warning || card.note"></p>
106
-
107
- <div class="oauth-primary">
108
- <button
109
- class="oauth-connect"
110
- type="button"
111
- @click="$store.oauthConfig.connectProvider(card.provider_id)"
112
- :disabled="Boolean($store.oauthConfig.connectingProvider) || Boolean($store.oauthConfig.disconnectingProvider)"
113
- x-show="!card.connected"
114
- >
115
- <span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'progress_activity' : 'login'"></span>
116
- <span x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'Waiting' : 'Connect'"></span>
117
- </button>
118
- <button
119
- class="oauth-connect danger"
120
- type="button"
121
- @click="$store.oauthConfig.disconnectProvider(card.provider_id)"
122
- :disabled="$store.oauthConfig.disconnectingProvider === card.provider_id"
123
- x-show="card.connected"
124
- >
125
- <span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'progress_activity' : 'link_off'"></span>
126
- <span x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'Disconnecting' : 'Disconnect'"></span>
127
- </button>
128
- <button
129
- class="oauth-connect secondary"
130
- type="button"
131
- @click="$store.oauthConfig.loadModels({ providerId: card.provider_id })"
132
- :disabled="!card.connected || $store.oauthConfig.loadingModelsProvider === card.provider_id"
133
- >
134
- <span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === card.provider_id ? 'progress_activity' : 'search'"></span>
135
- <span>Check models</span>
136
- </button>
137
- </div>
138
- </article>
139
- </template>
140
- </section>
141
-
142
- <section class="oauth-usage" x-show="$store.oauthConfig.providerConnected('codex_oauth') && $store.oauthConfig.usageWindows('codex_oauth').length">
143
- <template x-for="window in $store.oauthConfig.usageWindows('codex_oauth')" :key="window.key">
144
- <div class="oauth-usage-window">
145
- <div class="oauth-usage-head">
146
- <span>
147
- <span x-text="window.title"></span>
148
- <small x-show="$store.oauthConfig.formatWindowLabel(window)" x-text="$store.oauthConfig.formatWindowLabel(window)"></small>
149
- </span>
150
- <strong x-text="$store.oauthConfig.formatRemainingPercent(window)"></strong>
151
- </div>
152
- <div class="oauth-usage-bar" aria-hidden="true">
153
- <i :style="{ width: $store.oauthConfig.usageWidth(window) }"></i>
154
- </div>
155
- <p x-show="$store.oauthConfig.formatReset(window)" x-text="`Resets in ${$store.oauthConfig.formatReset(window)}`"></p>
156
- </div>
157
- </template>
158
- </section>
159
-
160
- <section class="oauth-status-row">
161
- <div>
162
- <span>OAuth Connections</span>
163
- <strong x-text="$store.oauthConfig.providerCards().filter((card) => card.connected).length + ' connected'"></strong>
164
- </div>
165
- <div>
166
- <span>Model provider</span>
167
- <strong x-text="$store.oauthConfig.providerLabel($store.oauthConfig.activeModelProvider)"></strong>
168
- </div>
169
- <button class="oauth-icon-button" type="button" @click="$store.oauthConfig.loadStatus()" title="Refresh status" aria-label="Refresh status">
170
- <span class="material-symbols-outlined">refresh</span>
171
- </button>
172
- </section>
173
-
174
- <section class="oauth-plan-catalog" x-show="$store.oauthConfig.usagePlanEntries().length">
175
- <div class="oauth-section-head">
176
- <div>
177
- <h3>Usage plans</h3>
178
- <p>Subscription and billing metadata for account-backed model providers.</p>
198
+ <p class="oauth-provider-note" x-show="$store.oauthConfig.selectedProvider().warning || $store.oauthConfig.selectedProvider().note" x-text="$store.oauthConfig.selectedProvider().warning || $store.oauthConfig.selectedProvider().note"></p>
199
</div>
180
- </div>
181
-
182
- <div class="oauth-plan-grid">
183
- <template x-for="entry in $store.oauthConfig.usagePlanEntries()" :key="entry.provider_id">
184
- <article class="oauth-plan-card">
185
- <div class="oauth-plan-head">
186
- <strong x-text="entry.display_name"></strong>
187
- <span x-text="$store.oauthConfig.usagePlanStatus(entry)"></span>
188
- </div>
189
-
190
- <div class="oauth-plan-list">
191
- <template x-for="plan in entry.plans" :key="entry.provider_id + plan.id">
192
- <span x-text="plan.label"></span>
193
- </template>
194
- </div>
195
-
196
- <template x-for="note in $store.oauthConfig.usagePlanNotes(entry)" :key="entry.provider_id + note">
197
- <p x-text="note"></p>
198
- </template>
199
- </article>
200
- </template>
201
- </div>
202
- </section>
200
+ </section>
201
+ </template>
202
203
<section class="oauth-model-config">
204
<div class="oauth-section-head">
205
<div>
207
- <h3>Agent Zero models</h3>
208
- <p>Select OAuth provider models used by the Main model and Utility model slots.</p>
206
+ <h3>Choose your models</h3>
207
+ <p>Select account-backed models used by the Main model and Utility model slots.</p>
208
</div>
209
<span class="oauth-save-chip" x-show="$store.oauthConfig.modelConfigDirty">Pending changes</span>
210
</div>
@@ -225,17 +224,6 @@
224
<span x-text="$store.oauthConfig.slotStatusLabel(slot.key)"></span>
225
</div>
226
<div class="oauth-model-actions">
228
- <template x-for="provider in $store.oauthConfig.providerCards().filter((card) => card.connected)" :key="slot.key + provider.provider_id">
229
- <button
230
- class="oauth-model-action"
231
- type="button"
232
- x-show="!$store.oauthConfig.slotUsesProvider(slot.key, provider.provider_id)"
233
- @click="$store.oauthConfig.useProviderForSlot(slot.key, provider.provider_id)"
234
- >
235
- <span class="material-symbols-outlined">swap_horiz</span>
236
- <span x-text="$store.oauthConfig.providerUseLabel(provider.provider_id)"></span>
237
- </button>
238
- </template>
227
<button
228
class="oauth-model-action icon"
229
type="button"
@@ -373,6 +361,120 @@
361
color: var(--color-text);
362
}
363
364
+ .oauth-provider-list,
365
+ .oauth-provider-detail {
366
+ display: grid;
367
+ gap: 12px;
368
+ padding: 0;
369
+ border: 0;
370
+ border-radius: 0;
371
+ background: transparent;
372
+ }
373
+
374
+ .oauth-provider-row-copy strong {
375
+ overflow: hidden;
376
+ display: block;
377
+ font-size: 0.88rem;
378
+ text-overflow: ellipsis;
379
+ white-space: nowrap;
380
+ }
381
+
382
+ .oauth-provider-rows {
383
+ display: grid;
384
+ gap: 8px;
385
+ }
386
+
387
+ .oauth-provider-row {
388
+ display: grid;
389
+ grid-template-columns: auto minmax(0, 1fr) auto auto;
390
+ align-items: center;
391
+ gap: 10px;
392
+ min-height: 62px;
393
+ padding: 10px 12px;
394
+ border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
395
+ border-radius: 8px;
396
+ background: color-mix(in srgb, var(--color-background) 24%, transparent);
397
+ cursor: pointer;
398
+ transition: border-color .18s ease, background .18s ease;
399
+ }
400
+
401
+ .oauth-provider-row:hover,
402
+ .oauth-provider-row.selected {
403
+ border-color: color-mix(in srgb, var(--color-primary) 62%, var(--color-border));
404
+ background: color-mix(in srgb, var(--color-primary) 7%, var(--color-panel));
405
+ }
406
+
407
+ .oauth-provider-row-copy {
408
+ display: grid;
409
+ min-width: 0;
410
+ gap: 3px;
411
+ }
412
+
413
+ .oauth-provider-row-copy span {
414
+ overflow: hidden;
415
+ color: var(--color-text-secondary);
416
+ font-size: 0.74rem;
417
+ font-weight: 700;
418
+ text-overflow: ellipsis;
419
+ white-space: nowrap;
420
+ }
421
+
422
+ .oauth-account-state {
423
+ display: inline-flex;
424
+ align-items: center;
425
+ justify-content: center;
426
+ min-height: 24px;
427
+ padding: 2px 8px;
428
+ border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
429
+ border-radius: 999px;
430
+ color: var(--color-text-secondary);
431
+ font-size: 0.68rem;
432
+ font-weight: 800;
433
+ white-space: nowrap;
434
+ }
435
+
436
+ .oauth-account-state.connected {
437
+ border-color: color-mix(in srgb, #35d07f 46%, var(--color-border));
438
+ color: var(--color-text);
439
+ }
440
+
441
+ .oauth .oauth-provider-row.connected .oauth-provider-row-copy strong {
442
+ color: var(--color-text, #fff);
443
+ }
444
+
445
+ .oauth .oauth-provider-row.connected .oauth-provider-row-copy span {
446
+ color: var(--color-text-secondary, rgba(255, 255, 255, .72));
447
+ }
448
+
449
+ .oauth-row-actions {
450
+ display: flex;
451
+ flex-wrap: wrap;
452
+ align-items: center;
453
+ justify-content: flex-end;
454
+ gap: 8px;
455
+ }
456
+
457
+ .oauth-provider-usage {
458
+ display: grid;
459
+ grid-column: 2 / -1;
460
+ grid-template-columns: repeat(2, minmax(0, 1fr));
461
+ gap: 10px;
462
+ min-width: 0;
463
+ padding: 4px 0 0;
464
+ }
465
+
466
+ .oauth-provider-detail-head {
467
+ display: flex;
468
+ align-items: center;
469
+ justify-content: space-between;
470
+ gap: 12px;
471
+ }
472
+
473
+ .oauth-detail-grid {
474
+ display: grid;
475
+ gap: 12px;
476
+ }
477
+
478
.oauth-provider-grid {
479
display: grid;
480
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -396,6 +498,11 @@
498
background: #35d07f;
499
}
500
501
+ .oauth-mark.connected {
502
+ color: #08120c;
503
+ background: #35d07f;
504
+ }
505
+
506
.oauth-provider-head {
507
display: grid;
508
grid-template-columns: 48px minmax(0, 1fr);
@@ -474,6 +581,14 @@
581
opacity: .65;
582
}
583
584
+ .oauth-connect.compact {
585
+ min-width: 0;
586
+ min-height: 34px;
587
+ padding: 0 10px;
588
+ font-size: 0.76rem;
589
+ white-space: nowrap;
590
+ }
591
+
592
.oauth-provider-input,
593
.oauth-provider-fields,
594
.oauth-manual-callback,
@@ -552,15 +667,6 @@
667
letter-spacing: 0;
668
}
669
555
- .oauth-usage {
556
- display: grid;
557
- grid-template-columns: repeat(2, minmax(0, 1fr));
558
- gap: 10px;
559
- padding: 12px 14px;
560
- border: 1px solid var(--color-border);
561
- border-radius: 8px;
562
- }
563
-
670
.oauth-usage-window {
671
display: grid;
672
min-width: 0;
@@ -584,6 +690,18 @@
690
font-weight: 750;
691
}
692
693
+ .oauth .oauth-provider-row.connected .oauth-usage-head span {
694
+ color: var(--color-text-secondary, rgba(255, 255, 255, .72));
695
+ }
696
+
697
+ .oauth .oauth-provider-row.connected .oauth-usage-head strong {
698
+ color: var(--color-text, #fff);
699
+ }
700
+
701
+ .oauth .oauth-provider-row.connected .oauth-usage-window p {
702
+ color: var(--color-text-secondary, rgba(255, 255, 255, .72));
703
+ }
704
+
705
.oauth-usage-head small {
706
padding: 2px 6px;
707
border-radius: 999px;
@@ -671,77 +789,6 @@
789
border: 0;
790
}
791
674
- .oauth-plan-catalog {
675
- display: grid;
676
- gap: 12px;
677
- padding: 0;
678
- border: 0;
679
- }
680
-
681
- .oauth-plan-grid {
682
- display: grid;
683
- grid-template-columns: repeat(2, minmax(0, 1fr));
684
- gap: 12px;
685
- }
686
-
687
- .oauth-plan-card {
688
- display: grid;
689
- min-width: 0;
690
- align-content: start;
691
- gap: 10px;
692
- padding: 12px;
693
- border: 1px solid var(--color-border);
694
- border-radius: 8px;
695
- background: color-mix(in srgb, var(--color-panel) 82%, transparent);
696
- }
697
-
698
- .oauth-plan-head {
699
- display: flex;
700
- min-width: 0;
701
- align-items: center;
702
- justify-content: space-between;
703
- gap: 10px;
704
- }
705
-
706
- .oauth-plan-head strong {
707
- overflow: hidden;
708
- font-size: 0.88rem;
709
- text-overflow: ellipsis;
710
- white-space: nowrap;
711
- }
712
-
713
- .oauth-plan-head span {
714
- flex: 0 0 auto;
715
- padding: 2px 7px;
716
- border-radius: 999px;
717
- background: color-mix(in srgb, var(--color-border) 48%, transparent);
718
- color: var(--color-text-secondary);
719
- font-size: 0.68rem;
720
- font-weight: 750;
721
- }
722
-
723
- .oauth-plan-list {
724
- display: flex;
725
- flex-wrap: wrap;
726
- gap: 6px;
727
- }
728
-
729
- .oauth-plan-list span {
730
- padding: 4px 7px;
731
- border-radius: 999px;
732
- background: color-mix(in srgb, var(--color-border) 34%, transparent);
733
- color: var(--color-text-secondary);
734
- font-size: 0.72rem;
735
- line-height: 1.2;
736
- }
737
-
738
- .oauth-plan-card p {
739
- margin: 0;
740
- color: var(--color-text-secondary);
741
- font-size: 0.74rem;
742
- line-height: 1.35;
743
- }
744
-
792
.oauth-models-panel {
793
display: grid;
794
gap: 10px;
@@ -951,13 +998,13 @@
998
}
999
1000
.oauth-advanced {
954
- border: 1px solid var(--color-border);
955
- border-radius: 8px;
1001
+ border: 0;
1002
+ border-radius: 0;
1003
padding: 0;
1004
}
1005
1006
.oauth-advanced summary {
960
- padding: 12px 14px;
1007
+ padding: 8px 0;
1008
color: var(--color-text-secondary);
1009
font-size: 0.84rem;
1010
font-weight: 750;
@@ -1045,22 +1092,40 @@
1092
.oauth-provider-grid {
1093
grid-template-columns: 1fr;
1094
}
1095
+
1096
+ .oauth-model-grid {
1097
+ grid-template-columns: 1fr;
1098
+ }
1099
+
1100
+ .oauth-provider-row {
1101
+ grid-template-columns: auto minmax(0, 1fr) auto;
1102
+ }
1103
+
1104
+ .oauth-row-actions {
1105
+ grid-column: 2 / -1;
1106
+ justify-content: flex-start;
1107
+ }
1108
}
1109
1110
@media (max-width: 720px) {
1111
.oauth-device,
1052
- .oauth-usage,
1112
+ .oauth-provider-usage,
1113
.oauth-status-row,
1054
- .oauth-plan-grid,
1114
.oauth-model-grid,
1115
.oauth-model-head,
1116
.oauth-grid,
1117
.oauth-details div,
1118
.oauth-manual-callback,
1060
- .oauth-auth-attempt {
1119
+ .oauth-auth-attempt,
1120
+ .oauth-provider-row,
1121
+ .oauth-provider-detail-head {
1122
grid-template-columns: 1fr;
1123
}
1124
1125
+ .oauth-provider-detail-head {
1126
+ display: grid;
1127
+ }
1128
+
1129
.oauth-section-head {
1130
flex-direction: column;
1131
}
@@ -1069,6 +1134,14 @@
1134
width: 100%;
1135
}
1136
1137
+ .oauth-row-actions {
1138
+ grid-column: auto;
1139
+ }
1140
+
1141
+ .oauth-provider-usage {
1142
+ grid-column: auto;
1143
+ }
1144
+
1145
.oauth-model-actions {
1146
justify-content: flex-start;
1147
}
plugins/_oauth/webui/oauth-config-store.js
+90
@@ -111,6 +111,7 @@ export const store = createStore("oauthConfig", {
111
connectingProvider: "",
112
disconnectingProvider: "",
113
loadingModelsProvider: "",
114
+ selectedProviderId: "",
115
activeModelProvider: CODEX_PROVIDER,
116
models: [],
117
modelSlots: MODEL_SLOTS,
@@ -153,6 +154,7 @@ export const store = createStore("oauthConfig", {
154
this.connectingProvider = "";
155
this.disconnectingProvider = "";
156
this.loadingModelsProvider = "";
157
+ this.selectedProviderId = "";
158
this.activeModelProvider = CODEX_PROVIDER;
159
this.models = [];
160
this.modelConfig = null;
@@ -241,6 +243,32 @@ export const store = createStore("oauthConfig", {
243
});
244
},
245
246
+ connectedProviderCards() {
247
+ return this.providerCards().filter((card) => card.connected);
248
+ },
249
+
250
+ availableProviderCards() {
251
+ return this.providerCards().filter((card) => !card.connected);
252
+ },
253
+
254
+ selectedProvider() {
255
+ const selected = this.providerCards().find((card) => card.provider_id === this.selectedProviderId);
256
+ return selected || this.providerCards()[0] || null;
257
+ },
258
+
259
+ selectProvider(providerId) {
260
+ if (!this.isOauthProvider(providerId)) return;
261
+ this.selectedProviderId = providerId;
262
+ },
263
+
264
+ connectedSummaryLabel() {
265
+ const connected = this.connectedProviderCards().length;
266
+ const total = this.providerCards().length;
267
+ if (!connected) return "Connect account-backed providers here. More than one can be connected at the same time.";
268
+ if (connected === 1) return `1 of ${total} account providers connected.`;
269
+ return `${connected} of ${total} account providers connected.`;
270
+ },
271
+
272
providerIds() {
273
return this.providerCards().map((card) => card.provider_id);
274
},
@@ -271,6 +299,45 @@ export const store = createStore("oauthConfig", {
299
return status.account_label || status.email || "Connected";
300
},
301
302
+ providerReadinessLabel(providerId) {
303
+ const status = this.providerStatus(providerId);
304
+ if (this.loadingStatus) return "Checking";
305
+ if (status.connected) return this.providerStatusLabel(providerId);
306
+ if (!this.providerSetupReady(providerId)) return "Needs OAuth client details";
307
+ if (status.warning) return "Available with restrictions";
308
+ return "Ready to connect";
309
+ },
310
+
311
+ providerSetupReady(providerId) {
312
+ const status = this.providerStatus(providerId);
313
+ if (!status.supports_oauth_client_config) return true;
314
+ const ui = this.providerUiFor(providerId);
315
+ const clientId = ui.client_id || status.client_id || this.geminiApi().client_id || "";
316
+ const hasSecret = Boolean(
317
+ ui.client_secret
318
+ || this.geminiApi().client_secret
319
+ || status.client_secret_configured
320
+ );
321
+ return Boolean(String(clientId).trim() && hasSecret);
322
+ },
323
+
324
+ providerPrimaryLabel(providerId) {
325
+ if (this.connectingProvider === providerId) return "Waiting";
326
+ return this.providerSetupReady(providerId) ? "Connect" : "Configure";
327
+ },
328
+
329
+ providerPrimaryDisabled(providerId) {
330
+ if (!this.isOauthProvider(providerId)) return true;
331
+ if (this.connectingProvider || this.disconnectingProvider) return true;
332
+ return false;
333
+ },
334
+
335
+ handleProviderPrimary(providerId) {
336
+ this.selectProvider(providerId);
337
+ if (!this.providerSetupReady(providerId)) return;
338
+ void this.connectProvider(providerId);
339
+ },
340
+
341
providerEndpointUrl(providerId) {
342
const status = this.providerStatus(providerId);
343
const proxyBase = String(status.proxy_base_path || "").replace(/\/$/, "");
@@ -289,6 +356,10 @@ export const store = createStore("oauthConfig", {
356
},
357
358
usageWindows(providerId = CODEX_PROVIDER) {
359
+ const status = this.providerStatus(providerId);
360
+ if (Array.isArray(status.usage_windows) && status.usage_windows.length) {
361
+ return status.usage_windows.filter((window) => Number.isFinite(this.remainingPercent(window)));
362
+ }
363
const usage = this.usage(providerId);
364
if (!usage?.available) return [];
365
return [
@@ -297,6 +368,20 @@ export const store = createStore("oauthConfig", {
368
].filter((window) => Number.isFinite(this.remainingPercent(window)));
369
},
370
371
+ connectedUsageWindows() {
372
+ const windows = [];
373
+ for (const card of this.connectedProviderCards()) {
374
+ for (const window of this.usageWindows(card.provider_id)) {
375
+ windows.push({
376
+ ...window,
377
+ key: `${card.provider_id}-${window.key}`,
378
+ title: `${card.short_name || card.display_name || "Account"} ${window.title}`,
379
+ });
380
+ }
381
+ }
382
+ return windows;
383
+ },
384
+
385
usagePlanCatalog() {
386
const catalog = this.status?.usage_plan_catalog;
387
return catalog && typeof catalog === "object" ? catalog : {};
@@ -556,6 +641,11 @@ export const store = createStore("oauthConfig", {
641
if (!this.isOauthProvider(this.activeModelProvider)) {
642
this.activeModelProvider = this.providerCards()[0]?.provider_id || CODEX_PROVIDER;
643
}
644
+ if (!this.isOauthProvider(this.selectedProviderId)) {
645
+ this.selectedProviderId = this.connectedProviderCards()[0]?.provider_id
646
+ || this.providerCards()[0]?.provider_id
647
+ || "";
648
+ }
649
} catch (error) {
650
void toastFrontendError(messageOf(error), "OAuth Connections");
651
} finally {
tests/test_oauth_providers.py
+33
@@ -75,6 +75,7 @@ from plugins._oauth.helpers.providers.base import (
75
write_private_json,
76
)
77
from plugins._oauth.helpers.providers.registry import get_provider, provider_registry
78
+from plugins._oauth.helpers.summary import build_oauth_status_summary
79
from plugins._oauth.helpers.usage_plans import usage_plan_catalog
80
81
@@ -347,6 +348,38 @@ def test_status_api_returns_provider_registry_shape(monkeypatch):
348
assert response["codex"] == response["provider_map"][CODEX_PROVIDER_ID]
349
350
351
+def test_oauth_status_summary_adds_accounts_and_usage_windows():
352
+ class FakeProvider:
353
+ provider_id = CODEX_PROVIDER_ID
354
+
355
+ def status(self):
356
+ return {
357
+ "provider_id": CODEX_PROVIDER_ID,
358
+ "display_name": "Codex/ChatGPT",
359
+ "short_name": "Codex",
360
+ "connected": True,
361
+ "account_label": "user@example.com",
362
+ "usage": {
363
+ "available": True,
364
+ "primary": {"remaining_percent": 91, "label": "5h", "reset_at": 123},
365
+ "secondary": {"used_percent": 14, "label": "7d", "reset_at": 456},
366
+ },
367
+ }
368
+
369
+ summary = build_oauth_status_summary(
370
+ provider_registry=lambda: {CODEX_PROVIDER_ID: FakeProvider()},
371
+ routes_installed=lambda: True,
372
+ )
373
+
374
+ assert summary["routes_installed"] is True
375
+ assert summary["connected_count"] == 1
376
+ assert summary["oauth_accounts"]["connected"][0]["account_label"] == "user@example.com"
377
+ assert summary["provider_map"][CODEX_PROVIDER_ID]["usage_windows"] == [
378
+ {"key": "primary", "title": "Session", "label": "5h", "remaining_percent": 91.0, "reset_at": 123},
379
+ {"key": "secondary", "title": "Week", "label": "7d", "remaining_percent": 86.0, "reset_at": 456},
380
+ ]
381
+
382
+
383
def test_status_api_contains_provider_status_exceptions(monkeypatch):
384
class GoodProvider:
385
provider_id = CODEX_PROVIDER_ID
tests/test_oauth_static.py
+42
-9
@@ -12,14 +12,25 @@ def test_oauth_settings_exposes_provider_cards_and_model_slots():
12
assert "OAuth Connections" in config_html + store_js
13
assert "OAUTH_PROVIDERS" not in store_js
14
assert "PROVIDER_FALLBACKS" not in store_js
15
- assert "Agent Zero models" in config_html
15
+ assert "Connected accounts" not in config_html
16
+ assert "Choose your models" in config_html
17
assert "Main model" in store_js
18
assert "Utility model" in store_js
19
assert "provider_map" in store_js
19
- assert "Usage plans" in config_html
20
+ assert "<h3>Accounts</h3>" not in config_html
21
+ assert "oauth-plan-catalog" not in config_html
22
+ assert "oauth-model-provider-choice" not in config_html
23
+ assert "<span>Account</span>" not in config_html
24
+ assert "oauth-connected-panel" not in config_html
25
+ assert "oauth-provider-usage" in config_html
26
+ assert "usageWindows(card.provider_id)" in config_html
27
+ assert "connectedUsageWindows()" not in config_html
28
+ assert "Agent Zero models" not in config_html
29
+ assert "Usage plans" not in config_html
30
assert "usagePlanEntries" in store_js
21
- assert "usage_plan_catalog" in (PROJECT_ROOT / "plugins/_oauth/api/status.py").read_text(encoding="utf-8")
31
+ assert "usage_plan_catalog" in (PROJECT_ROOT / "plugins/_oauth/helpers/summary.py").read_text(encoding="utf-8")
32
assert "copyMainToUtility" in config_html + store_js
33
+ assert "connectedSummaryLabel()" not in config_html
34
35
36
def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy():
@@ -37,19 +48,28 @@ def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy():
48
assert "supports_quota_project" in config_html + store_js
49
assert "OAuth client ID" in config_html
50
assert "quota_project_id" in config_html + store_js
40
- assert "submitManualCallback(card.provider_id)" in config_html
41
- assert "cancelConnect(card.provider_id)" in config_html
51
+ assert "submitManualCallback($store.oauthConfig.selectedProviderId)" in config_html
52
+ assert "cancelConnect($store.oauthConfig.selectedProviderId)" in config_html
53
assert "oauth-auth-attempt" in config_html
54
+ assert "oauth-detail-metrics" not in config_html
55
assert "Codex/ChatGPT Account" not in config_html + store_js
56
assert "Available models from selected provider" in config_html
57
assert "Available models from Codex account" not in config_html
58
+ assert "disconnectProvider(card.provider_id)" in config_html
59
+ assert "disconnectProvider($store.oauthConfig.selectedProviderId)" not in config_html
60
+ assert "color: #35d07f;" not in config_html
61
+ assert ".oauth .oauth-provider-row.connected .oauth-provider-row-copy span" in config_html
62
+ assert ".oauth .oauth-provider-row.connected .oauth-usage-head strong" in config_html
63
+ assert ".oauth .oauth-provider-row.connected .oauth-usage-window p" in config_html
64
+ assert "var(--color-text-secondary, rgba(255, 255, 255, .72))" in config_html
65
66
67
def test_oauth_connect_buttons_disable_during_any_provider_connection():
68
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
69
store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
70
52
- assert ':disabled="Boolean($store.oauthConfig.connectingProvider) || Boolean($store.oauthConfig.disconnectingProvider)"' in config_html
71
+ assert "providerPrimaryDisabled" in config_html + store_js
72
+ assert "this.connectingProvider || this.disconnectingProvider" in store_js
73
assert ':disabled="Boolean($store.oauthConfig.connectingProvider) || $store.oauthConfig.disconnectingProvider"' not in config_html
74
assert "cancelConnect(providerId = \"\")" in store_js
75
assert "if (this.connectingProvider === providerId) this.connectingProvider = \"\";" in store_js
@@ -59,8 +79,12 @@ def test_oauth_available_models_list_sits_above_advanced_without_borders():
79
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
80
81
assert "Available models from selected provider" in config_html
82
+ assert config_html.index("<h3>Providers</h3>") < config_html.index("Choose your models")
83
+ assert config_html.index("<h3>Providers</h3>") < config_html.index("<summary>Advanced</summary>")
84
assert config_html.index("Available models") < config_html.index("<summary>Advanced</summary>")
85
assert ".oauth-models-panel {\n display: grid;\n gap: 10px;\n padding: 0;\n border: 0;\n }" in config_html
86
+ assert ".oauth-provider-list,\n .oauth-provider-detail {\n display: grid;\n gap: 12px;\n padding: 0;\n border: 0;" in config_html
87
+ assert ".oauth-advanced {\n border: 0;\n border-radius: 0;\n padding: 0;\n }" in config_html
88
model_chip_rule = config_html.split(".oauth-models span {", 1)[1].split("}", 1)[0]
89
assert "border:" not in model_chip_rule
90
@@ -85,12 +109,13 @@ def test_browser_callback_completion_is_observed_from_modal():
109
assert "this.providerConnected(providerId)" in store_js
110
111
88
-def test_usage_plan_catalog_is_visible_without_provider_cards():
112
+def test_usage_plan_catalog_stays_backend_only_on_oauth_settings_page():
113
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
114
store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
115
plans_py = (PROJECT_ROOT / "plugins/_oauth/helpers/usage_plans.py").read_text(encoding="utf-8")
116
93
- assert "oauth-plan-catalog" in config_html
117
+ assert "oauth-plan-catalog" not in config_html
118
+ assert "usagePlanEntries" in store_js
119
assert "Google Gemini API" in plans_py
120
assert "GEMINI_API_PROVIDER_ID" in plans_py
121
assert "Google Gemini / Antigravity" not in plans_py
@@ -120,9 +145,17 @@ def test_connected_codex_welcome_card_renders_usage_limit_bars():
145
).read_text(encoding="utf-8")
146
discovery_store = (PROJECT_ROOT / "plugins/_discovery/webui/discovery-store.js").read_text(encoding="utf-8")
147
123
- assert "ChatGPT/Codex Connected" in discovery_cards
148
+ assert "discovery-oauth-accounts" in discovery_cards
149
+ assert "Your AI accounts" in discovery_cards
150
+ assert "Connected OAuth accounts" not in discovery_cards
151
+ assert "discovery-codex-oauth" not in discovery_cards
152
assert "5h and weekly limits are ready." not in discovery_cards
153
+ assert "account providers connected and ready for model selection" not in discovery_cards
154
+ assert '"icon": "account_circle"' not in discovery_cards
155
assert "usage_windows" in discovery_cards
156
+ assert "account_chips" in discovery_cards
157
+ assert "discovery-account-chip" in welcome_cards
158
+ assert 'x-show="card.thumbnail || card.icon"' in welcome_cards
159
assert "discovery-usage" in welcome_cards
160
assert "discovery-usage-bar" in welcome_cards
161
assert "formatRemainingPercent(window)" in welcome_cards + discovery_store