Skip to content

Commit 04c5c35

Browse files
committed
[dashboard] allow editing user information
fixes #10999
1 parent ba78bd4 commit 04c5c35

File tree

11 files changed

+284
-45
lines changed

11 files changed

+284
-45
lines changed

components/dashboard/src/Menu.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -342,9 +342,9 @@ export default function Menu() {
342342
>
343343
<path
344344
fill="currentColor"
345-
fill-rule="evenodd"
345+
fillRule="evenodd"
346346
d="M7 0a1 1 0 011 1v5h5a1 1 0 110 2H8v5a1 1 0 11-2 0V8H1a1 1 0 010-2h5V1a1 1 0 011-1z"
347-
clip-rule="evenodd"
347+
clipRule="evenodd"
348348
/>
349349
</svg>
350350
</div>
@@ -356,8 +356,8 @@ export default function Menu() {
356356
<div className="flex h-full pl-0 pr-1 py-1.5 text-gray-50">
357357
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg">
358358
<path
359-
fill-rule="evenodd"
360-
clip-rule="evenodd"
359+
fillRule="evenodd"
360+
clipRule="evenodd"
361361
d="M5.293 7.293a1 1 0 0 1 1.414 0L10 10.586l3.293-3.293a1 1 0 1 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 0-1.414Z"
362362
fill="#78716C"
363363
/>
@@ -385,8 +385,8 @@ export default function Menu() {
385385
<div className="flex pl-0 pr-1 py-1.5">
386386
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg">
387387
<path
388-
fill-rule="evenodd"
389-
clip-rule="evenodd"
388+
fillRule="evenodd"
389+
clipRule="evenodd"
390390
d="M7.293 14.707a1 1 0 0 1 0-1.414L10.586 10 7.293 6.707a1 1 0 1 1 1.414-1.414l4 4a1 1 0 0 1 0 1.414l-4 4a1 1 0 0 1-1.414 0Z"
391391
fill="#78716C"
392392
/>

components/dashboard/src/components/ConfirmationModal.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,27 +19,31 @@ export default function ConfirmationModal(props: {
1919
onClose: () => void;
2020
onConfirm: () => void;
2121
}) {
22-
const child: React.ReactChild[] = [<p className="mt-3 mb-3 text-base text-gray-500">{props.areYouSureText}</p>];
22+
const children: React.ReactChild[] = [
23+
<p key="areYouSure" className="mt-3 mb-3 text-base text-gray-500">
24+
{props.areYouSureText}
25+
</p>,
26+
];
2327

2428
if (props.warningText) {
25-
child.unshift(<AlertBox>{props.warningText}</AlertBox>);
29+
children.unshift(<AlertBox>{props.warningText}</AlertBox>);
2630
}
2731

2832
const isEntity = (x: any): x is Entity => typeof x === "object" && "name" in x;
2933
if (props.children) {
3034
if (isEntity(props.children)) {
31-
child.push(
32-
<div className="w-full p-4 mb-2 bg-gray-100 dark:bg-gray-700 rounded-xl group">
35+
children.push(
36+
<div key="entity" className="w-full p-4 mb-2 bg-gray-100 dark:bg-gray-700 rounded-xl group">
3337
<p className="text-base text-gray-800 dark:text-gray-100 font-semibold">{props.children.name}</p>
3438
{props.children.description && (
3539
<p className="text-gray-500 truncate">{props.children.description}</p>
3640
)}
3741
</div>,
3842
);
3943
} else if (Array.isArray(props.children)) {
40-
child.push(...props.children);
44+
children.push(...props.children);
4145
} else {
42-
child.push(props.children);
46+
children.push(props.children);
4347
}
4448
}
4549
const cancelButtonRef = useRef<HTMLButtonElement>(null);
@@ -76,7 +80,7 @@ export default function ConfirmationModal(props: {
7680
return true;
7781
}}
7882
>
79-
{child}
83+
{children}
8084
</Modal>
8185
);
8286
}

components/dashboard/src/settings/Account.tsx

Lines changed: 46 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,37 @@ import { getGitpodService, gitpodHostUrl } from "../service/service";
1111
import { UserContext } from "../user-context";
1212
import getSettingsMenu from "./settings-menu";
1313
import ConfirmationModal from "../components/ConfirmationModal";
14-
import CodeText from "../components/CodeText";
1514
import { PaymentContext } from "../payment-context";
15+
import ProfileInformation, { ProfileState } from "./ProfileInformation";
1616

1717
export default function Account() {
18-
const { user } = useContext(UserContext);
18+
const { user, setUser } = useContext(UserContext);
1919
const { showPaymentUI, showUsageBasedUI } = useContext(PaymentContext);
20-
2120
const [modal, setModal] = useState(false);
21+
const primaryEmail = User.getPrimaryEmail(user!) || "";
2222
const [typedEmail, setTypedEmail] = useState("");
23+
const original = ProfileState.getProfileState(user!);
24+
const [profileState, setProfileState] = useState(original);
25+
const [errorMessage, setErrorMessage] = useState("");
26+
const [updated, setUpdated] = useState(false);
27+
28+
const saveProfileState = () => {
29+
const error = ProfileState.validate(profileState);
30+
setErrorMessage(error);
31+
if (error) {
32+
return;
33+
}
34+
const updatedUser = ProfileState.setProfileState(user!, profileState);
35+
setUser(updatedUser);
36+
getGitpodService().server.updateLoggedInUser(updatedUser);
37+
setUpdated(true);
38+
setTimeout(() => setUpdated(false), 5000);
39+
};
2340

24-
const primaryEmail = User.getPrimaryEmail(user!) || "---";
41+
const cancelProfileUpdate = () => {
42+
setProfileState(original);
43+
setErrorMessage("");
44+
};
2545

2646
const deleteAccount = async () => {
2747
await getGitpodService().server.deleteAccount();
@@ -60,31 +80,29 @@ export default function Account() {
6080
title="Account"
6181
subtitle="Manage account and Git configuration."
6282
>
63-
<h3>Profile</h3>
64-
<p className="text-base text-gray-500 pb-4 max-w-2xl">
65-
The following information will be used to set up Git configuration. You can override Git author name
66-
and email per project by using the default environment variables{" "}
67-
<CodeText>GIT_AUTHOR_NAME</CodeText>, <CodeText>GIT_COMMITTER_NAME</CodeText>,{" "}
68-
<CodeText>GIT_AUTHOR_EMAIL</CodeText> and <CodeText>GIT_COMMITTER_EMAIL</CodeText>.
69-
</p>
70-
<div className="flex flex-col lg:flex-row">
71-
<div>
72-
<div className="mt-4">
73-
<h4>Name</h4>
74-
<input type="text" disabled={true} value={user?.fullName || user?.name} />
75-
</div>
76-
<div className="mt-4">
77-
<h4>Email</h4>
78-
<input type="text" disabled={true} value={primaryEmail} />
79-
</div>
80-
</div>
81-
<div className="lg:pl-14">
82-
<div className="mt-4">
83-
<h4>Avatar</h4>
84-
<img className="rounded-full w-24 h-24" src={user!.avatarUrl} alt={user!.name} />
85-
</div>
83+
<ProfileInformation
84+
profileState={profileState}
85+
setProfileState={setProfileState}
86+
errorMessage={errorMessage}
87+
updated={updated}
88+
>
89+
<div className="flex flex-row mt-4">
90+
<button
91+
className="primary"
92+
disabled={!ProfileState.hasChanges(original, profileState)}
93+
onClick={saveProfileState}
94+
>
95+
Save
96+
</button>
97+
<button
98+
className="secondary ml-4"
99+
disabled={!ProfileState.hasChanges(original, profileState)}
100+
onClick={cancelProfileUpdate}
101+
>
102+
Cancel
103+
</button>
86104
</div>
87-
</div>
105+
</ProfileInformation>
88106
<h3 className="mt-12">Delete Account</h3>
89107
<p className="text-base text-gray-500 pb-4">
90108
This action will remove all the data associated with your account in Gitpod.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
/**
2+
* Copyright (c) 2021 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 { User } from "@gitpod/gitpod-protocol";
8+
import { hoursBefore, isDateSmaller } from "@gitpod/gitpod-protocol/lib/util/timeutil";
9+
import React, { useContext, useState } from "react";
10+
import CodeText from "../components/CodeText";
11+
import Modal from "../components/Modal";
12+
import { getGitpodService } from "../service/service";
13+
import { UserContext } from "../user-context";
14+
15+
export namespace ProfileState {
16+
export interface ProfileState {
17+
name: string;
18+
email: string;
19+
company?: string;
20+
avatarURL?: string;
21+
}
22+
23+
export function getProfileState(user: User): ProfileState {
24+
return {
25+
name: User.getName(user!) || "",
26+
email: User.getPrimaryEmail(user!) || "",
27+
company: user?.additionalData?.companyName,
28+
avatarURL: user?.avatarUrl,
29+
};
30+
}
31+
32+
export function setProfileState(user: User, profileState: ProfileState): User {
33+
user.fullName = profileState.name;
34+
user.avatarUrl = profileState.avatarURL;
35+
36+
if (!user.additionalData) {
37+
user.additionalData = {};
38+
}
39+
user.additionalData.emailAddress = profileState.email;
40+
user.additionalData.companyName = profileState.company;
41+
user.additionalData.lastUpdatedDetailsNudge = new Date().toISOString();
42+
43+
return user;
44+
}
45+
46+
export function hasChanges(before: ProfileState, after: ProfileState) {
47+
return (
48+
before.name !== after.name ||
49+
before.email !== after.email ||
50+
before.company !== after.company ||
51+
before.avatarURL !== after.avatarURL
52+
);
53+
}
54+
55+
function shouldNudgeForUpdate(user: User): boolean {
56+
if (!user.additionalData) {
57+
// never updated profile information and account is older tha 24 hours (i.e. ask on second day).
58+
return !isDateSmaller(hoursBefore(new Date().toISOString(), 24), user.creationDate);
59+
}
60+
// if the profile wasn't updated for 12 months ask again.
61+
return !(
62+
!!user.additionalData.lastUpdatedDetailsNudge &&
63+
isDateSmaller(hoursBefore(new Date().toISOString(), 24 * 365), user.additionalData.lastUpdatedDetailsNudge)
64+
);
65+
}
66+
67+
/**
68+
* @param state
69+
* @returns error message or empty string when valid
70+
*/
71+
export function validate(state: ProfileState): string {
72+
if (state.name.trim() === "") {
73+
return "Name must not be empty.";
74+
}
75+
if (state.email.trim() === "") {
76+
return "Email must not be empty.";
77+
}
78+
if (!/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(state.email.trim())) {
79+
return "Please enter a valid email.";
80+
}
81+
return "";
82+
}
83+
84+
export function NudgeForProfileUpdateModal() {
85+
const { user, setUser } = useContext(UserContext);
86+
const original = ProfileState.getProfileState(user!);
87+
const [profileState, setProfileState] = useState(original);
88+
const [errorMessage, setErrorMessage] = useState("");
89+
const [visible, setVisible] = useState(shouldNudgeForUpdate(user!));
90+
91+
const saveProfileState = () => {
92+
const error = ProfileState.validate(profileState);
93+
setErrorMessage(error);
94+
if (error) {
95+
return;
96+
}
97+
const updatedUser = ProfileState.setProfileState(user!, profileState);
98+
setUser(updatedUser);
99+
getGitpodService().server.updateLoggedInUser(updatedUser);
100+
setVisible(shouldNudgeForUpdate(updatedUser!));
101+
};
102+
103+
const cancelProfileUpdate = () => {
104+
setProfileState(original);
105+
saveProfileState();
106+
};
107+
108+
return (
109+
<Modal
110+
title="Update Your Profile Information"
111+
visible={visible}
112+
onClose={cancelProfileUpdate}
113+
closeable={true}
114+
className="_max-w-xl"
115+
buttons={
116+
<div>
117+
<button className="secondary" onClick={cancelProfileUpdate}>
118+
Cancel
119+
</button>
120+
<button onClick={saveProfileState}>Save</button>
121+
</div>
122+
}
123+
>
124+
<ProfileInformation
125+
profileState={profileState}
126+
errorMessage={errorMessage}
127+
updated={false}
128+
setProfileState={setProfileState}
129+
/>
130+
</Modal>
131+
);
132+
}
133+
}
134+
135+
export default function ProfileInformation(props: {
136+
profileState: ProfileState.ProfileState;
137+
setProfileState: (newState: ProfileState.ProfileState) => void;
138+
errorMessage: string;
139+
updated: boolean;
140+
children?: React.ReactChild[] | React.ReactChild;
141+
}) {
142+
return (
143+
<div>
144+
<h3>Profile</h3>
145+
<p className="text-base text-gray-500 pb-4 max-w-2xl">
146+
The following information will be used to set up Git configuration. You can override Git author name and
147+
email per project by using the default environment variables <CodeText>GIT_AUTHOR_NAME</CodeText>,{" "}
148+
<CodeText>GIT_COMMITTER_NAME</CodeText>, <CodeText>GIT_AUTHOR_EMAIL</CodeText> and{" "}
149+
<CodeText>GIT_COMMITTER_EMAIL</CodeText>.
150+
</p>
151+
{props.errorMessage.length > 0 ? (
152+
<div className="dark:bg-gray-800 bg-gitpod-kumquat-light rounded-md p-3 text-gitpod-red text-sm mb-2">
153+
{props.errorMessage}
154+
</div>
155+
) : null}
156+
{props.updated ? (
157+
<div className="dark:bg-gray-800 bg-gray-300 rounded-md p-3 text-gray-700 dark:text-gray-200 text-sm mb-2">
158+
Profile information has been updated.
159+
</div>
160+
) : null}
161+
<div className="flex flex-col lg:flex-row">
162+
<div>
163+
<div className="mt-4">
164+
<h4>Name</h4>
165+
<input
166+
type="text"
167+
value={props.profileState.name}
168+
onChange={(e) => props.setProfileState({ ...props.profileState, name: e.target.value })}
169+
/>
170+
</div>
171+
<div className="mt-4">
172+
<h4>Email</h4>
173+
<input
174+
type="text"
175+
value={props.profileState.email}
176+
onChange={(e) => props.setProfileState({ ...props.profileState, email: e.target.value })}
177+
/>
178+
</div>
179+
<div className="mt-4">
180+
<h4>Company</h4>
181+
<input
182+
type="text"
183+
value={props.profileState.company}
184+
onChange={(e) => props.setProfileState({ ...props.profileState, company: e.target.value })}
185+
/>
186+
</div>
187+
</div>
188+
<div className="lg:pl-14">
189+
<div className="mt-4">
190+
<h4>Avatar</h4>
191+
<img
192+
className="rounded-full w-24 h-24"
193+
src={props.profileState.avatarURL}
194+
alt={props.profileState.name}
195+
/>
196+
</div>
197+
</div>
198+
</div>
199+
{props.children || null}
200+
</div>
201+
);
202+
}

components/dashboard/src/workspaces/Workspaces.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { StartWorkspaceModalContext, StartWorkspaceModalKeyBinding } from "./sta
2020
import SelectIDEModal from "../settings/SelectIDEModal";
2121
import Arrow from "../components/Arrow";
2222
import ConfirmationModal from "../components/ConfirmationModal";
23+
import { ProfileState } from "../settings/ProfileInformation";
2324

2425
export interface WorkspacesProps {}
2526

@@ -69,6 +70,7 @@ export default function () {
6970
></ConfirmationModal>
7071

7172
{isOnboardingUser && <SelectIDEModal location={"workspace_list"} />}
73+
{<ProfileState.NudgeForProfileUpdateModal />}
7274

7375
{workspaceModel?.initialized &&
7476
(activeWorkspaces.length > 0 || inactiveWorkspaces.length > 0 || workspaceModel.searchTerm ? (

0 commit comments

Comments
 (0)