Skip to content

Commit 071cd9a

Browse files
committed
fix: show over-capacity warning when members exceed paid seats
1 parent 1f48697 commit 071cd9a

4 files changed

Lines changed: 59 additions & 84 deletions

File tree

apps/builder/app/dashboard/workspace/manage-members-dialog.test.ts

Lines changed: 0 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -178,48 +178,4 @@ describe("computeAvailableSeats", () => {
178178
computeAvailableSeats(makeData({ maxSeats: 1, members }), optimistic)
179179
).toBe(-2);
180180
});
181-
182-
test("confirmedMaxSeats overrides stale server maxSeats when higher", () => {
183-
// Scenario: user confirmed paying for 2 extra seats but webhook hasn't fired.
184-
// Server still returns maxSeats=3, but confirmedMaxSeats=5.
185-
const members = [
186-
{
187-
userId: "u1",
188-
email: "a@example.com",
189-
relation: "editors" as const,
190-
createdAt: "",
191-
username: "",
192-
},
193-
];
194-
const pendingInvites = [
195-
{
196-
notificationId: "n1",
197-
recipientId: "r1",
198-
email: "b@example.com",
199-
relation: "editors" as const,
200-
createdAt: "",
201-
},
202-
{
203-
notificationId: "n2",
204-
recipientId: "r2",
205-
email: "c@example.com",
206-
relation: "editors" as const,
207-
createdAt: "",
208-
},
209-
];
210-
// maxSeats=3 (stale), but 1 member + 2 pending = 3 occupied → would be 0 available.
211-
// confirmedMaxSeats=5 → effectiveMax=5, available = 5-1-2 = 2.
212-
expect(
213-
computeAvailableSeats(
214-
makeData({ maxSeats: 3, members, pendingInvites }),
215-
[],
216-
5
217-
)
218-
).toBe(2);
219-
});
220-
221-
test("confirmedMaxSeats is ignored when server maxSeats is already higher", () => {
222-
// Webhook fired: server returns maxSeats=5, confirmedMaxSeats=4 (stale override).
223-
expect(computeAvailableSeats(makeData({ maxSeats: 5 }), [], 4)).toBe(5);
224-
});
225181
});

apps/builder/app/dashboard/workspace/manage-members-dialog.tsx

