diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 2baf21e..963a533 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -569,6 +569,10 @@ export function createRouter(deps: RouterDeps) { spaceNavigationDto(deps, context.actor, repos, groupRepos), ), create: authed.spaces.create.handler(async ({ context, input }) => { + // A linked folder lives on the host, and the host is the deployment owner's machine. + if (input.workspacePath && !context.actor.isDeploymentOwner) { + throw new ORPCError("FORBIDDEN"); + } let space: { id: string; name: string }; try { space = await createSpaceForMember(deps.prisma, { @@ -2230,10 +2234,7 @@ export function createRouter(deps: RouterDeps) { } } if (isProjectWorkspacePath(storedPath)) { - const workspaceRoot = await requireProjectWorkspaceRoot( - deps.prisma, - context.actor.spaceId, - ); + const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor); const entries = await listProjectWorkspaceFiles(workspaceRoot, storedPath); return entries.map((entry) => ({ ...entry, @@ -2281,10 +2282,7 @@ export function createRouter(deps: RouterDeps) { } } if (isProjectWorkspacePath(storedPath)) { - const workspaceRoot = await requireProjectWorkspaceRoot( - deps.prisma, - context.actor.spaceId, - ); + const workspaceRoot = await requireProjectWorkspaceRoot(deps.prisma, context.actor); const bytes = await readProjectWorkspaceFile(workspaceRoot, storedPath, { maxBytes: MAX_COMPUTER_TEXT_FILE_BYTES, }); @@ -4894,10 +4892,13 @@ function mapUpdaterError(error: unknown): never { async function requireProjectWorkspaceRoot( prisma: Pick, - spaceId: string, + actor: Actor, ): Promise { + // Only the deployment owner may link a host folder, so only they may browse one. This also + // closes folders that other members linked before linking was limited to the owner. + if (!actor.isDeploymentOwner) throw new ORPCError("FORBIDDEN"); const space = await prisma.space.findUnique({ - where: { id: spaceId }, + where: { id: actor.spaceId }, select: { workspacePath: true }, }); if (!space?.workspacePath) { @@ -5009,7 +5010,8 @@ async function spaceNavigationDto( memberships.length > 1 && !spacesWithContent.has(membership.spaceId) && (membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), - workspace: projectWorkspaceDto(membership.space), + // A linked folder is on the owner's host; other members see neither its path nor remote. + workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null, bots: spaceBots.map((bot) => ({ id: bot.id, spaceId: bot.spaceId, diff --git a/apps/web/e2e/spaces.spec.ts b/apps/web/e2e/spaces.spec.ts index d3b1dc6..7492809 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -3,6 +3,7 @@ import { captureScreenshot, completeOnboarding, openNewSpace, + routePatchRpc, rpc, signup, unterhaltungenOnly, @@ -45,6 +46,30 @@ test("Projekte + opens new project dialog instead of group form", async ({ page await expect(page.getByTestId("project-start-main")).toHaveText(/Starten/); }); +for (const owner of [true, false]) { + const role = owner ? "owner" : "member"; + const title = owner + ? "new project dialog offers a host folder to the deployment owner" + : "new project dialog offers no host folder to a member"; + test(title, async ({ page }, testInfo) => { + await signup(page, `proj-folder-${role}-${Date.now()}@rakazo.test`, "password12", "Folder"); + await completeOnboarding(page); + // Whoever signs up first owns the deployment, which depends on spec order. + await routePatchRpc(page, "bootstrap", (json) => ({ + ...json, + me: { ...(json.me as Record), isDeploymentOwner: owner }, + })); + await page.goto("/app"); + await page.waitForURL(/\/app\/[^/]+$/); + + await page.locator(".sidebar-projekte").getByTestId("create-project-trigger").click(); + const dialog = page.getByRole("dialog", { name: "New project" }); + await expect(dialog.getByLabel("Name")).toBeVisible(); + await expect(dialog.getByLabel("Project folder")).toHaveCount(owner ? 1 : 0); + await captureScreenshot(page, testInfo, `new-project-${role}`); + }); +} + test("spaces stay invisible by default and chat creation requires approval", async ({ page, }, testInfo) => { diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index f50bf3b..1708a55 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -5482,6 +5482,7 @@ export function ShellPage() { {newSpaceOpen ? ( setNewSpaceOpen(false)} onConfirm={async ({ name, workspacePath }) => { const space = await rpc.spaces.create({ diff --git a/apps/web/src/pages/shell/dialogs.tsx b/apps/web/src/pages/shell/dialogs.tsx index b094ce1..3542790 100644 --- a/apps/web/src/pages/shell/dialogs.tsx +++ b/apps/web/src/pages/shell/dialogs.tsx @@ -38,10 +38,13 @@ export type NewSpaceConfirmInput = { export function NewSpaceDialog({ variant = "space", + canLinkFolder, onCancel, onConfirm, }: { variant?: "space" | "project"; + /** Folders live on the host, which only the deployment owner may link. */ + canLinkFolder: boolean; onCancel: () => void; onConfirm: (input: NewSpaceConfirmInput) => Promise; }) { @@ -53,6 +56,7 @@ export function NewSpaceDialog({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const isProject = variant === "project"; + const showFolder = isProject && canLinkFolder; const desktop = desktopBridge(); const trimmedWorkspacePath = workspacePath.trim(); @@ -63,7 +67,7 @@ export function NewSpaceDialog({ setError(null); void onConfirm({ name: trimmed, - ...(isProject && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}), + ...(showFolder && trimmedWorkspacePath ? { workspacePath: trimmedWorkspacePath } : {}), }).catch((reason: unknown) => { setError( reason instanceof Error @@ -104,7 +108,7 @@ export function NewSpaceDialog({ className="mt-2" /> - {isProject ? ( + {showFolder ? (