mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: add VS Code iframe embed auth bootstrap (#23060)
## VS Code iframe embed auth via postMessage + setSessionToken Adds embed auth for VS Code iframe integration, allowing the Coder agent chat UI to be embedded in VS Code webviews without manual login — using direct header auth instead of cookies. ### How it works 1. **Parent frame** (VS Code webview) loads an iframe pointing to `/agents/:agentId/embed` 2. **Embed page** detects the user is signed out and posts `coder:vscode-ready` to the parent 3. **Parent** responds with `coder:vscode-auth-bootstrap` containing the user's Coder API token 4. **Embed page** calls `API.setSessionToken(token)` to set the `Coder-Session-Token` header on all subsequent axios requests 5. **Embed page** fetches user + permissions, sets them in the React Query cache atomically, and renders the authenticated agent chat UI No cookies, no CSRF, no backend endpoint needed. The token is passed via postMessage and used as a header on every API request. ### What changed **Frontend** (`site/src/pages/AgentsPage/`): - `AgentEmbedPage.tsx` — added postMessage bootstrap directly in the embed page: listens for `coder:vscode-auth-bootstrap`, calls `API.setSessionToken(token)`, fetches user/permissions atomically to avoid race conditions - `EmbedContext.tsx` — React context signaling embed mode (from previous commit, unchanged) - `AgentDetail/TopBar.tsx` — conditionally hides navigation elements in embed mode (from previous commit, unchanged) - Both `/agents/:agentId/embed` and `/agents/:agentId/embed/session` routes live outside `RequireAuth` **Auth bootstrap** (`site/src/api/queries/users.ts`): - `bootstrapChatEmbedSessionFn` now calls `API.setSessionToken(token)` instead of posting to a backend endpoint - Fetches user and permissions directly via `API.getAuthenticatedUser()` and `API.checkAuthorization()`, then sets both in the query cache atomically — this avoids a race where `isSignedIn` flips before permissions are loaded **Removed** (no longer needed): - `coderd/embedauth.go` — the `POST /api/experimental/chats/embed-session` handler - `coderd/embedauth_test.go` — backend tests for the endpoint - `codersdk/embedauth.go` — `EmbedSessionTokenRequest` SDK type - `site/src/api/api.ts` — `postChatEmbedSession` method - `docs/user-guides/workspace-access/vscode-embed-auth.md` — doc page for the old cookie flow - Swagger/API doc entries for the endpoint ### Why not cookies? The initial implementation used a backend endpoint to set an HttpOnly session cookie. This required `SameSite=None; Secure` for cross-origin iframes, which doesn't work over HTTP in development (Chrome requires HTTPS for `Secure` cookies). The `setSessionToken` approach bypasses cookies entirely — the token is set as an axios default header, and header-based auth also naturally bypasses CSRF protection. ### Dogfooding Tested end-to-end with a VS Code extension that: 1. Registers a `/openChat` deep link handler (`vscode://coder.coder-remote/openChat?url=...&token=...&agentId=...`) 2. Starts a local HTTP reverse proxy (to work around VS Code webview iframe sandboxing) 3. Loads `/agents/:agentId/embed` in an iframe through the proxy 4. Relays the postMessage handshake between the iframe and the extension host 5. The embed page receives the token, calls `setSessionToken`, and renders the chat Verified: chat title, messages, and input field all display correctly in VS Code's secondary sidebar panel.
This commit is contained in:
@@ -179,7 +179,7 @@ export const login = (
|
||||
mutationFn: async (credentials: { email: string; password: string }) =>
|
||||
loginFn({ ...credentials, authorization }),
|
||||
onSuccess: async (data: Awaited<ReturnType<typeof loginFn>>) => {
|
||||
queryClient.setQueryData(["me"], data.user);
|
||||
queryClient.setQueryData(meKey, data.user);
|
||||
queryClient.setQueryData(
|
||||
getAuthorizationKey(authorization),
|
||||
data.permissions,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import type { FC } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useEmbedContext } from "../EmbedContext";
|
||||
|
||||
interface SidebarPanelState {
|
||||
showSidebarPanel: boolean;
|
||||
@@ -70,19 +71,22 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
|
||||
onToggleSidebarCollapsed,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { isEmbedded } = useEmbedContext();
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 px-4 py-1.5">
|
||||
{/* Mobile back button */}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={() => navigate("/agents")}
|
||||
aria-label="Back"
|
||||
className="inline-flex h-7 w-7 min-w-0 shrink-0 md:hidden"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
{!isEmbedded && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={() => navigate("/agents")}
|
||||
aria-label="Back"
|
||||
className="inline-flex h-7 w-7 min-w-0 shrink-0 md:hidden"
|
||||
>
|
||||
<ArrowLeftIcon />
|
||||
</Button>
|
||||
)}
|
||||
{/* Desktop expand button: visible when sidebar is manually collapsed. */}
|
||||
{isSidebarCollapsed && (
|
||||
<Button
|
||||
@@ -120,108 +124,112 @@ export const AgentDetailTopBar: FC<AgentDetailTopBarProps> = ({
|
||||
</div>
|
||||
{/* Actions area */}
|
||||
<div className="flex items-center gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
aria-label="Open agent actions"
|
||||
>
|
||||
<EllipsisIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.canOpenEditors}
|
||||
onSelect={() => {
|
||||
workspace.onOpenInEditor("cursor");
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
Open in Cursor
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.canOpenEditors}
|
||||
onSelect={() => {
|
||||
workspace.onOpenInEditor("vscode");
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
Open in VS Code
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
// You can think of the web terminal as an editor if you squint.
|
||||
disabled={!workspace.canOpenEditors}
|
||||
onSelect={workspace.onOpenTerminal}
|
||||
>
|
||||
<TerminalIcon className="h-3.5 w-3.5" />
|
||||
Open Terminal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.sshCommand}
|
||||
onSelect={async () => {
|
||||
if (!workspace.sshCommand) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(workspace.sshCommand);
|
||||
toast.success("SSH command copied to clipboard");
|
||||
} catch {
|
||||
toast.error("Failed to copy SSH command");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-3.5 w-3.5" />
|
||||
Copy SSH Command
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.canOpenWorkspace}
|
||||
onSelect={workspace.onViewWorkspace}
|
||||
>
|
||||
<MonitorIcon className="h-3.5 w-3.5" />
|
||||
View Workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{isArchived ? (
|
||||
<DropdownMenuItem onSelect={onUnarchiveAgent}>
|
||||
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
|
||||
Unarchive Agent
|
||||
{!isEmbedded && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
aria-label="Open agent actions"
|
||||
>
|
||||
<EllipsisIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.canOpenEditors}
|
||||
onSelect={() => {
|
||||
workspace.onOpenInEditor("cursor");
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
Open in Cursor
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onSelect={onArchiveAgent}
|
||||
>
|
||||
<ArchiveIcon className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.canOpenEditors}
|
||||
onSelect={() => {
|
||||
workspace.onOpenInEditor("vscode");
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="h-3.5 w-3.5" />
|
||||
Open in VS Code
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
// You can think of the web terminal as an editor if you squint.
|
||||
disabled={!workspace.canOpenEditors}
|
||||
onSelect={workspace.onOpenTerminal}
|
||||
>
|
||||
<TerminalIcon className="h-3.5 w-3.5" />
|
||||
Open Terminal
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.sshCommand}
|
||||
onSelect={async () => {
|
||||
if (!workspace.sshCommand) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(workspace.sshCommand);
|
||||
toast.success("SSH command copied to clipboard");
|
||||
} catch {
|
||||
toast.error("Failed to copy SSH command");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-3.5 w-3.5" />
|
||||
Copy SSH Command
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
disabled={!workspace.canOpenWorkspace}
|
||||
onSelect={workspace.onViewWorkspace}
|
||||
>
|
||||
<MonitorIcon className="h-3.5 w-3.5" />
|
||||
View Workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{isArchived ? (
|
||||
<DropdownMenuItem onSelect={onUnarchiveAgent}>
|
||||
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
|
||||
Unarchive Agent
|
||||
</DropdownMenuItem>
|
||||
{hasWorkspace && (
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onSelect={onArchiveAndDeleteWorkspace}
|
||||
onSelect={onArchiveAgent}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
Archive & Delete Workspace
|
||||
<ArchiveIcon className="h-3.5 w-3.5" />
|
||||
Archive Agent
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
{hasWorkspace && (
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onSelect={onArchiveAndDeleteWorkspace}
|
||||
>
|
||||
<Trash2Icon className="h-3.5 w-3.5" />
|
||||
Archive & Delete Workspace
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{!isEmbedded && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={panel.onToggleSidebar}
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
aria-label="Toggle panel"
|
||||
>
|
||||
{panel.showSidebarPanel ? (
|
||||
<PanelRightCloseIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpenIcon className="h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
onClick={panel.onToggleSidebar}
|
||||
className="h-7 w-7 text-content-secondary hover:text-content-primary"
|
||||
aria-label="Toggle panel"
|
||||
>
|
||||
{panel.showSidebarPanel ? (
|
||||
<PanelRightCloseIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpenIcon className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { Button } from "components/Button/Button";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { useAuthContext } from "contexts/auth/AuthProvider";
|
||||
import { ProxyProvider } from "contexts/ProxyContext";
|
||||
import { DashboardProvider } from "modules/dashboard/DashboardProvider";
|
||||
import { permissionChecks } from "modules/permissions";
|
||||
import {
|
||||
type FC,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import type { AgentsOutletContext } from "./AgentsPage";
|
||||
import { bootstrapChatEmbedSession, EmbedProvider } from "./EmbedContext";
|
||||
|
||||
type BootstrapMessage = {
|
||||
type: "coder:vscode-auth-bootstrap";
|
||||
payload: {
|
||||
token: string;
|
||||
};
|
||||
};
|
||||
|
||||
const getBootstrapToken = (data: unknown): string | undefined => {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const message = data as Partial<BootstrapMessage>;
|
||||
if (message.type !== "coder:vscode-auth-bootstrap") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof message.payload !== "object" || message.payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payload = message.payload as { token?: unknown };
|
||||
if (typeof payload.token !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const token = payload.token.trim();
|
||||
return token.length > 0 ? token : undefined;
|
||||
};
|
||||
|
||||
const AgentEmbedPage: FC = () => {
|
||||
const { agentId } = useParams<{ agentId: string }>();
|
||||
if (!agentId) {
|
||||
throw new Error("AgentEmbedPage requires an agentId route parameter.");
|
||||
}
|
||||
|
||||
const auth = useAuthContext();
|
||||
const queryClient = useQueryClient();
|
||||
const embedSessionMutation = useMutation(
|
||||
bootstrapChatEmbedSession({ checks: permissionChecks }, queryClient),
|
||||
);
|
||||
const latestEmbedSessionMutationRef = useRef(embedSessionMutation);
|
||||
latestEmbedSessionMutationRef.current = embedSessionMutation;
|
||||
const inFlightBootstrapRef = useRef<Promise<unknown> | null>(null);
|
||||
|
||||
const [chatErrorReasons, setChatErrorReasons] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
|
||||
|
||||
const setChatErrorReason = useCallback((chatId: string, reason: string) => {
|
||||
const trimmedReason = reason.trim();
|
||||
if (!chatId || !trimmedReason) {
|
||||
return;
|
||||
}
|
||||
setChatErrorReasons((current) => {
|
||||
if (current[chatId] === trimmedReason) {
|
||||
return current;
|
||||
}
|
||||
return {
|
||||
...current,
|
||||
[chatId]: trimmedReason,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearChatErrorReason = useCallback((chatId: string) => {
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
setChatErrorReasons((current) => {
|
||||
if (!(chatId in current)) {
|
||||
return current;
|
||||
}
|
||||
const next = { ...current };
|
||||
delete next[chatId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const requestArchiveAgent = useCallback((_chatId: string) => {}, []);
|
||||
|
||||
const requestUnarchiveAgent = useCallback((_chatId: string) => {}, []);
|
||||
|
||||
const requestArchiveAndDeleteWorkspace = useCallback(
|
||||
(_chatId: string, _workspaceId: string) => {},
|
||||
[],
|
||||
);
|
||||
|
||||
const onToggleSidebarCollapsed = useCallback(() => {
|
||||
setIsSidebarCollapsed((current) => !current);
|
||||
}, []);
|
||||
|
||||
const outletContext = useMemo<AgentsOutletContext>(
|
||||
() => ({
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
requestArchiveAgent,
|
||||
requestUnarchiveAgent,
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
}),
|
||||
[
|
||||
chatErrorReasons,
|
||||
setChatErrorReason,
|
||||
clearChatErrorReason,
|
||||
requestArchiveAgent,
|
||||
requestUnarchiveAgent,
|
||||
requestArchiveAndDeleteWorkspace,
|
||||
isSidebarCollapsed,
|
||||
onToggleSidebarCollapsed,
|
||||
],
|
||||
);
|
||||
|
||||
// When signed out and not already bootstrapping, listen for the
|
||||
// postMessage from the parent frame carrying the session token.
|
||||
const isAwaitingBootstrapMessage =
|
||||
auth.isSignedOut &&
|
||||
!embedSessionMutation.isPending &&
|
||||
!embedSessionMutation.isError;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAwaitingBootstrapMessage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentWindow = window.parent;
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== parentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const token = getBootstrapToken(event.data);
|
||||
if (!token || inFlightBootstrapRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bootstrapPromise = latestEmbedSessionMutationRef.current
|
||||
.mutateAsync(token)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
inFlightBootstrapRef.current = null;
|
||||
});
|
||||
inFlightBootstrapRef.current = bootstrapPromise;
|
||||
};
|
||||
|
||||
// Register the listener before notifying the parent so an
|
||||
// immediate bootstrap response is never missed.
|
||||
window.addEventListener("message", handleMessage);
|
||||
parentWindow.postMessage(
|
||||
{ type: "coder:vscode-ready", payload: { agentId } },
|
||||
"*",
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage);
|
||||
};
|
||||
}, [agentId, isAwaitingBootstrapMessage]);
|
||||
|
||||
const handleBootstrapRetry = useCallback(() => {
|
||||
inFlightBootstrapRef.current = null;
|
||||
embedSessionMutation.reset();
|
||||
}, [embedSessionMutation]);
|
||||
|
||||
if (auth.isSignedIn) {
|
||||
return (
|
||||
<EmbedProvider value={{ isEmbedded: true }}>
|
||||
<DashboardProvider>
|
||||
<ProxyProvider>
|
||||
<Outlet context={outletContext} />
|
||||
</ProxyProvider>
|
||||
</DashboardProvider>
|
||||
</EmbedProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (embedSessionMutation.isError) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface-primary px-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold text-content-primary">
|
||||
Unable to start embedded agent.
|
||||
</h1>
|
||||
<p className="max-w-md text-sm text-content-secondary">
|
||||
{getErrorMessage(
|
||||
embedSessionMutation.error,
|
||||
"We couldn't exchange the VS Code bootstrap token for a session.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleBootstrapRetry}>Try again</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (embedSessionMutation.isPending) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface-primary px-6 text-center">
|
||||
<Loader label="Signing in to embedded agent" />
|
||||
<p className="max-w-md text-sm text-content-secondary">
|
||||
Signing in to the embedded agent…
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Either auth is loading or we're waiting for the bootstrap
|
||||
// postMessage from the parent frame.
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-surface-primary px-6 text-center">
|
||||
<Loader label="Waiting for VS Code authentication" />
|
||||
<p className="max-w-md text-sm text-content-secondary">
|
||||
{auth.isLoading ? "Loading…" : "Waiting for VS Code authentication…"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentEmbedPage;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { API } from "api/api";
|
||||
import { getAuthorizationKey } from "api/queries/authCheck";
|
||||
import { meKey } from "api/queries/users";
|
||||
import type { AuthorizationRequest } from "api/typesGenerated";
|
||||
import { createContext, useContext } from "react";
|
||||
import type { QueryClient } from "react-query";
|
||||
|
||||
interface EmbedContextValue {
|
||||
isEmbedded: boolean;
|
||||
}
|
||||
|
||||
const EmbedContext = createContext<EmbedContextValue>({
|
||||
isEmbedded: false,
|
||||
});
|
||||
|
||||
export const EmbedProvider = EmbedContext.Provider;
|
||||
|
||||
export const useEmbedContext = () => useContext(EmbedContext);
|
||||
|
||||
export const bootstrapChatEmbedSession = (
|
||||
authorization: AuthorizationRequest,
|
||||
queryClient: QueryClient,
|
||||
) => {
|
||||
return {
|
||||
mutationFn: async (token: string) =>
|
||||
bootstrapChatEmbedSessionFn({
|
||||
token,
|
||||
authorization,
|
||||
queryClient,
|
||||
}),
|
||||
onSuccess: (
|
||||
data: Awaited<ReturnType<typeof bootstrapChatEmbedSessionFn>>,
|
||||
) => {
|
||||
queryClient.setQueryData(meKey, data.user);
|
||||
queryClient.setQueryData(
|
||||
getAuthorizationKey(authorization),
|
||||
data.permissions,
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const bootstrapChatEmbedSessionFn = async ({
|
||||
token,
|
||||
authorization,
|
||||
queryClient,
|
||||
}: {
|
||||
token: string;
|
||||
authorization: AuthorizationRequest;
|
||||
queryClient: QueryClient;
|
||||
}) => {
|
||||
API.setSessionToken(token);
|
||||
// Fetch user and permissions first, then set them in the cache
|
||||
// atomically. This avoids a race where invalidating the "me"
|
||||
// query causes isSignedIn to flip before permissions are ready.
|
||||
const [user, permissions] = await Promise.all([
|
||||
API.getAuthenticatedUser(),
|
||||
API.checkAuthorization(authorization),
|
||||
]);
|
||||
queryClient.setQueryData(meKey, user);
|
||||
queryClient.setQueryData(getAuthorizationKey(authorization), permissions);
|
||||
return {
|
||||
user,
|
||||
permissions,
|
||||
};
|
||||
};
|
||||
@@ -348,6 +348,7 @@ const ProvisionerJobsPage = lazy(
|
||||
);
|
||||
const AgentsPage = lazy(() => import("./pages/AgentsPage/AgentsPage"));
|
||||
const AgentDetail = lazy(() => import("./pages/AgentsPage/AgentDetail"));
|
||||
const AgentEmbedPage = lazy(() => import("./pages/AgentsPage/AgentEmbedPage"));
|
||||
|
||||
import {
|
||||
AgentDetailSkeleton,
|
||||
@@ -654,6 +655,24 @@ export const router = createBrowserRouter(
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="/agents/:agentId/embed"
|
||||
element={
|
||||
<Suspense fallback={<AgentDetailSkeleton />}>
|
||||
<AgentEmbedPage />
|
||||
</Suspense>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
index
|
||||
element={
|
||||
<Suspense fallback={<AgentDetailSkeleton />}>
|
||||
<AgentDetail />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Route>,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user