diff --git a/package.json b/package.json index b253204..0d077bf 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,10 @@ "import": "./dist/auth.js", "types": "./dist/auth.d.ts" }, + "./integrations": { + "import": "./dist/integrations.js", + "types": "./dist/integrations.d.ts" + }, "./pages": { "import": "./dist/pages.js", "types": "./dist/pages.d.ts" diff --git a/src/auth/index.ts b/src/auth/index.ts index 951b3e0..4aff622 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -9,3 +9,7 @@ export { UserMenu, type UserMenuProps, } from "@tangle-network/ui/auth"; +export { + TangleLoginButton, + type TangleLoginButtonProps, +} from "./tangle-login-button"; diff --git a/src/auth/tangle-login-button.test.tsx b/src/auth/tangle-login-button.test.tsx new file mode 100644 index 0000000..4a2c4a0 --- /dev/null +++ b/src/auth/tangle-login-button.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { TangleLoginButton } from "./tangle-login-button"; + +describe("TangleLoginButton", () => { + const originalLocation = window.location; + + beforeEach(() => { + Object.defineProperty(window, "location", { + writable: true, + value: { ...originalLocation, href: "http://localhost/" }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, "location", { + writable: true, + value: originalLocation, + }); + }); + + it("defaults to /auth/tangle on click", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /sign in with tangle/i })); + expect(window.location.href).toBe("/auth/tangle"); + }); + + it("honors a custom authUrl prop", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /sign in with tangle/i })); + expect(window.location.href).toBe("/api/auth/tangle?redirect=/app"); + }); + + it("renders a custom label via children", () => { + render(Continue with Tangle); + expect( + screen.getByRole("button", { name: /continue with tangle/i }), + ).toBeInTheDocument(); + }); +}); diff --git a/src/auth/tangle-login-button.tsx b/src/auth/tangle-login-button.tsx new file mode 100644 index 0000000..b437caf --- /dev/null +++ b/src/auth/tangle-login-button.tsx @@ -0,0 +1,65 @@ +"use client"; + +import type * as React from "react"; +import { Button, type ButtonProps } from "@tangle-network/ui/primitives"; +import { cn } from "@tangle-network/ui/utils"; + +function TangleMark({ className }: { className?: string }) { + // Stylised "T" mark — `currentColor` so it adapts to any button variant. + return ( + + ); +} + +export interface TangleLoginButtonProps extends Omit { + /** + * Consumer-app endpoint that starts the cross-site SSO flow. The + * consumer's server is expected to redirect this request to the + * platform's `/cross-site/authorize` URL (built via + * `PlatformAuthClient.authorizeUrl` from + * `@tangle-network/agent-runtime/platform`). Defaults to + * `/auth/tangle`. + */ + authUrl?: string; + /** Product variant for styling. */ + variant?: "sandbox" | "default" | "outline"; +} + +/** + * "Login with Tangle" button — kicks off the cross-site SSO bridge. + * Server-side, the consumer app should: + * 1. Generate a `state` for CSRF. + * 2. Persist `state` in a session cookie or signed JWT. + * 3. 302-redirect to `PlatformAuthClient.authorizeUrl({ state, redirectUri })`. + * + * This component itself only triggers the redirect to `authUrl`; the + * server owns the platform URL construction. + */ +export function TangleLoginButton({ + authUrl = "/auth/tangle", + variant = "default", + className, + children, + ...props +}: TangleLoginButtonProps) { + return ( + + ); +} diff --git a/src/integrations/index.ts b/src/integrations/index.ts new file mode 100644 index 0000000..260ea75 --- /dev/null +++ b/src/integrations/index.ts @@ -0,0 +1,16 @@ +export { + IntegrationsPanel, + type IntegrationsPanelProps, +} from "./integrations-panel"; +export { + useIntegrations, + type ConnectInput, + type UseIntegrationsOptions, + type UseIntegrationsResult, +} from "./use-integrations"; +export type { + IntegrationConnection, + IntegrationConnector, + IntegrationHealth, + IntegrationProvider, +} from "./types"; diff --git a/src/integrations/integrations-panel.test.tsx b/src/integrations/integrations-panel.test.tsx new file mode 100644 index 0000000..117129c --- /dev/null +++ b/src/integrations/integrations-panel.test.tsx @@ -0,0 +1,143 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { IntegrationsPanel } from "./integrations-panel"; +import type { IntegrationConnection, IntegrationProvider } from "./types"; + +const catalog: IntegrationProvider[] = [ + { + providerId: "google", + displayName: "Google Workspace", + description: "Gmail, Drive, Calendar", + connectors: [{ connectorId: "gmail", displayName: "Gmail" }], + }, + { + providerId: "slack", + displayName: "Slack", + connectors: [{ connectorId: "slack" }], + }, +]; + +describe("IntegrationsPanel", () => { + it("renders one tile per catalog provider", () => { + render( + {}} + onDisconnect={() => {}} + />, + ); + expect(screen.getByText("Google Workspace")).toBeInTheDocument(); + expect(screen.getByText("Slack")).toBeInTheDocument(); + }); + + it("renders Connect button for providers with no live connection", () => { + const onConnect = vi.fn(); + render( + {}} + />, + ); + fireEvent.click(screen.getByTestId("connect-google")); + expect(onConnect).toHaveBeenCalledWith({ + providerId: "google", + connectorId: "gmail", + }); + }); + + it("renders Disconnect button for providers with a live connection", () => { + const onDisconnect = vi.fn(); + const live: IntegrationConnection[] = [ + { + id: "conn_1", + providerId: "google", + connectorId: "gmail", + status: "connected", + account: { displayName: "alice@example.com" }, + }, + ]; + render( + {}} + onDisconnect={onDisconnect} + />, + ); + expect(screen.getByText("alice@example.com")).toBeInTheDocument(); + fireEvent.click(screen.getByTestId("disconnect-google")); + expect(onDisconnect).toHaveBeenCalledWith("conn_1"); + }); + + it("ignores revoked connections when deciding which tile to show as live", () => { + render( + {}} + onDisconnect={() => {}} + />, + ); + expect(screen.getByTestId("connect-google")).toBeInTheDocument(); + expect(screen.queryByTestId("disconnect-google")).toBeNull(); + }); + + it("surfaces an error message when error is set", () => { + render( + {}} + onDisconnect={() => {}} + />, + ); + expect( + screen.getByText(/Failed to load integrations: boom/), + ).toBeInTheDocument(); + }); + + it("shows the empty state when the catalog is empty and not loading", () => { + render( + {}} + onDisconnect={() => {}} + />, + ); + expect(screen.getByText("Nothing here yet")).toBeInTheDocument(); + }); + + it("renders the health badge when healthByConnectionId is supplied", () => { + const live: IntegrationConnection[] = [ + { + id: "c1", + providerId: "google", + connectorId: "gmail", + status: "connected", + }, + ]; + render( + {}} + onDisconnect={() => {}} + />, + ); + expect(screen.getByText("degraded")).toBeInTheDocument(); + }); +}); diff --git a/src/integrations/integrations-panel.tsx b/src/integrations/integrations-panel.tsx new file mode 100644 index 0000000..f2e91b8 --- /dev/null +++ b/src/integrations/integrations-panel.tsx @@ -0,0 +1,195 @@ +"use client"; + +import * as React from "react"; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + EmptyState, +} from "@tangle-network/ui/primitives"; +import { cn } from "@tangle-network/ui/utils"; +import type { + IntegrationConnection, + IntegrationHealth, + IntegrationProvider, +} from "./types"; + +export interface IntegrationsPanelProps { + catalog: IntegrationProvider[]; + connections: IntegrationConnection[]; + healthByConnectionId?: Record; + isLoading?: boolean; + error?: Error | null; + /** + * Invoked when the user clicks "Connect" on a catalog tile. The + * consumer should call its data hook's `connect(...)` action. + */ + onConnect: (input: { + providerId: string; + connectorId: string; + }) => void | Promise; + /** Invoked when the user clicks "Disconnect" on a live connection. */ + onDisconnect: (connectionId: string) => void | Promise; + /** Empty-state message when the catalog hasn't loaded any providers. */ + emptyCatalogLabel?: string; + className?: string; +} + +function statusVariant( + status: string, +): "default" | "secondary" | "destructive" | "outline" { + if (status === "connected" || status === "ok") return "default"; + if (status === "pending") return "secondary"; + if (status === "revoked" || status === "expired" || status === "failing") + return "destructive"; + return "outline"; +} + +function defaultConnectorOf(provider: IntegrationProvider): string { + return provider.connectors?.[0]?.connectorId ?? provider.providerId; +} + +function buildConnectionIndex( + connections: IntegrationConnection[], +): Map { + const index = new Map(); + for (const conn of connections) { + if (conn.status === "revoked") continue; + index.set(`${conn.providerId}:${conn.connectorId}`, conn); + } + return index; +} + +export function IntegrationsPanel({ + catalog, + connections, + healthByConnectionId, + isLoading, + error, + onConnect, + onDisconnect, + emptyCatalogLabel = "No integrations available yet.", + className, +}: IntegrationsPanelProps) { + const connectionIndex = React.useMemo( + () => buildConnectionIndex(connections), + [connections], + ); + + if (error) { + return ( + + +

+ Failed to load integrations: {error.message} +

+
+
+ ); + } + + if (isLoading && catalog.length === 0) { + return ( +
+ {[0, 1, 2, 3].map((i) => ( + + +
+ + +
+ + + ))} +
+ ); + } + + if (catalog.length === 0) { + return ( + + ); + } + + return ( +
+ {catalog.map((provider) => { + const connectorId = defaultConnectorOf(provider); + const live = connectionIndex.get(`${provider.providerId}:${connectorId}`); + const health = live ? healthByConnectionId?.[live.id] : undefined; + const headline = + provider.displayName ?? provider.providerId.replace(/[-_]/g, " "); + return ( + + +
+
+

+ {headline} +

+ {live ? ( + + {live.status} + + ) : null} +
+ {provider.description ? ( +

+ {provider.description} +

+ ) : null} +
+ {health ? ( + + {health.status} + + ) : null} +
+ + {live ? ( + <> + + {live.account?.displayName ?? + live.account?.identity ?? + "Connected"} + + + + ) : ( + + )} + +
+ ); + })} +
+ ); +} diff --git a/src/integrations/types.ts b/src/integrations/types.ts new file mode 100644 index 0000000..b464dbf --- /dev/null +++ b/src/integrations/types.ts @@ -0,0 +1,44 @@ +/** + * Shapes for the integrations primitives. Mirrors the platform's + * `/v1/integrations/*` response shape so consumers can pipe payloads + * straight through. Defined here (rather than imported from + * `@tangle-network/agent-runtime/platform`) so the UI package stays + * leaf-level — no dependency on the server-side client. + */ + +export interface IntegrationConnection { + id: string; + providerId: string; + connectorId: string; + status: "connected" | "pending" | "revoked" | "expired" | (string & {}); + grantedScopes?: string[]; + account?: { + identity?: string; + displayName?: string; + } & Record; + expiresAt?: string | null; + createdAt?: string; + updatedAt?: string; +} + +export interface IntegrationConnector { + connectorId: string; + displayName?: string; + description?: string; + scopes?: string[]; +} + +export interface IntegrationProvider { + providerId: string; + displayName?: string; + description?: string; + iconUrl?: string; + connectors?: IntegrationConnector[]; +} + +export interface IntegrationHealth { + connectionId: string; + status: "ok" | "degraded" | "failing" | "unknown" | (string & {}); + checkedAt?: string; + message?: string; +} diff --git a/src/integrations/use-integrations.test.tsx b/src/integrations/use-integrations.test.tsx new file mode 100644 index 0000000..474b3aa --- /dev/null +++ b/src/integrations/use-integrations.test.tsx @@ -0,0 +1,200 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useIntegrations } from "./use-integrations"; + +function mockFetchSequence( + routes: Record Response | Promise>, +) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString(); + const path = new URL(url, "http://x").pathname; + const method = (init?.method ?? "GET").toUpperCase(); + const key = `${method} ${path}`; + const handler = routes[key] ?? routes[path]; + if (!handler) { + return new Response(`No mock for ${key}`, { status: 500 }); + } + return handler(init); + }) as unknown as typeof fetch; +} + +describe("useIntegrations", () => { + const originalLocation = window.location; + + beforeEach(() => { + Object.defineProperty(window, "location", { + writable: true, + value: { ...originalLocation, href: "http://localhost/" }, + }); + }); + + afterEach(() => { + Object.defineProperty(window, "location", { + writable: true, + value: originalLocation, + }); + }); + + it("loads catalog + connections on mount when autoLoad is true", async () => { + const fetchImpl = mockFetchSequence({ + "GET /api/integrations/catalog": () => + new Response( + JSON.stringify({ + success: true, + data: { + catalog: { + providers: [ + { providerId: "google", connectors: [{ connectorId: "gmail" }] }, + ], + }, + }, + }), + { status: 200 }, + ), + "GET /api/integrations/connections": () => + new Response( + JSON.stringify({ + success: true, + data: { + connections: [ + { + id: "c1", + providerId: "google", + connectorId: "gmail", + status: "connected", + }, + ], + }, + }), + { status: 200 }, + ), + "GET /api/integrations/healthchecks": () => + new Response( + JSON.stringify({ + success: true, + data: { healthchecks: [{ connectionId: "c1", status: "ok" }] }, + }), + { status: 200 }, + ), + }); + + const { result } = renderHook(() => + useIntegrations({ apiBaseUrl: "/api/integrations/", fetchImpl }), + ); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.catalog).toHaveLength(1); + expect(result.current.connections).toHaveLength(1); + expect(result.current.healthByConnectionId.c1?.status).toBe("ok"); + expect(result.current.error).toBeNull(); + }); + + it("redirects the browser when connect() succeeds", async () => { + const fetchImpl = mockFetchSequence({ + "GET /api/integrations/catalog": () => + new Response( + JSON.stringify({ success: true, data: { catalog: { providers: [] } } }), + { status: 200 }, + ), + "GET /api/integrations/connections": () => + new Response( + JSON.stringify({ success: true, data: { connections: [] } }), + { status: 200 }, + ), + "GET /api/integrations/healthchecks": () => + new Response("{}", { status: 200 }), + "POST /api/integrations/auth/start": (init) => { + const body = JSON.parse(String(init?.body)); + expect(body.providerId).toBe("google"); + return new Response( + JSON.stringify({ + success: true, + data: { authorizationUrl: "https://accounts.google.com/o/oauth2/auth?x=1" }, + }), + { status: 200 }, + ); + }, + }); + + const { result } = renderHook(() => + useIntegrations({ apiBaseUrl: "/api/integrations", fetchImpl }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.connect({ + providerId: "google", + connectorId: "gmail", + returnUrl: "https://gtm.tangle.tools/integrations", + }); + }); + expect(window.location.href).toBe("https://accounts.google.com/o/oauth2/auth?x=1"); + }); + + it("surfaces an error when /auth/start fails", async () => { + const fetchImpl = mockFetchSequence({ + "GET /api/integrations/catalog": () => + new Response( + JSON.stringify({ success: true, data: { catalog: { providers: [] } } }), + { status: 200 }, + ), + "GET /api/integrations/connections": () => + new Response( + JSON.stringify({ success: true, data: { connections: [] } }), + { status: 200 }, + ), + "GET /api/integrations/healthchecks": () => + new Response("{}", { status: 200 }), + "POST /api/integrations/auth/start": () => + new Response("forbidden", { status: 403 }), + }); + const { result } = renderHook(() => + useIntegrations({ apiBaseUrl: "/api/integrations", fetchImpl }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + result.current.connect({ + providerId: "google", + connectorId: "gmail", + returnUrl: "https://gtm.tangle.tools/integrations", + }), + ).rejects.toThrow(/Failed to start OAuth \(403\)/); + }); + + it("disconnect() DELETEs by connection id and refreshes", async () => { + let connectionsCallCount = 0; + const fetchImpl = mockFetchSequence({ + "GET /api/integrations/catalog": () => + new Response( + JSON.stringify({ success: true, data: { catalog: { providers: [] } } }), + { status: 200 }, + ), + "GET /api/integrations/connections": () => { + connectionsCallCount += 1; + return new Response( + JSON.stringify({ success: true, data: { connections: [] } }), + { status: 200 }, + ); + }, + "GET /api/integrations/healthchecks": () => + new Response("{}", { status: 200 }), + "DELETE /api/integrations/connections/c-99": () => + new Response( + JSON.stringify({ success: true, data: {} }), + { status: 200 }, + ), + }); + + const { result } = renderHook(() => + useIntegrations({ apiBaseUrl: "/api/integrations", fetchImpl }), + ); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + const initialCount = connectionsCallCount; + + await act(async () => { + await result.current.disconnect("c-99"); + }); + await waitFor(() => expect(connectionsCallCount).toBe(initialCount + 1)); + }); +}); diff --git a/src/integrations/use-integrations.ts b/src/integrations/use-integrations.ts new file mode 100644 index 0000000..acd84f5 --- /dev/null +++ b/src/integrations/use-integrations.ts @@ -0,0 +1,203 @@ +"use client"; + +import * as React from "react"; +import type { + IntegrationConnection, + IntegrationHealth, + IntegrationProvider, +} from "./types"; + +/** + * Endpoint contract expected on the consumer app's server (which + * wraps `PlatformHubClient` from `@tangle-network/agent-runtime/platform`): + * + * GET {base}/catalog + * → { catalog: { providers: IntegrationProvider[] } } + * GET {base}/connections + * → { connections: IntegrationConnection[] } + * GET {base}/healthchecks (optional) + * → { healthchecks: IntegrationHealth[] } + * POST {base}/auth/start + * body { providerId, connectorId, returnUrl, requestedScopes? } + * → { authorizationUrl: string } + * DELETE {base}/connections/{connectionId} + * → { connection: IntegrationConnection } + */ +export interface UseIntegrationsOptions { + /** Base URL where the consumer mounted the integrations endpoints. */ + apiBaseUrl: string; + /** Custom fetch (tests / non-browser runtimes). */ + fetchImpl?: typeof fetch; + /** Whether the initial load happens automatically on mount. */ + autoLoad?: boolean; +} + +export interface UseIntegrationsResult { + catalog: IntegrationProvider[]; + connections: IntegrationConnection[]; + healthByConnectionId: Record; + isLoading: boolean; + error: Error | null; + refresh: () => Promise; + /** Kick off OAuth — navigates the window on success. */ + connect: (input: ConnectInput) => Promise; + /** Revoke a connection by id; refreshes the connections list. */ + disconnect: (connectionId: string) => Promise; +} + +export interface ConnectInput { + providerId: string; + connectorId: string; + /** + * URL the platform redirects the user back to after OAuth. Must be + * allow-listed on the platform. + */ + returnUrl: string; + requestedScopes?: string[]; +} + +interface RawEnvelope { + success?: boolean; + data?: T; + error?: { code?: string; message?: string } | string; +} + +function unwrap(json: RawEnvelope | T): T { + if ( + json && + typeof json === "object" && + "data" in json && + (json as RawEnvelope).data !== undefined + ) { + return (json as RawEnvelope).data as T; + } + return json as T; +} + +export function useIntegrations({ + apiBaseUrl, + fetchImpl, + autoLoad = true, +}: UseIntegrationsOptions): UseIntegrationsResult { + const fetcher = fetchImpl ?? (typeof fetch === "function" ? fetch : null); + if (!fetcher) { + throw new Error("useIntegrations: fetch is not available in this environment"); + } + const base = apiBaseUrl.replace(/\/+$/, ""); + + const [catalog, setCatalog] = React.useState([]); + const [connections, setConnections] = React.useState([]); + const [healthByConnectionId, setHealthByConnectionId] = React.useState< + Record + >({}); + const [isLoading, setIsLoading] = React.useState(autoLoad); + const [error, setError] = React.useState(null); + + const refresh = React.useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const [catalogRes, connectionsRes] = await Promise.all([ + fetcher(`${base}/catalog`, { credentials: "include" }), + fetcher(`${base}/connections`, { credentials: "include" }), + ]); + if (!catalogRes.ok) { + throw new Error(`Failed to load integration catalog (${catalogRes.status})`); + } + if (!connectionsRes.ok) { + throw new Error( + `Failed to load integration connections (${connectionsRes.status})`, + ); + } + const catalogJson = unwrap<{ + catalog?: { providers?: IntegrationProvider[] }; + providers?: IntegrationProvider[]; + }>(await catalogRes.json()); + const providers = + catalogJson?.catalog?.providers ?? catalogJson?.providers ?? []; + setCatalog(providers); + + const connectionsJson = unwrap<{ connections?: IntegrationConnection[] }>( + await connectionsRes.json(), + ); + setConnections(connectionsJson?.connections ?? []); + + // Healthchecks are optional — don't fail the whole panel load + // if the consumer hasn't wired them up. + try { + const healthRes = await fetcher(`${base}/healthchecks`, { + credentials: "include", + }); + if (healthRes.ok) { + const healthJson = unwrap<{ healthchecks?: IntegrationHealth[] }>( + await healthRes.json(), + ); + const map: Record = {}; + for (const h of healthJson?.healthchecks ?? []) { + map[h.connectionId] = h; + } + setHealthByConnectionId(map); + } + } catch { + // Skip — non-fatal. + } + } catch (err) { + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setIsLoading(false); + } + }, [base, fetcher]); + + React.useEffect(() => { + if (autoLoad) { + void refresh(); + } + }, [autoLoad, refresh]); + + const connect = React.useCallback( + async (input: ConnectInput) => { + const res = await fetcher(`${base}/auth/start`, { + method: "POST", + credentials: "include", + headers: { "content-type": "application/json" }, + body: JSON.stringify(input), + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Failed to start OAuth (${res.status}): ${text}`); + } + const json = unwrap<{ authorizationUrl?: string }>(await res.json()); + if (!json?.authorizationUrl) { + throw new Error("Platform did not return an authorizationUrl"); + } + window.location.href = json.authorizationUrl; + }, + [base, fetcher], + ); + + const disconnect = React.useCallback( + async (connectionId: string) => { + const res = await fetcher( + `${base}/connections/${encodeURIComponent(connectionId)}`, + { method: "DELETE", credentials: "include" }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`Failed to revoke connection (${res.status}): ${text}`); + } + await refresh(); + }, + [base, fetcher, refresh], + ); + + return { + catalog, + connections, + healthByConnectionId, + isLoading, + error, + refresh, + connect, + disconnect, + }; +} diff --git a/tsup.config.ts b/tsup.config.ts index 0673532..b92ff41 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -14,6 +14,7 @@ export default defineConfig({ terminal: "src/terminal/index.ts", markdown: "src/markdown/index.ts", auth: "src/auth/index.ts", + integrations: "src/integrations/index.ts", pages: "src/pages/index.ts", hooks: "src/hooks/index.ts", "sdk-hooks": "src/sdk-hooks.ts",