diff --git a/apps/web/src/features/settings/ai/connected-apps.test.tsx b/apps/web/src/features/settings/ai/connected-apps.test.tsx new file mode 100644 index 000000000..56879c4ed --- /dev/null +++ b/apps/web/src/features/settings/ai/connected-apps.test.tsx @@ -0,0 +1,49 @@ +// @vitest-environment happy-dom + +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { expect, it, vi } from "vitest"; +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const oauth2 = vi.hoisted(() => ({ getConsents: vi.fn(), publicClient: vi.fn(), deleteConsent: vi.fn() })); +vi.mock("@/libs/auth/client", () => ({ authClient: { oauth2 } })); + +i18n.loadAndActivate({ locale: "en", messages: {} }); +const { ConnectedAppsSection } = await import("./connected-apps"); + +it("shows registered application names, keeps unnamed or unavailable clients revocable, and revokes the consent", async () => { + oauth2.getConsents.mockResolvedValue({ + data: [ + { id: "consent-1", clientId: "codex-client-id", scopes: ["api:read"] }, + { id: "consent-2", clientId: "unnamed-client-id", scopes: ["api:read"] }, + { id: "consent-3", clientId: "unavailable-client-id", scopes: ["api:read"] }, + ], + error: null, + }); + oauth2.publicClient.mockImplementation(({ query }: { query: { client_id: string } }) => { + if (query.client_id === "unavailable-client-id") return Promise.reject(new Error("Network unavailable")); + return Promise.resolve({ + data: { client_name: query.client_id === "codex-client-id" ? "Codex" : null }, + error: null, + }); + }); + oauth2.deleteConsent.mockResolvedValue({ error: null }); + + render( + + + + + , + ); + + expect(await screen.findByText("Codex")).toBeTruthy(); + expect(screen.queryByText("codex-client-id")).toBeNull(); + expect(screen.getByText("unnamed-client-id")).toBeTruthy(); + expect(screen.getByText("unavailable-client-id")).toBeTruthy(); + const connection = screen.getByText("Codex").closest("li"); + if (!connection) throw new Error("Application name is missing its connection row"); + fireEvent.click(within(connection).getByRole("button", { name: "Revoke access" })); + await waitFor(() => expect(oauth2.deleteConsent).toHaveBeenCalledWith({ id: "consent-1" })); +}); diff --git a/apps/web/src/features/settings/ai/connected-apps.tsx b/apps/web/src/features/settings/ai/connected-apps.tsx index 1b00993d9..268a31ac1 100644 --- a/apps/web/src/features/settings/ai/connected-apps.tsx +++ b/apps/web/src/features/settings/ai/connected-apps.tsx @@ -18,7 +18,14 @@ export function ConnectedAppsSection() { queryFn: async () => { const { data, error } = await authClient.oauth2.getConsents(); if (error) throw new Error(t`Could not load connected applications.`); - return data ?? []; + return Promise.all( + (data ?? []).map(async (connection) => { + const client = await authClient.oauth2 + .publicClient({ query: { client_id: connection.clientId } }) + .catch(() => null); + return { ...connection, clientName: client?.data?.client_name?.trim() || connection.clientId }; + }), + ); }, }); const revoke = useMutation({ @@ -48,7 +55,7 @@ export function ConnectedAppsSection() { {connections.map((connection) => (
  • -

    {connection.clientId}

    +

    {connection.clientName}

    {connection.scopes.join(", ")}