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