refactor(ui): consolidate invitation flow into the dashboard layout (#32576)

The App Router migration is complete: every page is a path route and the
legacy `?page=` switch is gone from the index. This closes it out.

The `/ui/` index (page.tsx) kept its own duplicate copy of teams state, a
teams fetch, and keys/addKey plumbing solely to feed a second `UserDashboard`
render for the `invitation_id` case. That was redundant: `ApiKeysDashboard`
already renders `UserDashboard` sourcing its own data, so the index is thinned
to just render `<ApiKeysDashboard />`. The login redirect, the legacy `?page=`
deep-link redirect for old bookmarks, and the post-login return-URL handling
stay on the index.

The invitation entry point now resolves in one place. Modern invitation links
already point at the dedicated `/onboarding` route; the dashboard layout now
redirects legacy `/ui/?invitation_id=` links there too (via `migratedHref`,
the same base-aware redirect the index uses for `?page=`), instead of
re-rendering that route's page component inline. This removes an import of one
route's `page.tsx` into another module, and lets the now-unreachable
`if (invitation_id) return <Onboarding/>` branch in the shared
`user_dashboard.tsx` be deleted along with its dead `Onboarding` import and
`searchParams` read. A layout test asserts the redirect and fails if it
regresses.

`legacyPageHref` and the sidebar's migrated-vs-legacy href fallback are left
in place; they are still live for the parent-category nav nodes (agentic,
tools, experimental, settings) that are not page routes.

eslint-metrics.json is resynced: -2 no-explicit-any from the removed `any`
casts, plus pre-existing drift the gate requires the snapshot to match.
This commit is contained in:
ryan-crabbe-berri 2026-07-09 11:59:22 -07:00 • committed by GitHub
parent 7d63b86e00
commit 1d9a86eac4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 47 additions and 71 deletions

View file

@ -1,5 +1,5 @@
{
"@typescript-eslint/no-explicit-any": 1982,
"@typescript-eslint/no-explicit-any": 1980,
"complexity": 128,
"local/no-large-inline-object-arg": 519,
"local/no-long-condition-chain": 233,

View file

@ -3,9 +3,13 @@ import { render, screen, waitFor } from "@testing-library/react";
import { AuthProvider } from "@/contexts/AuthContext";
import Layout from "./layout";
const { replaceMock } = vi.hoisted(() => ({ replaceMock: vi.fn() }));
let searchParamsValue = new URLSearchParams();
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn(), replace: vi.fn() })),
useSearchParams: vi.fn(() => new URLSearchParams()),
useRouter: vi.fn(() => ({ push: vi.fn(), replace: replaceMock })),
useSearchParams: vi.fn(() => searchParamsValue),
usePathname: vi.fn(() => "/ui/guardrails"),
}));
@ -58,6 +62,7 @@ describe("(dashboard) Layout", () => {
beforeEach(() => {
vi.clearAllMocks();
pendingUiConfig = createDeferred();
searchParamsValue = new URLSearchParams();
});
it("does not mount route content until getUiConfig has resolved", async () => {
@ -79,4 +84,25 @@ describe("(dashboard) Layout", () => {
expect(screen.getByTestId("navbar")).toBeTruthy();
expect(screen.queryByTestId("loading-screen")).toBeNull();
});
it("redirects an invitation link to the onboarding route instead of rendering the dashboard shell", async () => {
searchParamsValue = new URLSearchParams("invitation_id=abc123");
render(
<AuthProvider>
<Layout>
<div data-testid="page-content" />
</Layout>
</AuthProvider>,
);
pendingUiConfig.resolve();
await waitFor(() =>
expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("/onboarding?invitation_id=abc123")),
);
expect(screen.queryByTestId("page-content")).toBeNull();
expect(screen.queryByTestId("navbar")).toBeNull();
expect(screen.queryByTestId("sidebar")).toBeNull();
});
});

View file

@ -137,17 +137,26 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
}
function LayoutContent({ children }: { children: React.ReactNode }) {
const router = useRouter();
const searchParams = useSearchParams();
const { accessToken, authLoading } = useAuth();
const isInvitationFlow = Boolean(searchParams.get("invitation_id"));
if (authLoading) {
// Legacy invitation links point at /ui/?invitation_id=; the onboarding form now lives at its own
// /onboarding route. Redirect once ui-config has loaded so migratedHref resolves the SERVER_ROOT_PATH base.
useEffect(() => {
if (!authLoading && isInvitationFlow) {
router.replace(`${migratedHref("onboarding")}?${searchParams.toString()}`);
}
}, [authLoading, isInvitationFlow, router, searchParams]);
if (authLoading || isInvitationFlow) {
return <LoadingScreen />;
}
return (
<ThemeProvider accessToken={accessToken}>
{isInvitationFlow ? children : <DashboardShell>{children}</DashboardShell>}
<DashboardShell>{children}</DashboardShell>
</ThemeProvider>
);
}

View file

@ -1,11 +1,8 @@
"use client";
import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard";
import { teamListCall as v2TeamListCall } from "@/app/(dashboard)/hooks/teams/useTeams";
import LoadingScreen from "@/components/common_components/LoadingScreen";
import { Team } from "@/components/key_team_helpers/key_list";
import { proxyBaseUrl } from "@/components/networking";
import UserDashboard from "@/components/user_dashboard";
import { useAuth } from "@/contexts/AuthContext";
import {
buildLoginUrlWithReturn,
@ -16,32 +13,20 @@ import {
} from "@/utils/returnUrlUtils";
import { MIGRATED_PAGES, migratedHref } from "@/utils/migratedPages";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef, useState } from "react";
import { Suspense, useEffect, useRef } from "react";
function CreateKeyPageContent() {
const { authLoading, token, userID, userRole, userEmail, accessToken, premiumUser, setUserRole, setUserEmail } =
useAuth();
const [teams, setTeams] = useState<Team[] | null>(null);
const [keys, setKeys] = useState<null | any[]>([]);
const { authLoading, token } = useAuth();
const router = useRouter();
const searchParams = useSearchParams()!;
const [createClicked, setCreateClicked] = useState<boolean>(false);
const invitation_id = searchParams.get("invitation_id");
const explicitPage = searchParams.get("page");
const page = explicitPage || "api-keys";
// Track if we've already attempted a return URL redirect to prevent race conditions
const hasAttemptedReturnRedirectRef = useRef(false);
const addKey = (data: any) => {
setKeys((prevData) => (prevData ? [...prevData, data] : [data]));
setCreateClicked(() => !createClicked);
};
const redirectToLogin = authLoading === false && token === null && invitation_id === null;
const redirectToLogin = authLoading === false && token === null;
useEffect(() => {
if (redirectToLogin) {
@ -55,15 +40,13 @@ function CreateKeyPageContent() {
}
}, [redirectToLogin]);
// Redirect legacy query-param pages to their new path-based routes. Only when the page is
// explicitly requested via ?page=, so the bare landing renders inline and the post-login
// return-URL handling below stays intact.
// Redirect legacy ?page= deep links (old bookmarks) to their path-based routes.
const isLegacyRedirect = explicitPage !== null && explicitPage in MIGRATED_PAGES;
useEffect(() => {
if (!authLoading && isLegacyRedirect) {
router.replace(migratedHref(MIGRATED_PAGES[page]));
router.replace(migratedHref(MIGRATED_PAGES[explicitPage]));
}
}, [authLoading, isLegacyRedirect, page, router]);
}, [authLoading, isLegacyRedirect, explicitPage, router]);
// Check for a stored return URL after successful authentication
// This handles the case where user comes back from SSO and we need to redirect to the original URL
@ -102,42 +85,11 @@ function CreateKeyPageContent() {
}
}, [token]);
useEffect(() => {
if (accessToken && userID && userRole) {
v2TeamListCall(accessToken, 1, 100, {
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
})
.then((response) => setTeams(response.teams ?? []))
.catch(console.error);
}
}, [accessToken, userID, userRole]);
if (authLoading || redirectToLogin || isLegacyRedirect) {
return <LoadingScreen />;
}
return (
<>
{invitation_id ? (
<UserDashboard
userID={userID}
userRole={userRole}
premiumUser={premiumUser}
teams={teams}
keys={keys}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
setTeams={setTeams}
setKeys={setKeys}
addKey={addKey}
createClicked={createClicked}
/>
) : (
<ApiKeysDashboard />
)}
</>
);
return <ApiKeysDashboard />;
}
export default function CreateKeyPage() {

View file

@ -2,9 +2,7 @@
import { clearTokenCookies, getCookie } from "@/utils/cookieUtils";
import { Col, Grid } from "@tremor/react";
import { jwtDecode } from "jwt-decode";
import { useSearchParams } from "next/navigation";
import React, { useEffect, useState } from "react";
import Onboarding from "../app/onboarding/page";
import { fetchTeams } from "./common_components/fetch_teams";
import { KeyResponse, Team } from "./key_team_helpers/key_list";
import {
@ -76,13 +74,8 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
const [userSpendData, setUserSpendData] = useState<UserInfo | null>(null);
const [currentOrg, setCurrentOrg] = useState<Organization | null>(null);
// Assuming useSearchParams() hook exists and works in your setup
const searchParams = useSearchParams()!;
const token = getCookie("token");
const invitation_id = searchParams.get("invitation_id");
const [accessToken, setAccessToken] = useState<string | null>(null);
const [teamSpend, setTeamSpend] = useState<number | null>(null);
const [userModels, setUserModels] = useState<string[]>([]);
@ -232,10 +225,6 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
}
}, [selectedTeam]);
if (invitation_id != null) {
return <Onboarding></Onboarding>;
}
function gotoLogin() {
// Clear token cookies using the utility function
clearTokenCookies();