+
+
+
+ );
+}
+
+// MinimalShell renders a centered container without sidebar, websocket,
+// store-sync, or any dashboard-config fetches. Used for routes that opt
+// in via `staticData.tempTokenScoped` — typically public, scoped pages
+// like the MCP per-user OAuth auth page.
+function MinimalShell({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {children}
+
+
+ );
}
-function FullPage({ config, children }: { config: BifrostConfig | undefined; children: React.ReactNode }) {
- const pathname = useLocation({ select: (l) => l.pathname });
- if (config && config.is_db_connected) {
- return children;
- }
- if (config && config.is_logs_connected && pathname.startsWith("/workspace/logs")) {
- return children;
- }
- return ;
+function FullPage({
+ config,
+ children,
+}: {
+ config: BifrostConfig | undefined;
+ children: React.ReactNode;
+}) {
+ const pathname = useLocation({ select: (l) => l.pathname });
+ if (config && config.is_db_connected) {
+ return children;
+ }
+ if (
+ config &&
+ config.is_logs_connected &&
+ pathname.startsWith("/workspace/logs")
+ ) {
+ return children;
+ }
+ return ;
}
export function ClientLayout({ children }: { children: React.ReactNode }) {
- return (
-
-
-
-
-
-
- {children}
- {process.env.NODE_ENV === "development" && !process.env.BIFROST_DISABLE_PROFILER && }
-
-
-
-
-
- );
-}
\ No newline at end of file
+ return (
+
+
+
+
+
+
+ {children}
+ {process.env.NODE_ENV === "development" &&
+ !process.env.BIFROST_DISABLE_PROFILER && }
+
+
+
+
+
+ );
+}
diff --git a/ui/app/workspace/mcp-sessions/auth-failed/layout.tsx b/ui/app/workspace/mcp-sessions/auth-failed/layout.tsx
new file mode 100644
index 0000000000..7eb1149425
--- /dev/null
+++ b/ui/app/workspace/mcp-sessions/auth-failed/layout.tsx
@@ -0,0 +1,11 @@
+import { createFileRoute } from "@tanstack/react-router";
+import MCPSessionsAuthFailedPage from "./page";
+
+// Public landing for per-user OAuth callback failures. Symmetric to auth-success:
+// the anonymous (temp-token) branch of the callback handler redirects here when
+// upstream denied the request or the token exchange failed. publicShell makes
+// it MinimalShell-only with no API calls — works without a dashboard cookie.
+export const Route = createFileRoute("/workspace/mcp-sessions/auth-failed")({
+ staticData: { publicShell: true },
+ component: MCPSessionsAuthFailedPage,
+});
diff --git a/ui/app/workspace/mcp-sessions/auth-failed/page.tsx b/ui/app/workspace/mcp-sessions/auth-failed/page.tsx
new file mode 100644
index 0000000000..1b45b64f6a
--- /dev/null
+++ b/ui/app/workspace/mcp-sessions/auth-failed/page.tsx
@@ -0,0 +1,25 @@
+import { AlertCircle } from "lucide-react";
+import { useQueryState } from "nuqs";
+
+export default function MCPSessionsAuthFailedPage() {
+ const [error] = useQueryState("error");
+ return (
+
+
+
+
+
+
+ Authentication failed
+
+
+ {error ?? "We couldn't complete the authentication flow."}
+
+
+ You can close this tab and retry the original request from your MCP
+ client to generate a fresh authentication link.
+
+
+
+ );
+}
diff --git a/ui/app/workspace/mcp-sessions/auth-success/layout.tsx b/ui/app/workspace/mcp-sessions/auth-success/layout.tsx
new file mode 100644
index 0000000000..a21fe91d2b
--- /dev/null
+++ b/ui/app/workspace/mcp-sessions/auth-success/layout.tsx
@@ -0,0 +1,13 @@
+import { createFileRoute } from "@tanstack/react-router";
+import MCPSessionsAuthSuccessPage from "./page";
+
+// Landing page shown after a per-user OAuth callback completes successfully.
+// `publicShell` tells ClientLayout to render the MinimalShell unconditionally
+// — the post-OAuth redirect arrives with no fragment and no dashboard cookie
+// (temp-token visitors authenticated externally, not against Bifrost), so the
+// normal tempTokenScoped logic wouldn't fire. This flag short-circuits all of
+// that: no chrome, no auth probe, no API calls, just a static "done" view.
+export const Route = createFileRoute("/workspace/mcp-sessions/auth-success")({
+ staticData: { publicShell: true },
+ component: MCPSessionsAuthSuccessPage,
+});
diff --git a/ui/app/workspace/mcp-sessions/auth-success/page.tsx b/ui/app/workspace/mcp-sessions/auth-success/page.tsx
new file mode 100644
index 0000000000..24381e3d5d
--- /dev/null
+++ b/ui/app/workspace/mcp-sessions/auth-success/page.tsx
@@ -0,0 +1,21 @@
+import { CheckCircle2 } from "lucide-react";
+
+export default function MCPSessionsAuthSuccessPage() {
+ return (
+
+
+
+
+
+
+ Authentication successful
+
+
+ Your credential has been stored. You can close this tab and return to
+ your MCP client — future requests will use this credential
+ automatically.
+
+
+
+ );
+}
diff --git a/ui/app/workspace/mcp-sessions/auth/layout.tsx b/ui/app/workspace/mcp-sessions/auth/layout.tsx
index e22a07d441..d05e5c40b0 100644
--- a/ui/app/workspace/mcp-sessions/auth/layout.tsx
+++ b/ui/app/workspace/mcp-sessions/auth/layout.tsx
@@ -1,14 +1,25 @@
+import TempTokenScope from "@/components/tempTokenScope";
import { createFileRoute } from "@tanstack/react-router";
import MCPSessionsAuthPage from "./page";
+// staticData.tempTokenScoped opts this route out of the dashboard chrome —
+// ClientLayout renders a minimal shell and skips the protected
+// useGetCoreConfigQuery fetch when this flag is set, so an unauthenticated
+// browser can land on this page without bouncing to /login.
+//
+// TempTokenScope handles the auth half: it reads the `#t=…` fragment the
+// server appended to the URL, attaches it as `X-Bifrost-Temp-Token` on
+// outbound API calls, and suppresses the global 401-redirect so a stale
+// link renders an inline error instead.
function RouteComponent() {
- // Public-by-policy in OSS: the backend enforces identity match on the flow
- // row itself. We route any incoming caller to the page; if their identity
- // doesn't match the flow's, the API returns 403 and the page renders an
- // appropriate message.
- return ;
+ return (
+
+
+
+ );
}
export const Route = createFileRoute("/workspace/mcp-sessions/auth")({
- component: RouteComponent,
+ staticData: { tempTokenScoped: true },
+ component: RouteComponent,
});
diff --git a/ui/app/workspace/mcp-sessions/auth/page.tsx b/ui/app/workspace/mcp-sessions/auth/page.tsx
index 5f1a324f6e..12ef269928 100644
--- a/ui/app/workspace/mcp-sessions/auth/page.tsx
+++ b/ui/app/workspace/mcp-sessions/auth/page.tsx
@@ -12,270 +12,346 @@ import FullPageLoader from "@/components/fullPageLoader";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
-import { getErrorMessage, useGetMCPFlowDetailQuery, useStartMCPFlowMutation } from "@/lib/store";
+import {
+ getErrorMessage,
+ useGetMCPFlowDetailQuery,
+ useIsAuthEnabledQuery,
+ useStartMCPFlowMutation,
+} from "@/lib/store";
import { MCPFlowDetail } from "@/lib/types/mcpSessions";
import { Link } from "@tanstack/react-router";
-import { ExternalLink, Fingerprint, KeyRound, Loader2, ShieldCheck, UserRound } from "lucide-react";
+import {
+ ExternalLink,
+ Fingerprint,
+ KeyRound,
+ Loader2,
+ ShieldCheck,
+ UserRound,
+} from "lucide-react";
import { useQueryState } from "nuqs";
export default function MCPSessionsAuthPage() {
- const { toast } = useToast();
- const [flowId] = useQueryState("flow");
- const skip = !flowId;
- const { data: flow, isLoading, isError, error, refetch } = useGetMCPFlowDetailQuery(flowId ?? "", { skip });
- const [startFlow, { isLoading: starting }] = useStartMCPFlowMutation();
+ const { toast } = useToast();
+ const [flowId] = useQueryState("flow");
+ const skip = !flowId;
+ const {
+ data: flow,
+ isLoading,
+ isError,
+ error,
+ } = useGetMCPFlowDetailQuery(flowId ?? "", { skip });
+ const [startFlow, { isLoading: starting }] = useStartMCPFlowMutation();
- if (!flowId) {
- return (
-
-
Missing flow identifier
-
- This URL is missing the flow query parameter. Open the link from your
- inference response or the sessions tab.
-
-
-
-
-
- );
- }
+ if (!flowId) {
+ return (
+
+
Missing flow identifier
+
+ This URL is missing the{" "}
+ flow query
+ parameter. Open the link from your inference response or the sessions
+ tab.
+
+
+
+
+
+ );
+ }
- if (isLoading) {
- return ;
- }
+ if (isLoading) {
+ return ;
+ }
- if (isError || !flow) {
- const status = (error as { status?: number } | undefined)?.status;
- if (status === 401) {
- return ;
- }
- if (status === 403) {
- return (
-
-
This authentication flow isn't yours
-
- The pending flow belongs to a different identity. Ask the teammate whose VK or user identity triggered the original request
- to complete it, or trigger a new request yourself.
-
-
-
-
-
- );
- }
- if (status === 404) {
- return (
-
-
This authentication flow has expired or been completed
-
- Pending flows expire after a short window. If you still need to authenticate, trigger the original action again so a fresh
- flow is created.
-
-
-
-
-
- );
- }
- return (
-
-
Could not load this authentication flow
-
{getErrorMessage(error)}
-
- );
- }
+ if (isError || !flow) {
+ const status = (error as { status?: number } | undefined)?.status;
+ if (status === 401) {
+ return ;
+ }
+ if (status === 403) {
+ return (
+
+
+ This authentication flow isn't yours
+
+
+ The pending flow belongs to a different identity. Ask the teammate
+ whose VK or user identity triggered the original request to complete
+ it, or trigger a new request yourself.
+
+
+
+
+
+ );
+ }
+ if (status === 404) {
+ return (
+
+
+ This authentication flow has expired or been completed
+
+
+ Pending flows expire after a short window. If you still need to
+ authenticate, trigger the original action again so a fresh flow is
+ created.
+
+
+
+
+
+ );
+ }
+ return (
+
+
+ Could not load this authentication flow
+
+
+ {getErrorMessage(error)}
+
+
+ );
+ }
- // Flow row exists but isn't pending: it's already been completed, failed,
- // or expired. Don't show the "Authenticate" button since startFlow would
- // reject (BuildUpstreamAuthorizeURL rejects non-pending flows).
- if (flow.status !== "pending") {
- return ;
- }
+ // Flow row exists but isn't pending: it's already been completed, failed,
+ // or expired. Don't show the "Authenticate" button since startFlow would
+ // reject (BuildUpstreamAuthorizeURL rejects non-pending flows).
+ if (flow.status !== "pending") {
+ return ;
+ }
- const handleAuthenticate = async () => {
- try {
- const res = await startFlow(flowId).unwrap();
- window.location.href = res.authorize_url;
- } catch (err) {
- // The server-side flow could have been completed or expired in
- // another tab between the initial query and this click — startFlow
- // then rejects. Refetch so the page flips to the completed/expired
- // view instead of leaving a stale "Authenticate" CTA visible for a
- // retry that can't succeed.
- refetch();
- toast({ title: "Failed to start authentication", description: getErrorMessage(err), variant: "destructive" });
- }
- };
+ const handleAuthenticate = async () => {
+ try {
+ const res = await startFlow(flowId).unwrap();
+ window.location.href = res.authorize_url;
+ } catch (err) {
+ toast({
+ title: "Failed to start authentication",
+ description: getErrorMessage(err),
+ variant: "destructive",
+ });
+ }
+ };
- const mcpClientName = flow.mcp_client?.name || flow.mcp_client?.client_id || "MCP server";
- const isReauth = flow.has_active_token === true;
+ const mcpClientName =
+ flow.mcp_client?.name || flow.mcp_client?.client_id || "MCP server";
+ const isReauth = flow.has_active_token === true;
- return (
-
-
- {isReauth ? (
- <>
- An active credential already exists for the binding below. Completing this flow will replace it with a fresh
- credential. You can also close this tab to keep using the existing one.
- >
- ) : (
- <>
- You'll be redirected to the provider to sign in and grant access. Bifrost stores the resulting credential against the binding
- below so this request and future ones can proceed automatically.
- >
- )}
-
+ {isReauth ? (
+ <>
+ An active credential already exists for the binding below.
+ Completing this flow will replace it with a fresh
+ credential. You can also close this tab to keep using the existing
+ one.
+ >
+ ) : (
+ <>
+ You'll be redirected to the provider to sign in and grant access.
+ Bifrost stores the resulting credential against the binding below so
+ this request and future ones can proceed automatically.
+ >
+ )}
+
-
-
- } />
-
-
+
+
+ } />
+
+
-
-
-
-
-
- );
+
+
+
+
+
+ );
}
function CompletedFlowView({ flow }: { flow: MCPFlowDetail }) {
- const mcpClientName = flow.mcp_client?.name || flow.mcp_client?.client_id || "this MCP server";
- // has_active_token wins over the flow's row status: a pending flow with an
- // existing active token means OAuth was re-initiated unnecessarily.
- const effectivelyAuthorized = flow.status === "authorized" || flow.has_active_token;
- const title = effectivelyAuthorized
- ? "Already authenticated"
- : flow.status === "expired"
- ? "This authentication flow has expired"
- : "This authentication flow can no longer be completed";
- const body = effectivelyAuthorized
- ? `The OAuth credential for ${mcpClientName} is already stored. You can close this tab.`
- : "Trigger the original action again so a fresh flow is created.";
- return (
-
-
{title}
-
{body}
-
-
- } />
-
-
-
-
-
- );
+ const mcpClientName =
+ flow.mcp_client?.name || flow.mcp_client?.client_id || "this MCP server";
+ // has_active_token wins over the flow's row status: a pending flow with an
+ // existing active token means OAuth was re-initiated unnecessarily.
+ const effectivelyAuthorized =
+ flow.status === "authorized" || flow.has_active_token;
+ const title = effectivelyAuthorized
+ ? "Already authenticated"
+ : flow.status === "expired"
+ ? "This authentication flow has expired"
+ : "This authentication flow can no longer be completed";
+ const body = effectivelyAuthorized
+ ? `The OAuth credential for ${mcpClientName} is already stored. You can close this tab.`
+ : "Trigger the original action again so a fresh flow is created.";
+ return (
+
+
+ );
}
function BindingValue({ flow }: { flow: MCPFlowDetail }) {
- if (flow.flow_mode === "user") {
- const userID = flow.user_id;
- if (!userID) {
- return (
-
-
- First signed-in user
-
- );
- }
- const displayName = flow.user?.name || flow.user?.email;
- return (
-
-
- {displayName ? {displayName} : {userID}}
-
- );
- }
- if (flow.flow_mode === "vk" && flow.virtual_key) {
- return (
-
-
- {flow.virtual_key.name || flow.virtual_key.id}
-
- );
- }
- if (flow.flow_mode === "session" && flow.session_id) {
- return (
-
-
- {flow.session_id}
-
- );
- }
- return Unknown;
+ if (flow.flow_mode === "user") {
+ const userID = flow.user_id;
+ if (!userID) {
+ return (
+
+
+ First signed-in user
+
+ );
+ }
+ const displayName = flow.user?.name || flow.user?.email;
+ return (
+
+
+ {displayName ? (
+ {displayName}
+ ) : (
+ {userID}
+ )}
+
+ );
+ }
+ if (flow.flow_mode === "vk" && flow.virtual_key) {
+ return (
+
+
+ {flow.virtual_key.name || flow.virtual_key.id}
+
+ );
+ }
+ if (flow.flow_mode === "session" && flow.session_id) {
+ return (
+
+
+ {flow.session_id}
+
+ );
+ }
+ return Unknown;
}
function formatExpiry(iso: string): string {
- try {
- const t = new Date(iso).getTime();
- if (Number.isNaN(t)) return iso;
- const diffMs = t - Date.now();
- if (diffMs < 0) return "Expired";
- const mins = Math.floor(diffMs / 60_000);
- if (mins < 1) return "in less than a minute";
- if (mins === 1) return "in 1 minute";
- return `in ${mins} minutes`;
- } catch {
- return iso;
- }
+ try {
+ const t = new Date(iso).getTime();
+ if (Number.isNaN(t)) return iso;
+ const diffMs = t - Date.now();
+ if (diffMs < 0) return "Expired";
+ const mins = Math.floor(diffMs / 60_000);
+ if (mins < 1) return "in less than a minute";
+ if (mins === 1) return "in 1 minute";
+ return `in ${mins} minutes`;
+ } catch {
+ return iso;
+ }
}
function CenteredCard({ children }: { children: React.ReactNode }) {
- return (
-
-
{children}
-
- );
+ return (
+
+
+ {children}
+
+
+ );
}
-function SessionsTabLink({ variant = "outline" }: { variant?: "outline" | "ghost" }) {
- return (
-
- );
+function SessionsTabLink({
+ variant = "outline",
+}: {
+ variant?: "outline" | "ghost";
+}) {
+ // Hide the link only when the visitor has no dashboard session — for them,
+ // /workspace/mcp-sessions would 401 and bounce to /login. Admins (cookie
+ // present) still see it. ClientLayout already cached this query for the
+ // route, so this is a free hook call.
+ const { data: authState } = useIsAuthEnabledQuery();
+ if (authState?.is_auth_enabled && !authState.has_valid_token) {
+ return null;
+ }
+ return (
+
+ );
}
-// UnauthenticatedView is the 401 fallback: caller is not logged into the
-// dashboard / has no identity in context. Frontend redirects to the dashboard
-// login route with a return param so the user lands back here after signing in.
-//
-// The dashboard-auth-on-but-non-admin-needs-temp-token branch ships in OSS-3
-// alongside the temp-token mint endpoint; this OSS-2 cut just sends the user
-// to /login and lets the existing login flow handle it.
-function UnauthenticatedView({ flowId }: { flowId: string }) {
- const goto = `/workspace/mcp-sessions/auth?flow=${encodeURIComponent(flowId)}`;
- const loginURL = `/login?goto=${encodeURIComponent(goto)}`;
- return (
-
-
Sign in to complete authentication
-
- Bifrost needs to know who you are before linking this OAuth credential. You'll be sent back here after signing in.
-
-
-
-
-
- );
+// InvalidLinkView renders when the per-user-flow API returns 401, which now
+// means the caller arrived without either a valid dashboard session or a
+// valid mcp_auth temp token. Most often this is an expired or hand-edited
+// link — the temp token embedded in the URL fragment has aged out or the
+// fragment was dropped along the way. Trigger the original action again to
+// get a fresh URL.
+function InvalidLinkView() {
+ return (
+
+
+ This authentication link is no longer valid
+
+
+ The link may have expired, been used already, invalid, or had its
+ short-lived token stripped. Trigger the original action again so a fresh
+ authentication link is created.
+
+
+ );
}
diff --git a/ui/components/tempTokenScope.tsx b/ui/components/tempTokenScope.tsx
new file mode 100644
index 0000000000..051abeaf1c
--- /dev/null
+++ b/ui/components/tempTokenScope.tsx
@@ -0,0 +1,95 @@
+// TempTokenScope wraps a page that authenticates via a short-lived temp token
+// embedded in the URL fragment (`#t=`). It does three things:
+//
+// 1. On mount, reads the token from `window.location.hash` and installs it
+// in the baseApi module state so all RTK Query calls attach a
+// `X-Bifrost-Temp-Token` header.
+// 2. Strips the fragment from the URL via `history.replaceState` so the
+// token does not leak into Referer headers if the user later navigates
+// away.
+// 3. Sets the suppression flag so a 401 from a wrapped API call does NOT
+// trigger the global redirect-to-/login in baseQueryWithErrorHandling.
+// The wrapped page renders its own invalid/expired-link UI.
+//
+// The wrapper is scope-agnostic — the `name` prop only identifies the scope in
+// log lines (and is wired into future error UI). Routes that opt in still need
+// to declare `staticData: { tempTokenScoped: true }` on their `createFileRoute`
+// so ClientLayout skips the protected dashboard fetches; that piece is
+// orthogonal to this wrapper.
+
+import {
+ setActiveTempToken,
+ setSuppressGlobal401,
+} from "@/lib/store/apis/tempToken";
+import { useEffect, useState } from "react";
+
+interface TempTokenScopeProps {
+ name: string;
+ children: React.ReactNode;
+}
+
+export default function TempTokenScope({
+ name: _name,
+ children,
+}: TempTokenScopeProps) {
+ // Install the module state synchronously during render — NOT in useEffect.
+ // React fires child effects before parent effects, so a child API call
+ // triggered from its own useEffect would race ahead of a parent useEffect
+ // and go out without the X-Bifrost-Temp-Token header (and without the
+ // global-401 suppression flag set, so the 401 would force a /login
+ // redirect). useState's initializer runs once during the parent's render,
+ // strictly before any descendant render or effect — so by the time the
+ // child's query effect fires, the module state is already in place.
+ //
+ // Both setters are idempotent, which makes this safe under React strict
+ // mode's double-invocation.
+ useState(() => {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ const token = parseTokenFromFragment(window.location.hash);
+ if (token) {
+ // Token present: install both. The page authenticates via temp
+ // token and handles its own 401 display.
+ setActiveTempToken(token);
+ setSuppressGlobal401(true);
+ }
+ // No token: leave both unset so a 401 (e.g. a dashboard user whose
+ // session expired mid-page) still triggers the normal /login redirect.
+ // This preserves the existing reauth-from-sessions-tab flow.
+ return token;
+ });
+
+ useEffect(() => {
+ // Strip the fragment so the token doesn't end up in Referer headers on
+ // outbound navigation (e.g. the redirect to the upstream OAuth provider
+ // when the user clicks Authenticate). Pure URL cosmetics — safe to defer
+ // to an effect, doesn't affect auth correctness.
+ if (typeof window !== "undefined" && window.location.hash) {
+ window.history.replaceState(
+ null,
+ "",
+ window.location.pathname + window.location.search,
+ );
+ }
+ return () => {
+ setActiveTempToken(null);
+ setSuppressGlobal401(false);
+ };
+ }, []);
+
+ return <>{children}>;
+}
+
+// parseTokenFromFragment extracts the `t` parameter from a URL fragment like
+// `#t=abc123` or `#foo=bar&t=abc123`. Returns null if absent.
+function parseTokenFromFragment(fragment: string): string | null {
+ if (!fragment || fragment.length < 2) {
+ return null;
+ }
+ // URLSearchParams handles `?` and `&` separators; the fragment shape used
+ // by the server (`#t=...`) parses cleanly after stripping the leading `#`.
+ const params = new URLSearchParams(fragment.slice(1));
+ const token = params.get("t");
+ return token && token.length > 0 ? token : null;
+}
diff --git a/ui/lib/store/apis/baseApi.ts b/ui/lib/store/apis/baseApi.ts
index f4a2da2ad8..c040b517ab 100644
--- a/ui/lib/store/apis/baseApi.ts
+++ b/ui/lib/store/apis/baseApi.ts
@@ -4,6 +4,7 @@ import { getApiBaseUrl } from "@/lib/utils/port";
import { createBaseQueryWithRefresh } from "@enterprise/lib/store/utils/baseQueryWithRefresh";
import { clearOAuthStorage } from "@enterprise/lib/store/utils/tokenManager";
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";
+import { getActiveTempToken, getSuppressGlobal401 } from "./tempToken";
// Auth tokens are now stored in HTTP-only cookies (set by server)
// No client-side token needed — handled by credentials: "include"
@@ -39,23 +40,27 @@ export const clearAuthStorage = () => {
// Define the base query with authentication headers
const baseQuery = fetchBaseQuery({
- baseUrl: getApiBaseUrl(),
- credentials: "include",
- prepareHeaders: async (headers) => {
- // Default JSON only when an endpoint hasn't already set a content type.
- // Forcing application/json unconditionally would clobber multipart
- // requests (e.g. FormData uploads) — the browser-generated boundary
- // would be lost and the upload would fail.
+ baseUrl: getApiBaseUrl(),
+ credentials: "include",
+ prepareHeaders: async (headers) => {
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
- // Automatically include token from localStorage in Authorization header
- const token = await getTokenFromStorage();
- if (token) {
- headers.set("Authorization", `Bearer ${token}`);
- }
- return headers;
- },
+ // Automatically include token from localStorage in Authorization header
+ const token = await getTokenFromStorage();
+ if (token) {
+ headers.set("Authorization", `Bearer ${token}`);
+ }
+ // Attach a temp token when a TempTokenScope wrapper is mounted. The
+ // dashboard cookie (if present) still takes precedence on the server
+ // side; the temp token is the fallback that rescues unauthenticated
+ // browsers visiting a scoped page.
+ const tempToken = getActiveTempToken();
+ if (tempToken) {
+ headers.set("X-Bifrost-Temp-Token", tempToken);
+ }
+ return headers;
+ },
});
// Wrap base query with enterprise refresh logic (or passthrough for non-enterprise)
@@ -74,17 +79,20 @@ const baseQueryWithErrorHandling: typeof baseQueryWithRefresh = async (
if (result.error) {
const error = result.error as any;
- // Handle 401 for non-enterprise (no refresh available)
- if (error?.status === 401 && !IS_ENTERPRISE) {
- clearAuthStorage();
- if (
- typeof window !== "undefined" &&
- !window.location.pathname.includes("/login")
- ) {
- window.location.href = "/login";
- }
- return result;
- }
+ // Handle 401 for non-enterprise (no refresh available)
+ if (error?.status === 401 && !IS_ENTERPRISE) {
+ // When a TempTokenScope wrapper is active, the wrapped page handles
+ // its own 401 display (an "invalid/expired link" view). Skip the
+ // global redirect so the user stays on the page they opened.
+ if (getSuppressGlobal401()) {
+ return result;
+ }
+ clearAuthStorage();
+ if (typeof window !== "undefined" && !window.location.pathname.includes("/login")) {
+ window.location.href = "/login";
+ }
+ return result;
+ }
// Handle specific error types
if (error?.status === "FETCH_ERROR") {
diff --git a/ui/lib/store/apis/tempToken.ts b/ui/lib/store/apis/tempToken.ts
new file mode 100644
index 0000000000..625aec755e
--- /dev/null
+++ b/ui/lib/store/apis/tempToken.ts
@@ -0,0 +1,33 @@
+// Module-level state for the temp-token scope wrapper.
+//
+// The wrapper component (components/tempTokenScope.tsx) sets these on mount
+// and clears them on unmount. baseApi reads them on every request:
+// - prepareHeaders attaches `X-Bifrost-Temp-Token: ` when a token
+// is active, so APIs called from inside the scope can authenticate via
+// temp token instead of the dashboard session cookie.
+// - baseQueryWithErrorHandling consults the suppression flag before
+// force-redirecting to /login on a 401, so a scoped page can render its
+// own "invalid/expired link" view instead of yanking the user away.
+//
+// A module-level singleton is fine because we never expect two TempTokenScope
+// wrappers to be mounted concurrently in the same tab. The wrapper guards
+// against nested mounts via the same-token check on set.
+
+let activeTempToken: string | null = null;
+let suppressGlobal401 = false;
+
+export function setActiveTempToken(token: string | null): void {
+ activeTempToken = token;
+}
+
+export function getActiveTempToken(): string | null {
+ return activeTempToken;
+}
+
+export function setSuppressGlobal401(value: boolean): void {
+ suppressGlobal401 = value;
+}
+
+export function getSuppressGlobal401(): boolean {
+ return suppressGlobal401;
+}