Skip to content

Commit 3f8b2ab

Browse files
jeanp413mustard-mh
andcommitted
Add Delete, Regenerate, and Update PAT UI
Co-authored-by: Huiwen <[email protected]>
1 parent 5642845 commit 3f8b2ab

File tree

5 files changed

+255
-50
lines changed

5 files changed

+255
-50
lines changed

components/dashboard/src/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
usagePathMain,
3939
settingsPathPersonalAccessTokens,
4040
settingsPathPersonalAccessTokenCreate,
41+
settingsPathPersonalAccessTokenEdit,
4142
} from "./settings/settings.routes";
4243
import {
4344
projectsPathInstallGitHubApp,
@@ -410,6 +411,11 @@ function App() {
410411
exact
411412
component={PersonalAccessTokenCreateView}
412413
/>
414+
<Route
415+
path={settingsPathPersonalAccessTokenEdit + "/:tokenId"}
416+
exact
417+
component={PersonalAccessTokenCreateView}
418+
/>
413419
<Route path={settingsPathPreferences} exact component={Preferences} />
414420
<Route path={projectsPathInstallGitHubApp} exact component={InstallGitHubApp} />
415421
<Route path="/from-referrer" exact component={FromReferrer} />

components/dashboard/src/settings/PersonalAccessTokens.tsx

Lines changed: 194 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66

77
import { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb";
88
import { useContext, useEffect, useState } from "react";
9-
import { Redirect, useHistory, useLocation } from "react-router";
9+
import { Redirect, useHistory, useLocation, useParams } from "react-router";
1010
import { Link } from "react-router-dom";
1111
import CheckBox from "../components/CheckBox";
12+
import Modal from "../components/Modal";
1213
import { FeatureFlagContext } from "../contexts/FeatureFlagContext";
1314
import { personalAccessTokensService } from "../service/public-api";
1415
import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";
@@ -42,17 +43,98 @@ interface EditPATData {
4243
expirationDate: Date;
4344
}
4445

46+
interface TokenModalProps {
47+
token: PersonalAccessToken;
48+
title: string;
49+
description: string;
50+
descriptionImportant: string;
51+
actionDescription: string;
52+
onSave?: () => void;
53+
onClose?: () => void;
54+
}
55+
56+
enum Method {
57+
Create = "CREATED",
58+
Regerenrate = "REGENERATED",
59+
}
60+
61+
export function ShowTokenModal(props: TokenModalProps) {
62+
const onEnter = () => {
63+
if (props.onSave) {
64+
props.onSave();
65+
}
66+
return true;
67+
};
68+
69+
return (
70+
<Modal
71+
title={props.title}
72+
buttons={[
73+
<button
74+
className="secondary"
75+
onClick={() => {
76+
props.onClose && props.onClose();
77+
}}
78+
>
79+
Cancel
80+
</button>,
81+
<button onClick={props.onSave}>{props.actionDescription}</button>,
82+
]}
83+
visible={true}
84+
onClose={() => {
85+
props.onClose && props.onClose();
86+
}}
87+
onEnter={onEnter}
88+
>
89+
<div className="text-gray-500 dark:text-gray-400 text-md">
90+
<span>{props.description}</span> <span className="font-semibold">{props.descriptionImportant}</span>
91+
</div>
92+
<div className="p-4 mt-2 rounded-xl bg-gray-50 dark:bg-gray-800">
93+
<div className="font-semibold text-gray-700 dark:text-gray-200">{props.token.name}</div>
94+
<div className="font-medium text-gray-400 dark:text-gray-300">
95+
Expires on{" "}
96+
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(props.token.expirationTime?.toDate())}
97+
</div>
98+
</div>
99+
</Modal>
100+
);
101+
}
102+
45103
export function PersonalAccessTokenCreateView() {
46104
const { enablePersonalAccessTokens } = useContext(FeatureFlagContext);
47105

106+
const params = useParams();
48107
const history = useHistory();
108+
109+
const [editTokenID, setEditTokenID] = useState<null | string>(null);
49110
const [errorMsg, setErrorMsg] = useState("");
50111
const [value, setValue] = useState<EditPATData>({
51112
name: "",
52113
expirationDays: 30,
53114
expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
54115
});
55116

117+
const [showModal, setShowModal] = useState<boolean>(false);
118+
const [modalData, setModalData] = useState<PersonalAccessToken>();
119+
120+
useEffect(() => {
121+
(async () => {
122+
try {
123+
const { tokenId } = params as { tokenId: string };
124+
if (!tokenId) {
125+
return;
126+
}
127+
setEditTokenID(tokenId);
128+
const resp = await personalAccessTokensService.getPersonalAccessToken({ id: tokenId });
129+
const token = resp.token;
130+
value.name = token!.name;
131+
setModalData(token!);
132+
} catch (e) {
133+
setErrorMsg(e.message);
134+
}
135+
})();
136+
}, []);
137+
56138
const update = (change: Partial<EditPATData>) => {
57139
if (change.expirationDays) {
58140
change.expirationDate = new Date(Date.now() + change.expirationDays * 24 * 60 * 60 * 1000);
@@ -61,7 +143,32 @@ export function PersonalAccessTokenCreateView() {
61143
setValue({ ...value, ...change });
62144
};
63145

146+
const regenerate = async () => {
147+
if (!editTokenID) {
148+
return;
149+
}
150+
try {
151+
const resp = await personalAccessTokensService.regeneratePersonalAccessToken({
152+
id: editTokenID,
153+
expirationTime: Timestamp.fromDate(value.expirationDate),
154+
});
155+
history.push({
156+
pathname: settingsPathPersonalAccessTokens,
157+
state: {
158+
method: Method.Regerenrate,
159+
data: resp.token,
160+
} as TokenInfo,
161+
});
162+
} catch (e) {
163+
setErrorMsg(e.message);
164+
}
165+
};
166+
64167
const createToken = async () => {
168+
if (editTokenID) {
169+
setErrorMsg("Edit token is not implemented yet");
170+
return;
171+
}
65172
if (value.name.length < 3) {
66173
setErrorMsg("Token Name should have at least three characters.");
67174
return;
@@ -77,9 +184,9 @@ export function PersonalAccessTokenCreateView() {
77184
history.push({
78185
pathname: settingsPathPersonalAccessTokens,
79186
state: {
80-
method: "CREATED",
187+
method: Method.Create,
81188
data: resp.token,
82-
},
189+
} as TokenInfo,
83190
});
84191
} catch (e) {
85192
setErrorMsg(e.message);
@@ -93,7 +200,7 @@ export function PersonalAccessTokenCreateView() {
93200
return (
94201
<div>
95202
<PageWithSettingsSubMenu title="Access Tokens" subtitle="Manage your personal access tokens.">
96-
<div className="mb-4">
203+
<div className="mb-4 flex gap-2">
97204
<Link to={settingsPathPersonalAccessTokens}>
98205
<button className="secondary">
99206
<div className="flex place-content-center">
@@ -102,6 +209,14 @@ export function PersonalAccessTokenCreateView() {
102209
</div>
103210
</button>
104211
</Link>
212+
{editTokenID && (
213+
<button
214+
className="danger bg-red-50 dark:bg-red-600 text-red-600 dark:text-red-50"
215+
onClick={() => setShowModal(true)}
216+
>
217+
Regenerate
218+
</button>
219+
)}
105220
</div>
106221
<>
107222
{errorMsg.length > 0 && (
@@ -110,10 +225,39 @@ export function PersonalAccessTokenCreateView() {
110225
</Alert>
111226
)}
112227
</>
228+
<>
229+
{showModal && (
230+
<ShowTokenModal
231+
token={modalData!}
232+
title="Regenerate Token"
233+
description="Are you sure you want to regenerate this personal access token?"
234+
descriptionImportant="Any applications using this token will no longer be able to access the Gitpod API."
235+
actionDescription="Regenerate Token"
236+
onSave={() => {
237+
regenerate();
238+
}}
239+
onClose={() => {
240+
setShowModal(false);
241+
}}
242+
/>
243+
)}
244+
</>
113245
<div className="max-w-md mb-6">
114246
<div className="flex flex-col mb-4">
115-
<h3>New Personal Access Token</h3>
116-
<h2 className="text-gray-500">Create a new personal access token.</h2>
247+
<h3>{editTokenID ? "Edit" : "New"} Personal Access Token</h3>
248+
{editTokenID ? (
249+
<>
250+
<h2 className="text-gray-500 dark:text-gray-400 dark:text-gray-400">
251+
Update token name, expiration date, permissions, or regenerate token.
252+
</h2>
253+
</>
254+
) : (
255+
<>
256+
<h2 className="text-gray-500 dark:text-gray-400">
257+
Create a new personal access token.
258+
</h2>
259+
</>
260+
)}
117261
</div>
118262
<div className="flex flex-col gap-4">
119263
<div>
@@ -127,7 +271,7 @@ export function PersonalAccessTokenCreateView() {
127271
type="text"
128272
placeholder="Token Name"
129273
/>
130-
<p className="text-gray-500 mt-2">
274+
<p className="text-gray-500 dark:text-gray-400 mt-2">
131275
The application name using the token or the purpose of the token.
132276
</p>
133277
</div>
@@ -144,7 +288,7 @@ export function PersonalAccessTokenCreateView() {
144288
<option value="90">90 Days</option>
145289
<option value="180">180 Days</option>
146290
</select>
147-
<p className="text-gray-500 mt-2">
291+
<p className="text-gray-500 dark:text-gray-400 mt-2">
148292
The token will expire on{" "}
149293
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(value.expirationDate)}.
150294
</p>
@@ -161,14 +305,23 @@ export function PersonalAccessTokenCreateView() {
161305
</div>
162306
</div>
163307
</div>
164-
<button onClick={createToken}>Create Personal Access Token</button>
308+
<div className="flex gap-2">
309+
{editTokenID && (
310+
<Link to={settingsPathPersonalAccessTokens}>
311+
<button className="secondary" onClick={createToken}>
312+
Cancel
313+
</button>
314+
</Link>
315+
)}
316+
<button onClick={createToken}>{editTokenID ? "Edit" : "Create"} Personal Access Token</button>
317+
</div>
165318
</PageWithSettingsSubMenu>
166319
</div>
167320
);
168321
}
169322

170323
interface TokenInfo {
171-
method: string;
324+
method: Method;
172325
data: PersonalAccessToken;
173326
}
174327

@@ -178,11 +331,13 @@ function ListAccessTokensView() {
178331
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
179332
const [tokenInfo, setTokenInfo] = useState<TokenInfo>();
180333

334+
async function loadTokens() {
335+
const response = await personalAccessTokensService.listPersonalAccessTokens({});
336+
setTokens(response.tokens);
337+
}
338+
181339
useEffect(() => {
182-
(async () => {
183-
const response = await personalAccessTokensService.listPersonalAccessTokens({});
184-
setTokens(response.tokens);
185-
})();
340+
loadTokens();
186341
}, []);
187342

188343
useEffect(() => {
@@ -196,6 +351,13 @@ function ListAccessTokensView() {
196351
copyToClipboard(tokenInfo!.data.value);
197352
};
198353

354+
const handleDeleteToken = (tokenId: string) => {
355+
if (tokenId === tokenInfo?.data.id) {
356+
setTokenInfo(undefined);
357+
}
358+
loadTokens();
359+
};
360+
199361
return (
200362
<>
201363
<div className="flex items-center sm:justify-between mb-4">
@@ -212,15 +374,22 @@ function ListAccessTokensView() {
212374
<>
213375
{tokenInfo && (
214376
<>
215-
<div className="p-4 mb-4 divide-y rounded-xl bg-gray-100 dark:bg-gray-700">
377+
<div className="p-4 mb-4 divide-y rounded-xl bg-gray-50 dark:bg-gray-800">
216378
<div className="pb-2">
217-
<div className="font-semibold text-gray-700 dark:text-gray-200">
218-
{tokenInfo.data.name}{" "}
219-
<span className="px-2 py-1 rounded-full text-sm text-green-600 bg-green-100">
379+
<div className="flex gap-2 content-center font-semibold text-gray-700 dark:text-gray-200">
380+
<span className="ml-1">{tokenInfo.data.name}</span>
381+
<span
382+
className={
383+
"font-medium px-1 py-1 rounded-full text-xs" +
384+
(tokenInfo.method === Method.Create
385+
? " text-green-600 bg-green-100"
386+
: " text-blue-600 bg-blue-100")
387+
}
388+
>
220389
{tokenInfo.method.toUpperCase()}
221390
</span>
222391
</div>
223-
<div className="font-semibold text-gray-400 dark:text-gray-300">
392+
<div className="font-medium text-gray-400 dark:text-gray-300">
224393
<span>
225394
Expires on{" "}
226395
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(
@@ -237,13 +406,15 @@ function ListAccessTokensView() {
237406
</div>
238407
</div>
239408
<div className="pt-2">
240-
<div className="text-gray-600 font-semibold">Your New Personal Access Token</div>
409+
<div className="font-semibold text-gray-600 dark:text-gray-200">
410+
Your New Personal Access Token
411+
</div>
241412
<InputWithCopy
242413
className="my-2 max-w-md"
243414
value={tokenInfo.data.value}
244415
tip="Copy Token"
245416
/>
246-
<div className="mb-2 text-gray-500 font-medium text-sm">
417+
<div className="mb-2 font-medium text-sm text-gray-500 dark:text-gray-300">
247418
Make sure to copy your personal access token — you won't be able to access it again.
248419
</div>
249420
<button className="secondary" onClick={handleCopyToken}>
@@ -268,14 +439,14 @@ function ListAccessTokensView() {
268439
</div>
269440
) : (
270441
<>
271-
<div className="px-6 py-3 flex justify-between space-x-2 text-sm text-gray-400 mb-2 bg-gray-100 rounded-xl">
442+
<div className="px-6 py-3 flex justify-between space-x-2 text-sm text-gray-400 mb-2 bg-gray-100 dark:bg-gray-800 rounded-xl">
272443
<h2 className="w-3/12">Token Name</h2>
273444
<h2 className="w-3/12">Permissions</h2>
274445
<h2 className="w-3/12">Expires</h2>
275446
<div className="w-3/12"></div>
276447
</div>
277448
{tokens.map((t: PersonalAccessToken) => {
278-
return <TokenEntry token={t} />;
449+
return <TokenEntry token={t} onDelete={handleDeleteToken} />;
279450
})}
280451
</>
281452
)}

0 commit comments

Comments
 (0)