Skip to content

Commit 7f43d48

Browse files
[server, dashboard] Introduce multi-org (behind feature flag) (#20431)
* [server config] Introduce isDedicatedInstallation, and use it to replace isSIngleOrgInstallation incl. further cleanup around getConfiguration and server config * [server, dashboard] Remove enableDedicatedOnboardingFlow feature flag and replace is with getInstallationConfiguration.IsDedicatedInstallation * [dashboard, server] Remove "sinlgeOrgMode" * [server] OrganizationService: block createTeam consistently for org-owned users * [server, dashboard] Introduce "enable_multi_org" feature flag to allow admin-user to create organizations * [dashboard] introduce "/?orgSlug=", which allows to pre-select an org in a "create workspace" URL (e.g. "/?orgSlug=org1#github.com/my/repo") * [db] Auto-delete container "test-mysql" if it's already present * fix tests * [dashboard] Check if localStorage is available before using it * [dashboard] SSOLogin: fix orgSlug source precedence to: path/search/localStorage * [server] Deny "joinOrganization" for org-owned users * Gpl/970-multi-org-tests (#20436) * fix tests for real * [server] Create OrgService.createOrgOwnedUser, and use that across tests to fix the "can't join org" permission issues * Update components/server/src/orgs/organization-service.ts Co-authored-by: Filip Troníček <filip@gitpod.io> --------- Co-authored-by: Filip Troníček <filip@gitpod.io> --------- Co-authored-by: Filip Troníček <filip@gitpod.io>
1 parent 5bb738a commit 7f43d48

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2742
-621
lines changed

components/dashboard/src/#.tsx

+1-4
Original file line numberDiff line numberDiff line change
@@ -311,10 +311,7 @@ const LoginContent = ({
311311
</LoginButton>
312312
))
313313
)}
314-
<SSOLoginForm
315-
onSuccess={authorizeSuccessful}
316-
singleOrgMode={!!authProviders.data && authProviders.data.length === 0}
317-
/>
314+
<SSOLoginForm onSuccess={authorizeSuccessful} />
318315
</div>
319316
{errorMessage && <ErrorMessage imgSrc={exclamation} message={errorMessage} />}
320317
</div>

components/dashboard/src/data/featureflag-query.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ const featureFlags = {
1515
// Default to true to enable on gitpod dedicated until ff support is added for dedicated
1616
orgGitAuthProviders: true,
1717
userGitAuthProviders: false,
18-
enableDedicatedOnboardingFlow: false,
1918
// Local SSH feature of VS Code Desktop Extension
2019
gitpod_desktop_use_local_ssh_proxy: false,
2120
enabledOrbitalDiscoveries: "",
2221
// dummy specified dataops feature, default false
2322
dataops: false,
23+
enable_multi_org: false,
2424
showBrowserExtensionPromotion: false,
2525
enable_experimental_jbtb: false,
2626
enabled_configuration_prebuild_full_clone: false,

components/dashboard/src/data/installation/default-workspace-image-query.ts

+11
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,14 @@ export const useInstallationDefaultWorkspaceImageQuery = () => {
1717
},
1818
});
1919
};
20+
21+
export const useInstallationConfiguration = () => {
22+
return useQuery({
23+
queryKey: ["installation-configuration"],
24+
staleTime: 1000 * 60 * 10, // 10 minute
25+
queryFn: async () => {
26+
const response = await installationClient.getInstallationConfiguration({});
27+
return response.configuration;
28+
},
29+
});
30+
};

components/dashboard/src/data/organizations/orgs-query.ts

