Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.
}
- {status.connected &&
+ {view === "connection" && interaction &&
{interaction.progress &&
{interaction.progress}
}
{interaction.userCode &&
Device code: {interaction.userCode}
}
{interaction.browser &&
}
@@ -97,15 +107,14 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
}
>}
{(error || status.error) &&
{error || status.error}
}
-
- {!confirm && status.connectionId && !status.pending && <>
-
+ setConfirm(true)}>Disconnect}>
+ {!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <>
{status.error && }
-
+
>}
-
- {confirm ? : !status.connectionId && }
-
+
+ {confirm ?
: view === "notice" ?
: view === "connection" &&
}
+
}
;
}
@@ -114,9 +123,7 @@ export function NativeSubscriptionConnection({ providerId }: { providerId: Nativ
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
return
-
s.pending)} onAction={() => ui.open(providerId)}>
- {status.connected && Connected}
-
+
s.pending)} onAction={() => ui.open(providerId)} />
{status.error && {status.error}
}
;
}
diff --git a/tests/chatgpt-connection-ui.test.tsx b/tests/chatgpt-connection-ui.test.tsx
index e26f335..d685900 100644
--- a/tests/chatgpt-connection-ui.test.tsx
+++ b/tests/chatgpt-connection-ui.test.tsx
@@ -88,6 +88,9 @@ test("disconnected row uses shared Connect action; initial default is ON; cancel
assert(!document.body.textContent?.includes(CHATGPT_NOTICE)); const trigger = byTest("chatgpt-login");
assert.match(trigger.className, /cursor-pointer/); assert.match(trigger.className, /hover:bg-accent/); assert.match(trigger.className, /focus-visible:ring/);
await click(trigger); assert.equal(f.calls.login.length, 0); assert(document.body.textContent?.includes(CHATGPT_NOTICE));
+ assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
+ assert.equal(byTest("chatgpt-proceed").textContent, "Continue with ChatGPT"); assert(byTest("chatgpt-proceed").querySelector('img[src="/siwc/chatgpt-logo-white.svg"]'));
+ assert(!button("Continue", byTest("chatgpt-account-dialog")), "ChatGPT remains one step, with disclosure and toggle together");
assert.equal(toggle().getAttribute("aria-checked"), "true");
assert.equal(toggle().getAttribute("aria-label"), "Allow background use for all ChatGPT accounts");
assert(byTest("chatgpt-global-background").textContent?.includes("Background work counts toward your ChatGPT plan usage."));
@@ -180,23 +183,33 @@ test("email-only connected selector and Manage remain responsive; account select
assert.deepEqual([...document.querySelectorAll('select[aria-label="ChatGPT account"] option')].map((o) => o.textContent), ["learner@example.invalid", "second@example.invalid", "ChatGPT account"]);
assert.doesNotMatch(byTest("chatgpt-connection").textContent!, /private-registration|private-issued| · active|signed-out/);
assert.match(byTest("chatgpt-connection").firstElementChild!.className, /items-center/);
+ const row = byTest("chatgpt-connection").firstElementChild!;
+ assert.equal(row.children[0]!.textContent, "ChatGPT"); assert.equal(row.children[1]!.tagName, "SELECT"); assert.equal(row.children[2]!.textContent, "Manage…");
+ assert.match(row.children[2]!.className, /text-muted-foreground/); assert(!row.textContent?.includes("Connected"));
await selectAccount("ChatGPT account", "second"); assert.deepEqual(f.calls.select, ["second"]); assert.equal(f.status.allowBackgroundUse, false);
await click(button("Manage…")); assert(byTest("chatgpt-managed-identity").textContent?.includes("second@example.invalid"));
- assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]);
+ assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]);
const dialog = byTest("chatgpt-account-dialog").innerHTML;
assert(dialog.indexOf('data-testid="chatgpt-managed-identity"') < dialog.indexOf('data-testid="chatgpt-account-actions"'));
- assert(dialog.indexOf('data-testid="chatgpt-account-actions"') < dialog.indexOf('data-testid="chatgpt-global-background"'));
+ assert(dialog.indexOf('data-testid="chatgpt-global-background"') < dialog.indexOf('data-testid="chatgpt-managed-identity"'));
+ assert(!byTest("chatgpt-account-dialog").querySelector('[data-testid="chatgpt-privacy-notice"]'));
+ assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
+ assert(byTest("chatgpt-account-dialog").querySelector('a[href="https://openai.com/policies/privacy-policy/"]'));
+ assert(byTest("chatgpt-account-dialog").querySelector('a[href="https://chatgpt.com/settings/usage"]'));
await click(toggle()); assert.deepEqual(f.calls.consent, [true]); assert.equal(toggle().getAttribute("aria-checked"), "true");
await click(button("Close")); await selectAccount("ChatGPT account", "private-registration-id"); assert.equal(f.status.allowBackgroundUse, true);
assert.equal(f.calls.logout, 0);
});
-test("Disconnect account stays under identity, outside the nondestructive Manage footer; confirmation Cancel never forgets", async () => {
+test("Disconnect is the quiet red left-footer action below identity; confirmation Cancel never forgets", async () => {
assert.deepEqual(CHATGPT_DISCONNECT_COPY, { title: "Disconnect account?", body: "This removes the saved account and its credentials from this device." });
const f = fixture(connected()); await mount(); await click(button("Manage…"));
- assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect account"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
- assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-disconnect")));
+ assert.equal(byTest("chatgpt-account-actions").textContent, "Disconnect"); assert.match(byTest("chatgpt-disconnect").className, /text-red/);
+ assert.equal(byTest("chatgpt-account-footer").firstElementChild, byTest("chatgpt-account-actions"));
+ assert.equal(byTest("chatgpt-account-footer").lastElementChild?.lastElementChild, button("Close"));
+ assert(!byTest("chatgpt-account-footer").contains(byTest("chatgpt-add-accounts")));
await click(byTest("chatgpt-disconnect")); assert.equal(f.calls.disconnect.length, 0);
+ assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.title));
assert(byTest("chatgpt-disconnect-confirmation").textContent?.includes(CHATGPT_DISCONNECT_COPY.body));
assert(!document.body.textContent?.includes("delete your OpenAI"));
@@ -208,8 +221,9 @@ for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with $
const initial = connected(count === 3);
initial.accounts = [account(), ...Array.from({ length: count - 1 }, (_, i) => account(`saved-${i}`, `saved-${i}@example.invalid`))];
const f = fixture(initial); const before = structuredClone(initial); await mount(); await click(button("Manage…"));
- assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Add accounts", "Close"]);
- assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "outline");
+ assert.deepEqual([...byTest("chatgpt-account-footer").querySelectorAll("button")].map((b) => b.textContent), ["Disconnect", "Close"]);
+ assert.equal(byTest("chatgpt-add-accounts").getAttribute("data-variant"), "ghost");
+ assert.equal(byTest("chatgpt-add-accounts").parentElement, byTest("chatgpt-account-strip"));
assert.match(byTest("chatgpt-add-accounts").className, /cursor-pointer/);
await click(byTest("chatgpt-add-accounts"));
assert(byTest("chatgpt-privacy-notice").textContent?.includes(CHATGPT_NOTICE)); assert(byTest("chatgpt-proceed"));
@@ -226,6 +240,19 @@ for (const count of [1, 3]) test(`Add accounts reuses the actual add flow with $
assert.deepEqual(f.status, before); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.consent, []); assert.deepEqual(f.calls.disconnect, []);
});
+test("ChatGPT Sign in again is reserved for a saved disconnected account and keeps its existing registration flow", async () => {
+ const f = fixture(connected(false)); await mount(); await click(button("Manage…"));
+ assert(!button("Sign in again")); await selectAccount("Manage ChatGPT account", "signed-out-record"); assert(button("Sign in again"));
+ await click(button("Sign in again")); assert.equal(byTest("chatgpt-account-dialog").querySelector("h2")?.textContent, "ChatGPT");
+ assert(byTest("chatgpt-privacy-notice")); assert(toggle()); assert.equal(toggle().getAttribute("aria-checked"), "false");
+ assert.equal(document.querySelector
('select[aria-label="Saved ChatGPT account"]')?.value, "signed-out-record"); assert.deepEqual(f.calls.login, []);
+ await click(byTest("chatgpt-proceed")); assert.deepEqual(f.calls.login, ["signed-out-record"]); assert.equal(f.status.activeId, "private-registration-id");
+ await click(button("Cancel", byTest("chatgpt-account-dialog"))); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.disconnect, []); assert.deepEqual(f.calls.consent, []);
+});
+test("connected setup row retains the email dropdown before muted Manage", async () => {
+ fixture(connected()); await mount();
+ const row = byTest("chatgpt-connection").firstElementChild!; assert.equal(row.children[1]!.tagName, "SELECT"); assert.equal(row.children[2]!.textContent, "Manage…"); assert.match(row.children[2]!.className, /text-muted-foreground/);
+});
test("Add accounts reuses the existing dialog's initial focus for the pre-auth transition", async () => {
const previous = HTMLElement.prototype.focus; const focused: HTMLElement[] = [];
HTMLElement.prototype.focus = function () { focused.push(this); };
diff --git a/tests/subscription-connection-ui.test.tsx b/tests/subscription-connection-ui.test.tsx
index 839a63a..880f0e9 100644
--- a/tests/subscription-connection-ui.test.tsx
+++ b/tests/subscription-connection-ui.test.tsx
@@ -15,7 +15,7 @@ function deferred() { let resolve!: () => void; const promise = new Promise ({ ...emptySubscriptionStatus(id as NativeSubscriptionProviderId), available: true, connected, connectionId: connected ? `local-${id}` : null }));
const listeners = new Set<() => void>(), login = deferred();
- const calls = { login: [] as unknown[][], cancel: 0, open: [] as unknown[][], consent: [] as unknown[][], disconnect: [] as string[] };
+ const calls = { login: [] as unknown[][], cancel: 0, open: [] as unknown[][], consent: [] as unknown[][], disconnect: [] as string[], retry: [] as string[] };
function push(id: NativeSubscriptionProviderId, next: Partial) {
statuses = statuses.map((s) => s.providerId === id ? { ...s, revision: s.revision + 1, ...next } : s); for (const listener of listeners) listener();
}
@@ -23,7 +23,7 @@ function fixture(connected = false) {
status: async () => structuredClone(statuses),
login: async (id, enabled) => { calls.login.push([id, enabled]); push(id, { pending: true, interaction: { attemptId: "attempt", userCode: "PUBLIC-CODE", browser: { id: "opaque-action", url: "https://auth.example/device" }, progress: "Complete sign-in in your browser." } }); await login.promise; },
cancel: async (id) => { calls.cancel++; push(id, { pending: false, interaction: undefined }); },
- respond: async () => {}, open: async (...args) => { calls.open.push(args); }, retry: async () => {},
+ respond: async () => {}, open: async (...args) => { calls.open.push(args); }, retry: async (id) => { calls.retry.push(id); },
disconnect: async (id) => { calls.disconnect.push(id); push(id, { connected: false, connectionId: null }); },
consent: async (id, enabled) => { calls.consent.push([id, enabled]); push(id, { allowBackgroundUse: enabled }); },
onChange: (listener) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
@@ -35,10 +35,14 @@ async function mount() {
host = document.createElement("div"); document.body.append(host); root = createRoot(host);
await act(async () => root!.render());
}
-function button(text: string, scope: ParentNode = document) { const found = [...scope.querySelectorAll("button")].find((element) => element.textContent === text); assert(found, `missing button ${text}`); return found; }
+function maybeButton(text: string, scope: ParentNode = document) { return [...scope.querySelectorAll("button")].find((element) => element.textContent === text); }
+function button(text: string, scope: ParentNode = document) { const found = maybeButton(text, scope); assert(found, `missing button ${text}`); return found; }
const row = (id: string) => document.querySelector(`[data-testid="${id}-connection"]`)!;
const dialog = () => document.querySelector('[data-testid="subscription-connection-dialog"]')!;
+const toggle = () => dialog().querySelector('[role="switch"]')!;
+const footer = () => dialog().querySelector('[data-testid="subscription-connection-footer"]')!;
async function click(element: HTMLElement) { assert(element); await act(async () => element.click()); }
+async function connectionStep(id: string) { await click(button("Connect", row(id))); await click(button("Continue", dialog())); }
afterEach(async () => { await act(async () => root?.unmount()); host?.remove(); root = undefined; host = undefined; delete window.lh; });
test("three compact rows render in approved order with no browser-only sign-in or fabricated identities", () => {
@@ -48,34 +52,82 @@ test("three compact rows render in approved order with no browser-only sign-in o
assert.equal((html.match(/>Connect<\/button>/g) ?? []).length, 3);
assert(!html.includes("@")); assert(!html.includes("Add accounts"));
});
-test("Copilot disclosure and default-ON draft appear before login; browser action passes only opaque IDs", async () => {
- const f = fixture(); await mount(); await click(button("Connect", row("github-copilot")));
- assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE)); assert.equal(f.calls.login.length, 0);
- const toggle = dialog().querySelector('[role="switch"]')!; assert.equal(toggle.getAttribute("aria-checked"), "true");
- await click(toggle); await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [["github-copilot", false]]);
- assert(dialog().textContent?.includes("PUBLIC-CODE")); await click(button("Open browser", dialog()));
- assert.deepEqual(f.calls.open, [["github-copilot", "attempt", "opaque-action"]]);
+for (const [id, label] of [["github-copilot", "GitHub Copilot"], ["xai", "Grok"]] as const) test(`${id}: disclosure and connection are separate steps with no duplicated content or early authorization`, async () => {
+ const f = fixture(); await mount(); await click(button("Connect", row(id)));
+ assert.equal(dialog().querySelector("h2")?.textContent, label);
+ assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle());
+ assert.equal(!!dialog().querySelector('[data-testid="copilot-policy-notice"]'), id === "github-copilot");
+ if (id === "github-copilot") assert(dialog().textContent?.includes(COPILOT_POLICY_NOTICE));
+ assert(!maybeButton("Connect", dialog())); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []);
+ await click(button("Continue", dialog()));
+ assert.equal(dialog().querySelector("h2")?.textContent, label); assert.equal(toggle().getAttribute("aria-checked"), "true");
+ assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"], [data-testid="copilot-policy-notice"]'));
+ assert.equal(f.calls.login.length, 0); await click(toggle()); assert.deepEqual(f.calls.consent, []);
+ await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [[id, false]]);
+ assert(dialog().textContent?.includes("PUBLIC-CODE")); assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]'));
+ await click(button("Open browser", dialog())); assert.deepEqual(f.calls.open, [[id, "attempt", "opaque-action"]]);
await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert(!dialog());
- await click(button("Connect", row("xai"))); const newer = dialog(); await act(async () => f.login.resolve());
+ await click(button("Connect", row(id === "xai" ? "github-copilot" : "xai"))); const newer = dialog(); await act(async () => f.login.resolve());
assert(dialog() === newer, "old completion cannot close another provider dialog");
});
-test("single native connection management has no account controls; background changes are independent and disconnect is confirmed", async () => {
- const f = fixture(true); await mount(); await click(button("Manage…", row("xai")));
- assert(!dialog().querySelector("select")); assert(!dialog().textContent?.includes("Add accounts")); assert(button("Reconnect", dialog()));
- await click(dialog().querySelector('[role="switch"]')!); assert.deepEqual(f.calls.consent, [["xai", false]]);
- assert.equal(f.statuses.find((s) => s.providerId === "github-copilot")!.allowBackgroundUse, true);
+for (const phase of ["notice", "connection"] as const) test(`cancelling the native ${phase} step starts no OAuth and saves no draft preference`, async () => {
+ const f = fixture(); await mount(); await click(button("Connect", row("xai")));
+ if (phase === "connection") { await click(button("Continue", dialog())); await click(toggle()); }
+ await click(button("Cancel", dialog())); assert(!dialog()); assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.consent, []); assert.equal(f.calls.cancel, 0);
+ await connectionStep("xai"); assert.equal(toggle().getAttribute("aria-checked"), "true");
+});
+test("native step transition moves keyboard focus into the new connection controls", async () => {
+ const original = HTMLElement.prototype.focus, focused: HTMLElement[] = [];
+ HTMLElement.prototype.focus = function () { focused.push(this); };
+ try { fixture(); await mount(); await connectionStep("xai"); assert.equal(focused.at(-1), toggle()); }
+ finally { HTMLElement.prototype.focus = original; }
+});
+test("healthy management has only controls, quiet Manage, destructive action left and Close right", async () => {
+ const f = fixture(true); await mount();
+ assert(!row("xai").textContent?.includes("Connected")); assert.match(button("Manage…", row("xai")).className, /text-muted-foreground/);
+ await click(button("Manage…", row("xai")));
+ assert.equal(dialog().querySelector("h2")?.textContent, "Grok");
+ assert(!dialog().querySelector("select")); assert(!dialog().textContent?.includes("Add accounts"));
+ assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!maybeButton("Reconnect", dialog())); assert(!maybeButton("Sign in again", dialog()));
+ assert.equal(footer().firstElementChild, button("Disconnect", dialog())); assert.match(button("Disconnect", dialog()).className, /text-red/);
+ assert.equal(footer().lastElementChild?.lastElementChild, button("Close", dialog()));
+ await click(toggle()); assert.deepEqual(f.calls.consent, [["xai", false]]); assert.equal(f.statuses.find((s) => s.providerId === "github-copilot")!.allowBackgroundUse, true);
await click(button("Disconnect", dialog())); assert.equal(f.calls.disconnect.length, 0);
- assert(dialog().textContent?.includes("does not cancel your subscription or revoke access"));
- await click(button("Cancel", dialog())); assert(button("Reconnect", dialog()));
+ assert.equal(dialog().querySelector("h2")?.textContent, "Grok"); assert(dialog().textContent?.includes("does not cancel your subscription or revoke access"));
+ await click(button("Cancel", dialog())); assert(toggle()); assert(!maybeButton("Reconnect", dialog()));
await click(button("Disconnect", dialog())); await click(button("Disconnect", dialog())); assert.deepEqual(f.calls.disconnect, ["xai"]); assert(!dialog());
});
+test("authorization trouble offers Retry and staged Sign in again without removing the saved connection", async () => {
+ const f = fixture(true); f.push("xai", { connected: false, allowBackgroundUse: false, error: "The connection could not be refreshed." });
+ await mount(); await click(button("Manage…", row("xai"))); await click(button("Retry", dialog())); assert.deepEqual(f.calls.retry, ["xai"]);
+ await click(button("Sign in again", dialog())); assert(dialog().querySelector('[data-testid="subscription-privacy-notice"]')); assert(!toggle());
+ assert.deepEqual(f.calls.login, []); assert.deepEqual(f.calls.disconnect, []); assert.equal(f.statuses.find((s) => s.providerId === "xai")!.connectionId, "local-xai");
+ await click(button("Continue", dialog())); assert.equal(toggle().getAttribute("aria-checked"), "false");
+ await click(toggle()); await click(button("Connect", dialog())); assert.deepEqual(f.calls.login, [["xai", true]]); assert.deepEqual(f.calls.consent, []);
+ await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 1); assert.deepEqual(f.calls.disconnect, []); assert.equal(f.statuses.find((s) => s.providerId === "xai")!.allowBackgroundUse, false);
+ await act(async () => f.login.resolve());
+});
+test("a non-authority error on a still-working connection does not expose a healthy reauthorization action", async () => {
+ const f = fixture(true); f.push("xai", { error: "The replacement sign-in did not complete." });
+ await mount(); await click(button("Manage…", row("xai"))); assert(!maybeButton("Sign in again", dialog())); assert(!maybeButton("Retry", dialog()));
+});
+test("storage/desktop unavailability is not presented as an authorization repair action", async () => {
+ const f = fixture(true); await mount(); await click(button("Manage…", row("xai")));
+ await act(async () => f.push("xai", { available: false, connected: false, error: "The saved connection could not be read or written safely." }));
+ assert(!maybeButton("Sign in again", dialog())); assert(!maybeButton("Retry", dialog())); assert(button("Close", dialog()));
+});
test("required native prompts render and commit cannot be cancelled through button, Escape or backdrop", async () => {
- const f = fixture(); await mount(); await click(button("Connect", row("github-copilot"))); await click(button("Connect", dialog()));
+ const f = fixture(); await mount(); await connectionStep("github-copilot"); await click(button("Connect", dialog()));
await act(async () => f.push("github-copilot", { interaction: { attemptId: "attempt", prompt: { id: "enterprise", type: "text", message: "Enterprise domain (leave blank for github.com)", placeholder: "company.ghe.com" } } }));
assert.equal(dialog().querySelector("input")?.getAttribute("placeholder"), "company.ghe.com"); assert(button("Continue", dialog()));
+ assert(!dialog().querySelector('[data-testid="subscription-privacy-notice"]'));
await act(async () => f.push("github-copilot", { committing: true, interaction: undefined }));
assert(button("Cancel", dialog()).disabled); await click(button("Cancel", dialog())); assert.equal(f.calls.cancel, 0); assert(dialog());
await act(async () => { const event = new window.Event("keydown", { bubbles: true }); Object.defineProperty(event, "key", { value: "Escape" }); document.dispatchEvent(event); });
assert(dialog()); assert.equal(f.calls.cancel, 0);
- await act(async () => f.login.resolve());
+ await act(async () => {
+ const backdrop = document.querySelector('[data-testid="subscription-dialog-backdrop"]')!;
+ for (const type of ["pointerdown", "pointerup", "click"]) { const event = new window.Event(type, { bubbles: true }); Object.defineProperties(event, { pointerId: { value: 1 }, isPrimary: { value: true }, button: { value: 0 } }); backdrop.dispatchEvent(event); }
+ });
+ assert(dialog()); assert.equal(f.calls.cancel, 0); await act(async () => f.login.resolve());
});