ubiquitous-invention/apps/web/components/workspaces/create-workspace-dialog.tsx
Randall Stillwell c582d621ce multi-tenancy: promote workspaces to top-level table
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:

- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
  property_definitions, templates, forms, markdown_backlog_items,
  cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
  with a hand-augmented data-copy migration that preserves IDs and slug-collision-
  proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
  that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
  tenant-scoped routers (objects, types, properties, templates, forms, search,
  ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
  of the old `workspaceId`, including a workspace-sync layer that rewrites
  /<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
  workspace://{handle}/tree resource to accept either a slug or UUID so existing
  agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
  rename with redirect, owner-only archive).

Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 23:02:55 -05:00

202 lines
6.7 KiB
TypeScript

"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { useRouter } from "next/navigation";
import { Loader2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/trpc";
import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils";
const SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
function makeSlug(name: string): string {
return (
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60) || ""
);
}
export function CreateWorkspaceDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (next: boolean) => void;
}) {
const router = useRouter();
const utils = api.useUtils();
const setWorkspace = useWorkspaceStore((s) => s.setWorkspace);
const [name, setName] = React.useState("");
const [slug, setSlug] = React.useState("");
const [slugTouched, setSlugTouched] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const createMut = api.workspaces.create.useMutation({
onSuccess: async (ws) => {
await utils.workspaces.listForUser.invalidate();
setWorkspace({ id: ws.id, slug: ws.slug, name: ws.name });
onOpenChange(false);
router.push(`/${ws.slug}`);
},
onError: (e) => setError(e.message ?? "Failed to create workspace"),
});
React.useEffect(() => {
if (open) {
setName("");
setSlug("");
setSlugTouched(false);
setError(null);
}
}, [open]);
const previewSlug = slugTouched ? slug : makeSlug(name);
const slugInvalid = slugTouched && slug.length > 0 && !SLUG_RE.test(slug);
const submit = (e: React.FormEvent) => {
e.preventDefault();
if (createMut.isPending) return;
setError(null);
const trimmed = name.trim();
if (!trimmed) {
setError("Workspace name is required");
return;
}
if (slugTouched && slug && !SLUG_RE.test(slug)) {
setError("Slug must be lowercase letters, digits, or hyphens");
return;
}
createMut.mutate({
name: trimmed,
...(slugTouched && slug ? { slug } : {}),
});
};
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-[200] bg-background/80 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
)}
/>
<DialogPrimitive.Content
className={cn(
"fixed left-1/2 top-[18vh] z-[201] w-[min(480px,calc(100vw-1.5rem))] -translate-x-1/2 rounded-xl border border-border bg-popover p-6 shadow-2xl outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
)}
>
<div className="flex items-start justify-between gap-3 pb-4">
<div>
<DialogPrimitive.Title className="text-base font-semibold">
Create workspace
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-xs text-muted-foreground">
Workspaces isolate projects, tasks, and team members. You can
rename or change the slug later.
</DialogPrimitive.Description>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 shrink-0 text-muted-foreground"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="size-4" />
</Button>
</div>
<form onSubmit={submit} className="space-y-4">
<div className="space-y-1.5">
<label htmlFor="ws-name" className="text-xs font-medium">
Name
</label>
<Input
id="ws-name"
value={name}
placeholder="Acme Inc."
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-1.5">
<label htmlFor="ws-slug" className="text-xs font-medium">
Slug
</label>
<div className="flex items-center gap-2 rounded-md border border-input bg-background px-2 focus-within:ring-2 focus-within:ring-ring">
<span className="select-none text-xs text-muted-foreground">/</span>
<input
id="ws-slug"
className="h-9 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
value={slugTouched ? slug : previewSlug}
placeholder="acme"
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(true);
}}
onFocus={() => {
if (!slugTouched) {
setSlug(previewSlug);
setSlugTouched(true);
}
}}
/>
</div>
<p
className={cn(
"text-xs",
slugInvalid ? "text-destructive" : "text-muted-foreground",
)}
>
{slugInvalid
? "Use lowercase letters, digits, or hyphens (no leading/trailing dash)."
: "Used in URLs (e.g. /acme/projects). Auto-generated from name."}
</p>
</div>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={createMut.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={createMut.isPending || !name.trim()}>
{createMut.isPending ? (
<>
<Loader2 className="size-4 animate-spin" aria-hidden />
Creating
</>
) : (
"Create workspace"
)}
</Button>
</div>
</form>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}