Skip to content
Merged
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
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ export {
UserMenu,
type UserMenuProps,
} from "@tangle-network/ui/auth";
export {
TangleLoginButton,
type TangleLoginButtonProps,
} from "./tangle-login-button";
40 changes: 40 additions & 0 deletions src/auth/tangle-login-button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<TangleLoginButton />);
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(<TangleLoginButton authUrl="/api/auth/tangle?redirect=/app" />);
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(<TangleLoginButton>Continue with Tangle</TangleLoginButton>);
expect(
screen.getByRole("button", { name: /continue with tangle/i }),
).toBeInTheDocument();
});
});
65 changes: 65 additions & 0 deletions src/auth/tangle-login-button.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg
className={className}
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M4 5h16v3.2h-6.4V19h-3.2V8.2H4V5z" />
</svg>
);
}

export interface TangleLoginButtonProps extends Omit<ButtonProps, "onClick"> {
/**
* 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 (
<Button
variant={variant}
className={cn("gap-2", className)}
onClick={() => {
window.location.href = authUrl;
}}
{...props}
>
<TangleMark className="h-5 w-5" />
{children ?? "Sign in with Tangle"}
</Button>
);
}
16 changes: 16 additions & 0 deletions src/integrations/index.ts
Original file line number Diff line number Diff line change
@@ -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";
143 changes: 143 additions & 0 deletions src/integrations/integrations-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<IntegrationsPanel
catalog={catalog}
connections={[]}
onConnect={() => {}}
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(
<IntegrationsPanel
catalog={catalog}
connections={[]}
onConnect={onConnect}
onDisconnect={() => {}}
/>,
);
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(
<IntegrationsPanel
catalog={catalog}
connections={live}
onConnect={() => {}}
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(
<IntegrationsPanel
catalog={catalog}
connections={[
{
id: "conn_x",
providerId: "google",
connectorId: "gmail",
status: "revoked",
},
]}
onConnect={() => {}}
onDisconnect={() => {}}
/>,
);
expect(screen.getByTestId("connect-google")).toBeInTheDocument();
expect(screen.queryByTestId("disconnect-google")).toBeNull();
});

it("surfaces an error message when error is set", () => {
render(
<IntegrationsPanel
catalog={[]}
connections={[]}
error={new Error("boom")}
onConnect={() => {}}
onDisconnect={() => {}}
/>,
);
expect(
screen.getByText(/Failed to load integrations: boom/),
).toBeInTheDocument();
});

it("shows the empty state when the catalog is empty and not loading", () => {
render(
<IntegrationsPanel
catalog={[]}
connections={[]}
emptyCatalogLabel="Nothing here yet"
onConnect={() => {}}
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(
<IntegrationsPanel
catalog={catalog}
connections={live}
healthByConnectionId={{ c1: { connectionId: "c1", status: "degraded" } }}
onConnect={() => {}}
onDisconnect={() => {}}
/>,
);
expect(screen.getByText("degraded")).toBeInTheDocument();
});
});
Loading
Loading