Files
multica/packages/views/modals/create-workspace.tsx
Bohan Jiang 90ddfb04e2 feat(self-host): DISABLE_WORKSPACE_CREATION env var (MUL-2777) (#3441)
* feat(self-host): DISABLE_WORKSPACE_CREATION env var (MUL-2777, #3433)

When self-hosters set DISABLE_WORKSPACE_CREATION=true, POST /api/workspaces
returns 403 for every caller and the UI hides every "Create workspace"
affordance (sidebar, modal, /workspaces/new page, onboarding Step 2). This
closes the gap where ALLOW_SIGNUP=false still let any signed-in user open
an isolated workspace the platform admin couldn't see.

- server: new Config.DisableWorkspaceCreation, gate in CreateWorkspace,
  workspace_creation_disabled in /api/config, Go tests.
- frontend: new workspaceCreationDisabled in configStore, hide sidebar
  entry, swap NewWorkspacePage / CreateWorkspaceModal / onboarding
  StepWorkspace to a "creation disabled, ask for invite" state when the
  flag is on, EN + zh-Hans locale strings.
- ops: .env.example, docker-compose.selfhost, helm values + configmap,
  SELF_HOSTING.md, SELF_HOSTING_ADVANCED.md, environment-variables docs
  (EN + zh).

Co-authored-by: multica-agent <github@multica.ai>

* fix(onboarding): drive create path off workspaceCreationAllowed (#3433)

PR #3441 review: when DISABLE_WORKSPACE_CREATION=true and the user already
has a workspace, StepWorkspace still walked the resume copy (`headline_resume`
/ `lede_resume` mentioning "or start another") and `creatingActive` ignored
the flag, leaving a stale clickable create CTA possible if /api/config
arrived late.

Refactor StepWorkspace to derive a single `workspaceCreationAllowed`
boolean from the config store. It now drives:

- Initial `mode` state (defaults to "existing" when disabled + reusing so
  the CTA is pre-armed for the only valid action).
- `creatingActive` so the footer CTA cannot fall back into the create
  branch even mid-render.
- Eyebrow / headline / lede strings — adds
  `creation_disabled_{eyebrow,headline,lede}_resume` (EN + zh-Hans) for
  the disabled + reusing variant.

Tests: cover the three reachable shapes — flag off + no existing, flag on
+ no existing, flag on + existing.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-28 16:42:08 +08:00

94 lines
3.5 KiB
TypeScript

"use client";
import { useNavigation } from "../navigation";
import { DragStrip } from "../platform";
import { ArrowLeft } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
} from "@multica/ui/components/ui/dialog";
import { paths } from "@multica/core/paths";
import { useConfigStore } from "@multica/core/config";
import { CreateWorkspaceForm } from "../workspace/create-workspace-form";
import { useT } from "../i18n";
export function CreateWorkspaceModal({ onClose }: { onClose: () => void }) {
const { t } = useT("modals");
const tWorkspace = useT("workspace").t;
const router = useNavigation();
const workspaceCreationDisabled = useConfigStore((s) => s.workspaceCreationDisabled);
return (
<Dialog
open
onOpenChange={(v) => {
if (!v) onClose();
}}
>
<DialogContent
finalFocus={false}
showCloseButton={false}
className="inset-0 flex h-full w-full max-w-none sm:max-w-none translate-0 flex-col rounded-none bg-background ring-0 shadow-none"
>
{/* DragStrip as flex child — macOS traffic lights stay visible and
the top 48px is draggable. Back button sits just below the strip
(top-16 = 64px), clear of both traffic lights (y<=27) and the
strip (y<=48). `no-drag` is a belt-and-braces guard in case the
button's layout ever creeps up into the strip zone. */}
<DragStrip />
<Button
variant="ghost"
size="sm"
className="absolute top-16 left-12 text-muted-foreground"
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
onClick={onClose}
>
<ArrowLeft className="h-4 w-4" />
{t(($) => $.common.back)}
</Button>
<div className="flex flex-1 flex-col items-center justify-center px-6 pb-12">
<div className="flex w-full max-w-md flex-col items-center gap-6">
{workspaceCreationDisabled ? (
<div className="text-center">
<DialogTitle className="text-2xl font-semibold">
{tWorkspace(($) => $.creation_disabled.title)}
</DialogTitle>
<DialogDescription className="mt-2">
{tWorkspace(($) => $.creation_disabled.description)}
</DialogDescription>
</div>
) : (
<>
<div className="text-center">
<DialogTitle className="text-2xl font-semibold">
{t(($) => $.create_workspace.title)}
</DialogTitle>
<DialogDescription className="mt-2">
{t(($) => $.create_workspace.description)}
</DialogDescription>
</div>
<CreateWorkspaceForm
onSuccess={(newWs) => {
onClose();
// Navigate INTO the new workspace. The mutation's own onSuccess
// (in core/workspace/mutations.ts) runs before this callback and
// has already seeded the workspace list cache, so the destination
// [workspaceSlug]/layout will resolve newWs.slug → workspace
// synchronously without a loading flash.
router.push(paths.workspace(newWs.slug).issues());
}}
/>
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}