Pause Google one-click connect pending CASA verification

Gmail/Calendar/Drive show a disabled Coming-soon button; the server refuses the flow too.
Manual token connect and already-connected accounts are untouched.
This commit is contained in:
Rohit C Prasad
2026-07-23 07:43:24 -07:00
committed by Rohit P
parent 71b0df8ea2
commit 2a2fffd280
11 changed files with 159 additions and 13 deletions
+10 -2
View File
@@ -82,6 +82,10 @@ class ConnectorDescriptor:
# is an extra path, never a replacement (local-only open-source flow is
# sacred).
managed: bool = False
# One-click temporarily unavailable (e.g. Google pending CASA verification):
# the GUI shows a disabled button with a "Coming soon" badge, the server
# refuses begin_managed_connect, and the manual path is unaffected.
managed_paused: bool = False
# Multi-account (accounts.py generic layer): the creds field that names an
# account (e.g. "project_id"), or "@identity" = the validator's identity
# string. Non-empty → profiles live at `<name>:account:<id>` and the
@@ -558,6 +562,8 @@ DESCRIPTORS: list[ConnectorDescriptor] = [
],
available=True,
managed=True,
# Google OAuth verification (CASA) pending — one-click off until it clears.
managed_paused=True,
),
ConnectorDescriptor(
name="google_calendar",
@@ -582,6 +588,7 @@ DESCRIPTORS: list[ConnectorDescriptor] = [
],
available=True,
managed=True,
managed_paused=True, # same Google app as Gmail — paused until CASA clears
),
ConnectorDescriptor(
name="browser",
@@ -1119,12 +1126,13 @@ DESCRIPTORS: list[ConnectorDescriptor] = [
),
],
instructions=[
"One click connects via OpenWorker Cloud (recommended).",
"Manual: use a Google OAuth access token with Drive readonly scope.",
"Use a Google OAuth access token with Drive readonly scope.",
"Paste the access token below.",
],
validate=_validate_google_drive,
available=True,
managed=True,
managed_paused=True, # same Google app as Gmail — paused until CASA clears
# Key each connected account by its Google email (the broker's `account`
# field) so multiple Drive accounts list the same way Gmail's do, rather
# than by the opaque `sub` that account_field="account_id" would use.
+1
View File
@@ -97,6 +97,7 @@ def connector_list(secrets: SecretStore) -> list[dict[str, Any]]:
"experimental": d.experimental,
"risk_notice": d.risk_notice,
"managed": d.managed,
"managed_paused": d.managed_paused,
# Whether THIS profile came from managed OAuth (vs manual paste).
"managed_profile": bool(profile.get("managed")),
# "relay" for the managed cloud path; empty for manual/token connect.
+8
View File
@@ -968,7 +968,15 @@ def create_app(manager: SessionManager) -> FastAPI:
from .. import cloud
from ..config import load_config
from ..connectors.descriptors import get_descriptor
d = get_descriptor(name)
if d is not None and d.managed_paused:
# GUI shows the Coming-soon state; this guard covers stale GUIs/API callers.
return {
"ok": False,
"error": f"one-click connect for {d.title} is coming soon — connect manually for now",
}
access = str((body or {}).get("access") or "")
flow = str((body or {}).get("flow") or "") # github: "" install | "authorize"
out = await asyncio.to_thread(
+4
View File
@@ -399,6 +399,10 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
// Gmail — PER-TEST multi-account state (starts disconnected; managed connects add
// mailboxes instantly, mirroring the backend's gmail:account:<email> profiles).
// NOTE: the real server currently sends managed_paused: true for the Google trio
// (CASA pending). The fixture keeps gmail UNPAUSED because the cloud-machinery specs
// use its one-click as their subject; the paused UI is covered by google-paused.spec.ts
// via a per-test connectors override.
const gmailState = {
accounts: [] as {
email: string; default: boolean; managed: boolean; scopes: string; needs_reauth: boolean;
+81
View File
@@ -0,0 +1,81 @@
// Google one-click paused pending CASA verification (owner ask 2026-07-22): the managed
// button parks with a "Coming soon" badge — pre-connect modal AND the connected page's
// add-account — while the manual token path stays fully live. The shared fixture keeps
// gmail unpaused (the cloud-machinery specs use it as their one-click subject), so this
// spec overrides the connectors payload per test, like automations-quickstart does.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
const GMAIL_BASE = {
name: "gmail",
title: "Gmail",
icon: "✉",
blurb: "Search, summarize, draft, and send email.",
about: "Search, summarize, and send over your Gmail.",
access: ["Reads and searches your mail."],
auth: "oauth",
two_way: false,
channels: false,
available: true,
brand_color: "#ea4335",
logo: "gmail",
fields: [
{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" },
],
instructions: [],
account: null,
allowed_users: [],
tools: [],
managed: true,
managed_paused: true,
managed_profile: false,
};
async function serveGmail(page, extra: Record<string, unknown>) {
await page.route("**/v1/connectors", (route) =>
route.fulfill({ json: { connectors: [{ ...GMAIL_BASE, connected: false, enabled: false, ...extra }] } }),
);
}
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("paused one-click: Coming soon badge in the connect modal, manual path alive", async ({
page,
}) => {
await serveGmail(page, {});
await openConnectors(page);
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect", exact: true }).click();
const soon = page.getByTestId("managed-coming-soon");
await expect(soon).toBeVisible();
await expect(soon).toBeDisabled();
await expect(soon).toContainText("Coming soon");
await expect(page.getByText("connect manually below for now")).toBeVisible();
// The manual token field is still right there.
await expect(page.getByText("OAuth access token")).toBeVisible();
});
test("paused one-click: connected page's add-account is parked too", async ({ page }) => {
await serveGmail(page, {
connected: true,
enabled: true,
account: "rohit@gmail.com",
accounts: [
{ email: "rohit@gmail.com", default: true, managed: true, scopes: "gmail", needs_reauth: false },
],
filters: { senders: [], labels: [] },
});
await openConnectors(page);
await page.getByTestId("connector-gmail").click();
await expect(page.getByTestId("gmail-detail")).toBeVisible();
const add = page.getByTestId("add-account-btn");
await expect(add).toBeDisabled();
await expect(add).toContainText("Coming soon");
// Existing accounts keep working and stay manageable.
await expect(page.getByTestId("gmail-account-rohit@gmail.com")).toContainText("Default");
});
+1
View File
@@ -391,6 +391,7 @@ export interface Connector {
unauthorized?: ParkedMessage[]; // parked messages from unallowed senders (§19)
tools: ConnectorTool[];
managed: boolean; // one-click managed OAuth available (needs cloud sign-in)
managed_paused?: boolean; // one-click temporarily off (e.g. Google CASA pending) — badge "Coming soon"
managed_profile: boolean; // current profile came from managed OAuth (vs manual paste)
mode?: string; // "relay" for the managed cloud path; "" for manual/token connect
workspaces?: SlackWorkspace[]; // Slack only: connected workspaces (managed relay)
+16 -2
View File
@@ -824,7 +824,21 @@ export function ConnectSetup({
)}
{c.managed && !c.mcp && !manualOnly && (
<div className="space-y-2" data-testid="managed-connect">
{cloud?.signed_in ? (
{c.managed_paused ? (
// One-click temporarily off (e.g. Google pending CASA verification):
// a visibly-parked button, and the manual path below stays fully live.
<>
<button className={BTN_ACCENT + " opacity-50"} disabled data-testid="managed-coming-soon">
{`Connect ${c.title} with one click`}
<span className="ml-2 text-[11px] font-medium px-1.5 py-0.5 rounded-full bg-white/25">
Coming soon
</span>
</button>
<div className="text-[11.5px] text-faint">
One-click sign-in is coming soon connect manually below for now:
</div>
</>
) : cloud?.signed_in ? (
<button className={BTN_ACCENT} onClick={oneClick} disabled={waiting}>
{waiting ? "Check your browser…" : `Connect ${c.title} with one click`}
</button>
@@ -837,7 +851,7 @@ export function ConnectSetup({
// possibly-signed-in user (FB-013); the host keeps polling.
<CloudStatusPending />
)}
{cloud?.signed_in && (
{!c.managed_paused && cloud?.signed_in && (
<div className="text-[11.5px] text-faint">or connect manually:</div>
)}
</div>
@@ -46,13 +46,19 @@ export function CalendarDetail({ c, cloud, slack: _slack, onChanged }: DetailPro
</div>
</div>
<button
className={PILL_ACCENT}
className={PILL_ACCENT + (c.managed_paused ? " opacity-50" : "")}
data-testid="add-account-btn"
onClick={addAccount}
disabled={busy || !cloud?.signed_in}
title={cloud?.signed_in ? "" : "Sign in to OpenWorker Cloud first"}
disabled={busy || !cloud?.signed_in || c.managed_paused}
title={
c.managed_paused
? "One-click Google sign-in is coming soon"
: cloud?.signed_in
? ""
: "Sign in to OpenWorker Cloud first"
}
>
{busy ? "Check your browser…" : " Add account"}
{c.managed_paused ? " Add account · Coming soon" : busy ? "Check your browser…" : " Add account"}
</button>
</div>
@@ -48,13 +48,19 @@ export function GmailDetail({ c, cloud, slack: _slack, onChanged }: DetailProps)
</div>
</div>
<button
className={PILL_ACCENT}
className={PILL_ACCENT + (c.managed_paused ? " opacity-50" : "")}
data-testid="add-account-btn"
onClick={addAccount}
disabled={busy || !cloud?.signed_in}
title={cloud?.signed_in ? "" : "Sign in to OpenWorker Cloud first"}
disabled={busy || !cloud?.signed_in || c.managed_paused}
title={
c.managed_paused
? "One-click Google sign-in is coming soon"
: cloud?.signed_in
? ""
: "Sign in to OpenWorker Cloud first"
}
>
{busy ? "Check your browser…" : " Add account"}
{c.managed_paused ? " Add account · Coming soon" : busy ? "Check your browser…" : " Add account"}
</button>
</div>
+3 -1
View File
@@ -30,7 +30,9 @@ def test_cloud_status_signed_out(client):
def test_connect_managed_requires_sign_in(client):
body = client.post("/v1/connectors/gmail/connect-managed").json()
# notion, not gmail: the Google trio is managed_paused (CASA pending) and its
# guard fires before the sign-in check — see test_google_one_click_paused….
body = client.post("/v1/connectors/notion/connect-managed").json()
assert not body["ok"]
assert "not signed in" in body["error"]
+15
View File
@@ -751,3 +751,18 @@ def test_always_allow_grants_survive_restart(tmp_path):
# "Restart": new manager + engine rebuilt from the persisted record.
mgr2 = SessionManager(workspace=None, provider=_shell_turns())
_run_turn(TestClient(create_app(mgr2)), expect_prompts=0)
def test_google_one_click_paused_but_manual_alive(tmp_path):
"""CASA verification pending: Gmail/Calendar/Drive expose managed_paused (GUI badges
"Coming soon"), the managed-connect route refuses, and the manual fields stay."""
client = _client(tmp_path, [])
connectors = {c["name"]: c for c in client.get("/v1/connectors").json()["connectors"]}
for name in ("gmail", "google_calendar", "google_drive"):
c = connectors[name]
assert c["managed"] is True and c["managed_paused"] is True
assert c["fields"], f"{name} lost its manual fields"
assert connectors["slack"]["managed_paused"] is False # only Google is paused
refused = client.post("/v1/connectors/gmail/connect-managed", json={}).json()
assert refused["ok"] is False and "coming soon" in refused["error"]