Path: blob/main/components/dashboard/src/admin/BlockedRepositories.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 { useCallback, useEffect, useRef, useState } from "react";7import { AdminPageHeader } from "./AdminPageHeader";8import ConfirmationModal from "../components/ConfirmationModal";9import Modal, { ModalBody, ModalFooter, ModalHeader } from "../components/Modal";10import { CheckboxInputField } from "../components/forms/CheckboxInputField";11import { ItemFieldContextMenu } from "../components/ItemsList";12import { ContextMenuEntry } from "../components/ContextMenu";13import Alert from "../components/Alert";14import { SpinnerLoader } from "../components/Loader";15import searchIcon from "../icons/search.svg";16import { Button } from "@podkit/buttons/Button";17import { installationClient } from "../service/public-api";18import { Sort, SortOrder } from "@gitpod/public-api/lib/gitpod/v1/sorting_pb";19import { BlockedRepository, ListBlockedRepositoriesResponse } from "@gitpod/public-api/lib/gitpod/v1/installation_pb";20import { TextInputField } from "../components/forms/TextInputField";2122export function BlockedRepositories() {23return (24<AdminPageHeader title="Admin" subtitle="Configure and manage instance settings.">25<BlockedRepositoriesList />26</AdminPageHeader>27);28}2930type NewBlockedRepository = Pick<BlockedRepository, "urlRegexp" | "blockUser" | "blockFreeUsage">;31type ExistingBlockedRepository = Pick<BlockedRepository, "id" | "urlRegexp" | "blockUser" | "blockFreeUsage">;3233interface Props {}3435export function BlockedRepositoriesList(props: Props) {36const [searchResult, setSearchResult] = useState<ListBlockedRepositoriesResponse>(37new ListBlockedRepositoriesResponse({38blockedRepositories: [],39}),40);41const [queryTerm, setQueryTerm] = useState("");42const [searching, setSearching] = useState(false);4344const [isAddModalVisible, setAddModalVisible] = useState(false);45const [isDeleteModalVisible, setDeleteModalVisible] = useState(false);4647const [currentBlockedRepository, setCurrentBlockedRepository] = useState<BlockedRepository>(48new BlockedRepository({49id: 0,50urlRegexp: "",51blockUser: false,52blockFreeUsage: false,53}),54);5556const search = async () => {57setSearching(true);58try {59const result = await installationClient.listBlockedRepositories({60// Don't need, added it in json-rpc implement to make life easier.61// pagination: new PaginationRequest({62// token: Buffer.from(JSON.stringify({ offset: 0 })).toString("base64"),63// pageSize: 100,64// }),65sort: [66new Sort({67field: "urlRegexp",68order: SortOrder.ASC,69}),70],71searchTerm: queryTerm,72});73setSearchResult(result);74} finally {75setSearching(false);76}77};78useEffect(() => {79search(); // Initial list80// eslint-disable-next-line react-hooks/exhaustive-deps81}, []);8283const add = () => {84setCurrentBlockedRepository(85new BlockedRepository({86id: 0,87urlRegexp: "",88blockUser: false,89blockFreeUsage: false,90}),91);92setAddModalVisible(true);93};9495const save = async (blockedRepository: NewBlockedRepository) => {96await installationClient.createBlockedRepository({97urlRegexp: blockedRepository.urlRegexp ?? "",98blockUser: blockedRepository.blockUser ?? false,99blockFreeUsage: blockedRepository.blockFreeUsage ?? false,100});101setAddModalVisible(false);102search();103};104105const validate = (blockedRepository: NewBlockedRepository): string | undefined => {106if (blockedRepository.urlRegexp === "") {107return "Repository URL can not be empty";108}109};110111const deleteBlockedRepository = async (blockedRepository: ExistingBlockedRepository) => {112await installationClient.deleteBlockedRepository({113blockedRepositoryId: blockedRepository.id,114});115search();116};117118const confirmDeleteBlockedRepository = (blockedRepository: BlockedRepository) => {119setCurrentBlockedRepository(blockedRepository);120setAddModalVisible(false);121setDeleteModalVisible(true);122};123124return (125<div className="app-container">126{isAddModalVisible && (127<AddBlockedRepositoryModal128blockedRepository={currentBlockedRepository}129validate={validate}130save={save}131onClose={() => setAddModalVisible(false)}132/>133)}134{isDeleteModalVisible && (135<DeleteBlockedRepositoryModal136blockedRepository={currentBlockedRepository}137deleteBlockedRepository={async () => await deleteBlockedRepository(currentBlockedRepository)}138onClose={() => setDeleteModalVisible(false)}139/>140)}141<div className="pb-3 mt-3 flex">142<div className="flex justify-between w-full">143<div className="flex relative h-10 my-auto">144{searching ? (145<span className="filter-grayscale absolute top-3 left-3">146<SpinnerLoader small={true} />147</span>148) : (149<img150src={searchIcon}151title="Search"152className="filter-grayscale absolute top-3 left-3"153alt="search icon"154/>155)}156<input157className="w-64 pl-9 border-0"158type="search"159placeholder="Search by URL RegEx"160onKeyDown={(ke) => ke.key === "Enter" && search()}161onChange={(v) => {162setQueryTerm(v.target.value.trim());163}}164/>165</div>166<div className="flex space-x-2">167<Button onClick={add}>New Blocked Repository</Button>168</div>169</div>170</div>171172<Alert type={"info"} closable={false} showIcon={true} className="flex rounded p-2 mb-2 w-full">173Search by repository URL using <abbr title="regular expression">RegEx</abbr>.174</Alert>175<div className="flex flex-col space-y-2">176<div className="px-6 py-3 flex justify-between text-sm text-gray-400 border-t border-b border-gray-200 dark:border-gray-800 mb-2">177<div className="w-9/12">Repository URL (RegEx)</div>178<div className="w-1/12">Block Users</div>179<div className="w-2/12">Block Free Usage</div>180<div className="w-1/12"></div>181</div>182{searchResult.blockedRepositories.map((br) => (183<BlockedRepositoryEntry br={br} confirmedDelete={confirmDeleteBlockedRepository} />184))}185</div>186</div>187);188}189190function BlockedRepositoryEntry(props: { br: BlockedRepository; confirmedDelete: (br: BlockedRepository) => void }) {191const menuEntries: ContextMenuEntry[] = [192{193title: "Delete",194onClick: () => props.confirmedDelete(props.br),195customFontStyle: "text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300",196},197];198return (199<div className="rounded whitespace-nowrap flex py-6 px-6 w-full justify-between hover:bg-gray-100 dark:hover:bg-gray-800 focus:bg-kumquat-light group">200<div className="flex flex-col w-9/12 truncate">201<span className="mr-3 text-lg text-gray-600 truncate">{props.br.urlRegexp}</span>202</div>203<div className="flex flex-col self-center w-1/12">204<span className="mr-3 text-lg text-gray-600 truncate">{props.br.blockUser ? "Yes" : "No"}</span>205</div>206<div className="flex flex-col self-center w-2/12">207<span className="mr-3 text-lg text-gray-600 truncate">{props.br.blockFreeUsage ? "Yes" : " "}</span>208</div>209<div className="flex flex-col w-1/12">210<ItemFieldContextMenu menuEntries={menuEntries} />211</div>212</div>213);214}215216interface AddBlockedRepositoryModalProps {217blockedRepository: NewBlockedRepository;218validate: (blockedRepository: NewBlockedRepository) => string | undefined;219save: (br: NewBlockedRepository) => void;220onClose: () => void;221}222223function AddBlockedRepositoryModal(p: AddBlockedRepositoryModalProps) {224const [br, setBr] = useState({ ...p.blockedRepository });225const [error, setError] = useState("");226const ref = useRef(br);227228const update = (previous: Partial<NewBlockedRepository>) => {229const newEnv = { ...ref.current, ...previous };230setBr(newEnv);231ref.current = newEnv;232};233234useEffect(() => {235setBr({ ...p.blockedRepository });236setError("");237}, [p.blockedRepository]);238239const save = useCallback(() => {240const v = ref.current;241const newError = p.validate(v);242if (!!newError) {243setError(newError);244}245246p.save(v);247}, [p]);248249return (250<Modal visible onClose={p.onClose} onSubmit={save}>251<ModalHeader>New Blocked Repository</ModalHeader>252<ModalBody>253<Alert type={"warning"} closable={false} showIcon={true} className="flex rounded p-2 w-2/3 mb-2 w-full">254Entries in this table have an immediate effect on all users. Please use it carefully.255</Alert>256<Alert type={"message"} closable={false} showIcon={true} className="flex rounded p-2 w-2/3 mb-2 w-full">257Repositories are blocked by matching their URL against this regular expression.258</Alert>259<Details br={br} update={update} error={error} />260</ModalBody>261<ModalFooter>262<Button variant="secondary" onClick={p.onClose}>263Cancel264</Button>265<Button type="submit">Add Blocked Repository</Button>266</ModalFooter>267</Modal>268);269}270271function DeleteBlockedRepositoryModal(props: {272blockedRepository: ExistingBlockedRepository;273deleteBlockedRepository: () => void;274onClose: () => void;275}) {276return (277<ConfirmationModal278title="Delete Blocked Repository"279areYouSureText="Are you sure you want to delete this repository from the list?"280buttonText="Delete Blocked Repository"281onClose={props.onClose}282onConfirm={async () => {283await props.deleteBlockedRepository();284props.onClose();285}}286>287<Details br={props.blockedRepository} />288</ConfirmationModal>289);290}291292function Details(props: {293br: NewBlockedRepository;294error?: string;295update?: (pev: Partial<NewBlockedRepository>) => void;296}) {297return (298<div className="border-gray-200 dark:border-gray-800 -mx-6 px-6 py-4 flex flex-col">299{props.error ? (300<div className="bg-kumquat-light rounded-md p-3 text-gitpod-red text-sm mb-2">{props.error}</div>301) : null}302<TextInputField303label="Repository URL RegEx"304autoFocus305type="text"306value={props.br.urlRegexp}307placeholder={'e.g. "https://github.com/malicious-user/*"'}308disabled={!props.update}309onChange={(val) => {310if (!!props.update) {311props.update({ urlRegexp: val });312}313}}314/>315316<CheckboxInputField317label="Block Users"318hint="Block any user that tries to open a workspace for a repository URL that matches this RegEx."319checked={props.br.blockUser}320disabled={!props.update}321onChange={(checked) => {322if (!!props.update) {323props.update({ blockUser: checked });324}325}}326/>327328<CheckboxInputField329label="Block Free Usage"330hint="Block workspace start for a repository URL that matches this RegEx if user is on free tier."331checked={props.br.blockFreeUsage}332disabled={!props.update}333onChange={(checked) => {334if (!!props.update) {335props.update({ blockFreeUsage: checked });336}337}}338/>339</div>340);341}342343344