diff --git a/plugins/web-ui/server/index.ts b/plugins/web-ui/server/index.ts index 42cabfcb2..25f3ec400 100644 --- a/plugins/web-ui/server/index.ts +++ b/plugins/web-ui/server/index.ts @@ -1422,10 +1422,20 @@ const apiRoutes: readonly WebRoute[] = [ method: "POST", path: "/api/connectors/:provider/start", handle: async (c) => { - const { res, user } = c; + const { req, res, user } = c; + const p = await readJson<{ accountType?: unknown }>(req, res); + if (!p) return; + const accountType = typeof p.accountType === "string" ? p.accountType : undefined; + if (accountType && accountType !== "default" && accountType !== "personal" && accountType !== "company") { + return json(res, 400, { error: "bad_request", message: "invalid accountType" }); + } const provider = c.params.provider!; + if (accountType && accountType !== "default" && provider !== "google") { + return json(res, 400, { error: "bad_request", message: "accountType is only supported for Google" }); + } const callback = `${PUBLIC_URL}/v1/connectors/oauth/${encodeURIComponent(provider)}/callback`; const params = new URLSearchParams({ principalId: user, redirectUri: callback, returnTo: "/keychain" }); + if (accountType) params.set("accountType", accountType); const corePath = `/v1/connectors/oauth/${encodeURIComponent(provider)}/start?${params.toString()}`; return relayCore(res, "GET", corePath); }, @@ -1435,12 +1445,23 @@ const apiRoutes: readonly WebRoute[] = [ path: "/api/connectors/revoke", handle: async (c) => { const { req, res, user } = c; - const p = await readJson<{ provider?: unknown; host?: unknown }>(req, res, false); + const p = await readJson<{ provider?: unknown; host?: unknown; accountType?: unknown }>(req, res, false); if (!p) return; const provider = typeof p.provider === "string" ? p.provider : ""; const host = typeof p.host === "string" ? p.host : ""; + const accountType = typeof p.accountType === "string" ? p.accountType : undefined; + if (accountType && accountType !== "default" && accountType !== "personal" && accountType !== "company") { + return json(res, 400, { error: "bad_request", message: "invalid accountType" }); + } if (!provider && !host) return json(res, 400, { error: "bad_request", message: "provider or host required" }); - const rawBody = JSON.stringify({ principalId: user, ...(provider ? { provider } : { host }) }); + if (accountType && accountType !== "default" && provider !== "google") { + return json(res, 400, { error: "bad_request", message: "accountType is only supported for Google" }); + } + const rawBody = JSON.stringify({ + principalId: user, + ...(provider ? { provider } : { host }), + ...(accountType ? { accountType } : {}), + }); return relayCore(res, "POST", "/v1/connectors/oauth/revoke", rawBody); }, }, diff --git a/plugins/web-ui/src/connectors.ts b/plugins/web-ui/src/connectors.ts index 75667baed..c36649bb2 100644 --- a/plugins/web-ui/src/connectors.ts +++ b/plugins/web-ui/src/connectors.ts @@ -100,6 +100,42 @@ interface KeychainConnectorCredential { needsReconnect?: boolean; } +type GoogleAccountType = "default" | "personal" | "company"; + +interface GoogleAccountState { + accountType: GoogleAccountType; + connected: boolean; + needsReconnect: boolean; +} + +function googleAccountLabel(accountType: GoogleAccountType): string { + if (accountType === "company") return "Work Google"; + if (accountType === "personal") return "Personal Google"; + return "Google account"; +} + +function googleAccountStates(credentials: KeychainConnectorCredential[]): GoogleAccountState[] { + const order: GoogleAccountType[] = ["company", "personal", "default"]; + return order.flatMap((accountType) => { + const matches = credentials.filter((credential) => (credential.accountType ?? "default") === accountType); + if (!matches.length) return []; + return [ + { + accountType, + connected: matches.some((credential) => credential.connected), + needsReconnect: matches.some((credential) => Boolean(credential.needsReconnect)), + }, + ]; + }); +} + +function nextGoogleAccountType(accounts: GoogleAccountState[]): GoogleAccountType | undefined { + if (!accounts.length || accounts.some((account) => account.accountType === "default")) return undefined; + if (!accounts.some((account) => account.accountType === "company")) return "company"; + if (!accounts.some((account) => account.accountType === "personal")) return "personal"; + return undefined; +} + interface KeychainGrant { id: string; credentialId: string; @@ -446,6 +482,8 @@ function drawConnectors(loading = false): void { .filter((host): host is string => Boolean(host)), ); const credentials = keychainConnectorCredentials.filter((credential) => hosts.has(credential.host)); + const googleAccounts = id === "google" ? googleAccountStates(credentials) : []; + const nextGoogleAccount = nextGoogleAccountType(googleAccounts); const credentialsById = new Map( credentials.map((credential) => [credential.credentialId, { id: credential.credentialId, kind: "connector" }]), ); @@ -453,6 +491,26 @@ function drawConnectors(loading = false): void { let connectionState: TemplateResult | string = html`Not connected`; if (needsReconnect) connectionState = html`Reconnect needed`; else if (connected) connectionState = ""; + let connectorAction: TemplateResult | string = ""; + if ( + available && + id === "google" && + googleAccounts.length < 2 && + ((!connected && !needsReconnect) || googleAccounts.length > 0) + ) { + const label = nextGoogleAccount ? "Add another account" : "Reconnect Google"; + connectorAction = html``; + } else if (available && id !== "google") { + connectorAction = html``; + } return html`
@@ -467,6 +525,46 @@ function drawConnectors(loading = false): void {
${meta.desc ? html`

${meta.desc}

` : ""} ${needsReconnect && p.refreshError ? html`
Refresh failed: ${p.refreshError}
` : ""} + ${ + id === "google" && googleAccounts.length + ? html`
+
Connected accounts
+ ${googleAccounts.map( + (account) => + html`
+
+ ${googleAccountLabel(account.accountType)} +
+ ${account.needsReconnect ? "Reconnect required" : "Gmail, Calendar, Drive, and files"} +
+
+ +
`, + )} +
` + : "" + } ${ grants.length ? html`
@@ -493,8 +591,20 @@ function drawConnectors(loading = false): void { : "" }
- ${available ? html`` : ""} - ${connected || needsReconnect ? html`` : ""} + ${connectorAction} + ${ + id !== "google" && (connected || needsReconnect) + ? html`` + : "" + }
`; @@ -770,12 +880,13 @@ async function createDrop(): Promise { } } -async function startConnector(provider: string): Promise { +async function startConnector(provider: string, accountType?: GoogleAccountType): Promise { const stateEpoch = keychainOperations.captureEpoch(); connectorNotice = ""; try { const r = await api<{ authorizeUrl?: string }>(`/api/connectors/${encodeURIComponent(provider)}/start`, { method: "POST", + body: JSON.stringify(accountType ? { accountType } : {}), }); if (!keychainOperations.isCurrentEpoch(stateEpoch)) return; if (r.authorizeUrl) { @@ -790,13 +901,17 @@ async function startConnector(provider: string): Promise { drawConnectors(false); } -async function revokeConnector(provider: string): Promise { +async function revokeConnector(provider: string, accountType?: GoogleAccountType): Promise { const hosts = new Set( (connectorProviders[provider]?.hosts ?? []) .map((entry) => (typeof entry === "string" ? entry : entry.host)) .filter((host): host is string => Boolean(host)), ); - const providerCredentials = keychainConnectorCredentials.filter((credential) => hosts.has(credential.host)); + const providerCredentials = keychainConnectorCredentials.filter( + (credential) => + hosts.has(credential.host) && + (accountType === undefined || (credential.accountType ?? "default") === accountType), + ); const credentialIds = new Set(providerCredentials.map((credential) => credential.credentialId)); const credentialsById = new Map( providerCredentials.map((credential) => [ @@ -812,7 +927,7 @@ async function revokeConnector(provider: string): Promise { : ""; confirmationOpener = document.activeElement instanceof HTMLElement ? document.activeElement : null; confirmation = { - title: `Disconnect ${CONNECTOR_LABELS[provider]?.name ?? provider}?`, + title: `Disconnect ${accountType ? googleAccountLabel(accountType) : (CONNECTOR_LABELS[provider]?.name ?? provider)}?`, body: `${impact} Automations using this account may stop working.`.trim(), action: "Disconnect account", run: async () => { @@ -822,7 +937,7 @@ async function revokeConnector(provider: string): Promise { confirmationOpener = null; drawConnectors(); try { - await performRevokeConnector(provider, operation.epoch); + await performRevokeConnector(provider, operation.epoch, accountType); } finally { if (keychainOperations.finishMutation(operation)) drawConnectors(); } @@ -831,10 +946,17 @@ async function revokeConnector(provider: string): Promise { drawConnectors(); } -async function performRevokeConnector(provider: string, stateEpoch: number): Promise { +async function performRevokeConnector( + provider: string, + stateEpoch: number, + accountType?: GoogleAccountType, +): Promise { connectorNotice = ""; try { - await api("/api/connectors/revoke", { method: "POST", body: JSON.stringify({ provider }) }); + await api("/api/connectors/revoke", { + method: "POST", + body: JSON.stringify({ provider, ...(accountType ? { accountType } : {}) }), + }); } catch (e) { if (keychainOperations.isCurrentEpoch(stateEpoch)) connectorNotice = errMessage(e, "Could not disconnect."); } diff --git a/plugins/web-ui/src/shell.css b/plugins/web-ui/src/shell.css index dad84b7a8..611c17505 100644 --- a/plugins/web-ui/src/shell.css +++ b/plugins/web-ui/src/shell.css @@ -3772,6 +3772,12 @@ a.chat-row-open { .kc-access-row + .kc-access-row { border-top: 1px solid var(--kc-line); } +.kc-account-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; +} .kc-credential-facts { display: flex; align-items: center; @@ -3906,6 +3912,19 @@ a.chat-row-open { width: 100%; justify-content: flex-end; } + .kc-access-row { + align-items: flex-start; + flex-wrap: wrap; + gap: 8px; + } + .kc-account-actions { + flex-wrap: wrap; + width: 100%; + } + .kc-account-actions .kc-text-action { + min-height: 32px; + padding: 5px 0; + } .kc-summary { display: grid; grid-template-columns: 1fr 1fr; diff --git a/plugins/web-ui/test/api-body-parsing.test.ts b/plugins/web-ui/test/api-body-parsing.test.ts index 74f0383a0..f63ad9e35 100644 --- a/plugins/web-ui/test/api-body-parsing.test.ts +++ b/plugins/web-ui/test/api-body-parsing.test.ts @@ -81,3 +81,30 @@ test("routes that historically tolerated an empty body still do", async () => { const forked = calls.at(-1); assert.deepEqual(forked?.body, { principalId: "alice" }); }); + +test("connector start forwards an optional Google account type", async () => { + const r = await fetch(`${base}/api/connectors/google/start`, { + method: "POST", + headers, + body: JSON.stringify({ accountType: "company" }), + }); + assert.equal(r.status, 200); + const started = calls.at(-1); + const url = new URL(started?.url ?? "", "http://core.test"); + assert.equal(url.pathname, "/v1/connectors/oauth/google/start"); + assert.equal(url.searchParams.get("accountType"), "company"); +}); + +test("connector revoke forwards the selected Google account only", async () => { + const r = await fetch(`${base}/api/connectors/revoke`, { + method: "POST", + headers, + body: JSON.stringify({ provider: "google", accountType: "personal" }), + }); + assert.equal(r.status, 200); + assert.deepEqual(calls.at(-1)?.body, { + principalId: "alice", + provider: "google", + accountType: "personal", + }); +}); diff --git a/plugins/web-ui/test/keychain-flow.test.ts b/plugins/web-ui/test/keychain-flow.test.ts index a1073748d..4dca1358d 100644 --- a/plugins/web-ui/test/keychain-flow.test.ts +++ b/plugins/web-ui/test/keychain-flow.test.ts @@ -156,7 +156,9 @@ test("keychain rows reserve success badges for actionable states", () => { }); test("keychain actions keep secondary weight and compact mobile sizing", () => { - assert.match(connectorsSource, /\$\{available \? html`