+
+
+ Billing Settings
+ Usage
+
{submitted ? (
) : null}
+
redirectTo(action, "/api/stripe/portal", {})}
pendingAction={pendingAction}
@@ -433,42 +446,11 @@ export function BillingSection({ embedded = false }: { embedded?: boolean }) {
) : null}
+
-
-
-
- Recent usage costs
-
-
- See what used your inference balance.
-
-
-
- {summary.recentCosts.length === 0 ? (
- No usage costs yet.
- ) : (
- summary.recentCosts.map((cost) => (
-
-
-
{cost.description}
-
- {formatDate(cost.occurredAt)} ·{" "}
- {cost.status.replaceAll("_", " ")}
-
-
-
- {cost.totalCents === null
- ? "In progress"
- : formatUsd(cost.totalCents)}
-
-
- ))
- )}
-
-
+
+
+
{actionError ? (
@@ -489,6 +471,6 @@ export function BillingSection({ embedded = false }: { embedded?: boolean }) {
}}
open={selectedAddOn !== null}
/>
-
+
);
}
diff --git a/components/settings/billing-usage.tsx b/components/settings/billing-usage.tsx
new file mode 100644
index 00000000..99bbdf9e
--- /dev/null
+++ b/components/settings/billing-usage.tsx
@@ -0,0 +1,37 @@
+import { formatDate, formatUsd } from "./capacity-billing-format";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import type { CapacityBillingSummaryV2 } from "@/lib/billing/capacity-summary-types";
+
+export function BillingUsage({ summary }: { summary: CapacityBillingSummaryV2 }) {
+ return (
+
+
+ Inference balance
+
+
Available now {formatUsd(summary.hostedUsage.spendableCents)}
+
Included credit {formatUsd(summary.hostedUsage.includedRemainingCents)}
+
Purchased credit {formatUsd(summary.hostedUsage.purchasedRemainingCents)}
+
+
+
+
+ Recent usage costs
+ See what used your inference balance.
+
+
+ {summary.recentCosts.length === 0 ? (
+ No usage costs yet.
+ ) : summary.recentCosts.map((cost) => (
+
+
+
{cost.description}
+
{formatDate(cost.occurredAt)} · {cost.status.replaceAll("_", " ")}
+
+
{cost.totalCents === null ? "In progress" : formatUsd(cost.totalCents)}
+
+ ))}
+
+
+
+ );
+}
diff --git a/components/settings/settings-navigation.tsx b/components/settings/settings-navigation.tsx
new file mode 100644
index 00000000..a5eb975d
--- /dev/null
+++ b/components/settings/settings-navigation.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import Link from "next/link";
+import { NavArrowLeft } from "iconoir-react";
+import { useParams, usePathname } from "next/navigation";
+import { useMemberships } from "@/hooks/use-memberships";
+import { buildSettingsNavItems } from "@/lib/settings-navigation";
+
+export function SettingsNavigation({ onBack, onNavigate }: {
+ onBack: () => void;
+ onNavigate?: () => void;
+}) {
+ const { scope } = useParams<{ scope: string }>();
+ const pathname = usePathname();
+ const { memberships, isLoading } = useMemberships();
+ const team = memberships.teams.find((item) => item.slug === scope);
+ const personal = memberships.personal.slug === scope;
+ const items = team || personal
+ ? buildSettingsNavItems(scope, team ? "team" : "personal", team?.role === "owner" || team?.role === "admin")
+ : [];
+
+ return (
+
+
+
+ Settings
+
+ {isLoading && Loading settings…
}
+ {!isLoading && items.length === 0 && Settings navigation is unavailable.
}
+ {items.map((item) => (
+
+ {item.label}
+
+ ))}
+
+ );
+}
diff --git a/components/settings/team-settings-client.tsx b/components/settings/team-settings-client.tsx
index e97f90e5..4b906240 100644
--- a/components/settings/team-settings-client.tsx
+++ b/components/settings/team-settings-client.tsx
@@ -1,33 +1,21 @@
"use client";
-import { useCallback } from "react";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { BillingSection } from "@/components/settings/billing-section";
import { DecisionChecksSection } from "@/components/settings/decision-checks-section";
import { useTeamSettingsActions } from "./use-team-settings-actions";
import { MembersTabContent } from "./members-tab-content";
import { KeysTabContent } from "./keys-tab-content";
import { ModelsTabContent } from "./models-tab-content";
import { AuditTabContent } from "./audit-tab-content";
-import { TEAM_TAB_SET, type TeamSettingsTab } from "./team-settings-types";
export function TeamSettingsClient({
teamId,
teamSlug,
+ section,
}: {
teamId: string;
teamSlug: string;
+ section: "members" | "keys" | "models" | "audit";
}) {
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const tabParam = searchParams?.get("tab") ?? "";
- const activeTab: TeamSettingsTab = TEAM_TAB_SET.has(tabParam)
- ? (tabParam as TeamSettingsTab)
- : "members";
-
const actions = useTeamSettingsActions(teamId);
const {
status,
@@ -70,25 +58,13 @@ export function TeamSettingsClient({
saveModelAllowlist,
} = actions;
- const resolvedActiveTab =
- activeTab === "audit" && membersData && !canManageMembers
- ? "members"
- : activeTab;
-
- const handleTabChange = useCallback(
- (value: string) => {
- const params = new URLSearchParams(searchParams?.toString() ?? "");
- params.set("tab", value);
- router.replace(`${pathname}?${params.toString()}`, { scroll: false });
- },
- [pathname, router, searchParams]
- );
+ const title = { members: "Members", keys: "Provider Keys", models: "Models", audit: "Audit" }[section];
return (
-
+
-
Team Settings
-
Manage {membersData?.team.name ?? teamSlug}.
+
{title}
+
{membersData?.team.name ?? teamSlug}
{status && (
@@ -97,125 +73,116 @@ export function TeamSettingsClient({
)}
-
-
- {membersData?.team.iconUrl ? (
-
- ) : (
-
- {(membersData?.team.name ?? teamSlug)
- .trim()
- .split(/\s+/)
- .filter(Boolean)
- .slice(0, 2)
- .map((part) => part[0]?.toUpperCase() ?? "")
- .join("") || "?"}
-
- )}
-
-
-
Team icon
-
- PNG, JPG, WEBP, or GIF · up to 2 MB · square works best.
+ {section === "members" && (
+
+
+ {membersData?.team.iconUrl ? (
+
+ ) : (
+
+ {(membersData?.team.name ?? teamSlug)
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean)
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? "")
+ .join("") || "?"}
+
+ )}
-
- {canManageIcon && (
-
-
{
- const file = event.target.files?.[0];
- if (file) void uploadTeamIcon(file);
- }}
- />
-
iconInputRef.current?.click()}
- className="border border-border px-3 py-2 text-sm text-foreground hover:bg-secondary disabled:opacity-50"
- >
- {busyKey === "icon"
- ? "Uploading…"
- : membersData?.team.iconUrl
- ? "Replace"
- : "Upload"}
-
- {membersData?.team.iconUrl && (
+
+
Team icon
+
+ PNG, JPG, WEBP, or GIF · up to 2 MB · square works best.
+
+
+ {canManageIcon && (
+
+ {
+ const file = event.target.files?.[0];
+ if (file) void uploadTeamIcon(file);
+ }}
+ />
void removeTeamIcon()}
- className="border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-secondary disabled:opacity-50"
+ onClick={() => iconInputRef.current?.click()}
+ className="border border-border px-3 py-2 text-sm text-foreground hover:bg-secondary disabled:opacity-50"
>
- Remove
+ {busyKey === "icon"
+ ? "Uploading…"
+ : membersData?.team.iconUrl
+ ? "Replace"
+ : "Upload"}
- )}
-
- )}
-
-
-
-
-
- Members
- Keys
- Models
- {canManageMembers && Audit }
- Billing
-
-
-
+ {membersData?.team.iconUrl && (
+ void removeTeamIcon()}
+ className="border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-secondary disabled:opacity-50"
+ >
+ Remove
+
+ )}
+
+ )}
+
+ )}
-
-
-
+ {section === "members" && (
+
+ )}
-
-
-
+ {section === "keys" && (
+
+ )}
-
+ {section === "models" && (
+
-
-
- {canManageMembers && (
-
-
-
- )}
+
+ )}
-
-
-
-
+ {section === "audit" && (
+ canManageMembers ? :
+
+ {membersError ? "Unable to load team permissions." : membersData ? "Only a team owner or admin can view the audit log." : "Loading team permissions…"}
+
+ )}
);
}
diff --git a/components/settings/team-settings-types.ts b/components/settings/team-settings-types.ts
index 833576cb..8a652b0f 100644
--- a/components/settings/team-settings-types.ts
+++ b/components/settings/team-settings-types.ts
@@ -1,28 +1,11 @@
import type { TeamRole } from "@/lib/team-capabilities";
import type { Provider } from "@/lib/vault";
-export type TeamSettingsTab =
- | "members"
- | "keys"
- | "models"
- | "audit"
- | "billing";
-
export type TeamKeysResponse = {
keys: Array<{ provider: Provider; created_at: string; updated_at: string }>;
viewer: { role: TeamRole; canManage: boolean };
};
-export const TEAM_TABS = [
- "members",
- "keys",
- "models",
- "audit",
- "billing",
-] as const;
-
-export const TEAM_TAB_SET: ReadonlySet
= new Set(TEAM_TABS);
-
export const PROVIDERS: Array<{
id: Provider;
label: string;
diff --git a/components/top-bar.tsx b/components/top-bar.tsx
index 0846a2a6..baaf8cc4 100644
--- a/components/top-bar.tsx
+++ b/components/top-bar.tsx
@@ -8,7 +8,7 @@ import { useUser } from "@/hooks/use-user";
import { ScopeMenuItems } from "@/components/scope-switcher";
import { SlackFill } from "@/components/settings/icons";
import { ThemeSwitcher } from "@/components/theme-switcher";
-import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
+import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
import {
DropdownMenu,
DropdownMenuContent,
@@ -23,6 +23,7 @@ import {
isAppNavItemActive,
type AppNavItem,
} from "@/lib/app-navigation";
+import { SettingsNavigation } from "@/components/settings/settings-navigation";
const DOCS_URL = "https://docs.mogplex.com/";
@@ -40,14 +41,17 @@ const fetchSlackInstallations = async (url: string) => {
function MobileNavLink({
item,
pathname,
+ onClick,
}: {
item: AppNavItem;
pathname: string;
+ onClick?: () => void;
}) {
const isActive = isAppNavItemActive(pathname, item.match);
return (
void;
+ scope: string;
}) {
+ const [mainNavigationPath, setMainNavigationPath] = useState(null);
+ const settingsPath = scopedHref(scope, "/settings");
+ const inSettings = pathname === settingsPath || pathname.startsWith(`${settingsPath}/`);
+ if (inSettings && mainNavigationPath !== pathname) {
+ return setMainNavigationPath(pathname)} onNavigate={onNavigate} />;
+ }
return (
-
+
{primaryItems.map((item) => (
-
+
))}
{adminItems.length > 0 && (
<>
{adminItems.map((item) => (
-
+ setMainNavigationPath(null)} />
))}
>
)}
@@ -128,19 +142,24 @@ export function TopBar() {
}
: null);
+ const [navigationOpen, setNavigationOpen] = useState(false);
+
return (
-
+
-
+
|||
-
+
+ Navigation
setNavigationOpen(false)}
/>
diff --git a/lib/settings-navigation.test.ts b/lib/settings-navigation.test.ts
new file mode 100644
index 00000000..406c7fb7
--- /dev/null
+++ b/lib/settings-navigation.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { buildSettingsNavItems } from "./settings-navigation";
+
+describe("Settings navigation", () => {
+ it("gives each personal section a scoped page", () => {
+ expect(
+ buildSettingsNavItems("alex", "personal").map((item) => item.href)
+ ).toEqual([
+ "/alex/settings/account",
+ "/alex/settings/teams",
+ "/alex/settings/keys",
+ "/alex/settings/mogplex-keys",
+ "/alex/settings/mcp",
+ "/alex/settings/billing",
+ ]);
+ });
+ it("keeps personal settings out of team navigation and hides audit from members", () => {
+ expect(
+ buildSettingsNavItems("acme", "team").map((item) => item.id)
+ ).toEqual(["members", "keys", "models", "billing"]);
+ expect(
+ buildSettingsNavItems("acme", "team", true).map((item) => item.id)
+ ).toEqual(["members", "keys", "models", "audit", "billing"]);
+ });
+});
diff --git a/lib/settings-navigation.ts b/lib/settings-navigation.ts
new file mode 100644
index 00000000..8e4d58d0
--- /dev/null
+++ b/lib/settings-navigation.ts
@@ -0,0 +1,35 @@
+import { scopedHref } from "./scoped-href";
+
+export const PERSONAL_SETTINGS = [
+ { id: "account", label: "Account" },
+ { id: "teams", label: "Teams" },
+ { id: "keys", label: "Provider Keys" },
+ { id: "mogplex-keys", label: "Mogplex Keys" },
+ { id: "mcp", label: "MCP Servers" },
+ { id: "billing", label: "Billing" },
+] as const;
+
+export const TEAM_SETTINGS = [
+ { id: "members", label: "Members" },
+ { id: "keys", label: "Provider Keys" },
+ { id: "models", label: "Models" },
+ { id: "audit", label: "Audit" },
+ { id: "billing", label: "Billing" },
+] as const;
+
+export type SettingsSection =
+ | (typeof PERSONAL_SETTINGS)[number]["id"]
+ | (typeof TEAM_SETTINGS)[number]["id"];
+
+export function buildSettingsNavItems(
+ slug: string,
+ kind: "personal" | "team",
+ canManageTeam = false
+) {
+ return (kind === "team" ? TEAM_SETTINGS : PERSONAL_SETTINGS)
+ .filter((item) => item.id !== "audit" || canManageTeam)
+ .map((item) => ({
+ ...item,
+ href: scopedHref(slug, `/settings/${item.id}`),
+ }));
+}
diff --git a/lib/settings-redirect.test.ts b/lib/settings-redirect.test.ts
index af4f2220..27a9221b 100644
--- a/lib/settings-redirect.test.ts
+++ b/lib/settings-redirect.test.ts
@@ -41,21 +41,49 @@ describe("legacy Settings destinations", () => {
).toBe("/alex/connections?slack=error&reason=denied");
});
- it("keeps personal Models and Agents redirects and leaves Team Models in Settings", () => {
+ it("keeps Models and Agents redirects and routes Team Models to its own Settings page", () => {
expect(getLegacySettingsDestination(personal, "tab=models")).toBe(
"/alex/models/catalog"
);
expect(getLegacySettingsDestination(personal, "", "#agents")).toBe(
"/alex/agents/roster"
);
- expect(getLegacySettingsDestination(team, "tab=models")).toBeNull();
- expect(getLegacySettingsDestination(team, "", "#models")).toBeNull();
+ expect(getLegacySettingsDestination(team, "tab=models")).toBe(
+ "/acme/settings/models"
+ );
+ expect(getLegacySettingsDestination(team, "", "#models")).toBe(
+ "/acme/settings/models"
+ );
});
- it.each(["", "tab=account", "tab=billing", "tab=unknown", "tab=constructor"])(
- "leaves ordinary Settings alone: %s",
- (query) => {
- expect(getLegacySettingsDestination(personal, query)).toBeNull();
- }
- );
+ it.each([
+ ["", "/alex/settings/account"],
+ ["tab=account", "/alex/settings/account"],
+ ["tab=billing&billing=topup", "/alex/settings/billing?billing=topup"],
+ ["tab=keys&sub=cli", "/alex/settings/mogplex-keys"],
+ ["tab=keys&sub=api", "/alex/settings/keys"],
+ ["tab=teams", "/alex/settings/teams"],
+ ["tab=unknown", "/alex/settings/account"],
+ ["tab=constructor", "/alex/settings/account"],
+ ])("redirects retired tabs: %s", (query, expected) => {
+ expect(getLegacySettingsDestination(personal, query)).toBe(expected);
+ });
+
+ it("keeps team settings scoped and handles hashes", () => {
+ expect(getLegacySettingsDestination(team, "")).toBe(
+ "/acme/settings/members"
+ );
+ expect(getLegacySettingsDestination(team, "tab=keys&sub=cli")).toBe(
+ "/acme/settings/keys"
+ );
+ expect(getLegacySettingsDestination(team, "", "#audit")).toBe(
+ "/acme/settings/audit"
+ );
+ expect(getLegacySettingsDestination(personal, "keep=1", "#keys")).toBe(
+ "/alex/settings/keys?keep=1"
+ );
+ expect(getLegacySettingsDestination(personal, "billing=topup")).toBe(
+ "/alex/settings/billing?billing=topup"
+ );
+ });
});
diff --git a/lib/settings-redirect.ts b/lib/settings-redirect.ts
index 61a278bf..aa3415a0 100644
--- a/lib/settings-redirect.ts
+++ b/lib/settings-redirect.ts
@@ -1,5 +1,6 @@
import type { ScopeContext } from "./scope-context";
import { scopedHref } from "./scoped-href";
+import { PERSONAL_SETTINGS, TEAM_SETTINGS } from "./settings-navigation";
const MOVED_PERSONAL_TABS = new Map([
["models", "/models/catalog"],
@@ -11,17 +12,26 @@ export function getLegacySettingsDestination(
scope: ScopeContext,
query: string,
hash = ""
-): string | null {
+): string {
const params = new URLSearchParams(query);
const tab = params.get("tab") || hash.replace(/^#/, "");
const isConnectionReturn = params.has("oauth") || params.has("slack");
- const path =
- tab === "connections" || isConnectionReturn
- ? "/connections"
- : scope.kind === "personal"
- ? MOVED_PERSONAL_TABS.get(tab)
- : undefined;
- if (!path) return null;
+ const sections = scope.kind === "team" ? TEAM_SETTINGS : PERSONAL_SETTINGS;
+ const section = sections.find((item) => item.id === tab)?.id;
+ let path = `/settings/${section ?? (scope.kind === "team" ? "members" : "account")}`;
+ if (tab === "connections" || isConnectionReturn) {
+ path = "/connections";
+ } else if (scope.kind === "personal" && MOVED_PERSONAL_TABS.has(tab)) {
+ path = MOVED_PERSONAL_TABS.get(tab)!;
+ } else if (
+ scope.kind === "personal" &&
+ tab === "keys" &&
+ params.get("sub") === "cli"
+ ) {
+ path = "/settings/mogplex-keys";
+ } else if (!tab && params.has("billing")) {
+ path = "/settings/billing";
+ }
params.delete("tab");
params.delete("sub");
const remaining = params.toString();
diff --git a/tests/e2e/billing-settings.spec.ts b/tests/e2e/billing-settings.spec.ts
index 0e3750e4..fe739149 100644
--- a/tests/e2e/billing-settings.spec.ts
+++ b/tests/e2e/billing-settings.spec.ts
@@ -166,7 +166,11 @@ test("personal Billing shows capacity and reviews an add-on change", async ({
expect(headings.slice(0, 2)).toEqual(["Plus", "Add inference credit"]);
await expect(page.getByText("7 of 35")).toHaveCount(0);
await expect(page.getByText("2.3 GB of 5 GB")).toHaveCount(0);
+ await page.getByRole("tab", { name: "Usage", exact: true }).click();
await expect(page.getByText(/Code review .*#285/)).toBeVisible();
+ await page
+ .getByRole("tab", { name: "Billing Settings", exact: true })
+ .click();
const inferenceCheckout = page.waitForRequest(
"**/api/billing/hosted-usage/checkout"
@@ -174,6 +178,7 @@ test("personal Billing shows capacity and reviews an add-on change", async ({
await page.getByRole("button", { name: "Pay $10, get $10 credit" }).click();
expect((await inferenceCheckout).postDataJSON()).toMatchObject({
preset: "capacity_v2_hosted_usage_credit_10",
+ returnPath: scopedPath("settings/billing"),
});
await expect(
page.getByText("Payment submitted. Stripe will add the full credit amount.")
diff --git a/tests/e2e/connections-route.spec.ts b/tests/e2e/connections-route.spec.ts
index 9be5c33f..878c8a90 100644
--- a/tests/e2e/connections-route.spec.ts
+++ b/tests/e2e/connections-route.spec.ts
@@ -25,6 +25,7 @@ test("Connections has its own navigation destination outside Settings", async ({
await expect(
page.getByRole("tab", { name: "Connections", exact: true })
).toHaveCount(0);
+ await page.getByRole("button", { name: "Back to main navigation" }).click();
await page.getByTestId("app-nav-connections").click();
await expect(page).toHaveURL(scopedPath("connections"));
await expect(
diff --git a/tests/e2e/helpers/billing-settings-fixtures.ts b/tests/e2e/helpers/billing-settings-fixtures.ts
index c4a513b0..faceebd3 100644
--- a/tests/e2e/helpers/billing-settings-fixtures.ts
+++ b/tests/e2e/helpers/billing-settings-fixtures.ts
@@ -10,6 +10,9 @@ export async function fulfillJson(route: Route, data: unknown, status = 200) {
}
export async function mockSettingsShell(page: Page) {
+ await page.route("**/api/settings/decision-checks", (route) =>
+ fulfillJson(route, { enabled: true, viewer: { canManage: true } })
+ );
await page.route("**/api/auth/user", (route) =>
fulfillJson(route, {
user: {
diff --git a/tests/e2e/mcp-servers-settings.spec.ts b/tests/e2e/mcp-servers-settings.spec.ts
index 736e660b..7c6d6bee 100644
--- a/tests/e2e/mcp-servers-settings.spec.ts
+++ b/tests/e2e/mcp-servers-settings.spec.ts
@@ -1,4 +1,5 @@
-import { expect, test, type Route } from "@playwright/test";
+import type { Route } from "@playwright/test";
+import { expect, test } from "./helpers/run-checks-fixtures";
import { enableScopedE2EAuth, scopedPath } from "./helpers/auth";
type MockServer = {
diff --git a/tests/e2e/run-checks-team-settings.spec.ts b/tests/e2e/run-checks-team-settings.spec.ts
index d6ff7e45..9386dab5 100644
--- a/tests/e2e/run-checks-team-settings.spec.ts
+++ b/tests/e2e/run-checks-team-settings.spec.ts
@@ -24,8 +24,11 @@ for (const role of ["owner", "admin"] as const) {
});
await page.goto(TEAM_SETTINGS_PATH);
- await page.getByRole("tab", { name: "Models", exact: true }).click();
- await expect(page).toHaveURL(`${TEAM_SETTINGS_PATH}?tab=models`);
+ await page
+ .getByRole("navigation", { name: "Settings", exact: true })
+ .getByRole("link", { name: "Models", exact: true })
+ .click();
+ await expect(page).toHaveURL(`${TEAM_SETTINGS_PATH}/models`);
const toggle = page.getByRole("switch", { name: "Run checks" });
await expect(toggle).toBeChecked();
await expect(toggle).toBeEnabled();
diff --git a/tests/e2e/settings-navigation.spec.ts b/tests/e2e/settings-navigation.spec.ts
new file mode 100644
index 00000000..88162a2a
--- /dev/null
+++ b/tests/e2e/settings-navigation.spec.ts
@@ -0,0 +1,228 @@
+import {
+ test,
+ expect,
+ fulfillJson,
+ mockTeamSettings,
+ TEAM_SETTINGS_PATH,
+} from "./helpers/run-checks-fixtures";
+import { enableScopedE2EAuth, scopedPath } from "./helpers/auth";
+import { capacitySummary } from "./helpers/billing";
+
+test.beforeEach(async ({ page }) => {
+ await enableScopedE2EAuth(page);
+ await page.route("**/api/settings/decision-checks", (route) =>
+ fulfillJson(route, { enabled: true, viewer: { canManage: true } })
+ );
+ await page.route("**/api/settings/keys", (route) =>
+ fulfillJson(route, { keys: [] })
+ );
+ await page.route("**/api/settings/api-keys", (route) =>
+ fulfillJson(route, { keys: [] })
+ );
+ await page.route("**/api/mcp-servers", (route) =>
+ fulfillJson(route, { servers: [] })
+ );
+});
+
+test("Settings drills into page navigation and back restores the main menu", async ({
+ page,
+}) => {
+ await page.goto(scopedPath("settings/account"));
+ const nav = page.getByRole("navigation", { name: "Settings", exact: true });
+ await expect(nav).toBeVisible();
+ await expect(page.getByRole("tab")).toHaveCount(0);
+ await page.getByRole("button", { name: "Back to main navigation" }).click();
+ await expect(page.getByTestId("app-nav-connections")).toBeVisible();
+ await expect(page).toHaveURL(scopedPath("settings/account"));
+ await page.getByTestId("app-nav-settings").click();
+ await expect(nav).toBeVisible();
+ for (const [label, route] of [
+ ["Teams", "teams"],
+ ["Provider Keys", "keys"],
+ ["Mogplex Keys", "mogplex-keys"],
+ ["MCP Servers", "mcp"],
+ ["Account", "account"],
+ ]) {
+ await nav.getByRole("link", { name: label, exact: true }).click();
+ await expect(page).toHaveURL(scopedPath(`settings/${route}`));
+ await expect(
+ page.getByRole("heading", { name: label, exact: true }).first()
+ ).toBeVisible();
+ await expect(
+ nav.getByRole("link", { name: label, exact: true })
+ ).toHaveAttribute("aria-current", "page");
+ await expect(page.getByRole("tab")).toHaveCount(0);
+ }
+ await page.reload();
+ await expect(
+ nav.getByRole("link", { name: "Account", exact: true })
+ ).toHaveAttribute("aria-current", "page");
+});
+
+test("Billing has separate settings and usage, with history and reload support", async ({
+ page,
+}) => {
+ const summary = capacitySummary();
+ summary.recentCosts = [
+ {
+ operationId: "usage-1",
+ description: "Repository review",
+ status: "settled",
+ occurredAt: "2026-09-22T12:00:00Z",
+ totalCents: 125,
+ items: [],
+ },
+ ];
+ await page.route("**/api/billing/capacity", (route) =>
+ fulfillJson(route, summary)
+ );
+ await page.goto(scopedPath("settings/billing"));
+ await expect(
+ page.getByRole("tab", { name: "Billing Settings", exact: true })
+ ).toHaveAttribute("aria-selected", "true");
+ await expect(
+ page.getByRole("heading", { name: "Add inference credit" })
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Recent usage costs" })
+ ).toHaveCount(0);
+ await page.getByRole("tab", { name: "Usage", exact: true }).click();
+ await expect(page).toHaveURL(scopedPath("settings/billing?tab=usage"));
+ await expect(
+ page.getByText("Repository review", { exact: true })
+ ).toBeVisible();
+ await expect(page.getByText("$1.25", { exact: true })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Add inference credit" })
+ ).toHaveCount(0);
+ await page.reload();
+ await expect(
+ page.getByRole("tab", { name: "Usage", exact: true })
+ ).toHaveAttribute("aria-selected", "true");
+ await page.goBack();
+ await expect(
+ page.getByRole("tab", { name: "Billing Settings", exact: true })
+ ).toHaveAttribute("aria-selected", "true");
+ await page.goForward();
+ await expect(
+ page.getByText("Repository review", { exact: true })
+ ).toBeVisible();
+});
+
+test("mobile Settings navigation opens, selects a page and closes", async ({
+ page,
+}) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.route("**/api/billing/capacity", (route) =>
+ fulfillJson(route, { ...capacitySummary(), recentCosts: [] })
+ );
+ await page.goto(scopedPath("settings/account"));
+ await page.getByRole("button", { name: "Open navigation" }).click();
+ const dialog = page.getByRole("dialog", { name: "Navigation" });
+ await dialog.getByRole("button", { name: "Back to main navigation" }).click();
+ await dialog.getByRole("link", { name: "Settings", exact: true }).click();
+ await dialog.getByRole("link", { name: "Billing", exact: true }).click();
+ await expect(dialog).not.toBeVisible();
+ await expect(page).toHaveURL(scopedPath("settings/billing"));
+ await page.getByRole("tab", { name: "Usage", exact: true }).click();
+ await expect(page.getByText("No usage costs yet.")).toBeVisible();
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth
+ )
+ ).toBe(true);
+ await page.getByRole("button", { name: "Open navigation" }).click();
+ await expect(
+ page
+ .getByRole("dialog", { name: "Navigation" })
+ .getByRole("link", { name: "Billing", exact: true })
+ ).toBeVisible();
+});
+
+test("team Settings has team pages and limits Audit access", async ({
+ page,
+}) => {
+ await mockTeamSettings(page, "viewer");
+ await page.goto(TEAM_SETTINGS_PATH);
+ await expect(page).toHaveURL(`${TEAM_SETTINGS_PATH}/members`);
+ const nav = page.getByRole("navigation", { name: "Settings", exact: true });
+ await expect(
+ nav.getByRole("link", { name: "Members", exact: true })
+ ).toBeVisible();
+ await expect(
+ nav.getByRole("link", { name: "Account", exact: true })
+ ).toHaveCount(0);
+ await expect(
+ nav.getByRole("link", { name: "Audit", exact: true })
+ ).toHaveCount(0);
+ await expect(page.getByRole("tab")).toHaveCount(0);
+ await page.goto(`${TEAM_SETTINGS_PATH}/audit`);
+ await expect(
+ page.getByText("Only a team owner or admin can view the audit log.")
+ ).toBeVisible();
+});
+
+test("old key links open the right page and preserve unrelated query values", async ({
+ page,
+}) => {
+ await page.goto(scopedPath("settings?tab=keys&sub=cli&keep=1"));
+ await expect(page).toHaveURL(scopedPath("settings/mogplex-keys?keep=1"));
+ await expect(
+ page.getByRole("heading", { name: "Mogplex Keys", exact: true })
+ ).toBeVisible();
+ await page.goto(scopedPath("settings#teams"));
+ await expect(page).toHaveURL(scopedPath("settings/teams"));
+});
+
+test("Settings expands a compact sidebar and preserves the main menu width", async ({
+ page,
+}) => {
+ await page.addInitScript(() => {
+ localStorage.setItem("mogplex.appSidebar.width", "64");
+ localStorage.setItem("mogplex.appSidebar.collapsed", "true");
+ localStorage.setItem("mogplex-theme", "dark");
+ });
+ await page.emulateMedia({ colorScheme: "dark" });
+ await page.route(/\/api\/settings(?:\?.*)?$/, (route) =>
+ fulfillJson(route, { theme: "dark" })
+ );
+ await page.goto(scopedPath("settings/billing"));
+ const sidebar = page.getByTestId("app-sidebar");
+ await expect(sidebar).toHaveAttribute("data-compact", "false");
+ await expect(page.getByTestId("settings-nav-billing")).toBeVisible();
+ await page
+ .getByRole("button", { name: "Back to main navigation" })
+ .press("Enter");
+ await expect(sidebar).toHaveAttribute("data-compact", "true");
+ await page.getByTestId("app-nav-settings").press("Enter");
+ await expect(sidebar).toHaveAttribute("data-compact", "false");
+ await expect(page.getByTestId("settings-nav-account")).toHaveAttribute(
+ "aria-current",
+ "page"
+ );
+ await expect(
+ page.getByRole("heading", { name: "Account", exact: true })
+ ).toBeVisible();
+ await expect(page.getByRole("switch", { name: "Run checks" })).toBeEnabled();
+});
+
+test("team owners can open Keys and Audit as pages", async ({ page }) => {
+ await mockTeamSettings(page, "owner");
+ await page.goto(`${TEAM_SETTINGS_PATH}/members`);
+ const nav = page.getByRole("navigation", { name: "Settings", exact: true });
+ await nav.getByRole("link", { name: "Provider Keys", exact: true }).click();
+ await expect(page).toHaveURL(`${TEAM_SETTINGS_PATH}/keys`);
+ await expect(
+ page.getByRole("heading", { name: "Provider Keys", exact: true })
+ ).toBeVisible();
+ await nav.getByRole("link", { name: "Audit", exact: true }).click();
+ await expect(page).toHaveURL(`${TEAM_SETTINGS_PATH}/audit`);
+ await expect(
+ page.getByRole("heading", { name: "Audit", exact: true })
+ ).toBeVisible();
+ await expect(page.getByRole("tab")).toHaveCount(0);
+ await page.reload();
+ await expect(
+ nav.getByRole("link", { name: "Audit", exact: true })
+ ).toHaveAttribute("aria-current", "page");
+});
diff --git a/tests/e2e/theme-settings-github.spec.ts b/tests/e2e/theme-settings-github.spec.ts
index 601ea54f..a1ffb4fc 100644
--- a/tests/e2e/theme-settings-github.spec.ts
+++ b/tests/e2e/theme-settings-github.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { expect, test } from "./helpers/run-checks-fixtures";
import { enableScopedE2EAuth, scopedPath } from "./helpers/auth";
import {
connectedUser,
@@ -234,6 +234,8 @@ test("settings surfaces settings preference load failures without crashing", asy
await expect(
page.getByText("Unable to load settings preferences")
).toBeVisible();
- await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Account", exact: true })
+ ).toBeVisible();
expect(pageErrors).toEqual([]);
});
diff --git a/tests/e2e/theme-settings-theme.spec.ts b/tests/e2e/theme-settings-theme.spec.ts
index 91802016..03219fb7 100644
--- a/tests/e2e/theme-settings-theme.spec.ts
+++ b/tests/e2e/theme-settings-theme.spec.ts
@@ -1,4 +1,4 @@
-import { expect, test } from "@playwright/test";
+import { expect, test } from "./helpers/run-checks-fixtures";
import { enableScopedE2EAuth, scopedPath } from "./helpers/auth";
import {
connectedUser,
@@ -13,6 +13,13 @@ import {
} from "./helpers/theme-settings-fixtures";
import type { TestThemePreference } from "./helpers/theme-settings-fixtures";
+test.beforeEach(async ({ page }) => {
+ await page.route("**/api/realtime/events**", (route) =>
+ route.fulfill({ status: 204 })
+ );
+ await page.route("**/api/github/repos", (route) => fulfillJson(route, []));
+});
+
test("theme preference persists from user menu into spaces without UI regressions", async ({
page,
}) => {
diff --git a/tests/unit/top-bar-mobile-nav.test.ts b/tests/unit/top-bar-mobile-nav.test.ts
index 3e8569dd..bd06b8f2 100644
--- a/tests/unit/top-bar-mobile-nav.test.ts
+++ b/tests/unit/top-bar-mobile-nav.test.ts
@@ -13,9 +13,10 @@ test("mobile sheet nav renders admin items after a divider and marks the active
const items = buildAppNavItems("acme");
const html = renderToStaticMarkup(
createElement(MobileSheetNav, {
+ scope: "acme",
primaryItems: items.filter((item) => item.section === "primary"),
adminItems: items.filter((item) => item.section === "admin"),
- pathname: "/acme/settings",
+ pathname: "/acme/control",
})
);
@@ -26,7 +27,8 @@ test("mobile sheet nav renders admin items after a divider and marks the active
assert.ok(dividerIndex !== -1, "divider is rendered between the groups");
assert.ok(settingsIndex > dividerIndex, "settings renders after the divider");
assert.ok(settingsAnchor);
- assert.ok(settingsAnchor.includes('aria-current="page"'));
+ assert.ok(!settingsAnchor.includes('aria-current="page"'));
+ assert.ok(findAnchor(html, "/acme/control")?.includes('aria-current="page"'));
assert.equal(html.match(/aria-current="page"/g)?.length, 1);
});
@@ -36,6 +38,7 @@ test("mobile sheet nav omits the divider when the admin group is empty", async (
const items = buildAppNavItems("acme");
const html = renderToStaticMarkup(
createElement(MobileSheetNav, {
+ scope: "acme",
primaryItems: items.filter((item) => item.section === "primary"),
adminItems: [],
pathname: "/acme/control",
From 6c09ad1b3d71d45e976abc689593331a6a76f6d0 Mon Sep 17 00:00:00 2001
From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com>
Date: Tue, 22 Sep 2026 11:37:19 -0400
Subject: [PATCH 2/3] fix: restore Settings navigation focus and route state
---
.../[scope]/settings/[section]/page.tsx | 1 +
.../[scope]/settings/settings-page-client.tsx | 3 +-
components/app-sidebar.tsx | 21 ++++++------
components/top-bar.tsx | 21 ++++++++----
hooks/use-settings-navigation.ts | 29 +++++++++++++++++
tests/e2e/settings-navigation.spec.ts | 32 +++++++++++++++++++
6 files changed, 91 insertions(+), 16 deletions(-)
create mode 100644 hooks/use-settings-navigation.ts
diff --git a/app/(dashboard)/[scope]/settings/[section]/page.tsx b/app/(dashboard)/[scope]/settings/[section]/page.tsx
index d9c5c376..d63f5a2e 100644
--- a/app/(dashboard)/[scope]/settings/[section]/page.tsx
+++ b/app/(dashboard)/[scope]/settings/[section]/page.tsx
@@ -16,6 +16,7 @@ export default async function SettingsSectionPage({ params }: {
if (!item) notFound();
if (scope.kind === "team") {
+ // Billing and MCP have static route segments; this page owns the other sections.
if (section !== "members" && section !== "keys" && section !== "models" && section !== "audit") notFound();
return ;
}
diff --git a/app/(dashboard)/[scope]/settings/settings-page-client.tsx b/app/(dashboard)/[scope]/settings/settings-page-client.tsx
index 79e637de..3ea687d5 100644
--- a/app/(dashboard)/[scope]/settings/settings-page-client.tsx
+++ b/app/(dashboard)/[scope]/settings/settings-page-client.tsx
@@ -9,7 +9,8 @@ export function SettingsPageClient({ scope }: { scope: ScopeContext }) {
const router = useRouter();
const query = useSearchParams().toString();
useEffect(() => {
+ // Keep the root redirect client-side: legacy hash links are not sent to the server.
router.replace(getLegacySettingsDestination(scope, query, window.location.hash), { scroll: false });
}, [scope, query, router]);
return Opening Settings…
;
-}
\ No newline at end of file
+}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
index dcd0ade4..7fda47b2 100644
--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -35,6 +35,7 @@ import { formatUsd } from "@/lib/billing/catalog";
import type { CapacityBillingSummaryV2 } from "@/lib/billing/capacity-summary-types";
import { scopedHref } from "@/lib/scoped-href";
import { SettingsNavigation } from "@/components/settings/settings-navigation";
+import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
const SIDEBAR_WIDTH_KEY = "mogplex.appSidebar.width";
const SIDEBAR_COLLAPSED_KEY = "mogplex.appSidebar.collapsed";
@@ -42,6 +43,7 @@ const DEFAULT_WIDTH = 272;
const MIN_WIDTH = 64;
const COMPACT_THRESHOLD = 120;
const MAX_WIDTH = 320;
+const SETTINGS_MIN_WIDTH = 240;
const NAV_ICONS = {
control: Rocket,
@@ -75,17 +77,20 @@ function SidebarNavLink({
compact,
pathname,
onClick,
+ linkRef,
}: {
item: AppNavItem;
compact: boolean;
pathname: string;
onClick?: () => void;
+ linkRef?: React.Ref;
}) {
const Icon = NAV_ICONS[item.id];
const active = isAppNavItemActive(pathname, item.match);
return (
(null);
- const settingsPath = scopedHref(scope, "/settings");
- const inSettings = pathname === settingsPath || pathname.startsWith(`${settingsPath}/`);
- const showSettings = inSettings && mainNavigationPath !== pathname;
+ const { showSettings, backToMain, openSettings, settingsLinkRef } = useSettingsNavigation(scope, pathname);
const [resizing, setResizing] = useState(false);
const activePointerId = useRef(null);
const compact = !showSettings && width <= COMPACT_THRESHOLD;
@@ -223,7 +225,7 @@ export function AppSidebar() {
data-compact={compact ? "true" : "false"}
data-resizing={resizing ? "true" : "false"}
data-testid="app-sidebar"
- style={{ width: showSettings ? Math.max(width, 240) : width }}
+ style={{ width: showSettings ? Math.max(width, SETTINGS_MIN_WIDTH) : width }}
>
@@ -240,7 +242,7 @@ export function AppSidebar() {
)}
{showSettings ? (
- setMainNavigationPath(pathname)} />
+
) : <>
setMainNavigationPath(null)}
+ onClick={item.id === "settings" ? openSettings : undefined}
+ linkRef={item.id === "settings" ? settingsLinkRef : undefined}
/>
))}
@@ -305,7 +308,7 @@ export function AppSidebar() {
) : null}
-
+ />}
);
}
diff --git a/components/top-bar.tsx b/components/top-bar.tsx
index baaf8cc4..f5587b59 100644
--- a/components/top-bar.tsx
+++ b/components/top-bar.tsx
@@ -1,4 +1,5 @@
"use client";
+import React from "react";
import { useMemo, useState } from "react";
import Link from "next/link";
import { useParams, usePathname, useRouter } from "next/navigation";
@@ -24,6 +25,7 @@ import {
type AppNavItem,
} from "@/lib/app-navigation";
import { SettingsNavigation } from "@/components/settings/settings-navigation";
+import { useSettingsNavigation } from "@/hooks/use-settings-navigation";
const DOCS_URL = "https://docs.mogplex.com/";
@@ -42,15 +44,18 @@ function MobileNavLink({
item,
pathname,
onClick,
+ linkRef,
}: {
item: AppNavItem;
pathname: string;
onClick?: () => void;
+ linkRef?: React.Ref;
}) {
const isActive = isAppNavItemActive(pathname, item.match);
return (
void;
scope: string;
}) {
- const [mainNavigationPath, setMainNavigationPath] = useState(null);
- const settingsPath = scopedHref(scope, "/settings");
- const inSettings = pathname === settingsPath || pathname.startsWith(`${settingsPath}/`);
- if (inSettings && mainNavigationPath !== pathname) {
- return setMainNavigationPath(pathname)} onNavigate={onNavigate} />;
+ const { showSettings, backToMain, openSettings, settingsLinkRef } = useSettingsNavigation(scope, pathname);
+ if (showSettings) {
+ return ;
}
return (
@@ -92,7 +95,13 @@ export function MobileSheetNav({
<>
{adminItems.map((item) => (
- setMainNavigationPath(null)} />
+
))}
>
)}
diff --git a/hooks/use-settings-navigation.ts b/hooks/use-settings-navigation.ts
new file mode 100644
index 00000000..d1fff8f2
--- /dev/null
+++ b/hooks/use-settings-navigation.ts
@@ -0,0 +1,29 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { scopedHref } from "@/lib/scoped-href";
+
+export function useSettingsNavigation(scope: string, pathname: string) {
+ const [menu, setMenu] = useState({ pathname, dismissed: false });
+ const settingsLinkRef = useRef(null);
+ const settingsPath = scopedHref(scope, "/settings");
+ const inSettings =
+ pathname === settingsPath || pathname.startsWith(`${settingsPath}/`);
+
+ // Back only changes the current menu. A new route starts fresh.
+ if (menu.pathname !== pathname) {
+ setMenu({ pathname, dismissed: false });
+ }
+ const dismissed = menu.pathname === pathname && menu.dismissed;
+
+ useEffect(() => {
+ if (dismissed) settingsLinkRef.current?.focus();
+ }, [dismissed]);
+
+ return {
+ showSettings: inSettings && !dismissed,
+ settingsLinkRef,
+ backToMain: () => setMenu({ pathname, dismissed: true }),
+ openSettings: () => setMenu({ pathname, dismissed: false }),
+ };
+}
diff --git a/tests/e2e/settings-navigation.spec.ts b/tests/e2e/settings-navigation.spec.ts
index 88162a2a..cac48b43 100644
--- a/tests/e2e/settings-navigation.spec.ts
+++ b/tests/e2e/settings-navigation.spec.ts
@@ -120,6 +120,9 @@ test("mobile Settings navigation opens, selects a page and closes", async ({
await page.getByRole("button", { name: "Open navigation" }).click();
const dialog = page.getByRole("dialog", { name: "Navigation" });
await dialog.getByRole("button", { name: "Back to main navigation" }).click();
+ await expect(
+ dialog.getByRole("link", { name: "Settings", exact: true })
+ ).toBeFocused();
await dialog.getByRole("link", { name: "Settings", exact: true }).click();
await dialog.getByRole("link", { name: "Billing", exact: true }).click();
await expect(dialog).not.toBeVisible();
@@ -226,3 +229,32 @@ test("team owners can open Keys and Audit as pages", async ({ page }) => {
nav.getByRole("link", { name: "Audit", exact: true })
).toHaveAttribute("aria-current", "page");
});
+
+test("Back restores focus and reentering Billing opens its Settings menu", async ({
+ page,
+}) => {
+ await page.route("**/api/skills/catalog", (route) =>
+ fulfillJson(route, { skills: [] })
+ );
+ await page.route("**/api/observability/calls**", (route) =>
+ fulfillJson(route, { calls: [] })
+ );
+ await page.goto(scopedPath("settings/billing"));
+ await page
+ .getByRole("button", { name: "Back to main navigation" })
+ .press("Enter");
+ await expect(page.getByTestId("app-nav-settings")).toBeFocused();
+ await page.getByTestId("app-nav-connections").click();
+ await expect(page).toHaveURL(scopedPath("connections"));
+ await page.getByRole("link", { name: "Manage billing", exact: true }).click();
+ await expect(
+ page.getByRole("navigation", { name: "Settings", exact: true })
+ ).toBeVisible();
+ await expect(page.getByTestId("settings-nav-billing")).toHaveAttribute(
+ "aria-current",
+ "page"
+ );
+ await expect(
+ page.getByRole("separator", { name: "Resize app navigation" })
+ ).toHaveCount(0);
+});
From c549b1dd1153ab49592eab89a1311340c1a7779c Mon Sep 17 00:00:00 2001
From: Charles Howard <96023061+charlesrhoward@users.noreply.github.com>
Date: Tue, 22 Sep 2026 11:45:17 -0400
Subject: [PATCH 3/3] fix: include visible Settings label in back control name
---
components/settings/settings-navigation.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/components/settings/settings-navigation.tsx b/components/settings/settings-navigation.tsx
index a5eb975d..56bde8a5 100644
--- a/components/settings/settings-navigation.tsx
+++ b/components/settings/settings-navigation.tsx
@@ -21,7 +21,7 @@ export function SettingsNavigation({ onBack, onNavigate }: {
return (
-
Settings