From d2a10756a293a458490c9cc07c6460905eb4199d Mon Sep 17 00:00:00 2001 From: Sven Efftinge Date: Tue, 5 Jul 2022 06:31:38 +0000 Subject: [PATCH] [dashboard] allow editing user information fixes #10999 --- components/dashboard/src/Menu.tsx | 12 +- components/dashboard/src/components/Alert.tsx | 4 +- .../src/components/ConfirmationModal.tsx | 18 +- components/dashboard/src/settings/Account.tsx | 66 +++--- .../src/settings/ProfileInformation.tsx | 218 ++++++++++++++++++ .../dashboard/src/workspaces/Workspaces.tsx | 8 +- components/gitpod-protocol/src/protocol.ts | 16 +- components/licensor/typescript/ee/src/api.ts | 2 +- components/server/src/auth/auth-provider.ts | 1 + .../src/bitbucket/bitbucket-auth-provider.ts | 1 + .../server/src/github/github-auth-provider.ts | 3 +- .../server/src/gitlab/gitlab-auth-provider.ts | 3 +- 12 files changed, 306 insertions(+), 46 deletions(-) create mode 100644 components/dashboard/src/settings/ProfileInformation.tsx diff --git a/components/dashboard/src/Menu.tsx b/components/dashboard/src/Menu.tsx index 82da662e9fbd32..e5ba7007f86640 100644 --- a/components/dashboard/src/Menu.tsx +++ b/components/dashboard/src/Menu.tsx @@ -342,9 +342,9 @@ export default function Menu() { > @@ -356,8 +356,8 @@ export default function Menu() {
@@ -385,8 +385,8 @@ export default function Menu() {
diff --git a/components/dashboard/src/components/Alert.tsx b/components/dashboard/src/components/Alert.tsx index e93dda95ae0594..63c3c50fe8df37 100644 --- a/components/dashboard/src/components/Alert.tsx +++ b/components/dashboard/src/components/Alert.tsx @@ -79,7 +79,9 @@ export default function Alert(props: AlertProps) { {showIcon && {props.icon ?? info.icon}} {props.children} {props.closable && ( - setVisible(false)} className="mt-1 ml-4 w-3 h-3 cursor-pointer"> + + setVisible(false)} className="w-3 h-4 cursor-pointer"> + )}
); diff --git a/components/dashboard/src/components/ConfirmationModal.tsx b/components/dashboard/src/components/ConfirmationModal.tsx index ae5d005891a41a..c9ba391cfd5129 100644 --- a/components/dashboard/src/components/ConfirmationModal.tsx +++ b/components/dashboard/src/components/ConfirmationModal.tsx @@ -19,17 +19,21 @@ export default function ConfirmationModal(props: { onClose: () => void; onConfirm: () => void; }) { - const child: React.ReactChild[] = [

{props.areYouSureText}

]; + const children: React.ReactChild[] = [ +

+ {props.areYouSureText} +

, + ]; if (props.warningText) { - child.unshift({props.warningText}); + children.unshift({props.warningText}); } const isEntity = (x: any): x is Entity => typeof x === "object" && "name" in x; if (props.children) { if (isEntity(props.children)) { - child.push( -
+ children.push( +

{props.children.name}

{props.children.description && (

{props.children.description}

@@ -37,9 +41,9 @@ export default function ConfirmationModal(props: {
, ); } else if (Array.isArray(props.children)) { - child.push(...props.children); + children.push(...props.children); } else { - child.push(props.children); + children.push(props.children); } } const cancelButtonRef = useRef(null); @@ -76,7 +80,7 @@ export default function ConfirmationModal(props: { return true; }} > - {child} + {children} ); } diff --git a/components/dashboard/src/settings/Account.tsx b/components/dashboard/src/settings/Account.tsx index 356c3df76114e6..235b3f9d9a9516 100644 --- a/components/dashboard/src/settings/Account.tsx +++ b/components/dashboard/src/settings/Account.tsx @@ -11,17 +11,31 @@ import { getGitpodService, gitpodHostUrl } from "../service/service"; import { UserContext } from "../user-context"; import getSettingsMenu from "./settings-menu"; import ConfirmationModal from "../components/ConfirmationModal"; -import CodeText from "../components/CodeText"; import { PaymentContext } from "../payment-context"; +import ProfileInformation, { ProfileState } from "./ProfileInformation"; export default function Account() { - const { user } = useContext(UserContext); + const { user, setUser } = useContext(UserContext); const { showPaymentUI, showUsageBasedUI } = useContext(PaymentContext); - const [modal, setModal] = useState(false); + const primaryEmail = User.getPrimaryEmail(user!) || ""; const [typedEmail, setTypedEmail] = useState(""); + const original = ProfileState.getProfileState(user!); + const [profileState, setProfileState] = useState(original); + const [errorMessage, setErrorMessage] = useState(""); + const [updated, setUpdated] = useState(false); - const primaryEmail = User.getPrimaryEmail(user!) || "---"; + const saveProfileState = () => { + const error = ProfileState.validate(profileState); + setErrorMessage(error); + if (error) { + return; + } + const updatedUser = ProfileState.setProfileState(user!, profileState); + setUser(updatedUser); + getGitpodService().server.updateLoggedInUser(updatedUser); + setUpdated(true); + }; const deleteAccount = async () => { await getGitpodService().server.deleteAccount(); @@ -61,30 +75,28 @@ export default function Account() { subtitle="Manage account and Git configuration." >

Profile

-

- The following information will be used to set up Git configuration. You can override Git author name - and email per project by using the default environment variables{" "} - GIT_AUTHOR_NAME, GIT_COMMITTER_NAME,{" "} - GIT_AUTHOR_EMAIL and GIT_COMMITTER_EMAIL. -

-
-
-
-

Name

- -
-
-

Email

- -
-
-
-
-

Avatar

- {user!.name} +
{ + saveProfileState(); + e.preventDefault(); + }} + > + { + setProfileState(state); + setUpdated(false); + }} + errorMessage={errorMessage} + updated={updated} + > +
+
-
-
+ +

Delete Account

This action will remove all the data associated with your account in Gitpod. diff --git a/components/dashboard/src/settings/ProfileInformation.tsx b/components/dashboard/src/settings/ProfileInformation.tsx new file mode 100644 index 00000000000000..2f2eadfc113e33 --- /dev/null +++ b/components/dashboard/src/settings/ProfileInformation.tsx @@ -0,0 +1,218 @@ +/** + * Copyright (c) 2021 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 { User } from "@gitpod/gitpod-protocol"; +import { hoursBefore, isDateSmaller } from "@gitpod/gitpod-protocol/lib/util/timeutil"; +import React, { useContext, useState } from "react"; +import Alert from "../components/Alert"; +import CodeText from "../components/CodeText"; +import Modal from "../components/Modal"; +import { getGitpodService } from "../service/service"; +import { UserContext } from "../user-context"; +import { isGitpodIo } from "../utils"; + +export namespace ProfileState { + export interface ProfileState { + name: string; + email: string; + company?: string; + avatarURL?: string; + } + + export function getProfileState(user: User): ProfileState { + return { + name: User.getName(user!) || "", + email: User.getPrimaryEmail(user!) || "", + company: user?.additionalData?.profile?.companyName, + avatarURL: user?.avatarUrl, + }; + } + + export function setProfileState(user: User, profileState: ProfileState): User { + user.fullName = profileState.name; + user.avatarUrl = profileState.avatarURL; + + if (!user.additionalData) { + user.additionalData = {}; + } + if (!user.additionalData.profile) { + user.additionalData.profile = {}; + } + user.additionalData.profile.emailAddress = profileState.email; + user.additionalData.profile.companyName = profileState.company; + user.additionalData.profile.lastUpdatedDetailsNudge = new Date().toISOString(); + + return user; + } + + export function hasChanges(before: ProfileState, after: ProfileState) { + return ( + before.name !== after.name || + before.email !== after.email || + before.company !== after.company || + before.avatarURL !== after.avatarURL + ); + } + + function shouldNudgeForUpdate(user: User): boolean { + if (!isGitpodIo()) { + return false; + } + if (!user.additionalData?.profile) { + // never updated profile information and account is older than 24 hours (i.e. ask on second day). + return !isDateSmaller(hoursBefore(new Date().toISOString(), 24), user.creationDate); + } + // if the profile wasn't updated for 12 months ask again. + return !( + !!user.additionalData.profile.lastUpdatedDetailsNudge && + isDateSmaller( + hoursBefore(new Date().toISOString(), 24 * 365), + user.additionalData.profile.lastUpdatedDetailsNudge, + ) + ); + } + + /** + * @param state + * @returns error message or empty string when valid + */ + export function validate(state: ProfileState): string { + if (state.name.trim() === "") { + return "Name must not be empty."; + } + if (state.email.trim() === "") { + return "Email must not be empty."; + } + if ( + !/^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + state.email.trim(), + ) + ) { + return "Please enter a valid email."; + } + return ""; + } + + export function NudgeForProfileUpdateModal() { + const { user, setUser } = useContext(UserContext); + const original = ProfileState.getProfileState(user!); + const [profileState, setProfileState] = useState(original); + const [errorMessage, setErrorMessage] = useState(""); + const [visible, setVisible] = useState(shouldNudgeForUpdate(user!)); + + const saveProfileState = () => { + const error = ProfileState.validate(profileState); + setErrorMessage(error); + if (error) { + return; + } + const updatedUser = ProfileState.setProfileState(user!, profileState); + setUser(updatedUser); + getGitpodService().server.updateLoggedInUser(updatedUser); + setVisible(shouldNudgeForUpdate(updatedUser!)); + }; + + const cancelProfileUpdate = () => { + setProfileState(original); + saveProfileState(); + }; + + return ( + + + +

+ } + > + + + ); + } +} + +export default function ProfileInformation(props: { + profileState: ProfileState.ProfileState; + setProfileState: (newState: ProfileState.ProfileState) => void; + errorMessage: string; + updated: boolean; + children?: React.ReactChild[] | React.ReactChild; +}) { + return ( +
+

+ The following information will be used to set up Git configuration. You can override Git author name and + email per project by using the default environment variables GIT_AUTHOR_NAME,{" "} + GIT_COMMITTER_NAME, GIT_AUTHOR_EMAIL and{" "} + GIT_COMMITTER_EMAIL. +

+ {props.errorMessage.length > 0 && ( + + {props.errorMessage} + + )} + {props.updated && ( + + Profile information has been updated. + + )} +
+
+
+

Name

+ props.setProfileState({ ...props.profileState, name: e.target.value })} + /> +
+
+

Email

+ props.setProfileState({ ...props.profileState, email: e.target.value })} + /> +
+
+

Company

+ props.setProfileState({ ...props.profileState, company: e.target.value })} + /> +
+
+
+
+

Avatar

+ {props.profileState.name} +
+
+
+ {props.children || null} +
+ ); +} diff --git a/components/dashboard/src/workspaces/Workspaces.tsx b/components/dashboard/src/workspaces/Workspaces.tsx index c3cfd8a1bd2931..6dd530fce94b6d 100644 --- a/components/dashboard/src/workspaces/Workspaces.tsx +++ b/components/dashboard/src/workspaces/Workspaces.tsx @@ -20,6 +20,7 @@ import { StartWorkspaceModalContext, StartWorkspaceModalKeyBinding } from "./sta import SelectIDEModal from "../settings/SelectIDEModal"; import Arrow from "../components/Arrow"; import ConfirmationModal from "../components/ConfirmationModal"; +import { ProfileState } from "../settings/ProfileInformation"; export interface WorkspacesProps {} @@ -68,7 +69,12 @@ export default function () { }} > - {isOnboardingUser && } + {isOnboardingUser ? ( + + ) : ( + // modal hides itself + + )} {workspaceModel?.initialized && (activeWorkspaces.length > 0 || inactiveWorkspaces.length > 0 || workspaceModel.searchTerm ? ( diff --git a/components/gitpod-protocol/src/protocol.ts b/components/gitpod-protocol/src/protocol.ts index df6ca44934c4d7..cf590a97ed17bf 100644 --- a/components/gitpod-protocol/src/protocol.ts +++ b/components/gitpod-protocol/src/protocol.ts @@ -70,11 +70,14 @@ export namespace User { } /** - * Tries to return the primaryEmail of the first identity this user signed up with. + * Returns the stored email or if it doesn't exist returns the primaryEmail of the first identity this user signed up with. * @param user * @returns A primaryEmail, or undefined if there is none. */ export function getPrimaryEmail(user: User): string | undefined { + if (user.additionalData?.profile?.emailAddress) { + return user.additionalData?.profile?.emailAddress; + } const identities = user.identities.filter((i) => !!i.primaryEmail); if (identities.length <= 0) { return undefined; @@ -154,6 +157,17 @@ export interface AdditionalUserData { dotfileRepo?: string; // Identifies an explicit team or user ID to which all the user's workspace usage should be attributed to (e.g. for billing purposes) usageAttributionId?: string; + // additional user profile data + profile?: ProfileDetails; +} + +export interface ProfileDetails { + // when was the last time the user updated their profile information or has been nudged to do so. + lastUpdatedDetailsNudge?: string; + // the user's company name + companyName?: string; + // the user's email + emailAddress?: string; } export interface EmailNotificationSettings { diff --git a/components/licensor/typescript/ee/src/api.ts b/components/licensor/typescript/ee/src/api.ts index c3e2c8814a1ba5..9fde2c9a21f2ea 100644 --- a/components/licensor/typescript/ee/src/api.ts +++ b/components/licensor/typescript/ee/src/api.ts @@ -29,7 +29,7 @@ export interface LicensePayload { level: LicenseLevel validUntil: string seats: number - customerID: string + customerID?: string } export enum LicenseSubscriptionLevel { diff --git a/components/server/src/auth/auth-provider.ts b/components/server/src/auth/auth-provider.ts index 179a72db42f93c..00eeb1b9d6ab2b 100644 --- a/components/server/src/auth/auth-provider.ts +++ b/components/server/src/auth/auth-provider.ts @@ -73,6 +73,7 @@ export interface AuthUser { readonly primaryEmail: string; readonly name?: string; readonly avatarUrl?: string; + readonly company?: string; } export const AuthProvider = Symbol("AuthProvider"); diff --git a/components/server/src/bitbucket/bitbucket-auth-provider.ts b/components/server/src/bitbucket/bitbucket-auth-provider.ts index bcb2de4a5cdba7..405348933baeb0 100644 --- a/components/server/src/bitbucket/bitbucket-auth-provider.ts +++ b/components/server/src/bitbucket/bitbucket-auth-provider.ts @@ -81,6 +81,7 @@ export class BitbucketAuthProvider extends GenericAuthProvider { primaryEmail: primaryEmail, name: user.display_name, avatarUrl: user.links!.avatar!.href, + company: user.website, }, currentScopes, }; diff --git a/components/server/src/github/github-auth-provider.ts b/components/server/src/github/github-auth-provider.ts index 9f0e9891d15783..2f932e8a9aac50 100644 --- a/components/server/src/github/github-auth-provider.ts +++ b/components/server/src/github/github-auth-provider.ts @@ -90,7 +90,7 @@ export class GitHubAuthProvider extends GenericAuthProvider { try { const [ { - data: { id, login, avatar_url, name }, + data: { id, login, avatar_url, name, company }, headers, }, userEmails, @@ -123,6 +123,7 @@ export class GitHubAuthProvider extends GenericAuthProvider { avatarUrl: avatar_url, name, primaryEmail: filterPrimaryEmail(userEmails), + company, }, currentScopes, }; diff --git a/components/server/src/gitlab/gitlab-auth-provider.ts b/components/server/src/gitlab/gitlab-auth-provider.ts index 316f7b50faa5f1..882e4221f1b0d7 100644 --- a/components/server/src/gitlab/gitlab-auth-provider.ts +++ b/components/server/src/gitlab/gitlab-auth-provider.ts @@ -71,7 +71,7 @@ export class GitLabAuthProvider extends GenericAuthProvider { throw UnconfirmedUserException.create(unconfirmedUserMessage, result); } } - const { id, username, avatar_url, name, email } = result; + const { id, username, avatar_url, name, email, web_url } = result; return { authUser: { @@ -80,6 +80,7 @@ export class GitLabAuthProvider extends GenericAuthProvider { avatarUrl: avatar_url || undefined, name, primaryEmail: email, + company: web_url, }, currentScopes: this.readScopesFromVerifyParams(tokenResponse), };