Files
Oleg Isonen 6bfc8a80a7 feat: Allow owners to use Content mode (#6619)
A Free project owner could not switch to Content mode directly, but
could open a free Builder share link and use Content mode there. Owners
can now use the switch on any plan. Share-link visitors can use it only
when the project plan includes Content mode.

## Changes

- Keep Content visible in the mode menu. When it is unavailable, disable
it and show a Pro badge instead of the shortcut.
- Let project owners use Content mode on any plan.
- On Free projects, disable Content mode for every share-link
permission. A Content-only link opens in Preview; a Builder link still
opens in Design.

## Checklist

- [x] Keep the Content menu row visible and test its badge and shortcut.
- [x] Update the permission rule and tests for Free owners, Free share
links, and Pro share links.
- [x] Verify a Free Content-only link falls back to Preview mode.
- [x] Review Builder docs. The Modes guide already says owners can try
Content mode, and the Share links guide says Content-only links require
Pro. No copy change is needed.
- [x] Run focused tests, Builder typecheck, and lint after the
permission change.
- [x] Review the non-test diff for redundant code.
- [x] Set a paid plan only in E2E tests that open Content mode through
an Editor share link. Keep unrelated fixtures on their original plan.
- [x] Fix the Design-mode fallback for Free Content-only links and add a
regression test.
- [x] Confirm required CI checks pass on the latest commit.

## Verification

- Builder typecheck: passed.
- Oxlint on changed code: passed.
- Focused tests: 66 passed across mode, permission, and state suites.
The two new Design-entry cases failed before the fix.
- Latest-commit CI: Main workflow, Visual regression, Check submodules,
and Lint PR passed. Every E2E shard passed.
- Manual Storybook screenshots were not run because approval was not
received.

## Known limit

The Share dialog blocks Content-only links on Free plans. Token creation
and update do not enforce that plan limit on the server. This PR
controls which Builder mode a share-link visitor can enter; it does not
change token issuance.

Closes #6618
2026-10-28 23:36:18 +01:00

74 lines
2.9 KiB
TypeScript

import type { Role } from "@webstudio-is/project";
import type { AuthPermit } from "@webstudio-is/trpc-interface/index.server";
import type { PlanFeatures } from "@webstudio-is/plans";
/**
* Human-readable descriptions of what each workspace role can do.
* Mirrors the role-based permission logic in getPermissions().
*/
export const roleDescriptions: Record<Role, string> = {
viewers: "Can view, copy instances, and clone the project.",
editors: "Can edit content only — text, images, and predefined components.",
builders: "Can make any design changes and publish to staging only.",
administrators: "Can make any design changes and publish to custom domains.",
};
/**
* Single source of truth for all user permissions. Combines workspace
* role-based access with subscription plan features.
*
* Role-based permission mapping (mirrors backend checks):
* own → all actions
* administrators → create, duplicate, rename, tags, transfer
* builders → create, duplicate, rename, tags
* editors → rename, tags
* viewers → open in safe mode only (handled by caller)
*/
export const getPermissions = ({
role,
planFeatures,
authPermit,
workspaces,
}: {
role: Role | "own";
planFeatures: PlanFeatures;
authPermit?: AuthPermit;
workspaces: Array<{ role: Role | "own" }>;
}) => {
const isOwn = role === "own";
const isAdmin = role === "administrators";
const isBuilder = role === "builders" || isAdmin;
const isEditor = role === "editors" || isBuilder;
return {
// Workspace role permissions
canCreateProject: isOwn || isBuilder,
canDuplicate: isOwn || isBuilder,
canRename: isOwn || isEditor,
canShare: isOwn,
canDelete: isOwn,
canTransfer: isOwn || isAdmin,
canEditTags: isOwn || isEditor,
canOpenSettings: isOwn,
// Builders (but not admins/owners) can publish to staging only.
// Share-link users with "build" permit have the same restriction.
canPublishToStagingOnly:
(isBuilder && !isAdmin && !isOwn) || authPermit === "build",
// Plan feature permissions
canDownloadAssets: planFeatures.canDownloadAssets,
canRestoreBackups: planFeatures.canRestoreBackups,
allowDynamicData: planFeatures.allowDynamicData,
allowAuth: planFeatures.allowAuth,
allowContentMode: authPermit === "own" || planFeatures.allowContentMode,
allowStagingPublish: planFeatures.allowStagingPublish,
allowAdditionalPermissions: planFeatures.allowAdditionalPermissions,
maxContactEmailsPerProject: planFeatures.maxContactEmailsPerProject,
maxDomainsAllowedPerUser: planFeatures.maxDomainsAllowedPerUser,
maxDailyPublishesPerUser: planFeatures.maxDailyPublishesPerUser,
maxWorkspaces: planFeatures.maxWorkspaces,
canInviteMembers: planFeatures.maxWorkspaces > 1,
canCreateWorkspace:
workspaces.filter((w) => w.role === "own").length <
planFeatures.maxWorkspaces,
};
};