From 5507be9bd20251ab190f2aaed7da988b6a1a8376 Mon Sep 17 00:00:00 2001 From: "Laurie T. Malau" Date: Mon, 5 Dec 2022 12:32:43 +0000 Subject: [PATCH] [pat] Extract into separate components --- components/dashboard/src/App.tsx | 2 +- .../dashboard/src/components/DateSelector.tsx | 33 ++ .../src/settings/PersonalAccessTokens.tsx | 425 +----------------- .../PersonalAccessTokensCreateView.tsx | 276 ++++++++++++ .../dashboard/src/settings/ShowTokenModal.tsx | 82 ++++ .../dashboard/src/settings/TokenEntry.tsx | 58 +++ 6 files changed, 464 insertions(+), 412 deletions(-) create mode 100644 components/dashboard/src/components/DateSelector.tsx create mode 100644 components/dashboard/src/settings/PersonalAccessTokensCreateView.tsx create mode 100644 components/dashboard/src/settings/ShowTokenModal.tsx create mode 100644 components/dashboard/src/settings/TokenEntry.tsx diff --git a/components/dashboard/src/App.tsx b/components/dashboard/src/App.tsx index 4a8d0835bc8a0d..c4fce562bcd709 100644 --- a/components/dashboard/src/App.tsx +++ b/components/dashboard/src/App.tsx @@ -57,7 +57,7 @@ import { BlockedRepositories } from "./admin/BlockedRepositories"; import { AppNotifications } from "./AppNotifications"; import { publicApiTeamsToProtocol, teamsService } from "./service/public-api"; import { FeatureFlagContext } from "./contexts/FeatureFlagContext"; -import { PersonalAccessTokenCreateView } from "./settings/PersonalAccessTokens"; +import PersonalAccessTokenCreateView from "./settings/PersonalAccessTokensCreateView"; const Setup = React.lazy(() => import(/* webpackPrefetch: true */ "./Setup")); const Workspaces = React.lazy(() => import(/* webpackPrefetch: true */ "./workspaces/Workspaces")); diff --git a/components/dashboard/src/components/DateSelector.tsx b/components/dashboard/src/components/DateSelector.tsx new file mode 100644 index 00000000000000..72cbb62922915f --- /dev/null +++ b/components/dashboard/src/components/DateSelector.tsx @@ -0,0 +1,33 @@ +/** + * 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. + */ + +interface DateSelectorProps { + title: string; + description: string; + options: { value: string; label: string }[]; + value?: string; + onChange: (value: string) => void; +} + +function DateSelector(props: DateSelectorProps) { + return ( +
+ + +

{props.description}

+
+ ); +} + +export default DateSelector; diff --git a/components/dashboard/src/settings/PersonalAccessTokens.tsx b/components/dashboard/src/settings/PersonalAccessTokens.tsx index c02f94e47211a6..2d7df5167c4af9 100644 --- a/components/dashboard/src/settings/PersonalAccessTokens.tsx +++ b/components/dashboard/src/settings/PersonalAccessTokens.tsx @@ -6,29 +6,21 @@ import { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb"; import { useContext, useEffect, useState } from "react"; -import { Redirect, useHistory, useLocation, useParams } from "react-router"; +import { Redirect, useLocation } from "react-router"; import { Link } from "react-router-dom"; -import CheckBox from "../components/CheckBox"; -import Modal from "../components/Modal"; import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; import { personalAccessTokensService } from "../service/public-api"; import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; -import { - settingsPathPersonalAccessTokenCreate, - settingsPathPersonalAccessTokens, - settingsPathPersonalAccessTokenEdit, -} from "./settings.routes"; -import arrowDown from "../images/sort-arrow.svg"; -import { ReactComponent as ExclamationIcon } from "../images/exclamation.svg"; +import { settingsPathPersonalAccessTokenCreate, settingsPathPersonalAccessTokenEdit } from "./settings.routes"; import { Timestamp } from "@bufbuild/protobuf"; import Alert from "../components/Alert"; import { InputWithCopy } from "../components/InputWithCopy"; import { copyToClipboard } from "../utils"; -import { ContextMenuEntry } from "../components/ContextMenu"; import PillLabel from "../components/PillLabel"; import dayjs from "dayjs"; -import { ItemFieldContextMenu } from "../components/ItemsList"; -import { SpinnerLoader, SpinnerOverlayLoader } from "../components/Loader"; +import { SpinnerLoader } from "../components/Loader"; +import TokenEntry from "./TokenEntry"; +import ShowTokenModal from "./ShowTokenModal"; export default function PersonalAccessTokens() { const { enablePersonalAccessTokens } = useContext(FeatureFlagContext); @@ -46,28 +38,20 @@ export default function PersonalAccessTokens() { ); } -const personalAccessTokenNameRegex = /^[a-zA-Z0-9-_ ]{3,63}$/; - -enum TokenAction { +export enum TokenAction { Create = "CREATED", Regenerate = "REGENERATED", Delete = "DELETE", } -const TokenExpirationDays = [ +export const TokenExpirationDays = [ { value: "7", label: "7 Days" }, { value: "30", label: "30 Days" }, { value: "60", label: "60 Days" }, { value: "180", label: "180 Days" }, ]; -interface PermissionDetail { - name: string; - description: string; - scopes: string[]; -} - -const AllPermissions: PermissionDetail[] = [ +export const AllPermissions: PermissionDetail[] = [ { name: "Full Access", description: "Grant complete read and write access to the API.", @@ -76,396 +60,15 @@ const AllPermissions: PermissionDetail[] = [ }, ]; -interface DateSelectorProps { - title: string; - description: string; - options: { value: string; label: string }[]; - value?: string; - onChange: (value: string) => void; -} - -function DateSelector(props: DateSelectorProps) { - return ( -
- - -

{props.description}

-
- ); -} - -interface TokenModalProps { - token: PersonalAccessToken; - title: string; - description: string; - descriptionImportant: string; - actionDescription: string; - showDateSelector?: boolean; - onSave: (data: { expirationDate: Date }) => void; - onClose: () => void; -} - -function ShowTokenModal(props: TokenModalProps) { - const [expiration, setExpiration] = useState({ - expirationDays: "30", - expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - }); - - const onEnter = () => { - props.onSave({ expirationDate: expiration.expirationDate }); - return true; - }; - - return ( - props.onClose()}> - Cancel - , - , - ]} - visible={true} - onClose={() => props.onClose()} - onEnter={onEnter} - > -
- {props.description} {props.descriptionImportant} -
-
-
{props.token.name}
-
- Expires on {dayjs(props.token.expirationTime!.toDate()).format("MMM D, YYYY")} -
-
-
- {props.showDateSelector && ( - i.value === expiration.expirationDays)?.value} - onChange={(value) => - setExpiration({ - expirationDays: value, - expirationDate: new Date(Date.now() + Number(value) * 24 * 60 * 60 * 1000), - }) - } - /> - )} -
-
- ); +export interface TokenInfo { + method: TokenAction; + data: PersonalAccessToken; } -interface EditPATData { +interface PermissionDetail { name: string; - expirationDays: string; - expirationDate: Date; - scopes: Set; -} - -export function PersonalAccessTokenCreateView() { - const { enablePersonalAccessTokens } = useContext(FeatureFlagContext); - - const params = useParams<{ tokenId?: string }>(); - const history = useHistory(); - - const [loading, setLoading] = useState(false); - const [errorMsg, setErrorMsg] = useState(""); - const [editToken, setEditToken] = useState(); - const [token, setToken] = useState({ - name: "", - expirationDays: "30", - expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), - scopes: new Set(), - }); - const [modalData, setModalData] = useState<{ token: PersonalAccessToken }>(); - - const isEditing = !!params.tokenId; - - function backToListView(tokenInfo?: TokenInfo) { - history.push({ - pathname: settingsPathPersonalAccessTokens, - state: tokenInfo, - }); - } - - useEffect(() => { - (async () => { - try { - const { tokenId } = params; - if (!tokenId) { - return; - } - - setLoading(true); - const resp = await personalAccessTokensService.getPersonalAccessToken({ id: tokenId }); - const token = resp.token!; - setEditToken(token); - update({ - name: token.name, - scopes: new Set(token.scopes), - }); - } catch (e) { - setErrorMsg(e.message); - } - setLoading(false); - })(); - }, []); - - const update = (change: Partial, addScopes?: string[], removeScopes?: string[]) => { - if (change.expirationDays) { - change.expirationDate = new Date(Date.now() + Number(change.expirationDays) * 24 * 60 * 60 * 1000); - } - const data = { ...token, ...change }; - if (addScopes) { - addScopes.forEach((s) => data.scopes.add(s)); - } - if (removeScopes) { - removeScopes.forEach((s) => data.scopes.delete(s)); - } - setErrorMsg(""); - setToken(data); - }; - - const handleRegenerate = async (tokenId: string, expirationDate: Date) => { - try { - const resp = await personalAccessTokensService.regeneratePersonalAccessToken({ - id: tokenId, - expirationTime: Timestamp.fromDate(expirationDate), - }); - backToListView({ method: TokenAction.Regenerate, data: resp.token! }); - } catch (e) { - setErrorMsg(e.message); - } - }; - - const handleConfirm = async () => { - if (/^\s+/.test(token.name) || /\s+$/.test(token.name)) { - setErrorMsg("Token name should not start or end with a space"); - return; - } - if (!personalAccessTokenNameRegex.test(token.name)) { - setErrorMsg( - "Token name should have a length between 3 and 63 characters, it can only contain letters, numbers, underscore and space characters", - ); - return; - } - try { - const resp = editToken - ? await personalAccessTokensService.updatePersonalAccessToken({ - token: { - id: editToken.id, - name: token.name, - scopes: Array.from(token.scopes), - }, - updateMask: { paths: ["name", "scopes"] }, - }) - : await personalAccessTokensService.createPersonalAccessToken({ - token: { - name: token.name, - expirationTime: Timestamp.fromDate(token.expirationDate), - scopes: Array.from(token.scopes), - }, - }); - - backToListView(isEditing ? undefined : { method: TokenAction.Create, data: resp.token! }); - } catch (e) { - setErrorMsg(e.message); - } - }; - - if (!enablePersonalAccessTokens) { - return ; - } - - return ( -
- -
- - - - {editToken && ( - - )} -
- <> - {errorMsg.length > 0 && ( - - {errorMsg} - - )} - - <> - {modalData && ( - handleRegenerate(modalData.token.id, expirationDate)} - onClose={() => setModalData(undefined)} - /> - )} - - -
-
-

{isEditing ? "Edit" : "New"} Access Token

- {isEditing ? ( -

- Update token name, expiration date, permissions, or regenerate token. -

- ) : ( -

Create a new access token.

- )} -
-
-
-

Token Name

- update({ name: e.target.value })} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleConfirm(); - } - }} - type="text" - placeholder="Token Name" - /> -

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