+22-1
Original file line numberDiff line numberDiff line change
@@ -61,14 +61,31 @@ export function useCurrentOrg(): { data?: Organization; isLoading: boolean } {
6161
return { data: undefined, isLoading: true };
6262
}
6363
let orgId = localStorage.getItem("active-org");
64+
let org: Organization | undefined = undefined;
65+
if (orgId) {
66+
org = orgs.data.find((org) => org.id === orgId);
67+
}
68+
69+
// 1. Check for org slug
70+
const orgSlugParam = getOrgSlugFromQuery(location.search);
71+
if (orgSlugParam) {
72+
org = orgs.data.find((org) => org.slug === orgSlugParam);
73+
}
74+
75+
// 2. Check for org id
76+
// id is more speficic than slug, so it takes precedence
6477
const orgIdParam = new URLSearchParams(location.search).get("org");
6578
if (orgIdParam) {
6679
orgId = orgIdParam;
80+
org = orgs.data.find((org) => org.id === orgId);
6781
}
68-
let org = orgs.data.find((org) => org.id === orgId);
82+
83+
// 3. Fallback: pick the first org
6984
if (!org) {
7085
org = orgs.data[0];
7186
}
87+
88+
// Persist the selected org
7289
if (org) {
7390
localStorage.setItem("active-org", org.id);
7491
} else if (orgId && (orgs.isLoading || orgs.isStale)) {
@@ -79,3 +96,7 @@ export function useCurrentOrg(): { data?: Organization; isLoading: boolean } {
7996
}
8097
return { data: org, isLoading: false };
8198
}
99+
100+
export function getOrgSlugFromQuery(search: string): string | undefined {
101+
return new URLSearchParams(search).get("orgSlug") || undefined;
102+
}

components/dashboard/src/dedicated-setup/use-needs-setup.ts

+8-7
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,18 @@
55
*/
66

77
import { useQuery } from "@tanstack/react-query";
8-
import { useFeatureFlag } from "../data/featureflag-query";
98
import { noPersistence } from "../data/setup";
109
import { installationClient } from "../service/public-api";
1110
import { GetOnboardingStateRequest } from "@gitpod/public-api/lib/gitpod/v1/installation_pb";
11+
import { useInstallationConfiguration } from "../data/installation/default-workspace-image-query";
1212

1313
/**
1414
* @description Returns a flage stating if the current installation still needs setup before it can be used. Also returns an isLoading indicator as the check is async
1515
*/
1616
export const useNeedsSetup = () => {
1717
const { data: onboardingState, isLoading } = useOnboardingState();
18-
const enableDedicatedOnboardingFlow = useFeatureFlag("enableDedicatedOnboardingFlow");
18+
const { data: installationConfig } = useInstallationConfiguration();
19+
const isDedicatedInstallation = !!installationConfig?.isDedicatedInstallation;
1920

2021
// This needs to only be true if we've loaded the onboarding state
2122
let needsSetup = !isLoading && onboardingState && onboardingState.completed !== true;
@@ -25,14 +26,14 @@ export const useNeedsSetup = () => {
2526
}
2627

2728
return {
28-
needsSetup: enableDedicatedOnboardingFlow && needsSetup,
29+
needsSetup: isDedicatedInstallation && needsSetup,
2930
// disabled queries stay in `isLoading` state, so checking feature flag here too
30-
isLoading: enableDedicatedOnboardingFlow && isLoading,
31+
isLoading: isDedicatedInstallation && isLoading,
3132
};
3233
};
3334

