Show success feedback when copying OAuth device codes

Reuse the shared button-feedback helper to briefly show a green checkmark after copying a sign-in code, while preserving existing notifications and automatic reset.

Cover successful copies, clipboard failures, and missing codes in the reconnect regression test; update plugin documentation.

Validation: 218 OAuth tests and 3 plugin scan tests passed, along with the Node reconnect test and a live UI check of clipboard contents, success styling, and icon reset.
This commit is contained in:
Alessandro 2026-10-07 23:57:22 +02:00
parent 935fb5cdcc
commit 186acbc58c
5 changed files with 20 additions and 8 deletions

View file

@ -27,7 +27,7 @@
- Provider cards and model slot actions must be driven by backend provider status. Do not reintroduce hardcoded frontend provider lists or fallback provider catalogs.
- OAuth account surfaces in settings, discovery, and onboarding must use the provider registry/status summary rather than Codex-only frontend state.
- OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers.
- Device challenge codes use a selectable standalone box and an accessible copy button, reusing the shared clipboard helper and OAuth notifications for feedback.
- Device challenge codes use a selectable standalone box and an accessible copy button, reusing the shared clipboard and button-feedback helpers: successful copies briefly show a green checkmark and retain OAuth notifications.
- OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down.
- OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`.
- When exactly one OAuth provider is connected, use it as an unsaved default only for empty slots or slots already using that provider. A different saved provider must keep the explicit `Choose connected provider` prompt until the user opts into the switch.

View file

@ -15,7 +15,7 @@ OAuth-backed model providers do not require users to enter API keys. Agent Zero
### Codex/ChatGPT (`codex_oauth`)
- Uses the existing Codex device-code flow.
- Shows the sign-in code in a selectable box with a copy button.
- Shows the sign-in code in a selectable box with a copy button that briefly turns into a green checkmark after copying.
- Writes Codex-compatible credentials to an Agent Zero-owned `auth.json` file.
- Refreshes local tokens when needed.
- If a refresh token expires or is revoked, shows an **Open OAuth settings** toast. Choose **Reconnect** on the Codex row and complete sign-in, then retry your request. No manual disconnect is needed; cancelling sign-in preserves the saved credentials and model choices.

View file

@ -166,7 +166,7 @@
<span>Enter this code</span>
<div class="oauth-device-code">
<code x-text="$store.oauthConfig.providerDevice(card.provider_id)?.user_code"></code>
<button class="oauth-icon-button" type="button" title="Copy sign-in code" aria-label="Copy sign-in code" @click="$store.oauthConfig.copyDeviceCode(card.provider_id)">
<button class="oauth-icon-button" type="button" title="Copy sign-in code" aria-label="Copy sign-in code" @click="$store.oauthConfig.copyDeviceCode(card.provider_id, $el)">
<x-icon name="content_copy"></x-icon>
</button>
</div>
@ -855,6 +855,11 @@
cursor: pointer;
}
.oauth-icon-button.success {
color: #4caf50;
border-color: currentColor;
}
.oauth-model-config {
display: grid;
gap: 12px;

View file

@ -1,6 +1,6 @@
import { createStore } from "/js/AlpineStore.js";
import { callJsonApi, fetchApi } from "/js/api.js";
import { copyToClipboard } from "/components/messages/action-buttons/simple-action-buttons.js";
import { copyToClipboard, showButtonFeedback } from "/components/messages/action-buttons/simple-action-buttons.js";
import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
import {
store as notificationStore,
@ -311,11 +311,12 @@ export const store = createStore("oauthConfig", {
return this.devices[String(providerId || "")] || null;
},
async copyDeviceCode(providerId) {
async copyDeviceCode(providerId, button = null) {
const code = this.providerDevice(providerId)?.user_code;
if (!code) return;
try {
await copyToClipboard(code);
if (button) showButtonFeedback(button, true, "content_copy");
void toastFrontendSuccess("Sign-in code copied.", "OAuth Connections");
} catch {
void toastFrontendError("Could not copy the code. Select it and copy manually.", "OAuth Connections");

View file

@ -6,6 +6,8 @@ const requests = [];
const timers = new Map();
const dismissed = [];
const copied = [];
const buttonFeedback = [];
const copyButton = {};
const feedback = [];
let clipboardDenied = false;
const source = readFileSync(new URL('../plugins/_oauth/webui/oauth-config-store.js', import.meta.url), 'utf8')
@ -29,6 +31,7 @@ const store = vm.runInNewContext(`${source}\nstore`, {
if (clipboardDenied) throw new Error('Permission denied');
copied.push(code);
},
showButtonFeedback: (...args) => buttonFeedback.push(args),
notificationStore: { dismissToast: id => dismissed.push(id) },
});
const provider = 'codex_oauth';
@ -41,20 +44,23 @@ await store.connectProvider(provider);
assert.equal(store.providerPrimaryLabel(provider), 'Waiting');
assert.equal(store.providerDetailOpen(provider), true);
assert.equal(store.providerDevice(provider).user_code, 'TEST');
await store.copyDeviceCode(provider);
await store.copyDeviceCode(provider, copyButton);
assert.deepEqual(copied, ['TEST']);
assert.deepEqual(buttonFeedback, [[copyButton, true, 'content_copy']]);
assert.equal(feedback.pop(), 'Sign-in code copied.');
clipboardDenied = true;
await store.copyDeviceCode(provider);
await store.copyDeviceCode(provider, copyButton);
assert.equal(feedback.pop(), 'Could not copy the code. Select it and copy manually.');
assert.equal(buttonFeedback.length, 1);
assert.equal(timers.size, 1);
await store.connectProvider(provider);
assert.equal(requests.length, 1);
store.cancelConnect(provider);
assert.equal(timers.size, 0);
assert.equal(store.providerDevice(provider), null);
await store.copyDeviceCode(provider);
await store.copyDeviceCode(provider, copyButton);
assert.equal(copied.length, 1);
assert.equal(buttonFeedback.length, 1);
assert.equal(feedback.length, 0);
assert.equal(store.providerConnected(provider), true);
assert.equal(store.modelConfig.chat_model.name, 'chosen-model');