From 64911d40e014fc593a5eaefee6a8c0b968e9532d Mon Sep 17 00:00:00 2001 From: istarkov Date: Sat, 7 Dec 2024 06:10:36 +0000 Subject: [PATCH 1/4] Add canPublish permission --- .../app/builder/features/topbar/publish.tsx | 7 +- apps/builder/app/shared/nano-states/misc.ts | 1 + .../shared/share-project/share-project.tsx | 98 +++++++++++++------ .../src/db/authorization-token.ts | 25 ++++- .../src/trpc/authorization-tokens-router.ts | 2 + .../postgrest/src/__generated__/db-types.ts | 74 +++++++++++++- .../20241207052014_can-publish/migration.sql | 9 ++ 7 files changed, 175 insertions(+), 41 deletions(-) create mode 100644 packages/prisma-client/prisma/migrations/20241207052014_can-publish/migration.sql diff --git a/apps/builder/app/builder/features/topbar/publish.tsx b/apps/builder/app/builder/features/topbar/publish.tsx index 1e1b8994bf44..b9bc0cc9e56b 100644 --- a/apps/builder/app/builder/features/topbar/publish.tsx +++ b/apps/builder/app/builder/features/topbar/publish.tsx @@ -39,6 +39,7 @@ import { $isPublishDialogOpen } from "../../shared/nano-states"; import { validateProjectDomain, type Project } from "@webstudio-is/project"; import { $authPermit, + $authTokenPermissions, $project, $publishedOrigin, $userPlanFeatures, @@ -837,16 +838,16 @@ type PublishProps = { export const PublishButton = ({ projectId }: PublishProps) => { const isPublishDialogOpen = useStore($isPublishDialogOpen); - const authPermit = useStore($authPermit); + const authTokenPermissions = useStore($authTokenPermissions); const [dialogContentType, setDialogContentType] = useState< "publish" | "export" >("publish"); - const isPublishEnabled = authPermit === "own" || authPermit === "admin"; + const isPublishEnabled = authTokenPermissions.canPublish; const tooltipContent = isPublishEnabled ? undefined - : "Only owner or admin can publish projects"; + : "Only the owner, an admin, or content editors with publish permissions can publish projects"; const handleExportClick = () => { setDialogContentType("export"); diff --git a/apps/builder/app/shared/nano-states/misc.ts b/apps/builder/app/shared/nano-states/misc.ts index 9a72f2918484..d5e8e61c7beb 100644 --- a/apps/builder/app/shared/nano-states/misc.ts +++ b/apps/builder/app/shared/nano-states/misc.ts @@ -331,6 +331,7 @@ export const $authPermit = atom("view"); export const $authTokenPermissions = atom({ canClone: true, canCopy: true, + canPublish: false, }); export const $authToken = atom(undefined); diff --git a/apps/builder/app/shared/share-project/share-project.tsx b/apps/builder/app/shared/share-project/share-project.tsx index 3054dc757078..8f88c805a064 100644 --- a/apps/builder/app/shared/share-project/share-project.tsx +++ b/apps/builder/app/shared/share-project/share-project.tsx @@ -94,7 +94,7 @@ type MenuProps = { }; const Menu = ({ name, hasProPlan, value, onChange, onDelete }: MenuProps) => { - const ids = useIds(["name", "canClone", "canCopy"]); + const ids = useIds(["name", "canClone", "canCopy", "canPublish"]); const [isOpen, setIsOpen] = useState(false); const [customLinkName, setCustomLinkName] = useState(name); @@ -234,36 +234,71 @@ const Menu = ({ name, hasProPlan, value, onChange, onDelete }: MenuProps) => { {isFeatureEnabled("contentEditableMode") && ( - - Recipients can edit content only, such as text, images, and - predefined components. - {hasProPlan !== true && ( - <> -
-
- Upgrade to a Pro account to share with Content Edit - permissions. -

- - Upgrade - - - )} - - } - /> + <> + + Recipients can edit content only, such as text, images, and + predefined components. + {hasProPlan !== true && ( + <> +
+
+ Upgrade to a Pro account to share with Content Edit + permissions. +

+ + Upgrade + + + )} + + } + /> + + + { + onChange({ ...value, canPublish: Boolean(canPublish) }); + }} + id={ids.canPublish} + /> + + + + )} { + let result = token; + if (token.relation !== "viewers") { - return { - ...token, + result = { + ...result, canClone: true, canCopy: true, }; } - return token; + if (token.relation === "viewers") { + result = { + ...result, + canPublish: false, + }; + } + + if (token.relation === "builders") { + result = { + ...result, + canPublish: false, + }; + } + + return result; }; export const findMany = async ( @@ -55,6 +71,7 @@ export const findMany = async ( export const tokenDefaultPermissions = { canClone: true, canCopy: true, + canPublish: true, }; export type TokenPermissions = typeof tokenDefaultPermissions; @@ -89,6 +106,7 @@ export const getTokenPermissions = async ( return { canClone: dbToken.canClone, canCopy: dbToken.canCopy, + canPublish: dbToken.canPublish, }; }; @@ -168,6 +186,7 @@ export const update = async ( relation: props.relation, canClone: props.canClone, canCopy: props.canCopy, + canPublish: props.canPublish, }) .eq("projectId", projectId) .eq("token", props.token) diff --git a/packages/authorization-token/src/trpc/authorization-tokens-router.ts b/packages/authorization-token/src/trpc/authorization-tokens-router.ts index 8e8f75805c51..80b6e197e69b 100644 --- a/packages/authorization-token/src/trpc/authorization-tokens-router.ts +++ b/packages/authorization-token/src/trpc/authorization-tokens-router.ts @@ -68,6 +68,7 @@ export const authorizationTokenRouter = router({ relation: TokenProjectRelation, canClone: z.boolean(), canCopy: z.boolean(), + canPublish: z.boolean(), }) ) .mutation(async ({ input, ctx }) => { @@ -77,6 +78,7 @@ export const authorizationTokenRouter = router({ token: input.token, name: input.name, relation: input.relation, + canPublish: input.canPublish, canClone: input.canClone, canCopy: input.canCopy, }, diff --git a/packages/postgrest/src/__generated__/db-types.ts b/packages/postgrest/src/__generated__/db-types.ts index c9a6eb8fc9b6..935fe3c23bfd 100644 --- a/packages/postgrest/src/__generated__/db-types.ts +++ b/packages/postgrest/src/__generated__/db-types.ts @@ -7,6 +7,31 @@ export type Json = | Json[]; export type Database = { + graphql_public: { + Tables: { + [_ in never]: never; + }; + Views: { + [_ in never]: never; + }; + Functions: { + graphql: { + Args: { + operationName?: string; + query?: string; + variables?: Json; + extensions?: Json; + }; + Returns: Json; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; public: { Tables: { _prisma_migrations: { @@ -72,6 +97,7 @@ export type Database = { Row: { canClone: boolean; canCopy: boolean; + canPublish: boolean; createdAt: string; name: string; projectId: string; @@ -81,6 +107,7 @@ export type Database = { Insert: { canClone?: boolean; canCopy?: boolean; + canPublish?: boolean; createdAt?: string; name?: string; projectId: string; @@ -90,6 +117,7 @@ export type Database = { Update: { canClone?: boolean; canCopy?: boolean; + canPublish?: boolean; createdAt?: string; name?: string; projectId?: string; @@ -121,6 +149,7 @@ export type Database = { deployment: string | null; id: string; instances: string; + isCleaned: boolean | null; lastTransactionId: string | null; marketplaceProduct: string; pages: string; @@ -141,6 +170,7 @@ export type Database = { deployment?: string | null; id: string; instances?: string; + isCleaned?: boolean | null; lastTransactionId?: string | null; marketplaceProduct?: string; pages: string; @@ -161,6 +191,7 @@ export type Database = { deployment?: string | null; id?: string; instances?: string; + isCleaned?: boolean | null; lastTransactionId?: string | null; marketplaceProduct?: string; pages?: string; @@ -681,14 +712,14 @@ export type Database = { foreignKeyName: "Build_projectId_fkey"; columns: ["projectId"]; isOneToOne: false; - referencedRelation: "Project"; + referencedRelation: "DashboardProject"; referencedColumns: ["id"]; }, { foreignKeyName: "Build_projectId_fkey"; columns: ["projectId"]; isOneToOne: false; - referencedRelation: "DashboardProject"; + referencedRelation: "Project"; referencedColumns: ["id"]; }, ]; @@ -762,14 +793,14 @@ export type Database = { foreignKeyName: "Build_projectId_fkey"; columns: ["projectId"]; isOneToOne: false; - referencedRelation: "Project"; + referencedRelation: "DashboardProject"; referencedColumns: ["id"]; }, { foreignKeyName: "Build_projectId_fkey"; columns: ["projectId"]; isOneToOne: false; - referencedRelation: "DashboardProject"; + referencedRelation: "Project"; referencedColumns: ["id"]; }, ]; @@ -811,6 +842,13 @@ export type Database = { }; Returns: string; }; + database_cleanup: { + Args: { + from_date?: string; + to_date?: string; + }; + Returns: undefined; + }; domainsVirtual: { Args: { "": unknown; @@ -863,6 +901,19 @@ export type Database = { publishStatus: Database["public"]["Enums"]["PublishStatus"]; }[]; }; + latestProjectDomainBuildVirtual: { + Args: { + "": unknown; + }; + Returns: { + buildId: string; + createdAt: string; + domain: string; + domainsVirtualId: string; + projectId: string; + publishStatus: Database["public"]["Enums"]["PublishStatus"]; + }[]; + }; uuid_generate_v4: { Args: Record; Returns: string; @@ -970,3 +1021,18 @@ export type Enums< : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] ? PublicSchema["Enums"][PublicEnumNameOrOptions] : never; + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof PublicSchema["CompositeTypes"] + | { schema: keyof Database }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof Database; + } + ? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } + ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] + ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never; diff --git a/packages/prisma-client/prisma/migrations/20241207052014_can-publish/migration.sql b/packages/prisma-client/prisma/migrations/20241207052014_can-publish/migration.sql new file mode 100644 index 000000000000..cf4ca7b42cbc --- /dev/null +++ b/packages/prisma-client/prisma/migrations/20241207052014_can-publish/migration.sql @@ -0,0 +1,9 @@ + +ALTER TABLE "AuthorizationToken" ADD COLUMN "canPublish" boolean NOT NULL DEFAULT false; + +UPDATE "AuthorizationToken" +SET "canPublish" = +CASE + WHEN "relation" IN ('viewers', 'builders') THEN FALSE + ELSE TRUE +END; From 126c0baab6703fd18219a4f2e5d15dfcc1b1d96f Mon Sep 17 00:00:00 2001 From: istarkov Date: Sat, 7 Dec 2024 06:10:53 +0000 Subject: [PATCH 2/4] fix --- apps/builder/app/builder/features/topbar/publish.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/builder/app/builder/features/topbar/publish.tsx b/apps/builder/app/builder/features/topbar/publish.tsx index b9bc0cc9e56b..01242c707983 100644 --- a/apps/builder/app/builder/features/topbar/publish.tsx +++ b/apps/builder/app/builder/features/topbar/publish.tsx @@ -38,7 +38,6 @@ import stripIndent from "strip-indent"; import { $isPublishDialogOpen } from "../../shared/nano-states"; import { validateProjectDomain, type Project } from "@webstudio-is/project"; import { - $authPermit, $authTokenPermissions, $project, $publishedOrigin, From 2cd2a3182fab35ee2627f699fb4d63da5bdbd660 Mon Sep 17 00:00:00 2001 From: istarkov Date: Sat, 7 Dec 2024 07:01:12 +0000 Subject: [PATCH 3/4] ::migrate:: Fix --- .../app/shared/share-project/share-project.stories.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/builder/app/shared/share-project/share-project.stories.tsx b/apps/builder/app/shared/share-project/share-project.stories.tsx index 14c00687d69a..aebccd9583a0 100644 --- a/apps/builder/app/shared/share-project/share-project.stories.tsx +++ b/apps/builder/app/shared/share-project/share-project.stories.tsx @@ -19,6 +19,7 @@ const initialLinks: Array = [ relation: "viewers", canClone: false, canCopy: false, + canPublish: false, }, { token: crypto.randomUUID(), @@ -26,6 +27,7 @@ const initialLinks: Array = [ relation: "editors", canClone: false, canCopy: false, + canPublish: false, }, { token: crypto.randomUUID(), @@ -33,6 +35,7 @@ const initialLinks: Array = [ relation: "builders", canClone: false, canCopy: false, + canPublish: false, }, ]; @@ -63,6 +66,7 @@ const useShareProject = ( relation: "viewers", canClone: false, canCopy: false, + canPublish: false, }, ]); }; From e8d3a6b60f7f64c32ba42d2f78a7ef523f048c56 Mon Sep 17 00:00:00 2001 From: istarkov Date: Sat, 7 Dec 2024 07:22:20 +0000 Subject: [PATCH 4/4] Fix permissions checks --- .../src/db/authorization-token.ts | 11 ++++++++++ packages/project-build/package.json | 1 + packages/project-build/src/db/build.ts | 20 ++++++++++++++++++- pnpm-lock.yaml | 9 ++++++--- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/authorization-token/src/db/authorization-token.ts b/packages/authorization-token/src/db/authorization-token.ts index 9fb792e68f92..54336782e658 100644 --- a/packages/authorization-token/src/db/authorization-token.ts +++ b/packages/authorization-token/src/db/authorization-token.ts @@ -13,6 +13,7 @@ const applyTokenPermissions = ( ): AuthorizationToken => { let result = token; + // @todo: fix this on SQL level if (token.relation !== "viewers") { result = { ...result, @@ -21,6 +22,7 @@ const applyTokenPermissions = ( }; } + // @todo: fix this on SQL level if (token.relation === "viewers") { result = { ...result, @@ -28,6 +30,7 @@ const applyTokenPermissions = ( }; } + // @todo: fix this on SQL level if (token.relation === "builders") { result = { ...result, @@ -35,6 +38,14 @@ const applyTokenPermissions = ( }; } + // @todo: fix this on SQL level + if (token.relation === "administrators") { + result = { + ...result, + canPublish: true, + }; + } + return result; }; diff --git a/packages/project-build/package.json b/packages/project-build/package.json index 93a41eb778a4..3dd33a92191e 100644 --- a/packages/project-build/package.json +++ b/packages/project-build/package.json @@ -24,6 +24,7 @@ "@webstudio-is/postrest": "workspace:*", "@webstudio-is/sdk": "workspace:*", "@webstudio-is/trpc-interface": "workspace:*", + "@webstudio-is/authorization-token": "workspace:*", "nanoid": "^5.0.8", "zod": "^3.22.4" }, diff --git a/packages/project-build/src/db/build.ts b/packages/project-build/src/db/build.ts index d148245c5d42..b04a64119705 100644 --- a/packages/project-build/src/db/build.ts +++ b/packages/project-build/src/db/build.ts @@ -7,6 +7,7 @@ import { authorizeProject, type AppContext, } from "@webstudio-is/trpc-interface/index.server"; +import { db as authDb } from "@webstudio-is/authorization-token/index.server"; import { type Deployment, type Resource, @@ -299,7 +300,7 @@ export const createProductionBuild = async ( context: AppContext ) => { const canBuild = await authorizeProject.hasProjectPermit( - { projectId: props.projectId, permit: "build" }, + { projectId: props.projectId, permit: "edit" }, context ); @@ -307,6 +308,23 @@ export const createProductionBuild = async ( throw new AuthorizationError("You don't have access to build this project"); } + // Get token permissions + if (context.authorization.type === "token") { + const permissions = await authDb.getTokenPermissions( + { + projectId: props.projectId, + token: context.authorization.authToken, + }, + context + ); + + if (!permissions.canPublish) { + throw new AuthorizationError( + "The token does not have permission to build this project." + ); + } + } + const build = await context.postgrest.client.rpc("create_production_build", { project_id: props.projectId, deployment: JSON.stringify(props.deployment), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9de3f35a0a42..c888777ecade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1669,6 +1669,9 @@ importers: packages/project-build: dependencies: + '@webstudio-is/authorization-token': + specifier: workspace:* + version: link:../authorization-token '@webstudio-is/postrest': specifier: workspace:* version: link:../postgrest @@ -11794,7 +11797,7 @@ snapshots: '@vue/compiler-core@3.3.4': dependencies: - '@babel/parser': 7.25.7 + '@babel/parser': 7.26.2 '@vue/shared': 3.3.4 estree-walker: 2.0.2 source-map-js: 1.2.1 @@ -11806,7 +11809,7 @@ snapshots: '@vue/compiler-sfc@3.3.4': dependencies: - '@babel/parser': 7.25.7 + '@babel/parser': 7.26.2 '@vue/compiler-core': 3.3.4 '@vue/compiler-dom': 3.3.4 '@vue/compiler-ssr': 3.3.4 @@ -11824,7 +11827,7 @@ snapshots: '@vue/reactivity-transform@3.3.4': dependencies: - '@babel/parser': 7.25.7 + '@babel/parser': 7.26.2 '@vue/compiler-core': 3.3.4 '@vue/shared': 3.3.4 estree-walker: 2.0.2