Skip to content

Commit f64875a

Browse files
jeanp413iQQBotmustard-mheasyCZ
committed
[pat] add create UI
Co-authored-by: Pudong <[email protected]> Co-authored-by: Huiwen <[email protected]> Co-authored-by: Milan Pavlik <[email protected]>
1 parent 6dc123f commit f64875a

File tree

8 files changed

+274
-52
lines changed

8 files changed

+274
-52
lines changed

components/dashboard/src/App.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
settingsPathSSHKeys,
3838
usagePathMain,
3939
settingsPathPersonalAccessTokens,
40+
settingsPathPersonalAccessTokenCreate,
4041
} from "./settings/settings.routes";
4142
import {
4243
projectsPathInstallGitHubApp,
@@ -55,7 +56,7 @@ import { BlockedRepositories } from "./admin/BlockedRepositories";
5556
import { AppNotifications } from "./AppNotifications";
5657
import { publicApiTeamsToProtocol, teamsService } from "./service/public-api";
5758
import { FeatureFlagContext } from "./contexts/FeatureFlagContext";
58-
import PersonalAccessTokens from "./settings/PersonalAccessTokens";
59+
import PersonalAccessTokens, { PersonalAccessTokenCreateView } from "./settings/PersonalAccessTokens";
5960

6061
const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "./Setup"));
6162
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "./workspaces/Workspaces"));
@@ -404,6 +405,11 @@ function App() {
404405
<Route path={settingsPathVariables} exact component={EnvironmentVariables} />
405406
<Route path={settingsPathSSHKeys} exact component={SSHKeys} />
406407
<Route path={settingsPathPersonalAccessTokens} exact component={PersonalAccessTokens} />
408+
<Route
409+
path={settingsPathPersonalAccessTokenCreate}
410+
exact
411+
component={PersonalAccessTokenCreateView}
412+
/>
407413
<Route path={settingsPathPreferences} exact component={Preferences} />
408414
<Route path={projectsPathInstallGitHubApp} exact component={InstallGitHubApp} />
409415
<Route path="/from-referrer" exact component={FromReferrer} />

components/dashboard/src/components/CheckBox.tsx

+3-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ function CheckBox(props: {
1010
desc: string | React.ReactNode;
1111
checked: boolean;
1212
disabled?: boolean;
13+
className?: string;
1314
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
1415
}) {
1516
const inputProps: React.InputHTMLAttributes<HTMLInputElement> = {
@@ -20,11 +21,12 @@ function CheckBox(props: {
2021
if (props.name) {
2122
inputProps.name = props.name;
2223
}
24+
const className = props.className ?? "mt-4";
2325

2426
const checkboxId = `checkbox-${props.title}-${String(Math.random())}`;
2527

2628
return (
27-
<div className="flex mt-4 max-w-2xl">
29+
<div className={"flex max-w-2xl" + className}>
2830
<input
2931
className={
3032
"h-4 w-4 focus:ring-0 mt-1 rounded cursor-pointer bg-transparent border-2 dark:filter-invert border-gray-800 dark:border-gray-900 focus:border-gray-900 dark:focus:border-gray-800 " +
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
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 { useState } from "react";
8+
import Tooltip from "../components/Tooltip";
9+
import copy from "../images/copy.svg";
10+
import { copyToClipboard } from "../utils";
11+
12+
export function InputWithCopy(props: { value: string; tip?: string; className?: string }) {
13+
const [copied, setCopied] = useState<boolean>(false);
14+
const handleCopyToClipboard = (text: string) => {
15+
copyToClipboard(text);
16+
setCopied(true);
17+
setTimeout(() => setCopied(false), 2000);
18+
};
19+
const tip = props.tip ?? "Click to copy";
20+
return (
21+
<div className={`w-full relative ${props.className ?? ""}`}>
22+
<input
23+
disabled={true}
24+
readOnly={true}
25+
autoFocus
26+
className="w-full pr-8 overscroll-none"
27+
type="text"
28+
value={props.value}
29+
/>
30+
<div className="cursor-pointer" onClick={() => handleCopyToClipboard(props.value)}>
31+
<div className="absolute top-1/3 right-3">
32+
<Tooltip content={copied ? "Copied" : tip}>
33+
<img src={copy} alt="copy icon" title={tip} />
34+
</Tooltip>
35+
</div>
36+
</div>
37+
</div>
38+
);
39+
}

components/dashboard/src/settings/PersonalAccessTokens.tsx

+209-10
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,18 @@
66

77
import { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb";
88
import { useContext, useEffect, useState } from "react";
9-
import { Redirect } from "react-router";
9+
import { Redirect, useHistory, useLocation } from "react-router";
10+
import { Link } from "react-router-dom";
11+
import CheckBox from "../components/CheckBox";
1012
import { FeatureFlagContext } from "../contexts/FeatureFlagContext";
1113
import { personalAccessTokensService } from "../service/public-api";
1214
import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";
15+
import { settingsPathPersonalAccessTokenCreate, settingsPathPersonalAccessTokens } from "./settings.routes";
16+
import arrowDown from "../images/sort-arrow.svg";
17+
import { Timestamp } from "@bufbuild/protobuf";
18+
import Alert from "../components/Alert";
19+
import { InputWithCopy } from "../components/InputWithCopy";
20+
import { copyToClipboard } from "../utils";
1321

1422
function PersonalAccessTokens() {
1523
const { enablePersonalAccessTokens } = useContext(FeatureFlagContext);
@@ -27,31 +35,222 @@ function PersonalAccessTokens() {
2735
);
2836
}
2937

38+
interface EditPATData {
39+
name: string;
40+
expirationDays: number;
41+
expirationDate: Date;
42+
}
43+
44+
export function PersonalAccessTokenCreateView() {
45+
const { enablePersonalAccessTokens } = useContext(FeatureFlagContext);
46+
47+
const history = useHistory();
48+
const [errorMsg, setErrorMsg] = useState("");
49+
const [value, setValue] = useState<EditPATData>({
50+
name: "",
51+
expirationDays: 30,
52+
expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
53+
});
54+
55+
const update = (change: Partial<EditPATData>) => {
56+
if (change.expirationDays) {
57+
change.expirationDate = new Date(Date.now() + change.expirationDays * 24 * 60 * 60 * 1000);
58+
}
59+
setErrorMsg("");
60+
setValue({ ...value, ...change });
61+
};
62+
63+
const createToken = async () => {
64+
if (value.name.length < 3) {
65+
setErrorMsg("Token Name should have at least three characters.");
66+
return;
67+
}
68+
try {
69+
const resp = await personalAccessTokensService.createPersonalAccessToken({
70+
token: {
71+
name: value.name,
72+
expirationTime: Timestamp.fromDate(value.expirationDate),
73+
scopes: ["function:*", "resource:default"],
74+
},
75+
});
76+
history.push({
77+
pathname: settingsPathPersonalAccessTokens,
78+
state: {
79+
method: "CREATED",
80+
data: resp.token,
81+
},
82+
});
83+
} catch (e) {
84+
setErrorMsg(e.message);
85+
}
86+
};
87+
88+
if (!enablePersonalAccessTokens) {
89+
return <Redirect to="/" />;
90+
}
91+
92+
return (
93+
<div>
94+
<PageWithSettingsSubMenu title="Access Tokens" subtitle="Manage your personal access tokens.">
95+
<div className="mb-4">
96+
<Link to={settingsPathPersonalAccessTokens}>
97+
<button className="secondary">
98+
<div className="flex place-content-center">
99+
<img src={arrowDown} className="w-4 mr-2 transform rotate-90 mb-0" alt="Back arrow" />
100+
<span>Back to list</span>
101+
</div>
102+
</button>
103+
</Link>
104+
</div>
105+
<>
106+
{errorMsg.length > 0 && (
107+
<Alert type="error" className="mb-2">
108+
{errorMsg}
109+
</Alert>
110+
)}
111+
</>
112+
<div className="max-w-md mb-6">
113+
<div className="flex flex-col mb-4">
114+
<h3>New Personal Access Token</h3>
115+
<h2 className="text-gray-500">Create a new personal access token.</h2>
116+
</div>
117+
<div className="flex flex-col gap-4">
118+
<div>
119+
<h4>Token Name</h4>
120+
<input
121+
className="w-full"
122+
value={value.name}
123+
onChange={(e) => {
124+
update({ name: e.target.value });
125+
}}
126+
type="text"
127+
placeholder="Token Name"
128+
/>
129+
<p className="text-gray-500 mt-2">
130+
The application name using the token or the purpose of the token.
131+
</p>
132+
</div>
133+
<div>
134+
<h4>Expiration Date</h4>
135+
<select
136+
name="expiration"
137+
value={value.expirationDays}
138+
onChange={(e) => {
139+
update({ expirationDays: Number(e.target.value) });
140+
}}
141+
>
142+
<option value="30">30 Days</option>
143+
<option value="90">90 Days</option>
144+
<option value="180">180 Days</option>
145+
</select>
146+
<p className="text-gray-500 mt-2">
147+
The token will expire on{" "}
148+
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(value.expirationDate)}.
149+
</p>
150+
</div>
151+
<div>
152+
<h4>Permission</h4>
153+
<CheckBox
154+
className=""
155+
title="Access the user's API"
156+
desc="Grant complete read and write access to the API."
157+
checked={true}
158+
disabled={true}
159+
/>
160+
</div>
161+
</div>
162+
</div>
163+
<button onClick={createToken}>Create Personal Access Token</button>
164+
</PageWithSettingsSubMenu>
165+
</div>
166+
);
167+
}
168+
169+
interface TokenInfo {
170+
method: string;
171+
data: PersonalAccessToken;
172+
}
173+
30174
function ListAccessTokensView() {
175+
const location = useLocation();
176+
31177
const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
178+
const [tokenInfo, setTokenInfo] = useState<TokenInfo>();
32179

33180
useEffect(() => {
34181
(async () => {
35182
const response = await personalAccessTokensService.listPersonalAccessTokens({});
36183
setTokens(response.tokens);
37184
})();
38-
}, []);
185+
});
186+
187+
useEffect(() => {
188+
if (location.state) {
189+
setTokenInfo(location.state as any as TokenInfo);
190+
window.history.replaceState({}, "");
191+
}
192+
}, [location.state]);
193+
194+
const handleCopyToken = () => {
195+
copyToClipboard(tokenInfo!.data.value);
196+
};
39197

40198
return (
41199
<>
42-
<div className="flex items-start sm:justify-between mb-2">
200+
<div className="flex items-center sm:justify-between mb-2">
43201
<div>
44202
<h3>Personal Access Tokens</h3>
45203
<h2 className="text-gray-500">Create or regenerate active personal access tokens.</h2>
46204
</div>
205+
<Link to={settingsPathPersonalAccessTokenCreate}>
206+
<button>New Personal Access Token</button>
207+
</Link>
47208
</div>
48-
<div className="bg-gray-100 dark:bg-gray-800 rounded-xl w-full py-28 flex flex-col items-center">
49-
<h3 className="text-center pb-3 text-gray-500 dark:text-gray-400">No Personal Access Tokens (PAT)</h3>
50-
<p className="text-center pb-6 text-gray-500 text-base w-96">
51-
Generate a personal access token (PAT) for applications that need access to the Gitpod API.{" "}
52-
</p>
53-
<button>New Personal Access Token</button>
54-
</div>
209+
<>
210+
{tokenInfo && (
211+
<>
212+
<div className="p-4 mb-4 divide-y rounded-xl bg-gray-100 dark:bg-gray-700">
213+
<div className="pb-2">
214+
<div className="font-semibold text-gray-700 dark:text-gray-200">
215+
{tokenInfo.data.name}{" "}
216+
<span className="px-2 py-1 rounded-full text-sm text-green-600 bg-green-100">
217+
{tokenInfo.method.toUpperCase()}
218+
</span>
219+
</div>
220+
<div className="font-semibold text-gray-400 dark:text-gray-300">
221+
<span>
222+
Expires on{" "}
223+
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(
224+
tokenInfo.data.expirationTime?.toDate(),
225+
)}
226+
</span>
227+
<span></span>
228+
<span>
229+
Created on{" "}
230+
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(
231+
tokenInfo.data.createdAt?.toDate(),
232+
)}
233+
</span>
234+
</div>
235+
</div>
236+
<div className="pt-2">
237+
<div className="text-gray-600 font-semibold">Your New Personal Access Token</div>
238+
<InputWithCopy
239+
className="my-2 max-w-md"
240+
value={tokenInfo.data.value}
241+
tip="Copy Token"
242+
/>
243+
<div className="mb-2 text-gray-500 font-medium text-sm">
244+
Make sure to copy your personal access token — you won't be able to access it again.
245+
</div>
246+
<button className="secondary" onClick={handleCopyToken}>
247+
Copy Token To Clipboard
248+
</button>
249+
</div>
250+
</div>
251+
</>
252+
)}
253+
</>
55254
{tokens.length > 0 && (
56255
<ul>
57256
{tokens.map((t: PersonalAccessToken) => {

components/dashboard/src/settings/settings-menu.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
settingsPathVariables,
1818
settingsPathSSHKeys,
1919
settingsPathPersonalAccessTokens,
20+
settingsPathPersonalAccessTokenCreate,
2021
} from "./settings.routes";
2122

2223
export default function getSettingsMenu(params: {
@@ -37,7 +38,7 @@ export default function getSettingsMenu(params: {
3738
? [
3839
{
3940
title: "Access Tokens",
40-
link: [settingsPathPersonalAccessTokens],
41+
link: [settingsPathPersonalAccessTokens, settingsPathPersonalAccessTokenCreate],
4142
},
4243
]
4344
: []),

components/dashboard/src/settings/settings.routes.ts

+1
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@ export const settingsPathTeamsNew = [settingsPathTeams, "new"].join("/");
2020

2121
export const settingsPathVariables = "/variables";
2222
export const settingsPathPersonalAccessTokens = "/personal-tokens";
23+
export const settingsPathPersonalAccessTokenCreate = "/personal-tokens/create";
2324

2425
export const settingsPathSSHKeys = "/keys";

components/dashboard/src/utils.ts

+12
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,15 @@ export function inResource(pathname: string, resources: string[]): boolean {
7979
// E.g. "api/userspace/resource" path is a part of resource "api/userspace"
8080
return resources.map((res) => trimmedResource.startsWith(trimResource(res))).some(Boolean);
8181
}
82+
83+
export function copyToClipboard(text: string) {
84+
const el = document.createElement("textarea");
85+
el.value = text;
86+
document.body.appendChild(el);
87+
el.select();
88+
try {
89+
document.execCommand("copy");
90+
} finally {
91+
document.body.removeChild(el);
92+
}
93+
}

0 commit comments

Comments
 (0)