feat(ui): land keyless users on the connect page after login

A standalone /ui/connect route is only reachable if something points a user
at it. Post-login the dashboard always rendered the API-keys view, so a
keyless SSO user saw an empty dashboard and no path to connect.

Redirect to /ui/connect from the dashboard landing when the URL carries
?login=success, the user is not an admin, and their key list is empty.
Gating on the post-login marker keeps the dashboard reachable afterwards,
and an explicit stored return URL still wins. useKeys takes an optional
enabled flag so the lookup only runs on that landing.
This commit is contained in:
Tin Chi Lo 2026-07-22 18:15:37 -07:00
parent f6fc6d299a
commit 9e7b1c0b36
3 changed files with 135 additions and 3 deletions

View file

@ -101,13 +101,14 @@ export const useKeys = (
page: number,
pageSize: number,
options: KeyListCallOptions = {},
enabled: boolean = true,
): UseQueryResult<KeysResponse> => {
const { accessToken } = useAuthorized();
return useQuery<KeysResponse>({
queryKey: keyKeys.list({ page, limit: pageSize, ...options }),
queryFn: async () => await keyListCall(accessToken!, page, pageSize, options),
enabled: Boolean(accessToken),
enabled: Boolean(accessToken) && enabled,
staleTime: 30000, // 30 seconds
placeholderData: keepPreviousData,
});

View file

@ -0,0 +1,111 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import CreateKeyPage from "./page";
interface KeyRow {
token: string;
}
const { mockReplace, mockUseKeys, mockMigratedHref, state } = vi.hoisted(() => {
const state = {
login: "success" as string | null,
userRole: "Internal User",
keys: [] as KeyRow[],
keysLoading: false,
returnUrl: null as string | null,
};
return {
state,
mockReplace: vi.fn(),
mockMigratedHref: vi.fn((segment: string) => `/mocked-ui/${segment}`),
mockUseKeys: vi.fn((_page: number, _size: number, _opts: unknown, _enabled: boolean) => ({
data: state.keysLoading ? undefined : { keys: state.keys, total_count: state.keys.length },
isLoading: state.keysLoading,
})),
};
});
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: mockReplace }),
useSearchParams: () => ({ get: (key: string) => (key === "login" ? state.login : null) }),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => ({
authLoading: false,
token: "tok",
userRole: state.userRole,
userID: "user-1",
}),
}));
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: mockUseKeys }));
vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({
default: () => <div data-testid="api-keys-dashboard" />,
}));
vi.mock("@/components/common_components/LoadingScreen", () => ({
default: () => <div data-testid="loading-screen" />,
}));
vi.mock("@/components/networking", () => ({ proxyBaseUrl: "" }));
vi.mock("@/utils/migratedPages", () => ({ MIGRATED_PAGES: {}, migratedHref: mockMigratedHref }));
vi.mock("@/utils/returnUrlUtils", () => ({
buildLoginUrlWithReturn: (u: string) => u,
consumeReturnUrl: () => state.returnUrl,
getLoginUrl: () => "/login",
isValidReturnUrl: () => true,
normalizeUrlForCompare: (u: string) => u,
storeReturnUrl: () => undefined,
}));
describe("dashboard landing keyless redirect", () => {
afterEach(() => {
state.login = "success";
state.userRole = "Internal User";
state.keys = [];
state.keysLoading = false;
state.returnUrl = null;
mockReplace.mockClear();
mockUseKeys.mockClear();
mockMigratedHref.mockClear();
});
it("sends a keyless non-admin to the connect page after login", () => {
render(<CreateKeyPage />);
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/connect");
expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument();
});
it("leaves an admin with no keys on the dashboard", () => {
state.userRole = "Admin";
render(<CreateKeyPage />);
expect(mockReplace).not.toHaveBeenCalled();
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
});
it("leaves a user who already has a key on the dashboard", () => {
state.keys = [{ token: "sk-abc" }];
render(<CreateKeyPage />);
expect(mockReplace).not.toHaveBeenCalled();
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
});
it("does not redirect outside the post-login landing, and skips the key lookup entirely", () => {
state.login = null;
render(<CreateKeyPage />);
expect(mockReplace).not.toHaveBeenCalled();
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
expect(mockUseKeys.mock.calls[0][3]).toBe(false);
});
it("holds the loading screen while the key lookup is in flight", () => {
state.keysLoading = true;
render(<CreateKeyPage />);
expect(screen.getByTestId("loading-screen")).toBeInTheDocument();
expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument();
expect(mockReplace).not.toHaveBeenCalled();
});
it("yields to an explicit return URL instead of the connect redirect", () => {
state.returnUrl = "/ui/models-and-endpoints";
render(<CreateKeyPage />);
expect(mockReplace).not.toHaveBeenCalledWith("/mocked-ui/connect");
});
});

View file

@ -3,6 +3,8 @@
import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { proxyBaseUrl } from "@/components/networking";
import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys";
import { isAdminRole } from "@/utils/roles";
import { useAuth } from "@/contexts/AuthContext";
import {
buildLoginUrlWithReturn,
@ -17,7 +19,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef } from "react";
function CreateKeyPageContent() {
const { authLoading, token } = useAuth();
const { authLoading, token, userRole, userID } = useAuth();
const router = useRouter();
const searchParams = useSearchParams()!;
@ -26,6 +28,7 @@ function CreateKeyPageContent() {
// Track if we've already attempted a return URL redirect to prevent race conditions
const hasAttemptedReturnRedirectRef = useRef(false);
const didReturnRedirectRef = useRef(false);
const redirectToLogin = authLoading === false && token === null;
@ -75,6 +78,7 @@ function CreateKeyPageContent() {
// Only redirect if the return URL is different from the current URL
// This prevents infinite redirect loops
if (normalizedReturnUrl !== normalizedCurrentUrl) {
didReturnRedirectRef.current = true;
window.location.replace(safeUrl.href);
}
}
@ -83,10 +87,26 @@ function CreateKeyPageContent() {
useEffect(() => {
if (!token) {
hasAttemptedReturnRedirectRef.current = false;
didReturnRedirectRef.current = false;
}
}, [token]);
if (authLoading || redirectToLogin || isLegacyRedirect) {
const isPostLoginLanding = searchParams.get("login") === "success";
const isSignedIn = !authLoading && Boolean(token);
const shouldCheckForKeys = isPostLoginLanding && isSignedIn && !isAdminRole(userRole);
const { data: keysData, isLoading: keysLoading } = useKeys(1, 1, { userID }, shouldCheckForKeys);
const isKeylessLanding = shouldCheckForKeys && !keysLoading && keysData?.keys?.length === 0;
const isResolvingKeylessLanding = (shouldCheckForKeys && keysLoading) || isKeylessLanding;
useEffect(() => {
if (isKeylessLanding && !didReturnRedirectRef.current) {
router.replace(migratedHref("connect"));
}
}, [isKeylessLanding, router]);
const isRedirecting = redirectToLogin || isLegacyRedirect || isResolvingKeylessLanding;
if (authLoading || isRedirecting) {
return <LoadingScreen />;
}