34-
const useOnboardingState = () => {
35-
const enableDedicatedOnboardingFlow = useFeatureFlag("enableDedicatedOnboardingFlow");
35+
export const useOnboardingState = () => {
36+
const { data: installationConfig } = useInstallationConfiguration();
3637

3738
return useQuery(
3839
noPersistence(["onboarding-state"]),
@@ -42,7 +43,7 @@ const useOnboardingState = () => {
4243
},
4344
{
4445
// Only query if feature flag is enabled
45-
enabled: enableDedicatedOnboardingFlow,
46+
enabled: !!installationConfig?.isDedicatedInstallation,
4647
},
4748
);
4849
};

components/dashboard/src/dedicated-setup/use-show-dedicated-setup.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
*/
66

77
import { useQueryParams } from "../hooks/use-query-params";
8-
import { useFeatureFlag } from "../data/featureflag-query";
98
import { useCallback, useState } from "react";
109
import { isCurrentHostExcludedFromSetup, useNeedsSetup } from "./use-needs-setup";
10+
import { useInstallationConfiguration } from "../data/installation/default-workspace-image-query";
1111

1212
const FORCE_SETUP_PARAM = "dedicated-setup";
1313
const FORCE_SETUP_PARAM_VALUE = "force";
@@ -21,7 +21,8 @@ export const useShowDedicatedSetup = () => {
2121
// again in case onboarding state isn't updated right away
2222
const [inProgress, setInProgress] = useState(false);
2323

24-
const enableDedicatedOnboardingFlow = useFeatureFlag("enableDedicatedOnboardingFlow");
24+
const { data: installationConfig } = useInstallationConfiguration();
25+
const enableDedicatedOnboardingFlow = !!installationConfig?.isDedicatedInstallation;
2526
const params = useQueryParams();
2627

2728
const { needsSetup } = useNeedsSetup();

components/dashboard/src/#/SSOLoginForm.tsx

+24-5
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import { useOnBlurError } from "../hooks/use-onblur-error";
1212
import { openOIDCStartWindow } from "../provider-utils";
1313
import { useFeatureFlag } from "../data/featureflag-query";
1414
import { useLocation } from "react-router";
15+
import { useOnboardingState } from "../dedicated-setup/use-needs-setup";
16+
import { getOrgSlugFromQuery } from "../data/organizations/orgs-query";
17+
import { storageAvailable } from "../utils";
1518

1619
type Props = {
17-
singleOrgMode?: boolean;
1820
onSuccess: () => void;
1921
};
2022

@@ -27,11 +29,13 @@ function getOrgSlugFromPath(path: string) {
2729
return pathSegments[2];
2830
}
2931

30-
export const SSOLoginForm: FC<Props> = ({ singleOrgMode, onSuccess }) => {
32+
export const SSOLoginForm: FC<Props> = ({ onSuccess }) => {
3133
const location = useLocation();
34+
const { data: onboardingState } = useOnboardingState();
35+
const singleOrgMode = (onboardingState?.organizationCountTotal || 0) < 2;
3236

3337
const [orgSlug, setOrgSlug] = useState(
34-
getOrgSlugFromPath(location.pathname) || window.localStorage.getItem("sso-org-slug") || "",
38+
getOrgSlugFromPath(location.pathname) || getOrgSlugFromQuery(location.search) || readSSOOrgSlug() || "",
3539
);
3640
const [error, setError] = useState("");
3741

@@ -40,7 +44,7 @@ export const SSOLoginForm: FC<Props> = ({ singleOrgMode, onSuccess }) => {
4044
const openLoginWithSSO = useCallback(
4145
async (e: React.FormEvent<HTMLFormElement>) => {
4246
e.preventDefault();
43-
window.localStorage.setItem("sso-org-slug", orgSlug.trim());
47+
persistSSOOrgSlug(orgSlug.trim());
4448

4549
try {
4650
await openOIDCStartWindow({
@@ -78,7 +82,7 @@ export const SSOLoginForm: FC<Props> = ({ singleOrgMode, onSuccess }) => {
7882
<div className="mt-10 space-y-2 w-56">
7983
{!singleOrgMode && (
8084
<TextInputField
81-
label="Organization Slug"
85+
label="Organization"
8286
placeholder="my-organization"
8387
value={orgSlug}
8488
onChange={setOrgSlug}
@@ -99,3 +103,18 @@ export const SSOLoginForm: FC<Props> = ({ singleOrgMode, onSuccess }) => {
99103
</form>
100104
);
101105
};
106+
107+
function readSSOOrgSlug(): string | undefined {
108+
const isLocalStorageAvailable = storageAvailable("localStorage");
109+
if (isLocalStorageAvailable) {
110+
return window.localStorage.getItem("sso-org-slug") || undefined;
111+
}
112+
return undefined;
113+
}
114+
115+
function persistSSOOrgSlug(slug: string) {
116+
const isLocalStorageAvailable = storageAvailable("localStorage");
117+
if (isLocalStorageAvailable) {
118+
window.localStorage.setItem("sso-org-slug", slug.trim());
119+
}
120+
}

components/dashboard/src/menu/OrganizationSelector.tsx

+7-4
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import { useCurrentUser } from "../user-context";
1111
import { useCurrentOrg, useOrganizations } from "../data/organizations/orgs-query";
1212
import { useLocation } from "react-router";
1313
import { useOrgBillingMode } from "../data/billing-mode/org-billing-mode-query";
14-
import { useFeatureFlag } from "../data/featureflag-query";
1514
import { useIsOwner, useListOrganizationMembers, useHasRolePermission } from "../data/organizations/members-query";
16-
import { isOrganizationOwned } from "@gitpod/public-api-common/lib/user-utils";
15+
import { isAllowedToCreateOrganization } from "@gitpod/public-api-common/lib/user-utils";
1716
import { OrganizationRole } from "@gitpod/public-api/lib/gitpod/v1/organization_pb";
17+
import { useInstallationConfiguration } from "../data/installation/default-workspace-image-query";
18+
import { useFeatureFlag } from "../data/featureflag-query";
1819

1920
export default function OrganizationSelector() {
2021
const user = useCurrentUser();
@@ -25,10 +26,12 @@ export default function OrganizationSelector() {
2526
const hasMemberPermission = useHasRolePermission(OrganizationRole.MEMBER);
2627
const { data: billingMode } = useOrgBillingMode();
2728
const getOrgURL = useGetOrgURL();
28-
const isDedicated = useFeatureFlag("enableDedicatedOnboardingFlow");
29+
const { data: installationConfig } = useInstallationConfiguration();
30+
const isDedicated = !!installationConfig?.isDedicatedInstallation;
31+
const isMultiOrgEnabled = useFeatureFlag("enable_multi_org");
2932

3033
// we should have an API to ask for permissions, until then we duplicate the logic here
31-
const canCreateOrgs = user && !isOrganizationOwned(user) && !isDedicated;
34+
const canCreateOrgs = user && isAllowedToCreateOrganization(user, isDedicated, isMultiOrgEnabled);
3235

3336
const userFullName = user?.name || "...";
3437

components/dashboard/src/service/json-rpc-installation-client.ts

+12
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
GetInstallationWorkspaceDefaultImageResponse,
2323
GetOnboardingStateRequest,
2424
GetOnboardingStateResponse,
25+
GetInstallationConfigurationRequest,
26+
GetInstallationConfigurationResponse,
2527
} from "@gitpod/public-api/lib/gitpod/v1/installation_pb";
2628
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
2729
import { getGitpodService } from "./service";
@@ -130,4 +132,14 @@ export class JsonRpcInstallationClient implements PromiseClient<typeof Installat
130132
onboardingState: converter.toOnboardingState(info),
131133
});
132134
}
135+
136+
async getInstallationConfiguration(
137+
request: Partial<GetInstallationConfigurationRequest>,
138+
_options?: CallOptions | undefined,
139+
): Promise<GetInstallationConfigurationResponse> {
140+
const config = await getGitpodService().server.getConfiguration();
141+
return new GetInstallationConfigurationResponse({
142+
configuration: converter.toInstallationConfiguration(config),
143+
});
144+
}
133145
}

components/gitpod-db/BUILD.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ packages:
7777
# Note: In CI there is a DB running as sidecar; in workspaces we're starting it once.
7878
# Re-use of the instance because of the init scripts (cmp. next step).
7979
# (gpl): It would be nice to use bitnami/mysql here as we do in previews. However the container does not start in Gitpod workspaces due to some docker/kernel/namespace issue.
80-
- ["sh", "-c", "mysqladmin ping --wait=${DB_RETRIES:-1} -h $DB_HOST --port $DB_PORT -p$DB_PASSWORD -u$DB_USER --default-auth=mysql_native_password --silent || (docker run --name test-mysql -d -e MYSQL_ROOT_PASSWORD=$DB_PASSWORD -e MYSQL_TCP_PORT=$DB_PORT -p $DB_PORT:$DB_PORT mysql:8.0.33 --default-authentication-plugin=mysql_native_password; while ! mysqladmin ping -h \"$DB_HOST\" -P \"$DB_PORT\" -p$DB_PASSWORD -u$DB_USER --default-auth=mysql_native_password --silent; do echo \"waiting for DB...\"; sleep 2; done)"]
80+
- ["sh", "-c", "mysqladmin ping --wait=${DB_RETRIES:-1} -h $DB_HOST --port $DB_PORT -p$DB_PASSWORD -u$DB_USER --default-auth=mysql_native_password --silent || (docker container rm test-mysql; docker run --name test-mysql -d -e MYSQL_ROOT_PASSWORD=$DB_PASSWORD -e MYSQL_TCP_PORT=$DB_PORT -p $DB_PORT:$DB_PORT mysql:8.0.33 --default-authentication-plugin=mysql_native_password; while ! mysqladmin ping -h \"$DB_HOST\" -P \"$DB_PORT\" -p$DB_PASSWORD -u$DB_USER --default-auth=mysql_native_password --silent; do echo \"waiting for DB...\"; sleep 2; done)"]
8181
# Apply the DB initialization scripts (re-creates the "gitpod" DB if already there)
8282
- ["mkdir", "-p", "init-scripts"]
8383
- ["sh", "-c", "find . -name \"*.sql\" | sort | xargs -I file cp file init-scripts"]

components/gitpod-db/src/team-db.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export const TeamDB = Symbol("TeamDB");
1818
export interface TeamDB extends TransactionalDB<TeamDB> {
1919
findTeams(
2020
offset: number,
21-
limit: number,
21+
limit: number | undefined,
2222
orderBy: keyof Team,
2323
orderDir: "ASC" | "DESC",
2424
searchTerm?: string,

components/gitpod-db/src/typeorm/team-db-impl.ts

+6-2
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
5858

5959
public async findTeams(
6060
offset: number,
61-
limit: number,
61+
limit: number | undefined,
6262
orderBy: keyof Team,
6363
orderDir: "DESC" | "ASC",
6464
searchTerm?: string,
@@ -70,7 +70,11 @@ export class TeamDBImpl extends TransactionalDBImpl<TeamDB> implements TeamDB {
7070
searchTerm: `%${searchTerm}%`,
7171
});
7272
}
73-
queryBuilder = queryBuilder.andWhere("markedDeleted = 0").skip(offset).take(limit).orderBy(orderBy, orderDir);
73+
queryBuilder = queryBuilder.andWhere("markedDeleted = 0").skip(offset);
74+
if (limit) {
75+
queryBuilder = queryBuilder.take(limit);
76+
}
77+
queryBuilder = queryBuilder.orderBy(orderBy, orderDir);
7478

7579
const [rows, total] = await queryBuilder.getManyAndCount();
7680
return { total, rows };

components/gitpod-protocol/go/gitpod-service.go

+1-2
Original file line numberDiff line numberDiff line change
@@ -1886,8 +1886,7 @@ type VSCodeConfig struct {
18861886

18871887
// Configuration is the Configuration message type
18881888
type Configuration struct {
1889-
DaysBeforeGarbageCollection float64 `json:"daysBeforeGarbageCollection,omitempty"`
1890-
GarbageCollectionStartDate float64 `json:"garbageCollectionStartDate,omitempty"`
1889+
IsDedicatedInstallation bool `json:"isDedicatedInstallation,omitempty"`
18911890
}
18921891

18931892
// EnvVar is the EnvVar message type

components/gitpod-protocol/src/gitpod-service.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -489,11 +489,10 @@ export namespace GitpodServer {
489489
* Whether this Gitpod instance is already configured with SSO.
490490
*/
491491
readonly isCompleted: boolean;
492-
493492
/**
494-
* Whether this Gitpod instance has at least one org.
493+
* Total number of organizations.
495494
*/
496-
readonly hasAnyOrg: boolean;
495+
readonly organizationCountTotal: number;
497496
}
498497
}
499498

components/gitpod-protocol/src/protocol.ts

+1-3
Original file line numberDiff line numberDiff line change
@@ -1456,9 +1456,7 @@ export namespace AuthProviderEntry {
14561456
}
14571457

14581458
export interface Configuration {
1459-
readonly daysBeforeGarbageCollection: number;
1460-
readonly garbageCollectionStartDate: number;
1461-
readonly isSingleOrgInstallation: boolean;
1459+
readonly isDedicatedInstallation: boolean;
14621460
}
14631461

14641462
export interface StripeConfig {

components/public-api/gitpod/v1/installation.proto

+15
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ service InstallationService {
3131

3232
// GetOnboardingState returns the onboarding state of the installation.
3333
rpc GetOnboardingState(GetOnboardingStateRequest) returns (GetOnboardingStateResponse) {}
34+
35+
// GetInstallationConfiguration returns configuration of the installation.
36+
rpc GetInstallationConfiguration(GetInstallationConfigurationRequest) returns (GetInstallationConfigurationResponse) {}
3437
}
3538

3639
message GetOnboardingStateRequest {}
@@ -39,7 +42,11 @@ message GetOnboardingStateResponse {
3942
}
4043

4144
message OnboardingState {
45+
// Whether at least one organization has completed the onboarding
4246
bool completed = 1;
47+
48+
// The total number of organizations
49+
int32 organization_count_total = 2;
4350
}
4451

4552
message GetInstallationWorkspaceDefaultImageRequest {}
@@ -144,3 +151,11 @@ message BlockedEmailDomain {
144151

145152
bool negative = 3;
146153
}
154+
155+
message GetInstallationConfigurationRequest {}
156+
message GetInstallationConfigurationResponse {
157+
InstallationConfiguration configuration = 1;
158+
}
159+
message InstallationConfiguration {
160+
bool is_dedicated_installation = 1;
161+
}

0 commit comments

Comments
 (0)