From ee82d153e3ce76e13c3987d1567ad6c1cc53df27 Mon Sep 17 00:00:00 2001 From: Alex Tugarev Date: Fri, 21 Apr 2023 15:08:42 +0200 Subject: [PATCH] =?UTF-8?q?Enable=20Dedicated=20Onboarding=20Flow=20?= =?UTF-8?q?=E2=80=93=20WEB-193=20(#17303)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * uncommitted yarn.lock * [protocol] adding ONBOARDING_IN_PROGRESS * [gitpod-db] add `someOrgWithSSO` to team db * [server] add ONBOARDING_IN_PROGRESS hook * [dashboard] catch ONBOARDING_IN_PROGRESS error * [configcat] adding `custom.gitpod_host` attribute to select preview envs --- .../dashboard/src/DedicatedOnboarding.tsx | 54 +++++++++++++++++++ .../error-boundaries/QueryErrorBoundary.tsx | 10 ++++ components/gitpod-db/src/team-db.spec.db.ts | 28 ++++++++++ components/gitpod-db/src/team-db.ts | 2 + .../gitpod-db/src/typeorm/team-db-impl.ts | 8 +++ components/gitpod-protocol/go/error.go | 3 ++ .../src/experiments/configcat.ts | 4 ++ .../gitpod-protocol/src/experiments/types.ts | 4 ++ .../gitpod-protocol/src/messaging/error.ts | 3 ++ .../src/workspace/gitpod-server-impl.ts | 31 ++++++++++- 10 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 components/dashboard/src/DedicatedOnboarding.tsx diff --git a/components/dashboard/src/DedicatedOnboarding.tsx b/components/dashboard/src/DedicatedOnboarding.tsx new file mode 100644 index 0000000000..b18f189917 --- /dev/null +++ b/components/dashboard/src/DedicatedOnboarding.tsx @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2023 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 { useCallback, useState } from "react"; +import { Button } from "./components/Button"; +import Modal, { ModalBody, ModalFooter, ModalHeader } from "./components/Modal"; +import { OIDCClientConfigModal } from "./teams/sso/OIDCClientConfigModal"; + +type Props = { + onComplete?: () => void; +}; +export default function DedicatedOnboarding({ onComplete }: Props) { + const [showModal] = useState(false); + + const acceptAndContinue = useCallback(() => { + // // setShowModal(true); + }, []); + + // const onAuthorize = useCallback( + // (payload?: string) => { + // onComplete && onComplete(); + + // // run without await, so the integrated closing of new tab isn't blocked + // (async () => { + // window.location.href = gitpodHostUrl.asDashboard().toString(); + // })(); + // }, + // [onComplete], + // ); + + return ( +
+ {!showModal && ( + {}} closeable={false}> + Welcome to Gitpod Dedicated 🚀 + +

+ Before this instance can be used, you need to set up Single Sign-on. +

+
+ + + +
+ )} + {showModal && {}} />} +
+ ); +} diff --git a/components/dashboard/src/components/error-boundaries/QueryErrorBoundary.tsx b/components/dashboard/src/components/error-boundaries/QueryErrorBoundary.tsx index 2d9617d95f..2276d192ce 100644 --- a/components/dashboard/src/components/error-boundaries/QueryErrorBoundary.tsx +++ b/components/dashboard/src/components/error-boundaries/QueryErrorBoundary.tsx @@ -15,6 +15,7 @@ import { isGitpodIo } from "../../utils"; import { CaughtError } from "./ReloadPageErrorBoundary"; const Setup = lazy(() => import(/* webpackPrefetch: true */ "../../Setup")); +const DedicatedOnboarding = lazy(() => import(/* webpackPrefetch: true */ "../../DedicatedOnboarding")); // Error boundary intended to catch and handle expected errors from api calls export const QueryErrorBoundary: FC = ({ children }) => { @@ -50,6 +51,15 @@ const ExpectedQueryErrorsFallback: FC = ({ error, resetErrorBound return ; } + // Setup needed before proceeding + if (caughtError.code === ErrorCodes.ONBOARDING_IN_PROGRESS) { + return ( + }> + + + ); + } + // Setup needed before proceeding if (caughtError.code === ErrorCodes.SETUP_REQUIRED) { return ( diff --git a/components/gitpod-db/src/team-db.spec.db.ts b/components/gitpod-db/src/team-db.spec.db.ts index 5a5af331dd..9264252495 100644 --- a/components/gitpod-db/src/team-db.spec.db.ts +++ b/components/gitpod-db/src/team-db.spec.db.ts @@ -7,6 +7,7 @@ import * as chai from "chai"; const expect = chai.expect; import { suite, test, timeout } from "mocha-typescript"; +import { v4 as uuidv4 } from "uuid"; import { testContainer } from "./test-container"; import { TeamDBImpl } from "./typeorm/team-db-impl"; @@ -16,6 +17,7 @@ import { DBTeam } from "./typeorm/entity/db-team"; import { DBTeamMembership } from "./typeorm/entity/db-team-membership"; import { DBUser } from "./typeorm/entity/db-user"; import { DBIdentity } from "./typeorm/entity/db-identity"; +import { Connection } from "typeorm"; @suite class TeamDBSpec { @@ -37,6 +39,7 @@ class TeamDBSpec { await manager.getRepository(DBTeamMembership).delete({}); await manager.getRepository(DBUser).delete({}); await manager.getRepository(DBIdentity).delete({}); + await manager.query("delete from d_b_oidc_client_config;"); } @test(timeout(10000)) @@ -151,6 +154,31 @@ class TeamDBSpec { const result = await this.db.findTeams(0, 10, "creationTime", "DESC", searchTerm); expect(result.rows.length).to.eq(1); } + + @test(timeout(10000)) + public async test_someOrgWithSSOExists() { + expect(await this.db.someOrgWithSSOExists(), "case 1: empty db").to.be.false; + + const user = await this.userDb.newUser(); + const org = await this.db.createTeam(user.id, "Some Org"); + expect(await this.db.someOrgWithSSOExists(), "case 2: org without sso").to.be.false; + + await this.exec(async (c) => { + await c.query("INSERT INTO d_b_oidc_client_config (id, issuer, organizationId, data) VALUES (?,?,?,?)", [ + uuidv4(), + "https://issuer.local", + org.id, + "{}", + ]); + }); + expect(await this.db.someOrgWithSSOExists(), "case 3: org with sso").to.be.true; + } + + protected async exec(queryFn: (connection: Connection) => Promise) { + const typeorm = testContainer.get(TypeORM); + const connection = await typeorm.getConnection(); + await queryFn(connection); + } } module.exports = new TeamDBSpec(); diff --git a/components/gitpod-db/src/team-db.ts b/components/gitpod-db/src/team-db.ts index df03dc01f7..d4b5d9b173 100644 --- a/components/gitpod-db/src/team-db.ts +++ b/components/gitpod-db/src/team-db.ts @@ -40,4 +40,6 @@ export interface TeamDB { findOrgSettings(teamId: string): Promise; setOrgSettings(teamId: string, settings: Partial): Promise; + + someOrgWithSSOExists(): Promise; } diff --git a/components/gitpod-db/src/typeorm/team-db-impl.ts b/components/gitpod-db/src/typeorm/team-db-impl.ts index e30ab48656..b693c4ac10 100644 --- a/components/gitpod-db/src/typeorm/team-db-impl.ts +++ b/components/gitpod-db/src/typeorm/team-db-impl.ts @@ -365,4 +365,12 @@ export class TeamDBImpl implements TeamDB { repo.save(team); } } + + public async someOrgWithSSOExists(): Promise { + const repo = await this.getOrgSettingsRepo(); + const result = await repo.query( + `select org.id from d_b_team as org inner join d_b_oidc_client_config as oidc on org.id = oidc.organizationId limit 1;`, + ); + return result.length === 1; + } } diff --git a/components/gitpod-protocol/go/error.go b/components/gitpod-protocol/go/error.go index a591bb8af7..0f1ef0254e 100644 --- a/components/gitpod-protocol/go/error.go +++ b/components/gitpod-protocol/go/error.go @@ -27,6 +27,9 @@ const ( // 411 No User NEEDS_VERIFICATION = 411 + // 412 Dedicated Cell is in "onboarding" mode + ONBOARDING_IN_PROGRESS = 412 + // 429 Too Many Requests TOO_MANY_REQUESTS = 429 diff --git a/components/gitpod-protocol/src/experiments/configcat.ts b/components/gitpod-protocol/src/experiments/configcat.ts index 9ac617ff87..321ffbcec4 100644 --- a/components/gitpod-protocol/src/experiments/configcat.ts +++ b/components/gitpod-protocol/src/experiments/configcat.ts @@ -14,6 +14,7 @@ export const PROJECT_ID_ATTRIBUTE = "project_id"; export const TEAM_ID_ATTRIBUTE = "team_id"; export const TEAM_NAME_ATTRIBUTE = "team_name"; export const BILLING_TIER_ATTRIBUTE = "billing_tier"; +export const GITPOD_HOST = "gitpod_host"; export class ConfigCatClient implements Client { private client: IConfigCatClient; @@ -51,6 +52,9 @@ export function attributesToUser(attributes: Attributes): ConfigCatUser { if (attributes.billingTier) { custom[BILLING_TIER_ATTRIBUTE] = attributes.billingTier; } + if (attributes.gitpodHost) { + custom[GITPOD_HOST] = attributes.gitpodHost; + } return new ConfigCatUser(userId, email, "", custom); } diff --git a/components/gitpod-protocol/src/experiments/types.ts b/components/gitpod-protocol/src/experiments/types.ts index 57864dd579..d804c99acd 100644 --- a/components/gitpod-protocol/src/experiments/types.ts +++ b/components/gitpod-protocol/src/experiments/types.ts @@ -22,8 +22,12 @@ export interface Attributes { // Currently selected Gitpod Team ID (mapped to "custom.team_id") teamId?: string; + // Currently selected Gitpod Team Name (mapped to "custom.team_name") teamName?: string; + + // Host name of the Gitpod installation (mapped to "custom.gitpod_host") + gitpodHost?: string; } export interface Client { diff --git a/components/gitpod-protocol/src/messaging/error.ts b/components/gitpod-protocol/src/messaging/error.ts index 37158ef972..a8846298e2 100644 --- a/components/gitpod-protocol/src/messaging/error.ts +++ b/components/gitpod-protocol/src/messaging/error.ts @@ -26,6 +26,9 @@ export namespace ErrorCodes { // 411 No User export const NEEDS_VERIFICATION = 411; + // 412 Dedicated Cell is in "onboarding" mode + export const ONBOARDING_IN_PROGRESS = 412; + // 429 Too Many Requests export const TOO_MANY_REQUESTS = 429; diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 122c38ec68..30c2a0d355 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -357,9 +357,13 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } protected checkUser(methodName?: string, logPayload?: {}, ctx?: LogContext): User { - if (this.showSetupCondition?.value) { + // Old self-hosted mode did not require a session, therefore it's checked first. + // But we need to make sure, it's not triggered if the new Dedicated Onboarding is active. + if (this.showSetupCondition?.value && !this.showOnboardingFlowCondition?.value) { throw new ResponseError(ErrorCodes.SETUP_REQUIRED, "Setup required."); } + + // Generally, a user session is required. if (!this.user) { throw new ResponseError(ErrorCodes.NOT_AUTHENTICATED, "User is not authenticated. Please login."); } @@ -377,6 +381,12 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } log.debug(userContext, methodName, payload); } + + // A session is required to continue with an activated onboarding flow. + if (this.showOnboardingFlowCondition?.value) { + throw new ResponseError(ErrorCodes.ONBOARDING_IN_PROGRESS, "Dedicated Onboarding Flow in progress."); + } + return this.user; } @@ -403,7 +413,26 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { } protected showSetupCondition: { value: boolean } | undefined = undefined; + protected showOnboardingFlowCondition: { value: boolean } | undefined = undefined; protected async doUpdateUser(): Promise { + // Conditionally enable Dedicated Onboarding Flow + const enableDedicatedOnboardingFlow = await this.configCatClientFactory().getValueAsync( + "enableDedicatedOnboardingFlow", + false, + { + gitpodHost: new URL(this.config.hostUrl.toString()).host, + }, + ); + if (enableDedicatedOnboardingFlow) { + const someOrgWithSSOExists = await this.teamDB.someOrgWithSSOExists(); + const shouldShowOnboardingFlow = !someOrgWithSSOExists; + this.showOnboardingFlowCondition = { value: shouldShowOnboardingFlow }; + if (shouldShowOnboardingFlow) { + console.log(`Dedicated Onboarding flow is active.`); + } + } else { + this.showOnboardingFlowCondition = { value: false }; + } // execute the check for the setup to be shown until the setup is not required. // cf. evaluation of the condition in `checkUser` if (!this.config.showSetupModal) {