Skip to content

[Admin] Show BillingMode on Team-/UserDetails pages #12770

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 9, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions components/dashboard/src/admin/TeamDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,13 @@ import DropDown from "../components/DropDown";
import { Link } from "react-router-dom";
import Label from "./Label";
import Property from "./Property";
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";

export default function TeamDetail(props: { team: Team }) {
const { team } = props;
const [teamMembers, setTeamMembers] = useState<TeamMemberInfo[] | undefined>(undefined);
const [billingMode, setBillingMode] = useState<BillingMode | undefined>(undefined);
const [searchText, setSearchText] = useState<string>("");

useEffect(() => {
Expand All @@ -26,6 +29,9 @@ export default function TeamDetail(props: { team: Team }) {
setTeamMembers(members);
}
})();
getGitpodService()
.server.adminGetBillingMode(AttributionId.render({ kind: "team", teamId: props.team.id }))
.then((bm) => setBillingMode(bm));
}, [team]);

const filteredMembers = teamMembers?.filter((m) => {
Expand Down Expand Up @@ -59,6 +65,7 @@ export default function TeamDetail(props: { team: Team }) {
</div>
<div className="flex mt-6">
{!team.markedDeleted && teamMembers && <Property name="Members">{teamMembers.length}</Property>}
{!team.markedDeleted && <Property name="BillingMode">{billingMode?.mode || "---"}</Property>}
</div>
<div className="flex mt-4">
<div className="flex">
Expand Down
172 changes: 102 additions & 70 deletions components/dashboard/src/admin/UserDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ import { getGitpodService } from "../service/service";
import { WorkspaceSearch } from "./WorkspacesSearch";
import Property from "./Property";
import { PageWithAdminSubMenu } from "./PageWithAdminSubMenu";
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
import { AttributionId } from "@gitpod/gitpod-protocol/lib/attribution";

export default function UserDetail(p: { user: User }) {
const [activity, setActivity] = useState(false);
const [user, setUser] = useState(p.user);
const [billingMode, setBillingMode] = useState<BillingMode | undefined>(undefined);
const [accountStatement, setAccountStatement] = useState<AccountStatement>();
const [isStudent, setIsStudent] = useState<boolean>();
const [editFeatureFlags, setEditFeatureFlags] = useState(false);
Expand All @@ -46,6 +49,9 @@ export default function UserDetail(p: { user: User }) {
getGitpodService()
.server.adminIsStudent(p.user.id)
.then((isStud) => setIsStudent(isStud));
getGitpodService()
.server.adminGetBillingMode(AttributionId.render({ kind: "user", userId: p.user.id }))
.then((bm) => setBillingMode(bm));
}, [p.user]);

const email = User.getPrimaryEmail(p.user);
Expand Down Expand Up @@ -120,6 +126,101 @@ export default function UserDetail(p: { user: User }) {
}
};

function renderUserBillingProperties(): JSX.Element {
if (billingMode?.mode === "none") {
return <></>; // nothing to show here atm
}

const properties: JSX.Element[] = [
<Property
name="Student"
actions={
!isStudent && emailDomain && !["gmail.com", "yahoo.com", "hotmail.com"].includes(emailDomain)
? [
{
label: `Make '${emailDomain}' a student domain`,
onClick: addStudentDomain,
},
]
: undefined
}
>
{isStudent === undefined ? "---" : isStudent ? "Enabled" : "Disabled"}
</Property>,
<Property name="BillingMode">{billingMode?.mode || "---"}</Property>,
];

switch (billingMode?.mode) {
case "chargebee":
properties.push(
<Property
name="Remaining Hours"
actions={
accountStatement && [
{
label: "Download Account Statement",
onClick: () => downloadAccountStatement(),
},
{
label: "Grant 20 Extra Hours",
onClick: async () => {
await getGitpodService().server.adminGrantExtraHours(user.id, 20);
setAccountStatement(
await getGitpodService().server.adminGetAccountStatement(user.id),
);
},
},
]
}
>
{accountStatement?.remainingHours ? accountStatement?.remainingHours.toString() : "---"}
</Property>,
);
properties.push(
<Property
name="Plan"
actions={
accountStatement && [
{
label: (isProfessionalOpenSource ? "Disable" : "Enable") + " Professional OSS",
onClick: async () => {
await getGitpodService().server.adminSetProfessionalOpenSource(
user.id,
!isProfessionalOpenSource,
);
setAccountStatement(
await getGitpodService().server.adminGetAccountStatement(user.id),
);
},
},
]
}
>
{accountStatement?.subscriptions
? accountStatement.subscriptions
.filter((s) => !s.deleted && Subscription.isActive(s, new Date().toISOString()))
.map((s) => Plans.getById(s.planId)?.name)
.join(", ")
: "---"}
</Property>,
);
break;
case "usage-based":
// TODO(gpl) Add info about Stripe plan, etc.
break;
default:
break;
}

// Split properties into rows of 3
const rows: JSX.Element[] = [];
while (properties.length > 0) {
const row = properties.splice(0, 3);
rows.push(<div className="flex w-full mt-6">{row}</div>);
}
return <>{rows}</>;
}

return (
<>
<PageWithAdminSubMenu title="Users" subtitle="Search and manage all users.">
Expand Down Expand Up @@ -157,59 +258,6 @@ export default function UserDetail(p: { user: User }) {
<div className="flex flex-col w-full">
<div className="flex w-full mt-6">
<Property name="Sign Up Date">{moment(user.creationDate).format("MMM D, YYYY")}</Property>
<Property
name="Remaining Hours"
actions={
accountStatement && [
{
label: "Download Account Statement",
onClick: () => downloadAccountStatement(),
},
{
label: "Grant 20 Extra Hours",
onClick: async () => {
await getGitpodService().server.adminGrantExtraHours(user.id, 20);
setAccountStatement(
await getGitpodService().server.adminGetAccountStatement(user.id),
);
},
},
]
}
>
{accountStatement?.remainingHours ? accountStatement?.remainingHours.toString() : "---"}
</Property>
<Property
name="Plan"
actions={
accountStatement && [
{
label:
(isProfessionalOpenSource ? "Disable" : "Enable") + " Professional OSS",
onClick: async () => {
await getGitpodService().server.adminSetProfessionalOpenSource(
user.id,
!isProfessionalOpenSource,
);
setAccountStatement(
await getGitpodService().server.adminGetAccountStatement(user.id),
);
},
},
]
}
>
{accountStatement?.subscriptions
? accountStatement.subscriptions
.filter(
(s) => !s.deleted && Subscription.isActive(s, new Date().toISOString()),
)
.map((s) => Plans.getById(s.planId)?.name)
.join(", ")
: "---"}
</Property>
</div>
<div className="flex w-full mt-6">
<Property
name="Feature Flags"
actions={[
Expand All @@ -236,24 +284,8 @@ export default function UserDetail(p: { user: User }) {
>
{user.rolesOrPermissions?.join(", ") || "---"}
</Property>
<Property
name="Student"
actions={
!isStudent &&
emailDomain &&
!["gmail.com", "yahoo.com", "hotmail.com"].includes(emailDomain)
? [
{
label: `Make '${emailDomain}' a student domain`,
onClick: addStudentDomain,
},
]
: undefined
}
>
{isStudent === undefined ? "---" : isStudent ? "Enabled" : "Disabled"}
</Property>
</div>
{renderUserBillingProperties()}
</div>
</div>
<WorkspaceSearch user={user} />
Expand Down
2 changes: 2 additions & 0 deletions components/gitpod-protocol/src/admin-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { WorkspaceInstance, WorkspaceInstancePhase } from "./workspace-instance"
import { RoleOrPermission } from "./permission";
import { AccountStatement } from "./accounting-protocol";
import { InstallationAdminSettings } from "./installation-admin-protocol";
import { BillingMode } from "./billing-mode";

export interface AdminServer {
adminGetUsers(req: AdminGetListRequest<User>): Promise<AdminGetListResult<User>>;
Expand Down Expand Up @@ -49,6 +50,7 @@ export interface AdminServer {
adminIsStudent(userId: string): Promise<boolean>;
adminAddStudentEmailDomain(userId: string, domain: string): Promise<void>;
adminGrantExtraHours(userId: string, extraHours: number): Promise<void>;
adminGetBillingMode(attributionId: string): Promise<BillingMode>;

adminGetSettings(): Promise<InstallationAdminSettings>;
adminUpdateSettings(settings: InstallationAdminSettings): Promise<void>;
Expand Down
15 changes: 15 additions & 0 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2374,6 +2374,21 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
await this.subscriptionService.addCredit(userId, extraHours, new Date().toISOString());
}

async adminGetBillingMode(ctx: TraceContextWithSpan, attributionId: string): Promise<BillingMode> {
traceAPIParams(ctx, { attributionId });

const user = this.checkAndBlockUser("adminGetBillingMode");
if (!this.authorizationService.hasPermission(user, Permission.ADMIN_USERS)) {
throw new ResponseError(ErrorCodes.PERMISSION_DENIED, "not allowed");
}

const parsedAttributionId = AttributionId.parse(attributionId);
if (!parsedAttributionId) {
throw new ResponseError(ErrorCodes.BAD_REQUEST, "Unable to parse attributionId");
}
return this.billingModes.getBillingMode(parsedAttributionId, new Date());
}

// various
async sendFeedback(ctx: TraceContext, feedback: string): Promise<string | undefined> {
traceAPIParams(ctx, {}); // feedback is not interesting here, any may contain names
Expand Down
1 change: 1 addition & 0 deletions components/server/src/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const defaultFunctions: FunctionsConfig = {
adminGetBlockedRepositories: { group: "default", points: 1 },
adminCreateBlockedRepository: { group: "default", points: 1 },
adminDeleteBlockedRepository: { group: "default", points: 1 },
adminGetBillingMode: { group: "default", points: 1 },

validateLicense: { group: "default", points: 1 },
getLicenseInfo: { group: "default", points: 1 },
Expand Down
3 changes: 3 additions & 0 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3098,6 +3098,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
async adminGrantExtraHours(ctx: TraceContext, userId: string, extraHours: number): Promise<void> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async adminGetBillingMode(ctx: TraceContextWithSpan, attributionId: string): Promise<BillingMode> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async isStudent(ctx: TraceContext): Promise<boolean> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
Expand Down