From fb6c17564cf0f40c4697f1faf4f7e5e84af8c877 Mon Sep 17 00:00:00 2001 From: Nandaja Date: Thu, 31 Mar 2022 17:50:40 +0000 Subject: [PATCH 1/2] [dashboard] license tab in the admin dashboard --- components/dashboard/src/App.tsx | 9 +- components/dashboard/src/admin/License.tsx | 190 ++++++++++++++++++ components/dashboard/src/admin/admin-menu.ts | 4 + .../dashboard/src/components/InfoBox.tsx | 26 +++ components/dashboard/src/images/tick.svg | 3 + components/dashboard/src/index.tsx | 25 ++- components/dashboard/src/license-context.tsx | 22 ++ .../gitpod-protocol/src/license-protocol.ts | 7 + components/licensor/ee/pkg/licensor/gitpod.go | 4 +- .../licensor/ee/pkg/licensor/licensor.go | 38 ++++ .../licensor/ee/pkg/licensor/licensor_test.go | 6 +- .../licensor/ee/pkg/licensor/replicated.go | 44 ++-- components/licensor/typescript/ee/genapi.go | 20 ++ components/licensor/typescript/ee/main.go | 26 +++ components/licensor/typescript/ee/src/api.ts | 12 ++ .../licensor/typescript/ee/src/index.ts | 12 +- .../licensor/typescript/ee/src/module.cc | 45 +++++ .../typescript/ee/src/nativemodule.d.ts | 2 + components/server/ee/src/container-module.ts | 3 +- .../ee/src/workspace/gitpod-server-impl.ts | 3 +- components/server/src/auth/rate-limiter.ts | 1 + components/server/src/container-module.ts | 3 + .../src/workspace/gitpod-server-impl.ts | 42 ++++ 23 files changed, 510 insertions(+), 37 deletions(-) create mode 100644 components/dashboard/src/admin/License.tsx create mode 100644 components/dashboard/src/images/tick.svg create mode 100644 components/dashboard/src/license-context.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 6c9e0148cb45a9..3285d62b77d228 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -13,13 +13,14 @@ import { UserContext } from "./user-context"; import { TeamsContext } from "./teams/teams-context"; import { ThemeContext } from "./theme-context"; import { AdminContext } from "./admin-context"; +import { LicenseContext } from "./license-context"; import { getGitpodService } from "./service/service"; import { shouldSeeWhatsNew, WhatsNew } from "./whatsnew/WhatsNew"; import gitpodIcon from "./icons/gitpod.svg"; import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { useHistory } from "react-router-dom"; import { trackButtonOrAnchor, trackPathChange, trackLocation } from "./Analytics"; -import { User } from "@gitpod/gitpod-protocol"; +import { LicenseInfo, User } from "@gitpod/gitpod-protocol"; import * as GitpodCookie from "@gitpod/gitpod-protocol/lib/util/gitpod-cookie"; import { Experiment } from "./experiments"; import { workspacesPathMain } from "./workspaces/workspaces.routes"; @@ -77,6 +78,7 @@ const AdminSettings = React.lazy(() => import(/* webpackPrefetch: true */ "./adm const ProjectsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/ProjectsSearch")); const TeamsSearch = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/TeamsSearch")); const OAuthClientApproval = React.lazy(() => import(/* webpackPrefetch: true */ "./OauthClientApproval")); +const License = React.lazy(() => import(/* webpackPrefetch: true */ "./admin/License")); function Loading() { return <>; @@ -150,6 +152,7 @@ function App() { const { teams, setTeams } = useContext(TeamsContext); const { setAdminSettings } = useContext(AdminContext); const { setIsDark } = useContext(ThemeContext); + const { setLicense } = useContext(LicenseContext); const [loading, setLoading] = useState(true); const [isWhatsNewShown, setWhatsNewShown] = useState(false); @@ -186,6 +189,9 @@ function App() { if (user?.rolesOrPermissions?.includes("admin")) { const adminSettings = await getGitpodService().server.adminGetSettings(); setAdminSettings(adminSettings); + + var license: LicenseInfo = await getGitpodService().server.adminGetLicense(); + setLicense(license); } } catch (error) { console.error(error); @@ -367,6 +373,7 @@ function App() { + diff --git a/components/dashboard/src/admin/License.tsx b/components/dashboard/src/admin/License.tsx new file mode 100644 index 00000000000000..6a6c5b77a67128 --- /dev/null +++ b/components/dashboard/src/admin/License.tsx @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { PageWithSubMenu } from "../components/PageWithSubMenu"; +import { adminMenu } from "./admin-menu"; + +import { LicenseContext } from "../license-context"; +import { ReactElement, useContext } from "react"; +import { UserContext } from "../user-context"; +import { Redirect } from "react-router-dom"; +import { BlackBox, LightBox } from "../components/InfoBox"; + +import { ReactComponent as Bang } from "../images/exclamation.svg"; +import { ReactComponent as Tick } from "../images/tick.svg"; + +export default function License() { + // @ts-ignore + const { license, setLicense } = useContext(LicenseContext); + const { user } = useContext(UserContext); + + if (!user || !user?.rolesOrPermissions?.includes("admin")) { + return ; + } + + const featureList = license?.enabledFeatures; + const features = license?.features; + const licenseType = license?.type ? capitalizeInitials(license?.type) : ""; + + const userLimit = license?.seats == 0 ? "Unlimited" : license?.seats; + // const communityLicense = license?.key == "default-license" + + const [licenseLevel, paid, msg, tick] = getSubscriptionLevel( + license?.plan || "", + license?.userCount || 0, + license?.seats || 0, + false, + ); + + return ( +
+ + {!license?.valid ? ( +

+ You do not have a valid license associated with this account. {license?.errorMsg} +

+ ) : ( +
+
+ +

{licenseLevel}

+

{paid}

+

Available features:

+
+ {features && + features.map((feat: string) => ( +
+ {featureList?.includes(feat) ? ( + + + + ) : ( + + + + )} + {capitalizeInitials(feat)} +
+ ))} +
+
+
+
+ +
+
{msg}
+
{getLicenseValidIcon(tick)}
+
+

Registered Users

+

+ {license.userCount || 0} + + {" "} + / {userLimit}{" "} + +

+

License Type

+

{licenseType}

+
+
+
+ )} +
+
+ ); +} + +function capitalizeInitials(str: string): string { + return str + .split("-") + .map((item) => { + return item.charAt(0).toUpperCase() + item.slice(1); + }) + .join(" "); +} + +function getSubscriptionLevel(level: string, userCount: number, seats: number, fallbackAllowed: boolean): string[] { + switch (level) { + case "prod": { + return professionalPlan(userCount, seats); + } + case "community": { + return communityPlan(userCount, seats, fallbackAllowed); + } + case "trial": { + return ["Trial", "Free", "You have a trial license.", "grey-tick"]; + } + default: { + return ["Unknown", "Free", "No active licenses.", "red-cross"]; + } + } +} + +function professionalPlan(userCount: number, seats: number): string[] { + const aboveLimit: boolean = userCount >= seats; + let msg: string, tick: string; + if (aboveLimit) { + msg = "You have reached the usage limit."; + tick = "red-cross"; + } else { + msg = "You have an active professional license."; + tick = "green-tick"; + } + + return ["Professional", "Paid", msg, tick]; +} + +function communityPlan(userCount: number, seats: number, fallbackAllowed: boolean): string[] { + const aboveLimit: boolean = userCount >= seats; + + let msg: string = "You are using the free community edition"; + let tick: string = "grey-tick"; + if (aboveLimit) { + if (fallbackAllowed) { + msg = "No active license. You are using the community edition."; + } else { + msg = "No active license. You have reached your usage limit."; + tick = "red-cross"; + } + } + + return ["Community", "Free", msg, tick]; +} + +function getLicenseValidIcon(iconname: string): ReactElement { + switch (iconname) { + case "green-tick": + return ; + case "grey-tick": + return ; + case "red-cross": + return ; + default: + return ; + } +} diff --git a/components/dashboard/src/admin/admin-menu.ts b/components/dashboard/src/admin/admin-menu.ts index 91e5a88a93a78a..a62001a9fafb60 100644 --- a/components/dashboard/src/admin/admin-menu.ts +++ b/components/dashboard/src/admin/admin-menu.ts @@ -21,6 +21,10 @@ export const adminMenu = [ title: "Teams", link: ["/admin/teams"], }, + { + title: "License", + link: ["/admin/license"], + }, { title: "Settings", link: ["/admin/settings"], diff --git a/components/dashboard/src/components/InfoBox.tsx b/components/dashboard/src/components/InfoBox.tsx index 66a4d4093dba88..93cc17bcf9d92a 100644 --- a/components/dashboard/src/components/InfoBox.tsx +++ b/components/dashboard/src/components/InfoBox.tsx @@ -19,3 +19,29 @@ export default function InfoBox(p: { className?: string; children?: React.ReactN ); } + +export function BlackBox(p: { className?: string; children?: React.ReactNode }) { + return ( +
+ {p.children} +
+ ); +} + +export function LightBox(p: { className?: string; children?: React.ReactNode }) { + return ( +
+ {p.children} +
+ ); +} diff --git a/components/dashboard/src/images/tick.svg b/components/dashboard/src/images/tick.svg new file mode 100644 index 00000000000000..f2f05ec54aa09d --- /dev/null +++ b/components/dashboard/src/images/tick.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/components/dashboard/src/index.tsx b/components/dashboard/src/index.tsx index 13a095b62a7865..10cf8ea23ab8eb 100644 --- a/components/dashboard/src/index.tsx +++ b/components/dashboard/src/index.tsx @@ -10,6 +10,7 @@ import App from "./App"; import { UserContextProvider } from "./user-context"; import { AdminContextProvider } from "./admin-context"; import { PaymentContextProvider } from "./payment-context"; +import { LicenseContextProvider } from "./license-context"; import { TeamsContextProvider } from "./teams/teams-context"; import { ProjectContextProvider } from "./projects/project-context"; import { ThemeContextProvider } from "./theme-context"; @@ -23,17 +24,19 @@ ReactDOM.render( - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/components/dashboard/src/license-context.tsx b/components/dashboard/src/license-context.tsx new file mode 100644 index 00000000000000..789ef69b020089 --- /dev/null +++ b/components/dashboard/src/license-context.tsx @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import React, { createContext, useState } from "react"; +import { LicenseInfo } from "@gitpod/gitpod-protocol"; + +const LicenseContext = createContext<{ + license?: LicenseInfo; + setLicense: React.Dispatch; +}>({ + setLicense: () => null, +}); + +const LicenseContextProvider: React.FC = ({ children }) => { + const [license, setLicense] = useState(); + return {children}; +}; + +export { LicenseContext, LicenseContextProvider }; diff --git a/components/gitpod-protocol/src/license-protocol.ts b/components/gitpod-protocol/src/license-protocol.ts index ce0042e655a1c3..6447ac95225282 100644 --- a/components/gitpod-protocol/src/license-protocol.ts +++ b/components/gitpod-protocol/src/license-protocol.ts @@ -15,9 +15,15 @@ export type LicenseIssue = "seats-exhausted"; export interface LicenseInfo { key: string; seats: number; + userCount?: number; valid: boolean; validUntil: string; plan?: string; + features?: string[]; + enabledFeatures?: string[]; + type?: string; + errorMsg?: string; + fallbackAllowed: boolean; } export interface GetLicenseInfoResult { @@ -33,5 +39,6 @@ export enum LicenseFeature { export interface LicenseService { validateLicense(): Promise; getLicenseInfo(): Promise; + adminGetLicense(): Promise; licenseIncludesFeature(feature: LicenseFeature): Promise; } diff --git a/components/licensor/ee/pkg/licensor/gitpod.go b/components/licensor/ee/pkg/licensor/gitpod.go index 05592b787d5de8..c474b69c1849fa 100644 --- a/components/licensor/ee/pkg/licensor/gitpod.go +++ b/components/licensor/ee/pkg/licensor/gitpod.go @@ -21,6 +21,7 @@ func NewGitpodEvaluator(key []byte, domain string) (res *Evaluator) { return &Evaluator{ lic: defaultLicense, allowFallback: true, + plan: CommunityLicense, } } @@ -63,6 +64,7 @@ func NewGitpodEvaluator(key []byte, domain string) (res *Evaluator) { return &Evaluator{ lic: lic.LicensePayload, - allowFallback: false, // Gitpod licenses cannot fallback - assume these are always paid-for + allowFallback: false, // Gitpod licenses cannot fallback - assume these are always paid-for + plan: ProfessionalLicense, // This essentially means "paid" license } } diff --git a/components/licensor/ee/pkg/licensor/licensor.go b/components/licensor/ee/pkg/licensor/licensor.go index 0058fbd96f9e96..104f21d1f2d9ec 100644 --- a/components/licensor/ee/pkg/licensor/licensor.go +++ b/components/licensor/ee/pkg/licensor/licensor.go @@ -24,12 +24,30 @@ const ( LicenseTypeReplicated LicenseType = "replicated" ) +// LicenseSubscriptionLevel is initialized to have a standard license plan +// between replicated and gitpod licenses +type LicenseSubscriptionLevel string + +const ( + CommunityLicense LicenseSubscriptionLevel = "community" + ProfessionalLicense LicenseSubscriptionLevel = "prod" +) + +// LicenseData has type specific info about the license +type LicenseData struct { + Type LicenseType `json:"type"` + Payload LicensePayload `json:"payload"` + Plan LicenseSubscriptionLevel `json:"plan"` + FallbackAllowed bool `json:"fallbackAllowed"` +} + // LicensePayload is the actual license content type LicensePayload struct { ID string `json:"id"` Domain string `json:"domain"` Level LicenseLevel `json:"level"` ValidUntil time.Time `json:"validUntil"` + // Type LicenseType `json:"type"` // Seats == 0 means there's no seat limit Seats int `json:"seats"` @@ -119,6 +137,8 @@ var fallbackLicense = LicensePayload{ ID: "fallback-license", Level: LevelTeam, Seats: 0, + // we set default licese type as "Gitpod" + // Type: LicenseTypeGitpod, // Domain, ValidUntil are free for all } @@ -127,6 +147,8 @@ var defaultLicense = LicensePayload{ ID: "default-license", Level: LevelEnterprise, Seats: 10, + // we set default licese type as "Gitpod" + // Type: LicenseTypeGitpod, // Domain, ValidUntil are free for all } @@ -153,6 +175,7 @@ type Evaluator struct { invalid string allowFallback bool // Paid licenses cannot fallback and prevent additional signups lic LicensePayload + plan LicenseSubscriptionLevel // Specifies if it is a community/free plan or paid plan } // Validate returns false if the license isn't valid and a message explaining why that is. @@ -212,6 +235,21 @@ func (e *Evaluator) Inspect() LicensePayload { return e.lic } +func (e *Evaluator) LicenseData() LicenseData { + data := LicenseData{ + Type: LicenseType(e.GetLicenseType()), + Payload: e.Inspect(), + FallbackAllowed: e.allowFallback, + Plan: e.plan, + } + + return data +} + +func (e *Evaluator) GetLicenseType() string { + return os.Getenv("GITPOD_LICENSE_TYPE") +} + // Sign signs a license so that it can be used with the evaluator func Sign(l LicensePayload, priv *rsa.PrivateKey) (res []byte, err error) { rawl, err := json.Marshal(l) diff --git a/components/licensor/ee/pkg/licensor/licensor_test.go b/components/licensor/ee/pkg/licensor/licensor_test.go index 9e730c1d625e27..5f07df8ae3caa3 100644 --- a/components/licensor/ee/pkg/licensor/licensor_test.go +++ b/components/licensor/ee/pkg/licensor/licensor_test.go @@ -28,7 +28,7 @@ type licenseTest struct { Validate func(t *testing.T, eval *Evaluator) Type LicenseType NeverExpires bool - ReplicatedLicenseType *ReplicatedLicenseType + ReplicatedLicenseType *LicenseSubscriptionLevel } // roundTripFunc . @@ -77,7 +77,7 @@ func (test *licenseTest) Run(t *testing.T) { } payload, err := json.Marshal(replicatedLicensePayload{ - LicenseType: func() ReplicatedLicenseType { + LicenseType: func() LicenseSubscriptionLevel { if test.ReplicatedLicenseType == nil { return ReplicatedLicenseTypePaid } @@ -220,7 +220,7 @@ func TestFeatures(t *testing.T) { Features []Feature LicenseType LicenseType UserCount int - ReplicatedLicenseType *ReplicatedLicenseType + ReplicatedLicenseType *LicenseSubscriptionLevel }{ {"Gitpod (in seats): no license", true, LicenseLevel(0), []Feature{ FeatureAdminDashboard, diff --git a/components/licensor/ee/pkg/licensor/replicated.go b/components/licensor/ee/pkg/licensor/replicated.go index 3d54b7b4c1bf60..9d9d3bc5d482b2 100644 --- a/components/licensor/ee/pkg/licensor/replicated.go +++ b/components/licensor/ee/pkg/licensor/replicated.go @@ -23,30 +23,30 @@ type replicatedFields struct { Value interface{} `json:"value"` // This is of type "fieldType" } -type ReplicatedLicenseType string - // variable names are what Replicated calls them in the vendor portal const ( - ReplicatedLicenseTypeCommunity ReplicatedLicenseType = "community" - ReplicatedLicenseTypeDevelopment ReplicatedLicenseType = "dev" - ReplicatedLicenseTypePaid ReplicatedLicenseType = "prod" - ReplicatedLicenseTypeTrial ReplicatedLicenseType = "trial" + ReplicatedLicenseTypeCommunity LicenseSubscriptionLevel = "community" + ReplicatedLicenseTypeDevelopment LicenseSubscriptionLevel = "dev" + ReplicatedLicenseTypePaid LicenseSubscriptionLevel = "prod" + ReplicatedLicenseTypeTrial LicenseSubscriptionLevel = "trial" ) // replicatedLicensePayload exists to convert the JSON structure to a LicensePayload type replicatedLicensePayload struct { - LicenseID string `json:"license_id"` - InstallationID string `json:"installation_id"` - Assignee string `json:"assignee"` - ReleaseChannel string `json:"release_channel"` - LicenseType ReplicatedLicenseType `json:"license_type"` - ExpirationTime *time.Time `json:"expiration_time,omitempty"` // Not set if license never expires - Fields []replicatedFields `json:"fields"` + LicenseID string `json:"license_id"` + InstallationID string `json:"installation_id"` + Assignee string `json:"assignee"` + ReleaseChannel string `json:"release_channel"` + LicenseType LicenseSubscriptionLevel `json:"license_type"` + ExpirationTime *time.Time `json:"expiration_time,omitempty"` // Not set if license never expires + Fields []replicatedFields `json:"fields"` } type ReplicatedEvaluator struct { - invalid string - lic LicensePayload + invalid string + lic LicensePayload + plan LicenseSubscriptionLevel + allowFallback bool } func (e *ReplicatedEvaluator) Enabled(feature Feature) bool { @@ -66,6 +66,17 @@ func (e *ReplicatedEvaluator) HasEnoughSeats(seats int) bool { return e.lic.Seats == 0 || seats <= e.lic.Seats } +func (e *ReplicatedEvaluator) LicenseData() LicenseData { + data := LicenseData{ + Type: LicenseTypeReplicated, + Payload: e.Inspect(), + FallbackAllowed: e.allowFallback, + Plan: e.plan, + } + + return data +} + func (e *ReplicatedEvaluator) Inspect() LicensePayload { return e.lic } @@ -80,9 +91,11 @@ func (e *ReplicatedEvaluator) Validate() (msg string, valid bool) { // defaultReplicatedLicense this is the default license if call fails func defaultReplicatedLicense() *Evaluator { + return &Evaluator{ lic: defaultLicense, allowFallback: true, + plan: ReplicatedLicenseTypeCommunity, } } @@ -131,6 +144,7 @@ func newReplicatedEvaluator(client *http.Client, domain string) (res *Evaluator) return &Evaluator{ lic: lic, allowFallback: replicatedPayload.LicenseType == ReplicatedLicenseTypeCommunity, // Only community licenses are allowed to fallback + plan: replicatedPayload.LicenseType, } } diff --git a/components/licensor/typescript/ee/genapi.go b/components/licensor/typescript/ee/genapi.go index fd172463048201..af6d2b8576ca67 100644 --- a/components/licensor/typescript/ee/genapi.go +++ b/components/licensor/typescript/ee/genapi.go @@ -74,6 +74,26 @@ func main() { res = append(res, t) } + ts, err = bel.Extract(licensor.LicenseData{}, bel.WithEnumerations(handler)) + if err != nil { + panic(err) + } + for _, t := range ts { + if t.Name == "" { + continue + } + + if t.Name == "LicenseData" { + for i, m := range t.Members { + if m.Name == "payload" { + t.Members[i].Type.Name = "LicensePayload" + } + } + } + + res = append(res, t) + } + sort.Slice(res, func(i, j int) bool { return res[i].Name < res[j].Name }) f, err := os.Create("src/api.ts") diff --git a/components/licensor/typescript/ee/main.go b/components/licensor/typescript/ee/main.go index a5f81a117fc8ad..a39a8fdb71e5a4 100644 --- a/components/licensor/typescript/ee/main.go +++ b/components/licensor/typescript/ee/main.go @@ -35,6 +35,32 @@ func Init(key *C.char, domain *C.char) (id int) { return id } +// GetLicenseData returns the info about license for the admin dashboard +//export GetLicenseData +func GetLicenseData(id int) (licData *C.char, ok bool) { + e, ok := instances[id] + if !ok { + return + } + + b, err := json.Marshal(e.LicenseData()) + if err != nil { + log.WithError(err).Warn("GetLicenseData(): cannot retrieve license data") + return nil, false + } + + return C.CString(string(b)), true + +} + +// GetLicenseType returns the license type of the current Gitpod Installation +//export GetLicenseType +func GetLicenseType(id int) *C.char { + e, _ := instances[id] + licenseType := e.GetLicenseType() + return C.CString(licenseType) +} + // Validate returns false if the license isn't valid and a message explaining why that is. //export Validate func Validate(id int) (msg *C.char, valid bool) { diff --git a/components/licensor/typescript/ee/src/api.ts b/components/licensor/typescript/ee/src/api.ts index 00937b5c63823a..c7f1a9c634a76b 100644 --- a/components/licensor/typescript/ee/src/api.ts +++ b/components/licensor/typescript/ee/src/api.ts @@ -12,6 +12,13 @@ export enum Feature { FeatureSnapshot = "snapshot", FeatureWorkspaceSharing = "workspace-sharing", } +export interface LicenseData { + type: LicenseType + payload: LicensePayload + plan: string + fallbackAllowed: boolean +} + export enum LicenseLevel { LevelTeam = 0, LevelEnterprise = 1, @@ -23,3 +30,8 @@ export interface LicensePayload { validUntil: string seats: number } + +export enum LicenseType { + LicenseTypeGitpod = "gitpod", + LicenseTypeReplicated = "replicated", +} diff --git a/components/licensor/typescript/ee/src/index.ts b/components/licensor/typescript/ee/src/index.ts index d4b24bc083bb79..5b2d6c6540198d 100644 --- a/components/licensor/typescript/ee/src/index.ts +++ b/components/licensor/typescript/ee/src/index.ts @@ -5,8 +5,8 @@ */ import { injectable, inject, postConstruct } from 'inversify'; -import { init, Instance, dispose, isEnabled, hasEnoughSeats, inspect, validate } from "./nativemodule"; -import { Feature, LicensePayload } from './api'; +import { init, Instance, dispose, isEnabled, hasEnoughSeats, inspect, validate, getLicenseType, getLicenseData } from "./nativemodule"; +import { Feature, LicensePayload, LicenseData } from './api'; export const LicenseKeySource = Symbol("LicenseKeySource"); @@ -61,6 +61,14 @@ export class LicenseEvaluator { return JSON.parse(inspect(this.instanceID)); } + public getLicenseType(): string { + return getLicenseType(this.instanceID); + } + + public getLicenseData(): LicenseData { + return JSON.parse(getLicenseData(this.instanceID)); + } + public dispose() { dispose(this.instanceID); } diff --git a/components/licensor/typescript/ee/src/module.cc b/components/licensor/typescript/ee/src/module.cc index 3787bcfc5bbbde..8d46adf37afb12 100644 --- a/components/licensor/typescript/ee/src/module.cc +++ b/components/licensor/typescript/ee/src/module.cc @@ -176,6 +176,32 @@ void HasEnoughSeatsM(const FunctionCallbackInfo &args) { args.GetReturnValue().Set(Boolean::New(isolate, r.r0)); } +void GetLicenseDataM(const FunctionCallbackInfo &args) { + Isolate *isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + + if (args.Length() < 1) { + isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "wrong number of arguments").ToLocalChecked())); + return; + } + if (!args[0]->IsNumber() || args[0]->IsUndefined()) { + isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "argument 0 must be a number").ToLocalChecked())); + return; + } + + double rid = args[0]->NumberValue(context).FromMaybe(0); + int id = static_cast(rid); + + GetLicenseData_return r = GetLicenseData(id); + if (!r.r1) { + isolate->ThrowException(Exception::Error(String::NewFromUtf8(isolate, "invalid instance ID").ToLocalChecked())); + return; + } + + args.GetReturnValue().Set(String::NewFromUtf8(isolate, r.r0).ToLocalChecked()); +} + + void InspectM(const FunctionCallbackInfo &args) { Isolate *isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); @@ -220,6 +246,23 @@ void DisposeM(const FunctionCallbackInfo &args) { Dispose(id); } +void GetLicenseTypeM(const FunctionCallbackInfo &args) { + Isolate *isolate = args.GetIsolate(); + Local context = isolate->GetCurrentContext(); + + if (args.Length() < 1) { + isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "wrong number of arguments").ToLocalChecked())); + return; + } + + double rid = args[0]->NumberValue(context).FromMaybe(0); + int id = static_cast(rid); + + char* r = GetLicenseType(id); + + args.GetReturnValue().Set(String::NewFromUtf8(isolate, r).ToLocalChecked()); +} + // add method to exports void initModule(Local exports) { NODE_SET_METHOD(exports, "init", InitM); @@ -228,6 +271,8 @@ void initModule(Local exports) { NODE_SET_METHOD(exports, "hasEnoughSeats", HasEnoughSeatsM); NODE_SET_METHOD(exports, "inspect", InspectM); NODE_SET_METHOD(exports, "dispose", DisposeM); + NODE_SET_METHOD(exports, "getLicenseType", GetLicenseTypeM); + NODE_SET_METHOD(exports, "getLicenseData", GetLicenseDataM); } // create module diff --git a/components/licensor/typescript/ee/src/nativemodule.d.ts b/components/licensor/typescript/ee/src/nativemodule.d.ts index ae90944cbcbffe..b946ddbab1d019 100644 --- a/components/licensor/typescript/ee/src/nativemodule.d.ts +++ b/components/licensor/typescript/ee/src/nativemodule.d.ts @@ -13,3 +13,5 @@ export function isEnabled(id: Instance, feature: Feature, seats: int): boolean; export function hasEnoughSeats(id: Instance, seats: int): boolean; export function inspect(id: Instance): string; export function dispose(id: Instance); +export function getLicenseType(id: Instance): string; +export function getLicenseData(id: Instance): string; diff --git a/components/server/ee/src/container-module.ts b/components/server/ee/src/container-module.ts index 43c1a884c71ae3..23d8b144df7268 100644 --- a/components/server/ee/src/container-module.ts +++ b/components/server/ee/src/container-module.ts @@ -11,7 +11,7 @@ import { Server } from "../../src/server"; import { ServerEE } from "./server"; import { UserController } from "../../src/user/user-controller"; import { UserControllerEE } from "./user/user-controller"; -import { LicenseEvaluator, LicenseKeySource } from "@gitpod/licensor/lib"; +import { LicenseKeySource } from "@gitpod/licensor/lib"; import { DBLicenseKeySource } from "./license-source"; import { UserService } from "../../src/user/user-service"; import { UserServiceEE } from "./user/user-service"; @@ -82,7 +82,6 @@ export const productionEEContainerModule = new ContainerModule((bind, unbind, is bind(UserCounter).toSelf().inSingletonScope(); - bind(LicenseEvaluator).toSelf().inSingletonScope(); bind(LicenseKeySource).to(DBLicenseKeySource).inSingletonScope(); // GitpodServerImpl (stateful per user) diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 6d46644431073b..7d53fbe4dedc1e 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -59,7 +59,7 @@ import { import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error"; import { v4 as uuidv4 } from "uuid"; import { log, LogContext } from "@gitpod/gitpod-protocol/lib/util/logging"; -import { LicenseEvaluator, LicenseKeySource } from "@gitpod/licensor/lib"; +import { LicenseKeySource } from "@gitpod/licensor/lib"; import { Feature } from "@gitpod/licensor/lib/api"; import { LicenseValidationResult, LicenseFeature } from "@gitpod/gitpod-protocol/lib/license-protocol"; import { PrebuildManager } from "../prebuilds/prebuild-manager"; @@ -102,7 +102,6 @@ import { UserCounter } from "../user/user-counter"; @injectable() export class GitpodServerEEImpl extends GitpodServerImpl { - @inject(LicenseEvaluator) protected readonly licenseEvaluator: LicenseEvaluator; @inject(PrebuildManager) protected readonly prebuildManager: PrebuildManager; @inject(LicenseDB) protected readonly licenseDB: LicenseDB; @inject(LicenseKeySource) protected readonly licenseKeySource: LicenseKeySource; diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index a8f8d6da017d34..7e13d8e64a6e9b 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -146,6 +146,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { adminGetProjectsBySearchTerm: { group: "default", points: 1 }, adminGetProjectById: { group: "default", points: 1 }, adminFindPrebuilds: { group: "default", points: 1 }, + adminGetLicense: { group: "default", points: 1 }, adminSetLicense: { group: "default", points: 1 }, adminGetSettings: { group: "default", points: 1 }, adminUpdateSettings: { group: "default", points: 1 }, diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index e4ac066622fe52..dc6797e3f50eb9 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -97,6 +97,7 @@ import { LocalMessageBroker, LocalRabbitMQBackedMessageBroker } from "./messagin import { contentServiceBinder } from "@gitpod/content-service/lib/sugar"; import { ReferrerPrefixParser } from "./workspace/referrer-prefix-context-parser"; import { InstallationAdminTelemetryDataProvider } from "./installation-admin/telemetry-data-provider"; +import { LicenseEvaluator } from "@gitpod/licensor/lib"; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Config).toConstantValue(ConfigFile.fromFile()); @@ -213,6 +214,8 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(InstallationAdminTelemetryDataProvider).toSelf().inSingletonScope(); + bind(LicenseEvaluator).toSelf().inSingletonScope(); + // binds all content services contentServiceBinder((ctx) => { const config = ctx.container.get(Config); diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 17ca4dc765b7da..a440433913fc32 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -89,6 +89,7 @@ import { import { GetLicenseInfoResult, LicenseFeature, + LicenseInfo, LicenseValidationResult, } from "@gitpod/gitpod-protocol/lib/license-protocol"; import { GitpodFileParser } from "@gitpod/gitpod-protocol/lib/gitpod-file-parser"; @@ -157,6 +158,8 @@ import { ProjectEnvVar } from "@gitpod/gitpod-protocol/src/protocol"; import { InstallationAdminSettings, TelemetryData } from "@gitpod/gitpod-protocol"; import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred"; import { InstallationAdminTelemetryDataProvider } from "../installation-admin/telemetry-data-provider"; +import { LicenseEvaluator } from "@gitpod/licensor/lib"; +import { Feature } from "@gitpod/licensor/lib/api"; // shortcut export const traceWI = (ctx: TraceContext, wi: Omit) => TraceContext.setOWI(ctx, wi); // userId is already taken care of in WebsocketConnectionManager @@ -183,6 +186,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { @inject(InstallationAdminDB) protected readonly installationAdminDb: InstallationAdminDB; @inject(InstallationAdminTelemetryDataProvider) protected readonly telemetryDataProvider: InstallationAdminTelemetryDataProvider; + @inject(LicenseEvaluator) protected readonly licenseEvaluator: LicenseEvaluator; @inject(WorkspaceStarter) protected readonly workspaceStarter: WorkspaceStarter; @inject(WorkspaceManagerClientProvider) @@ -2600,6 +2604,44 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { throw new ResponseError(ErrorCodes.EE_FEATURE, `Licensing is implemented in Gitpod's Enterprise Edition`); } + async adminGetLicense(ctx: TraceContext): Promise { + const licenseData = this.licenseEvaluator.getLicenseData(); + const licensePayload = licenseData.payload; + const licenseValid = this.licenseEvaluator.validate(); + + const userCount = await this.userDB.getUserCount(true); + + const features = Object.keys(Feature); + const enabledFeatures = await this.licenseFeatures(ctx, features); + + return { + key: licensePayload.id, + seats: licensePayload.seats, + userCount: userCount, + plan: licenseData.plan, + fallbackAllowed: licenseData.fallbackAllowed, + valid: licenseValid.valid, + errorMsg: licenseValid.msg, + type: licenseData.type, + validUntil: licensePayload.validUntil, + features: features.map((feat) => Feature[feat as keyof typeof Feature]), + enabledFeatures: enabledFeatures, + }; + } + + async licenseFeatures(ctx: TraceContext, features: string[]): Promise { + var enabledFeatures: string[] = []; + const userCount = await this.userDB.getUserCount(true); + for (const feature of features) { + const featureName: Feature = Feature[feature as keyof typeof Feature]; + if (this.licenseEvaluator.isEnabled(featureName, userCount)) { + enabledFeatures.push(featureName); + } + } + + return enabledFeatures; + } + async licenseIncludesFeature(ctx: TraceContext, feature: LicenseFeature): Promise { return false; } From 140be3e25519715aec175ee389dbd226ab89f79e Mon Sep 17 00:00:00 2001 From: Nandaja Date: Thu, 14 Apr 2022 15:13:14 +0000 Subject: [PATCH 2/2] remove old method --- components/dashboard/src/admin/License.tsx | 175 ++++++++---------- .../dashboard/src/components/InfoBox.tsx | 26 --- components/dashboard/src/images/check.svg | 3 + .../licensor/ee/pkg/licensor/licensor.go | 4 - components/licensor/typescript/ee/main.go | 8 - .../licensor/typescript/ee/src/index.ts | 6 +- .../licensor/typescript/ee/src/module.cc | 19 -- .../typescript/ee/src/nativemodule.d.ts | 1 - .../src/workspace/gitpod-server-impl.ts | 5 +- 9 files changed, 87 insertions(+), 160 deletions(-) create mode 100644 components/dashboard/src/images/check.svg diff --git a/components/dashboard/src/admin/License.tsx b/components/dashboard/src/admin/License.tsx index 6a6c5b77a67128..87c67daf3a5a8a 100644 --- a/components/dashboard/src/admin/License.tsx +++ b/components/dashboard/src/admin/License.tsx @@ -8,112 +8,86 @@ import { PageWithSubMenu } from "../components/PageWithSubMenu"; import { adminMenu } from "./admin-menu"; import { LicenseContext } from "../license-context"; -import { ReactElement, useContext } from "react"; -import { UserContext } from "../user-context"; -import { Redirect } from "react-router-dom"; -import { BlackBox, LightBox } from "../components/InfoBox"; +import { ReactElement, useContext, useEffect } from "react"; +import { getGitpodService } from "../service/service"; -import { ReactComponent as Bang } from "../images/exclamation.svg"; -import { ReactComponent as Tick } from "../images/tick.svg"; +import { ReactComponent as Alert } from "../images/exclamation.svg"; +import { ReactComponent as Success } from "../images/tick.svg"; +import { ReactComponent as Tick } from "../images/check.svg"; +import { ReactComponent as Cross } from "../images/x.svg"; export default function License() { - // @ts-ignore const { license, setLicense } = useContext(LicenseContext); - const { user } = useContext(UserContext); - if (!user || !user?.rolesOrPermissions?.includes("admin")) { - return ; - } + useEffect(() => { + if (isGitpodIo()) { + return; // temporarily disable to avoid hight CPU on the DB + } + (async () => { + const data = await getGitpodService().server.adminGetLicense(); + setLicense(data); + })(); + }, []); const featureList = license?.enabledFeatures; const features = license?.features; - const licenseType = license?.type ? capitalizeInitials(license?.type) : ""; + // if user seats is 0, it means that there is user limit in the license const userLimit = license?.seats == 0 ? "Unlimited" : license?.seats; - // const communityLicense = license?.key == "default-license" const [licenseLevel, paid, msg, tick] = getSubscriptionLevel( license?.plan || "", license?.userCount || 0, license?.seats || 0, - false, + license?.fallbackAllowed || false, ); return (
- - {!license?.valid ? ( -

- You do not have a valid license associated with this account. {license?.errorMsg} -

- ) : ( -
-
- -

{licenseLevel}

-

{paid}

-

Available features:

-
- {features && - features.map((feat: string) => ( -
- {featureList?.includes(feat) ? ( - - - - ) : ( - - - - )} - {capitalizeInitials(feat)} -
- ))} -
-
-
-
- -
-
{msg}
-
{getLicenseValidIcon(tick)}
+ +
+ +

{licenseLevel}

+

{paid}

+

Available features:

+ {features && + features.map((feat: string) => ( +
+ {featureList?.includes(feat) ? ( + + ) : ( + + )} + {capitalizeInitials(feat)}
-

Registered Users

-

- {license.userCount || 0} - - {" "} - / {userLimit}{" "} - -

-

License Type

-

{licenseType}

- + ))} +
+ +
+
{msg}
+
{getLicenseValidIcon(tick)}
-
- )} +

Registered Users

+ {license?.userCount || 0} + / {userLimit} +

License Type

+

{capitalizeInitials(license?.type || "")}

+ + +
); @@ -146,10 +120,10 @@ function getSubscriptionLevel(level: string, userCount: number, seats: number, f } function professionalPlan(userCount: number, seats: number): string[] { - const aboveLimit: boolean = userCount >= seats; + const aboveLimit: boolean = userCount > seats; let msg: string, tick: string; if (aboveLimit) { - msg = "You have reached the usage limit."; + msg = "You have exceeded the usage limit."; tick = "red-cross"; } else { msg = "You have an active professional license."; @@ -160,15 +134,16 @@ function professionalPlan(userCount: number, seats: number): string[] { } function communityPlan(userCount: number, seats: number, fallbackAllowed: boolean): string[] { - const aboveLimit: boolean = userCount >= seats; + const aboveLimit: boolean = userCount > seats; let msg: string = "You are using the free community edition"; - let tick: string = "grey-tick"; + let tick: string = "green-tick"; if (aboveLimit) { if (fallbackAllowed) { msg = "No active license. You are using the community edition."; + tick = "grey-tick"; } else { - msg = "No active license. You have reached your usage limit."; + msg = "No active license. You have exceeded the usage limit."; tick = "red-cross"; } } @@ -179,12 +154,24 @@ function communityPlan(userCount: number, seats: number, fallbackAllowed: boolea function getLicenseValidIcon(iconname: string): ReactElement { switch (iconname) { case "green-tick": - return ; + return ; case "grey-tick": - return ; + return ; case "red-cross": - return ; + return ; default: - return ; + return ; } } + +function isGitpodIo() { + return window.location.hostname === "gitpod.io" || window.location.hostname === "gitpod-staging.com"; +} + +function Card(p: { className?: string; children?: React.ReactNode }) { + return ( +
+ {p.children} +
+ ); +} diff --git a/components/dashboard/src/components/InfoBox.tsx b/components/dashboard/src/components/InfoBox.tsx index 93cc17bcf9d92a..66a4d4093dba88 100644 --- a/components/dashboard/src/components/InfoBox.tsx +++ b/components/dashboard/src/components/InfoBox.tsx @@ -19,29 +19,3 @@ export default function InfoBox(p: { className?: string; children?: React.ReactN
); } - -export function BlackBox(p: { className?: string; children?: React.ReactNode }) { - return ( -
- {p.children} -
- ); -} - -export function LightBox(p: { className?: string; children?: React.ReactNode }) { - return ( -
- {p.children} -
- ); -} diff --git a/components/dashboard/src/images/check.svg b/components/dashboard/src/images/check.svg new file mode 100644 index 00000000000000..5e150e9c299e21 --- /dev/null +++ b/components/dashboard/src/images/check.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/components/licensor/ee/pkg/licensor/licensor.go b/components/licensor/ee/pkg/licensor/licensor.go index 104f21d1f2d9ec..562f2279936c1b 100644 --- a/components/licensor/ee/pkg/licensor/licensor.go +++ b/components/licensor/ee/pkg/licensor/licensor.go @@ -137,8 +137,6 @@ var fallbackLicense = LicensePayload{ ID: "fallback-license", Level: LevelTeam, Seats: 0, - // we set default licese type as "Gitpod" - // Type: LicenseTypeGitpod, // Domain, ValidUntil are free for all } @@ -147,8 +145,6 @@ var defaultLicense = LicensePayload{ ID: "default-license", Level: LevelEnterprise, Seats: 10, - // we set default licese type as "Gitpod" - // Type: LicenseTypeGitpod, // Domain, ValidUntil are free for all } diff --git a/components/licensor/typescript/ee/main.go b/components/licensor/typescript/ee/main.go index a39a8fdb71e5a4..cc000085d24b18 100644 --- a/components/licensor/typescript/ee/main.go +++ b/components/licensor/typescript/ee/main.go @@ -53,14 +53,6 @@ func GetLicenseData(id int) (licData *C.char, ok bool) { } -// GetLicenseType returns the license type of the current Gitpod Installation -//export GetLicenseType -func GetLicenseType(id int) *C.char { - e, _ := instances[id] - licenseType := e.GetLicenseType() - return C.CString(licenseType) -} - // Validate returns false if the license isn't valid and a message explaining why that is. //export Validate func Validate(id int) (msg *C.char, valid bool) { diff --git a/components/licensor/typescript/ee/src/index.ts b/components/licensor/typescript/ee/src/index.ts index 5b2d6c6540198d..b9495ff9d714fa 100644 --- a/components/licensor/typescript/ee/src/index.ts +++ b/components/licensor/typescript/ee/src/index.ts @@ -5,7 +5,7 @@ */ import { injectable, inject, postConstruct } from 'inversify'; -import { init, Instance, dispose, isEnabled, hasEnoughSeats, inspect, validate, getLicenseType, getLicenseData } from "./nativemodule"; +import { init, Instance, dispose, isEnabled, hasEnoughSeats, inspect, validate, getLicenseData } from "./nativemodule"; import { Feature, LicensePayload, LicenseData } from './api'; export const LicenseKeySource = Symbol("LicenseKeySource"); @@ -61,10 +61,6 @@ export class LicenseEvaluator { return JSON.parse(inspect(this.instanceID)); } - public getLicenseType(): string { - return getLicenseType(this.instanceID); - } - public getLicenseData(): LicenseData { return JSON.parse(getLicenseData(this.instanceID)); } diff --git a/components/licensor/typescript/ee/src/module.cc b/components/licensor/typescript/ee/src/module.cc index 8d46adf37afb12..3595c147cc5736 100644 --- a/components/licensor/typescript/ee/src/module.cc +++ b/components/licensor/typescript/ee/src/module.cc @@ -201,7 +201,6 @@ void GetLicenseDataM(const FunctionCallbackInfo &args) { args.GetReturnValue().Set(String::NewFromUtf8(isolate, r.r0).ToLocalChecked()); } - void InspectM(const FunctionCallbackInfo &args) { Isolate *isolate = args.GetIsolate(); Local context = isolate->GetCurrentContext(); @@ -246,23 +245,6 @@ void DisposeM(const FunctionCallbackInfo &args) { Dispose(id); } -void GetLicenseTypeM(const FunctionCallbackInfo &args) { - Isolate *isolate = args.GetIsolate(); - Local context = isolate->GetCurrentContext(); - - if (args.Length() < 1) { - isolate->ThrowException(Exception::TypeError(String::NewFromUtf8(isolate, "wrong number of arguments").ToLocalChecked())); - return; - } - - double rid = args[0]->NumberValue(context).FromMaybe(0); - int id = static_cast(rid); - - char* r = GetLicenseType(id); - - args.GetReturnValue().Set(String::NewFromUtf8(isolate, r).ToLocalChecked()); -} - // add method to exports void initModule(Local exports) { NODE_SET_METHOD(exports, "init", InitM); @@ -271,7 +253,6 @@ void initModule(Local exports) { NODE_SET_METHOD(exports, "hasEnoughSeats", HasEnoughSeatsM); NODE_SET_METHOD(exports, "inspect", InspectM); NODE_SET_METHOD(exports, "dispose", DisposeM); - NODE_SET_METHOD(exports, "getLicenseType", GetLicenseTypeM); NODE_SET_METHOD(exports, "getLicenseData", GetLicenseDataM); } diff --git a/components/licensor/typescript/ee/src/nativemodule.d.ts b/components/licensor/typescript/ee/src/nativemodule.d.ts index b946ddbab1d019..d74dae36e86c6f 100644 --- a/components/licensor/typescript/ee/src/nativemodule.d.ts +++ b/components/licensor/typescript/ee/src/nativemodule.d.ts @@ -13,5 +13,4 @@ export function isEnabled(id: Instance, feature: Feature, seats: int): boolean; export function hasEnoughSeats(id: Instance, seats: int): boolean; export function inspect(id: Instance): string; export function dispose(id: Instance); -export function getLicenseType(id: Instance): string; export function getLicenseData(id: Instance): string; diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index a440433913fc32..ea7ae83174885a 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -2612,7 +2612,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { const userCount = await this.userDB.getUserCount(true); const features = Object.keys(Feature); - const enabledFeatures = await this.licenseFeatures(ctx, features); + const enabledFeatures = await this.licenseFeatures(ctx, features, userCount); return { key: licensePayload.id, @@ -2629,9 +2629,8 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { }; } - async licenseFeatures(ctx: TraceContext, features: string[]): Promise { + async licenseFeatures(ctx: TraceContext, features: string[], userCount: number): Promise { var enabledFeatures: string[] = []; - const userCount = await this.userDB.getUserCount(true); for (const feature of features) { const featureName: Feature = Feature[feature as keyof typeof Feature]; if (this.licenseEvaluator.isEnabled(featureName, userCount)) {