-
- {!isEditing && ( - i.value === token.expirationDays)?.value} - onChange={(value) => { - update({ expirationDays: value }); - }} - /> - )} -
-

Permission

-
- {AllPermissions.map((item) => ( - token.scopes.has(s))} - onChange={(e) => { - if (e.target.checked) { - update({}, item.scopes); - } else { - update({}, undefined, item.scopes); - } - }} - /> - ))} -
-
-
-
-
- {isEditing && ( - - - - )} - -
-
-
-
- ); -} - -interface TokenEntryProps { - token: PersonalAccessToken; - menuEntries: ContextMenuEntry[]; -} - -function TokenEntry(props: TokenEntryProps) { - const expirationDay = dayjs(props.token.expirationTime!.toDate()); - const expired = expirationDay.isBefore(dayjs()); - - const getScopes = () => { - if (!props.token.scopes) { - return ""; - } - const permissions = AllPermissions.filter((e) => e.scopes.every((v) => props.token.scopes.includes(v))); - if (permissions.length > 0) { - return permissions.map((e) => e.name).join("\n"); - } else { - return "No access"; - } - }; - - return ( - <> -
-
- {props.token.name || ""} -
-
- {getScopes()} -
-
- - {expirationDay.format("MMM D, YYYY")} - {expired && } - -
-
- -
-
- - ); -} - -interface TokenInfo { - method: TokenAction; - data: PersonalAccessToken; + description: string; + scopes: string[]; } function ListAccessTokensView() { diff --git a/components/dashboard/src/settings/PersonalAccessTokensCreateView.tsx b/components/dashboard/src/settings/PersonalAccessTokensCreateView.tsx new file mode 100644 index 00000000000000..90e5ab3ca07f83 --- /dev/null +++ b/components/dashboard/src/settings/PersonalAccessTokensCreateView.tsx @@ -0,0 +1,276 @@ +/** + * 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 { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb"; +import dayjs from "dayjs"; +import { useContext, useEffect, useState } from "react"; +import { Redirect, useHistory, useParams } from "react-router"; +import { Link } from "react-router-dom"; +import Alert from "../components/Alert"; +import CheckBox from "../components/CheckBox"; +import DateSelector from "../components/DateSelector"; +import { SpinnerOverlayLoader } from "../components/Loader"; +import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; +import { personalAccessTokensService } from "../service/public-api"; +import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; +import { AllPermissions, TokenAction, TokenExpirationDays, TokenInfo } from "./PersonalAccessTokens"; +import { settingsPathPersonalAccessTokens } from "./settings.routes"; +import ShowTokenModal from "./ShowTokenModal"; +import { Timestamp } from "@bufbuild/protobuf"; +import arrowDown from "../images/sort-arrow.svg"; + +interface EditPATData { + name: string; + expirationDays: string; + expirationDate: Date; + scopes: Set; +} + +const personalAccessTokenNameRegex = /^[a-zA-Z0-9-_ ]{3,63}$/; + +function PersonalAccessTokenCreateView() { + const { enablePersonalAccessTokens } = useContext(FeatureFlagContext); + + const params = useParams<{ tokenId?: string }>(); + const history = useHistory(); + + const [loading, setLoading] = useState(false); + const [errorMsg, setErrorMsg] = useState(""); + const [editToken, setEditToken] = useState(); + const [token, setToken] = useState({ + name: "", + expirationDays: "30", + expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + scopes: new Set(), + }); + const [modalData, setModalData] = useState<{ token: PersonalAccessToken }>(); + + const isEditing = !!params.tokenId; + + function backToListView(tokenInfo?: TokenInfo) { + history.push({ + pathname: settingsPathPersonalAccessTokens, + state: tokenInfo, + }); + } + + useEffect(() => { + (async () => { + try { + const { tokenId } = params; + if (!tokenId) { + return; + } + + setLoading(true); + const resp = await personalAccessTokensService.getPersonalAccessToken({ id: tokenId }); + const token = resp.token!; + setEditToken(token); + update({ + name: token.name, + scopes: new Set(token.scopes), + }); + } catch (e) { + setErrorMsg(e.message); + } + setLoading(false); + })(); + }, []); + + const update = (change: Partial, addScopes?: string[], removeScopes?: string[]) => { + if (change.expirationDays) { + change.expirationDate = new Date(Date.now() + Number(change.expirationDays) * 24 * 60 * 60 * 1000); + } + const data = { ...token, ...change }; + if (addScopes) { + addScopes.forEach((s) => data.scopes.add(s)); + } + if (removeScopes) { + removeScopes.forEach((s) => data.scopes.delete(s)); + } + setErrorMsg(""); + setToken(data); + }; + + const handleRegenerate = async (tokenId: string, expirationDate: Date) => { + try { + const resp = await personalAccessTokensService.regeneratePersonalAccessToken({ + id: tokenId, + expirationTime: Timestamp.fromDate(expirationDate), + }); + backToListView({ method: TokenAction.Regenerate, data: resp.token! }); + } catch (e) { + setErrorMsg(e.message); + } + }; + + const handleConfirm = async () => { + if (/^\s+/.test(token.name) || /\s+$/.test(token.name)) { + setErrorMsg("Token name should not start or end with a space"); + return; + } + if (!personalAccessTokenNameRegex.test(token.name)) { + setErrorMsg( + "Token name should have a length between 3 and 63 characters, it can only contain letters, numbers, underscore and space characters", + ); + return; + } + try { + const resp = editToken + ? await personalAccessTokensService.updatePersonalAccessToken({ + token: { + id: editToken.id, + name: token.name, + scopes: Array.from(token.scopes), + }, + updateMask: { paths: ["name", "scopes"] }, + }) + : await personalAccessTokensService.createPersonalAccessToken({ + token: { + name: token.name, + expirationTime: Timestamp.fromDate(token.expirationDate), + scopes: Array.from(token.scopes), + }, + }); + + backToListView(isEditing ? undefined : { method: TokenAction.Create, data: resp.token! }); + } catch (e) { + setErrorMsg(e.message); + } + }; + + if (!enablePersonalAccessTokens) { + return ; + } + + return ( +
+ +
+ + + + {editToken && ( + + )} +
+ <> + {errorMsg.length > 0 && ( + + {errorMsg} + + )} + + <> + {modalData && ( + handleRegenerate(modalData.token.id, expirationDate)} + onClose={() => setModalData(undefined)} + /> + )} + + +
+
+

{isEditing ? "Edit" : "New"} Access Token

+ {isEditing ? ( +

+ Update token name, expiration date, permissions, or regenerate token. +

+ ) : ( +

Create a new access token.

+ )} +
+
+
+

Token Name

+ update({ name: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleConfirm(); + } + }} + type="text" + placeholder="Token Name" + /> +

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

+
+ {!isEditing && ( + i.value === token.expirationDays)?.value} + onChange={(value) => { + update({ expirationDays: value }); + }} + /> + )} +
+

Permission

+
+ {AllPermissions.map((item) => ( + token.scopes.has(s))} + onChange={(e) => { + if (e.target.checked) { + update({}, item.scopes); + } else { + update({}, undefined, item.scopes); + } + }} + /> + ))} +
+
+
+
+
+ {isEditing && ( + + + + )} + +
+
+
+
+ ); +} + +export default PersonalAccessTokenCreateView; diff --git a/components/dashboard/src/settings/ShowTokenModal.tsx b/components/dashboard/src/settings/ShowTokenModal.tsx new file mode 100644 index 00000000000000..4d9af437968256 --- /dev/null +++ b/components/dashboard/src/settings/ShowTokenModal.tsx @@ -0,0 +1,82 @@ +/** + * 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 { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb"; +import dayjs from "dayjs"; +import { useState } from "react"; +import DateSelector from "../components/DateSelector"; +import Modal from "../components/Modal"; +import { TokenExpirationDays } from "./PersonalAccessTokens"; + +interface TokenModalProps { + token: PersonalAccessToken; + title: string; + description: string; + descriptionImportant: string; + actionDescription: string; + showDateSelector?: boolean; + onSave: (data: { expirationDate: Date }) => void; + onClose: () => void; +} + +function ShowTokenModal(props: TokenModalProps) { + const [expiration, setExpiration] = useState({ + expirationDays: "30", + expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }); + + const onEnter = () => { + props.onSave({ expirationDate: expiration.expirationDate }); + return true; + }; + + return ( + props.onClose()}> + Cancel + , + , + ]} + visible={true} + onClose={() => props.onClose()} + onEnter={onEnter} + > +
+ {props.description} {props.descriptionImportant} +
+
+
{props.token.name}
+
+ Expires on {dayjs(props.token.expirationTime!.toDate()).format("MMM D, YYYY")} +
+
+
+ {props.showDateSelector && ( + i.value === expiration.expirationDays)?.value} + onChange={(value) => + setExpiration({ + expirationDays: value, + expirationDate: new Date(Date.now() + Number(value) * 24 * 60 * 60 * 1000), + }) + } + /> + )} +
+
+ ); +} + +export default ShowTokenModal; diff --git a/components/dashboard/src/settings/TokenEntry.tsx b/components/dashboard/src/settings/TokenEntry.tsx new file mode 100644 index 00000000000000..4127811526317b --- /dev/null +++ b/components/dashboard/src/settings/TokenEntry.tsx @@ -0,0 +1,58 @@ +/** + * 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 { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb"; +import dayjs from "dayjs"; +import { ContextMenuEntry } from "../components/ContextMenu"; +import { ItemFieldContextMenu } from "../components/ItemsList"; +import { ReactComponent as ExclamationIcon } from "../images/exclamation.svg"; +import { AllPermissions } from "./PersonalAccessTokens"; + +interface TokenEntryProps { + token: PersonalAccessToken; + menuEntries: ContextMenuEntry[]; +} + +function TokenEntry(props: TokenEntryProps) { + const expirationDay = dayjs(props.token.expirationTime!.toDate()); + const expired = expirationDay.isBefore(dayjs()); + + const getScopes = () => { + if (!props.token.scopes) { + return ""; + } + const permissions = AllPermissions.filter((e) => e.scopes.every((v) => props.token.scopes.includes(v))); + if (permissions.length > 0) { + return permissions.map((e) => e.name).join("\n"); + } else { + return "No access"; + } + }; + + return ( + <> +
+
+ {props.token.name || ""} +
+
+ {getScopes()} +
+
+ + {expirationDay.format("MMM D, YYYY")} + {expired && } + +
+
+ +
+
+ + ); +} + +export default TokenEntry;