From d0539a6a3c3f825e6dcdaa3e3b64cd3648c041c8 Mon Sep 17 00:00:00 2001 From: Jean Pierre Date: Wed, 23 Nov 2022 17:22:10 +0000 Subject: [PATCH] [pat] add create UI Co-authored-by: Pudong Co-authored-by: Huiwen Co-authored-by: Milan Pavlik --- components/dashboard/src/App.tsx | 8 +- .../dashboard/src/components/CheckBox.tsx | 4 +- .../src/components/InputWithCopy.tsx | 39 ++++ .../src/settings/PersonalAccessTokens.tsx | 217 +++++++++++++++++- .../dashboard/src/settings/settings-menu.ts | 3 +- .../dashboard/src/settings/settings.routes.ts | 1 + components/dashboard/src/utils.ts | 12 + .../src/workspaces/ConnectToSSHModal.tsx | 40 +--- 8 files changed, 273 insertions(+), 51 deletions(-) create mode 100644 components/dashboard/src/components/InputWithCopy.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index f4859d5b623e68..f1fc62cd6bfe65 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -37,6 +37,7 @@ import { settingsPathSSHKeys, usagePathMain, settingsPathPersonalAccessTokens, + settingsPathPersonalAccessTokenCreate, } from "./settings/settings.routes"; import { projectsPathInstallGitHubApp, @@ -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")); @@ -404,6 +405,11 @@ function App() { + diff --git a/components/dashboard/src/components/CheckBox.tsx b/components/dashboard/src/components/CheckBox.tsx index 9825fb0a3dfbf6..2dd768f70718a7 100644 --- a/components/dashboard/src/components/CheckBox.tsx +++ b/components/dashboard/src/components/CheckBox.tsx @@ -10,6 +10,7 @@ function CheckBox(props: { desc: string | React.ReactNode; checked: boolean; disabled?: boolean; + className?: string; onChange?: (e: React.ChangeEvent) => void; }) { const inputProps: React.InputHTMLAttributes = { @@ -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 ( -
+
(false); + const handleCopyToClipboard = (text: string) => { + copyToClipboard(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + const tip = props.tip ?? "Click to copy"; + return ( +
+ +
handleCopyToClipboard(props.value)}> +
+ + copy icon + +
+
+
+ ); +} diff --git a/components/dashboard/src/settings/PersonalAccessTokens.tsx b/components/dashboard/src/settings/PersonalAccessTokens.tsx index 06dc0e8c422800..500bc0ad9f434c 100644 --- a/components/dashboard/src/settings/PersonalAccessTokens.tsx +++ b/components/dashboard/src/settings/PersonalAccessTokens.tsx @@ -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); @@ -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({ + name: "", + expirationDays: 30, + expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }); + + const update = (change: Partial) => { + 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 ; + } + + return ( +
+ +
+ + + +
+ <> + {errorMsg.length > 0 && ( + + {errorMsg} + + )} + +
+
+

New Personal Access Token

+

Create a new personal access token.

+
+
+
+

Token Name

+ { + update({ name: e.target.value }); + }} + type="text" + placeholder="Token Name" + /> +

+ The application name using the token or the purpose of the token. +

+
+
+

Expiration Date

+ +

+ The token will expire on{" "} + {Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(value.expirationDate)}. +

+
+
+

Permission

+ +
+
+
+ +
+
+ ); +} + +interface TokenInfo { + method: string; + data: PersonalAccessToken; +} + function ListAccessTokensView() { + const location = useLocation(); + const [tokens, setTokens] = useState([]); + const [tokenInfo, setTokenInfo] = useState(); useEffect(() => { (async () => { @@ -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 ( <> -
+

Personal Access Tokens

Create or regenerate active personal access tokens.

+ + +
-
-

No Personal Access Tokens (PAT)

-

- Generate a personal access token (PAT) for applications that need access to the Gitpod API.{" "} -

- -
+ <> + {tokenInfo && ( + <> +
+
+
+ {tokenInfo.data.name}{" "} + + {tokenInfo.method.toUpperCase()} + +
+
+ + Expires on{" "} + {Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format( + tokenInfo.data.expirationTime?.toDate(), + )} + + + + Created on{" "} + {Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format( + tokenInfo.data.createdAt?.toDate(), + )} + +
+
+
+
Your New Personal Access Token
+ +
+ Make sure to copy your personal access token — you won't be able to access it again. +
+ +
+
+ + )} + {tokens.length > 0 && (
    {tokens.map((t: PersonalAccessToken) => { diff --git a/components/dashboard/src/settings/settings-menu.ts b/components/dashboard/src/settings/settings-menu.ts index 2f80bcdaf6e5d1..9ba76643c194a7 100644 --- a/components/dashboard/src/settings/settings-menu.ts +++ b/components/dashboard/src/settings/settings-menu.ts @@ -17,6 +17,7 @@ import { settingsPathVariables, settingsPathSSHKeys, settingsPathPersonalAccessTokens, + settingsPathPersonalAccessTokenCreate, } from "./settings.routes"; export default function getSettingsMenu(params: { @@ -37,7 +38,7 @@ export default function getSettingsMenu(params: { ? [ { title: "Access Tokens", - link: [settingsPathPersonalAccessTokens], + link: [settingsPathPersonalAccessTokens, settingsPathPersonalAccessTokenCreate], }, ] : []), diff --git a/components/dashboard/src/settings/settings.routes.ts b/components/dashboard/src/settings/settings.routes.ts index d4520a8e50d557..f3e121c1536578 100644 --- a/components/dashboard/src/settings/settings.routes.ts +++ b/components/dashboard/src/settings/settings.routes.ts @@ -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"; diff --git a/components/dashboard/src/utils.ts b/components/dashboard/src/utils.ts index c29b45f501f179..955f07bf859e1a 100644 --- a/components/dashboard/src/utils.ts +++ b/components/dashboard/src/utils.ts @@ -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); + } +} diff --git a/components/dashboard/src/workspaces/ConnectToSSHModal.tsx b/components/dashboard/src/workspaces/ConnectToSSHModal.tsx index 5e0366704312c8..a7aed9c30f0c19 100644 --- a/components/dashboard/src/workspaces/ConnectToSSHModal.tsx +++ b/components/dashboard/src/workspaces/ConnectToSSHModal.tsx @@ -6,49 +6,11 @@ import { useEffect, useState } from "react"; import Modal from "../components/Modal"; -import Tooltip from "../components/Tooltip"; -import copy from "../images/copy.svg"; import Alert from "../components/Alert"; import TabMenuItem from "../components/TabMenuItem"; import { settingsPathSSHKeys } from "../settings/settings.routes"; import { getGitpodService } from "../service/service"; - -function InputWithCopy(props: { value: string; tip?: string; className?: string }) { - const [copied, setCopied] = useState(false); - const 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); - } - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }; - const tip = props.tip ?? "Click to copy"; - return ( -
    - -
    copyToClipboard(props.value)}> -
    - - copy icon - -
    -
    -
    - ); -} +import { InputWithCopy } from "../components/InputWithCopy"; interface SSHProps { workspaceId: string;