Skip to content

Implement user account verification with LinkedIn during onboarding #17074

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 16 commits into from
Apr 12, 2023
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
1 change: 1 addition & 0 deletions components/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"react-dom": "^17.0.1",
"react-error-boundary": "^3.1.4",
"react-intl-tel-input": "^8.2.0",
"react-linkedin-login-oauth2": "^2.0.1",
"react-popper": "^2.3.0",
"react-portal": "^4.2.2",
"react-router-dom": "^5.2.0",
Expand Down
5 changes: 5 additions & 0 deletions components/dashboard/src/app/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import { useFeatureFlags } from "../contexts/FeatureFlagContext";
import { FORCE_ONBOARDING_PARAM, FORCE_ONBOARDING_PARAM_VALUE } from "../onboarding/UserOnboarding";
import { Heading1, Subheading } from "../components/typography/headings";
import { useCurrentUser } from "../user-context";
import { LinkedInCallback } from "react-linkedin-login-oauth2";

const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "../Setup"));
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "../workspaces/Workspaces"));
Expand Down Expand Up @@ -122,6 +123,10 @@ export const AppRoutes = () => {
return <WhatsNew onClose={() => setWhatsNewShown(false)} />;
}

if (location.pathname === "/linkedin" && search.get("code") && search.get("state")) {
return <LinkedInCallback />;
}

// Show new signup flow if:
// * feature flag enabled
// * User is onboarding (no ide selected yet) OR query param `onboarding=force` is set
Expand Down
3 changes: 3 additions & 0 deletions components/dashboard/src/images/sign-in-with-linkedin.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 87 additions & 0 deletions components/dashboard/src/onboarding/LinkedInBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* 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 { useQuery } from "@tanstack/react-query";
import classNames from "classnames";
import { FC } from "react";
import { useLinkedIn } from "react-linkedin-login-oauth2";
import Alert from "../components/Alert";
import { Button } from "../components/Button";
import SignInWithLinkedIn from "../images/sign-in-with-linkedin.svg";
import { getGitpodService } from "../service/service";
import { LinkedInProfile } from "@gitpod/gitpod-protocol";