Lines changed: 44 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
ScrollAreaNative,
2020
Select,
2121
Tooltip,
22+
PanelBanner,
2223
css,
2324
theme,
2425
} from "@webstudio-is/design-system";
@@ -254,18 +255,11 @@ const computeAvailableSeats = (
254255
{ success: true }
255256
>["data"]
256257
| undefined,
257-
optimisticPending: OptimisticPendingInvite[],
258-
// Optimistic upper bound for maxSeats set when extra seats are confirmed,
259-
// to avoid flickering while the Stripe webhook has not yet updated TransactionLog.
260-
confirmedMaxSeats?: number
258+
optimisticPending: OptimisticPendingInvite[]
261259
): number | undefined => {
262260
if (membersData === undefined) {
263261
return;
264262
}
265-
const effectiveMaxSeats =
266-
confirmedMaxSeats !== undefined
267-
? Math.max(membersData.maxSeats, confirmedMaxSeats)
268-
: membersData.maxSeats;
269263
const knownEmails = new Set([
270264
membersData.owner.email,
271265
...membersData.members.map((m) => m.email ?? ""),
@@ -275,7 +269,7 @@ const computeAvailableSeats = (
275269
(o) => !knownEmails.has(o.email)
276270
).length;
277271
return (
278-
effectiveMaxSeats -
272+
membersData.maxSeats -
279273
membersData.members.length -
280274
membersData.pendingInvites.length -
281275
extraCount
@@ -435,10 +429,6 @@ export const ManageMembersDialog = ({
435429
relation: Role;
436430
extraSeats: number;
437431
}>();
438-
// Optimistic maxSeats ceiling: set when the user confirms extra-seat charges so
439-
// the footer and confirmation gate stay correct while the Stripe webhook is in
440-
// flight (TransactionLog not yet updated).
441-
const [confirmedMaxSeats, setConfirmedMaxSeats] = useState<number>();
442432
const formRef = useRef<HTMLFormElement>(null);
443433

444434
const { load, data } = trpcClient.workspace.listMembers.useQuery();
@@ -453,22 +443,23 @@ export const ManageMembersDialog = ({
453443
}
454444
}, [isOpen, isOwner, load, workspace.id]);
455445

456-
// Clear the optimistic override once the server reflects the paid seats.
457-
useEffect(() => {
458-
if (
459-
membersData !== undefined &&
460-
confirmedMaxSeats !== undefined &&
461-
membersData.maxSeats >= confirmedMaxSeats
462-
) {
463-
setConfirmedMaxSeats(undefined);
464-
}
465-
}, [membersData, confirmedMaxSeats]);
446+
const availableSeats = computeAvailableSeats(membersData, optimisticPending);
466447

467-
const availableSeats = computeAvailableSeats(
468-
membersData,
469-
optimisticPending,
470-
confirmedMaxSeats
471-
);
448+
const syncSeatsMutation = trpcClient.workspace.syncSeats.useMutation();
449+
450+
const overCapacity =
451+
availableSeats !== undefined && availableSeats < 0 ? -availableSeats : 0;
452+
453+
const handleSyncSeats = () => {
454+
syncSeatsMutation.send({ workspaceId: workspace.id }, (result) => {
455+
if (result && "error" in result) {
456+
setErrors([result.error]);
457+
return;
458+
}
459+
handleRefresh();
460+
revalidator.revalidate();
461+
});
462+
};
472463

473464
const performInvite = async (emails: string[], relation: Role) => {
474465
setErrors(undefined);
@@ -536,14 +527,6 @@ export const ManageMembersDialog = ({
536527
onConfirm={async () => {
537528
const confirm = pendingConfirm;
538529
setPendingConfirm(undefined);
539-
// Optimistically raise confirmedMaxSeats so the footer and the
540-
// confirmation gate reflect the payment before the webhook fires.
541-
setConfirmedMaxSeats((prev) =>
542-
Math.max(
543-
prev ?? 0,
544-
(membersData?.maxSeats ?? 0) + confirm.extraSeats
545-
)
546-
);
547530
await performInvite(confirm.emails, confirm.relation);
548531
}}
549532
/>
@@ -554,7 +537,6 @@ export const ManageMembersDialog = ({
554537
onOpenChange(open);
555538
if (open === false) {
556539
setErrors(undefined);
557-
setConfirmedMaxSeats(undefined);
558540
}
559541
}}
560542
>
@@ -599,6 +581,31 @@ export const ManageMembersDialog = ({
599581
</Flex>
600582
</Flex>
601583
)}
584+
{isOwner && overCapacity > 0 && (
585+
<PanelBanner variant="warning">
586+
<Flex direction="column" gap="2">
587+
<Text>
588+
{`Your workspace has ${overCapacity} more member${overCapacity === 1 ? "" : "s"} than your plan covers. Non-owner members won't be able to access the workspace until this is resolved.`}
589+
</Text>
590+
<Flex gap="2">
591+
<Button
592+
color="dark"
593+
onClick={handleSyncSeats}
594+
state={
595+
syncSeatsMutation.state !== "idle"
596+
? "pending"
597+
: undefined
598+
}
599+
>
600+
{`Buy ${overCapacity} extra seat${overCapacity === 1 ? "" : "s"}`}
601+
</Button>
602+
<Text color="subtle" css={{ alignSelf: "center" }}>
603+
{`or remove ${overCapacity} member${overCapacity === 1 ? "" : "s"}`}
604+
</Text>
605+
</Flex>
606+
</Flex>
607+
</PanelBanner>
608+
)}
602609
<ScrollAreaNative
603610
css={{
604611
maxHeight: 300,

apps/builder/app/routes/_ui.dashboard.tsx

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,9 +268,7 @@ const DashboardRoute = () => {
268268
}, []);
269269

270270
useEffect(() => {
271-
if (search !== "") {
272-
setSetting("lastDashboardSearch", search);
273-
}
271+
setSetting("lastDashboardSearch", search);
274272
}, [search]);
275273

276274
// `useLoaderData` wraps the return in `JsonifyObject` which turns

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,20 @@ export const workspaceRouter = router({
299299
}
300300
}),
301301

302+
syncSeats: procedure
303+
.input(z.object({ workspaceId: z.string() }))
304+
.mutation(async ({ input, ctx }) => {
305+
try {
306+
if (ctx.planFeatures.maxWorkspaces <= 1) {
307+
throw new Error("Upgrade your plan to manage workspace seats.");
308+
}
309+
await syncOwnerSeats(input.workspaceId, ctx);
310+
return { success: true as const };
311+
} catch (error) {
312+
return createErrorResponse(error);
313+
}
314+
}),
315+
302316
listMembers: procedure
303317
.input(z.object({ workspaceId: z.string() }))
304318
.query(async ({ input, ctx }) => {

0 commit comments

Comments
 (0)