diff --git a/DESIGN.md b/DESIGN.md index 85aca29b..39b8c7f9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -168,6 +168,10 @@ behavior. Defaults can be overridden by the owning screen; inspect it first. 6px corners, Iconoir icons, and `aria-current` on the active destination. The generic [sidebar menu](./components/ui/sidebar-menu.tsx) has different defaults (32px rows, 4px corners); these are separate components. + Settings opens a secondary menu in the same sidebar, with a back arrow to + restore the main navigation. Each Settings section has a scoped page URL. + The mobile navigation sheet follows the same pattern and closes after a + section is selected. Billing alone has Billing Settings and Usage tabs. - **[Dialog](./components/ui/dialog.tsx):** reuse the Radix-backed focus and dismissal behavior, 6px corners, 24px padding, and responsive action layout. Provide a title and description. Use inline disclosure when it can complete diff --git a/app/(dashboard)/[scope]/settings/[section]/page.tsx b/app/(dashboard)/[scope]/settings/[section]/page.tsx new file mode 100644 index 00000000..d63f5a2e --- /dev/null +++ b/app/(dashboard)/[scope]/settings/[section]/page.tsx @@ -0,0 +1,33 @@ +import { notFound } from "next/navigation"; +import { getScopeContext } from "@/lib/scope-context"; +import { PERSONAL_SETTINGS, TEAM_SETTINGS } from "@/lib/settings-navigation"; +import { TeamSettingsClient } from "@/components/settings/team-settings-client"; +import { TeamsListSection } from "@/components/settings/teams-list-section"; +import { CliApiKeysSection } from "@/components/settings/cli-api-keys-section"; +import { PersonalAccountPage } from "../_components/personal-account-page"; +import { ProviderKeysPage } from "../_components/provider-keys-page"; + +export default async function SettingsSectionPage({ params }: { + params: Promise<{ section: string }>; +}) { + const [{ section }, scope] = await Promise.all([params, getScopeContext()]); + const items = scope.kind === "team" ? TEAM_SETTINGS : PERSONAL_SETTINGS; + const item = items.find((entry) => entry.id === section); + 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 ; + } + + return ( +
+

{item.label}

+ {section === "account" && } + {section === "teams" && } + {section === "keys" && } + {section === "mogplex-keys" && } +
+ ); +} diff --git a/app/(dashboard)/[scope]/settings/_components/index.ts b/app/(dashboard)/[scope]/settings/_components/index.ts index d65e9913..1b41c033 100644 --- a/app/(dashboard)/[scope]/settings/_components/index.ts +++ b/app/(dashboard)/[scope]/settings/_components/index.ts @@ -10,10 +10,4 @@ export { type GithubInstallationView, type GithubOwnerTarget, type SettingsView, - type SettingsTab, - type KeysSubTab, - SETTINGS_TABS, - SETTINGS_TAB_SET, - KEYS_SUB_TABS, - KEYS_SUB_TAB_SET, } from "./settings-types"; diff --git a/app/(dashboard)/[scope]/settings/_components/personal-account-page.tsx b/app/(dashboard)/[scope]/settings/_components/personal-account-page.tsx new file mode 100644 index 00000000..1722cbec --- /dev/null +++ b/app/(dashboard)/[scope]/settings/_components/personal-account-page.tsx @@ -0,0 +1,111 @@ +"use client" + +import { useCallback, useMemo } from "react" +import useSWR from "swr" +import { useUser } from "@/hooks/use-user" +import { fetchJsonArray, fetchJsonObject } from "@/lib/client-fetch" +import { DecisionChecksSection } from "@/components/settings/decision-checks-section" +import { trackActivation } from "@/lib/activation-tracking" +import { AccountSection } from "./account-section" +import type { GithubInstallationView, GithubOwnerTarget, SettingsView } from "./settings-types" + +export function PersonalAccountPage() { + const { user, isLoading } = useUser() + const { error: settingsError } = useSWR( + "/api/settings", + (url: string) => fetchJsonObject(url, "Failed to load settings"), + ) + const settingsLoadError = settingsError ? "Unable to load settings preferences" : null + const { + data: githubInstallations, + error: githubInstallationsError, + } = useSWR( + "/api/github/installations", + (url: string) => fetchJsonArray(url, "Failed to load GitHub installations"), + ) + const { data: githubOwnerTargets } = useSWR( + user?.github_connected ? "/api/github/owners" : null, + (url: string) => fetchJsonArray(url, "Failed to load GitHub accounts"), + ) + const ownerTargets = githubOwnerTargets ?? [] + const ownerTargetsNeedingInstall = ownerTargets.filter((target) => target.github_installation_id == null) + const githubInstallationsLoadError = githubInstallationsError ? "Unable to load GitHub App installations" : null + + const githubPrimaryAction = useMemo(() => { + if (user?.github_primary_action) return user.github_primary_action + if (!user?.github_connected) { + return { + label: user?.github_app_available ? "Install GitHub App" : "Connect GitHub", + href: "/api/auth/github", + } + } + return null + }, [user]) + + const showGithubAddInstallAction = Boolean( + user?.github_app_available && + user?.github_connected && + githubPrimaryAction?.href !== "/api/auth/github", + ) + + const nextStep = useMemo(() => { + if (githubPrimaryAction) { + const githubState = user?.github_state + const title = githubState === "app_installed" + ? "Sync repositories next" + : githubState === "app_install_pending" + ? "Complete the GitHub App install" + : githubState === "oauth_connected" + ? "Upgrade GitHub from OAuth to the App" + : "Install GitHub App next" + const description = githubState === "app_installed" + ? "Open Projects and sync the repositories covered by your GitHub App installation into Mogplex." + : user?.github_status_detail || "GitHub powers repo sync, trigger coverage, and automation." + return { + title, + description, + href: githubPrimaryAction.href, + label: githubPrimaryAction.label, + } + } + + if (!user?.github_connected) { + return { + title: user?.github_app_available ? "Install GitHub App next" : "Connect GitHub next", + description: "GitHub is required to import repositories and make Open Workspace useful.", + href: "/api/auth/github", + label: user?.github_app_available ? "Install GitHub App" : "Connect GitHub", + } + } + + return null + }, [githubPrimaryAction, user]) + + const trackConnectionStart = useCallback((provider: "github", source: string) => { + if (provider === "github") { + trackActivation("github_connect_started", { + source, + connection_mode: user?.github_app_available ? "app" : "oauth", + }) + } + }, [user?.github_app_available]) + + return ( +
+ {settingsLoadError &&
{settingsLoadError}
} + + +
+ ) +} diff --git a/app/(dashboard)/[scope]/settings/_components/provider-keys-page.tsx b/app/(dashboard)/[scope]/settings/_components/provider-keys-page.tsx new file mode 100644 index 00000000..cc019450 --- /dev/null +++ b/app/(dashboard)/[scope]/settings/_components/provider-keys-page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useUser } from "@/hooks/use-user"; +import { ApiKeysSection } from "./api-keys-section"; + +export function ProviderKeysPage() { + const { user, isLoading } = useUser(); + return ; +} diff --git a/app/(dashboard)/[scope]/settings/_components/settings-types.ts b/app/(dashboard)/[scope]/settings/_components/settings-types.ts index 732a69ee..ac7f965a 100644 --- a/app/(dashboard)/[scope]/settings/_components/settings-types.ts +++ b/app/(dashboard)/[scope]/settings/_components/settings-types.ts @@ -71,11 +71,3 @@ export const PROVIDER_META: Record< masked: "sk-or-...****", }, }; - -export const SETTINGS_TABS = ["account", "teams", "keys", "billing"] as const; -export type SettingsTab = (typeof SETTINGS_TABS)[number]; -export const SETTINGS_TAB_SET: ReadonlySet = new Set(SETTINGS_TABS); - -export const KEYS_SUB_TABS = ["api", "cli"] as const; -export type KeysSubTab = (typeof KEYS_SUB_TABS)[number]; -export const KEYS_SUB_TAB_SET: ReadonlySet = new Set(KEYS_SUB_TABS); diff --git a/app/(dashboard)/[scope]/settings/billing/page.tsx b/app/(dashboard)/[scope]/settings/billing/page.tsx index db588837..267438a0 100644 --- a/app/(dashboard)/[scope]/settings/billing/page.tsx +++ b/app/(dashboard)/[scope]/settings/billing/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { BillingSection } from "@/components/settings/billing-section"; +import { getScopeContext } from "@/lib/scope-context"; export const metadata: Metadata = { title: "Billing | Mogplex", @@ -10,10 +11,11 @@ export const metadata: Metadata = { }, }; -export default function BillingSettingsPage() { +export default async function BillingSettingsPage() { + await getScopeContext(); return ( -
-

Billing

+
+

Billing

); diff --git a/app/(dashboard)/[scope]/settings/settings-page-client.tsx b/app/(dashboard)/[scope]/settings/settings-page-client.tsx index e208d419..3ea687d5 100644 --- a/app/(dashboard)/[scope]/settings/settings-page-client.tsx +++ b/app/(dashboard)/[scope]/settings/settings-page-client.tsx @@ -1,227 +1,16 @@ -"use client" +"use client"; -import { useEffect, useCallback, useMemo } from "react" -import { usePathname, useRouter, useSearchParams } from "next/navigation" -import useSWR from "swr" -import { useUser } from "@/hooks/use-user" -import { fetchJsonArray, fetchJsonObject } from "@/lib/client-fetch" -import { TeamSettingsClient } from "@/components/settings/team-settings-client" -import { TeamsListSection } from "@/components/settings/teams-list-section" -import { BillingSection } from "@/components/settings/billing-section" -import { DecisionChecksSection } from "@/components/settings/decision-checks-section" -import { CliApiKeysSection } from "@/components/settings/cli-api-keys-section" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" -import { trackActivation } from "@/lib/activation-tracking" -import { getLegacySettingsDestination } from "@/lib/settings-redirect" -import type { ScopeContext } from "@/lib/scope-context" - -import { - AccountSection, - ApiKeysSection, - type GithubInstallationView, - type GithubOwnerTarget, - type SettingsView, - type SettingsTab, - type KeysSubTab, - SETTINGS_TAB_SET, - KEYS_SUB_TAB_SET, -} from "./_components" +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { getLegacySettingsDestination } from "@/lib/settings-redirect"; +import type { ScopeContext } from "@/lib/scope-context"; export function SettingsPageClient({ scope }: { scope: ScopeContext }) { - const router = useRouter() - const searchParams = useSearchParams() - const query = searchParams.toString() - const destination = getLegacySettingsDestination(scope, query) + const router = useRouter(); + const query = useSearchParams().toString(); useEffect(() => { - const target = getLegacySettingsDestination(scope, query, window.location.hash) - if (target) router.replace(target, { scroll: false }) - }, [scope, query, router]) - if (destination) return null - - if (scope.kind === "team") { - return - } - - return -} - -function PersonalSettingsClient() { - const router = useRouter() - const pathname = usePathname() - const searchParams = useSearchParams() - const tabParam = searchParams?.get("tab") ?? "" - const subParam = searchParams?.get("sub") ?? "" - const activeTab: SettingsTab = SETTINGS_TAB_SET.has(tabParam) ? (tabParam as SettingsTab) : "account" - const activeSubTab: KeysSubTab = KEYS_SUB_TAB_SET.has(subParam) ? (subParam as KeysSubTab) : "api" - - const handleTabChange = useCallback( - (value: string) => { - const params = new URLSearchParams(searchParams?.toString() ?? "") - params.set("tab", value) - params.delete("sub") - router.replace(`${pathname}?${params.toString()}`, { scroll: false }) - }, - [router, pathname, searchParams], - ) - - const handleSubTabChange = useCallback( - (value: string) => { - const params = new URLSearchParams(searchParams?.toString() ?? "") - params.set("tab", "keys") - params.set("sub", value) - router.replace(`${pathname}?${params.toString()}`, { scroll: false }) - }, - [router, pathname, searchParams], - ) - - const { user, isLoading } = useUser() - const { error: settingsError } = useSWR( - "/api/settings", - (url: string) => fetchJsonObject(url, "Failed to load settings"), - ) - const settingsLoadError = settingsError ? "Unable to load settings preferences" : null - const { - data: githubInstallations, - error: githubInstallationsError, - } = useSWR( - "/api/github/installations", - (url: string) => fetchJsonArray(url, "Failed to load GitHub installations"), - ) - const { data: githubOwnerTargets } = useSWR( - user?.github_connected ? "/api/github/owners" : null, - (url: string) => fetchJsonArray(url, "Failed to load GitHub accounts"), - ) - const ownerTargets = githubOwnerTargets ?? [] - const ownerTargetsNeedingInstall = ownerTargets.filter((target) => target.github_installation_id == null) - const githubInstallationsLoadError = githubInstallationsError ? "Unable to load GitHub App installations" : null - const platformAccess = user ? user.platform_access : null - const platformAiEnabled = platformAccess?.allowPlatformAi ?? null - - const githubPrimaryAction = useMemo(() => { - if (user?.github_primary_action) return user.github_primary_action - if (!user?.github_connected) { - return { - label: user?.github_app_available ? "Install GitHub App" : "Connect GitHub", - href: "/api/auth/github", - } - } - return null - }, [user]) - - const showGithubAddInstallAction = Boolean( - user?.github_app_available && - user?.github_connected && - githubPrimaryAction?.href !== "/api/auth/github", - ) - - const nextStep = useMemo(() => { - if (githubPrimaryAction) { - const githubState = user?.github_state - const title = githubState === "app_installed" - ? "Sync repositories next" - : githubState === "app_install_pending" - ? "Complete the GitHub App install" - : githubState === "oauth_connected" - ? "Upgrade GitHub from OAuth to the App" - : "Install GitHub App next" - const description = githubState === "app_installed" - ? "Open Projects and sync the repositories covered by your GitHub App installation into Mogplex." - : user?.github_status_detail || "GitHub powers repo sync, trigger coverage, and automation." - return { - title, - description, - href: githubPrimaryAction.href, - label: githubPrimaryAction.label, - } - } - - if (!user?.github_connected) { - return { - title: user?.github_app_available ? "Install GitHub App next" : "Connect GitHub next", - description: "GitHub is required to import repositories and make Open Workspace useful.", - href: "/api/auth/github", - label: user?.github_app_available ? "Install GitHub App" : "Connect GitHub", - } - } - - return null - }, [githubPrimaryAction, user]) - - const trackConnectionStart = useCallback((provider: "github", source: string) => { - if (provider === "github") { - trackActivation("github_connect_started", { - source, - connection_mode: user?.github_app_available ? "app" : "oauth", - }) - } - }, [user?.github_app_available]) - - return ( -
-
-

Settings

-
Account preferences, keys, and billing.
-
- - {settingsLoadError && ( -
{settingsLoadError}
- )} - - - - - Account - Teams - Keys & Tokens - Billing - - - - - - -
- -
-
- - - - - - - - - Provider Keys - Mogplex Keys - - - - - - - - - - - - - -
-
- ) + // 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…

; } - -export default SettingsPageClient diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 40d59a0c..7fda47b2 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -19,6 +19,7 @@ import { DeliveryTruck, Flash, PlugTypeA, + NavArrowRight, Repository, Rocket, Search, @@ -33,6 +34,8 @@ import { 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"; @@ -40,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, @@ -72,16 +76,22 @@ function SidebarNavLink({ item, 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 ( {item.label} )} + {item.id === "settings" && !compact &&