type Props = {
onSuccess(profile: LinkedInProfile): void;
};
export const LinkedInBanner: FC<Props> = ({ onSuccess }) => {
const {
data: clientID,
isLoading,
isError,
} = useQuery(
["linkedin-clientid"],
async () => {
return (await getGitpodService().server.getLinkedInClientId()) || "";
},
{ enabled: true },
);

const { linkedInLogin } = useLinkedIn({
clientId: clientID || "",
redirectUri: `${window.location.origin}/linkedin`,
scope: "r_liteprofile r_emailaddress",
onSuccess: (code) => {
console.log("success", code);
getGitpodService()
.server.connectWithLinkedIn(code)
.then((profile) => {
onSuccess(profile);
})
.catch((error) => {
console.error("LinkedIn connection failed", error);
});
},
onError: (error) => {
console.error("error", error);
},
});

return (
<>
<div
className={classNames(
"mt-6 p-6",
"border-2 border-dashed rounded-md space-y-4",
"bg-gray-50 dark:bg-gray-800",
)}
>
<div className="flex items-center justify-center space-x-6">
<span className="text-4xl">🎁</span>
{/* TODO: Shouldn't need a fixed width here, but was hard to center otherwise */}
<p className="w-64 text-base text-gray-500 dark:text-gray-100">
Receive <strong>500 credits</strong> per month by connecting your <strong>LinkedIn</strong>{" "}
account.
</p>
</div>
<Button
className="w-full flex items-center justify-center space-x-2"
onClick={(event) => { event.preventDefault(); linkedInLogin(); }}
disabled={isLoading || !clientID}
>
<img src={SignInWithLinkedIn} width={20} height={20} alt="Sign in with Linked In" />
<span>Connect with LinkedIn</span>
</Button>
</div>
{/* TODO: Figure out if there's a different way we want to handle an error getting the clientID */}
{isError && (
<Alert className="mt-4" type="error">
We're sorry, there was a problem with the LinkedIn connection.
</Alert>
)}
</>
);
};
15 changes: 12 additions & 3 deletions components/dashboard/src/onboarding/OnboardingStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type Props = {
isSaving?: boolean;
error?: string;
onSubmit(): void;
submitButtonText?: string;
submitButtonType?: "primary" | "secondary" | "danger" | "danger.secondary";
};
export const OnboardingStep: FC<Props> = ({
title,
Expand All @@ -25,6 +27,8 @@ export const OnboardingStep: FC<Props> = ({
error,
children,
onSubmit,
submitButtonText,
submitButtonType,
}) => {
const handleSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
Expand All @@ -50,9 +54,14 @@ export const OnboardingStep: FC<Props> = ({

{error && <Alert type="error">{error}</Alert>}

<div className="mt-8">
<Button htmlType="submit" disabled={!isValid || isSaving} size="block">
Continue
<div className="mt-4">
<Button
htmlType="submit"
type={submitButtonType || "primary"}
disabled={!isValid || isSaving}
size="block"
>
{submitButtonText || "Continue"}
</Button>
</div>
</form>
Expand Down
31 changes: 19 additions & 12 deletions components/dashboard/src/onboarding/StepUserInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
* See License.AGPL.txt in the project root for license information.
*/

import { User } from "@gitpod/gitpod-protocol";
import { LinkedInProfile, User } from "@gitpod/gitpod-protocol";
import { FC, useCallback, useState } from "react";
import { TextInputField } from "../components/forms/TextInputField";
import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";
import { useOnBlurError } from "../hooks/use-onblur-error";
import { OnboardingStep } from "./OnboardingStep";
import { LinkedInBanner } from "./LinkedInBanner";

type Props = {
user: User;
Expand Down Expand Up @@ -50,11 +51,23 @@ export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
}
}, [emailAddress, firstName, lastName, onComplete, updateUser, user.additionalData]);

const onLinkedInSuccess = async (profile: LinkedInProfile) => {
if (!firstName && profile.firstName) {
setFirstName(profile.firstName);
}
if (!lastName && profile.lastName) {
setLastName(profile.lastName);
}
if (!emailAddress && profile.emailAddress) {
setEmailAddress(profile.emailAddress);
}
handleSubmit();
};

const firstNameError = useOnBlurError("Please enter a value", !!firstName);
const lastNameError = useOnBlurError("Please enter a value", !!lastName);
const emailError = useOnBlurError("Please enter your email address", !!emailAddress);

const isValid = [firstNameError, lastNameError, emailError].every((e) => e.isValid);
const isValid = [firstNameError, lastNameError].every((e) => e.isValid);

return (
<OnboardingStep
Expand All @@ -64,6 +77,8 @@ export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
isValid={isValid}
isSaving={updateUser.isLoading}
onSubmit={handleSubmit}
submitButtonText="Continue with 100 credits per month"
submitButtonType="secondary"
>
{user.avatarUrl && (
<div className="my-4 flex justify-center">
Expand Down Expand Up @@ -93,15 +108,7 @@ export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
/>
</div>

<TextInputField
value={emailAddress}
label="Work Email"
type="email"
error={emailError.message}
onBlur={emailError.onBlur}
onChange={setEmailAddress}
required
/>
<LinkedInBanner onSuccess={onLinkedInSuccess} />
</OnboardingStep>
);
};
Expand Down
2 changes: 1 addition & 1 deletion components/dashboard/src/onboarding/UserOnboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { User } from "@gitpod/gitpod-protocol";
import { FunctionComponent, useCallback, useContext, useState } from "react";
import { FunctionComponent, useCallback, useContext, useEffect, useState } from "react";
import gitpodIcon from "../icons/gitpod.svg";
import { Separator } from "../components/Separator";
import { useHistory, useLocation } from "react-router";
Expand Down
4 changes: 4 additions & 0 deletions components/gitpod-db/src/container-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import { UserToTeamMigrationService } from "./user-to-team-migration-service";
import { Synchronizer } from "./typeorm/synchronizer";
import { WorkspaceOrganizationIdMigration } from "./long-running-migration/workspace-organizationid-migration";
import { LongRunningMigration, LongRunningMigrationService } from "./long-running-migration/long-running-migration";
import { LinkedInProfileDBImpl } from "./typeorm/linked-in-profile-db-impl";
import { LinkedInProfileDB } from "./linked-in-profile-db";

// THE DB container module that contains all DB implementations
export const dbContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
Expand Down Expand Up @@ -165,6 +167,8 @@ export const dbContainerModule = new ContainerModule((bind, unbind, isBound, reb
bind(UserToTeamMigrationService).toSelf().inSingletonScope();
bind(WorkspaceOrganizationIdMigration).toSelf().inSingletonScope();
bind(Synchronizer).toSelf().inSingletonScope();
bind(LinkedInProfileDBImpl).toSelf().inSingletonScope();
bind(LinkedInProfileDB).toService(LinkedInProfileDBImpl);

bind(LongRunningMigrationService).toSelf().inSingletonScope();
bind(LongRunningMigration).to(WorkspaceOrganizationIdMigration).inSingletonScope();
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ export * from "./webhook-event-db";
export * from "./typeorm/metrics";
export * from "./personal-access-token-db";
export * from "./typeorm/entity/db-personal-access-token";
export * from "./linked-in-profile-db";
12 changes: 12 additions & 0 deletions components/gitpod-db/src/linked-in-profile-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* 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 { LinkedInProfile } from "@gitpod/gitpod-protocol";

export const LinkedInProfileDB = Symbol("LinkedInProfileDB");
export interface LinkedInProfileDB {
storeProfile(userId: string, profile: LinkedInProfile): Promise<void>;
}
5 changes: 5 additions & 0 deletions components/gitpod-db/src/tables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,11 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
timeColumn: "_lastModified",
deletionColumn: "deleted",
},
{
name: "d_b_linked_in_profile",
primaryKeys: ["id"],
timeColumn: "_lastModified",
},
];

public getSortedTables(): TableDescription[] {
Expand Down
28 changes: 28 additions & 0 deletions components/gitpod-db/src/typeorm/entity/db-linked-in-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* 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 { Column, Entity, PrimaryColumn } from "typeorm";
import { TypeORM } from "../typeorm";
import { LinkedInProfile } from "@gitpod/gitpod-protocol";

@Entity()
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
export class DBLinkedInProfile {
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
id: string;

@Column(TypeORM.UUID_COLUMN_TYPE)
userId: string;

@Column({
type: "simple-json",
nullable: false,
})
profile: LinkedInProfile;

@Column("varchar")
creationTime: string;
}
44 changes: 44 additions & 0 deletions components/gitpod-db/src/typeorm/linked-in-profile-db-impl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* 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 { LinkedInProfile } from "@gitpod/gitpod-protocol";
import { inject, injectable } from "inversify";
import { Repository } from "typeorm";
import { v4 as uuidv4 } from "uuid";
import { LinkedInProfileDB } from "../linked-in-profile-db";
import { DBLinkedInProfile } from "./entity/db-linked-in-profile";
import { TypeORM } from "./typeorm";

@injectable()
export class LinkedInProfileDBImpl implements LinkedInProfileDB {
@inject(TypeORM) typeORM: TypeORM;

protected async getEntityManager() {
return (await this.typeORM.getConnection()).manager;
}

protected async getRepo(): Promise<Repository<DBLinkedInProfile>> {
return (await this.getEntityManager()).getRepository<DBLinkedInProfile>(DBLinkedInProfile);
}

public async storeProfile(userId: string, profile: LinkedInProfile): Promise<void> {
const repo = await this.getRepo();
const existingProfile = await repo
.createQueryBuilder("p")
.where('JSON_EXTRACT(`p`.`profile`, "$.id") = :id', { id: profile.id })
.getOne();
if (existingProfile && existingProfile.userId !== userId) {
throw new Error(`LinkedIn profile ${profile.id} is already associated with another user.`);
}

await repo.save({
id: existingProfile?.id || uuidv4(),
userId,
profile,
creationTime: existingProfile?.creationTime || new Date().toISOString(),
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* 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 { MigrationInterface, QueryRunner } from "typeorm";
import { tableExists } from "./helper/helper";

const TABLE_NAME = "d_b_linked_in_profile";

export class LinkedInProfile1680096507296 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (id char(36) NOT NULL, userId char(36) NOT NULL, profile text NOT NULL, creationTime varchar(255) NOT NULL, _lastModified timestamp(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (id)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this not be unique per user?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@svenefftinge it could be. Please explicitly mention what you're expecting to see different in this CREATE statement. 🙂

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no id but making the userid the PK

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great idea, thanks!

);
}

public async down(queryRunner: QueryRunner): Promise<void> {
if (await tableExists(queryRunner, TABLE_NAME)) {
await queryRunner.query(`DROP TABLE ${TABLE_NAME}`);
}
}
}
4 changes: 4 additions & 0 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
EnvVarWithValue,
WorkspaceTimeoutSetting,
WorkspaceContext,
LinkedInProfile,
} from "./protocol";
import {
Team,
Expand Down Expand Up @@ -308,6 +309,9 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
getBillingModeForUser(): Promise<BillingMode>;
getBillingModeForTeam(teamId: string): Promise<BillingMode>;

getLinkedInClientId(): Promise<string>;
connectWithLinkedIn(code: string): Promise<LinkedInProfile>;

/**
* Analytics
*/
Expand Down
Loading