From 076a7fb6d7d4df2304ead271ba956dde86cbdcd0 Mon Sep 17 00:00:00 2001
From: Ben Senescu <44480372+bensenescu@users.noreply.github.com>
Date: Tue, 9 Jun 2026 20:12:09 -0400
Subject: [PATCH] Multi-project support per organization (#246)
---
src/client/components/Sidebar.tsx | 17 +-
.../features/gsc/GscReEngagementModal.tsx | 4 +-
.../SearchConsoleOnboardingStep.tsx | 17 +-
.../features/projects/CreateProjectModal.tsx | 111 +++++++++
.../features/projects/ProjectSettings.tsx | 225 ++++++++++++++++++
.../features/projects/ProjectSwitcher.tsx | 124 ++++++++++
src/client/features/projects/types.ts | 7 +
src/client/layout/AppShell.tsx | 54 +++--
src/client/lib/active-project.ts | 32 +++
src/client/navigation/items.ts | 9 +-
src/routeTree.gen.ts | 65 +++--
src/routes/_app/index.tsx | 42 ++--
src/routes/_app/projects.tsx | 90 +++++++
.../_project/p/$projectId/integrations.tsx | 25 --
src/routes/_project/p/$projectId/route.tsx | 21 +-
src/routes/_project/p/$projectId/settings.tsx | 15 ++
.../repositories/ProjectRepository.ts | 62 +++--
.../projects/services/ProjectService.ts | 8 +-
.../projects/services/projects.test.ts | 155 ++++++++++--
.../features/projects/services/projects.ts | 111 +++++----
.../mcp/tools/search-console-tools.test.ts | 4 +-
src/server/mcp/tools/search-console-tools.ts | 14 +-
src/serverFunctions/projects.ts | 35 ++-
src/types/schemas/projects.ts | 29 ++-
24 files changed, 1052 insertions(+), 224 deletions(-)
create mode 100644 src/client/features/projects/CreateProjectModal.tsx
create mode 100644 src/client/features/projects/ProjectSettings.tsx
create mode 100644 src/client/features/projects/ProjectSwitcher.tsx
create mode 100644 src/client/features/projects/types.ts
create mode 100644 src/client/lib/active-project.ts
create mode 100644 src/routes/_app/projects.tsx
delete mode 100644 src/routes/_project/p/$projectId/integrations.tsx
create mode 100644 src/routes/_project/p/$projectId/settings.tsx
diff --git a/src/client/components/Sidebar.tsx b/src/client/components/Sidebar.tsx
index 286c5b4..c004375 100644
--- a/src/client/components/Sidebar.tsx
+++ b/src/client/components/Sidebar.tsx
@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
-import { ChevronsUpDown, X } from "lucide-react";
+import { X } from "lucide-react";
import { getProjectNavGroups } from "@/client/navigation/items";
+import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
interface SidebarProps {
projectId: string;
@@ -29,15 +30,11 @@ export function Sidebar({ projectId, onNavigate, onClose }: SidebarProps) {
{/* Project picker */}
@@ -33,11 +34,7 @@ export function SearchConsoleOnboardingStep() {
Connect with Google Search Console now?
- {projectQuery.data ? (
-
- ) : (
-
- )}
+ {projectId ?
:
}
For now, Search Console data flows through the OpenSEO MCP. We're
diff --git a/src/client/features/projects/CreateProjectModal.tsx b/src/client/features/projects/CreateProjectModal.tsx
new file mode 100644
index 0000000..23f248e
--- /dev/null
+++ b/src/client/features/projects/CreateProjectModal.tsx
@@ -0,0 +1,111 @@
+import * as React from "react";
+import { useNavigate } from "@tanstack/react-router";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { Modal } from "@/client/components/Modal";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import { setLastProjectId } from "@/client/lib/active-project";
+import { createProject } from "@/serverFunctions/projects";
+
+export function CreateProjectModal({ onClose }: { onClose: () => void }) {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [name, setName] = React.useState("");
+ const [domain, setDomain] = React.useState("");
+
+ const createMutation = useMutation({
+ mutationFn: () =>
+ createProject({
+ data: { name: name.trim(), domain: domain.trim() || undefined },
+ }),
+ onSuccess: async (created) => {
+ setLastProjectId(created.id);
+ await queryClient.invalidateQueries({ queryKey: ["projects"] });
+ onClose();
+ toast.success("Project created");
+ // Land on the new project's settings so they can connect Search Console
+ // and finish setting up the workspace.
+ void navigate({
+ to: "/p/$projectId/settings",
+ params: { projectId: created.id },
+ });
+ },
+ onError: (error) =>
+ toast.error(getStandardErrorMessage(error, "Failed to create project")),
+ });
+
+ const isPending = createMutation.isPending;
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (isPending) return;
+ if (!name.trim()) {
+ toast.error("Project name is required");
+ return;
+ }
+ createMutation.mutate();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/src/client/features/projects/ProjectSettings.tsx b/src/client/features/projects/ProjectSettings.tsx
new file mode 100644
index 0000000..4383fd8
--- /dev/null
+++ b/src/client/features/projects/ProjectSettings.tsx
@@ -0,0 +1,225 @@
+import * as React from "react";
+import { Link, useNavigate } from "@tanstack/react-router";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { ChevronLeft } from "lucide-react";
+import { toast } from "sonner";
+import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
+import { getStandardErrorMessage } from "@/client/lib/error-messages";
+import {
+ clearLastProjectId,
+ getLastProjectId,
+} from "@/client/lib/active-project";
+import {
+ deleteProject,
+ getProjects,
+ updateProject,
+} from "@/serverFunctions/projects";
+import type { ProjectSummary } from "./types";
+
+export function ProjectSettings({ projectId }: { projectId: string }) {
+ const projectsQuery = useQuery({
+ queryKey: ["projects"],
+ queryFn: () => getProjects(),
+ });
+ const projects = projectsQuery.data ?? [];
+ const project = projects.find((entry) => entry.id === projectId) ?? null;
+
+ if (!project) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Projects
+
+
+
+ Project settings
+
+
{project.name}
+
+
+
+ {/* key resets the form's local state when switching between projects */}
+
+
+
+
+
1} />
+
+ );
+}
+
+function GeneralSection({ project }: { project: ProjectSummary }) {
+ const queryClient = useQueryClient();
+ const [name, setName] = React.useState(project.name);
+ const [domain, setDomain] = React.useState(project.domain ?? "");
+
+ const updateMutation = useMutation({
+ mutationFn: () =>
+ updateProject({
+ data: {
+ projectId: project.id,
+ name: name.trim(),
+ domain: domain.trim() || undefined,
+ },
+ }),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({ queryKey: ["projects"] });
+ toast.success("Project updated");
+ },
+ onError: (error) =>
+ toast.error(getStandardErrorMessage(error, "Failed to update project")),
+ });
+
+ const isDirty =
+ name.trim() !== project.name ||
+ (domain.trim() || "") !== (project.domain ?? "");
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (updateMutation.isPending) return;
+ if (!name.trim()) {
+ toast.error("Project name is required");
+ return;
+ }
+ updateMutation.mutate();
+ };
+
+ return (
+
+ );
+}
+
+function DangerSection({
+ project,
+ canDelete,
+}: {
+ project: ProjectSummary;
+ canDelete: boolean;
+}) {
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const [confirming, setConfirming] = React.useState(false);
+
+ const deleteMutation = useMutation({
+ mutationFn: () => deleteProject({ data: { projectId: project.id } }),
+ onSuccess: async () => {
+ if (getLastProjectId() === project.id) clearLastProjectId();
+ await queryClient.invalidateQueries({ queryKey: ["projects"] });
+ toast.success("Project deleted");
+ // Re-resolve to a remaining project via the landing redirect.
+ void navigate({ to: "/" });
+ },
+ onError: (error) =>
+ toast.error(getStandardErrorMessage(error, "Failed to delete project")),
+ });
+
+ return (
+
+
+ Delete project
+
+
+ {confirming ? (
+
+
+ Deleting{" "}
+
+ {project.name}
+ {" "}
+ permanently removes its Search Console connection, rank tracking,
+ audits, and saved keywords. This can't be undone.
+
+
+ deleteMutation.mutate()}
+ disabled={deleteMutation.isPending}
+ >
+ Yes, delete project
+
+ setConfirming(false)}
+ disabled={deleteMutation.isPending}
+ >
+ Cancel
+
+
+
+ ) : (
+
+
+ {canDelete
+ ? "Permanently delete this project and all of its data."
+ : "You can't delete your only project."}
+
+
setConfirming(true)}
+ disabled={!canDelete}
+ >
+ Delete project
+
+
+ )}
+
+ );
+}
diff --git a/src/client/features/projects/ProjectSwitcher.tsx b/src/client/features/projects/ProjectSwitcher.tsx
new file mode 100644
index 0000000..29af615
--- /dev/null
+++ b/src/client/features/projects/ProjectSwitcher.tsx
@@ -0,0 +1,124 @@
+import { Link, useNavigate } from "@tanstack/react-router";
+import { useQuery } from "@tanstack/react-query";
+import { Check, ChevronsUpDown, FolderCog } from "lucide-react";
+import { getProjects } from "@/serverFunctions/projects";
+import { setLastProjectId } from "@/client/lib/active-project";
+import type { ProjectSummary } from "./types";
+
+function closeDropdown() {
+ if (document.activeElement instanceof HTMLElement) {
+ document.activeElement.blur();
+ }
+}
+
+export function ProjectSwitcher({
+ activeProjectId,
+ variant = "topbar",
+ onCloseDrawer,
+}: {
+ activeProjectId: string | null;
+ variant?: "topbar" | "sidebar";
+ // Mobile sidebar passes this so switching / navigating away also closes the
+ // drawer overlay.
+ onCloseDrawer?: () => void;
+}) {
+ const navigate = useNavigate();
+ const projectsQuery = useQuery({
+ queryKey: ["projects"],
+ queryFn: () => getProjects(),
+ });
+ const projects = projectsQuery.data ?? [];
+ const activeProject =
+ projects.find((project) => project.id === activeProjectId) ?? null;
+
+ const isSidebar = variant === "sidebar";
+
+ const handleSelect = (project: ProjectSummary) => {
+ closeDropdown();
+ onCloseDrawer?.();
+ if (project.id === activeProjectId) return;
+ setLastProjectId(project.id);
+ void navigate({
+ to: "/p/$projectId/keywords",
+ params: { projectId: project.id },
+ });
+ };
+
+ return (
+
+
+
+
+ {activeProject?.name ?? "Select project"}
+
+ {activeProject?.domain ? (
+
+ {activeProject.domain}
+
+ ) : null}
+
+
+
+
+
+ {projects.map((project) => {
+ const isActive = project.id === activeProjectId;
+ return (
+
+ handleSelect(project)}
+ className={isActive ? "active" : ""}
+ >
+
+ {project.name}
+ {project.domain ? (
+
+ {project.domain}
+
+ ) : null}
+
+ {isActive ? (
+
+ ) : null}
+
+
+ );
+ })}
+
+ {projects.length > 0 ? (
+
+
+
+ ) : null}
+
+
+ {
+ closeDropdown();
+ onCloseDrawer?.();
+ }}
+ >
+
+ Manage projects
+
+
+
+
+ );
+}
diff --git a/src/client/features/projects/types.ts b/src/client/features/projects/types.ts
new file mode 100644
index 0000000..22d3a49
--- /dev/null
+++ b/src/client/features/projects/types.ts
@@ -0,0 +1,7 @@
+// Shape returned by the getProjects server function (a mapped project row).
+export type ProjectSummary = {
+ id: string;
+ name: string;
+ domain: string | null;
+ createdAt: string;
+};
diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx
index bd041f9..1e921f3 100644
--- a/src/client/layout/AppShell.tsx
+++ b/src/client/layout/AppShell.tsx
@@ -3,11 +3,10 @@ import { Link, useLocation } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import {
ChevronDown,
- ChevronsUpDown,
CircleHelp,
CreditCard,
+ FolderCog,
Menu,
- Plug,
Settings,
User,
} from "lucide-react";
@@ -19,13 +18,15 @@ import {
import { GscReEngagementModal } from "@/client/features/gsc/GscReEngagementModal";
import {
getProjectNavGroups,
- integrationsLinkOptions,
+ projectSettingsLinkOptions,
} from "@/client/navigation/items";
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { BILLING_ROUTE } from "@/shared/billing";
import { getSeoApiKeyStatus } from "@/serverFunctions/config";
-import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
+import { getProjects } from "@/serverFunctions/projects";
+import { ProjectSwitcher } from "@/client/features/projects/ProjectSwitcher";
+import { getLastProjectId } from "@/client/lib/active-project";
const DATAFORSEO_HELP_PATH = "/help/dataforseo-api-key";
const SUPPORT_PATH = "/support";
@@ -44,12 +45,27 @@ export function AuthenticatedAppLayout({
const setupModalRef = React.useRef
(null);
const [showMissingSeoApiKeyModal, setShowMissingSeoApiKeyModal] =
React.useState(false);
- const defaultProjectQuery = useQuery({
- queryKey: ["defaultProject"],
- queryFn: () => getOrCreateDefaultProject(),
+ // On non-project pages (e.g. /settings) there's no projectId in the URL, so
+ // derive one for the nav/switcher: prefer the last-visited project, else the
+ // most recent. Reading localStorage in an effect keeps SSR/first render stable.
+ const projectsQuery = useQuery({
+ queryKey: ["projects"],
+ queryFn: () => getProjects(),
enabled: !projectId,
});
- const headerProjectId = projectId ?? defaultProjectQuery.data?.id ?? null;
+ const [rememberedProjectId, setRememberedProjectId] = React.useState<
+ string | null
+ >(null);
+ React.useEffect(() => {
+ setRememberedProjectId(getLastProjectId());
+ }, []);
+ const fallbackProjects = projectsQuery.data ?? [];
+ const fallbackProjectId =
+ fallbackProjects.find((project) => project.id === rememberedProjectId)
+ ?.id ??
+ fallbackProjects[0]?.id ??
+ null;
+ const headerProjectId = projectId ?? fallbackProjectId;
const shouldCheckSeoApiKeyStatus = location.pathname !== BILLING_ROUTE;
const seoApiKeyStatusQuery = useQuery({
queryKey: ["seoApiKeyStatus"],
@@ -284,21 +300,7 @@ function TopNav({