Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/dashboard/src/user-settings/PersonalAccessTokensCreateView.tsx
2500 views
1
/**
2
* Copyright (c) 2022 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 { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb";
8
import { useEffect, useMemo, useState } from "react";
9
import { useHistory, useParams } from "react-router";
10
import Alert from "../components/Alert";
11
import DateSelector from "../components/DateSelector";
12
import { SpinnerOverlayLoader } from "../components/Loader";
13
import { personalAccessTokensService } from "../service/public-api";
14
import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";
15
import {
16
AllPermissions,
17
TokenAction,
18
getTokenExpirationDays,
19
TokenInfo,
20
getTokenExpirationDescription,
21
} from "./PersonalAccessTokens";
22
import { settingsPathPersonalAccessTokens } from "./settings.routes";
23
import ShowTokenModal from "./ShowTokenModal";
24
import { Timestamp } from "@bufbuild/protobuf";
25
import arrowDown from "../images/sort-arrow.svg";
26
import { Heading2, Subheading } from "../components/typography/headings";
27
import { useIsDataOps } from "../data/featureflag-query";
28
import { LinkButton } from "@podkit/buttons/LinkButton";
29
import { Button } from "@podkit/buttons/Button";
30
import { TextInputField } from "../components/forms/TextInputField";
31
32
interface EditPATData {
33
name: string;
34
expirationValue: string;
35
expirationDate: Date;
36
scopes: Set<string>;
37
}
38
39
const personalAccessTokenNameRegex = /^[a-zA-Z0-9-_ ]{3,63}$/;
40
41
function PersonalAccessTokenCreateView() {
42
const params = useParams<{ tokenId?: string }>();
43
const history = useHistory<TokenInfo>();
44
45
const [loading, setLoading] = useState(false);
46
const [errorMsg, setErrorMsg] = useState("");
47
const [editToken, setEditToken] = useState<PersonalAccessToken>();
48
const [token, setToken] = useState<EditPATData>({
49
name: "",
50
expirationValue: "30 Days",
51
expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // default option 30 days
52
scopes: new Set<string>(AllPermissions[0].scopes), // default to all permissions
53
});
54
const [modalData, setModalData] = useState<{ token: PersonalAccessToken }>();
55
56
const isEditing = !!params.tokenId;
57
58
function backToListView(tokenInfo?: TokenInfo) {
59
history.push({
60
pathname: settingsPathPersonalAccessTokens,
61
state: tokenInfo,
62
});
63
}
64
65
const isDataOps = useIsDataOps();
66
const TokenExpirationDays = useMemo(() => getTokenExpirationDays(isDataOps), [isDataOps]);
67
68
useEffect(() => {
69
(async () => {
70
try {
71
const { tokenId } = params;
72
if (!tokenId) {
73
return;
74
}
75
76
setLoading(true);
77
const resp = await personalAccessTokensService.getPersonalAccessToken({ id: tokenId });
78
const token = resp.token!;
79
setEditToken(token);
80
update({
81
name: token.name,
82
scopes: new Set(token.scopes),
83
});
84
} catch (e) {
85
setErrorMsg(e.message);
86
}
87
setLoading(false);
88
})();
89
// eslint-disable-next-line react-hooks/exhaustive-deps
90
}, []);
91
92
const update = (change: Partial<EditPATData>, addScopes?: string[], removeScopes?: string[]) => {
93
if (change.expirationValue) {
94
const found = TokenExpirationDays.find((e) => e.value === change.expirationValue);
95
change.expirationDate = found?.getDate();
96
}
97
const data = { ...token, ...change };
98
if (addScopes) {
99
addScopes.forEach((s) => data.scopes.add(s));
100
}
101
if (removeScopes) {
102
removeScopes.forEach((s) => data.scopes.delete(s));
103
}
104
setErrorMsg("");
105
setToken(data);
106
};
107
108
const handleRegenerate = async (tokenId: string, expirationDate: Date) => {
109
try {
110
const resp = await personalAccessTokensService.regeneratePersonalAccessToken({
111
id: tokenId,
112
expirationTime: Timestamp.fromDate(expirationDate),
113
});
114
backToListView({ method: TokenAction.Regenerate, data: resp.token! });
115
} catch (e) {
116
setErrorMsg(e.message);
117
}
118
};
119
120
const handleConfirm = async () => {
121
if (/^\s+/.test(token.name) || /\s+$/.test(token.name)) {
122
setErrorMsg("Token name should not start or end with a space");
123
return;
124
}
125
if (!personalAccessTokenNameRegex.test(token.name)) {
126
setErrorMsg(
127
"Token name should have a length between 3 and 63 characters, it can only contain letters, numbers, underscore and space characters",
128
);
129
return;
130
}
131
try {
132
const resp = editToken
133
? await personalAccessTokensService.updatePersonalAccessToken({
134
token: {
135
id: editToken.id,
136
name: token.name,
137
scopes: Array.from(token.scopes),
138
},
139
updateMask: { paths: ["name", "scopes"] },
140
})
141
: await personalAccessTokensService.createPersonalAccessToken({
142
token: {
143
name: token.name,
144
expirationTime: Timestamp.fromDate(token.expirationDate),
145
scopes: Array.from(token.scopes),
146
},
147
});
148
149
backToListView(isEditing ? undefined : { method: TokenAction.Create, data: resp.token! });
150
} catch (e) {
151
setErrorMsg(e.message);
152
}
153
};
154
155
return (
156
<div>
157
<PageWithSettingsSubMenu>
158
<div className="mb-4 flex gap-2">
159
<LinkButton variant="secondary" href={settingsPathPersonalAccessTokens}>
160
<img src={arrowDown} className="w-4 mr-2 transform rotate-90 mb-0" alt="Back arrow" />
161
<span>Back to list</span>
162
</LinkButton>
163
{editToken && (
164
<Button variant="destructive" onClick={() => setModalData({ token: editToken })}>
165
Regenerate
166
</Button>
167
)}
168
</div>
169
{errorMsg.length > 0 && (
170
<Alert type="error" className="mb-2 max-w-md">
171
{errorMsg}
172
</Alert>
173
)}
174
{!editToken && (
175
<Alert type={"warning"} closable={false} showIcon={true} className="my-4 max-w-lg">
176
This token will have complete read / write access to the API. Use it responsibly and revoke it
177
if necessary.
178
</Alert>
179
)}
180
{modalData && (
181
<ShowTokenModal
182
token={modalData.token}
183
title="Regenerate Token"
184
description="Are you sure you want to regenerate this access token?"
185
descriptionImportant="Any applications using this token will no longer be able to access the Gitpod API."
186
actionDescription="Regenerate Token"
187
showDateSelector
188
onSave={({ expirationDate }) => handleRegenerate(modalData.token.id, expirationDate)}
189
onClose={() => setModalData(undefined)}
190
/>
191
)}
192
<SpinnerOverlayLoader content="loading access token" loading={loading}>
193
<div className="mb-6">
194
<div className="flex flex-col mb-4">
195
<Heading2>{isEditing ? "Edit" : "New"} Access Token</Heading2>
196
{isEditing ? (
197
<Subheading>
198
Update token name, expiration date, permissions, or regenerate token.
199
</Subheading>
200
) : (
201
<Subheading>Create a new access token.</Subheading>
202
)}
203
</div>
204
<div className="flex flex-col gap-4">
205
<TextInputField
206
label="Token Name"
207
placeholder="Token Name"
208
hint="The application name using the token or the purpose of the token."
209
value={token.name}
210
type="text"
211
className="max-w-md"
212
onChange={(val) => update({ name: val })}
213
onKeyDown={(e) => {
214
if (e.key === "Enter") {
215
e.preventDefault();
216
handleConfirm();
217
}
218
}}
219
/>
220
221
{!isEditing && (
222
<DateSelector
223
title="Expiration Date"
224
description={getTokenExpirationDescription(token.expirationDate)}
225
options={TokenExpirationDays}
226
value={TokenExpirationDays.find((i) => i.value === token.expirationValue)?.value}
227
onChange={(value) => {
228
update({ expirationValue: value });
229
}}
230
/>
231
)}
232
</div>
233
</div>
234
<div className="flex gap-2">
235
{isEditing && (
236
<LinkButton variant="secondary" href={settingsPathPersonalAccessTokens}>
237
Cancel
238
</LinkButton>
239
)}
240
<Button onClick={handleConfirm} disabled={isEditing && !editToken}>
241
{isEditing ? "Update" : "Create"} Access Token
242
</Button>
243
</div>
244
</SpinnerOverlayLoader>
245
</PageWithSettingsSubMenu>
246
</div>
247
);
248
}
249
250
export default PersonalAccessTokenCreateView;
251
252