Skip to content

Commit f7101c5

Browse files
Implement user account verification with LinkedIn during onboarding (#17074)
* Implement user account verification with LinkedIn during onboarding * updating connect with linked-in banner * removing unused imports * Store token, fix binding * Refactor LinkedInToken to LinkedInProfile * Actually write the LinkedIn secret to the server config * Fetch LinkedIn user profile and email address * Add creationTime column to d_b_linked_in_profile * Add more debug logging * Fix LinkedIn API calls, mount LinkedInProfileDB * Also bind LinkedInProfileDB * Add LinkedIn scope r_liteprofile * Enhance LinkedIn profile retrieval, store the profile, ensure uniqueness * Align with UX spec and complete onboarding flow * Prevent the LinkedIn button from auto-submitting the onboarding form * Address nits (LinkedInService to /src and minor spacing) --------- Co-authored-by: Brad Harris <[email protected]>
1 parent 895054c commit f7101c5

28 files changed

+477
-16
lines changed

components/dashboard/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"react-dom": "^17.0.1",
2929
"react-error-boundary": "^3.1.4",
3030
"react-intl-tel-input": "^8.2.0",
31+
"react-linkedin-login-oauth2": "^2.0.1",
3132
"react-popper": "^2.3.0",
3233
"react-portal": "^4.2.2",
3334
"react-router-dom": "^5.2.0",

components/dashboard/src/app/AppRoutes.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import { useFeatureFlags } from "../contexts/FeatureFlagContext";
4848
import { FORCE_ONBOARDING_PARAM, FORCE_ONBOARDING_PARAM_VALUE } from "../onboarding/UserOnboarding";
4949
import { Heading1, Subheading } from "../components/typography/headings";
5050
import { useCurrentUser } from "../user-context";
51+
import { LinkedInCallback } from "react-linkedin-login-oauth2";
5152

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

126+
if (location.pathname === "/linkedin" && search.get("code") && search.get("state")) {
127+
return <LinkedInCallback />;
128+
}
129+
125130
// Show new signup flow if:
126131
// * feature flag enabled
127132
// * User is onboarding (no ide selected yet) OR query param `onboarding=force` is set
Lines changed: 3 additions & 0 deletions
Loading
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { useQuery } from "@tanstack/react-query";
8+
import classNames from "classnames";
9+
import { FC } from "react";
10+
import { useLinkedIn } from "react-linkedin-login-oauth2";
11+
import Alert from "../components/Alert";
12+
import { Button } from "../components/Button";
13+
import SignInWithLinkedIn from "../images/sign-in-with-linkedin.svg";
14+
import { getGitpodService } from "../service/service";
15+
import { LinkedInProfile } from "@gitpod/gitpod-protocol";
16+
17+
type Props = {
18+
onSuccess(profile: LinkedInProfile): void;
19+
};
20+
export const LinkedInBanner: FC<Props> = ({ onSuccess }) => {
21+
const {
22+
data: clientID,
23+
isLoading,
24+
isError,
25+
} = useQuery(
26+
["linkedin-clientid"],
27+
async () => {
28+
return (await getGitpodService().server.getLinkedInClientId()) || "";
29+
},
30+
{ enabled: true },
31+
);
32+
33+
const { linkedInLogin } = useLinkedIn({
34+
clientId: clientID || "",
35+
redirectUri: `${window.location.origin}/linkedin`,
36+
scope: "r_liteprofile r_emailaddress",
37+
onSuccess: (code) => {
38+
console.log("success", code);
39+
getGitpodService()
40+
.server.connectWithLinkedIn(code)
41+
.then((profile) => {
42+
onSuccess(profile);
43+
})
44+
.catch((error) => {
45+
console.error("LinkedIn connection failed", error);
46+
});
47+
},
48+
onError: (error) => {
49+
console.error("error", error);
50+
},
51+
});
52+
53+
return (
54+
<>
55+
<div
56+
className={classNames(
57+
"mt-6 p-6",
58+
"border-2 border-dashed rounded-md space-y-4",
59+
"bg-gray-50 dark:bg-gray-800",
60+
)}
61+
>
62+
<div className="flex items-center justify-center space-x-6">
63+
<span className="text-4xl">🎁</span>
64+
{/* TODO: Shouldn't need a fixed width here, but was hard to center otherwise */}
65+
<p className="w-64 text-base text-gray-500 dark:text-gray-100">
66+
Receive <strong>500 credits</strong> per month by connecting your <strong>LinkedIn</strong>{" "}
67+
account.
68+
</p>
69+
</div>
70+
<Button
71+
className="w-full flex items-center justify-center space-x-2"
72+
onClick={(event) => { event.preventDefault(); linkedInLogin(); }}
73+
disabled={isLoading || !clientID}
74+
>
75+
<img src={SignInWithLinkedIn} width={20} height={20} alt="Sign in with Linked In" />
76+
<span>Connect with LinkedIn</span>
77+
</Button>
78+
</div>
79+
{/* TODO: Figure out if there's a different way we want to handle an error getting the clientID */}
80+
{isError && (
81+
<Alert className="mt-4" type="error">
82+
We're sorry, there was a problem with the LinkedIn connection.
83+
</Alert>
84+
)}
85+
</>
86+
);
87+
};

components/dashboard/src/onboarding/OnboardingStep.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ type Props = {
1616
isSaving?: boolean;
1717
error?: string;
1818
onSubmit(): void;
19+
submitButtonText?: string;
20+
submitButtonType?: "primary" | "secondary" | "danger" | "danger.secondary";
1921
};
2022
export const OnboardingStep: FC<Props> = ({
2123
title,
@@ -25,6 +27,8 @@ export const OnboardingStep: FC<Props> = ({
2527
error,
2628
children,
2729
onSubmit,
30+
submitButtonText,
31+
submitButtonType,
2832
}) => {
2933
const handleSubmit = useCallback(
3034
async (e: FormEvent<HTMLFormElement>) => {
@@ -50,9 +54,14 @@ export const OnboardingStep: FC<Props> = ({
5054

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

53-
<div className="mt-8">
54-
<Button htmlType="submit" disabled={!isValid || isSaving} size="block">
55-
Continue
57+
<div className="mt-4">
58+
<Button
59+
htmlType="submit"
60+
type={submitButtonType || "primary"}
61+
disabled={!isValid || isSaving}
62+
size="block"
63+
>
64+
{submitButtonText || "Continue"}
5665
</Button>
5766
</div>
5867
</form>

components/dashboard/src/onboarding/StepUserInfo.tsx

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
* See License.AGPL.txt in the project root for license information.
55
*/
66

7-
import { User } from "@gitpod/gitpod-protocol";
7+
import { LinkedInProfile, User } from "@gitpod/gitpod-protocol";
88
import { FC, useCallback, useState } from "react";
99
import { TextInputField } from "../components/forms/TextInputField";
1010
import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";
1111
import { useOnBlurError } from "../hooks/use-onblur-error";
1212
import { OnboardingStep } from "./OnboardingStep";
13+
import { LinkedInBanner } from "./LinkedInBanner";
1314

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

54+
const onLinkedInSuccess = async (profile: LinkedInProfile) => {
55+
if (!firstName && profile.firstName) {
56+
setFirstName(profile.firstName);
57+
}
58+
if (!lastName && profile.lastName) {
59+
setLastName(profile.lastName);
60+
}
61+
if (!emailAddress && profile.emailAddress) {
62+
setEmailAddress(profile.emailAddress);
63+
}
64+
handleSubmit();
65+
};
66+
5367
const firstNameError = useOnBlurError("Please enter a value", !!firstName);
5468
const lastNameError = useOnBlurError("Please enter a value", !!lastName);
55-
const emailError = useOnBlurError("Please enter your email address", !!emailAddress);
5669

57-
const isValid = [firstNameError, lastNameError, emailError].every((e) => e.isValid);
70+
const isValid = [firstNameError, lastNameError].every((e) => e.isValid);
5871

5972
return (
6073
<OnboardingStep
@@ -64,6 +77,8 @@ export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
6477
isValid={isValid}
6578
isSaving={updateUser.isLoading}
6679
onSubmit={handleSubmit}
80+
submitButtonText="Continue with 100 credits per month"
81+
submitButtonType="secondary"
6782
>
6883
{user.avatarUrl && (
6984
<div className="my-4 flex justify-center">
@@ -93,15 +108,7 @@ export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
93108
/>
94109
</div>
95110

96-
<TextInputField
97-
value={emailAddress}
98-
label="Work Email"
99-
type="email"
100-
error={emailError.message}
101-
onBlur={emailError.onBlur}
102-
onChange={setEmailAddress}
103-
required
104-
/>
111+
<LinkedInBanner onSuccess={onLinkedInSuccess} />
105112
</OnboardingStep>
106113
);
107114
};

components/dashboard/src/onboarding/UserOnboarding.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { User } from "@gitpod/gitpod-protocol";
8-
import { FunctionComponent, useCallback, useContext, useState } from "react";
8+
import { FunctionComponent, useCallback, useContext, useEffect, useState } from "react";
99
import gitpodIcon from "../icons/gitpod.svg";
1010
import { Separator } from "../components/Separator";
1111
import { useHistory, useLocation } from "react-router";

components/gitpod-db/src/container-module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ import { UserToTeamMigrationService } from "./user-to-team-migration-service";
7070
import { Synchronizer } from "./typeorm/synchronizer";
7171
import { WorkspaceOrganizationIdMigration } from "./long-running-migration/workspace-organizationid-migration";
7272
import { LongRunningMigration, LongRunningMigrationService } from "./long-running-migration/long-running-migration";
73+
import { LinkedInProfileDBImpl } from "./typeorm/linked-in-profile-db-impl";
74+
import { LinkedInProfileDB } from "./linked-in-profile-db";
7375

7476
// THE DB container module that contains all DB implementations
7577
export const dbContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => {
@@ -165,6 +167,8 @@ export const dbContainerModule = new ContainerModule((bind, unbind, isBound, reb
165167
bind(UserToTeamMigrationService).toSelf().inSingletonScope();
166168
bind(WorkspaceOrganizationIdMigration).toSelf().inSingletonScope();
167169
bind(Synchronizer).toSelf().inSingletonScope();
170+
bind(LinkedInProfileDBImpl).toSelf().inSingletonScope();
171+
bind(LinkedInProfileDB).toService(LinkedInProfileDBImpl);
168172

169173
bind(LongRunningMigrationService).toSelf().inSingletonScope();
170174
bind(LongRunningMigration).to(WorkspaceOrganizationIdMigration).inSingletonScope();

components/gitpod-db/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,4 @@ export * from "./webhook-event-db";
4242
export * from "./typeorm/metrics";
4343
export * from "./personal-access-token-db";
4444
export * from "./typeorm/entity/db-personal-access-token";
45+
export * from "./linked-in-profile-db";
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { LinkedInProfile } from "@gitpod/gitpod-protocol";
8+
9+
export const LinkedInProfileDB = Symbol("LinkedInProfileDB");
10+
export interface LinkedInProfileDB {
11+
storeProfile(userId: string, profile: LinkedInProfile): Promise<void>;
12+
}

components/gitpod-db/src/tables.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,11 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
334334
timeColumn: "_lastModified",
335335
deletionColumn: "deleted",
336336
},
337+
{
338+
name: "d_b_linked_in_profile",
339+
primaryKeys: ["id"],
340+
timeColumn: "_lastModified",
341+
},
337342
];
338343

339344
public getSortedTables(): TableDescription[] {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { Column, Entity, PrimaryColumn } from "typeorm";
8+
import { TypeORM } from "../typeorm";
9+
import { LinkedInProfile } from "@gitpod/gitpod-protocol";
10+
11+
@Entity()
12+
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
13+
export class DBLinkedInProfile {
14+
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
15+
id: string;
16+
17+
@Column(TypeORM.UUID_COLUMN_TYPE)
18+
userId: string;
19+
20+
@Column({
21+
type: "simple-json",
22+
nullable: false,
23+
})
24+
profile: LinkedInProfile;
25+
26+
@Column("varchar")
27+
creationTime: string;
28+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { LinkedInProfile } from "@gitpod/gitpod-protocol";
8+
import { inject, injectable } from "inversify";
9+
import { Repository } from "typeorm";
10+
import { v4 as uuidv4 } from "uuid";
11+
import { LinkedInProfileDB } from "../linked-in-profile-db";
12+
import { DBLinkedInProfile } from "./entity/db-linked-in-profile";
13+
import { TypeORM } from "./typeorm";
14+
15+
@injectable()
16+
export class LinkedInProfileDBImpl implements LinkedInProfileDB {
17+
@inject(TypeORM) typeORM: TypeORM;
18+
19+
protected async getEntityManager() {
20+
return (await this.typeORM.getConnection()).manager;
21+
}
22+
23+
protected async getRepo(): Promise<Repository<DBLinkedInProfile>> {
24+
return (await this.getEntityManager()).getRepository<DBLinkedInProfile>(DBLinkedInProfile);
25+
}
26+
27+
public async storeProfile(userId: string, profile: LinkedInProfile): Promise<void> {
28+
const repo = await this.getRepo();
29+
const existingProfile = await repo
30+
.createQueryBuilder("p")
31+
.where('JSON_EXTRACT(`p`.`profile`, "$.id") = :id', { id: profile.id })
32+
.getOne();
33+
if (existingProfile && existingProfile.userId !== userId) {
34+
throw new Error(`LinkedIn profile ${profile.id} is already associated with another user.`);
35+
}
36+
37+
await repo.save({
38+
id: existingProfile?.id || uuidv4(),
39+
userId,
40+
profile,
41+
creationTime: existingProfile?.creationTime || new Date().toISOString(),
42+
});
43+
}
44+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright (c) 2023 Gitpod GmbH. All rights reserved.
3+
* Licensed under the GNU Affero General Public License (AGPL).
4+
* See License.AGPL.txt in the project root for license information.
5+
*/
6+
7+
import { MigrationInterface, QueryRunner } from "typeorm";
8+
import { tableExists } from "./helper/helper";
9+
10+
const TABLE_NAME = "d_b_linked_in_profile";
11+
12+
export class LinkedInProfile1680096507296 implements MigrationInterface {
13+
public async up(queryRunner: QueryRunner): Promise<void> {
14+
await queryRunner.query(
15+
`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;`,
16+
);
17+
}
18+
19+
public async down(queryRunner: QueryRunner): Promise<void> {
20+
if (await tableExists(queryRunner, TABLE_NAME)) {
21+
await queryRunner.query(`DROP TABLE ${TABLE_NAME}`);
22+
}
23+
}
24+
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
EnvVarWithValue,
3131
WorkspaceTimeoutSetting,
3232
WorkspaceContext,
33+
LinkedInProfile,
3334
} from "./protocol";
3435
import {
3536
Team,
@@ -308,6 +309,9 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
308309
getBillingModeForUser(): Promise<BillingMode>;
309310
getBillingModeForTeam(teamId: string): Promise<BillingMode>;
310311

312+
getLinkedInClientId(): Promise<string>;
313+
connectWithLinkedIn(code: string): Promise<LinkedInProfile>;
314+
311315
/**
312316
* Analytics
313317
*/

0 commit comments

Comments
 (0)