Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions app/(dashboard)/[scope]/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
@@ -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();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning: Team scope can still reach the personal MCP page

TEAM_SETTINGS has no mcp entry and this guard notFounds anything outside members/keys/models/audit, but the static settings/mcp/page.tsx segment wins over [section] and performs no scope-kind check — /{team}/settings/mcp renders the personal MCP servers page with no nav entry pointing at it. The route predates this PR, but the PR is what establishes the personal-only section list, so the inconsistency is now part of this contract.

Suggestion: in settings/mcp/page.tsx, const scope = await getScopeContext(); if (scope.kind === "team") notFound(); (mirroring the guard here), or add mcp to TEAM_SETTINGS if team-scoped MCP is intended.

Related nit on the same page: the personal branch renders a title-only page for any PERSONAL_SETTINGS id without a matching component. It is unreachable today (mcp/billing have static routes), but a future section added to the list would silently render an empty page — consider a notFound() in the else branch.

return <TeamSettingsClient teamId={scope.teamId} teamSlug={scope.slug} section={section} />;
}

return (
<div className="min-h-full w-full max-w-[1488px] space-y-4 p-3 md:space-y-6 md:p-6">
<h1 className="ui-page-title">{item.label}</h1>
{section === "account" && <PersonalAccountPage />}
{section === "teams" && <TeamsListSection />}
{section === "keys" && <ProviderKeysPage />}
{section === "mogplex-keys" && <CliApiKeysSection />}
</div>
);
}
6 changes: 0 additions & 6 deletions app/(dashboard)/[scope]/settings/_components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
@@ -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<SettingsView>(
"/api/settings",
(url: string) => fetchJsonObject<SettingsView>(url, "Failed to load settings"),
)
const settingsLoadError = settingsError ? "Unable to load settings preferences" : null
const {
data: githubInstallations,
error: githubInstallationsError,
} = useSWR<GithubInstallationView[]>(
"/api/github/installations",
(url: string) => fetchJsonArray<GithubInstallationView>(url, "Failed to load GitHub installations"),
)
const { data: githubOwnerTargets } = useSWR<GithubOwnerTarget[]>(
user?.github_connected ? "/api/github/owners" : null,
(url: string) => fetchJsonArray<GithubOwnerTarget>(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 (
<div className="space-y-4 md:space-y-6">
{settingsLoadError && <div className="text-sm text-destructive">{settingsLoadError}</div>}
<AccountSection
isLoading={isLoading}
user={user}
githubPrimaryAction={githubPrimaryAction}
showGithubAddInstallAction={showGithubAddInstallAction}
nextStep={nextStep}
trackConnectionStart={trackConnectionStart}
githubInstallationsLoadError={githubInstallationsLoadError}
githubInstallations={githubInstallations}
ownerTargets={ownerTargets}
ownerTargetsNeedingInstall={ownerTargetsNeedingInstall}
/>
<DecisionChecksSection endpoint="/api/settings/decision-checks" audience="personal" />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -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 <ApiKeysSection platformAiEnabled={isLoading ? null : user?.platform_access?.allowPlatformAi ?? null} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = 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<string> = new Set(KEYS_SUB_TABS);
8 changes: 5 additions & 3 deletions app/(dashboard)/[scope]/settings/billing/page.tsx
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -10,10 +11,11 @@ export const metadata: Metadata = {
},
};

export default function BillingSettingsPage() {
export default async function BillingSettingsPage() {
await getScopeContext();
return (
<div className="mx-auto w-full max-w-3xl p-6">
<h1 className="mb-6 text-xl font-semibold">Billing</h1>
<div className="min-h-full w-full max-w-[1488px] space-y-4 p-3 md:space-y-6 md:p-6">
<h1 className="ui-page-title">Billing</h1>
<BillingSection />
</div>
);
Expand Down
Loading
Loading