Enable Dedicated Onboarding Flow – WEB-193 (#17303)

* 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
This commit is contained in:
Alex Tugarev 2023-04-21 15:08:42 +02:00 committed by GitHub
parent dafcf19ded
commit ee82d153e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 146 additions and 1 deletions

View File

@ -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<boolean>(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 (
<div>
{!showModal && (
<Modal visible={true} onClose={() => {}} closeable={false}>
<ModalHeader>Welcome to Gitpod Dedicated 🚀</ModalHeader>
<ModalBody>
<p className="pb-4 text-gray-500 text-base">
Before this instance can be used, you need to set up Single Sign-on.
</p>
</ModalBody>
<ModalFooter>
<Button className={"ml-2"} onClick={acceptAndContinue}>
Continue
</Button>
</ModalFooter>
</Modal>
)}
{showModal && <OIDCClientConfigModal onClose={() => {}} />}
</div>
);
}

View File

@ -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<FallbackProps> = ({ error, resetErrorBound
return <Login onLoggedIn={resetErrorBoundary} />;
}
// Setup needed before proceeding
if (caughtError.code === ErrorCodes.ONBOARDING_IN_PROGRESS) {
return (
<Suspense fallback={<AppLoading />}>
<DedicatedOnboarding onComplete={resetErrorBoundary} />
</Suspense>
);
}
// Setup needed before proceeding
if (caughtError.code === ErrorCodes.SETUP_REQUIRED) {
return (

View File

@ -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<void>) {
const typeorm = testContainer.get<TypeORM>(TypeORM);
const connection = await typeorm.getConnection();
await queryFn(connection);
}
}
module.exports = new TeamDBSpec();

View File

@ -40,4 +40,6 @@ export interface TeamDB {
findOrgSettings(teamId: string): Promise<OrganizationSettings | undefined>;
setOrgSettings(teamId: string, settings: Partial<OrganizationSettings>): Promise<void>;
someOrgWithSSOExists(): Promise<boolean>;
}

View File

@ -365,4 +365,12 @@ export class TeamDBImpl implements TeamDB {
repo.save(team);
}
}
public async someOrgWithSSOExists(): Promise<boolean> {
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;
}
}

View File

@ -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

View File

@ -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);
}

View File

@ -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 {

View File

@ -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;

View File

@ -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<void> {
// 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) {