Skip to content

Commit 9c2116a

Browse files
authored
fix: Use project owner plan limits when project is shared via workspace (#5751)
1 parent 7046cdc commit 9c2116a

21 files changed

Lines changed: 724 additions & 180 deletions

apps/builder/app/builder/features/publish/publish.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -784,8 +784,10 @@ const useCanAddDomain = () => {
784784
(domain) => domain.status === "ACTIVE" && domain.verified
785785
).length;
786786
useEffect(() => {
787-
load();
788-
}, [load, activeDomainsCount]);
787+
if (project?.id !== undefined) {
788+
load({ projectId: project.id });
789+
}
790+
}, [load, activeDomainsCount, project?.id]);
789791
const canAddDomain = data
790792
? data.success && data.data < maxDomainsAllowedPerUser
791793
: true;
@@ -795,9 +797,12 @@ const useCanAddDomain = () => {
795797
const useUserPublishCount = () => {
796798
const { load, data } = trpcClient.project.userPublishCount.useQuery();
797799
const { maxDailyPublishesPerUser } = useStore($permissions);
800+
const project = useStore($project);
798801
useEffect(() => {
799-
load();
800-
}, [load]);
802+
if (project?.id !== undefined) {
803+
load({ projectId: project.id });
804+
}
805+
}, [load, project?.id]);
801806
return {
802807
userPublishCount: data?.success ? data.data : 0,
803808
maxDailyPublishesPerUser,

apps/builder/app/routes/rest.assets.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ export const action = async (props: ActionFunctionArgs) => {
5252
projectId,
5353
type,
5454
filename,
55-
maxAssetsPerProject: context.planFeatures.maxAssetsPerProject,
5655
},
5756
context
5857
);

apps/builder/app/services/workspace-router.server.test.ts

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ const teamPlan: PlanFeatures = {
3232
};
3333

