Multi-project support per organization (#246)
This commit is contained in:
parent
e16f8d5647
commit
076a7fb6d7
@ -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 */}
|
||||
<div className="px-3 py-3 border-b border-base-300">
|
||||
<div
|
||||
className="tooltip tooltip-bottom w-full"
|
||||
data-tip="Multiple projects coming soon"
|
||||
>
|
||||
<button className="btn btn-ghost btn-sm w-full justify-between font-medium text-sm cursor-default">
|
||||
<span className="truncate">Default</span>
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
|
||||
</button>
|
||||
</div>
|
||||
<ProjectSwitcher
|
||||
activeProjectId={projectId}
|
||||
variant="sidebar"
|
||||
onCloseDrawer={onNavigate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
|
||||
@ -87,10 +87,10 @@ export function GscReEngagementModal({
|
||||
// screen, and on return they'll either have a grant (which suppresses this
|
||||
// anyway) or have abandoned it — neither case should re-nag.
|
||||
persistDismiss();
|
||||
// Land them on the project's Integrations page so they can pick a property
|
||||
// Land them on the project's settings page so they can pick a property
|
||||
// right after granting access (the grant alone has no property bound yet).
|
||||
const callbackURL = projectId
|
||||
? `${window.location.origin}/p/${projectId}/integrations`
|
||||
? `${window.location.origin}/p/${projectId}/settings#search-console`
|
||||
: window.location.href;
|
||||
void startGscLink(callbackURL);
|
||||
}
|
||||
|
||||
@ -13,19 +13,20 @@ import {
|
||||
listGscSites,
|
||||
setGscSite,
|
||||
} from "@/serverFunctions/gsc";
|
||||
import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
||||
import { getProjects } from "@/serverFunctions/projects";
|
||||
|
||||
/**
|
||||
* Onboarding step for connecting Google Search Console: link the account-level
|
||||
* OAuth grant, then bind a verified property to the user's default project —
|
||||
* OAuth grant, then bind a verified property to the user's first project —
|
||||
* the same binding the project's Integrations page does — so it's done in one
|
||||
* place.
|
||||
*/
|
||||
export function SearchConsoleOnboardingStep() {
|
||||
const projectQuery = useQuery({
|
||||
queryKey: ["defaultProject"],
|
||||
queryFn: () => getOrCreateDefaultProject(),
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: () => getProjects(),
|
||||
});
|
||||
const projectId = projectsQuery.data?.[0]?.id;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@ -33,11 +34,7 @@ export function SearchConsoleOnboardingStep() {
|
||||
Connect with Google Search Console now?
|
||||
</h2>
|
||||
|
||||
{projectQuery.data ? (
|
||||
<GscConnect projectId={projectQuery.data.id} />
|
||||
) : (
|
||||
<Checking />
|
||||
)}
|
||||
{projectId ? <GscConnect projectId={projectId} /> : <Checking />}
|
||||
|
||||
<p className="text-xs leading-relaxed text-base-content/55">
|
||||
For now, Search Console data flows through the OpenSEO MCP. We're
|
||||
|
||||
111
src/client/features/projects/CreateProjectModal.tsx
Normal file
111
src/client/features/projects/CreateProjectModal.tsx
Normal file
@ -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 (
|
||||
<Modal
|
||||
maxWidth="max-w-md"
|
||||
onClose={isPending ? undefined : onClose}
|
||||
labelledBy="create-project-title"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<h2 id="create-project-title" className="text-lg font-semibold">
|
||||
New project
|
||||
</h2>
|
||||
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="font-medium">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
maxLength={120}
|
||||
autoFocus
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="font-medium">
|
||||
Domain <span className="text-base-content/50">(optional)</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="example.com"
|
||||
maxLength={255}
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
<span className="text-xs text-base-content/50">
|
||||
You can connect Search Console and set up rank tracking after
|
||||
creating the project.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={isPending}
|
||||
>
|
||||
Create project
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
225
src/client/features/projects/ProjectSettings.tsx
Normal file
225
src/client/features/projects/ProjectSettings.tsx
Normal file
@ -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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<span className="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-8 p-4 py-8 sm:p-6 md:py-12">
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
to="/projects"
|
||||
className="inline-flex items-center gap-1 text-sm text-base-content/60 transition-colors hover:text-base-content"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
Projects
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Project settings
|
||||
</h1>
|
||||
<p className="text-sm text-base-content/60">{project.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* key resets the form's local state when switching between projects */}
|
||||
<GeneralSection key={project.id} project={project} />
|
||||
|
||||
<section id="search-console" className="space-y-3 scroll-mt-6">
|
||||
<h2 className="text-sm font-medium text-base-content/50">
|
||||
Search Console
|
||||
</h2>
|
||||
<SearchConsoleConnectionCard projectId={projectId} />
|
||||
</section>
|
||||
|
||||
<DangerSection project={project} canDelete={projects.length > 1} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-base-content/50">General</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="font-medium">Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
maxLength={120}
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1.5 text-sm">
|
||||
<span className="font-medium">
|
||||
Domain <span className="text-base-content/50">(optional)</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
placeholder="example.com"
|
||||
maxLength={255}
|
||||
className="input input-bordered w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={updateMutation.isPending || !isDirty}
|
||||
>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="space-y-3 border-t border-base-300 pt-8">
|
||||
<h2 className="text-sm font-medium text-base-content/50">
|
||||
Delete project
|
||||
</h2>
|
||||
|
||||
{confirming ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-base-content/70">
|
||||
Deleting{" "}
|
||||
<span className="font-medium text-base-content">
|
||||
{project.name}
|
||||
</span>{" "}
|
||||
permanently removes its Search Console connection, rank tracking,
|
||||
audits, and saved keywords. This can't be undone.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error btn-sm"
|
||||
onClick={() => deleteMutation.mutate()}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Yes, delete project
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-base-content/60">
|
||||
{canDelete
|
||||
? "Permanently delete this project and all of its data."
|
||||
: "You can't delete your only project."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline btn-error btn-sm shrink-0"
|
||||
onClick={() => setConfirming(true)}
|
||||
disabled={!canDelete}
|
||||
>
|
||||
Delete project
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
124
src/client/features/projects/ProjectSwitcher.tsx
Normal file
124
src/client/features/projects/ProjectSwitcher.tsx
Normal file
@ -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 (
|
||||
<div className={`dropdown ${isSidebar ? "w-full" : "dropdown-end"}`}>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
aria-label="Switch project"
|
||||
className={
|
||||
isSidebar
|
||||
? "btn btn-ghost btn-sm w-full justify-between font-medium"
|
||||
: "flex h-10 max-w-[12rem] items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
|
||||
}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-base-content">
|
||||
{activeProject?.name ?? "Select project"}
|
||||
</span>
|
||||
{activeProject?.domain ? (
|
||||
<span className="truncate text-xs font-normal text-base-content/50">
|
||||
{activeProject.domain}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/40" />
|
||||
</button>
|
||||
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className={`dropdown-content z-30 menu rounded-box border border-base-300 bg-base-100 p-2 shadow-lg ${
|
||||
isSidebar ? "w-full" : "mt-2 w-64"
|
||||
}`}
|
||||
>
|
||||
{projects.map((project) => {
|
||||
const isActive = project.id === activeProjectId;
|
||||
return (
|
||||
<li key={project.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelect(project)}
|
||||
className={isActive ? "active" : ""}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate">{project.name}</span>
|
||||
{project.domain ? (
|
||||
<span className="truncate text-xs text-base-content/50">
|
||||
{project.domain}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isActive ? (
|
||||
<Check className="size-4 shrink-0 text-primary" />
|
||||
) : null}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
{projects.length > 0 ? (
|
||||
<li>
|
||||
<hr className="my-1 border-base-300" />
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
<li>
|
||||
<Link
|
||||
to="/projects"
|
||||
onClick={() => {
|
||||
closeDropdown();
|
||||
onCloseDrawer?.();
|
||||
}}
|
||||
>
|
||||
<FolderCog className="size-4" />
|
||||
Manage projects
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/client/features/projects/types.ts
Normal file
7
src/client/features/projects/types.ts
Normal file
@ -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;
|
||||
};
|
||||
@ -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<HTMLDivElement | null>(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({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center rounded-full border border-base-300 bg-base-100/70 px-1 py-1 shadow-sm">
|
||||
<div
|
||||
className="tooltip tooltip-left before:whitespace-nowrap"
|
||||
data-tip="Multiple projects coming soon"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-10 cursor-default items-center gap-2 rounded-full px-3 text-left transition-colors hover:bg-base-200/80"
|
||||
aria-label="Current project"
|
||||
>
|
||||
<span className="max-w-28 truncate text-sm font-medium text-base-content">
|
||||
Default
|
||||
</span>
|
||||
<ChevronsUpDown className="size-3.5 shrink-0 text-base-content/35" />
|
||||
</button>
|
||||
</div>
|
||||
<ProjectSwitcher activeProjectId={projectId} variant="topbar" />
|
||||
|
||||
<AccountMenu projectId={projectId} />
|
||||
</div>
|
||||
@ -363,11 +365,11 @@ function AccountMenu({
|
||||
{projectId ? (
|
||||
<li>
|
||||
<Link
|
||||
{...integrationsLinkOptions(projectId)}
|
||||
{...projectSettingsLinkOptions(projectId)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plug className="h-4 w-4" />
|
||||
Integrations
|
||||
<FolderCog className="h-4 w-4" />
|
||||
Project settings
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
32
src/client/lib/active-project.ts
Normal file
32
src/client/lib/active-project.ts
Normal file
@ -0,0 +1,32 @@
|
||||
// Remembers the last project the user was looking at so the app can return them
|
||||
// there on the next visit. Browser-local only (per-device); the server never
|
||||
// trusts it — landing and the route guard always re-validate the id against the
|
||||
// org's project list.
|
||||
const LAST_PROJECT_KEY = "openseo:lastProjectId";
|
||||
|
||||
export function getLastProjectId(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage.getItem(LAST_PROJECT_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLastProjectId(projectId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(LAST_PROJECT_KEY, projectId);
|
||||
} catch {
|
||||
// Ignore private-mode / disabled-storage failures.
|
||||
}
|
||||
}
|
||||
|
||||
export function clearLastProjectId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.removeItem(LAST_PROJECT_KEY);
|
||||
} catch {
|
||||
// Ignore private-mode / disabled-storage failures.
|
||||
}
|
||||
}
|
||||
@ -120,12 +120,11 @@ export function getProjectNavGroups(projectId: string) {
|
||||
];
|
||||
}
|
||||
|
||||
// Integrations is a setup surface, so it lives in the account dropdown rather
|
||||
// than the workflow nav — but it's still project-scoped (a property is bound
|
||||
// per project), hence the projectId param.
|
||||
export function integrationsLinkOptions(projectId: string) {
|
||||
// Per-project configuration (name, domain, Search Console, delete) lives on the
|
||||
// project settings page rather than the workflow nav, hence the projectId param.
|
||||
export function projectSettingsLinkOptions(projectId: string) {
|
||||
return linkOptions({
|
||||
to: "/p/$projectId/integrations" as const,
|
||||
to: "/p/$projectId/settings" as const,
|
||||
params: { projectId },
|
||||
});
|
||||
}
|
||||
|
||||
@ -24,6 +24,7 @@ import { Route as AuthSignUpRouteImport } from './routes/_auth.sign-up'
|
||||
import { Route as AuthSignInRouteImport } from './routes/_auth.sign-in'
|
||||
import { Route as AppSupportRouteImport } from './routes/_app/support'
|
||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||
import { Route as AppProjectsRouteImport } from './routes/_app/projects'
|
||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||
import { Route as AppAiRouteImport } from './routes/_app/ai'
|
||||
import { Route as Char91DotwellKnownChar93OpenaiAppsChallengeRouteImport } from './routes/[.well-known]/openai-apps-challenge'
|
||||
@ -33,11 +34,11 @@ import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/
|
||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||
import { Route as ApiGscOauthCallbackRouteImport } from './routes/api/gsc/oauth/callback'
|
||||
import { Route as ProjectPProjectIdSettingsRouteImport } from './routes/_project/p/$projectId/settings'
|
||||
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
||||
import { Route as ProjectPProjectIdRankTrackingRouteImport } from './routes/_project/p/$projectId/rank-tracking'
|
||||
import { Route as ProjectPProjectIdPromptExplorerRouteImport } from './routes/_project/p/$projectId/prompt-explorer'
|
||||
import { Route as ProjectPProjectIdKeywordsRouteImport } from './routes/_project/p/$projectId/keywords'
|
||||
import { Route as ProjectPProjectIdIntegrationsRouteImport } from './routes/_project/p/$projectId/integrations'
|
||||
import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p/$projectId/domain'
|
||||
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
|
||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||
@ -119,6 +120,11 @@ const AppSettingsRoute = AppSettingsRouteImport.update({
|
||||
path: '/settings',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const AppProjectsRoute = AppProjectsRouteImport.update({
|
||||
id: '/projects',
|
||||
path: '/projects',
|
||||
getParentRoute: () => AppRouteRoute,
|
||||
} as any)
|
||||
const AppBillingRoute = AppBillingRouteImport.update({
|
||||
id: '/billing',
|
||||
path: '/billing',
|
||||
@ -165,6 +171,12 @@ const ApiGscOauthCallbackRoute = ApiGscOauthCallbackRouteImport.update({
|
||||
path: '/api/gsc/oauth/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ProjectPProjectIdSettingsRoute =
|
||||
ProjectPProjectIdSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdSavedRoute = ProjectPProjectIdSavedRouteImport.update({
|
||||
id: '/saved',
|
||||
path: '/saved',
|
||||
@ -188,12 +200,6 @@ const ProjectPProjectIdKeywordsRoute =
|
||||
path: '/keywords',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdIntegrationsRoute =
|
||||
ProjectPProjectIdIntegrationsRouteImport.update({
|
||||
id: '/integrations',
|
||||
path: '/integrations',
|
||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||
} as any)
|
||||
const ProjectPProjectIdDomainRoute = ProjectPProjectIdDomainRouteImport.update({
|
||||
id: '/domain',
|
||||
path: '/domain',
|
||||
@ -249,6 +255,7 @@ export interface FileRoutesByFullPath {
|
||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||
'/ai': typeof AppAiRoute
|
||||
'/billing': typeof AppBillingRoute
|
||||
'/projects': typeof AppProjectsRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/support': typeof AppSupportRoute
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
@ -264,11 +271,11 @@ export interface FileRoutesByFullPath {
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||
@ -284,6 +291,7 @@ export interface FileRoutesByTo {
|
||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||
'/ai': typeof AppAiRoute
|
||||
'/billing': typeof AppBillingRoute
|
||||
'/projects': typeof AppProjectsRoute
|
||||
'/settings': typeof AppSettingsRoute
|
||||
'/support': typeof AppSupportRoute
|
||||
'/sign-in': typeof AuthSignInRoute
|
||||
@ -297,10 +305,10 @@ export interface FileRoutesByTo {
|
||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
||||
'/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/p/$projectId': typeof ProjectPProjectIdIndexRoute
|
||||
'/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||
@ -320,6 +328,7 @@ export interface FileRoutesById {
|
||||
'/.well-known/openai-apps-challenge': typeof Char91DotwellKnownChar93OpenaiAppsChallengeRoute
|
||||
'/_app/ai': typeof AppAiRoute
|
||||
'/_app/billing': typeof AppBillingRoute
|
||||
'/_app/projects': typeof AppProjectsRoute
|
||||
'/_app/settings': typeof AppSettingsRoute
|
||||
'/_app/support': typeof AppSupportRoute
|
||||
'/_auth/sign-in': typeof AuthSignInRoute
|
||||
@ -336,11 +345,11 @@ export interface FileRoutesById {
|
||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||
'/_project/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||
'/_project/p/$projectId/integrations': typeof ProjectPProjectIdIntegrationsRoute
|
||||
'/_project/p/$projectId/keywords': typeof ProjectPProjectIdKeywordsRoute
|
||||
'/_project/p/$projectId/prompt-explorer': typeof ProjectPProjectIdPromptExplorerRoute
|
||||
'/_project/p/$projectId/rank-tracking': typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
'/_project/p/$projectId/saved': typeof ProjectPProjectIdSavedRoute
|
||||
'/_project/p/$projectId/settings': typeof ProjectPProjectIdSettingsRoute
|
||||
'/api/gsc/oauth/callback': typeof ApiGscOauthCallbackRoute
|
||||
'/_project/p/$projectId/': typeof ProjectPProjectIdIndexRoute
|
||||
'/_project/p/$projectId/rank-tracking/$configId': typeof ProjectPProjectIdRankTrackingConfigIdRoute
|
||||
@ -358,6 +367,7 @@ export interface FileRouteTypes {
|
||||
| '/.well-known/openai-apps-challenge'
|
||||
| '/ai'
|
||||
| '/billing'
|
||||
| '/projects'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/sign-in'
|
||||
@ -373,11 +383,11 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/integrations'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/prompt-explorer'
|
||||
| '/p/$projectId/rank-tracking'
|
||||
| '/p/$projectId/saved'
|
||||
| '/p/$projectId/settings'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/p/$projectId/'
|
||||
| '/p/$projectId/rank-tracking/$configId'
|
||||
@ -393,6 +403,7 @@ export interface FileRouteTypes {
|
||||
| '/.well-known/openai-apps-challenge'
|
||||
| '/ai'
|
||||
| '/billing'
|
||||
| '/projects'
|
||||
| '/settings'
|
||||
| '/support'
|
||||
| '/sign-in'
|
||||
@ -406,10 +417,10 @@ export interface FileRouteTypes {
|
||||
| '/p/$projectId/backlinks'
|
||||
| '/p/$projectId/brand-lookup'
|
||||
| '/p/$projectId/domain'
|
||||
| '/p/$projectId/integrations'
|
||||
| '/p/$projectId/keywords'
|
||||
| '/p/$projectId/prompt-explorer'
|
||||
| '/p/$projectId/saved'
|
||||
| '/p/$projectId/settings'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/p/$projectId'
|
||||
| '/p/$projectId/rank-tracking/$configId'
|
||||
@ -428,6 +439,7 @@ export interface FileRouteTypes {
|
||||
| '/.well-known/openai-apps-challenge'
|
||||
| '/_app/ai'
|
||||
| '/_app/billing'
|
||||
| '/_app/projects'
|
||||
| '/_app/settings'
|
||||
| '/_app/support'
|
||||
| '/_auth/sign-in'
|
||||
@ -444,11 +456,11 @@ export interface FileRouteTypes {
|
||||
| '/_project/p/$projectId/backlinks'
|
||||
| '/_project/p/$projectId/brand-lookup'
|
||||
| '/_project/p/$projectId/domain'
|
||||
| '/_project/p/$projectId/integrations'
|
||||
| '/_project/p/$projectId/keywords'
|
||||
| '/_project/p/$projectId/prompt-explorer'
|
||||
| '/_project/p/$projectId/rank-tracking'
|
||||
| '/_project/p/$projectId/saved'
|
||||
| '/_project/p/$projectId/settings'
|
||||
| '/api/gsc/oauth/callback'
|
||||
| '/_project/p/$projectId/'
|
||||
| '/_project/p/$projectId/rank-tracking/$configId'
|
||||
@ -578,6 +590,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AppSettingsRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/_app/projects': {
|
||||
id: '/_app/projects'
|
||||
path: '/projects'
|
||||
fullPath: '/projects'
|
||||
preLoaderRoute: typeof AppProjectsRouteImport
|
||||
parentRoute: typeof AppRouteRoute
|
||||
}
|
||||
'/_app/billing': {
|
||||
id: '/_app/billing'
|
||||
path: '/billing'
|
||||
@ -641,6 +660,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiGscOauthCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_project/p/$projectId/settings': {
|
||||
id: '/_project/p/$projectId/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/p/$projectId/settings'
|
||||
preLoaderRoute: typeof ProjectPProjectIdSettingsRouteImport
|
||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||
}
|
||||
'/_project/p/$projectId/saved': {
|
||||
id: '/_project/p/$projectId/saved'
|
||||
path: '/saved'
|
||||
@ -669,13 +695,6 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ProjectPProjectIdKeywordsRouteImport
|
||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||
}
|
||||
'/_project/p/$projectId/integrations': {
|
||||
id: '/_project/p/$projectId/integrations'
|
||||
path: '/integrations'
|
||||
fullPath: '/p/$projectId/integrations'
|
||||
preLoaderRoute: typeof ProjectPProjectIdIntegrationsRouteImport
|
||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||
}
|
||||
'/_project/p/$projectId/domain': {
|
||||
id: '/_project/p/$projectId/domain'
|
||||
path: '/domain'
|
||||
@ -738,6 +757,7 @@ declare module '@tanstack/react-router' {
|
||||
interface AppRouteRouteChildren {
|
||||
AppAiRoute: typeof AppAiRoute
|
||||
AppBillingRoute: typeof AppBillingRoute
|
||||
AppProjectsRoute: typeof AppProjectsRoute
|
||||
AppSettingsRoute: typeof AppSettingsRoute
|
||||
AppSupportRoute: typeof AppSupportRoute
|
||||
AppIndexRoute: typeof AppIndexRoute
|
||||
@ -747,6 +767,7 @@ interface AppRouteRouteChildren {
|
||||
const AppRouteRouteChildren: AppRouteRouteChildren = {
|
||||
AppAiRoute: AppAiRoute,
|
||||
AppBillingRoute: AppBillingRoute,
|
||||
AppProjectsRoute: AppProjectsRoute,
|
||||
AppSettingsRoute: AppSettingsRoute,
|
||||
AppSupportRoute: AppSupportRoute,
|
||||
AppIndexRoute: AppIndexRoute,
|
||||
@ -797,11 +818,11 @@ interface ProjectPProjectIdRouteRouteChildren {
|
||||
ProjectPProjectIdBacklinksRoute: typeof ProjectPProjectIdBacklinksRoute
|
||||
ProjectPProjectIdBrandLookupRoute: typeof ProjectPProjectIdBrandLookupRoute
|
||||
ProjectPProjectIdDomainRoute: typeof ProjectPProjectIdDomainRoute
|
||||
ProjectPProjectIdIntegrationsRoute: typeof ProjectPProjectIdIntegrationsRoute
|
||||
ProjectPProjectIdKeywordsRoute: typeof ProjectPProjectIdKeywordsRoute
|
||||
ProjectPProjectIdPromptExplorerRoute: typeof ProjectPProjectIdPromptExplorerRoute
|
||||
ProjectPProjectIdRankTrackingRoute: typeof ProjectPProjectIdRankTrackingRouteWithChildren
|
||||
ProjectPProjectIdSavedRoute: typeof ProjectPProjectIdSavedRoute
|
||||
ProjectPProjectIdSettingsRoute: typeof ProjectPProjectIdSettingsRoute
|
||||
ProjectPProjectIdIndexRoute: typeof ProjectPProjectIdIndexRoute
|
||||
}
|
||||
|
||||
@ -811,12 +832,12 @@ const ProjectPProjectIdRouteRouteChildren: ProjectPProjectIdRouteRouteChildren =
|
||||
ProjectPProjectIdBacklinksRoute: ProjectPProjectIdBacklinksRoute,
|
||||
ProjectPProjectIdBrandLookupRoute: ProjectPProjectIdBrandLookupRoute,
|
||||
ProjectPProjectIdDomainRoute: ProjectPProjectIdDomainRoute,
|
||||
ProjectPProjectIdIntegrationsRoute: ProjectPProjectIdIntegrationsRoute,
|
||||
ProjectPProjectIdKeywordsRoute: ProjectPProjectIdKeywordsRoute,
|
||||
ProjectPProjectIdPromptExplorerRoute: ProjectPProjectIdPromptExplorerRoute,
|
||||
ProjectPProjectIdRankTrackingRoute:
|
||||
ProjectPProjectIdRankTrackingRouteWithChildren,
|
||||
ProjectPProjectIdSavedRoute: ProjectPProjectIdSavedRoute,
|
||||
ProjectPProjectIdSettingsRoute: ProjectPProjectIdSettingsRoute,
|
||||
ProjectPProjectIdIndexRoute: ProjectPProjectIdIndexRoute,
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { getOrCreateDefaultProject } from "@/serverFunctions/projects";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getProjects } from "@/serverFunctions/projects";
|
||||
import {
|
||||
clearLastProjectId,
|
||||
getLastProjectId,
|
||||
} from "@/client/lib/active-project";
|
||||
import {
|
||||
getErrorCode,
|
||||
getStandardErrorMessage,
|
||||
@ -17,19 +21,29 @@ export const Route = createFileRoute("/_app/")({
|
||||
function IndexRedirect() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { mutate, error, isError } = useMutation({
|
||||
mutationFn: () => getOrCreateDefaultProject(),
|
||||
onSuccess: (project) => {
|
||||
void navigate({
|
||||
to: "/p/$projectId/keywords",
|
||||
params: { projectId: project.id },
|
||||
});
|
||||
},
|
||||
const { data, error, isError, refetch } = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: () => getProjects(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
mutate();
|
||||
}, [mutate]);
|
||||
if (!data || data.length === 0) return;
|
||||
|
||||
// localStorage is untrusted — only honor the remembered project if it's
|
||||
// actually in the org's list; otherwise fall back to the most recent and
|
||||
// clear the stale id.
|
||||
const lastProjectId = getLastProjectId();
|
||||
const target = data.find((project) => project.id === lastProjectId);
|
||||
if (lastProjectId && !target) {
|
||||
clearLastProjectId();
|
||||
}
|
||||
|
||||
void navigate({
|
||||
to: "/p/$projectId/keywords",
|
||||
params: { projectId: (target ?? data[0]).id },
|
||||
});
|
||||
}, [data, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (getErrorCode(error) !== "PAYMENT_REQUIRED") {
|
||||
@ -51,7 +65,7 @@ function IndexRedirect() {
|
||||
"An unexpected error occurred. Please check server logs.",
|
||||
)}
|
||||
onRetry={() => {
|
||||
mutate();
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -64,7 +78,7 @@ function IndexRedirect() {
|
||||
<UnauthenticatedErrorCard
|
||||
message="Please sign in to access your OpenSEO workspace."
|
||||
onRetry={() => {
|
||||
mutate();
|
||||
void refetch();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
90
src/routes/_app/projects.tsx
Normal file
90
src/routes/_app/projects.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import * as React from "react";
|
||||
import { Link, createFileRoute } from "@tanstack/react-router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ChevronRight, Plus } from "lucide-react";
|
||||
import { getProjects } from "@/serverFunctions/projects";
|
||||
import { getLastProjectId } from "@/client/lib/active-project";
|
||||
import { CreateProjectModal } from "@/client/features/projects/CreateProjectModal";
|
||||
|
||||
export const Route = createFileRoute("/_app/projects")({
|
||||
component: ProjectsPage,
|
||||
});
|
||||
|
||||
function ProjectsPage() {
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
// Read after mount to keep SSR/first render stable.
|
||||
const [currentProjectId, setCurrentProjectId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
setCurrentProjectId(getLastProjectId());
|
||||
}, []);
|
||||
const projectsQuery = useQuery({
|
||||
queryKey: ["projects"],
|
||||
queryFn: () => getProjects(),
|
||||
});
|
||||
const projects = projectsQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-base-100 px-4 py-8 pb-24 md:px-6 md:py-12 md:pb-8">
|
||||
<div className="mx-auto w-full max-w-2xl space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Projects</h1>
|
||||
<p className="mt-1 text-sm text-base-content/60">
|
||||
Each project is a separate workspace with its own Search Console,
|
||||
rank tracking, and audits.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm shrink-0"
|
||||
onClick={() => setCreating(true)}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{projectsQuery.isLoading ? (
|
||||
<div className="flex justify-center py-10">
|
||||
<span className="loading loading-spinner loading-md" />
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-base-300 overflow-hidden rounded-lg border border-base-300">
|
||||
{projects.map((project) => (
|
||||
<li key={project.id}>
|
||||
<Link
|
||||
to="/p/$projectId/settings"
|
||||
params={{ projectId: project.id }}
|
||||
className="flex items-center justify-between gap-3 p-3 transition-colors hover:bg-base-200/40"
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{project.name}
|
||||
</span>
|
||||
{project.id === currentProjectId ? (
|
||||
<span className="shrink-0 rounded-full bg-base-300/70 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-base-content/60">
|
||||
Current
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="truncate text-xs text-base-content/50">
|
||||
{project.domain ?? "No domain set"}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="size-4 shrink-0 text-base-content/40" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{creating ? (
|
||||
<CreateProjectModal onClose={() => setCreating(false)} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SearchConsoleConnectionCard } from "@/client/features/gsc/SearchConsoleConnectionCard";
|
||||
|
||||
export const Route = createFileRoute("/_project/p/$projectId/integrations")({
|
||||
component: IntegrationsRoute,
|
||||
});
|
||||
|
||||
function IntegrationsRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
|
||||
return (
|
||||
<div
|
||||
id="search-console"
|
||||
className="mx-auto w-full max-w-4xl space-y-6 p-4 sm:p-6"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Integrations</h1>
|
||||
<p className="text-sm text-base-content/60">
|
||||
Connect your data sources.
|
||||
</p>
|
||||
</div>
|
||||
<SearchConsoleConnectionCard projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,11 @@
|
||||
import { Outlet, createFileRoute, redirect } from "@tanstack/react-router";
|
||||
import {
|
||||
Outlet,
|
||||
createFileRoute,
|
||||
redirect,
|
||||
useLocation,
|
||||
} from "@tanstack/react-router";
|
||||
import { useEffect } from "react";
|
||||
import { setLastProjectId } from "@/client/lib/active-project";
|
||||
import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard";
|
||||
import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner";
|
||||
import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect";
|
||||
@ -37,6 +44,18 @@ function ProjectLayout() {
|
||||
const authGate = useHostedAuthRouteGuard();
|
||||
useOnboardingRedirect();
|
||||
|
||||
// Remember this as the last-visited project for the landing redirect.
|
||||
// Settings is excluded: editing another project's settings is
|
||||
// administration, not a context switch, so it shouldn't change which
|
||||
// project the app opens next time.
|
||||
const isSettingsPage = useLocation({
|
||||
select: (l) => l.pathname.endsWith("/settings"),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (isSettingsPage) return;
|
||||
setLastProjectId(projectId);
|
||||
}, [projectId, isSettingsPage]);
|
||||
|
||||
if (!authGate.canRenderAuthenticatedContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
15
src/routes/_project/p/$projectId/settings.tsx
Normal file
15
src/routes/_project/p/$projectId/settings.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { ProjectSettings } from "@/client/features/projects/ProjectSettings";
|
||||
|
||||
export const Route = createFileRoute("/_project/p/$projectId/settings")({
|
||||
component: ProjectSettingsRoute,
|
||||
});
|
||||
|
||||
function ProjectSettingsRoute() {
|
||||
const { projectId } = Route.useParams();
|
||||
return (
|
||||
<div className="h-full overflow-auto bg-base-100">
|
||||
<ProjectSettings projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
import { and, count, desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { projects } from "@/db/schema";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
@ -10,15 +10,12 @@ async function listProjects(organizationId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getDefaultProjectForOrganization(organizationId: string) {
|
||||
return db.query.projects.findFirst({
|
||||
where: and(
|
||||
eq(projects.organizationId, organizationId),
|
||||
eq(projects.name, "Default"),
|
||||
isNull(projects.domain),
|
||||
),
|
||||
orderBy: [desc(projects.createdAt), desc(projects.id)],
|
||||
});
|
||||
async function countProjects(organizationId: string) {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(projects)
|
||||
.where(eq(projects.organizationId, organizationId));
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
async function getProjectForOrganization(
|
||||
@ -33,25 +30,40 @@ async function getProjectForOrganization(
|
||||
});
|
||||
}
|
||||
|
||||
async function getProjectById(projectId: string) {
|
||||
return db.query.projects.findFirst({
|
||||
where: eq(projects.id, projectId),
|
||||
});
|
||||
}
|
||||
|
||||
async function createProject(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
domain?: string,
|
||||
) {
|
||||
const id = crypto.randomUUID();
|
||||
await db.insert(projects).values({
|
||||
id,
|
||||
organizationId,
|
||||
name,
|
||||
domain,
|
||||
});
|
||||
return id;
|
||||
const [row] = await db
|
||||
.insert(projects)
|
||||
.values({ id, organizationId, name, domain })
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
async function updateProject(
|
||||
projectId: string,
|
||||
organizationId: string,
|
||||
input: { name: string; domain?: string },
|
||||
) {
|
||||
const [row] = await db
|
||||
.update(projects)
|
||||
.set({ name: input.name, domain: input.domain ?? null })
|
||||
.where(
|
||||
and(
|
||||
eq(projects.id, projectId),
|
||||
eq(projects.organizationId, organizationId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
async function tryCreateDefaultProject(organizationId: string) {
|
||||
@ -87,10 +99,10 @@ async function deleteProject(projectId: string, organizationId: string) {
|
||||
|
||||
export const ProjectRepository = {
|
||||
listProjects,
|
||||
getDefaultProjectForOrganization,
|
||||
countProjects,
|
||||
getProjectForOrganization,
|
||||
getProjectById,
|
||||
createProject,
|
||||
updateProject,
|
||||
tryCreateDefaultProject,
|
||||
deleteProject,
|
||||
} as const;
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import {
|
||||
createProject,
|
||||
deleteProject,
|
||||
getOrCreateDefaultProject,
|
||||
getProject,
|
||||
getProjectForOrganization,
|
||||
listProjects,
|
||||
listProjectsEnsuringOne,
|
||||
updateProject,
|
||||
} from "@/server/features/projects/services/projects";
|
||||
|
||||
export const ProjectService = {
|
||||
listProjects,
|
||||
listProjectsEnsuringOne,
|
||||
createProject,
|
||||
updateProject,
|
||||
deleteProject,
|
||||
getOrCreateDefaultProject,
|
||||
getProject,
|
||||
getProjectForOrganization,
|
||||
} as const;
|
||||
|
||||
@ -2,9 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
deleteProject: vi.fn(),
|
||||
getDefaultProjectForOrganization: vi.fn(),
|
||||
getProjectById: vi.fn(),
|
||||
countProjects: vi.fn(),
|
||||
getProjectForOrganization: vi.fn(),
|
||||
listProjects: vi.fn(),
|
||||
tryCreateDefaultProject: vi.fn(),
|
||||
@ -21,33 +21,148 @@ const defaultProject = {
|
||||
createdAt: "2026-05-19 12:00:00",
|
||||
};
|
||||
|
||||
const namedProject = {
|
||||
id: "project_acme",
|
||||
name: "Acme",
|
||||
domain: "acme.com",
|
||||
createdAt: "2026-05-20 12:00:00",
|
||||
};
|
||||
|
||||
describe("project service", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
for (const mock of Object.values(mocks)) mock.mockReset();
|
||||
});
|
||||
|
||||
it("recovers from the default project creation race", async () => {
|
||||
mocks.getDefaultProjectForOrganization
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce(defaultProject);
|
||||
mocks.tryCreateDefaultProject.mockResolvedValue(null);
|
||||
const { getOrCreateDefaultProject } = await import("./projects");
|
||||
describe("listProjectsEnsuringOne", () => {
|
||||
it("returns existing projects without creating a Default", async () => {
|
||||
mocks.listProjects.mockResolvedValue([namedProject]);
|
||||
const { listProjectsEnsuringOne } = await import("./projects");
|
||||
|
||||
await expect(getOrCreateDefaultProject("org_1")).resolves.toEqual(
|
||||
defaultProject,
|
||||
);
|
||||
expect(mocks.tryCreateDefaultProject).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(2);
|
||||
await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([
|
||||
namedProject,
|
||||
]);
|
||||
expect(mocks.tryCreateDefaultProject).not.toHaveBeenCalled();
|
||||
expect(mocks.listProjects).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not swallow unrelated default project create failures", async () => {
|
||||
const error = new Error("D1 unavailable");
|
||||
mocks.getDefaultProjectForOrganization.mockResolvedValue(null);
|
||||
mocks.tryCreateDefaultProject.mockRejectedValue(error);
|
||||
const { getOrCreateDefaultProject } = await import("./projects");
|
||||
it("creates a Default when the org has no projects", async () => {
|
||||
mocks.listProjects
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([defaultProject]);
|
||||
mocks.tryCreateDefaultProject.mockResolvedValue("project_default");
|
||||
const { listProjectsEnsuringOne } = await import("./projects");
|
||||
|
||||
await expect(getOrCreateDefaultProject("org_1")).rejects.toBe(error);
|
||||
expect(mocks.getDefaultProjectForOrganization).toHaveBeenCalledTimes(1);
|
||||
await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([
|
||||
defaultProject,
|
||||
]);
|
||||
expect(mocks.tryCreateDefaultProject).toHaveBeenCalledWith("org_1");
|
||||
expect(mocks.listProjects).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("recovers from the Default creation race", async () => {
|
||||
mocks.listProjects
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValueOnce([defaultProject]);
|
||||
// A racing request won the insert, so this call's onConflictDoNothing
|
||||
// returns null — but the re-list still finds the Default.
|
||||
mocks.tryCreateDefaultProject.mockResolvedValue(null);
|
||||
const { listProjectsEnsuringOne } = await import("./projects");
|
||||
|
||||
await expect(listProjectsEnsuringOne("org_1")).resolves.toEqual([
|
||||
defaultProject,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProject", () => {
|
||||
it("returns the full created project", async () => {
|
||||
mocks.createProject.mockResolvedValue(namedProject);
|
||||
const { createProject } = await import("./projects");
|
||||
|
||||
await expect(
|
||||
createProject("org_1", { name: "Acme", domain: "acme.com" }),
|
||||
).resolves.toEqual(namedProject);
|
||||
expect(mocks.createProject).toHaveBeenCalledWith(
|
||||
"org_1",
|
||||
"Acme",
|
||||
"acme.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps the reserved Default conflict to a friendly CONFLICT", async () => {
|
||||
mocks.createProject.mockRejectedValue(
|
||||
new Error(
|
||||
"UNIQUE constraint failed: projects.projects_one_default_per_organization_idx",
|
||||
),
|
||||
);
|
||||
const { createProject } = await import("./projects");
|
||||
|
||||
await expect(
|
||||
createProject("org_1", { name: "Default", domain: undefined }),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateProject", () => {
|
||||
it("returns the updated project", async () => {
|
||||
mocks.updateProject.mockResolvedValue(namedProject);
|
||||
const { updateProject } = await import("./projects");
|
||||
|
||||
await expect(
|
||||
updateProject("org_1", {
|
||||
projectId: "project_acme",
|
||||
name: "Acme",
|
||||
domain: "acme.com",
|
||||
}),
|
||||
).resolves.toEqual(namedProject);
|
||||
expect(mocks.updateProject).toHaveBeenCalledWith(
|
||||
"project_acme",
|
||||
"org_1",
|
||||
{ name: "Acme", domain: "acme.com" },
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the domain when none is provided", async () => {
|
||||
const cleared = { ...namedProject, domain: null };
|
||||
mocks.updateProject.mockResolvedValue(cleared);
|
||||
const { updateProject } = await import("./projects");
|
||||
|
||||
await expect(
|
||||
updateProject("org_1", {
|
||||
projectId: "project_acme",
|
||||
name: "Acme",
|
||||
domain: undefined,
|
||||
}),
|
||||
).resolves.toEqual(cleared);
|
||||
expect(mocks.updateProject).toHaveBeenCalledWith(
|
||||
"project_acme",
|
||||
"org_1",
|
||||
{ name: "Acme", domain: undefined },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteProject", () => {
|
||||
it("refuses to delete the org's only project", async () => {
|
||||
mocks.countProjects.mockResolvedValue(1);
|
||||
const { deleteProject } = await import("./projects");
|
||||
|
||||
await expect(
|
||||
deleteProject("org_1", { projectId: "project_default" }),
|
||||
).rejects.toMatchObject({ code: "CONFLICT" });
|
||||
expect(mocks.deleteProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes when more than one project remains", async () => {
|
||||
mocks.countProjects.mockResolvedValue(2);
|
||||
mocks.deleteProject.mockResolvedValue(undefined);
|
||||
const { deleteProject } = await import("./projects");
|
||||
|
||||
await expect(
|
||||
deleteProject("org_1", { projectId: "project_acme" }),
|
||||
).resolves.toEqual({ success: true });
|
||||
expect(mocks.deleteProject).toHaveBeenCalledWith("project_acme", "org_1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type {
|
||||
CreateProjectInput,
|
||||
DeleteProjectInput,
|
||||
UpdateProjectInput,
|
||||
} from "@/types/schemas/projects";
|
||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
@ -19,66 +20,94 @@ function mapProject(project: {
|
||||
};
|
||||
}
|
||||
|
||||
// The projects table's only unique index guards the auto-created ("Default",
|
||||
// null) singleton. A UNIQUE violation while writing exactly that name/domain
|
||||
// therefore means one already exists — gating on the input (not just the error
|
||||
// string) keeps this from misclassifying any unrelated failure.
|
||||
function isReservedDefaultConflict(
|
||||
error: unknown,
|
||||
input: { name: string; domain?: string },
|
||||
) {
|
||||
return (
|
||||
input.name === "Default" &&
|
||||
!input.domain &&
|
||||
error instanceof Error &&
|
||||
error.message.includes("UNIQUE constraint failed")
|
||||
);
|
||||
}
|
||||
|
||||
const RESERVED_DEFAULT_MESSAGE =
|
||||
'A project named "Default" with no domain already exists. Pick a different name or add a domain.';
|
||||
|
||||
export async function listProjects(organizationId: string) {
|
||||
const rows = await ProjectRepository.listProjects(organizationId);
|
||||
return rows.map(mapProject);
|
||||
}
|
||||
|
||||
// Source of truth for "which projects does this org have", guaranteeing at least
|
||||
// one. Count-based — never matches on the "Default" name — so renaming the last
|
||||
// project does not cause a spurious second Default to be created on next visit.
|
||||
export async function listProjectsEnsuringOne(organizationId: string) {
|
||||
const existing = await listProjects(organizationId);
|
||||
if (existing.length > 0) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
await ProjectRepository.tryCreateDefaultProject(organizationId);
|
||||
return listProjects(organizationId);
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
organizationId: string,
|
||||
input: CreateProjectInput,
|
||||
) {
|
||||
const id = await ProjectRepository.createProject(
|
||||
try {
|
||||
const row = await ProjectRepository.createProject(
|
||||
organizationId,
|
||||
input.name,
|
||||
input.domain,
|
||||
);
|
||||
return { id };
|
||||
return mapProject(row);
|
||||
} catch (error) {
|
||||
if (isReservedDefaultConflict(error, input)) {
|
||||
throw new AppError("CONFLICT", RESERVED_DEFAULT_MESSAGE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
organizationId: string,
|
||||
input: UpdateProjectInput,
|
||||
) {
|
||||
try {
|
||||
const row = await ProjectRepository.updateProject(
|
||||
input.projectId,
|
||||
organizationId,
|
||||
{ name: input.name, domain: input.domain },
|
||||
);
|
||||
return mapProject(row);
|
||||
} catch (error) {
|
||||
if (isReservedDefaultConflict(error, input)) {
|
||||
throw new AppError("CONFLICT", RESERVED_DEFAULT_MESSAGE);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
organizationId: string,
|
||||
input: DeleteProjectInput,
|
||||
) {
|
||||
const remaining = await ProjectRepository.countProjects(organizationId);
|
||||
if (remaining <= 1) {
|
||||
throw new AppError("CONFLICT", "You can't delete your only project.");
|
||||
}
|
||||
|
||||
await ProjectRepository.deleteProject(input.projectId, organizationId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function getOrCreateDefaultProject(organizationId: string) {
|
||||
const existing =
|
||||
await ProjectRepository.getDefaultProjectForOrganization(organizationId);
|
||||
if (existing) {
|
||||
return mapProject(existing);
|
||||
}
|
||||
|
||||
const id = await ProjectRepository.tryCreateDefaultProject(organizationId);
|
||||
if (id) {
|
||||
return {
|
||||
id,
|
||||
name: "Default",
|
||||
domain: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const createdProject =
|
||||
await ProjectRepository.getDefaultProjectForOrganization(organizationId);
|
||||
if (createdProject) {
|
||||
return mapProject(createdProject);
|
||||
}
|
||||
|
||||
throw new AppError("INTERNAL_ERROR");
|
||||
}
|
||||
|
||||
export async function getProject(projectId: string) {
|
||||
const project = await ProjectRepository.getProjectById(projectId);
|
||||
if (!project) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return mapProject(project);
|
||||
}
|
||||
|
||||
export async function getProjectForOrganization(
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
|
||||
@ -161,7 +161,7 @@ describe("search console MCP tools", () => {
|
||||
const first = result.content[0];
|
||||
expect(first.type).toBe("text");
|
||||
expect(first.type === "text" && first.text).toContain(
|
||||
"/p/project_1/integrations",
|
||||
"/p/project_1/settings",
|
||||
);
|
||||
});
|
||||
|
||||
@ -183,7 +183,7 @@ describe("search console MCP tools", () => {
|
||||
});
|
||||
const first = result.content[0];
|
||||
expect(first.type === "text" && first.text).toContain(
|
||||
"/p/project_1/integrations",
|
||||
"/p/project_1/settings",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -31,8 +31,8 @@ type ProjectAuthContext = {
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
function integrationsUrl(baseUrl: string, projectId: string): string {
|
||||
return buildDashboardUrl(baseUrl, `/p/${projectId}/integrations`);
|
||||
function connectGscUrl(baseUrl: string, projectId: string): string {
|
||||
return buildDashboardUrl(baseUrl, `/p/${projectId}/settings#search-console`);
|
||||
}
|
||||
|
||||
/** Self-hosted GSC requires the operator to provide a Google OAuth client and
|
||||
@ -49,7 +49,7 @@ async function missingSelfHostedGoogleClientResponse(
|
||||
if (hosted || configured) return null;
|
||||
|
||||
return mcpResponse({
|
||||
text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from Integrations. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`,
|
||||
text: `This self-hosted OpenSEO deployment is not configured for Search Console yet. Set GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and BETTER_AUTH_SECRET, then reconnect Search Console from the project's settings page. Setup docs: ${GSC_SELF_HOSTED_SETUP_DOCS_URL}`,
|
||||
meta: buildProjectMeta(context, projectId),
|
||||
structuredContent: {
|
||||
ok: false,
|
||||
@ -196,11 +196,11 @@ export const getSearchConsolePerformanceTool = {
|
||||
);
|
||||
if (blocked) return blocked;
|
||||
|
||||
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
||||
const connectUrl = connectGscUrl(context.baseUrl, args.projectId);
|
||||
const meta = buildProjectMeta(
|
||||
context,
|
||||
args.projectId,
|
||||
`/p/${args.projectId}/integrations`,
|
||||
`/p/${args.projectId}/settings`,
|
||||
);
|
||||
|
||||
// GSC rejects searchAppearance combined with any other dimension.
|
||||
@ -337,11 +337,11 @@ export const inspectUrlsTool = {
|
||||
);
|
||||
if (blocked) return blocked;
|
||||
|
||||
const connectUrl = integrationsUrl(context.baseUrl, args.projectId);
|
||||
const connectUrl = connectGscUrl(context.baseUrl, args.projectId);
|
||||
const meta = buildProjectMeta(
|
||||
context,
|
||||
args.projectId,
|
||||
`/p/${args.projectId}/integrations`,
|
||||
`/p/${args.projectId}/settings`,
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@ -1,12 +1,41 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
requireAuthenticatedContext,
|
||||
requireProjectContext,
|
||||
} from "@/serverFunctions/middleware";
|
||||
import {
|
||||
createProjectSchema,
|
||||
deleteProjectSchema,
|
||||
updateProjectSchema,
|
||||
} from "@/types/schemas/projects";
|
||||
import { z } from "zod";
|
||||
|
||||
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
||||
export const getProjects = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.handler(async ({ context }) =>
|
||||
ProjectService.getOrCreateDefaultProject(context.organizationId),
|
||||
ProjectService.listProjectsEnsuringOne(context.organizationId),
|
||||
);
|
||||
|
||||
export const createProject = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) => createProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
ProjectService.createProject(context.organizationId, data),
|
||||
);
|
||||
|
||||
export const updateProject = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => updateProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
ProjectService.updateProject(context.organizationId, data),
|
||||
);
|
||||
|
||||
export const deleteProject = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => deleteProjectSchema.parse(data))
|
||||
.handler(async ({ data, context }) =>
|
||||
ProjectService.deleteProject(context.organizationId, data),
|
||||
);
|
||||
|
||||
export const getProjectAccess = createServerFn({ method: "POST" })
|
||||
|
||||
@ -1,13 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: z.string().min(1, "Project name is required").max(120),
|
||||
domain: z
|
||||
const projectNameField = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Project name is required")
|
||||
.max(120);
|
||||
|
||||
const projectDomainField = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(255)
|
||||
.transform((value) => value || undefined)
|
||||
.optional(),
|
||||
.optional();
|
||||
|
||||
export const createProjectSchema = z.object({
|
||||
name: projectNameField,
|
||||
domain: projectDomainField,
|
||||
});
|
||||
|
||||
export const updateProjectSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
name: projectNameField,
|
||||
domain: projectDomainField,
|
||||
});
|
||||
|
||||
export const deleteProjectSchema = z.object({
|
||||
@ -15,4 +29,5 @@ export const deleteProjectSchema = z.object({
|
||||
});
|
||||
|
||||
export type CreateProjectInput = z.infer<typeof createProjectSchema>;
|
||||
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user