diff --git a/components/dashboard/src/components/Loader.tsx b/components/dashboard/src/components/Loader.tsx new file mode 100644 index 00000000000000..34da05be543519 --- /dev/null +++ b/components/dashboard/src/components/Loader.tsx @@ -0,0 +1,37 @@ +/** + * 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 { ReactComponent as Spinner } from "../icons/Spinner.svg"; + +export function SpinnerLoader(props: { content?: string }) { + return ( +
+ + {props.content && {props.content}} +
+ ); +} + +interface SpinnerContentProps { + loading?: boolean; + content?: string; + children: React.ReactChild[] | React.ReactChild | React.ReactNode; +} +export function SpinnerOverlayLoader(props: SpinnerContentProps) { + return ( +
+ {props.loading && ( +
+
+ + {props.content && {props.content}} +
+
+ )} +
{props.children}
+
+ ); +} diff --git a/components/dashboard/src/components/PillLabel.tsx b/components/dashboard/src/components/PillLabel.tsx index 3000b1777df748..5f98e16d850022 100644 --- a/components/dashboard/src/components/PillLabel.tsx +++ b/components/dashboard/src/components/PillLabel.tsx @@ -4,21 +4,27 @@ * See License-AGPL.txt in the project root for license information. */ +export type PillType = "info" | "warn" | "success"; + +const PillClsMap: Record = { + info: "bg-blue-50 text-blue-500 dark:bg-blue-500 dark:text-blue-100", + warn: "bg-orange-100 text-orange-700 dark:bg-orange-600 dark:text-orange-100", + success: "bg-green-100 text-green-700 dark:bg-green-600 dark:text-green-100", +}; + /** * Renders a pill. * * **type**\ * info: Renders a blue pile label (default).\ * warn: Renders an orange pile label. + * success: Renders an green pile label. * * **className**\ * Add additional css classes to style this component. */ -export default function PillLabel(props: { children?: React.ReactNode; type?: "info" | "warn"; className?: string }) { - const infoStyle = "bg-blue-50 text-blue-500 dark:bg-blue-500 dark:text-blue-100"; - const warnStyle = "bg-orange-100 text-orange-700 dark:bg-orange-600 dark:text-orange-100"; - const style = `px-2 py-1 text-sm uppercase rounded-xl ${props.type === "warn" ? warnStyle : infoStyle} ${ - props.className - }`; +export default function PillLabel(props: { children?: React.ReactNode; type?: PillType; className?: string }) { + const type = props.type || "info"; + const style = `px-2 py-1 text-sm uppercase rounded-xl ${PillClsMap[type]} ${props.className}`; return {props.children}; } diff --git a/components/dashboard/src/settings/PersonalAccessTokens.tsx b/components/dashboard/src/settings/PersonalAccessTokens.tsx index d994e61c88506c..ea391388dbd170 100644 --- a/components/dashboard/src/settings/PersonalAccessTokens.tsx +++ b/components/dashboard/src/settings/PersonalAccessTokens.tsx @@ -13,15 +13,24 @@ import Modal from "../components/Modal"; import { FeatureFlagContext } from "../contexts/FeatureFlagContext"; import { personalAccessTokensService } from "../service/public-api"; import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu"; -import { settingsPathPersonalAccessTokenCreate, settingsPathPersonalAccessTokens } from "./settings.routes"; +import { + settingsPathPersonalAccessTokenCreate, + settingsPathPersonalAccessTokens, + settingsPathPersonalAccessTokenEdit, +} from "./settings.routes"; import arrowDown from "../images/sort-arrow.svg"; +import { ReactComponent as ExclamationIcon } from "../images/exclamation.svg"; import { Timestamp } from "@bufbuild/protobuf"; import Alert from "../components/Alert"; import { InputWithCopy } from "../components/InputWithCopy"; import { copyToClipboard } from "../utils"; -import TokenEntry from "./TokenEntry"; +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"; -function PersonalAccessTokens() { +export default function PersonalAccessTokens() { const { enablePersonalAccessTokens } = useContext(FeatureFlagContext); if (!enablePersonalAccessTokens) { @@ -37,10 +46,60 @@ function PersonalAccessTokens() { ); } -interface EditPATData { +const personalAccessTokenNameRegex = /^[a-zA-Z0-9-_ ]{3,63}$/; + +enum TokenAction { + Create = "CREATED", + Regerenrate = "REGENERATED", + Delete = "DELETE", +} + +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; - expirationDays: number; - expirationDate: Date; + description: string; + scopes: string[]; +} + +const AllPermissions: PermissionDetail[] = [ + { + name: "Access the user's API", + description: "Grant complete read and write access to the API.", + // TODO: what if scopes are duplicate? maybe use a key: uniq string; to filter will be better + scopes: ["function:*", "resource:default"], + }, +]; + +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 { @@ -49,21 +108,19 @@ interface TokenModalProps { description: string; descriptionImportant: string; actionDescription: string; - children?: React.ReactNode; - onSave?: () => void; - onClose?: () => void; + showDateSelector?: boolean; + onSave: (data: { expirationDate: Date }) => void; + onClose: () => void; } -enum Method { - Create = "CREATED", - Regerenrate = "REGENERATED", -} +function ShowTokenModal(props: TokenModalProps) { + const [expiration, setExpiration] = useState({ + expirationDays: "30", + expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }); -export function ShowTokenModal(props: TokenModalProps) { const onEnter = () => { - if (props.onSave) { - props.onSave(); - } + props.onSave({ expirationDate: expiration.expirationDate }); return true; }; @@ -71,22 +128,15 @@ export function ShowTokenModal(props: TokenModalProps) { { - props.onClose && props.onClose(); - }} - > + , - , ]} visible={true} - onClose={() => { - props.onClose && props.onClose(); - }} + onClose={() => props.onClose()} onEnter={onEnter} >
@@ -95,31 +145,56 @@ export function ShowTokenModal(props: TokenModalProps) {
{props.token.name}
- Expires on{" "} - {Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(props.token.expirationTime?.toDate())} + Expires on {dayjs(props.token.expirationTime!.toDate()).format("MMM D, YYYY")}
- {props.children ?
{props.children}
: <>} + <> + {props.showDateSelector && ( + i.value === expiration.expirationDays)?.value} + onChange={(value) => + setExpiration({ + expirationDays: value, + expirationDate: new Date(Date.now() + Number(value) * 24 * 60 * 60 * 1000), + }) + } + /> + )} + ); } +interface EditPATData { + name: string; + expirationDays: string; + expirationDate: Date; + scopes: Set; +} + export function PersonalAccessTokenCreateView() { const { enablePersonalAccessTokens } = useContext(FeatureFlagContext); - const params = useParams(); + const params = useParams<{ tokenId?: string }>(); const history = useHistory(); - const [editTokenID, setEditTokenID] = useState(null); + const [loading, setLoading] = useState(false); const [errorMsg, setErrorMsg] = useState(""); - const [value, setValue] = useState({ + const [editToken, setEditToken] = useState(); + const [token, setToken] = useState({ name: "", - expirationDays: 30, + expirationDays: "30", expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + scopes: new Set(), }); + const [modalData, setModalData] = useState<{ token: PersonalAccessToken }>(); - const [showModal, setShowModal] = useState(false); - const [modalData, setModalData] = useState(); + const isEditing = !!params.tokenId; function backToListView(tokenInfo?: TokenInfo) { history.push({ @@ -131,68 +206,83 @@ export function PersonalAccessTokenCreateView() { useEffect(() => { (async () => { try { - const { tokenId } = params as { tokenId: string }; + const { tokenId } = params; if (!tokenId) { return; } - setEditTokenID(tokenId); + + setLoading(true); const resp = await personalAccessTokensService.getPersonalAccessToken({ id: tokenId }); - const token = resp.token; - value.name = token!.name; - setModalData(token!); + 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) => { + const update = (change: Partial, addScopes?: string[], removeScopes?: string[]) => { if (change.expirationDays) { - change.expirationDate = new Date(Date.now() + change.expirationDays * 24 * 60 * 60 * 1000); + 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(""); - setValue({ ...value, ...change }); + setToken(data); }; - const regenerate = async () => { - if (!editTokenID) { - return; - } + const handleRegenerate = async (tokenId: string, expirationDate: Date) => { try { const resp = await personalAccessTokensService.regeneratePersonalAccessToken({ - id: editTokenID, - expirationTime: Timestamp.fromDate(value.expirationDate), + id: tokenId, + expirationTime: Timestamp.fromDate(expirationDate), }); - backToListView({ method: Method.Regerenrate, data: resp.token! }); + backToListView({ method: TokenAction.Regerenrate, data: resp.token! }); } catch (e) { setErrorMsg(e.message); } }; const handleConfirm = async () => { - if (value.name.length < 3) { - setErrorMsg("Token name should have at least three characters."); + 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 = editTokenID + const resp = editToken ? await personalAccessTokensService.updatePersonalAccessToken({ token: { - id: editTokenID, - name: value.name, - scopes: ["function:*", "resource:default"], + id: editToken.id, + name: token.name, + scopes: Array.from(token.scopes), }, updateMask: { paths: ["name", "scopes"] }, }) : await personalAccessTokensService.createPersonalAccessToken({ token: { - name: value.name, - expirationTime: Timestamp.fromDate(value.expirationDate), - scopes: ["function:*", "resource:default"], + name: token.name, + expirationTime: Timestamp.fromDate(token.expirationDate), + scopes: Array.from(token.scopes), }, }); - backToListView(editTokenID ? undefined : { method: Method.Create, data: resp.token! }); + backToListView(isEditing ? undefined : { method: TokenAction.Create, data: resp.token! }); } catch (e) { setErrorMsg(e.message); } @@ -214,10 +304,10 @@ export function PersonalAccessTokenCreateView() {
- {editTokenID && ( + {editToken && ( @@ -225,140 +315,177 @@ export function PersonalAccessTokenCreateView() { <> {errorMsg.length > 0 && ( - + {errorMsg} )} <> - {showModal && ( + {modalData && ( { - regenerate(); - }} - onClose={() => { - setShowModal(false); - }} - > -
-

Expiration Date

- -

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

-
-
+ showDateSelector + onSave={({ expirationDate }) => handleRegenerate(modalData.token.id, expirationDate)} + onClose={() => setModalData(undefined)} + /> )} -
-
-

{editTokenID ? "Edit" : "New"} Personal Access Token

- {editTokenID ? ( - <> -

+ +
+
+

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

+ {isEditing ? ( +

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

- - ) : ( - <> + ) : (

Create a new 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. -

+ )}
- {!editTokenID && ( +
-

Expiration Date

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

- The token will expire on{" "} - {Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(value.expirationDate)}. + The application name using the token or the purpose of the token.

- )} -
-

Permission

- + {!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); + } + }} + /> + ))} +
+
-
-
- {editTokenID && ( - - - - )} - -
+
+ {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: Method; + method: TokenAction; data: PersonalAccessToken; } function ListAccessTokensView() { const location = useLocation(); + const [loading, setLoading] = useState(false); const [tokens, setTokens] = useState([]); const [tokenInfo, setTokenInfo] = useState(); + const [modalData, setModalData] = useState<{ token: PersonalAccessToken; action: TokenAction }>(); + const [errorMsg, setErrorMsg] = useState(""); async function loadTokens() { - const response = await personalAccessTokensService.listPersonalAccessTokens({}); - setTokens(response.tokens); + try { + setLoading(true); + const response = await personalAccessTokensService.listPersonalAccessTokens({}); + setTokens(response.tokens); + } catch (e) { + setErrorMsg(e.message); + } + setLoading(false); } useEffect(() => { @@ -376,11 +503,31 @@ function ListAccessTokensView() { copyToClipboard(tokenInfo!.data.value); }; - const handleDeleteToken = (tokenId: string) => { - if (tokenId === tokenInfo?.data.id) { - setTokenInfo(undefined); + const handleDeleteToken = async (tokenId: string) => { + try { + await personalAccessTokensService.deletePersonalAccessToken({ id: tokenId }); + if (tokenId === tokenInfo?.data.id) { + setTokenInfo(undefined); + } + loadTokens(); + setModalData(undefined); + } catch (e) { + setErrorMsg(e.message); + } + }; + + const handleRegenerateToken = async (tokenId: string, expirationDate: Date) => { + try { + const resp = await personalAccessTokensService.regeneratePersonalAccessToken({ + id: tokenId, + expirationTime: Timestamp.fromDate(expirationDate), + }); + setTokenInfo({ method: TokenAction.Regerenrate, data: resp.token! }); + loadTokens(); + setModalData(undefined); + } catch (e) { + setErrorMsg(e.message); } - loadTokens(); }; return ( @@ -396,37 +543,35 @@ function ListAccessTokensView() { )}

+ <> + {errorMsg.length > 0 && ( + + {errorMsg} + + )} + <> {tokenInfo && ( <>
- {tokenInfo.data.name} - {tokenInfo.data.name} + {tokenInfo.method.toUpperCase()} - +
-
+
Expires on{" "} - {Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format( - tokenInfo.data.expirationTime?.toDate(), - )} + {dayjs(tokenInfo.data.expirationTime!.toDate()).format("MMM D, YYYY")} - + · - Created on{" "} - {Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format( - tokenInfo.data.createdAt?.toDate(), - )} + Created on {dayjs(tokenInfo.data.createdAt!.toDate()).format("MMM D, YYYY")}
@@ -450,31 +595,84 @@ function ListAccessTokensView() { )} - {tokens.length === 0 ? ( -
-

No Access Tokens

-

- Generate an access token for applications that need access to the Gitpod API.{" "} -

- - - -
+ {loading ? ( + ) : ( <> -
-

Token Name

-

Permissions

-

Expires

-
-
- {tokens.map((t: PersonalAccessToken) => { - return ; - })} + {tokens.length === 0 ? ( +
+

+ No Access Tokens (PAT) +

+

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

+ + + +
+ ) : ( + <> +
+

Token Name

+

Permissions

+

Expires

+
+
+ {tokens.map((t: PersonalAccessToken) => ( + setModalData({ token: t, action: TokenAction.Regerenrate }), + }, + { + title: "Delete", + href: "", + customFontStyle: + "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", + onClick: () => setModalData({ token: t, action: TokenAction.Delete }), + }, + ]} + /> + ))} + + )} )} + + {modalData?.action === TokenAction.Delete && ( + handleDeleteToken(modalData.token.id)} + onClose={() => setModalData(undefined)} + /> + )} + {modalData?.action === TokenAction.Regerenrate && ( + handleRegenerateToken(modalData.token.id, expirationDate)} + onClose={() => setModalData(undefined)} + /> + )} ); } - -export default PersonalAccessTokens; diff --git a/components/dashboard/src/settings/TokenEntry.tsx b/components/dashboard/src/settings/TokenEntry.tsx deleted file mode 100644 index 2271ccf09287d1..00000000000000 --- a/components/dashboard/src/settings/TokenEntry.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/** - * 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 { useState } from "react"; -import { ContextMenuEntry } from "../components/ContextMenu"; -import { ItemFieldContextMenu } from "../components/ItemsList"; -import { personalAccessTokensService } from "../service/public-api"; -import { ShowTokenModal } from "./PersonalAccessTokens"; -import { settingsPathPersonalAccessTokenEdit } from "./settings.routes"; - -function TokenEntry(props: { token: PersonalAccessToken; onDelete: (tokenId: string) => void }) { - const [showDelModal, setShowDelModal] = useState(false); - - const menuEntries: ContextMenuEntry[] = [ - { - title: "Edit", - link: `${settingsPathPersonalAccessTokenEdit}/${props.token.id}`, - }, - { - title: "Delete", - href: "", - customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300", - onClick: () => { - setShowDelModal(true); - }, - }, - ]; - - const doDeletePAT = async () => { - try { - await personalAccessTokensService.deletePersonalAccessToken({ id: props.token.id }); - setShowDelModal(false); - props.onDelete(props.token.id); - } catch (e) { - // TODO: show error - } - }; - - const getDate = () => { - if (!props.token.expirationTime) { - return ""; - } - const date = props.token.expirationTime?.toDate(); - return date.toDateString(); - }; - - const defaultAllScope = ["function:*", "resource:default"]; - - const getScopes = () => { - if (!props.token.scopes) { - return ""; - } - if (props.token.scopes.every((v) => defaultAllScope.includes(v))) { - return "Access the user's API"; - } else { - return "No access"; - } - }; - - return ( - <> -
-
- {props.token.name || ""} -
-
- {getScopes()} -
-
- {getDate()} -
-
- -
-
- {showDelModal && ( - { - setShowDelModal(false); - }} - /> - )} - - ); -} - -export default TokenEntry;