Skip to content

Commit d7a8821

Browse files
atrakhConvex, Inc.
authored andcommitted
dashboard: disable UI for team actions that cannot be done on managed teams (#38345)
GitOrigin-RevId: 8a0b1a5f81c267566fc2d200f910079cf9bc7f2b
1 parent 22bf914 commit d7a8821

File tree

10 files changed

+142
-43
lines changed

10 files changed

+142
-43
lines changed

npm-packages/dashboard/src/components/billing/PriceSummary.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { planNameMap } from "components/billing/planCards/PlanCard";
22
import { PlanResponse } from "generatedApi";
33
import Link from "next/link";
4+
import startCase from "lodash/startCase";
5+
import { Callout } from "@ui/Callout";
46

57
export function PriceSummary({
68
plan,
@@ -9,19 +11,29 @@ export function PriceSummary({
911
couponDurationInMonths,
1012
requiresPaymentMethod,
1113
isUpgrading,
14+
teamManagedBy,
1215
}: {
1316
plan: PlanResponse;
1417
teamMemberDiscountPct: number;
1518
numMembers: number;
1619
couponDurationInMonths?: number;
1720
requiresPaymentMethod: boolean;
1821
isUpgrading: boolean;
22+
teamManagedBy?: string;
1923
}) {
2024
const newPlanName = plan.planType
2125
? planNameMap[plan.planType] || plan.name
2226
: plan.name;
2327
return (
2428
<div className="flex flex-col gap-2 text-sm" data-testid="price-summary">
29+
{teamManagedBy && (
30+
<Callout className="mb-2">
31+
This team's billing is currently being managed by{" "}
32+
{startCase(teamManagedBy)}. Upgrading to this plan will disable your{" "}
33+
{startCase(teamManagedBy)} integration and migrate billing to be
34+
handled by Convex.
35+
</Callout>
36+
)}
2537
{plan.seatPrice ? (
2638
<>
2739
<p>

npm-packages/dashboard/src/components/billing/SubscriptionOverview.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { QuestionMarkCircledIcon } from "@radix-ui/react-icons";
2727
import { Callout } from "@ui/Callout";
2828
import { formatUsd } from "@common/lib/utils";
2929
import { planNameMap } from "components/billing/planCards/PlanCard";
30+
import startCase from "lodash/startCase";
3031
import { BillingContactInputs } from "./BillingContactInputs";
3132
import { CreateSubscriptionSchema } from "./UpgradePlanContent";
3233
import { PaymentDetailsForm } from "./PaymentDetailsForm";
@@ -452,6 +453,14 @@ function BillingAddressForm({
452453
return (
453454
<div className="flex flex-col gap-4" ref={ref}>
454455
<h4>Billing Address</h4>
456+
{team.managedBy && (
457+
<Callout>
458+
<div>
459+
This team is managed by {startCase(team.managedBy)}. You may add a
460+
billing address if you wish to upgrade to the Professional plan.
461+
</div>
462+
</Callout>
463+
)}
455464
{!showForm ? (
456465
<>
457466
<div className="text-sm">
@@ -589,6 +598,14 @@ function PaymentMethodForm({
589598
return (
590599
<div className="flex flex-col gap-4">
591600
<h4>Payment Method</h4>
601+
{team.managedBy && (
602+
<Callout>
603+
<div>
604+
This team is managed by {startCase(team.managedBy)}. You may add a
605+
payment method if you wish to upgrade to the Professional plan.
606+
</div>
607+
</Callout>
608+
)}
592609
{subscription.paymentMethod && (
593610
<div className="text-sm">
594611
Current payment method:{" "}

npm-packages/dashboard/src/components/billing/UpgradePlanContent.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export type UpgradePlanContentProps = {
3535
billingAddressInputs: React.ReactNode;
3636
paymentDetailsForm: React.ReactNode;
3737
isChef: boolean;
38+
teamManagedBy?: string;
3839
};
3940

4041
export const CreateSubscriptionSchema = Yup.object().shape({
@@ -152,6 +153,7 @@ export function UpgradePlanContentContainer({
152153
{...props}
153154
plan={plan}
154155
isChef={isChef}
156+
teamManagedBy={team.managedBy || undefined}
155157
setPaymentMethod={(p) => {
156158
if (!p) {
157159
resetClientSecret();
@@ -206,6 +208,7 @@ export function UpgradePlanContent({
206208
billingAddressInputs,
207209
paymentDetailsForm,
208210
isChef,
211+
teamManagedBy,
209212
}: UpgradePlanContentProps) {
210213
const formState = useFormikContext<UpgradeFormState>();
211214

@@ -236,6 +239,7 @@ export function UpgradePlanContent({
236239
requiresPaymentMethod={requiresPaymentMethod}
237240
couponDurationInMonths={couponDurationInMonths}
238241
isUpgrading={false}
242+
teamManagedBy={teamManagedBy}
239243
/>
240244
{plan.planType === "CONVEX_PROFESSIONAL" && (
241245
<div className="flex max-w-64 items-center gap-2">

npm-packages/dashboard/src/components/billing/planCards/ChangePlanDialogs.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export function UpgradePlanDialog({
123123
requiresPaymentMethod
124124
couponDurationInMonths={undefined}
125125
isUpgrading
126+
teamManagedBy={team.managedBy || undefined}
126127
/>
127128
{newPlan.planType === "CONVEX_PROFESSIONAL" && (
128129
<div className="flex max-w-64 items-center gap-2">

npm-packages/dashboard/src/components/billing/planCards/FreePlan.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Link from "next/link";
88
import { useCancelSubscription } from "api/billing";
99
import { InfoCircledIcon } from "@radix-ui/react-icons";
1010
import { OrbSubscriptionResponse, Team } from "generatedApi";
11+
import startCase from "lodash/startCase";
1112
import { PlanCard } from "./PlanCard";
1213

1314
export function FreePlan({
@@ -52,13 +53,15 @@ export function FreePlan({
5253
</p>
5354
) : (
5455
<Button
55-
disabled={!hasAdminPermissions}
56+
disabled={!hasAdminPermissions || !!team.managedBy}
5657
tip={
5758
!hasAdminPermissions
5859
? "You do not have permission to modify the team subscription."
59-
: typeof subscription.endDate === "number"
60-
? `Your subscription has already been canceled and will end on ${formatDate(new Date(subscription.endDate))}. You may resume the subscription before then to avoid losing access to features.`
61-
: undefined
60+
: team.managedBy
61+
? `This team is managed by ${startCase(team.managedBy)}. You may manage the team subscription in ${startCase(team.managedBy)}.`
62+
: typeof subscription.endDate === "number"
63+
? `Your subscription has already been canceled and will end on ${formatDate(new Date(subscription.endDate))}. You may resume the subscription before then to avoid losing access to features.`
64+
: undefined
6265
}
6366
variant="neutral"
6467
onClick={() => {

npm-packages/dashboard/src/components/billing/planCards/OrbSelfServePlan.tsx

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
UpgradePlanDialog,
1010
} from "components/billing/planCards/ChangePlanDialogs";
1111
import { planNameMap } from "components/billing/planCards/PlanCard";
12+
import startCase from "lodash/startCase";
1213
import { SelfServePlan } from "./SelfServePlan";
1314

1415
export function OrbSelfServePlan({
@@ -72,14 +73,20 @@ export function OrbSelfServePlan({
7273
tip={
7374
!hasAdminPermissions
7475
? "You do not have permission to modify the team subscription."
75-
: missingRequiredPaymentMethod
76-
? "Add a payment method in the settings below to switch to this plan."
77-
: undefined
76+
: team.managedBy && plan.planType !== "CONVEX_PROFESSIONAL"
77+
? `This team is managed by ${startCase(team.managedBy)}. You may select this plan in ${startCase(team.managedBy)}.`
78+
: missingRequiredPaymentMethod
79+
? "Add a payment method in the settings below to switch to this plan."
80+
: undefined
7881
}
7982
onClick={() => {
8083
setIsChangingPlan(true);
8184
}}
82-
disabled={!hasAdminPermissions || missingRequiredPaymentMethod}
85+
disabled={
86+
!hasAdminPermissions ||
87+
missingRequiredPaymentMethod ||
88+
(!!team.managedBy && plan.planType !== "CONVEX_PROFESSIONAL")
89+
}
8390
variant={isDowngrade ? "neutral" : "primary"}
8491
>
8592
{isDowngrade
@@ -89,14 +96,19 @@ export function OrbSelfServePlan({
8996
) : (
9097
<Button
9198
onClick={() => upgrade()}
92-
disabled={!hasAdminPermissions}
99+
disabled={
100+
!hasAdminPermissions ||
101+
(!!team.managedBy && plan.planType !== "CONVEX_PROFESSIONAL")
102+
}
93103
variant={
94104
plan.planType === "CONVEX_PROFESSIONAL" ? "primary" : "neutral"
95105
}
96106
tip={
97107
!hasAdminPermissions
98108
? "You do not have permission to modify the team subscription."
99-
: undefined
109+
: team.managedBy && plan.planType !== "CONVEX_PROFESSIONAL"
110+
? `This team is managed by ${startCase(team.managedBy)}. You may select this plan in ${startCase(team.managedBy)}.`
111+
: undefined
100112
}
101113
>
102114
Upgrade to {newPlanName}

npm-packages/dashboard/src/components/teamSettings/TeamForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export function TeamForm({
7070
value={formState.values.name}
7171
id="name"
7272
error={formState.errors.name}
73-
disabled={!hasAdminPermissions}
73+
disabled={!hasAdminPermissions || !!team.managedBy}
7474
/>
7575
</Tooltip>
7676
<Tooltip

npm-packages/dashboard/src/components/teamSettings/TeamMemberListItem.tsx

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useMount } from "react-use";
1717
import classNames from "classnames";
1818
import startCase from "lodash/startCase";
1919
import Link from "next/link";
20+
import { Callout } from "@ui/Callout";
2021
import { MemberProjectRolesModal } from "./MemberProjectRolesModal";
2122

2223
export const roleOptions: Option<"admin" | "developer">[] = [
@@ -79,7 +80,9 @@ export function TeamMemberListItem({
7980
}
8081

8182
let updateRoleMessage = "";
82-
if (isMemberTheLastAdmin) {
83+
if (team.managedBy) {
84+
updateRoleMessage = `This team is managed by ${startCase(team.managedBy)}. You may manage team roles in ${startCase(team.managedBy)}.`;
85+
} else if (isMemberTheLastAdmin) {
8386
updateRoleMessage = "You cannot change the role of the last admin.";
8487
} else if (!hasAdminPermissions) {
8588
updateRoleMessage = "You do not have permission to change member roles.";
@@ -125,10 +128,10 @@ export function TeamMemberListItem({
125128
<div className="text-sm text-content-primary">
126129
{startCase(member.role)}
127130
</div>
128-
) : !canManageMember ? (
131+
) : !canManageMember || team.managedBy ? (
129132
// Combobox is difficult to create a disabled state for, so we're using a div here that looks like a disabled input
130133
<Tooltip tip={updateRoleMessage}>
131-
<div className="flex cursor-not-allowed items-center gap-1 rounded border bg-background-tertiary px-3 py-2 text-content-secondary">
134+
<div className="flex cursor-not-allowed items-center gap-1 rounded border bg-background-tertiary p-1.5 text-content-secondary">
132135
{startCase(member.role)}
133136
<CaretSortIcon className="h-5 w-5" />
134137
</div>
@@ -195,9 +198,22 @@ export function TeamMemberListItem({
195198
}}
196199
dialogTitle={isMemberMe ? "Leave team" : "Remove team member"}
197200
dialogBody={
198-
isMemberMe
199-
? `You are about to leave ${team.name}, are you sure you want to continue?`
200-
: `You are about to remove ${confirmationDisplayName} from ${team.name}, are you sure you want to continue?`
201+
isMemberMe ? (
202+
`You are about to leave ${team.name}, are you sure you want to continue?`
203+
) : (
204+
<>
205+
You are about to remove {confirmationDisplayName} from{" "}
206+
{team.name}, are you sure you want to continue?
207+
{team.managedBy && (
208+
<Callout>
209+
Note that this member may be able to re-join the team
210+
through the {startCase(team.managedBy)} dashboard if they
211+
are still a member of your {startCase(team.managedBy)}{" "}
212+
team.
213+
</Callout>
214+
)}
215+
</>
216+
)
201217
}
202218
confirmText="Confirm"
203219
/>

npm-packages/dashboard/src/components/teamSettings/TeamMembers.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import { useTeamInvites } from "api/invitations";
66
import { useIsCurrentMemberTeamAdmin } from "api/roles";
77
import Link from "next/link";
88
import { Team } from "generatedApi";
9+
import startCase from "lodash/startCase";
910

11+
import { captureMessage } from "@sentry/nextjs";
1012
import { InviteMemberForm } from "./InviteMemberForm";
1113
import { TeamMemberList } from "./TeamMemberList";
1214

@@ -27,6 +29,17 @@ export function TeamMembers({ team }: { team: Team }) {
2729
inviteMembers = (
2830
<Loading className="h-[9.5rem] w-full rounded" fullHeight={false} />
2931
);
32+
} else if (team.managedBy) {
33+
inviteMembers = (
34+
<Callout>
35+
<div className="flex flex-col gap-2 p-2">
36+
<div>
37+
This team is managed by {startCase(team.managedBy)}.{" "}
38+
{joinInstructionsForTeamManagedBy(team.managedBy)}
39+
</div>
40+
</div>
41+
</Callout>
42+
);
3043
} else if (canAddMembers) {
3144
// Show invite form if you can add members.
3245
inviteMembers = (
@@ -86,3 +99,13 @@ export function TeamMembers({ team }: { team: Team }) {
8699
</>
87100
);
88101
}
102+
103+
function joinInstructionsForTeamManagedBy(managedBy: string) {
104+
switch (managedBy) {
105+
case "vercel":
106+
return 'Your team members may join the team by clicking "Open in Convex" when viewing the Convex integration in their Vercel dashboard.';
107+
default:
108+
captureMessage(`Unknown team managed by: ${managedBy}`);
109+
return "";
110+
}
111+
}

npm-packages/dashboard/src/components/teamSettings/TeamSettings.tsx

Lines changed: 37 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { Sheet } from "@ui/Sheet";
1212
import { Button } from "@ui/Button";
1313
import { ConfirmationDialog } from "@ui/ConfirmationDialog";
1414
import { useState } from "react";
15+
import startCase from "lodash/startCase";
16+
import { Callout } from "@ui/Callout";
1517
import { TeamForm } from "./TeamForm";
1618

1719
export function TeamSettings({ team }: { team: Team }) {
@@ -38,39 +40,48 @@ export function TeamSettings({ team }: { team: Team }) {
3840
remove all team members and delete all projects associated with the
3941
team.
4042
</p>
43+
{team.managedBy && (
44+
<Callout>
45+
This team is managed by {startCase(team.managedBy)}. You must delete
46+
the integration in {startCase(team.managedBy)} before you can delete
47+
this team.
48+
</Callout>
49+
)}
4150
{subscription && (
4251
<p className="mb-4">
4352
Deleting your team will automatically cancel your{" "}
4453
<span className="font-semibold">{subscription.plan.name}</span>{" "}
4554
subscription.
4655
</p>
4756
)}
48-
<Button
49-
variant="danger"
50-
onClick={() => setShowDeleteTeamModal(true)}
51-
disabled={
52-
!hasAdminPermissions ||
53-
!teams ||
54-
teams.length === 1 ||
55-
!teamMembers ||
56-
teamMembers.length > 1 ||
57-
!projects ||
58-
projects.length > 0
59-
}
60-
tip={
61-
!hasAdminPermissions
62-
? "You do not have permission to delete this team."
63-
: teams && teams.length === 1
64-
? "You cannot delete your last team."
65-
: teamMembers && teamMembers.length > 1
66-
? "You must remove all other team members before deleting the team."
67-
: projects && projects.length > 0
68-
? "You must delete all projects before deleting the team."
69-
: undefined
70-
}
71-
>
72-
Delete Team
73-
</Button>
57+
{!team.managedBy && (
58+
<Button
59+
variant="danger"
60+
onClick={() => setShowDeleteTeamModal(true)}
61+
disabled={
62+
!hasAdminPermissions ||
63+
!teams ||
64+
teams.length === 1 ||
65+
!teamMembers ||
66+
teamMembers.length > 1 ||
67+
!projects ||
68+
projects.length > 0
69+
}
70+
tip={
71+
!hasAdminPermissions
72+
? "You do not have permission to delete this team."
73+
: teams && teams.length === 1
74+
? "You cannot delete your last team."
75+
: teamMembers && teamMembers.length > 1
76+
? "You must remove all other team members before deleting the team."
77+
: projects && projects.length > 0
78+
? "You must delete all projects before deleting the team."
79+
: undefined
80+
}
81+
>
82+
Delete Team
83+
</Button>
84+
)}
7485
{showDeleteTeamModal && (
7586
<ConfirmationDialog
7687
onClose={() => setShowDeleteTeamModal(false)}

0 commit comments

Comments
 (0)