Path: blob/main/components/dashboard/src/user-settings/PersonalAccessTokensCreateView.tsx
2500 views
/**1* Copyright (c) 2022 Gitpod GmbH. All rights reserved.2* Licensed under the GNU Affero General Public License (AGPL).3* See License.AGPL.txt in the project root for license information.4*/56import { PersonalAccessToken } from "@gitpod/public-api/lib/gitpod/experimental/v1/tokens_pb";7import { useEffect, useMemo, useState } from "react";8import { useHistory, useParams } from "react-router";9import Alert from "../components/Alert";10import DateSelector from "../components/DateSelector";11import { SpinnerOverlayLoader } from "../components/Loader";12import { personalAccessTokensService } from "../service/public-api";13import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";14import {15AllPermissions,16TokenAction,17getTokenExpirationDays,18TokenInfo,19getTokenExpirationDescription,20} from "./PersonalAccessTokens";21import { settingsPathPersonalAccessTokens } from "./settings.routes";22import ShowTokenModal from "./ShowTokenModal";23import { Timestamp } from "@bufbuild/protobuf";24import arrowDown from "../images/sort-arrow.svg";25import { Heading2, Subheading } from "../components/typography/headings";26import { useIsDataOps } from "../data/featureflag-query";27import { LinkButton } from "@podkit/buttons/LinkButton";28import { Button } from "@podkit/buttons/Button";29import { TextInputField } from "../components/forms/TextInputField";3031interface EditPATData {32name: string;33expirationValue: string;34expirationDate: Date;35scopes: Set<string>;36}3738const personalAccessTokenNameRegex = /^[a-zA-Z0-9-_ ]{3,63}$/;3940function PersonalAccessTokenCreateView() {41const params = useParams<{ tokenId?: string }>();42const history = useHistory<TokenInfo>();4344const [loading, setLoading] = useState(false);45const [errorMsg, setErrorMsg] = useState("");46const [editToken, setEditToken] = useState<PersonalAccessToken>();47const [token, setToken] = useState<EditPATData>({48name: "",49expirationValue: "30 Days",50expirationDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // default option 30 days51scopes: new Set<string>(AllPermissions[0].scopes), // default to all permissions52});53const [modalData, setModalData] = useState<{ token: PersonalAccessToken }>();5455const isEditing = !!params.tokenId;5657function backToListView(tokenInfo?: TokenInfo) {58history.push({59pathname: settingsPathPersonalAccessTokens,60state: tokenInfo,61});62}6364const isDataOps = useIsDataOps();65const TokenExpirationDays = useMemo(() => getTokenExpirationDays(isDataOps), [isDataOps]);6667useEffect(() => {68(async () => {69try {70const { tokenId } = params;71if (!tokenId) {72return;73}7475setLoading(true);76const resp = await personalAccessTokensService.getPersonalAccessToken({ id: tokenId });77const token = resp.token!;78setEditToken(token);79update({80name: token.name,81scopes: new Set(token.scopes),82});83} catch (e) {84setErrorMsg(e.message);85}86setLoading(false);87})();88// eslint-disable-next-line react-hooks/exhaustive-deps89}, []);9091const update = (change: Partial<EditPATData>, addScopes?: string[], removeScopes?: string[]) => {92if (change.expirationValue) {93const found = TokenExpirationDays.find((e) => e.value === change.expirationValue);94change.expirationDate = found?.getDate();95}96const data = { ...token, ...change };97if (addScopes) {98addScopes.forEach((s) => data.scopes.add(s));99}100if (removeScopes) {101removeScopes.forEach((s) => data.scopes.delete(s));102}103setErrorMsg("");104setToken(data);105};106107const handleRegenerate = async (tokenId: string, expirationDate: Date) => {108try {109const resp = await personalAccessTokensService.regeneratePersonalAccessToken({110id: tokenId,111expirationTime: Timestamp.fromDate(expirationDate),112});113backToListView({ method: TokenAction.Regenerate, data: resp.token! });114} catch (e) {115setErrorMsg(e.message);116}117};118119const handleConfirm = async () => {120if (/^\s+/.test(token.name) || /\s+$/.test(token.name)) {121setErrorMsg("Token name should not start or end with a space");122return;123}124if (!personalAccessTokenNameRegex.test(token.name)) {125setErrorMsg(126"Token name should have a length between 3 and 63 characters, it can only contain letters, numbers, underscore and space characters",127);128return;129}130try {131const resp = editToken132? await personalAccessTokensService.updatePersonalAccessToken({133token: {134id: editToken.id,135name: token.name,136scopes: Array.from(token.scopes),137},138updateMask: { paths: ["name", "scopes"] },139})140: await personalAccessTokensService.createPersonalAccessToken({141token: {142name: token.name,143expirationTime: Timestamp.fromDate(token.expirationDate),144scopes: Array.from(token.scopes),145},146});147148backToListView(isEditing ? undefined : { method: TokenAction.Create, data: resp.token! });149} catch (e) {150setErrorMsg(e.message);151}152};153154return (155<div>156<PageWithSettingsSubMenu>157<div className="mb-4 flex gap-2">158<LinkButton variant="secondary" href={settingsPathPersonalAccessTokens}>159<img src={arrowDown} className="w-4 mr-2 transform rotate-90 mb-0" alt="Back arrow" />160<span>Back to list</span>161</LinkButton>162{editToken && (163<Button variant="destructive" onClick={() => setModalData({ token: editToken })}>164Regenerate165</Button>166)}167</div>168{errorMsg.length > 0 && (169<Alert type="error" className="mb-2 max-w-md">170{errorMsg}171</Alert>172)}173{!editToken && (174<Alert type={"warning"} closable={false} showIcon={true} className="my-4 max-w-lg">175This token will have complete read / write access to the API. Use it responsibly and revoke it176if necessary.177</Alert>178)}179{modalData && (180<ShowTokenModal181token={modalData.token}182title="Regenerate Token"183description="Are you sure you want to regenerate this access token?"184descriptionImportant="Any applications using this token will no longer be able to access the Gitpod API."185actionDescription="Regenerate Token"186showDateSelector187onSave={({ expirationDate }) => handleRegenerate(modalData.token.id, expirationDate)}188onClose={() => setModalData(undefined)}189/>190)}191<SpinnerOverlayLoader content="loading access token" loading={loading}>192<div className="mb-6">193<div className="flex flex-col mb-4">194<Heading2>{isEditing ? "Edit" : "New"} Access Token</Heading2>195{isEditing ? (196<Subheading>197Update token name, expiration date, permissions, or regenerate token.198</Subheading>199) : (200<Subheading>Create a new access token.</Subheading>201)}202</div>203<div className="flex flex-col gap-4">204<TextInputField205label="Token Name"206placeholder="Token Name"207hint="The application name using the token or the purpose of the token."208value={token.name}209type="text"210className="max-w-md"211onChange={(val) => update({ name: val })}212onKeyDown={(e) => {213if (e.key === "Enter") {214e.preventDefault();215handleConfirm();216}217}}218/>219220{!isEditing && (221<DateSelector222title="Expiration Date"223description={getTokenExpirationDescription(token.expirationDate)}224options={TokenExpirationDays}225value={TokenExpirationDays.find((i) => i.value === token.expirationValue)?.value}226onChange={(value) => {227update({ expirationValue: value });228}}229/>230)}231</div>232</div>233<div className="flex gap-2">234{isEditing && (235<LinkButton variant="secondary" href={settingsPathPersonalAccessTokens}>236Cancel237</LinkButton>238)}239<Button onClick={handleConfirm} disabled={isEditing && !editToken}>240{isEditing ? "Update" : "Create"} Access Token241</Button>242</div>243</SpinnerOverlayLoader>244</PageWithSettingsSubMenu>245</div>246);247}248249export default PersonalAccessTokenCreateView;250251252