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>
202 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|