react-icons/fa#FaPencilAlt TypeScript Examples

The following examples show how to use react-icons/fa#FaPencilAlt. You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example #1
Source File: Modal.tsx    From hub with Apache License 2.0 5 votes vote down vote up
OrganizationModal = (props: Props) => {
  const form = useRef<HTMLFormElement>(null);
  const [isSending, setIsSending] = useState(false);
  const [apiError, setApiError] = useState<null | string>(null);

  const onCloseModal = () => {
    props.onClose();
  };

  const submitForm = () => {
    if (form.current) {
      form.current.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
    }
  };

  return (
    <Modal
      header={
        <div className={`h3 m-2 flex-grow-1 ${styles.title}`}>
          {isUndefined(props.organization) ? <>Add organization</> : <>Update organization</>}
        </div>
      }
      open={props.open}
      modalClassName={styles.modal}
      closeButton={
        <button
          className="btn btn-sm btn-outline-secondary"
          type="button"
          disabled={isSending}
          onClick={submitForm}
          aria-label={`${isUndefined(props.organization) ? 'Add' : 'Update'} organization`}
        >
          {isSending ? (
            <>
              <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
              <span className="ms-2">
                {isUndefined(props.organization) ? <>Adding organization</> : <>Updating organization</>}
              </span>
            </>
          ) : (
            <div className="d-flex flex-row align-items-center text-uppercase">
              {isUndefined(props.organization) ? (
                <>
                  <MdAddCircle className="me-2" />
                  <div>Add</div>
                </>
              ) : (
                <>
                  <FaPencilAlt className="me-2" />
                  <div>Update</div>
                </>
              )}
            </div>
          )}
        </button>
      }
      onClose={onCloseModal}
      error={apiError}
      cleanError={() => setApiError(null)}
    >
      <div className="w-100">
        <OrganizationForm
          ref={form}
          organization={props.organization}
          onSuccess={() => {
            if (!isUndefined(props.onSuccess)) {
              props.onSuccess();
            }
            onCloseModal();
          }}
          setIsSending={setIsSending}
          onAuthError={props.onAuthError}
          setApiError={setApiError}
        />
      </div>
    </Modal>
  );
}
Example #2
Source File: UpdateOrg.tsx    From hub with Apache License 2.0 5 votes vote down vote up
UpdateOrganization = (props: Props) => {
  const form = useRef<HTMLFormElement>(null);
  const [isSending, setIsSending] = useState(false);

  const submitForm = () => {
    if (form.current) {
      form.current.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
    }
  };

  return (
    <>
      {!props.isLoading && (
        <OrganizationForm
          ref={form}
          organization={!props.isLoading ? props.organization : undefined}
          onAuthError={props.onAuthError}
          onSuccess={props.onSuccess}
          setIsSending={setIsSending}
        />
      )}

      <div className="mt-4">
        <ActionBtn
          className="btn btn-sm btn-outline-secondary"
          onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
            e.preventDefault();
            submitForm();
          }}
          action={AuthorizerAction.UpdateOrganization}
          disabled={isSending}
          label="Update organization modal"
        >
          <>
            {isSending ? (
              <>
                <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
                <span className="ms-2">Updating organization</span>
              </>
            ) : (
              <div className="d-flex flex-row align-items-center text-uppercase">
                <FaPencilAlt className="me-2" />
                <div>Update</div>
              </div>
            )}
          </>
        </ActionBtn>
      </div>
    </>
  );
}
Example #3
Source File: Card.tsx    From hub with Apache License 2.0 4 votes vote down vote up
RepositoryCard = (props: Props) => {
  const history = useHistory();
  const { ctx } = useContext(AppCtx);
  const [dropdownMenuStatus, setDropdownMenuStatus] = useState<boolean>(false);
  const [transferModalStatus, setTransferModalStatus] = useState<boolean>(false);
  const [deletionModalStatus, setDeletionModalStatus] = useState<boolean>(false);
  const [badgeModalStatus, setBadgeModalStatus] = useState<boolean>(false);
  const dropdownMenu = useRef(null);
  const organizationName = ctx.prefs.controlPanel.selectedOrg;
  const hasErrors = !isUndefined(props.repository.lastTrackingErrors) && !isNull(props.repository.lastTrackingErrors);
  const hasScanningErrors =
    !isUndefined(props.repository.lastScanningErrors) && !isNull(props.repository.lastScanningErrors);
  const [openErrorsModal, setOpenErrorsModal] = useState<boolean>(false);
  const [openScanningErrorsModal, setOpenScanningErrorsModal] = useState<boolean>(false);

  const closeDropdown = () => {
    setDropdownMenuStatus(false);
  };

  useOutsideClick([dropdownMenu], dropdownMenuStatus, closeDropdown);

  useEffect(() => {
    if (props.visibleModal) {
      if (props.visibleModal === 'scanning') {
        setOpenScanningErrorsModal(true);
      } else {
        setOpenErrorsModal(true);
      }
      history.replace({
        search: '',
      });
    }
  }, []); /* eslint-disable-line react-hooks/exhaustive-deps */

  const getLastTracking = (): JSX.Element => {
    const nextCheckTime: number = minutesToNearestInterval(30);

    if (isUndefined(props.repository.lastTrackingTs) || isNull(props.repository.lastTrackingTs)) {
      return (
        <>
          Not processed yet
          {props.repository.disabled
            ? '.'
            : nextCheckTime > 0
            ? `, it will be processed automatically in ~ ${nextCheckTime} minutes`
            : ', it will be processed automatically in less than 30 minutes'}
        </>
      );
    }

    const content = (
      <>
        {!isFuture(props.repository.lastTrackingTs!) && (
          <span>{moment.unix(props.repository.lastTrackingTs!).fromNow()}</span>
        )}
        {hasErrors ? (
          <>
            <FaExclamation className="mx-1 text-warning" />
            <RepositoryWarningModal />
          </>
        ) : (
          <FaCheck className="mx-1 text-success" />
        )}
      </>
    );

    let nextCheckMsg: string = '';
    if (nextCheckTime > 0 && !props.repository.disabled) {
      nextCheckMsg = `(it will be checked for updates again in ~ ${nextCheckTime} minutes)`;
    }

    if (hasErrors) {
      return (
        <>
          {content}
          <Modal
            modalDialogClassName={styles.modalDialog}
            modalClassName="mh-100"
            className={`d-inline-block ${styles.modal}`}
            buttonType={`ms-1 btn badge btn-outline-secondary ${styles.btn}`}
            buttonContent={
              <div className="d-flex flex-row align-items-center">
                <HiExclamation className="me-2" />
                <span className="d-none d-xl-inline d-xxl-none d-xxxl-inline">Show tracking errors log</span>
                <span className="d-inline d-xl-none d-xxl-inline d-xxxl-none">Logs</span>
              </div>
            }
            header={
              <div className={`h3 m-2 flex-grow-1 text-truncate ${styles.title}`}>
                Tracking errors log - {props.repository.displayName || props.repository.name}
              </div>
            }
            open={openErrorsModal}
            onClose={() => setOpenErrorsModal(false)}
            footerClassName={styles.modalFooter}
          >
            <div className="d-flex h-100 mw-100 overflow-hidden">
              <div className="d-flex flex-column w-100">
                <div className={`mb-2 ${styles.trackingTime}`}>
                  {moment.unix(props.repository.lastTrackingTs!).format('llll Z')}
                </div>
                <div
                  className={`position-relative flex-grow-1 mw-100 mh-100 overflow-hidden ${styles.modalSyntaxTrackerWrapper}`}
                >
                  {props.repository.lastTrackingErrors && (
                    <SyntaxHighlighter
                      language="bash"
                      style={tomorrowNight}
                      customStyle={{ fontSize: '90%', height: '100%' }}
                    >
                      {props.repository.lastTrackingErrors}
                    </SyntaxHighlighter>
                  )}
                </div>
              </div>
            </div>
          </Modal>
          <span className="ms-3 fst-italic text-muted">{nextCheckMsg}</span>
        </>
      );
    } else {
      return (
        <>
          {content}
          {openErrorsModal && (
            <Modal
              className={`d-inline-block ${styles.modal}`}
              header={<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>Tracking errors log</div>}
              open
            >
              <div className="h5 text-center my-5 mw-100">
                It looks like the last tracking of this repository worked fine and no errors were produced.
                <br />
                <br />
                If you have arrived to this screen from an email listing some errors, please keep in mind those may have
                been already solved.
              </div>
            </Modal>
          )}
          <span className="ms-1 fst-italic text-muted">{nextCheckMsg}</span>
        </>
      );
    }
  };

  const getLastScanning = (): JSX.Element => {
    const nextCheckTime: number = minutesToNearestInterval(30, 15);

    if (
      props.repository.scannerDisabled ||
      isUndefined(props.repository.lastTrackingTs) ||
      isNull(props.repository.lastTrackingTs)
    )
      return <>-</>;

    if (isUndefined(props.repository.lastScanningTs) || isNull(props.repository.lastScanningTs)) {
      return (
        <>
          Not scanned yet
          {props.repository.disabled
            ? '.'
            : nextCheckTime > 0
            ? `, it will be scanned for security vulnerabilities in ~ ${nextCheckTime} minutes`
            : ', it will be scanned for security vulnerabilities in less than 30 minutes'}
        </>
      );
    }

    const content = (
      <>
        {!isFuture(props.repository.lastScanningTs!) && (
          <span>{moment.unix(props.repository.lastScanningTs!).fromNow()}</span>
        )}
        {hasScanningErrors ? (
          <FaExclamation className="mx-2 text-warning" />
        ) : (
          <FaCheck className="mx-2 text-success" />
        )}
      </>
    );

    let nextCheckMsg: string = '';
    if (nextCheckTime > 0 && !props.repository.disabled) {
      nextCheckMsg = `(it will be checked for updates again in ~ ${nextCheckTime} minutes)`;
    }

    if (hasScanningErrors) {
      return (
        <>
          {content}
          <Modal
            modalDialogClassName={styles.modalDialog}
            modalClassName="mh-100"
            className={`d-inline-block ${styles.modal}`}
            buttonType={`ms-1 btn badge btn-outline-secondary ${styles.btn}`}
            buttonContent={
              <div className="d-flex flex-row align-items-center">
                <HiExclamation className="me-2" />
                <span className="d-none d-sm-inline">Show scanning errors log</span>
                <span className="d-inline d-sm-none">Logs</span>
              </div>
            }
            header={
              <div className={`h3 m-2 flex-grow-1 text-truncate ${styles.title}`}>
                Scanning errors log - {props.repository.displayName || props.repository.name}
              </div>
            }
            open={openScanningErrorsModal}
            onClose={() => setOpenErrorsModal(false)}
            footerClassName={styles.modalFooter}
          >
            <div className="d-flex h-100 mw-100 overflow-hidden">
              <div className={`d-flex overflow-scroll ${styles.modalSyntaxWrapper}`}>
                {props.repository.lastScanningErrors && (
                  <SyntaxHighlighter
                    language="bash"
                    style={tomorrowNight}
                    customStyle={{ fontSize: '90%', height: '100%', marginBottom: '0' }}
                  >
                    {props.repository.lastScanningErrors}
                  </SyntaxHighlighter>
                )}
              </div>
            </div>
          </Modal>
          <span className="ms-3 fst-italic text-muted">{nextCheckMsg}</span>
        </>
      );
    } else {
      return (
        <>
          {content}
          {openScanningErrorsModal && (
            <Modal
              className={`d-inline-block ${styles.modal}`}
              header={<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>Scanning errors log</div>}
              open
            >
              <div className="h5 text-center my-5 mw-100">
                It looks like the last security vulnerabilities scan of this repository worked fine and no errors were
                produced.
                <br />
                <br />
                If you have arrived to this screen from an email listing some errors, please keep in mind those may have
                been already solved.
              </div>
            </Modal>
          )}
          <span className="ms-1 fst-italic text-muted">{nextCheckMsg}</span>
        </>
      );
    }
  };

  return (
    <div className="col-12 col-xxl-6 py-sm-3 py-2 px-0 px-xxl-3" data-testid="repoCard">
      <div className="card h-100">
        <div className="card-body d-flex flex-column h-100">
          <div className="d-flex flex-row w-100 justify-content-between">
            <div className={`text-truncate h5 mb-0 ${styles.titleCard}`}>
              {props.repository.displayName || props.repository.name}
            </div>

            <OfficialBadge
              official={props.repository.official}
              className={`ms-3 d-none d-md-inline ${styles.labelWrapper}`}
              type="repo"
            />

            <VerifiedPublisherBadge
              verifiedPublisher={props.repository.verifiedPublisher}
              className={`ms-3 d-none d-md-inline ${styles.labelWrapper}`}
            />

            <DisabledRepositoryBadge
              disabled={props.repository.disabled!}
              className={`ms-3 d-none d-md-inline ${styles.labelWrapper}`}
            />

            <ScannerDisabledRepositoryBadge
              scannerDisabled={props.repository.scannerDisabled!}
              className={`ms-3 d-none d-md-inline ${styles.labelWrapper}`}
            />

            {transferModalStatus && (
              <TransferRepositoryModal
                open={true}
                repository={props.repository}
                onSuccess={props.onSuccess}
                onAuthError={props.onAuthError}
                onClose={() => setTransferModalStatus(false)}
              />
            )}

            {deletionModalStatus && (
              <DeletionModal
                repository={props.repository}
                organizationName={organizationName}
                setDeletionModalStatus={setDeletionModalStatus}
                onSuccess={props.onSuccess}
                onAuthError={props.onAuthError}
              />
            )}

            {badgeModalStatus && (
              <BadgeModal
                repository={props.repository}
                onClose={() => setBadgeModalStatus(false)}
                open={badgeModalStatus}
              />
            )}

            <div className="ms-auto ps-3">
              <RepositoryIconLabel kind={props.repository.kind} isPlural />
            </div>

            <div className="ms-3">
              <div
                ref={dropdownMenu}
                className={classnames('dropdown-menu dropdown-menu-end p-0', styles.dropdownMenu, {
                  show: dropdownMenuStatus,
                })}
              >
                <div className={`dropdown-arrow ${styles.arrow}`} />

                <button
                  className="dropdown-item btn btn-sm rounded-0 text-dark"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    closeDropdown();
                    setBadgeModalStatus(true);
                  }}
                  aria-label="Open badge modal"
                >
                  <div className="d-flex flex-row align-items-center">
                    <MdLabel className={`me-2 ${styles.btnIcon}`} />
                    <span>Get badge</span>
                  </div>
                </button>

                <ActionBtn
                  className="dropdown-item btn btn-sm rounded-0 text-dark"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    closeDropdown();
                    setTransferModalStatus(true);
                  }}
                  action={AuthorizerAction.TransferOrganizationRepository}
                  label="Open transfer repository modal"
                >
                  <>
                    <RiArrowLeftRightLine className={`me-2 ${styles.btnIcon}`} />
                    <span>Transfer</span>
                  </>
                </ActionBtn>

                <ActionBtn
                  className="dropdown-item btn btn-sm rounded-0 text-dark"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    closeDropdown();
                    props.setModalStatus({
                      open: true,
                      repository: props.repository,
                    });
                  }}
                  action={AuthorizerAction.UpdateOrganizationRepository}
                  label="Open update repository modal"
                >
                  <>
                    <FaPencilAlt className={`me-2 ${styles.btnIcon}`} />
                    <span>Edit</span>
                  </>
                </ActionBtn>

                <ActionBtn
                  className="dropdown-item btn btn-sm rounded-0 text-dark"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    closeDropdown();
                    setDeletionModalStatus(true);
                  }}
                  action={AuthorizerAction.DeleteOrganizationRepository}
                  label="Open delete repository modal"
                >
                  <>
                    <FaTrashAlt className={`me-2 ${styles.btnIcon}`} />
                    <span>Delete</span>
                  </>
                </ActionBtn>
              </div>

              <button
                className={`btn btn-outline-secondary rounded-circle p-0 text-center ${styles.btnDropdown}`}
                onClick={() => setDropdownMenuStatus(true)}
                aria-label="Open menu"
                aria-expanded={dropdownMenuStatus}
              >
                <BsThreeDotsVertical />
              </button>
            </div>
          </div>
          {props.repository.repositoryId && (
            <div className="mt-2 d-flex flex-row align-items-baseline">
              <div className="text-truncate">
                <small className="text-muted text-uppercase me-1">ID: </small>
                <small>{props.repository.repositoryId}</small>
              </div>
              <div className={`ms-1 ${styles.copyBtn}`}>
                <div className={`position-absolute ${styles.copyBtnWrapper}`}>
                  <ButtonCopyToClipboard
                    text={props.repository.repositoryId}
                    className="btn-link border-0 text-dark fw-bold"
                    label="Copy repository ID to clipboard"
                  />
                </div>
              </div>
            </div>
          )}
          <div className="text-truncate">
            <small className="text-muted text-uppercase me-1">Url: </small>
            <small>{props.repository.url}</small>
          </div>
          <div>
            <small className="text-muted text-uppercase me-1">Last processed: </small>
            <small>{getLastTracking()}</small>
          </div>
          <div>
            <small className="text-muted text-uppercase me-1">Last security scan: </small>
            <small>{getLastScanning()}</small>
          </div>

          <div className="mt-3 m-md-0 d-flex flex-row d-md-none">
            <OfficialBadge official={props.repository.official} className="me-3" type="repo" />
            <VerifiedPublisherBadge verifiedPublisher={props.repository.verifiedPublisher} className="me-3" />
            <DisabledRepositoryBadge disabled={props.repository.disabled!} />
          </div>
        </div>
      </div>
    </div>
  );
}
Example #4
Source File: Modal.tsx    From hub with Apache License 2.0 4 votes vote down vote up
RepositoryModal = (props: Props) => {
  const { ctx } = useContext(AppCtx);
  const form = useRef<HTMLFormElement>(null);
  const nameInput = useRef<RefInputField>(null);
  const urlInput = useRef<RefInputField>(null);
  const [isSending, setIsSending] = useState(false);
  const [isValidated, setIsValidated] = useState(false);
  const [apiError, setApiError] = useState<string | null>(null);
  const organizationName = ctx.prefs.controlPanel.selectedOrg;
  const [isDisabled, setIsDisabled] = useState<boolean>(props.repository ? props.repository.disabled! : false);
  const [isScannerDisabled, setIsScannerDisabled] = useState<boolean>(
    props.repository ? props.repository.scannerDisabled! : false
  );
  const [visibleDisabledConfirmation, setVisibleDisabledConfirmation] = useState<boolean>(false);
  const [selectedKind, setSelectedKind] = useState<RepositoryKind>(
    isUndefined(props.repository) ? DEFAULT_SELECTED_REPOSITORY_KIND : props.repository.kind
  );
  const [isValidInput, setIsValidInput] = useState<boolean>(false);
  const [urlContainsTreeTxt, setUrlContainsTreeTxt] = useState<boolean>(false);
  const [resetFields, setResetFields] = useState<boolean>(false);
  const [authUser, setAuthUser] = useState<string | null>(props.repository ? props.repository.authUser || null : null);
  const [authPass, setAuthPass] = useState<string | null>(props.repository ? props.repository.authPass || null : null);

  const prepareTags = (): ContainerTag[] => {
    if (props.repository) {
      if (props.repository.data && props.repository.data.tags && !isEmpty(props.repository.data.tags)) {
        return props.repository.data.tags.map((tag: ContainerTag) => {
          return { ...tag, id: nanoid() };
        });
      } else {
        return [];
      }
    } else {
      // By default, we add mutable tag latest for new container images
      return [{ name: 'latest', mutable: true, id: nanoid() }];
    }
  };

  const [containerTags, setContainerTags] = useState<ContainerTag[]>(prepareTags());
  const [repeatedTagNames, setRepeatedTagNames] = useState<boolean>(false);

  const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setIsValidInput(e.target.value === props.repository!.name);
  };

  const onUrlInputChange = (e: ChangeEvent<HTMLInputElement>) => {
    setUrlContainsTreeTxt(e.target.value.includes('/tree/'));
  };

  const allowPrivateRepositories: boolean = getMetaTag('allowPrivateRepositories', true);
  const siteName = getMetaTag('siteName');

  // Clean API error when form is focused after validation
  const cleanApiError = () => {
    if (!isNull(apiError)) {
      setApiError(null);
    }
  };

  const onCloseModal = () => {
    props.onClose();
  };

  async function handleRepository(repository: Repository) {
    try {
      if (isUndefined(props.repository)) {
        await API.addRepository(repository, organizationName);
      } else {
        await API.updateRepository(repository, organizationName);
      }
      if (!isUndefined(props.onSuccess)) {
        props.onSuccess();
      }
      setIsSending(false);
      onCloseModal();
    } catch (err: any) {
      setIsSending(false);
      if (err.kind !== ErrorKind.Unauthorized) {
        let error = compoundErrorMessage(
          err,
          `An error occurred ${isUndefined(props.repository) ? 'adding' : 'updating'} the repository`
        );

        if (!isUndefined(organizationName) && err.kind === ErrorKind.Forbidden) {
          error = `You do not have permissions to ${isUndefined(props.repository) ? 'add' : 'update'} the repository  ${
            isUndefined(props.repository) ? 'to' : 'from'
          } the organization.`;
        }

        setApiError(error);
      } else {
        props.onAuthError();
      }
    }
  }

  const submitForm = () => {
    cleanApiError();
    setIsSending(true);
    if (form.current) {
      validateForm(form.current).then((validation: FormValidation) => {
        if (validation.isValid && !isNull(validation.repository)) {
          handleRepository(validation.repository);
        } else {
          setIsSending(false);
        }
      });
    }
  };

  const validateForm = async (form: HTMLFormElement): Promise<FormValidation> => {
    let repository: Repository | null = null;

    return validateAllFields().then((isValid: boolean) => {
      if (isValid) {
        const formData = new FormData(form);
        repository = {
          kind: selectedKind,
          name: !isUndefined(props.repository) ? props.repository.name : (formData.get('name') as string),
          url: formData.get('url') as string,
          branch: formData.get('branch') as string,
          displayName: formData.get('displayName') as string,
          disabled: isDisabled,
          scannerDisabled: isScannerDisabled,
          authUser:
            !isUndefined(props.repository) &&
            props.repository.private &&
            !resetFields &&
            selectedKind === RepositoryKind.Helm
              ? '='
              : authUser,
          authPass: !isUndefined(props.repository) && props.repository.private && !resetFields ? '=' : authPass,
        };

        if (selectedKind === RepositoryKind.Container) {
          const cleanTags = containerTags.filter((tag: ContainerTag) => tag.name !== '');
          const readyTags = cleanTags.map((tag: ContainerTag) => ({ name: tag.name, mutable: tag.mutable }));
          repository.data = {
            tags: readyTags,
          };
        }
      }
      setIsValidated(true);
      return { isValid, repository };
    });
  };

  const checkContainerTags = (): boolean => {
    if (selectedKind !== RepositoryKind.Container) return true;

    const tagNames = compact(containerTags.map((tag: ContainerTag) => tag.name));
    const uniqTagNames = uniq(tagNames);
    if (tagNames.length === uniqTagNames.length) {
      setRepeatedTagNames(false);
      return true;
    } else {
      setRepeatedTagNames(true);
      return false;
    }
  };

  const validateAllFields = async (): Promise<boolean> => {
    return Promise.all([
      nameInput.current!.checkIsValid(),
      urlInput.current!.checkIsValid(),
      checkContainerTags(),
    ]).then((res: boolean[]) => {
      return every(res, (isValid: boolean) => isValid);
    });
  };

  const handleOnReturnKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
    if (event.key === 'Enter' && !isNull(form)) {
      submitForm();
    }
  };

  const handleKindChange = (event: ChangeEvent<HTMLSelectElement>) => {
    setSelectedKind(parseInt(event.target.value));
    // URL is validated when value has been entered previously
    if (urlInput.current!.getValue() !== '') {
      urlInput.current!.checkIsValid();
    }
  };

  const getAdditionalInfo = (): JSX.Element | undefined => {
    let link: JSX.Element | undefined;

    switch (selectedKind) {
      case RepositoryKind.Helm:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#helm-charts-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            Helm charts repositories
          </ExternalLink>
        );
        break;
      case RepositoryKind.OLM:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#olm-operators-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            OLM operators repositories
          </ExternalLink>
        );
        break;
      case RepositoryKind.Falco:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#falco-rules-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            Falco rules repositories
          </ExternalLink>
        );
        break;
      case RepositoryKind.OPA:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#opa-policies-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            OPA policies repositories
          </ExternalLink>
        );
        break;
      case RepositoryKind.TBAction:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#tinkerbell-actions-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            Tinkerbell actions
          </ExternalLink>
        );
        break;
      case RepositoryKind.Krew:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#krew-kubectl-plugins-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            Krew kubectl plugins
          </ExternalLink>
        );
        break;
      case RepositoryKind.HelmPlugin:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#helm-plugins-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            Helm plugins
          </ExternalLink>
        );
        break;
      case RepositoryKind.TektonTask:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#tekton-tasks-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            <u>Tekton tasks</u>
          </ExternalLink>
        );
        break;
      case RepositoryKind.KedaScaler:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#keda-scalers-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            KEDA scalers
          </ExternalLink>
        );
        break;
      case RepositoryKind.CoreDNS:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#coredns-plugins-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            CoreDNS plugins
          </ExternalLink>
        );
        break;
      case RepositoryKind.Keptn:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#keptn-integrations-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            Keptn integrations
          </ExternalLink>
        );
        break;
      case RepositoryKind.TektonPipeline:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#tekton-pipelines-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            Tekton pipelines
          </ExternalLink>
        );
        break;
      case RepositoryKind.Container:
        link = (
          <ExternalLink
            href="/docs/topics/repositories#container-images-repositories"
            className="text-primary fw-bold"
            label="Open documentation"
          >
            Container images
          </ExternalLink>
        );
        break;
    }

    if (isUndefined(link)) return;

    return (
      <div className="text-muted text-break mt-1 mb-4">
        <small>
          {(() => {
            switch (selectedKind) {
              case RepositoryKind.Falco:
              case RepositoryKind.OLM:
              case RepositoryKind.OPA:
              case RepositoryKind.TBAction:
              case RepositoryKind.Krew:
              case RepositoryKind.HelmPlugin:
              case RepositoryKind.TektonTask:
              case RepositoryKind.KedaScaler:
              case RepositoryKind.CoreDNS:
              case RepositoryKind.Keptn:
              case RepositoryKind.TektonPipeline:
                return (
                  <>
                    <p
                      className={classnames('mb-2 opacity-100', {
                        [styles.animatedWarning]: urlContainsTreeTxt,
                      })}
                    >
                      Please DO NOT include the git hosting platform specific parts, like
                      <span className="fw-bold">tree/branch</span>, just the path to your packages like it would show in
                      the filesystem.
                    </p>
                    <p className="mb-0">
                      For more information about the url format and the repository structure, please see the {link}{' '}
                      section in the{' '}
                      <ExternalLink
                        href="/docs/repositories"
                        className="text-primary fw-bold"
                        label="Open documentation"
                      >
                        repositories guide
                      </ExternalLink>
                      .
                    </p>
                  </>
                );

              case RepositoryKind.Container:
                return (
                  <>
                    <p className="mb-3">
                      The url <span className="fw-bold">must</span> follow the following format:
                    </p>
                    <p className="mb-3 ms-3">
                      <code className={`me-2 ${styles.code}`}>oci://registry/[namespace]/repository</code> (example:{' '}
                      <span className="fst-italic">oci://index.docker.io/artifacthub/ah</span>)
                    </p>
                    <p>
                      The registry host is required, please use <code className={styles.code}>index.docker.io</code>{' '}
                      when referring to repositories hosted in the Docker Hub. The url should not contain any tag. For
                      more information please see the {link} section in the{' '}
                      <ExternalLink
                        href="/docs/repositories"
                        className="text-primary fw-bold"
                        label="Open documentation"
                      >
                        repositories guide
                      </ExternalLink>
                      .
                    </p>
                  </>
                );

              default:
                return (
                  <p className="mb-0">
                    For more information about the url format and the repository structure, please see the {link}{' '}
                    section in the{' '}
                    <ExternalLink href="/docs/repositories" className="text-primary fw-bold" label="Open documentation">
                      repositories guide
                    </ExternalLink>
                    .
                  </p>
                );
            }
          })()}
        </small>
      </div>
    );
  };

  const getURLPattern = (): string | undefined => {
    switch (selectedKind) {
      case RepositoryKind.Helm:
        return undefined;
      case RepositoryKind.OLM:
        return `(^(https://([A-Za-z0-9_.-]+)/|${OCI_PREFIX})[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/?(.*)$`;
      case RepositoryKind.Container:
        return `^(${OCI_PREFIX})[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/?(.*)$`;
      default:
        return '^(https://([A-Za-z0-9_.-]+)/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)/?(.*)$';
    }
  };

  const renderPrivateFields = (): JSX.Element => (
    <>
      {(() => {
        switch (selectedKind) {
          case RepositoryKind.Helm:
          case RepositoryKind.Container:
            return (
              <div className="row">
                <InputField
                  className="col-sm-12 col-md-6"
                  type="text"
                  label="Username"
                  name="authUser"
                  autoComplete="off"
                  value={authUser || ''}
                  onChange={onAuthUserChange}
                />

                <InputField
                  className="col-sm-12 col-md-6"
                  type="password"
                  label="Password"
                  name="authPass"
                  autoComplete="new-password"
                  value={authPass || ''}
                  onChange={onAuthPassChange}
                  visiblePassword
                />
              </div>
            );

          default:
            return (
              <div>
                <InputField
                  type="text"
                  label="Authentication token"
                  name="authPass"
                  additionalInfo={
                    <small className="text-muted text-break mt-1">
                      <p className="mb-0">Authentication token used in private git based repositories.</p>
                    </small>
                  }
                  value={authPass || ''}
                  onChange={onAuthPassChange}
                />
              </div>
            );
        }
      })()}
    </>
  );

  const resetAuthFields = () => {
    setResetFields(true);
    setAuthPass(null);
    setAuthUser(null);
  };

  const onAuthUserChange = (e: ChangeEvent<HTMLInputElement>) => {
    setAuthUser(e.target.value);
  };

  const onAuthPassChange = (e: ChangeEvent<HTMLInputElement>) => {
    setAuthPass(e.target.value);
  };

  return (
    <Modal
      header={
        <div className={`h3 m-2 flex-grow-1 ${styles.title}`}>
          {isUndefined(props.repository) ? (
            <>Add repository</>
          ) : (
            <>{visibleDisabledConfirmation ? 'Disable repository' : 'Update repository'}</>
          )}
        </div>
      }
      open={props.open}
      modalClassName={classnames(styles.modal, { [styles.allowPrivateModal]: allowPrivateRepositories })}
      closeButton={
        <>
          {visibleDisabledConfirmation ? (
            <>
              <button
                type="button"
                className={`btn btn-sm btn-success ${styles.btnLight}`}
                onClick={() => {
                  setVisibleDisabledConfirmation(false);
                  setIsValidInput(false);
                }}
                aria-label="Cancel"
              >
                <span>I'll leave it enabled</span>
              </button>

              <button
                type="button"
                className={classnames(
                  'btn btn-sm ms-3',
                  { 'btn-outline-secondary': !isValidInput },
                  { 'btn-danger': isValidInput }
                )}
                onClick={(e) => {
                  e.preventDefault();
                  setIsDisabled(!isDisabled);
                  setVisibleDisabledConfirmation(false);
                }}
                disabled={!isValidInput}
                aria-label="Disable repository"
              >
                <span>I understand, continue</span>
              </button>
            </>
          ) : (
            <button
              className="btn btn-sm btn-outline-secondary"
              type="button"
              disabled={isSending || visibleDisabledConfirmation}
              onClick={submitForm}
              aria-label={`${isUndefined(props.repository) ? 'Add' : 'Update'} repository`}
            >
              {isSending ? (
                <>
                  <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
                  <span className="ms-2">Validating repository...</span>
                </>
              ) : (
                <div className="d-flex flex-row align-items-center text-uppercase">
                  {isUndefined(props.repository) ? (
                    <>
                      <MdAddCircle className="me-2" />
                      <div>Add</div>
                    </>
                  ) : (
                    <>
                      <FaPencilAlt className="me-2" />
                      <div>Update</div>
                    </>
                  )}
                </div>
              )}
            </button>
          )}
        </>
      }
      onClose={onCloseModal}
      error={apiError}
      cleanError={cleanApiError}
    >
      <div className="w-100">
        {visibleDisabledConfirmation ? (
          <>
            <div className="alert alert-warning my-4">
              <span className="fw-bold text-uppercase">Important:</span> Please read this carefully.
            </div>

            <p>If you disable this repository all packages belonging to it will be deleted.</p>

            <p>
              All information related to the packages in your repository will be permanently deleted as well. This
              includes packages' stars, subscriptions, webhooks, events and notifications.{' '}
              <span className="fw-bold">This operation cannot be undone.</span>
            </p>

            <p>
              You can enable back your repository at any time and the information available in the source repository
              will be indexed and made available in {siteName} again.
            </p>

            <p>
              Please type <span className="fw-bold">{props.repository!.name}</span> to confirm:
            </p>

            <InputField type="text" name="repoName" autoComplete="off" value="" onChange={onInputChange} />
          </>
        ) : (
          <form
            data-testid="repoForm"
            ref={form}
            className={classnames('w-100', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
            onFocus={cleanApiError}
            autoComplete="on"
            noValidate
          >
            <div className="w-75 mb-4">
              <label className={`form-label fw-bold ${styles.label}`} htmlFor="repoKind">
                Kind
              </label>
              <select
                className="form-select"
                aria-label="kind-select"
                name="repoKind"
                value={selectedKind.toString()}
                onChange={handleKindChange}
                disabled={!isUndefined(props.repository)}
                required
              >
                {REPOSITORY_KINDS.map((repoKind: RepoKindDef) => {
                  return (
                    <option key={`kind_${repoKind.label}`} value={repoKind.kind}>
                      {repoKind.name}
                    </option>
                  );
                })}
              </select>
            </div>

            <InputField
              ref={nameInput}
              type="text"
              label="Name"
              labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
              name="name"
              value={!isUndefined(props.repository) ? props.repository.name : ''}
              readOnly={!isUndefined(props.repository)}
              invalidText={{
                default: 'This field is required',
                patternMismatch: 'Only lower case letters, numbers or hyphens. Must start with a letter',
                customError: 'There is another repository with this name',
              }}
              validateOnBlur
              checkAvailability={{
                isAvailable: true,
                resourceKind: ResourceKind.repositoryName,
                excluded: !isUndefined(props.repository) ? [props.repository.name] : [],
              }}
              pattern="[a-z][a-z0-9-]*"
              autoComplete="off"
              disabled={!isUndefined(props.repository)}
              additionalInfo={
                <small className="text-muted text-break mt-1">
                  <p className="mb-0">
                    This name will appear in your packages' urls and <span className="fw-bold">cannot be updated</span>{' '}
                    once is saved.
                  </p>
                </small>
              }
              required
            />

            <InputField
              type="text"
              label="Display name"
              name="displayName"
              value={
                !isUndefined(props.repository) && !isNull(props.repository.displayName)
                  ? props.repository.displayName
                  : ''
              }
            />

            <InputField
              ref={urlInput}
              type="url"
              label="Url"
              labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
              name="url"
              value={props.repository ? props.repository.url || '' : ''}
              invalidText={{
                default: 'This field is required',
                typeMismatch: 'Please enter a valid url',
                patternMismatch: 'Please enter a valid repository url for this repository kind',
                customError: 'There is another repository using this url',
              }}
              onKeyDown={handleOnReturnKeyDown}
              validateOnBlur
              checkAvailability={{
                isAvailable: true,
                resourceKind: ResourceKind.repositoryURL,
                excluded: props.repository ? [props.repository.url] : [],
              }}
              placeholder={selectedKind === RepositoryKind.Container ? OCI_PREFIX : ''}
              pattern={getURLPattern()}
              onChange={onUrlInputChange}
              required
            />

            {getAdditionalInfo()}

            {selectedKind === RepositoryKind.Container && (
              <TagsList
                tags={containerTags}
                setContainerTags={setContainerTags}
                repeatedTagNames={repeatedTagNames}
                setRepeatedTagNames={setRepeatedTagNames}
              />
            )}

            {[
              RepositoryKind.Falco,
              RepositoryKind.OLM,
              RepositoryKind.OPA,
              RepositoryKind.TBAction,
              RepositoryKind.Krew,
              RepositoryKind.HelmPlugin,
              RepositoryKind.TektonTask,
              RepositoryKind.KedaScaler,
              RepositoryKind.CoreDNS,
              RepositoryKind.Keptn,
              RepositoryKind.TektonPipeline,
            ].includes(selectedKind) && (
              <div>
                <InputField
                  type="text"
                  label="Branch"
                  name="branch"
                  placeholder="master"
                  additionalInfo={
                    <small className="text-muted text-break mt-1">
                      <p className="mb-0">
                        Branch used in git based repositories. The <span className="fw-bold">master</span> branch is
                        used by default when none is provided.
                      </p>
                    </small>
                  }
                  value={!isUndefined(props.repository) && props.repository.branch ? props.repository.branch : ''}
                />
              </div>
            )}

            {allowPrivateRepositories && (
              <>
                {props.repository && props.repository.private ? (
                  <>
                    {!resetFields ? (
                      <div className="mt-1 mb-4">
                        <div className={`fw-bold mb-2 ${styles.label}`}>Credentials</div>
                        <small>
                          <p className="mb-0 text-muted text-break">
                            This repository is private and has some credentials set. Current credentials cannot be
                            viewed, but you can{' '}
                            <button
                              type="button"
                              className={`btn btn-link btn-sm p-0 m-0 text-primary fw-bold position-relative d-inline-block ${styles.btnInline}`}
                              onClick={resetAuthFields}
                              aria-label="Reset credentials"
                            >
                              reset them
                            </button>
                            .
                          </p>
                        </small>
                      </div>
                    ) : (
                      <>{renderPrivateFields()}</>
                    )}
                  </>
                ) : (
                  <>{renderPrivateFields()}</>
                )}
              </>
            )}

            <div className="mb-4">
              <div className="form-check form-switch ps-0">
                <label htmlFor="disabledRepo" className={`form-check-label fw-bold ${styles.label}`}>
                  Disabled
                </label>
                <input
                  id="disabledRepo"
                  type="checkbox"
                  className="form-check-input position-absolute ms-2"
                  role="switch"
                  value="true"
                  onChange={() => {
                    // Confirmation content is displayed when an existing repo is going to be disabled and it was not disabled before
                    if (props.repository && !isDisabled && !props.repository.disabled) {
                      setVisibleDisabledConfirmation(true);
                    } else {
                      setIsDisabled(!isDisabled);
                    }
                  }}
                  checked={isDisabled}
                />
              </div>

              <div className="form-text text-muted mt-2">
                Use this switch to disable the repository temporarily or permanently.
              </div>
            </div>

            {[
              RepositoryKind.Helm,
              RepositoryKind.Falco,
              RepositoryKind.OLM,
              RepositoryKind.OPA,
              RepositoryKind.TBAction,
              RepositoryKind.TektonTask,
              RepositoryKind.KedaScaler,
              RepositoryKind.CoreDNS,
              RepositoryKind.Keptn,
              RepositoryKind.TektonPipeline,
              RepositoryKind.Container,
            ].includes(selectedKind) && (
              <div className="mt-4 mb-3">
                <div className="form-check form-switch ps-0">
                  <label htmlFor="scannerDisabledRepo" className={`form-check-label fw-bold ${styles.label}`}>
                    Security scanner disabled
                  </label>{' '}
                  <input
                    id="scannerDisabledRepo"
                    type="checkbox"
                    className="form-check-input position-absolute ms-2"
                    value="true"
                    role="switch"
                    onChange={() => setIsScannerDisabled(!isScannerDisabled)}
                    checked={isScannerDisabled}
                  />
                </div>

                <div className="form-text text-muted mt-2">
                  Use this switch to disable the security scanning of the packages in this repository.
                </div>
              </div>
            )}
          </form>
        )}
      </div>
    </Modal>
  );
}
Example #5
Source File: index.tsx    From hub with Apache License 2.0 4 votes vote down vote up
AuthorizationSection = (props: Props) => {
  const { ctx, dispatch } = useContext(AppCtx);
  const siteName = getMetaTag('siteName');
  const updateActionBtn = useRef<RefActionBtn>(null);
  const [apiError, setApiError] = useState<string | JSX.Element | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isSaving, setIsSaving] = useState<boolean>(false);
  const [isTesting, setIsTesting] = useState<boolean>(false);
  const [savedOrgPolicy, setSavedOrgPolicy] = useState<OrganizationPolicy | undefined>(undefined);
  const [orgPolicy, setOrgPolicy] = useState<OrganizationPolicy | undefined | null>(undefined);
  const [invalidPolicy, setInvalidPolicy] = useState<boolean>(false);
  const [invalidPolicyDataJSON, setInvalidPolicyDataJSON] = useState<boolean>(false);
  const [selectedOrg, setSelectedOrg] = useState<string | undefined>(undefined);
  const [members, setMembers] = useState<string[] | undefined>(undefined);
  const [notGetPolicyAllowed, setNotGetPolicyAllowed] = useState<boolean>(false);
  const [updatePolicyAllowed, setUpdatePolicyAllowed] = useState<boolean>(false);
  const [confirmationModal, setConfirmationModal] = useState<ConfirmationModal>({ open: false });

  const getPredefinedPolicy = (name?: string): AuthorizationPolicy | undefined => {
    let policy = PREDEFINED_POLICIES.find((item: AuthorizationPolicy) => item.name === name);
    if (!isUndefined(policy) && !isUndefined(members)) {
      policy = {
        ...policy,
        data: {
          roles: {
            ...policy.data.roles,
            owner: {
              users: members,
            },
          },
        },
      };
    }
    return policy;
  };

  const onPayloadChange = (e: ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    let updatedOrgPolicy: OrganizationPolicy | undefined = undefined;
    if (value === 'predefined') {
      if (savedOrgPolicy && savedOrgPolicy.predefinedPolicy) {
        updatedOrgPolicy = {
          ...savedOrgPolicy,
          authorizationEnabled: true,
        };
      } else {
        const defaultPolicy = getPredefinedPolicy(DEFAULT_POLICY_NAME);
        if (defaultPolicy) {
          updatedOrgPolicy = {
            ...orgPolicy!,
            customPolicy: null,
            predefinedPolicy: defaultPolicy.name,
            policyData: stringifyPolicyData(defaultPolicy.data),
          };
        }
      }

      checkPolicyChanges(
        () => setOrgPolicy(updatedOrgPolicy!),
        PolicyChangeAction.OnSwitchFromCustomToPredefinedPolicy
      );
    } else {
      let updatedOrgPolicy: OrganizationPolicy | undefined = undefined;

      if (savedOrgPolicy && savedOrgPolicy.customPolicy) {
        updatedOrgPolicy = {
          ...savedOrgPolicy,
          authorizationEnabled: true,
        };
      } else {
        updatedOrgPolicy = {
          ...orgPolicy!,
          customPolicy: null,
          predefinedPolicy: null,
          policyData: null,
        };
      }

      checkPolicyChanges(
        () => setOrgPolicy(updatedOrgPolicy!),
        PolicyChangeAction.OnSwitchFromPredefinedToCustomPolicy
      );
    }
  };

  const checkIfUnsavedChanges = (): boolean => {
    const lostData = checkUnsavedPolicyChanges(savedOrgPolicy!, orgPolicy!);
    return lostData.lostData;
  };

  async function triggerTestInRegoPlayground() {
    try {
      setIsTesting(true);
      let policy: string = '';
      if (orgPolicy!.predefinedPolicy) {
        const predefined = getPredefinedPolicy(orgPolicy!.predefinedPolicy);
        if (predefined) {
          policy = predefined.policy;
        }
      } else {
        policy = orgPolicy!.customPolicy || '';
      }

      const data = prepareRegoPolicyForPlayground(policy, JSON.parse(orgPolicy!.policyData!), ctx.user!.alias);
      const share: RegoPlaygroundResult = await API.triggerTestInRegoPlayground(data);
      const popup = window.open(share.result, '_blank');
      if (isNull(popup)) {
        alertDispatcher.postAlert({
          type: 'warning',
          message:
            'You have Pop-up windows blocked for this site. Please allow them so that we can open the OPA Playground for you.',
        });
      }
      setIsTesting(false);
    } catch (err: any) {
      setIsTesting(false);
      alertDispatcher.postAlert({
        type: 'danger',
        message: 'An error occurred opening the Playground, please try again later.',
      });
    }
  }

  async function getAuthorizationPolicy() {
    try {
      setIsLoading(true);
      const policy = await API.getAuthorizationPolicy(selectedOrg!);
      const formattedPolicy = {
        authorizationEnabled: policy.authorizationEnabled,
        predefinedPolicy: policy.predefinedPolicy || null,
        customPolicy: policy.customPolicy || null,
        policyData: policy.policyData ? stringifyPolicyData(policy.policyData) : null,
      };
      setSavedOrgPolicy(formattedPolicy);
      setOrgPolicy(formattedPolicy);
      setNotGetPolicyAllowed(false);
      setUpdatePolicyAllowed(
        authorizer.check({
          organizationName: selectedOrg!,
          action: AuthorizerAction.UpdateAuthorizationPolicy,
          user: ctx.user!.alias,
        })
      );
      setIsLoading(false);
    } catch (err: any) {
      setIsLoading(false);
      if (err.kind === ErrorKind.Unauthorized) {
        props.onAuthError();
      } else if (err.kind === ErrorKind.Forbidden) {
        setNotGetPolicyAllowed(true);
        setOrgPolicy(null);
      } else {
        setNotGetPolicyAllowed(false);
        setOrgPolicy(null);
        alertDispatcher.postAlert({
          type: 'danger',
          message: 'An error occurred getting the policy from the organization, please try again later.',
        });
      }
    }
  }

  async function updateAuthorizationPolicy() {
    try {
      setIsSaving(true);
      await API.updateAuthorizationPolicy(selectedOrg!, orgPolicy!);
      getAuthorizationPolicy();
      // Update allowed actions and re-render button
      authorizer.getAllowedActionsList(() => updateActionBtn.current!.reRender());
      setIsSaving(false);
    } catch (err: any) {
      setIsSaving(false);
      if (err.kind !== ErrorKind.Unauthorized) {
        let error: string | JSX.Element = compoundErrorMessage(err, 'An error occurred updating the policy');
        error = (
          <>
            {error}. For more information please see the{' '}
            <ExternalLink
              href="https://github.com/artifacthub/hub/blob/master/docs/authorization.md"
              className="text-primary fw-bold"
              label="Open documentation"
            >
              documentation
            </ExternalLink>
            .
          </>
        );
        if (err.kind === ErrorKind.Forbidden) {
          error = 'You do not have permissions to update the policy from the organization.';
          setUpdatePolicyAllowed(false);
        }
        setApiError(error);
      } else {
        props.onAuthError();
      }
    }
  }

  async function fetchMembers() {
    try {
      const membersList: Member[] = await API.getAllOrganizationMembers(ctx.prefs.controlPanel.selectedOrg!);
      setMembers(membersList.map((member: Member) => member.alias));
    } catch (err: any) {
      setMembers(undefined);
    }
  }

  const onSaveAuthorizationPolicy = () => {
    const policy = orgPolicy!.customPolicy || orgPolicy!.predefinedPolicy;
    if (isNull(policy) || isUndefined(policy) || trim(policy) === '') {
      setInvalidPolicy(true);
    } else if (!isValidJSON(orgPolicy!.policyData || '')) {
      setInvalidPolicyDataJSON(true);
    } else {
      checkPolicyChanges(updateAuthorizationPolicy, PolicyChangeAction.OnSavePolicy);
    }
  };

  const onAuthorizationEnabledChange = () => {
    let extraData = {};
    const authorized = !orgPolicy!.authorizationEnabled;
    const defaultPolicy = getPredefinedPolicy(DEFAULT_POLICY_NAME);
    if (
      authorized &&
      (isNull(savedOrgPolicy!.customPolicy) || isUndefined(savedOrgPolicy!.customPolicy)) &&
      (isNull(savedOrgPolicy!.predefinedPolicy) || isUndefined(savedOrgPolicy!.predefinedPolicy)) &&
      !isUndefined(defaultPolicy)
    ) {
      extraData = {
        predefinedPolicy: defaultPolicy.name,
        policyData: stringifyPolicyData(defaultPolicy.data),
      };
    }

    const updatedOrgPolicy = {
      ...savedOrgPolicy!,
      ...extraData,
      authorizationEnabled: authorized,
    };

    if (!authorized) {
      checkPolicyChanges(() => setOrgPolicy(updatedOrgPolicy), PolicyChangeAction.OnDisableAuthorization);
    } else {
      setOrgPolicy(updatedOrgPolicy);
    }
  };

  const onPredefinedPolicyChange = (e: ChangeEvent<HTMLSelectElement>) => {
    e.preventDefault();
    const activePredefinedPolicy = getPredefinedPolicy(e.target.value);
    const updatedOrgPolicy = {
      ...orgPolicy!,
      predefinedPolicy: e.target.value,
      policyData: !isUndefined(activePredefinedPolicy) ? stringifyPolicyData(activePredefinedPolicy.data) : '',
    };

    checkPolicyChanges(() => setOrgPolicy(updatedOrgPolicy!), PolicyChangeAction.OnChangePredefinedPolicy);
  };

  const checkPolicyChanges = (onConfirmAction: () => void, action?: PolicyChangeAction) => {
    const currentPredefinedPolicy =
      orgPolicy && orgPolicy.predefinedPolicy ? getPredefinedPolicy(orgPolicy.predefinedPolicy) : undefined;
    const lostData = checkUnsavedPolicyChanges(
      savedOrgPolicy!,
      orgPolicy!,
      action,
      currentPredefinedPolicy ? currentPredefinedPolicy.data : undefined
    );
    if (lostData.lostData) {
      setConfirmationModal({
        open: true,
        message: lostData.message,
        onConfirm: onConfirmAction,
      });
    } else {
      onConfirmAction();
    }
  };

  useEffect(() => {
    if (selectedOrg) {
      getAuthorizationPolicy();
      fetchMembers();
    }
  }, [selectedOrg]); /* eslint-disable-line react-hooks/exhaustive-deps */

  useEffect(() => {
    if (ctx.prefs.controlPanel.selectedOrg) {
      if (selectedOrg !== ctx.prefs.controlPanel.selectedOrg) {
        if (!checkIfUnsavedChanges()) {
          setSelectedOrg(ctx.prefs.controlPanel.selectedOrg);
        } else {
          const warningPrompt = window.confirm(
            'You have some unsaved changes in your policy data. If you continue without saving, those changes will be lost.'
          );
          if (!warningPrompt) {
            dispatch(updateOrg(selectedOrg!));
          } else {
            setSelectedOrg(ctx.prefs.controlPanel.selectedOrg);
          }
        }
      }
    }
  }, [ctx.prefs.controlPanel.selectedOrg]); /* eslint-disable-line react-hooks/exhaustive-deps */

  const onBeforeUnload = (e: BeforeUnloadEvent) => {
    e.preventDefault();

    e.returnValue =
      'You have some unsaved changes in your policy data. If you continue without saving, those changes will be lost.';
  };

  useEffect(() => {
    if (checkIfUnsavedChanges()) {
      window.addEventListener('beforeunload', onBeforeUnload);
    } else {
      window.removeEventListener('beforeunload', onBeforeUnload);
    }

    return () => {
      window.removeEventListener('beforeunload', onBeforeUnload);
    };
  }, [orgPolicy]); /* eslint-disable-line react-hooks/exhaustive-deps */

  return (
    <main role="main" className="p-0">
      {(isUndefined(orgPolicy) || isLoading) && <Loading />}

      <Prompt
        when={!isNull(orgPolicy) && !isUndefined(orgPolicy) && !notGetPolicyAllowed && checkIfUnsavedChanges()}
        message="You have some unsaved changes in your policy data. If you continue without saving, those changes will be lost."
      />

      <div className={`h3 pb-2 border-bottom ${styles.title}`}>Authorization</div>

      <div className="mt-4 mt-md-5" onClick={() => setApiError(null)}>
        <p>
          {siteName} allows you to setup fine-grained access control based on authorization policies. Authorization
          polices are written in{' '}
          <ExternalLink
            href="https://www.openpolicyagent.org/docs/latest/#rego"
            className="text-primary fw-bold"
            label="Open rego documentation"
          >
            rego
          </ExternalLink>{' '}
          and they are evaluated using the{' '}
          <ExternalLink
            href="https://www.openpolicyagent.org"
            className="text-primary fw-bold"
            label="Open Open Policy Agent documentation"
          >
            Open Policy Agent
          </ExternalLink>
          . Depending on your requirements, you can use a predefined policy and only supply a data file, or you can
          provide your custom policy for maximum flexibility. For more information please see the{' '}
          <ExternalLink href="/docs/authorization" className="text-primary fw-bold" label="Open documentation">
            documentation
          </ExternalLink>
          .
        </p>

        {(isNull(orgPolicy) || isUndefined(orgPolicy)) && notGetPolicyAllowed && (
          <NoData>You are not allowed to manage this organization's authorization policy</NoData>
        )}

        {orgPolicy && (
          <>
            <div className="form-check form-switch mb-4">
              <input
                id="activeAuthorization"
                type="checkbox"
                className="form-check-input"
                value="true"
                role="switch"
                onChange={onAuthorizationEnabledChange}
                checked={orgPolicy.authorizationEnabled}
                disabled={!updatePolicyAllowed}
              />
              <label className="form-check-label" htmlFor="activeAuthorization">
                Fine-grained access control
              </label>
            </div>

            {orgPolicy.authorizationEnabled && (
              <>
                <label className={`form-label ${styles.label}`} htmlFor="payload">
                  <span className="fw-bold">Select authorization policy:</span>
                </label>
                <div className="d-flex flex-row mb-2">
                  {PAYLOAD_OPTION.map((item: Option) => {
                    const activeOption = !isNull(orgPolicy.predefinedPolicy) ? 'predefined' : 'custom';
                    return (
                      <div className="form-check me-4 mb-2" key={`payload_${item.name}`}>
                        <input
                          className="form-check-input"
                          type="radio"
                          id={item.name}
                          name="payload"
                          value={item.name}
                          checked={activeOption === item.name}
                          onChange={onPayloadChange}
                          disabled={!updatePolicyAllowed}
                        />
                        <label className="form-check-label" htmlFor={item.name}>
                          {item.label}
                        </label>
                      </div>
                    );
                  })}
                </div>
                {orgPolicy.predefinedPolicy && (
                  <div className=" w-75 mb-4">
                    <select
                      className="form-select"
                      aria-label="org-select"
                      value={orgPolicy.predefinedPolicy || ''}
                      onChange={onPredefinedPolicyChange}
                      required={!isNull(orgPolicy.predefinedPolicy)}
                      disabled={!updatePolicyAllowed}
                    >
                      <option value="" disabled>
                        Select policy
                      </option>
                      {PREDEFINED_POLICIES.map((item: Option) => (
                        <option key={`policy_${item.name}`} value={item.name}>
                          {item.label}
                        </option>
                      ))}
                    </select>
                    <div className={`invalid-feedback ${styles.fieldFeedback}`}>This field is required</div>
                  </div>
                )}
                <div className="d-flex flex-row align-self-stretch">
                  <div className="d-flex flex-column w-50 pe-2">
                    <div className="text-uppercase text-muted mb-2">Policy</div>

                    <div className="flex-grow-1">
                      <CodeEditor
                        mode="rego"
                        value={
                          orgPolicy.predefinedPolicy
                            ? getPredefinedPolicy(orgPolicy.predefinedPolicy)!.policy
                            : orgPolicy.customPolicy
                        }
                        onChange={(value: string) => {
                          if (invalidPolicy) {
                            setInvalidPolicy(false);
                          }
                          setOrgPolicy({
                            ...orgPolicy!,
                            customPolicy: value || null,
                          });
                        }}
                        disabled={orgPolicy.predefinedPolicy || !updatePolicyAllowed}
                      />
                      {invalidPolicy && (
                        <small className="text-danger">
                          <span className="fw-bold">Error: </span> This field is required
                        </small>
                      )}
                    </div>
                  </div>

                  <div className="d-flex flex-column w-50 ps-2">
                    <div className="text-uppercase text-muted mb-2">Data</div>

                    <div className="flex-grow-1">
                      <CodeEditor
                        value={orgPolicy.policyData}
                        mode="javascript"
                        onChange={(value: string) => {
                          if (invalidPolicyDataJSON) {
                            setInvalidPolicyDataJSON(false);
                          }
                          setOrgPolicy({
                            ...orgPolicy!,
                            policyData: value || null,
                          });
                        }}
                        disabled={!updatePolicyAllowed}
                      />
                      {invalidPolicyDataJSON && (
                        <small className="text-danger">
                          <span className="fw-bold">Error: </span> Invalid JSON format
                        </small>
                      )}
                    </div>
                  </div>
                </div>
              </>
            )}

            <div className="d-flex flex-row mt-4">
              {orgPolicy.authorizationEnabled && (
                <button
                  type="button"
                  className="btn btn-sm btn-success"
                  onClick={triggerTestInRegoPlayground}
                  aria-label="Test in playground"
                >
                  {isTesting ? (
                    <>
                      <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
                      <span className="ms-2">Preparing Playground...</span>
                    </>
                  ) : (
                    <div className="d-flex flex-row align-items-center text-uppercase">
                      <RiTestTubeFill className="me-2" /> <div>Test in Playground</div>
                    </div>
                  )}
                </button>
              )}

              <div className="ms-auto">
                <ActionBtn
                  ref={updateActionBtn}
                  className="btn btn-sm btn-outline-secondary"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    onSaveAuthorizationPolicy();
                  }}
                  action={AuthorizerAction.UpdateAuthorizationPolicy}
                  disabled={isSaving}
                  label="Update authorization policy"
                >
                  <>
                    {isSaving ? (
                      <>
                        <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
                        <span className="ms-2">Saving</span>
                      </>
                    ) : (
                      <div className="d-flex flex-row align-items-center text-uppercase">
                        <FaPencilAlt className="me-2" />
                        <div>Save</div>
                      </div>
                    )}
                  </>
                </ActionBtn>
              </div>
            </div>
          </>
        )}

        <Alert message={apiError} type="danger" onClose={() => setApiError(null)} />
      </div>

      {confirmationModal.open && (
        <Modal
          className={`d-inline-block ${styles.modal}`}
          closeButton={
            <>
              <button
                className="btn btn-sm btn-outline-secondary text-uppercase"
                onClick={() => setConfirmationModal({ open: false })}
                aria-label="Cancel"
              >
                Cancel
              </button>

              <button
                className="btn btn-sm btn-outline-secondary text-uppercase ms-3"
                onClick={(e) => {
                  e.preventDefault();
                  confirmationModal.onConfirm!();
                  setConfirmationModal({ open: false });
                }}
                aria-label="Confirm"
              >
                Ok
              </button>
            </>
          }
          header={<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>Confirm action</div>}
          onClose={() => setConfirmationModal({ open: false })}
          open
        >
          <div className="mt-3 mw-100 text-center">
            <p>{confirmationModal.message!}</p>
          </div>
        </Modal>
      )}
    </main>
  );
}
Example #6
Source File: Card.tsx    From hub with Apache License 2.0 4 votes vote down vote up
APIKeyCard = (props: Props) => {
  const [isDeleting, setIsDeleting] = useState(false);
  const [dropdownMenuStatus, setDropdownMenuStatus] = useState<boolean>(false);
  const dropdownMenu = useRef(null);
  const [deletionModalStatus, setDeletionModalStatus] = useState<boolean>(false);

  const closeDropdown = () => {
    setDropdownMenuStatus(false);
  };

  useOutsideClick([dropdownMenu], dropdownMenuStatus, closeDropdown);

  async function deleteAPIKey() {
    try {
      setIsDeleting(true);
      await API.deleteAPIKey(props.apiKey.apiKeyId!);
      setIsDeleting(false);
      props.onSuccess();
    } catch (err: any) {
      setIsDeleting(false);
      if (err.kind === ErrorKind.Unauthorized) {
        props.onAuthError();
      } else {
        alertDispatcher.postAlert({
          type: 'danger',
          message: 'An error occurred deleting the API key, please try again later.',
        });
      }
    }
  }

  return (
    <div className="col-12 col-xxl-6 py-sm-3 py-2 px-0 px-xxl-3" data-testid="APIKeyCard">
      <div className="card h-100">
        <div className="card-body d-flex flex-column h-100">
          <div className="d-flex flex-row w-100 justify-content-between">
            <div className={`h5 mb-1 me-2 text-break ${styles.titleCard}`}>{props.apiKey.name}</div>
            {deletionModalStatus && (
              <Modal
                className={`d-inline-block ${styles.modal}`}
                closeButton={
                  <>
                    <button
                      className="btn btn-sm btn-outline-secondary text-uppercase"
                      onClick={() => setDeletionModalStatus(false)}
                      aria-label="Cancel"
                    >
                      <div className="d-flex flex-row align-items-center">
                        <IoMdCloseCircle className="me-2" />
                        <span>Cancel</span>
                      </div>
                    </button>

                    <button
                      className="btn btn-sm btn-danger ms-3"
                      onClick={(e) => {
                        e.preventDefault();
                        closeDropdown();
                        deleteAPIKey();
                      }}
                      disabled={isDeleting}
                      aria-label="Delete API key"
                    >
                      <div className="d-flex flex-row align-items-center text-uppercase">
                        {isDeleting ? (
                          <>
                            <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
                            <span className="ms-2">Deleting...</span>
                          </>
                        ) : (
                          <>
                            <FaTrashAlt className={`me-2 ${styles.btnDeleteIcon}`} />
                            <span>Delete</span>
                          </>
                        )}
                      </div>
                    </button>
                  </>
                }
                header={<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>Delete API key</div>}
                onClose={() => setDeletionModalStatus(false)}
                open
              >
                <div className="mt-3 mw-100 text-center">
                  <p>Are you sure you want to remove this API key?</p>
                </div>
              </Modal>
            )}

            <div className="ms-auto">
              <div
                ref={dropdownMenu}
                className={classnames('dropdown-menu dropdown-menu-end p-0', styles.dropdownMenu, {
                  show: dropdownMenuStatus,
                })}
              >
                <div className={`dropdown-arrow ${styles.arrow}`} />

                <button
                  className="dropdown-item btn btn-sm rounded-0 text-dark"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    closeDropdown();
                    props.setModalStatus({
                      open: true,
                      apiKey: props.apiKey,
                    });
                  }}
                  aria-label="Open API key modal"
                >
                  <div className="d-flex flex-row align-items-center">
                    <FaPencilAlt className={`me-2 ${styles.btnIcon}`} />
                    <span>Edit</span>
                  </div>
                </button>

                <button
                  className="dropdown-item btn btn-sm rounded-0 text-dark"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    closeDropdown();
                    setDeletionModalStatus(true);
                  }}
                  aria-label="Open deletion modal"
                >
                  <div className="d-flex flex-row align-items-center">
                    <FaTrashAlt className={`me-2 ${styles.btnIcon}`} />
                    <span>Delete</span>
                  </div>
                </button>
              </div>

              <button
                className={`btn btn-outline-secondary rounded-circle p-0 text-center  ${styles.btnDropdown}`}
                onClick={() => setDropdownMenuStatus(true)}
                aria-label="Open menu"
                aria-expanded={dropdownMenuStatus}
              >
                <BsThreeDotsVertical />
              </button>
            </div>
          </div>

          <div className="mt-2 d-flex flex-row align-items-baseline">
            <div className="text-truncate">
              <small className="text-muted text-uppercase me-1">API-KEY-ID: </small>
              <small>{props.apiKey.apiKeyId}</small>
            </div>
            <div className={`ms-1 ${styles.copyBtn}`}>
              <div className={`position-absolute ${styles.copyBtnWrapper}`}>
                <ButtonCopyToClipboard
                  text={props.apiKey.apiKeyId!}
                  className="btn-link border-0 text-dark fw-bold"
                  label="Copy API key ID to clipboard"
                />
              </div>
            </div>
          </div>
          <div className="text-truncate">
            <small className="text-muted text-uppercase me-1">Created at: </small>
            <small>{moment.unix(props.apiKey.createdAt!).format('YYYY/MM/DD HH:mm:ss (Z)')}</small>
          </div>
        </div>
      </div>
    </div>
  );
}
Example #7
Source File: Modal.tsx    From hub with Apache License 2.0 4 votes vote down vote up
APIKeyModal = (props: Props) => {
  const form = useRef<HTMLFormElement>(null);
  const nameInput = useRef<RefInputField>(null);
  const [isSending, setIsSending] = useState(false);
  const [isValidated, setIsValidated] = useState(false);
  const [apiKey, setApiKey] = useState<APIKey | undefined>(props.apiKey);
  const [apiError, setApiError] = useState<string | null>(null);
  const [apiKeyCode, setApiKeyCode] = useState<APIKeyCode | undefined>(undefined);

  // Clean API error when form is focused after validation
  const cleanApiError = () => {
    if (!isNull(apiError)) {
      setApiError(null);
    }
  };

  const onCloseModal = () => {
    setApiKey(undefined);
    setApiKeyCode(undefined);
    setIsValidated(false);
    setApiError(null);
    props.onClose();
  };

  async function handleAPIKey(name: string) {
    try {
      if (props.apiKey) {
        await API.updateAPIKey(props.apiKey.apiKeyId!, name);
      } else {
        setApiKeyCode(await API.addAPIKey(name));
      }
      if (props.onSuccess) {
        props.onSuccess();
      }
      setIsSending(false);

      // Modal is closed only when updating API key
      if (props.apiKey) {
        onCloseModal();
      }
    } catch (err: any) {
      setIsSending(false);
      if (err.kind !== ErrorKind.Unauthorized) {
        setApiError(
          `An error occurred ${isUndefined(props.apiKey) ? 'adding' : 'updating'} the API key, please try again later.`
        );
      } else {
        props.onAuthError();
      }
    }
  }

  const submitForm = () => {
    cleanApiError();
    setIsSending(true);
    if (form.current) {
      const { isValid, apiKey } = validateForm(form.current);
      if (isValid && apiKey) {
        handleAPIKey(apiKey.name);
      } else {
        setIsSending(false);
      }
    }
  };

  const validateForm = (form: HTMLFormElement): FormValidation => {
    let apiKey: APIKey | null = null;
    const formData = new FormData(form);
    const isValid = form.checkValidity();

    if (isValid) {
      apiKey = {
        name: formData.get('name') as string,
      };
    }

    setIsValidated(true);
    return { isValid, apiKey };
  };

  const handleOnReturnKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
    if (event.key === 'Enter' && form) {
      event.preventDefault();
      event.stopPropagation();
      submitForm();
    }
  };

  useEffect(() => {
    async function getAPIKey() {
      try {
        const currentAPIKey = await API.getAPIKey(props.apiKey!.apiKeyId!);
        setApiKey(currentAPIKey);
        nameInput.current!.updateValue(currentAPIKey.name);
      } catch (err: any) {
        if (err.kind === ErrorKind.Unauthorized) {
          props.onAuthError();
        }
      }
    }

    if (props.apiKey && isUndefined(apiKey)) {
      getAPIKey();
    }
  }, [apiKey, props, props.apiKey]);

  const sendBtn = (
    <button
      className="btn btn-sm btn-outline-secondary"
      type="button"
      disabled={isSending}
      onClick={submitForm}
      aria-label={`${props.apiKey ? 'Update' : 'Add'} API key`}
    >
      {isSending ? (
        <>
          <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
          <span className="ms-2">{`${props.apiKey ? 'Updating' : 'Adding'} API key`}</span>
        </>
      ) : (
        <div className="d-flex flex-row align-items-center text-uppercase">
          {isUndefined(props.apiKey) ? (
            <>
              <MdAddCircle className="me-2" />
              <div>Add</div>
            </>
          ) : (
            <>
              <FaPencilAlt className="me-2" />
              <div>Update</div>
            </>
          )}
        </div>
      )}
    </button>
  );

  return (
    <Modal
      header={
        <div className={`h3 m-2 flex-grow-1 ${styles.title}`}>{`${props.apiKey ? 'Update' : 'Add'} API key`}</div>
      }
      open={props.open}
      modalClassName={styles.modal}
      closeButton={isUndefined(apiKeyCode) ? sendBtn : undefined}
      onClose={onCloseModal}
      error={apiError}
      cleanError={cleanApiError}
    >
      <div className={`w-100 ${styles.contentWrapper}`}>
        {apiKeyCode ? (
          <>
            <div className="d-flex justify-content-between mb-2">
              <SmallTitle text="API-KEY-ID" />
              <div>
                <ButtonCopyToClipboard text={apiKeyCode.apiKeyId} label="Copy API key ID to clipboard" />
              </div>
            </div>

            <SyntaxHighlighter
              language="bash"
              style={docco}
              customStyle={{
                backgroundColor: 'var(--color-1-10)',
              }}
            >
              {apiKeyCode.apiKeyId}
            </SyntaxHighlighter>

            <div className="d-flex justify-content-between mb-2">
              <SmallTitle text="API-KEY-SECRET" />
              <div>
                <ButtonCopyToClipboard text={apiKeyCode.secret} label="Copy API key secret to clipboard" />
              </div>
            </div>

            <SyntaxHighlighter
              language="bash"
              style={docco}
              customStyle={{
                backgroundColor: 'var(--color-1-10)',
              }}
            >
              {apiKeyCode.secret}
            </SyntaxHighlighter>

            <small className="text-muted">
              These are the credentials you will need to provide when making requests to the API. Please, copy and store
              them in a safe place.{' '}
              <b>
                <u>You will not be able to see the secret again when you close this window.</u>
              </b>{' '}
              For more information please see the authorize section in the{' '}
              <ExternalLink className="text-primary fw-bold" href="/docs/api" label="Open documentation">
                API docs
              </ExternalLink>
              .
            </small>

            <div className={`alert alert-warning mt-4 mb-2 ${styles.alert}`}>
              <span className="fw-bold text-uppercase">Important:</span> the API key you've just generated can be used
              to perform <u className="fw-bold">ANY</u> operation you can, so please store it safely and don't share it
              with others.
            </div>
          </>
        ) : (
          <form
            data-testid="apiKeyForm"
            ref={form}
            className={classnames('w-100 mt-3', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
            onFocus={cleanApiError}
            autoComplete="on"
            noValidate
          >
            <InputField
              ref={nameInput}
              type="text"
              label="Name"
              labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
              name="name"
              value={isUndefined(apiKey) ? '' : apiKey.name}
              invalidText={{
                default: 'This field is required',
              }}
              autoComplete="off"
              onKeyDown={handleOnReturnKeyDown}
              required
            />
          </form>
        )}
      </div>
    </Modal>
  );
}
Example #8
Source File: UpdatePassword.tsx    From hub with Apache License 2.0 4 votes vote down vote up
UpdatePassword = () => {
  const form = useRef<HTMLFormElement>(null);
  const oldPasswordInput = useRef<RefInputField>(null);
  const passwordInput = useRef<RefInputField>(null);
  const repeatPasswordInput = useRef<RefInputField>(null);
  const [isSending, setIsSending] = useState(false);
  const [password, setPassword] = useState<Password>({ value: '', isValid: false });
  const [isValidated, setIsValidated] = useState(false);

  const onPasswordChange = (e: ChangeEvent<HTMLInputElement>) => {
    setPassword({ value: e.target.value, isValid: e.currentTarget.checkValidity() });
  };

  async function updatePassword(oldPassword: string, newPassword: string) {
    try {
      setIsSending(true);
      await API.updatePassword(oldPassword, newPassword);
      cleanForm();
      setIsSending(false);
      setIsValidated(false);
    } catch (err: any) {
      setIsSending(false);
      if (err.kind !== ErrorKind.Unauthorized) {
        let error = compoundErrorMessage(err, 'An error occurred updating your password');
        alertDispatcher.postAlert({
          type: 'danger',
          message: error,
        });
      } else {
        alertDispatcher.postAlert({
          type: 'danger',
          message:
            'An error occurred updating your password, please make sure you have entered your old password correctly',
        });
      }
    }
  }

  const cleanForm = () => {
    oldPasswordInput.current!.reset();
    passwordInput.current!.reset();
    repeatPasswordInput.current!.reset();
  };

  const submitForm = () => {
    setIsSending(true);
    if (form.current) {
      validateForm(form.current).then((validation: FormValidation) => {
        if (validation.isValid) {
          updatePassword(validation.oldPassword!, validation.newPassword!);
        } else {
          setIsSending(false);
        }
      });
    }
  };

  const validateForm = async (form: HTMLFormElement): Promise<FormValidation> => {
    let newPassword: string | null = null;
    let oldPassword: string | null = null;

    return validateAllFields().then((isValid: boolean) => {
      if (isValid) {
        const formData = new FormData(form);
        newPassword = formData.get('password') as string;
        oldPassword = formData.get('oldPassword') as string;
      }

      setIsValidated(true);
      return { isValid, newPassword, oldPassword };
    });
  };

  const validateAllFields = async (): Promise<boolean> => {
    return Promise.all([
      oldPasswordInput.current!.checkIsValid(),
      passwordInput.current!.checkIsValid(),
      repeatPasswordInput.current!.checkIsValid(),
    ]).then((res: boolean[]) => {
      return every(res, (isValid: boolean) => isValid);
    });
  };

  return (
    <form
      data-testid="updatePasswordForm"
      ref={form}
      className={classnames('w-100', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
      autoComplete="on"
      noValidate
    >
      <InputField
        ref={oldPasswordInput}
        type="password"
        label="Old password"
        name="oldPassword"
        invalidText={{
          default: 'This field is required',
        }}
        autoComplete="password"
        validateOnBlur
        required
      />

      <InputField
        ref={passwordInput}
        type="password"
        label="New password"
        name="password"
        minLength={6}
        invalidText={{
          default: 'This field is required',
          customError: 'Insecure password',
        }}
        onChange={onPasswordChange}
        autoComplete="new-password"
        checkPasswordStrength
        validateOnChange
        validateOnBlur
        required
      />

      <InputField
        ref={repeatPasswordInput}
        type="password"
        label="Confirm new password"
        labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
        name="confirmPassword"
        pattern={password.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}
        invalidText={{
          default: 'This field is required',
          patternMismatch: "Passwords don't match",
        }}
        autoComplete="new-password"
        validateOnBlur={password.isValid}
        required
      />

      <div className="mt-4 mb-2">
        <button
          className="btn btn-sm btn-outline-secondary"
          type="button"
          disabled={isSending}
          onClick={submitForm}
          aria-label="Update password"
        >
          {isSending ? (
            <>
              <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
              <span className="ms-2">Changing password</span>
            </>
          ) : (
            <div className="d-flex flex-row align-items-center text-uppercase">
              <FaPencilAlt className="me-2" />
              <div>Change</div>
            </div>
          )}
        </button>
      </div>
    </form>
  );
}
Example #9
Source File: UpdateProfile.tsx    From hub with Apache License 2.0 4 votes vote down vote up
UpdateProfile = (props: Props) => {
  const { dispatch } = useContext(AppCtx);
  const form = useRef<HTMLFormElement>(null);
  const usernameInput = useRef<RefInputField>(null);
  const [isSending, setIsSending] = useState(false);
  const [isValidated, setIsValidated] = useState(false);
  const [profile, setProfile] = useState<Profile | null | undefined>(props.profile);
  const [imageId, setImageId] = useState<string | undefined>(
    props.profile && props.profile.profileImageId ? props.profile.profileImageId : undefined
  );

  useEffect(() => {
    setProfile(props.profile);
  }, [props.profile]);

  async function updateProfile(user: UserFullName) {
    try {
      setIsSending(true);
      const formattedUser = { ...user };
      await API.updateUserProfile(user);
      dispatch(updateUser(formattedUser));
      setIsSending(false);
    } catch (err: any) {
      setIsSending(false);
      if (err.kind !== ErrorKind.Unauthorized) {
        let error = compoundErrorMessage(err, 'An error occurred updating your profile');
        alertDispatcher.postAlert({
          type: 'danger',
          message: error,
        });
      } else {
        props.onAuthError();
      }
    }
  }

  const submitForm = () => {
    setIsSending(true);
    if (form.current) {
      validateForm(form.current).then((validation: FormValidation) => {
        if (validation.isValid && !isNull(validation.user)) {
          updateProfile(validation.user);
        } else {
          setIsSending(false);
        }
      });
    }
  };

  const validateForm = (form: HTMLFormElement): Promise<FormValidation> => {
    let user: User | null = null;

    return validateAllFields().then((isValid: boolean) => {
      if (isValid) {
        const formData = new FormData(form);
        user = {
          alias: formData.get('alias') as string,
        };
        if (formData.get('firstName') !== '') {
          user['firstName'] = formData.get('firstName') as string;
        }

        if (formData.get('lastName') !== '') {
          user['lastName'] = formData.get('lastName') as string;
        }

        if (!isUndefined(imageId)) {
          user['profileImageId'] = imageId;
        }
      }
      setIsValidated(true);
      return { isValid, user };
    });
  };

  const validateAllFields = async (): Promise<boolean> => {
    return Promise.all([usernameInput.current!.checkIsValid()]).then((res: boolean[]) => {
      return every(res, (isValid: boolean) => isValid);
    });
  };

  return (
    <form
      data-testid="updateProfileForm"
      ref={form}
      className={classnames('w-100', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
      autoComplete="on"
      noValidate
    >
      <InputFileField
        name="image"
        label="Profile image"
        labelLegend={<small className="ms-1 fst-italic">(Click on the image to update)</small>}
        value={imageId}
        onImageChange={(imageId: string) => setImageId(imageId)}
        onAuthError={props.onAuthError}
      />

      <InputField
        type="email"
        label="Email"
        name="email"
        value={!isUndefined(profile) && !isNull(profile) ? profile.email : ''}
        readOnly
      />

      <InputField
        ref={usernameInput}
        type="text"
        label="Username"
        labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
        name="alias"
        value={!isUndefined(profile) && !isNull(profile) ? profile.alias : ''}
        invalidText={{
          default: 'This field is required',
          customError: 'Username not available',
        }}
        checkAvailability={{
          isAvailable: true,
          resourceKind: ResourceKind.userAlias,
          excluded: !isUndefined(profile) && !isNull(profile) ? [profile.alias] : [],
        }}
        validateOnBlur
        autoComplete="username"
        required
      />

      <InputField
        type="text"
        label="First Name"
        name="firstName"
        autoComplete="given-name"
        value={!isUndefined(profile) && !isNull(profile) ? profile.firstName : ''}
      />

      <InputField
        type="text"
        label="Last Name"
        name="lastName"
        autoComplete="family-name"
        value={!isUndefined(profile) && !isNull(profile) ? profile.lastName : ''}
      />

      <div className="mt-4">
        <button
          className="btn btn-sm btn-outline-secondary"
          type="button"
          disabled={isSending}
          onClick={submitForm}
          aria-label="Update profile"
        >
          {isSending ? (
            <>
              <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
              <span className="ms-2">Updating profile</span>
            </>
          ) : (
            <div className="d-flex flex-row align-items-center text-uppercase">
              <FaPencilAlt className="me-2" />
              <div>Update</div>
            </div>
          )}
        </button>
      </div>
    </form>
  );
}
Example #10
Source File: Card.tsx    From hub with Apache License 2.0 4 votes vote down vote up
WebhookCard = (props: Props) => {
  const { ctx } = useContext(AppCtx);
  const [isDeleting, setIsDeleting] = useState<boolean>(false);
  const [dropdownMenuStatus, setDropdownMenuStatus] = useState<boolean>(false);
  const dropdownMenu = useRef(null);
  const [deletionModalStatus, setDeletionModalStatus] = useState<boolean>(false);

  const closeDropdown = () => {
    setDropdownMenuStatus(false);
  };

  useOutsideClick([dropdownMenu], dropdownMenuStatus, closeDropdown);

  async function deleteWebhook() {
    try {
      setIsDeleting(true);
      await API.deleteWebhook(props.webhook.webhookId!, ctx.prefs.controlPanel.selectedOrg);
      setIsDeleting(false);
      props.onDeletion();
    } catch (err: any) {
      setIsDeleting(false);
      if (err.kind === ErrorKind.Unauthorized) {
        props.onAuthError();
      } else {
        alertDispatcher.postAlert({
          type: 'danger',
          message: 'An error occurred deleting the webhook, please try again later.',
        });
      }
    }
  }

  return (
    <div className="col-12 col-xxl-6 py-sm-3 py-2 px-0 px-xxl-3" role="listitem">
      <div className={`card cardWithHover w-100 h-100 mw-100 bg-white ${styles.card}`}>
        <div className="card-body position-relative">
          <div className="d-flex flex-row">
            <div className="h5 card-title mb-3 me-3 lh-1 text-break">
              <div className="d-flex flex-row align-items-start">
                <div>{props.webhook.name}</div>
                {props.webhook.active ? (
                  <span
                    className={`ms-3 mt-1 fw-bold badge rounded-pill border border-success text-success text-uppercase ${styles.badge}`}
                  >
                    Active
                  </span>
                ) : (
                  <span
                    className={`ms-3 mt-1 fw-bold badge rounded-pill border border-dark text-dark text-uppercase ${styles.badge} ${styles.inactiveBadge}`}
                  >
                    Inactive
                  </span>
                )}
              </div>
            </div>

            {deletionModalStatus && (
              <Modal
                className={`d-inline-block ${styles.modal}`}
                closeButton={
                  <>
                    <button
                      className="btn btn-sm btn-outline-secondary text-uppercase"
                      onClick={() => setDeletionModalStatus(false)}
                      aria-label="Close deletion modal"
                    >
                      <div className="d-flex flex-row align-items-center">
                        <IoMdCloseCircle className="me-2" />
                        <span>Cancel</span>
                      </div>
                    </button>

                    <button
                      className="btn btn-sm btn-danger ms-3"
                      onClick={(e) => {
                        e.preventDefault();
                        deleteWebhook();
                      }}
                      disabled={isDeleting}
                      aria-label="Delete webhook"
                    >
                      <div className="d-flex flex-row align-items-center text-uppercase">
                        {isDeleting ? (
                          <>
                            <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
                            <span className="ms-2">Deleting...</span>
                          </>
                        ) : (
                          <>
                            <FaTrashAlt className={`me-2 ${styles.btnDeleteIcon}`} />
                            <span>Delete</span>
                          </>
                        )}
                      </div>
                    </button>
                  </>
                }
                header={<div className={`h3 m-2 flex-grow-1 ${styles.title}`}>Delete webhook</div>}
                onClose={() => setDeletionModalStatus(false)}
                open
              >
                <div className="mt-3 mw-100 text-center">
                  <p>Are you sure you want to delete this webhook?</p>
                </div>
              </Modal>
            )}

            <div className="ms-auto">
              <div
                ref={dropdownMenu}
                className={classnames('dropdown-menu dropdown-menu-end p-0', styles.dropdownMenu, {
                  show: dropdownMenuStatus,
                })}
              >
                <div className={`dropdown-arrow ${styles.arrow}`} />

                <button
                  className="dropdown-item btn btn-sm rounded-0 text-dark"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    closeDropdown();
                    props.onEdition();
                  }}
                  aria-label="Edit webhook"
                >
                  <div className="d-flex flex-row align-items-center">
                    <FaPencilAlt className={`me-2 ${styles.btnIcon}`} />
                    <span>Edit</span>
                  </div>
                </button>

                <button
                  className="dropdown-item btn btn-sm rounded-0 text-dark"
                  onClick={(e: ReactMouseEvent<HTMLButtonElement>) => {
                    e.preventDefault();
                    closeDropdown();
                    setDeletionModalStatus(true);
                  }}
                  aria-label="Open deletion webhook modal"
                >
                  <div className="d-flex flex-row align-items-center">
                    <FaTrashAlt className={`me-2 ${styles.btnIcon}`} />
                    <span>Delete</span>
                  </div>
                </button>
              </div>

              <button
                className={`btn btn-outline-secondary rounded-circle p-0 text-center  ${styles.btnDropdown}`}
                onClick={() => setDropdownMenuStatus(true)}
                aria-label="Open menu"
                aria-expanded={dropdownMenuStatus}
              >
                <BsThreeDotsVertical />
              </button>
            </div>
          </div>

          <div className="d-flex flex-column">
            <div className="card-subtitle d-flex flex-column mw-100 mt-1">
              <p className="card-text">{props.webhook.description}</p>
            </div>

            <div className="text-truncate">
              <small className="text-muted text-uppercase me-2">Url:</small>
              <small>{props.webhook.url}</small>
            </div>

            <div className="d-flex flex-row justify-content-between align-items-baseline">
              {props.webhook.lastNotifications && (
                <div className="d-none d-md-inline mt-2">
                  <LastNotificationsModal notifications={props.webhook.lastNotifications} />
                </div>
              )}

              {(isUndefined(props.webhook.packages) || props.webhook.packages.length === 0) && (
                <div className="ms-auto mt-2">
                  <ElementWithTooltip
                    element={
                      <span
                        className={`d-flex flex-row align-items-center badge bg-warning rounded-pill ${styles.badgeNoPackages}`}
                      >
                        <TiWarningOutline />
                        <span className="ms-1">No packages</span>
                      </span>
                    }
                    tooltipMessage="This webhook is not associated to any packages."
                    active
                    visibleTooltip
                  />
                </div>
              )}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}
Example #11
Source File: Form.tsx    From hub with Apache License 2.0 4 votes vote down vote up
WebhookForm = (props: Props) => {
  const { ctx } = useContext(AppCtx);
  const form = useRef<HTMLFormElement>(null);
  const urlInput = useRef<RefInputField>(null);
  const contentTypeInput = useRef<RefInputField>(null);
  const [isSending, setIsSending] = useState(false);
  const [isValidated, setIsValidated] = useState(false);
  const [apiError, setApiError] = useState<string | null>(null);
  const [selectedPackages, setSelectedPackages] = useState<Package[]>(
    !isUndefined(props.webhook) && props.webhook.packages ? props.webhook.packages : []
  );
  const [eventKinds, setEventKinds] = useState<EventKind[]>(
    !isUndefined(props.webhook) ? props.webhook.eventKinds : [EventKind.NewPackageRelease]
  );
  const [isActive, setIsActive] = useState<boolean>(!isUndefined(props.webhook) ? props.webhook.active : true);
  const [contentType, setContentType] = useState<string>(
    !isUndefined(props.webhook) && props.webhook.contentType ? props.webhook.contentType : ''
  );
  const [template, setTemplate] = useState<string>(
    !isUndefined(props.webhook) && props.webhook.template ? props.webhook.template : ''
  );
  const [isAvailableTest, setIsAvailableTest] = useState<boolean>(false);
  const [currentTestWebhook, setCurrentTestWebhook] = useState<TestWebhook | null>(null);
  const [isTestSent, setIsTestSent] = useState<boolean>(false);
  const [isSendingTest, setIsSendingTest] = useState<boolean>(false);

  const getPayloadKind = (): PayloadKind => {
    let currentPayloadKind: PayloadKind = DEFAULT_PAYLOAD_KIND;
    if (!isUndefined(props.webhook) && props.webhook.contentType && props.webhook.template) {
      currentPayloadKind = PayloadKind.custom;
    }
    return currentPayloadKind;
  };

  const [payloadKind, setPayloadKind] = useState<PayloadKind>(getPayloadKind());

  const onCloseForm = () => {
    props.onClose();
  };

  const onContentTypeChange = (e: ChangeEvent<HTMLInputElement>) => {
    setContentType(e.target.value);
  };

  async function handleWebhook(webhook: Webhook) {
    try {
      setIsSending(true);
      if (isUndefined(props.webhook)) {
        await API.addWebhook(webhook, ctx.prefs.controlPanel.selectedOrg!);
      } else {
        await API.updateWebhook(webhook, ctx.prefs.controlPanel.selectedOrg!);
      }
      setIsSending(false);
      props.onSuccess();
      onCloseForm();
    } catch (err: any) {
      setIsSending(false);
      if (err.kind !== ErrorKind.Unauthorized) {
        let error = compoundErrorMessage(
          err,
          `An error occurred ${isUndefined(props.webhook) ? 'adding' : 'updating'} the webhook`
        );
        if (!isUndefined(props.webhook) && err.kind === ErrorKind.Forbidden) {
          error = `You do not have permissions to ${isUndefined(props.webhook) ? 'add' : 'update'} the webhook ${
            isUndefined(props.webhook) ? 'to' : 'from'
          } the organization.`;
        }
        setApiError(error);
      } else {
        props.onAuthError();
      }
    }
  }

  async function triggerWebhookTest(webhook: TestWebhook) {
    try {
      setIsSendingTest(true);
      setIsTestSent(false);
      await API.triggerWebhookTest(webhook);
      setIsTestSent(true);
      setIsSendingTest(false);
    } catch (err: any) {
      setIsSendingTest(false);
      if (err.kind !== ErrorKind.Unauthorized) {
        let error = compoundErrorMessage(err, `An error occurred testing the webhook`);
        setApiError(error);
      } else {
        props.onAuthError();
      }
    }
  }

  const triggerTest = () => {
    if (!isNull(currentTestWebhook)) {
      cleanApiError();
      triggerWebhookTest(currentTestWebhook);
    }
  };

  const submitForm = () => {
    if (form.current) {
      cleanApiError();
      const { isValid, webhook } = validateForm(form.current);
      if (isValid && !isNull(webhook)) {
        handleWebhook(webhook);
      }
    }
  };

  const validateForm = (form: HTMLFormElement): FormValidation => {
    let webhook: Webhook | null = null;
    const formData = new FormData(form);
    const isValid = form.checkValidity() && selectedPackages.length > 0;

    if (isValid) {
      webhook = {
        name: formData.get('name') as string,
        url: formData.get('url') as string,
        secret: formData.get('secret') as string,
        description: formData.get('description') as string,
        eventKinds: eventKinds,
        active: isActive,
        packages: selectedPackages,
      };

      if (payloadKind === PayloadKind.custom) {
        webhook = {
          ...webhook,
          template: template,
          contentType: contentType,
        };
      }

      if (props.webhook) {
        webhook = {
          ...webhook,
          webhookId: props.webhook.webhookId,
        };
      }
    }
    setIsValidated(true);
    return { isValid, webhook };
  };

  const addPackage = (packageItem: Package) => {
    const packagesList = [...selectedPackages];
    packagesList.push(packageItem);
    setSelectedPackages(packagesList);
  };

  const deletePackage = (packageId: string) => {
    const packagesList = selectedPackages.filter((item: Package) => item.packageId !== packageId);
    setSelectedPackages(packagesList);
  };

  const getPackagesIds = (): string[] => {
    return selectedPackages.map((item: Package) => item.packageId);
  };

  const updateEventKindList = (eventKind: EventKind) => {
    let updatedEventKinds: EventKind[] = [...eventKinds];
    if (eventKinds.includes(eventKind)) {
      // At least event kind must be selected
      if (updatedEventKinds.length > 1) {
        updatedEventKinds = eventKinds.filter((kind: EventKind) => kind !== eventKind);
      }
    } else {
      updatedEventKinds.push(eventKind);
    }
    setEventKinds(updatedEventKinds);
  };

  const cleanApiError = () => {
    if (!isNull(apiError)) {
      setApiError(null);
    }
  };

  const updateTemplate = (e: ChangeEvent<HTMLTextAreaElement>) => {
    setTemplate(e.target.value);
    checkTestAvailability();
  };

  const checkTestAvailability = () => {
    const formData = new FormData(form.current!);

    let webhook: TestWebhook = {
      url: formData.get('url') as string,
      eventKinds: eventKinds,
    };

    if (payloadKind === PayloadKind.custom) {
      webhook = {
        ...webhook,
        template: template,
        contentType: contentType,
      };
    }

    const isFilled = Object.values(webhook).every((x) => x !== null && x !== '');

    if (urlInput.current!.checkValidity() && isFilled) {
      setCurrentTestWebhook(webhook);
      setIsAvailableTest(true);
    } else {
      setCurrentTestWebhook(null);
      setIsAvailableTest(false);
    }
  };

  useEffect(() => {
    checkTestAvailability();
  }, []); /* eslint-disable-line react-hooks/exhaustive-deps */

  const getPublisher = (pkg: Package): JSX.Element => {
    return (
      <>
        {pkg.repository.userAlias || pkg.repository.organizationDisplayName || pkg.repository.organizationName}

        <small className="ms-2">
          (<span className={`text-uppercase text-muted d-none d-sm-inline ${styles.legend}`}>Repo: </span>
          <span className="text-dark">{pkg.repository.displayName || pkg.repository.name}</span>)
        </small>
      </>
    );
  };

  return (
    <div>
      <div className="mb-4 pb-2 border-bottom">
        <button
          className={`btn btn-link text-dark btn-sm ps-0 d-flex align-items-center ${styles.link}`}
          onClick={onCloseForm}
          aria-label="Back to webhooks list"
        >
          <IoIosArrowBack className="me-2" />
          Back to webhooks list
        </button>
      </div>

      <div className="mt-2">
        <form
          ref={form}
          data-testid="webhookForm"
          className={classnames('w-100', { 'needs-validation': !isValidated }, { 'was-validated': isValidated })}
          onClick={() => setApiError(null)}
          autoComplete="off"
          noValidate
        >
          <div className="d-flex">
            <div className="col-md-8">
              <InputField
                type="text"
                label="Name"
                labelLegend={<small className="ms-1 fst-italic">(Required)</small>}
                name="name"
                value={!isUndefined(props.webhook) ? props.webhook.name : ''}
                invalidText={{
                  default: 'This field is required',
                }}
                validateOnBlur
                required
              />
            </div>
          </div>

          <div className="d-flex">
            <div className="col-md-8">
              <InputField
                type="text"
                label="Description"
                name="description"
                value={!isUndefined(props.webhook) ? props.webhook.description : ''}
              />
            </div>
          </div>

          <div>
            <label className={`form-label fw-bold ${styles.label}`} htmlFor="url">
              Url<small className="ms-1 fst-italic">(Required)</small>
            </label>
            <div className="form-text text-muted mb-2 mt-0">
              A POST request will be sent to the provided URL when any of the events selected in the triggers section
              happens.
            </div>
            <div className="d-flex">
              <div className="col-md-8">
                <InputField
                  ref={urlInput}
                  type="url"
                  name="url"
                  value={!isUndefined(props.webhook) ? props.webhook.url : ''}
                  invalidText={{
                    default: 'This field is required',
                    typeMismatch: 'Please enter a valid url',
                  }}
                  onChange={checkTestAvailability}
                  validateOnBlur
                  required
                />
              </div>
            </div>
          </div>

          <div>
            <label className={`form-label fw-bold ${styles.label}`} htmlFor="secret">
              Secret
            </label>
            <div className="form-text text-muted mb-2 mt-0">
              If you provide a secret, we'll send it to you in the <span className="fw-bold">X-ArtifactHub-Secret</span>{' '}
              header on each request. This will allow you to validate that the request comes from ArtifactHub.
            </div>
            <div className="d-flex">
              <div className="col-md-8">
                <InputField type="text" name="secret" value={!isUndefined(props.webhook) ? props.webhook.secret : ''} />
              </div>
            </div>
          </div>

          <div className="mb-3">
            <div className="form-check form-switch ps-0">
              <label htmlFor="active" className={`form-check-label fw-bold ${styles.label}`}>
                Active
              </label>
              <input
                id="active"
                type="checkbox"
                role="switch"
                className={`position-absolute ms-2 form-check-input ${styles.checkbox}`}
                value="true"
                onChange={() => setIsActive(!isActive)}
                checked={isActive}
              />
            </div>

            <div className="form-text text-muted mt-2">
              This flag indicates if the webhook is active or not. Inactive webhooks will not receive notifications.
            </div>
          </div>

          <div className="h4 pb-2 mt-4 mt-md-5 mb-4 border-bottom">Triggers</div>

          <div className="my-4">
            <label className={`form-label fw-bold ${styles.label}`} htmlFor="kind" id="events-group">
              Events
            </label>

            <div role="group" aria-labelledby="events-group">
              {PACKAGE_SUBSCRIPTIONS_LIST.map((subs: SubscriptionItem) => {
                return (
                  <CheckBox
                    key={`check_${subs.kind}`}
                    name="eventKind"
                    value={subs.kind.toString()}
                    device="all"
                    label={subs.title}
                    checked={eventKinds.includes(subs.kind)}
                    onChange={() => {
                      updateEventKindList(subs.kind);
                      checkTestAvailability();
                    }}
                  />
                );
              })}
            </div>
          </div>

          <div className="mb-4">
            <label className={`form-label fw-bold ${styles.label}`} htmlFor="packages" id="webhook-pkg-list">
              Packages<small className="ms-1 fst-italic">(Required)</small>
            </label>
            <div className="form-text text-muted mb-4 mt-0">
              When the events selected happen for any of the packages you've chosen, a notification will be triggered
              and the configured url will be called. At least one package must be selected.
            </div>
            <div className="mb-3 row">
              <div className="col-12 col-xxl-10 col-xxxl-8">
                <SearchPackages disabledPackages={getPackagesIds()} onSelection={addPackage} label="webhook-pkg-list" />
              </div>
            </div>

            {isValidated && selectedPackages.length === 0 && (
              <div className="invalid-feedback mt-0 d-block">At least one package has to be selected</div>
            )}

            {selectedPackages.length > 0 && (
              <div className="row">
                <div className="col-12 col-xxl-10 col-xxxl-8">
                  <table className={`table table-hover table-sm border transparentBorder text-break ${styles.table}`}>
                    <thead>
                      <tr className={styles.tableTitle}>
                        <th scope="col" className={`align-middle d-none d-sm-table-cell ${styles.fitCell}`}></th>
                        <th scope="col" className={`align-middle ${styles.packageCell}`}>
                          Package
                        </th>
                        <th scope="col" className="align-middle w-50 d-none d-sm-table-cell">
                          Publisher
                        </th>
                        <th scope="col" className={`align-middle ${styles.fitCell}`}></th>
                      </tr>
                    </thead>
                    <tbody className={styles.body}>
                      {selectedPackages.map((item: Package) => (
                        <tr key={`subs_${item.packageId}`} data-testid="packageTableCell">
                          <td className="align-middle text-center d-none d-sm-table-cell">
                            <RepositoryIcon kind={item.repository.kind} className={`${styles.icon} h-auto mx-2`} />
                          </td>
                          <td className="align-middle">
                            <div className="d-flex flex-row align-items-center">
                              <div
                                className={`d-flex align-items-center justify-content-center overflow-hidden p-1 rounded-circle border border-2 bg-white ${styles.imageWrapper} imageWrapper`}
                              >
                                <Image
                                  alt={item.displayName || item.name}
                                  imageId={item.logoImageId}
                                  className="mw-100 mh-100 fs-4"
                                  kind={item.repository.kind}
                                />
                              </div>

                              <div className={`ms-2 text-dark ${styles.cellWrapper}`}>
                                <div className="text-truncate">
                                  {item.displayName || item.name}
                                  <span className={`d-inline d-sm-none ${styles.legend}`}>
                                    <span className="mx-2">/</span>
                                    {getPublisher(item)}
                                  </span>
                                </div>
                              </div>
                            </div>
                          </td>
                          <td className="align-middle position-relative text-dark d-none d-sm-table-cell">
                            <div className={`d-table w-100 ${styles.cellWrapper}`}>
                              <div className="text-truncate">{getPublisher(item)}</div>
                            </div>
                          </td>

                          <td className="align-middle">
                            <button
                              className={`btn btn-link btn-sm mx-2 ${styles.closeBtn}`}
                              type="button"
                              onClick={(event: ReactMouseEvent<HTMLButtonElement, MouseEvent>) => {
                                event.preventDefault();
                                event.stopPropagation();
                                deletePackage(item.packageId);
                              }}
                              aria-label="Delete package from webhook"
                            >
                              <MdClose className="text-danger fs-5" />
                            </button>
                          </td>
                        </tr>
                      ))}
                    </tbody>
                  </table>
                </div>
              </div>
            )}
          </div>

          <div className="h4 pb-2 mt-4 mt-md-5 mb-4 border-bottom">Payload</div>

          <div className="d-flex flex-row mb-3">
            {PAYLOAD_KINDS_LIST.map((item: PayloadKindsItem) => {
              return (
                <div className="form-check me-4" key={`payload_${item.kind}`}>
                  <input
                    className="form-check-input"
                    type="radio"
                    id={`payload_${item.kind}`}
                    name="payloadKind"
                    value={item.name}
                    checked={payloadKind === item.kind}
                    onChange={() => {
                      setPayloadKind(item.kind);
                      setIsValidated(false);
                      checkTestAvailability();
                    }}
                  />
                  <label className="form-check-label" htmlFor={`payload_${item.kind}`}>
                    {item.title}
                  </label>
                </div>
              );
            })}
          </div>

          {payloadKind === PayloadKind.custom && (
            <div className="lh-base">
              <div className="form-text text-muted mb-3">
                It's possible to customize the payload used to notify your service. This may help integrating
                ArtifactHub webhooks with other services without requiring you to write any code. To integrate
                ArtifactHub webhooks with Slack, for example, you could use a custom payload using the following
                template:
                <div className="my-3 w-100">
                  <div
                    className={`alert alert-light text-nowrap ${styles.codeWrapper}`}
                    role="alert"
                    aria-live="off"
                    aria-atomic="true"
                  >
                    {'{'}
                    <br />
                    <span className="ms-3">
                      {`"text": "Package`} <span className="fw-bold">{`{{ .Package.Name }}`}</span> {`version`}{' '}
                      <span className="fw-bold">{`{{ .Package.Version }}`}</span> released!{' '}
                      <span className="fw-bold">{`{{ .Package.URL }}`}</span>
                      {`"`}
                      <br />
                      {'}'}
                    </span>
                  </div>
                </div>
              </div>
            </div>
          )}

          <div className="d-flex">
            <div className="col-md-8">
              <InputField
                ref={contentTypeInput}
                type="text"
                label="Request Content-Type"
                name="contentType"
                value={contentType}
                placeholder={payloadKind === PayloadKind.default ? 'application/cloudevents+json' : 'application/json'}
                disabled={payloadKind === PayloadKind.default}
                required={payloadKind !== PayloadKind.default}
                invalidText={{
                  default: 'This field is required',
                }}
                onChange={(e: ChangeEvent<HTMLInputElement>) => {
                  onContentTypeChange(e);
                  checkTestAvailability();
                }}
              />
            </div>
          </div>

          <div className=" mb-4">
            <label className={`form-label fw-bold ${styles.label}`} htmlFor="template">
              Template
            </label>

            {payloadKind === PayloadKind.custom && (
              <div className="form-text text-muted mb-4 mt-0">
                Custom payloads are generated using{' '}
                <ExternalLink
                  href="https://golang.org/pkg/text/template/"
                  className="fw-bold text-dark"
                  label="Open Go templates documentation"
                >
                  Go templates
                </ExternalLink>
                . Below you will find a list of the variables available for use in your template.
              </div>
            )}

            <div className="row">
              <div className="col col-xxl-10 col-xxxl-8">
                <AutoresizeTextarea
                  name="template"
                  value={payloadKind === PayloadKind.default ? DEFAULT_PAYLOAD_TEMPLATE : template}
                  disabled={payloadKind === PayloadKind.default}
                  required={payloadKind !== PayloadKind.default}
                  invalidText="This field is required"
                  minRows={6}
                  onChange={updateTemplate}
                />
              </div>
            </div>
          </div>

          <div className="mb-3">
            <label className={`form-label fw-bold ${styles.label}`} htmlFor="template">
              Variables reference
            </label>
            <div className="row">
              <div className="col col-xxxl-8 overflow-auto">
                <small className={`text-muted ${styles.tableWrapper}`}>
                  <table className={`table table-sm border ${styles.variablesTable}`}>
                    <tbody>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .BaseURL }}`}</span>
                        </th>
                        <td>Artifact Hub deployment base url.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Event.ID }}`}</span>
                        </th>
                        <td>Id of the event triggering the notification.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Event.Kind }}`}</span>
                        </th>
                        <td>
                          Kind of the event triggering notification. Possible values are{' '}
                          <span className="fw-bold">package.new-release</span> and{' '}
                          <span className="fw-bold">package.security-alert</span>.
                        </td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Name }}`}</span>
                        </th>
                        <td>Name of the package.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Version }}`}</span>
                        </th>
                        <td>Version of the new release.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.URL }}`}</span>
                        </th>
                        <td>ArtifactHub URL of the package.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Changes }}`}</span>
                        </th>
                        <td>List of changes this package version introduces.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Changes[i].Kind }}`}</span>
                        </th>
                        <td>
                          Kind of the change. Possible values are <span className="fw-bold">added</span>,{' '}
                          <span className="fw-bold">changed</span>, <span className="fw-bold">deprecated</span>,{' '}
                          <span className="fw-bold">removed</span>, <span className="fw-bold">fixed</span> and{' '}
                          <span className="fw-bold">security</span>. When the change kind is not provided, the value
                          will be empty.
                        </td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Changes[i].Description }}`}</span>
                        </th>
                        <td>Brief text explaining the change.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Changes[i].Links }}`}</span>
                        </th>
                        <td>List of links related to the change.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Changes[i].Links[i].Name }}`}</span>
                        </th>
                        <td>Name of the link.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Changes[i].Links[i].URL }}`}</span>
                        </th>
                        <td>Url of the link.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.ContainsSecurityUpdates }}`}</span>
                        </th>
                        <td>Boolean flag that indicates whether this package contains security updates or not.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Prerelease }}`}</span>
                        </th>
                        <td>Boolean flag that indicates whether this package version is a pre-release or not.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Repository.Kind }}`}</span>
                        </th>
                        <td>
                          Kind of the repository associated with the notification. Possible values are{' '}
                          <span className="fw-bold">falco</span>, <span className="fw-bold">helm</span>,{' '}
                          <span className="fw-bold">olm</span> and <span className="fw-bold">opa</span>.
                        </td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Repository.Name }}`}</span>
                        </th>
                        <td>Name of the repository.</td>
                      </tr>
                      <tr>
                        <th scope="row">
                          <span className="text-nowrap">{`{{ .Package.Repository.Publisher }}`}</span>
                        </th>
                        <td>
                          Publisher of the repository. If the owner is a user it'll be the user alias. If it's an
                          organization, it'll be the organization name.
                        </td>
                      </tr>
                    </tbody>
                  </table>
                </small>
              </div>
            </div>
          </div>

          <div className={`mt-4 mt-md-5 ${styles.btnWrapper}`}>
            <div className="d-flex flex-row justify-content-between">
              <div className="d-flex flex-row align-items-center me-3">
                <button
                  type="button"
                  className="btn btn-sm btn-success"
                  onClick={triggerTest}
                  disabled={!isAvailableTest || isSendingTest}
                  aria-label="Test webhook"
                >
                  {isSendingTest ? (
                    <>
                      <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
                      <span className="ms-2">
                        Testing <span className="d-none d-md-inline"> webhook</span>
                      </span>
                    </>
                  ) : (
                    <div className="d-flex flex-row align-items-center text-uppercase">
                      <RiTestTubeFill className="me-2" />{' '}
                      <div>
                        Test <span className="d-none d-sm-inline-block">webhook</span>
                      </div>
                    </div>
                  )}
                </button>

                {isTestSent && (
                  <span className="text-success ms-2" data-testid="testWebhookTick">
                    <FaCheck />
                  </span>
                )}
              </div>

              <div className="ms-auto">
                <button
                  type="button"
                  className="btn btn-sm btn-outline-secondary me-3"
                  onClick={onCloseForm}
                  aria-label="Cancel"
                >
                  <div className="d-flex flex-row align-items-center text-uppercase">
                    <MdClose className="me-2" />
                    <div>Cancel</div>
                  </div>
                </button>

                <button
                  className="btn btn-sm btn-outline-secondary"
                  type="button"
                  disabled={isSending}
                  onClick={submitForm}
                  aria-label="Add webhook"
                >
                  {isSending ? (
                    <>
                      <span className="spinner-grow spinner-grow-sm" role="status" aria-hidden="true" />
                      <span className="ms-2">{isUndefined(props.webhook) ? 'Adding' : 'Updating'} webhook</span>
                    </>
                  ) : (
                    <div className="d-flex flex-row align-items-center text-uppercase">
                      {isUndefined(props.webhook) ? (
                        <>
                          <MdAddCircle className="me-2" />
                          <span>Add</span>
                        </>
                      ) : (
                        <>
                          <FaPencilAlt className="me-2" />
                          <div>Save</div>
                        </>
                      )}
                    </div>
                  )}
                </button>
              </div>
            </div>

            <Alert message={apiError} type="danger" onClose={() => setApiError(null)} />
          </div>
        </form>
      </div>
    </div>
  );
}