diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/helpers.test.ts b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/helpers.test.ts
new file mode 100644
index 0000000000..b254139ca2
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/helpers.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "vitest";
+
+import { LinkType } from "@/app/api/__generated__/models/linkType";
+import {
+ getLoginRedirect,
+ getPlatformDisplayName,
+ isUserLink,
+ TOKEN_PATTERN,
+} from "../helpers";
+
+describe("platform linking helpers", () => {
+ test("accepts URL-safe token values up to the backend limit", () => {
+ expect(TOKEN_PATTERN.test("abc_123-XYZ")).toBe(true);
+ expect(TOKEN_PATTERN.test("a".repeat(64))).toBe(true);
+ });
+
+ test("rejects malformed token values", () => {
+ expect(TOKEN_PATTERN.test("")).toBe(false);
+ expect(TOKEN_PATTERN.test("a".repeat(65))).toBe(false);
+ expect(TOKEN_PATTERN.test("abc.123")).toBe(false);
+ expect(TOKEN_PATTERN.test("abc/123")).toBe(false);
+ });
+
+ test("formats known platforms and falls back for unknown values", () => {
+ expect(getPlatformDisplayName("discord")).toBe("Discord");
+ expect(getPlatformDisplayName("GITHUB")).toBe("GitHub");
+ expect(getPlatformDisplayName("matrix")).toBe("matrix");
+ expect(getPlatformDisplayName(null)).toBe("chat platform");
+ });
+
+ test("builds the login redirect for valid and missing tokens", () => {
+ expect(getLoginRedirect("token-123")).toBe(
+ "/login?next=%2Flink%2Ftoken-123",
+ );
+ expect(getLoginRedirect(null)).toBe("/login?next=%2F");
+ });
+
+ test("identifies user link token types", () => {
+ expect(isUserLink(LinkType.USER)).toBe(true);
+ expect(isUserLink(LinkType.SERVER)).toBe(false);
+ expect(isUserLink(undefined)).toBe(false);
+ });
+});
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/page.test.tsx b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/page.test.tsx
new file mode 100644
index 0000000000..c4f36c4fa1
--- /dev/null
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/__tests__/page.test.tsx
@@ -0,0 +1,273 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { LinkType } from "@/app/api/__generated__/models/linkType";
+import {
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200,
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler401,
+ getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler200,
+ getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler401,
+ getPostPlatformLinkingConfirmAUserLinkTokenUserMustBeAuthenticatedMockHandler200,
+} from "@/app/api/__generated__/endpoints/platform-linking/platform-linking.msw";
+import { server } from "@/mocks/mock-server";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@/tests/integrations/test-utils";
+import PlatformLinkPage from "../page";
+
+const mockUseParams = vi.hoisted(() => vi.fn());
+const mockUseSearchParams = vi.hoisted(() => vi.fn());
+const mockUseSupabase = vi.hoisted(() => vi.fn());
+const mockLogOut = vi.hoisted(() => vi.fn());
+
+vi.mock("next/navigation", () => ({
+ useParams: mockUseParams,
+ usePathname: () => "/link/token-123",
+ useRouter: () => ({
+ back: vi.fn(),
+ forward: vi.fn(),
+ prefetch: vi.fn(),
+ push: vi.fn(),
+ refresh: vi.fn(),
+ replace: vi.fn(),
+ }),
+ useSearchParams: mockUseSearchParams,
+}));
+
+vi.mock("@/lib/supabase/hooks/useSupabase", () => ({
+ useSupabase: mockUseSupabase,
+}));
+
+function authenticate() {
+ mockUseSupabase.mockReturnValue({
+ user: {
+ id: "user-1",
+ email: "owner@example.com",
+ app_metadata: {},
+ user_metadata: {},
+ aud: "authenticated",
+ created_at: "2026-01-01T00:00:00.000Z",
+ },
+ isLoggedIn: true,
+ isUserLoading: false,
+ logOut: mockLogOut,
+ supabase: {},
+ });
+}
+
+function setRoute(token = "token-123", platform = "discord") {
+ mockUseParams.mockReturnValue({ token });
+ mockUseSearchParams.mockReturnValue(new URLSearchParams({ platform }));
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ authenticate();
+ setRoute();
+ Object.defineProperty(window, "location", {
+ configurable: true,
+ value: { href: "http://localhost/link/token-123" },
+ });
+});
+
+describe("PlatformLinkPage", () => {
+ test("shows a malformed-token error without fetching link info", () => {
+ const infoHandler = vi.fn();
+ setRoute("bad.token");
+ server.use(
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200(() => {
+ infoHandler();
+ return {
+ platform: "DISCORD",
+ link_type: LinkType.SERVER,
+ server_name: "Guild",
+ };
+ }),
+ );
+
+ render();
+
+ expect(screen.getByText(/setup link is malformed/i)).toBeDefined();
+ expect(infoHandler).not.toHaveBeenCalled();
+ });
+
+ test("asks unauthenticated users to sign in without fetching link info", () => {
+ const infoHandler = vi.fn();
+ mockUseSupabase.mockReturnValue({
+ user: null,
+ isLoggedIn: false,
+ isUserLoading: false,
+ logOut: mockLogOut,
+ supabase: {},
+ });
+ server.use(
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200(() => {
+ infoHandler();
+ return {
+ platform: "DISCORD",
+ link_type: LinkType.SERVER,
+ server_name: "Guild",
+ };
+ }),
+ );
+
+ render();
+
+ expect(
+ screen.getByRole("heading", { name: /sign in to continue/i }),
+ ).toBeDefined();
+ expect(
+ screen.getByRole("link", { name: /^sign in$/i }).getAttribute("href"),
+ ).toBe("/login?next=%2Flink%2Ftoken-123");
+ expect(infoHandler).not.toHaveBeenCalled();
+ });
+
+ test("loads server link details and confirms the server link", async () => {
+ server.use(
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200({
+ platform: "DISCORD",
+ link_type: LinkType.SERVER,
+ server_name: "Builders Guild",
+ }),
+ getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler200(
+ {
+ success: true,
+ link_type: LinkType.SERVER,
+ platform: "DISCORD",
+ platform_server_id: "server-1",
+ server_name: "Builders Guild",
+ },
+ ),
+ );
+
+ render();
+
+ expect(
+ await screen.findByRole("heading", {
+ name: /set up autopilot for builders guild/i,
+ }),
+ ).toBeDefined();
+ expect(screen.getByText(/signed in as owner@example.com/i)).toBeDefined();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /connect discord to autogpt/i }),
+ );
+
+ expect(
+ await screen.findByRole("heading", { name: /autopilot is ready/i }),
+ ).toBeDefined();
+ expect(screen.getByText(/builders guild/i)).toBeDefined();
+ });
+
+ test("loads user link details and confirms the user link endpoint", async () => {
+ let serverConfirmCalls = 0;
+ let userConfirmCalls = 0;
+ server.use(
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200({
+ platform: "TELEGRAM",
+ link_type: LinkType.USER,
+ server_name: null,
+ }),
+ getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler200(
+ () => {
+ serverConfirmCalls += 1;
+ return {
+ success: true,
+ link_type: LinkType.SERVER,
+ platform: "TELEGRAM",
+ platform_server_id: "server-1",
+ server_name: null,
+ };
+ },
+ ),
+ getPostPlatformLinkingConfirmAUserLinkTokenUserMustBeAuthenticatedMockHandler200(
+ () => {
+ userConfirmCalls += 1;
+ return {
+ success: true,
+ link_type: LinkType.USER,
+ platform: "TELEGRAM",
+ platform_user_id: "platform-user-1",
+ };
+ },
+ ),
+ );
+
+ render();
+
+ expect(
+ await screen.findByRole("heading", { name: /link your telegram dms/i }),
+ ).toBeDefined();
+
+ fireEvent.click(
+ screen.getByRole("button", { name: /connect my telegram dms/i }),
+ );
+
+ expect(
+ await screen.findByRole("heading", { name: /autopilot is ready/i }),
+ ).toBeDefined();
+ expect(userConfirmCalls).toBe(1);
+ expect(serverConfirmCalls).toBe(0);
+ });
+
+ test("shows an expired-link message when info loading fails", async () => {
+ server.use(
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler401(),
+ );
+
+ render();
+
+ expect(
+ await screen.findByText(/couldn't load setup details/i),
+ ).toBeDefined();
+ });
+
+ test("shows backend detail when confirmation fails", async () => {
+ server.use(
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200({
+ platform: "DISCORD",
+ link_type: LinkType.SERVER,
+ server_name: "Builders Guild",
+ }),
+ getPostPlatformLinkingConfirmAServerLinkTokenUserMustBeAuthenticatedMockHandler401(
+ {
+ detail: "This setup link was already used.",
+ },
+ ),
+ );
+
+ render();
+
+ fireEvent.click(
+ await screen.findByRole("button", {
+ name: /connect discord to autogpt/i,
+ }),
+ );
+
+ expect(
+ await screen.findByText(/this setup link was already used/i),
+ ).toBeDefined();
+ });
+
+ test("signs out and redirects back through login when switching account", async () => {
+ mockLogOut.mockResolvedValue(undefined);
+ server.use(
+ getGetPlatformLinkingGetDisplayInfoForALinkTokenMockHandler200({
+ platform: "DISCORD",
+ link_type: LinkType.SERVER,
+ server_name: "Builders Guild",
+ }),
+ );
+
+ render();
+
+ fireEvent.click(await screen.findByRole("button", { name: /not you/i }));
+
+ await waitFor(() => {
+ expect(mockLogOut).toHaveBeenCalledTimes(1);
+ expect(window.location.href).toBe("/login?next=%2Flink%2Ftoken-123");
+ });
+ });
+});
diff --git a/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/usePlatformLinkingPage.ts b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/usePlatformLinkingPage.ts
index 32d83a11bc..af78efc99a 100644
--- a/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/usePlatformLinkingPage.ts
+++ b/autogpt_platform/frontend/src/app/(no-navbar)/link/[token]/usePlatformLinkingPage.ts
@@ -43,15 +43,7 @@ export function usePlatformLinkingPage() {
isError: isInfoError,
} = useGetPlatformLinkingGetDisplayInfoForALinkToken(token ?? "", {
query: {
- // Endpoint requires auth — gate the query on a logged-in user so we
- // don't fire a guaranteed-401 request before the user signs in.
enabled: Boolean(token) && Boolean(user),
- // Narrow the discriminated response union so `info` is typed as the
- // success payload (LinkTokenInfoResponse) rather than the 200 ∪ 401 ∪
- // 422 union. The literal `=== 200` check is what lets TS narrow the
- // discriminated union — `okData` doesn't distribute correctly across
- // multiple error-shape variants, and a 2xx range check (`>=200 && <300`)
- // wouldn't narrow at all. GET-only success here is 200.
select: (res) => (res && res.status === 200 ? res.data : undefined),
retry: false,
},
@@ -82,10 +74,6 @@ export function usePlatformLinkingPage() {
function handleLink() {
if (!token || !info) return;
- // Fire-and-forget: React Query surfaces the outcome via
- // `mutation.isPending | isSuccess | isError`, which `resolveStatus`
- // consumes. Using `mutate` over `mutateAsync` avoids the unhandled
- // promise rejection that would otherwise fire on API failure.
mutation.mutate({ token });
}
@@ -190,7 +178,22 @@ const DEFAULT_MUTATION_ERROR =
function extractDetail(error: unknown): string | undefined {
if (!error || typeof error !== "object") return undefined;
- const maybeData = (error as { data?: { detail?: unknown } }).data;
- const detail = maybeData?.detail;
+ const responseDetail = extractDetailFromPayload(
+ (error as { response?: unknown }).response,
+ );
+ if (responseDetail) return responseDetail;
+
+ const dataDetail = extractDetailFromPayload(
+ (error as { data?: unknown }).data,
+ );
+ if (dataDetail) return dataDetail;
+
+ const message = (error as { message?: unknown }).message;
+ return typeof message === "string" ? message : undefined;
+}
+
+function extractDetailFromPayload(payload: unknown): string | undefined {
+ if (!payload || typeof payload !== "object") return undefined;
+ const detail = (payload as { detail?: unknown }).detail;
return typeof detail === "string" ? detail : undefined;
}