3434
const createContext = (
35-
overrides?: Partial<{ planFeatures: PlanFeatures; userId: string }>
35+
overrides?: Partial<{
36+
planFeatures: PlanFeatures;
37+
ownerPlanFeatures: PlanFeatures;
38+
userId: string;
39+
}>
3640
): AppContext =>
3741
({
3842
...testContext,
@@ -43,7 +47,8 @@ const createContext = (
4347
planFeatures: overrides?.planFeatures ?? teamPlan,
4448
purchases: [],
4549
trpcCache: { setMaxAge: () => {} },
46-
getOwnerPlanFeatures: async () => overrides?.planFeatures ?? teamPlan,
50+
getOwnerPlanFeatures: async () =>
51+
overrides?.ownerPlanFeatures ?? overrides?.planFeatures ?? teamPlan,
4752
}) as unknown as AppContext;
4853

4954
// ---------------------------------------------------------------------------
@@ -134,7 +139,11 @@ const setupAddMemberMocks = (opts?: {
134139

135140
describe("addMember", () => {
136141
test("rejects when maxWorkspaces <= 1 (free/pro plan)", async () => {
137-
// No DB mocks needed — the check happens before any queries.
142+
server.use(
143+
db.get("Workspace", () =>
144+
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
145+
)
146+
);
138147
const ctx = createContext({ planFeatures: defaultPlanFeatures });
139148
const caller = createCaller(ctx);
140149

@@ -150,6 +159,35 @@ describe("addMember", () => {
150159
}
151160
});
152161

162+
test("rejects non-owners before billing sync", async () => {
163+
let paymentWorkerCalled = false;
164+
165+
server.use(
166+
db.get("Workspace", () =>
167+
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
168+
),
169+
http.post(`${env.PAYMENT_WORKER_URL}/seats/sync`, () => {
170+
paymentWorkerCalled = true;
171+
return HttpResponse.json({ type: "success", seats: 1 });
172+
})
173+
);
174+
175+
const ctx = createContext({ userId: "member-1" });
176+
const caller = createCaller(ctx);
177+
178+
const result = await caller.addMember({
179+
workspaceId: "ws-1",
180+
email: "invitee@test.com",
181+
relation: "editors",
182+
});
183+
184+
expect(result.success).toBe(false);
185+
if (!result.success) {
186+
expect(result.error).toContain("Only the workspace owner");
187+
}
188+
expect(paymentWorkerCalled).toBe(false);
189+
});
190+
153191
test("rejects when workspace seat limit reached", async () => {
154192
const plan: PlanFeatures = { ...teamPlan, maxSeatsPerWorkspace: 2 };
155193
const ctx = createContext({ planFeatures: plan });
@@ -320,6 +358,11 @@ describe("removeMember", () => {
320358

321359
describe("syncSeats", () => {
322360
test("rejects when maxWorkspaces <= 1", async () => {
361+
server.use(
362+
db.get("Workspace", () =>
363+
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
364+
)
365+
);
323366
const ctx = createContext({ planFeatures: defaultPlanFeatures });
324367
const caller = createCaller(ctx);
325368

@@ -335,6 +378,9 @@ describe("syncSeats", () => {
335378
let capturedBody: { workspaceId: string; delta?: number } | undefined;
336379

337380
server.use(
381+
db.get("Workspace", () =>
382+
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
383+
),
338384
http.post(`${env.PAYMENT_WORKER_URL}/seats/sync`, async ({ request }) => {
339385
capturedBody = (await request.json()) as typeof capturedBody;
340386
return HttpResponse.json({ type: "success", seats: 3 });
@@ -376,7 +422,13 @@ describe("listMembers", () => {
376422
json({ id: "ws-1", userId: "owner-1", isDeleted: false })
377423
),
378424
// listMembers: WorkspaceMember SELECT (members list + access check)
379-
db.get("WorkspaceMember", () => json(members)),
425+
db.get("WorkspaceMember", ({ request }) => {
426+
const url = new URL(request.url);
427+
if (url.searchParams.has("userId")) {
428+
return json({ userId: url.searchParams.get("userId") });
429+
}
430+
return json(members);
431+
}),
380432
// listMembers: Notification GET (pending invites for this workspace)
381433
db.get("Notification", () => json([])),
382434
// listMembers: User batch lookup
@@ -449,6 +501,23 @@ describe("listMembers", () => {
449501
}
450502
});
451503

504+
test("maxSeats uses workspace owner's seats for member callers", async () => {
505+
setupListMembersMocks({ transactionLog: null });
506+
507+
const ctx = createContext({
508+
userId: "m-1",
509+
planFeatures: defaultPlanFeatures,
510+
ownerPlanFeatures: teamPlan,
511+
});
512+
const caller = createCaller(ctx);
513+
const result = await caller.listMembers({ workspaceId: "ws-1" });
514+
515+
expect(result.success).toBe(true);
516+
if (result.success) {
517+
expect(result.data.maxSeats).toBe(4);
518+
}
519+
});
520+
452521
test("returns owner, members, and pendingInvites", async () => {
453522
setupListMembersMocks({ memberCount: 2, transactionLog: null });
454523

apps/builder/app/services/workspace-router.server.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
router,
44
procedure,
55
createErrorResponse,
6+
getPlanFeaturesByOwnerId,
67
} from "@webstudio-is/trpc-interface/index.server";
78
import { workspace as workspaceApi } from "@webstudio-is/project/index.server";
89
import { roles } from "@webstudio-is/trpc-interface/authorize";
@@ -118,7 +119,12 @@ export const workspaceRouter = router({
118119
)
119120
.mutation(async ({ input, ctx }) => {
120121
try {
121-
if (ctx.planFeatures.maxWorkspaces <= 1) {
122+
const { plan } = await workspaceApi.assertWorkspaceOwnerPlan(
123+
input.workspaceId,
124+
ctx
125+
);
126+
127+
if (plan.maxWorkspaces <= 1) {
122128
throw new Error("Upgrade your plan to invite members to workspaces.");
123129
}
124130

@@ -132,7 +138,7 @@ export const workspaceRouter = router({
132138
);
133139
}
134140

135-
const { maxSeatsPerWorkspace } = ctx.planFeatures;
141+
const { maxSeatsPerWorkspace } = plan;
136142
if (maxSeatsPerWorkspace > 0) {
137143
const [membersResult, pendingResult] = await Promise.all([
138144
ctx.postgrest.client
@@ -229,7 +235,12 @@ export const workspaceRouter = router({
229235
)
230236
.mutation(async ({ input, ctx }) => {
231237
try {
232-
if (ctx.planFeatures.maxWorkspaces <= 1) {
238+
const { plan } = await workspaceApi.assertWorkspaceOwnerPlan(
239+
input.workspaceId,
240+
ctx
241+
);
242+
243+
if (plan.maxWorkspaces <= 1) {
233244
throw new Error(
234245
"Upgrade your plan to manage workspace member roles."
235246
);
@@ -258,7 +269,12 @@ export const workspaceRouter = router({
258269
.input(z.object({ workspaceId: z.string() }))
259270
.mutation(async ({ input, ctx }) => {
260271
try {
261-
if (ctx.planFeatures.maxWorkspaces <= 1) {
272+
const { plan } = await workspaceApi.assertWorkspaceOwnerPlan(
273+
input.workspaceId,
274+
ctx
275+
);
276+
277+
if (plan.maxWorkspaces <= 1) {
262278
throw new Error("Upgrade your plan to manage workspace seats.");
263279
}
264280
await syncSeats(input.workspaceId);
@@ -273,18 +289,18 @@ export const workspaceRouter = router({
273289
.query(async ({ input, ctx }) => {
274290
try {
275291
const members = await workspaceApi.listMembers(input, ctx);
276-
const extraPaidSeats = await getExtraPaidSeats(
277-
members.owner.userId,
278-
ctx
279-
);
292+
const [ownerPlan, extraPaidSeats] = await Promise.all([
293+
getPlanFeaturesByOwnerId(members.owner.userId, ctx),
294+
getExtraPaidSeats(members.owner.userId, ctx),
295+
]);
280296
return {
281297
success: true as const,
282298
data: {
283299
...members,
284300
// seatsIncluded = seats covered by the Team plan.
285301
// extraPaidSeats = extra seats from the Seats subscription.
286302
// Total capacity = included + extras.
287-
maxSeats: ctx.planFeatures.seatsIncluded + (extraPaidSeats ?? 0),
303+
maxSeats: ownerPlan.seatsIncluded + (extraPaidSeats ?? 0),
288304
},
289305
};
290306
} catch (error) {
@@ -316,8 +332,9 @@ export const workspaceRouter = router({
316332
throw new Error("Target workspace not found");
317333
}
318334

319-
const ownerPlan = await ctx.getOwnerPlanFeatures(
320-
targetWorkspace.data.userId
335+
const ownerPlan = await getPlanFeaturesByOwnerId(
336+
targetWorkspace.data.userId,
337+
ctx
321338
);
322339

323340
if (ownerPlan.maxWorkspaces <= 1) {

packages/asset-uploader/src/upload.test.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,31 @@ import { createUploadName } from "./upload";
1111

1212
const server = createTestServer();
1313

14-
const createContext = (): AppContext =>
14+
const createContext = ({
15+
maxAssetsPerProject = 350,
16+
maxWorkspaces = 20,
17+
ownerPlanCalls,
18+
}: {
19+
maxAssetsPerProject?: number;
20+
maxWorkspaces?: number;
21+
ownerPlanCalls?: string[];
22+
} = {}): AppContext =>
1523
({
1624
...testContext,
1725
authorization: { type: "user", userId: "user-1" },
18-
getOwnerPlanFeatures: () => Promise.resolve({}),
26+
planFeatures: { maxAssetsPerProject: 100 },
27+
getOwnerPlanFeatures: (userId: string) => {
28+
ownerPlanCalls?.push(userId);
29+
return Promise.resolve({ maxAssetsPerProject, maxWorkspaces });
30+
},
1931
}) as unknown as AppContext;
2032

2133
const ownershipHandler = db.get("Project", ({ request }) => {
2234
const url = new URL(request.url);
2335
if (url.searchParams.has("userId")) {
2436
return json({ id: url.searchParams.get("id")?.replace("eq.", "") });
2537
}
26-
return json(null);
38+
return json({ userId: "owner-1", workspaceId: null });
2739
});
2840

2941
describe("createUploadName", () => {
@@ -64,7 +76,6 @@ describe("createUploadName", () => {
6476
projectId: "project-1",
6577
type: "image/png",
6678
filename: "photo.png",
67-
maxAssetsPerProject: 350,
6879
},
6980
createContext()
7081
);
@@ -91,10 +102,49 @@ describe("createUploadName", () => {
91102
projectId: "project-2",
92103
type: "image/png",
93104
filename: "photo.png",
94-
maxAssetsPerProject: 350,
95105
},
96106
createContext()
97107
)
98108
).rejects.toThrow("The maximum number of assets per project is 350.");
99109
});
110+
111+
test("uses project owner's asset limit for shared workspace projects", async () => {
112+
let insertedFile = false;
113+
const ownerPlanCalls: string[] = [];
114+
115+
server.use(
116+
db.get("Project", ({ request }) => {
117+
const url = new URL(request.url);
118+
if (url.searchParams.has("userId")) {
119+
return json(null);
120+
}
121+
return json({ userId: "team-owner" });
122+
}),
123+
db.get("WorkspaceProjectAuthorization", () =>
124+
json([{ relation: "editors" }])
125+
),
126+
db.head("Asset", () => empty({ headers: { "Content-Range": "*/100" } })),
127+
db.head("File", () => empty({ headers: { "Content-Range": "*/0" } })),
128+
db.post("File", () => {
129+
insertedFile = true;
130+
return empty({ status: 201 });
131+
}),
132+
db.post("Asset", () => empty({ status: 201 }))
133+
);
134+
135+
await expect(
136+
createUploadName(
137+
{
138+
assetId: "asset-3",
139+
projectId: "workspace-project",
140+
type: "image/png",
141+
filename: "photo.png",
142+
},
143+
createContext({ maxAssetsPerProject: 350, ownerPlanCalls })
144+
)
145+
).resolves.toMatch(/^photo_.+\.png$/);
146+
147+
expect(insertedFile).toBe(true);
148+
expect(ownerPlanCalls).toEqual(["team-owner"]);
149+
});
100150
});

packages/asset-uploader/src/upload.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
type AppContext,
33
authorizeProject,
44
AuthorizationError,
5+
getProjectPlanFeatures,
56
} from "@webstudio-is/trpc-interface/index.server";
67
import type { Asset } from "@webstudio-is/sdk";
78
import type { AssetClient } from "./client";
@@ -14,7 +15,6 @@ type UploadData = {
1415
projectId: string;
1516
type: string;
1617
filename: string;
17-
maxAssetsPerProject: number;
1818
};
1919

2020
const UPLOADING_STALE_TIMEOUT = 1000 * 60 * 30; // 30 minutes
@@ -23,7 +23,7 @@ export const createUploadName = async (
2323
data: UploadData,
2424
context: AppContext
2525
): Promise<string> => {
26-
const { assetId, projectId, maxAssetsPerProject, type, filename } = data;
26+
const { assetId, projectId, type, filename } = data;
2727
const canEdit = await authorizeProject.hasProjectPermit(
2828
{ projectId, permit: "edit" },
2929
context
@@ -34,6 +34,11 @@ export const createUploadName = async (
3434
);
3535
}
3636

37+
const { maxAssetsPerProject } = await getProjectPlanFeatures(
38+
projectId,
39+
context
40+
);
41+
3742
/**
3843
* sometimes for example on request timeout we don't know what happened to the "UPLOADING" asset,
3944
* so we don't take into account assets with the "UPLOADING" status that were created more

0 commit comments

Comments
 (0)