Skip to content

[public-api] Create token UI #14899

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Nov 24, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion components/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
settingsPathSSHKeys,
usagePathMain,
settingsPathPersonalAccessTokens,
settingsPathPersonalAccessTokenCreate,
} from "./settings/settings.routes";
import {
projectsPathInstallGitHubApp,
Expand All @@ -55,7 +56,7 @@ import { BlockedRepositories } from "./admin/BlockedRepositories";
import { AppNotifications } from "./AppNotifications";
import { publicApiTeamsToProtocol, teamsService } from "./service/public-api";
import { FeatureFlagContext } from "./contexts/FeatureFlagContext";
import PersonalAccessTokens from "./settings/PersonalAccessTokens";
import PersonalAccessTokens, { PersonalAccessTokenCreateView } from "./settings/PersonalAccessTokens";

const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "./Setup"));
const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "./workspaces/Workspaces"));
Expand Down Expand Up @@ -404,6 +405,11 @@ function App() {
<Route path={settingsPathVariables} exact component={EnvironmentVariables} />
<Route path={settingsPathSSHKeys} exact component={SSHKeys} />
<Route path={settingsPathPersonalAccessTokens} exact component={PersonalAccessTokens} />
<Route
path={settingsPathPersonalAccessTokenCreate}
exact
component={PersonalAccessTokenCreateView}
/>
<Route path={settingsPathPreferences} exact component={Preferences} />
<Route path={projectsPathInstallGitHubApp} exact component={InstallGitHubApp} />
<Route path="/from-referrer" exact component={FromReferrer} />
Expand Down
4 changes: 3 additions & 1 deletion components/dashboard/src/components/CheckBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ function CheckBox(props: {
desc: string | React.ReactNode;
checked: boolean;
disabled?: boolean;
className?: string;
onChange?: (e: React.ChangeEvent<HTMLInputElement>) => void;
}) {
const inputProps: React.InputHTMLAttributes<HTMLInputElement> = {
Expand All @@ -20,11 +21,12 @@ function CheckBox(props: {
if (props.name) {
inputProps.name = props.name;
}
const className = props.className ?? "mt-4";

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

return (
<div className="flex mt-4 max-w-2xl">
<div className={"flex max-w-2xl" + className}>
<input
className={
"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 " +
Expand Down
39 changes: 39 additions & 0 deletions components/dashboard/src/components/InputWithCopy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { useState } from "react";
import Tooltip from "../components/Tooltip";
import copy from "../images/copy.svg";
import { copyToClipboard } from "../utils";

export function InputWithCopy(props: { value: string; tip?: string; className?: string }) {
const [copied, setCopied] = useState<boolean>(false);
const handleCopyToClipboard = (text: string) => {
copyToClipboard(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const tip = props.tip ?? "Click to copy";
return (
<div className={`w-full relative ${props.className ?? ""}`}>
<input
disabled={true}
readOnly={true}
autoFocus
className="w-full pr-8 overscroll-none"
type="text"
value={props.value}
/>
<div className="cursor-pointer" onClick={() => handleCopyToClipboard(props.value)}>
<div className="absolute top-1/3 right-3">
<Tooltip content={copied ? "Copied" : tip}>
<img src={copy} alt="copy icon" title={tip} />
</Tooltip>
</div>
</div>
</div>
);
}
217 changes: 208 additions & 9 deletions components/dashboard/src/settings/PersonalAccessTokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@

import { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb";
import { useContext, useEffect, useState } from "react";
import { Redirect } from "react-router";
import { Redirect, useHistory, useLocation } from "react-router";
import { Link } from "react-router-dom";
import CheckBox from "../components/CheckBox";
import { FeatureFlagContext } from "../contexts/FeatureFlagContext";
import { personalAccessTokensService } from "../service/public-api";
import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";
import { settingsPathPersonalAccessTokenCreate, settingsPathPersonalAccessTokens } from "./settings.routes";
import arrowDown from "../images/sort-arrow.svg";
import { Timestamp } from "@bufbuild/protobuf";
import Alert from "../components/Alert";
import { InputWithCopy } from "../components/InputWithCopy";
import { copyToClipboard } from "../utils";

function PersonalAccessTokens() {
const { enablePersonalAccessTokens } = useContext(FeatureFlagContext);
Expand All @@ -27,8 +35,147 @@ function PersonalAccessTokens() {
);
}

interface EditPATData {
name: string;
expirationDays: number;
expirationDate: Date;
}

export function PersonalAccessTokenCreateView() {
const { enablePersonalAccessTokens } = useContext(FeatureFlagContext);

const history = useHistory();
const [errorMsg, setErrorMsg] = useState("");
const [value, setValue] = useState<EditPATData>({
name: "",
expirationDays: 30,
expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});

const update = (change: Partial<EditPATData>) => {
if (change.expirationDays) {
change.expirationDate = new Date(Date.now() + change.expirationDays * 24 * 60 * 60 * 1000);
}
setErrorMsg("");
setValue({ ...value, ...change });
};

const createToken = async () => {
if (value.name.length < 3) {
setErrorMsg("Token Name should have at least three characters.");
return;
}
try {
const resp = await personalAccessTokensService.createPersonalAccessToken({
token: {
name: value.name,
expirationTime: Timestamp.fromDate(value.expirationDate),
scopes: ["function:*", "resource:default"],
},
});
history.push({
pathname: settingsPathPersonalAccessTokens,
state: {
method: "CREATED",
data: resp.token,
},
});
} catch (e) {
setErrorMsg(e.message);
}
};

if (!enablePersonalAccessTokens) {
return <Redirect to="/" />;
}

return (
<div>
<PageWithSettingsSubMenu title="Access Tokens" subtitle="Manage your personal access tokens.">
<div className="mb-4">
<Link to={settingsPathPersonalAccessTokens}>
<button className="secondary">
<div className="flex place-content-center">
<img src={arrowDown} className="w-4 mr-2 transform rotate-90 mb-0" alt="Back arrow" />
<span>Back to list</span>
</div>
</button>
</Link>
</div>
<>
{errorMsg.length > 0 && (
<Alert type="error" className="mb-2">
{errorMsg}
</Alert>
)}
</>
<div className="max-w-md mb-6">
<div className="flex flex-col mb-4">
<h3>New Personal Access Token</h3>
<h2 className="text-gray-500">Create a new personal access token.</h2>
</div>
<div className="flex flex-col gap-4">
<div>
<h4>Token Name</h4>
<input
className="w-full"
value={value.name}
onChange={(e) => {
update({ name: e.target.value });
}}
type="text"
placeholder="Token Name"
/>
<p className="text-gray-500 mt-2">
The application name using the token or the purpose of the token.
</p>
</div>
<div>
<h4>Expiration Date</h4>
<select
name="expiration"
value={value.expirationDays}
onChange={(e) => {
update({ expirationDays: Number(e.target.value) });
}}
>
<option value="30">30 Days</option>
<option value="90">90 Days</option>
<option value="180">180 Days</option>
</select>
<p className="text-gray-500 mt-2">
The token will expire on{" "}
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(value.expirationDate)}.
</p>
</div>
<div>
<h4>Permission</h4>
<CheckBox
className=""
title="Access the user's API"
desc="Grant complete read and write access to the API."
checked={true}
disabled={true}
/>
</div>
</div>
</div>
<button onClick={createToken}>Create Personal Access Token</button>
</PageWithSettingsSubMenu>
</div>
);
}

interface TokenInfo {
method: string;
data: PersonalAccessToken;
}

function ListAccessTokensView() {
const location = useLocation();

const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);
const [tokenInfo, setTokenInfo] = useState<TokenInfo>();

useEffect(() => {
(async () => {
Expand All @@ -37,21 +184,73 @@ function ListAccessTokensView() {
})();
}, []);

useEffect(() => {
if (location.state) {
setTokenInfo(location.state as any as TokenInfo);
window.history.replaceState({}, "");
}
}, [location.state]);

const handleCopyToken = () => {
copyToClipboard(tokenInfo!.data.value);
};

return (
<>
<div className="flex items-start sm:justify-between mb-2">
<div className="flex items-center sm:justify-between mb-2">
<div>
<h3>Personal Access Tokens</h3>
<h2 className="text-gray-500">Create or regenerate active personal access tokens.</h2>
</div>
<Link to={settingsPathPersonalAccessTokenCreate}>
<button>New Personal Access Token</button>
</Link>
</div>
<div className="bg-gray-100 dark:bg-gray-800 rounded-xl w-full py-28 flex flex-col items-center">
<h3 className="text-center pb-3 text-gray-500 dark:text-gray-400">No Personal Access Tokens (PAT)</h3>
<p className="text-center pb-6 text-gray-500 text-base w-96">
Generate a personal access token (PAT) for applications that need access to the Gitpod API.{" "}
</p>
<button>New Personal Access Token</button>
</div>
<>
{tokenInfo && (
<>
<div className="p-4 mb-4 divide-y rounded-xl bg-gray-100 dark:bg-gray-700">
<div className="pb-2">
<div className="font-semibold text-gray-700 dark:text-gray-200">
{tokenInfo.data.name}{" "}
<span className="px-2 py-1 rounded-full text-sm text-green-600 bg-green-100">
{tokenInfo.method.toUpperCase()}
</span>
</div>
<div className="font-semibold text-gray-400 dark:text-gray-300">
<span>
Expires on{" "}
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(
tokenInfo.data.expirationTime?.toDate(),
)}
</span>
<span> • </span>
<span>
Created on{" "}
{Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(
tokenInfo.data.createdAt?.toDate(),
)}
</span>
</div>
</div>
<div className="pt-2">
<div className="text-gray-600 font-semibold">Your New Personal Access Token</div>
<InputWithCopy
className="my-2 max-w-md"
value={tokenInfo.data.value}
tip="Copy Token"
/>
<div className="mb-2 text-gray-500 font-medium text-sm">
Make sure to copy your personal access token — you won't be able to access it again.
</div>
<button className="secondary" onClick={handleCopyToken}>
Copy Token To Clipboard
</button>
</div>
</div>
</>
)}
</>
{tokens.length > 0 && (
<ul>
{tokens.map((t: PersonalAccessToken) => {
Expand Down
3 changes: 2 additions & 1 deletion components/dashboard/src/settings/settings-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
settingsPathVariables,
settingsPathSSHKeys,
settingsPathPersonalAccessTokens,
settingsPathPersonalAccessTokenCreate,
} from "./settings.routes";

export default function getSettingsMenu(params: {
Expand All @@ -37,7 +38,7 @@ export default function getSettingsMenu(params: {
? [
{
title: "Access Tokens",
link: [settingsPathPersonalAccessTokens],
link: [settingsPathPersonalAccessTokens, settingsPathPersonalAccessTokenCreate],
},
]
: []),
Expand Down
1 change: 1 addition & 0 deletions components/dashboard/src/settings/settings.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export const settingsPathTeamsNew = [settingsPathTeams, "new"].join("/");

export const settingsPathVariables = "/variables";
export const settingsPathPersonalAccessTokens = "/personal-tokens";
export const settingsPathPersonalAccessTokenCreate = "/personal-tokens/create";

export const settingsPathSSHKeys = "/keys";
12 changes: 12 additions & 0 deletions components/dashboard/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,15 @@ export function inResource(pathname: string, resources: string[]): boolean {
// E.g. "api/userspace/resource" path is a part of resource "api/userspace"
return resources.map((res) => trimmedResource.startsWith(trimResource(res))).some(Boolean);
}

export function copyToClipboard(text: string) {
const el = document.createElement("textarea");
el.value = text;
document.body.appendChild(el);
el.select();
try {
document.execCommand("copy");
} finally {
document.body.removeChild(el);
}
}
Loading