Skip to content

Commit a81ea7c

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

File tree

12 files changed

+296
-46
lines changed

12 files changed

+296
-46
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/Alert.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ export default function Alert(props: AlertProps) {
7575
const showIcon = props.showIcon ?? true;
7676
const light = props.light ?? false;
7777
return (
78-
<div className={`flex relative rounded p-4 ${info.txtCls} ${props.className || ""} ${light ? "" : info.bgCls}`}>
78+
<div
79+
className={`flex items-center relative rounded p-4 ${info.txtCls} ${props.className || ""} ${
80+
light ? "" : info.bgCls
81+
}`}
82+
>
7983
{showIcon && <span className={`mt-1 mr-4 h-4 w-4 ${info.iconColor}`}>{props.icon ?? info.icon}</span>}
8084
<span className="flex-1 text-left">{props.children}</span>
8185
{props.closable && (

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: 39 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,31 @@ 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);
2327

24-
const primaryEmail = User.getPrimaryEmail(user!) || "---";
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+
};
2539

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

0 commit comments

Comments
 (0)