Skip to content

Commit aa48b54

Browse files
committed
Implement user account verification with LinkedIn during onboarding
1 parent 0095dce commit aa48b54

File tree

22 files changed

+255
-2
lines changed

22 files changed

+255
-2
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",
Lines changed: 3 additions & 0 deletions
Loading

components/dashboard/src/onboarding/StepUserInfo.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@
66

77
import { User } from "@gitpod/gitpod-protocol";
88
import { FC, useCallback, useState } from "react";
9+
import { useLinkedIn } from "react-linkedin-login-oauth2";
10+
import { LinkedInCallback } from "react-linkedin-login-oauth2";
911
import { TextInputField } from "../components/forms/TextInputField";
1012
import { useUpdateCurrentUserMutation } from "../data/current-user/update-mutation";
1113
import { useOnBlurError } from "../hooks/use-onblur-error";
1214
import { OnboardingStep } from "./OnboardingStep";
15+
import SignInWithLinkedIn from "../images/sign-in-with-linkedin.svg";
1316

1417
type Props = {
1518
user: User;
19+
linkedInClientId: string;
1620
onComplete(user: User): void;
1721
};
18-
export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
22+
export const StepUserInfo: FC<Props> = ({ user, linkedInClientId, onComplete }) => {
1923
const updateUser = useUpdateCurrentUserMutation();
2024
// attempt to split provided name for default input values
2125
const { first, last } = getInitialNameParts(user);
@@ -25,6 +29,17 @@ export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
2529
// Email purposefully not pre-filled
2630
const [emailAddress, setEmailAddress] = useState("");
2731

32+
const { linkedInLogin } = useLinkedIn({
33+
clientId: linkedInClientId,
34+
redirectUri: `${window.location.origin}/linkedin`,
35+
onSuccess: (code) => {
36+
console.log("success", code);
37+
},
38+
onError: (error) => {
39+
console.log("error", error);
40+
},
41+
});
42+
2843
const handleSubmit = useCallback(async () => {
2944
const additionalData = user.additionalData || {};
3045
const profile = additionalData.profile || {};
@@ -56,6 +71,12 @@ export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
5671

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

74+
// FIXME: might be cleaner to have this in a dedicated /linkedin route instead of relying on the onboarding to show up again
75+
const params = new URLSearchParams(window.location.search);
76+
if (params.get("code") && params.get("state")) {
77+
return <LinkedInCallback />;
78+
}
79+
5980
return (
6081
<OnboardingStep
6182
title="Welcome to Gitpod"
@@ -102,6 +123,20 @@ export const StepUserInfo: FC<Props> = ({ user, onComplete }) => {
102123
onChange={setEmailAddress}
103124
required
104125
/>
126+
127+
<div>
128+
<p className="text-gray-500 text-sm mt-4">
129+
Verify your account by connecting with LinkedIn and receive 500 credits per month. 🎁
130+
</p>
131+
<button className="primary" onClick={linkedInLogin}>
132+
<img
133+
src={SignInWithLinkedIn}
134+
alt="Sign in with Linked In"
135+
style={{ maxWidth: "180px", cursor: "pointer" }}
136+
/>
137+
Connect with LinkedIn
138+
</button>
139+
</div>
105140
</OnboardingStep>
106141
);
107142
};

components/dashboard/src/onboarding/UserOnboarding.tsx

Lines changed: 10 additions & 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";
@@ -40,6 +40,14 @@ const UserOnboarding: FunctionComponent<Props> = ({ user }) => {
4040

4141
const [step, setStep] = useState(STEPS.ONE);
4242
const [completingError, setCompletingError] = useState("");
43+
const [linkedInClientId, setLinkedInClientId] = useState("");
44+
45+
useEffect(() => {
46+
(async () => {
47+
const clientId = await getGitpodService().server.getLinkedInClientId();
48+
setLinkedInClientId(clientId);
49+
})();
50+
}, []);
4351

4452
// We track this state here so we can persist it at the end of the flow instead of when it's selected
4553
// This is because setting the ide is how we indicate a user has onboarded, and want to defer that until the end
@@ -134,6 +142,7 @@ const UserOnboarding: FunctionComponent<Props> = ({ user }) => {
134142
{step === STEPS.ONE && (
135143
<StepUserInfo
136144
user={user}
145+
linkedInClientId={linkedInClientId}
137146
onComplete={(updatedUser) => {
138147
setUser(updatedUser);
139148
setStep(STEPS.TWO);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
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+
export const LinkedInTokenDB = Symbol("LinkedInTokenDB");
8+
export interface LinkedInTokenDB {}

components/gitpod-db/src/tables.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,11 @@ export class GitpodTableDescriptionProvider implements TableDescriptionProvider
340340
timeColumn: "_lastModified",
341341
deletionColumn: "deleted",
342342
},
343+
{
344+
name: "d_b_linked_in_token",
345+
primaryKeys: ["id"],
346+
timeColumn: "_lastModified",
347+
},
343348
];
344349

345350
public getSortedTables(): TableDescription[] {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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 { Entity, PrimaryColumn } from "typeorm";
8+
import { TypeORM } from "../typeorm";
9+
10+
@Entity()
11+
// on DB but not Typeorm: @Index("ind_lastModified", ["_lastModified"]) // DBSync
12+
export class DBLinkedInToken {
13+
@PrimaryColumn(TypeORM.UUID_COLUMN_TYPE)
14+
id: string;
15+
}
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 { inject, injectable } from "inversify";
8+
import { Repository } from "typeorm";
9+
import { LinkedInTokenDB } from "../linked-in-token-db";
10+
import { DBLinkedInToken } from "./entity/db-linked-in-token";
11+
import { TypeORM } from "./typeorm";
12+
13+
@injectable()
14+
export class LinkedInTokenDBImpl implements LinkedInTokenDB {
15+
@inject(TypeORM) typeORM: TypeORM;
16+
17+
protected async getEntityManager() {
18+
return (await this.typeORM.getConnection()).manager;
19+
}
20+
21+
protected async getRepo(): Promise<Repository<DBLinkedInToken>> {
22+
return (await this.getEntityManager()).getRepository<DBLinkedInToken>(DBLinkedInToken);
23+
}
24+
}
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_token";
11+
12+
export class LinkedInToken1680096507296 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, _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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,9 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
308308
getBillingModeForUser(): Promise<BillingMode>;
309309
getBillingModeForTeam(teamId: string): Promise<BillingMode>;
310310

311+
getLinkedInClientId(): Promise<string>;
312+
connectWithLinkedIn(code: string): Promise<void>;
313+
311314
/**
312315
* Analytics
313316
*/
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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 { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
8+
import { inject, injectable } from "inversify";
9+
import fetch from "node-fetch";
10+
import { ResponseError } from "vscode-jsonrpc";
11+
import { Config } from "../../../src/config";
12+
13+
@injectable()
14+
export class LinkedInService {
15+
@inject(Config) protected readonly config: Config;
16+
17+
async getAccessToken(code: string) {
18+
const { clientId, clientSecret } = this.config.linkedInSecrets || {
19+
clientId: "86lcfbj2vwm2ba", // FIXME(janx)
20+
clientSecret: "zRctByb55dTZ9ss3", // FIXME(janx)
21+
};
22+
if (!clientId || !clientSecret) {
23+
throw new ResponseError(
24+
ErrorCodes.INTERNAL_SERVER_ERROR,
25+
"LinkedIn is not properly configured (no Client ID or Client Secret)",
26+
);
27+
}
28+
const redirectUri = this.config.hostUrl.with({ pathname: "/linkedin" }).toString();
29+
const url = `https://www.linkedin.com/oauth/v2/accessToken?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}&client_id=${clientId}&client_secret=${clientSecret}`;
30+
const response = await fetch(url, {
31+
method: "POST",
32+
headers: {
33+
"Content-Type": "application/x-www-form-urlencoded",
34+
},
35+
});
36+
const data = await response.json();
37+
if (data.error) {
38+
throw new Error(data.error_description);
39+
}
40+
return data;
41+
}
42+
}

components/server/ee/src/workspace/gitpod-server-impl.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ import { ChargebeeCouponComputer } from "../user/coupon-computer";
100100
import { ChargebeeService } from "../user/chargebee-service";
101101
import { Chargebee as chargebee } from "@gitpod/gitpod-payment-endpoint/lib/chargebee";
102102
import { StripeService } from "../user/stripe-service";
103+
import { LinkedInService } from "../user/linkedin-service";
103104

104105
import { GitHubAppSupport } from "../github/github-app-support";
105106
import { GitLabAppSupport } from "../gitlab/gitlab-app-support";
@@ -161,6 +162,7 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
161162
@inject(UserCounter) protected readonly userCounter: UserCounter;
162163

163164
@inject(UserService) protected readonly userService: UserService;
165+
@inject(LinkedInService) protected readonly linkedInService: LinkedInService;
164166

165167
@inject(UsageServiceDefinition.name)
166168
protected readonly usageService: UsageServiceClient;
@@ -2675,6 +2677,26 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
26752677
return this.billingModes.getBillingModeForTeam(team, new Date());
26762678
}
26772679

2680+
async getLinkedInClientId(ctx: TraceContextWithSpan): Promise<string> {
2681+
traceAPIParams(ctx, {});
2682+
this.checkAndBlockUser("getLinkedInClientID");
2683+
const clientId = this.config.linkedInSecrets?.clientId || "86lcfbj2vwm2ba"; // FIXME(janx)
2684+
if (!clientId) {
2685+
throw new ResponseError(
2686+
ErrorCodes.INTERNAL_SERVER_ERROR,
2687+
"LinkedIn is not properly configured (no Client ID)",
2688+
);
2689+
}
2690+
return clientId;
2691+
}
2692+
2693+
async connectWithLinkedIn(ctx: TraceContextWithSpan, code: string): Promise<void> {
2694+
traceAPIParams(ctx, { code });
2695+
const user = this.checkAndBlockUser("connectWithLinkedIn");
2696+
const { accessToken, expiresAt } = await this.linkedInService.getAccessToken(code);
2697+
await this.userService.setLinkedInAccessToken(user, accessToken, expiresAt);
2698+
}
2699+
26782700
// (SaaS) – admin
26792701
async adminGetAccountStatement(ctx: TraceContext, userId: string): Promise<AccountStatement> {
26802702
traceAPIParams(ctx, { userId });

components/server/src/auth/rate-limiter.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,8 @@ const defaultFunctions: FunctionsConfig = {
224224
listUsage: { group: "default", points: 1 },
225225
getBillingModeForTeam: { group: "default", points: 1 },
226226
getBillingModeForUser: { group: "default", points: 1 },
227+
getLinkedInClientId: { group: "default", points: 1 },
228+
connectWithLinkedIn: { group: "default", points: 1 },
227229
tsAddMembersToOrg: { group: "default", points: 1 },
228230

229231
trackEvent: { group: "default", points: 1 },

components/server/src/config.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,15 @@ export type Config = Omit<
2626
| "chargebeeProviderOptionsFile"
2727
| "stripeSecretsFile"
2828
| "stripeConfigFile"
29+
| "linkedInSecretsFile"
2930
| "licenseFile"
3031
| "patSigningKeyFile"
3132
> & {
3233
hostUrl: GitpodHostUrl;
3334
workspaceDefaults: WorkspaceDefaults;
3435
chargebeeProviderOptions?: ChargebeeProviderOptions;
3536
stripeSecrets?: { publishableKey: string; secretKey: string };
37+
linkedInSecrets?: { clientId: string; clientSecret: string };
3638
builtinAuthProvidersConfigured: boolean;
3739
inactivityPeriodForReposInDays?: number;
3840

@@ -206,6 +208,11 @@ export interface ConfigSerialized {
206208
stripeConfigFile?: string;
207209
enablePayment?: boolean;
208210

211+
/**
212+
* LinkedIn OAuth2 configuration
213+
*/
214+
linkedInSecretsFile?: string;
215+
209216
/**
210217
* Number of prebuilds that can be started in a given time period.
211218
* Key '*' specifies the default rate limit for a cloneURL, unless overriden by a specific cloneURL.
@@ -291,6 +298,16 @@ export namespace ConfigFile {
291298
log.error("Could not load Stripe secrets", error);
292299
}
293300
}
301+
let linkedInSecrets: { clientId: string; clientSecret: string } | undefined;
302+
if (config.linkedInSecretsFile) {
303+
try {
304+
linkedInSecrets = JSON.parse(
305+
fs.readFileSync(filePathTelepresenceAware(config.linkedInSecretsFile), "utf-8"),
306+
);
307+
} catch (error) {
308+
log.error("Could not load LinkedIn secrets", error);
309+
}
310+
}
294311
let license = config.license;
295312
const licenseFile = config.licenseFile;
296313
if (licenseFile) {
@@ -336,6 +353,7 @@ export namespace ConfigFile {
336353
builtinAuthProvidersConfigured,
337354
chargebeeProviderOptions,
338355
stripeSecrets,
356+
linkedInSecrets,
339357
twilioConfig,
340358
license,
341359
workspaceGarbageCollection: {

components/server/src/user/user-service.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -563,4 +563,6 @@ export class UserService {
563563
async mayCreateOrJoinOrganization(user: User): Promise<boolean> {
564564
return !user.organizationId;
565565
}
566+
567+
async setLinkedInAccessToken(user: User, accessToken: string, expiresAt: string): Promise<void> {}
566568
}

components/server/src/workspace/gitpod-server-impl.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3576,6 +3576,14 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
35763576
return BillingMode.NONE;
35773577
}
35783578

3579+
async getLinkedInClientId(ctx: TraceContextWithSpan): Promise<string> {
3580+
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
3581+
}
3582+
3583+
async connectWithLinkedIn(ctx: TraceContextWithSpan, code: string): Promise<void> {
3584+
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
3585+
}
3586+
35793587
//
35803588
//#endregion
35813589

install/installer/pkg/components/server/configmap.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,7 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) {
282282
EnablePayment: chargebeeSecret != "" || stripeSecret != "" || stripeConfig != "",
283283
ChargebeeProviderOptionsFile: fmt.Sprintf("%s/providerOptions", chargebeeMountPath),
284284
StripeSecretsFile: fmt.Sprintf("%s/apikeys", stripeSecretMountPath),
285+
LinkedInSecretsFile: fmt.Sprintf("%s/linkedin", linkedInSecretMountPath),
285286
InsecureNoDomain: false,
286287
PrebuildLimiter: PrebuildRateLimiters{
287288
// default limit for all cloneURLs

0 commit comments

Comments
 (0)