Path: blob/main/components/dashboard/src/user-settings/PersonalAccessTokens.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 { useCallback, useEffect, useState } from "react";8import { useLocation } from "react-router";9import { personalAccessTokensService } from "../service/public-api";10import { PageWithSettingsSubMenu } from "./PageWithSettingsSubMenu";11import { settingsPathPersonalAccessTokenCreate, settingsPathPersonalAccessTokenEdit } from "./settings.routes";12import { Timestamp } from "@bufbuild/protobuf";13import Alert from "../components/Alert";14import { InputWithCopy } from "../components/InputWithCopy";15import { copyToClipboard } from "../utils";16import PillLabel from "../components/PillLabel";17import dayjs from "dayjs";18import { SpinnerLoader } from "../components/Loader";19import TokenEntry from "./TokenEntry";20import ShowTokenModal from "./ShowTokenModal";21import Pagination from "../Pagination/Pagination";22import { Heading2, Subheading } from "../components/typography/headings";23import { Button } from "@podkit/buttons/Button";24import { LinkButton } from "@podkit/buttons/LinkButton";2526export default function PersonalAccessTokens() {27return (28<div>29<PageWithSettingsSubMenu>30<ListAccessTokensView />31</PageWithSettingsSubMenu>32</div>33);34}3536export enum TokenAction {37Create = "CREATED",38Regenerate = "REGENERATED",39Delete = "DELETE",40}4142const expirationOptions = [7, 30, 60, 180].map((d) => ({43label: `${d} Days`,44value: `${d} Days`,45getDate: () => dayjs().add(d, "days").toDate(),46}));4748// Max value of timestamp(6) in mysql is 2038-01-19 03:14:1749const NoExpiresDate = dayjs("2038-01-01T00:00:00+00:00").toDate();50export function getTokenExpirationDays(showForever: boolean) {51if (!showForever) {52return expirationOptions;53}54return [...expirationOptions, { label: "No expiration", value: "No expiration", getDate: () => NoExpiresDate }];55}5657export function isNeverExpired(date: Date) {58return date.getTime() >= NoExpiresDate.getTime();59}6061export function getTokenExpirationDescription(date: Date) {62if (isNeverExpired(date)) {63return "The token will never expire!";64}65return `The token will expire on ${dayjs(date).format("MMM D, YYYY")}`;66}6768export const AllPermissions: PermissionDetail[] = [69{70name: "Full Access",71description: "Grant complete read and write access to the API.",72// TODO: what if scopes are duplicate? maybe use a key: uniq string; to filter will be better73scopes: ["function:*", "resource:default"],74},75];7677export interface TokenInfo {78method: TokenAction;79data: PersonalAccessToken;80}8182interface PermissionDetail {83name: string;84description: string;85scopes: string[];86}8788function ListAccessTokensView() {89const location = useLocation();9091const [loading, setLoading] = useState<boolean>(false);92const [tokens, setTokens] = useState<PersonalAccessToken[]>([]);93const [tokenInfo, setTokenInfo] = useState<TokenInfo>();94const [modalData, setModalData] = useState<{ token: PersonalAccessToken; action: TokenAction }>();95const [errorMsg, setErrorMsg] = useState("");96const [totalResults, setTotalResults] = useState<number>();97const pageLength = 25;98const [currentPage, setCurrentPage] = useState<number>(1);99100const loadTokens = useCallback(async () => {101try {102setLoading(true);103const response = await personalAccessTokensService.listPersonalAccessTokens({104pagination: { pageSize: pageLength, page: currentPage },105});106setTokens(response.tokens);107setTotalResults(Number(response.totalResults));108} catch (e) {109setErrorMsg(e.message);110}111setLoading(false);112}, [currentPage]);113114useEffect(() => {115loadTokens();116}, [loadTokens]);117118useEffect(() => {119if (location.state) {120setTokenInfo(location.state as any as TokenInfo);121window.history.replaceState({}, "");122}123}, [location.state]);124125const handleCopyToken = () => {126copyToClipboard(tokenInfo!.data.value);127};128129const handleDeleteToken = async (tokenId: string) => {130try {131await personalAccessTokensService.deletePersonalAccessToken({ id: tokenId });132if (tokenId === tokenInfo?.data.id) {133setTokenInfo(undefined);134}135loadTokens();136setModalData(undefined);137} catch (e) {138setErrorMsg(e.message);139}140};141142const handleRegenerateToken = async (tokenId: string, expirationDate: Date) => {143try {144const resp = await personalAccessTokensService.regeneratePersonalAccessToken({145id: tokenId,146expirationTime: Timestamp.fromDate(expirationDate),147});148setTokenInfo({ method: TokenAction.Regenerate, data: resp.token! });149loadTokens();150setModalData(undefined);151} catch (e) {152setErrorMsg(e.message);153}154};155156const loadPage = (page: number = 1) => {157setCurrentPage(page);158};159160return (161<>162<div className="flex items-center sm:justify-between mb-4">163<div>164<Heading2 className="flex gap-4 items-center">Access Tokens</Heading2>165<Subheading>166Create or regenerate access tokens.{" "}167<a168className="gp-link"169href="https://www.gitpod.io/docs/configure/user-settings/access-tokens"170target="_blank"171rel="noreferrer"172>173Learn more174</a>175</Subheading>176</div>177{tokens.length > 0 && (178<LinkButton href={settingsPathPersonalAccessTokenCreate}>New Access Token</LinkButton>179)}180</div>181{errorMsg.length > 0 && (182<Alert type="error" className="mb-2">183{errorMsg}184</Alert>185)}186{tokenInfo && (187<div className="p-4 mb-4 divide-y rounded-xl bg-pk-surface-secondary">188<div className="pb-2">189<div className="flex gap-2 content-center font-semibold text-gray-700 dark:text-gray-200">190<span>{tokenInfo.data.name}</span>191<PillLabel192type={tokenInfo.method === TokenAction.Create ? "success" : "info"}193className="py-0.5 px-1"194>195{tokenInfo.method.toUpperCase()}196</PillLabel>197</div>198<div className="text-gray-400 dark:text-gray-300">199<span>200{isNeverExpired(tokenInfo.data.expirationTime!.toDate())201? "Never expires!"202: `Expires on ${dayjs(tokenInfo.data.expirationTime!.toDate()).format(203"MMM D, YYYY",204)}`}205</span>206<span> · </span>207<span>Created on {dayjs(tokenInfo.data.createdAt!.toDate()).format("MMM D, YYYY")}</span>208</div>209</div>210<div className="pt-2">211<div className="font-semibold text-gray-600 dark:text-gray-200">Your New Access Token</div>212<InputWithCopy className="my-2 max-w-md" value={tokenInfo.data.value} tip="Copy Token" />213<div className="mb-2 font-medium text-sm text-gray-500 dark:text-gray-300">214Make sure to copy your access token — you won't be able to access it again.215</div>216<Button variant="secondary" onClick={handleCopyToken}>217Copy Token to Clipboard218</Button>219</div>220</div>221)}222{loading ? (223<SpinnerLoader content="loading access token list" />224) : (225<>226{tokens.length === 0 ? (227<div className="bg-pk-surface-secondary rounded-xl w-full py-28 flex flex-col items-center">228<Heading2 className="text-center pb-3 text-pk-content-invert-secondary">229No Access Tokens230</Heading2>231<Subheading className="text-center pb-6 w-96">232Generate an access token for applications that need access to the Gitpod API.{" "}233</Subheading>234<LinkButton href={settingsPathPersonalAccessTokenCreate}>New Access Token</LinkButton>235</div>236) : (237<>238<div className="px-3 py-3 flex justify-between space-x-2 text-sm text-gray-400 mb-2 bg-pk-surface-secondary rounded-xl">239<Subheading className="w-4/12">Token Name</Subheading>240<Subheading className="w-4/12">Permissions</Subheading>241<Subheading className="w-3/12">Expires</Subheading>242<div className="w-1/12"></div>243</div>244{tokens.map((t: PersonalAccessToken) => (245<TokenEntry246key={t.id}247token={t}248menuEntries={[249{250title: "Edit",251link: `${settingsPathPersonalAccessTokenEdit}/${t.id}`,252},253{254title: "Regenerate",255href: "",256customFontStyle:257"text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",258onClick: () => setModalData({ token: t, action: TokenAction.Regenerate }),259},260{261title: "Delete",262href: "",263customFontStyle:264"text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",265onClick: () => setModalData({ token: t, action: TokenAction.Delete }),266},267]}268/>269))}270{totalResults && (271<Pagination272totalNumberOfPages={Math.ceil(totalResults / pageLength)}273currentPage={currentPage}274setPage={loadPage}275/>276)}277</>278)}279</>280)}281282{modalData?.action === TokenAction.Delete && (283<ShowTokenModal284token={modalData.token}285title="Delete Access Token"286description="Are you sure you want to delete this access token?"287descriptionImportant="Any applications using this token will no longer be able to access the Gitpod API."288actionDescription="Delete Access Token"289onSave={() => handleDeleteToken(modalData.token.id)}290onClose={() => setModalData(undefined)}291/>292)}293{modalData?.action === TokenAction.Regenerate && (294<ShowTokenModal295token={modalData.token}296title="Regenerate Token"297description="Are you sure you want to regenerate this access token?"298descriptionImportant="Any applications using this token will no longer be able to access the Gitpod API."299actionDescription="Regenerate Token"300showDateSelector301onSave={({ expirationDate }) => handleRegenerateToken(modalData.token.id, expirationDate)}302onClose={() => setModalData(undefined)}303/>304)}305</>306);307}308309310