Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions plugins/web-ui/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand All @@ -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);
},
},
Expand Down
140 changes: 131 additions & 9 deletions plugins/web-ui/src/connectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -446,13 +482,35 @@ 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" }]),
);
const grants = keychainGrants.filter((grant) => isActiveGrant(grant, credentialsById.get(grant.credentialId)));
let connectionState: TemplateResult | string = html`<span class="kc-state neutral">Not connected</span>`;
if (needsReconnect) connectionState = html`<span class="kc-state warning">Reconnect needed</span>`;
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`<button
class="btn"
type="button"
@click=${() => void startConnector(id, nextGoogleAccount)}
>
${googleAccounts.length ? html`${icon(Plus, 15)}<span>${label}</span>` : "Connect Google"}
</button>`;
} else if (available && id !== "google") {
connectorAction = html`<button class="btn" type="button" @click=${() => void startConnector(id)}>
${connected || needsReconnect ? "Reconnect" : "Connect account"}
</button>`;
}
return html`
<article class="kc-resource kc-account">
<div class="kc-resource-main">
Expand All @@ -467,6 +525,46 @@ function drawConnectors(loading = false): void {
</div>
${meta.desc ? html`<p class="kc-resource-description">${meta.desc}</p>` : ""}
${needsReconnect && p.refreshError ? html`<div class="kc-inline-warning" role="status">Refresh failed: ${p.refreshError}</div>` : ""}
${
id === "google" && googleAccounts.length
? html`<div class="kc-access-block kc-connected-accounts">
<div class="kc-access-label">Connected accounts</div>
${googleAccounts.map(
(account) =>
html`<div class="kc-access-row">
<div>
<strong>${googleAccountLabel(account.accountType)}</strong>
<div>
${account.needsReconnect ? "Reconnect required" : "Gmail, Calendar, Drive, and files"}
</div>
</div>
<div class="kc-account-actions">
${
account.needsReconnect
? html`<button
class="kc-text-action"
type="button"
@click=${() => void startConnector(id, account.accountType)}
>
Reconnect
</button>`
: ""
}
<button
class="kc-text-action danger"
type="button"
data-confirm-key=${`disconnect:${id}:${account.accountType}`}
?disabled=${keychainOperations.mutationInFlight}
@click=${() => void revokeConnector(id, account.accountType)}
>
Disconnect
</button>
</div>
</div>`,
)}
</div>`
: ""
}
${
grants.length
? html`<div class="kc-access-block">
Expand All @@ -493,8 +591,20 @@ function drawConnectors(loading = false): void {
: ""
}
<div class="kc-resource-actions">
${available ? html`<button class="btn" type="button" @click=${() => void startConnector(id)}>${connected || needsReconnect ? "Reconnect" : "Connect account"}</button>` : ""}
${connected || needsReconnect ? html`<button class="kc-text-action danger" type="button" data-confirm-key=${`disconnect:${id}`} ?disabled=${keychainOperations.mutationInFlight} @click=${() => void revokeConnector(id)}>Disconnect</button>` : ""}
${connectorAction}
${
id !== "google" && (connected || needsReconnect)
? html`<button
class="kc-text-action danger"
type="button"
data-confirm-key=${`disconnect:${id}`}
?disabled=${keychainOperations.mutationInFlight}
@click=${() => void revokeConnector(id)}
>
Disconnect
</button>`
: ""
}
</div>
</article>
`;
Expand Down Expand Up @@ -770,12 +880,13 @@ async function createDrop(): Promise<void> {
}
}

async function startConnector(provider: string): Promise<void> {
async function startConnector(provider: string, accountType?: GoogleAccountType): Promise<void> {
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) {
Expand All @@ -790,13 +901,17 @@ async function startConnector(provider: string): Promise<void> {
drawConnectors(false);
}

async function revokeConnector(provider: string): Promise<void> {
async function revokeConnector(provider: string, accountType?: GoogleAccountType): Promise<void> {
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) => [
Expand All @@ -812,7 +927,7 @@ async function revokeConnector(provider: string): Promise<void> {
: "";
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 () => {
Expand All @@ -822,7 +937,7 @@ async function revokeConnector(provider: string): Promise<void> {
confirmationOpener = null;
drawConnectors();
try {
await performRevokeConnector(provider, operation.epoch);
await performRevokeConnector(provider, operation.epoch, accountType);
} finally {
if (keychainOperations.finishMutation(operation)) drawConnectors();
}
Expand All @@ -831,10 +946,17 @@ async function revokeConnector(provider: string): Promise<void> {
drawConnectors();
}

async function performRevokeConnector(provider: string, stateEpoch: number): Promise<void> {
async function performRevokeConnector(
provider: string,
stateEpoch: number,
accountType?: GoogleAccountType,
): Promise<void> {
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.");
}
Expand Down
19 changes: 19 additions & 0 deletions plugins/web-ui/src/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions plugins/web-ui/test/api-body-parsing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
4 changes: 3 additions & 1 deletion plugins/web-ui/test/keychain-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`<button class="btn" type="button"/);
assert.match(connectorsSource, /available\s*&&\s*id === "google"\s*&&\s*googleAccounts\.length < 2/);
assert.match(connectorsSource, /!connected\s*&&\s*!needsReconnect/);
assert.match(connectorsSource, /Add another account/);
assert.doesNotMatch(shellCssSource, /\.kc-hero-actions \.btn\s*\{\s*flex:\s*1;/);
assert.doesNotMatch(shellCssSource, /sidebar-closed \.kc-hero-copy/);
});
Loading