mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 12:27:31 +00:00
feat: select all on page for secrets tables and fix multipage select behavior for actions
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
import { faCheck, faMinus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
@@ -15,6 +15,7 @@ export type CheckboxProps = Omit<
|
|||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
checkIndicatorBg?: string | undefined;
|
checkIndicatorBg?: string | undefined;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
|
isIndeterminate?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Checkbox = ({
|
export const Checkbox = ({
|
||||||
@@ -26,6 +27,7 @@ export const Checkbox = ({
|
|||||||
isRequired,
|
isRequired,
|
||||||
checkIndicatorBg,
|
checkIndicatorBg,
|
||||||
isError,
|
isError,
|
||||||
|
isIndeterminate,
|
||||||
...props
|
...props
|
||||||
}: CheckboxProps): JSX.Element => {
|
}: CheckboxProps): JSX.Element => {
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +47,11 @@ export const Checkbox = ({
|
|||||||
id={id}
|
id={id}
|
||||||
>
|
>
|
||||||
<CheckboxPrimitive.Indicator className={`${checkIndicatorBg || "text-bunker-800"}`}>
|
<CheckboxPrimitive.Indicator className={`${checkIndicatorBg || "text-bunker-800"}`}>
|
||||||
<FontAwesomeIcon icon={faCheck} size="sm" />
|
{isIndeterminate ? (
|
||||||
|
<FontAwesomeIcon icon={faMinus} size="sm" />
|
||||||
|
) : (
|
||||||
|
<FontAwesomeIcon icon={faCheck} size="sm" />
|
||||||
|
)}
|
||||||
</CheckboxPrimitive.Indicator>
|
</CheckboxPrimitive.Indicator>
|
||||||
</CheckboxPrimitive.Root>
|
</CheckboxPrimitive.Root>
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -2,29 +2,33 @@ import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
|
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
|
||||||
|
|
||||||
|
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||||
|
|
||||||
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
|
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
|
||||||
// This is first step and the whole secret crud will be moved to this global page scope state
|
// This is first step and the whole secret crud will be moved to this global page scope state
|
||||||
// this will allow more stuff like undo grouping stuffs etc
|
// this will allow more stuff like undo grouping stuffs etc
|
||||||
type SelectedSecretState = {
|
type SelectedSecretState = {
|
||||||
selectedSecret: Record<string, boolean>;
|
selectedSecret: Record<string, SecretV3RawSanitized>;
|
||||||
action: {
|
action: {
|
||||||
toggle: (id: string) => void;
|
toggle: (secret: SecretV3RawSanitized) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
|
set: (secrets: Record<string, SecretV3RawSanitized>) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
|
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
|
||||||
selectedSecret: {},
|
selectedSecret: {},
|
||||||
action: {
|
action: {
|
||||||
toggle: (id) =>
|
toggle: (secret) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const isChecked = Boolean(state.selectedSecret?.[id]);
|
const isChecked = Boolean(state.selectedSecret?.[secret.id]);
|
||||||
const newChecks = { ...state.selectedSecret };
|
const newChecks = { ...state.selectedSecret };
|
||||||
// remove selection if its present else add it
|
// remove selection if its present else add it
|
||||||
if (isChecked) delete newChecks[id];
|
if (isChecked) delete newChecks[secret.id];
|
||||||
else newChecks[id] = true;
|
else newChecks[secret.id] = secret;
|
||||||
return { selectedSecret: newChecks };
|
return { selectedSecret: newChecks };
|
||||||
}),
|
}),
|
||||||
reset: () => set({ selectedSecret: {} })
|
reset: () => set({ selectedSecret: {} }),
|
||||||
|
set: (secrets) => set({ selectedSecret: secrets })
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
@@ -9,7 +9,7 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import NavHeader from "@app/components/navigation/NavHeader";
|
import NavHeader from "@app/components/navigation/NavHeader";
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { PermissionDeniedBanner } from "@app/components/permissions";
|
import { PermissionDeniedBanner } from "@app/components/permissions";
|
||||||
import { ContentLoader, Pagination } from "@app/components/v2";
|
import { Checkbox, ContentLoader, Pagination, Tooltip } from "@app/components/v2";
|
||||||
import {
|
import {
|
||||||
ProjectPermissionActions,
|
ProjectPermissionActions,
|
||||||
ProjectPermissionSub,
|
ProjectPermissionSub,
|
||||||
@@ -39,7 +39,11 @@ import { PitDrawer } from "./components/PitDrawer";
|
|||||||
import { SecretDropzone } from "./components/SecretDropzone";
|
import { SecretDropzone } from "./components/SecretDropzone";
|
||||||
import { SecretListView } from "./components/SecretListView";
|
import { SecretListView } from "./components/SecretListView";
|
||||||
import { SnapshotView } from "./components/SnapshotView";
|
import { SnapshotView } from "./components/SnapshotView";
|
||||||
import { StoreProvider } from "./SecretMainPage.store";
|
import {
|
||||||
|
StoreProvider,
|
||||||
|
useSelectedSecretActions,
|
||||||
|
useSelectedSecrets
|
||||||
|
} from "./SecretMainPage.store";
|
||||||
import { Filter, RowType } from "./SecretMainPage.types";
|
import { Filter, RowType } from "./SecretMainPage.types";
|
||||||
|
|
||||||
const LOADER_TEXT = [
|
const LOADER_TEXT = [
|
||||||
@@ -48,7 +52,7 @@ const LOADER_TEXT = [
|
|||||||
"Getting secret import links..."
|
"Getting secret import links..."
|
||||||
];
|
];
|
||||||
|
|
||||||
export const SecretMainPage = () => {
|
const SecretMainPageContent = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
|
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -283,6 +287,33 @@ export const SecretMainPage = () => {
|
|||||||
}
|
}
|
||||||
}, [secretPath]);
|
}, [secretPath]);
|
||||||
|
|
||||||
|
const selectedSecrets = useSelectedSecrets();
|
||||||
|
const selectedSecretActions = useSelectedSecretActions();
|
||||||
|
|
||||||
|
const allRowsSelectedOnPage = useMemo(() => {
|
||||||
|
if (secrets?.every((secret) => selectedSecrets[secret.id]))
|
||||||
|
return { isChecked: true, isIndeterminate: false };
|
||||||
|
|
||||||
|
if (secrets?.some((secret) => selectedSecrets[secret.id]))
|
||||||
|
return { isChecked: true, isIndeterminate: true };
|
||||||
|
|
||||||
|
return { isChecked: false, isIndeterminate: false };
|
||||||
|
}, [selectedSecrets, secrets]);
|
||||||
|
|
||||||
|
const toggleSelectAllRows = () => {
|
||||||
|
const newChecks = { ...selectedSecrets };
|
||||||
|
|
||||||
|
secrets?.forEach((secret) => {
|
||||||
|
if (allRowsSelectedOnPage.isChecked) {
|
||||||
|
delete newChecks[secret.id];
|
||||||
|
} else {
|
||||||
|
newChecks[secret.id] = secret;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedSecretActions.set(newChecks);
|
||||||
|
};
|
||||||
|
|
||||||
if (isDetailsLoading) {
|
if (isDetailsLoading) {
|
||||||
return <ContentLoader text={LOADER_TEXT} />;
|
return <ContentLoader text={LOADER_TEXT} />;
|
||||||
}
|
}
|
||||||
@@ -300,169 +331,193 @@ export const SecretMainPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StoreProvider>
|
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||||
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
<SecretV2MigrationSection />
|
||||||
<SecretV2MigrationSection />
|
<div className="relative right-6 -top-2 mb-2 ml-6">
|
||||||
<div className="relative right-6 -top-2 mb-2 ml-6">
|
<NavHeader
|
||||||
<NavHeader
|
pageName={t("dashboard.title")}
|
||||||
pageName={t("dashboard.title")}
|
currentEnv={environment}
|
||||||
currentEnv={environment}
|
userAvailableEnvs={currentWorkspace?.environments}
|
||||||
userAvailableEnvs={currentWorkspace?.environments}
|
isFolderMode
|
||||||
isFolderMode
|
secretPath={secretPath}
|
||||||
|
isProjectRelated
|
||||||
|
onEnvChange={handleEnvChange}
|
||||||
|
isProtectedBranch={isProtectedBranch}
|
||||||
|
protectionPolicyName={boardPolicy?.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isRollbackMode ? (
|
||||||
|
<>
|
||||||
|
<ActionBar
|
||||||
|
environment={environment}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
projectSlug={projectSlug}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
isProjectRelated
|
isVisible={isVisible}
|
||||||
onEnvChange={handleEnvChange}
|
filter={filter}
|
||||||
isProtectedBranch={isProtectedBranch}
|
tags={tags}
|
||||||
protectionPolicyName={boardPolicy?.name}
|
onVisibilityToggle={handleToggleVisibility}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
onToggleTagFilter={handleTagToggle}
|
||||||
|
snapshotCount={snapshotCount || 0}
|
||||||
|
isSnapshotCountLoading={isSnapshotCountLoading}
|
||||||
|
onToggleRowType={handleToggleRowType}
|
||||||
|
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
|
||||||
{!isRollbackMode ? (
|
<div className="flex flex-col" id="dashboard">
|
||||||
<>
|
{isNotEmpty && (
|
||||||
<ActionBar
|
<div
|
||||||
secrets={secrets}
|
className={twMerge(
|
||||||
environment={environment}
|
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
|
||||||
workspaceId={workspaceId}
|
)}
|
||||||
projectSlug={projectSlug}
|
>
|
||||||
secretPath={secretPath}
|
|
||||||
isVisible={isVisible}
|
|
||||||
filter={filter}
|
|
||||||
tags={tags}
|
|
||||||
onVisibilityToggle={handleToggleVisibility}
|
|
||||||
onSearchChange={handleSearchChange}
|
|
||||||
onToggleTagFilter={handleTagToggle}
|
|
||||||
snapshotCount={snapshotCount || 0}
|
|
||||||
isSnapshotCountLoading={isSnapshotCountLoading}
|
|
||||||
onToggleRowType={handleToggleRowType}
|
|
||||||
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
|
||||||
/>
|
|
||||||
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300">
|
|
||||||
<div className="flex flex-col" id="dashboard">
|
|
||||||
{isNotEmpty && (
|
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2"
|
||||||
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
|
role="button"
|
||||||
)}
|
tabIndex={0}
|
||||||
|
onClick={handleSortToggle}
|
||||||
|
onKeyDown={(evt) => {
|
||||||
|
if (evt.key === "Enter") handleSortToggle();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ width: "2.8rem" }} className="flex-shrink-0 px-4 py-3" />
|
<Tooltip
|
||||||
<div
|
className="max-w-[20rem] whitespace-nowrap"
|
||||||
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2"
|
content={
|
||||||
role="button"
|
totalCount > 0
|
||||||
tabIndex={0}
|
? `${
|
||||||
onClick={handleSortToggle}
|
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
|
||||||
onKeyDown={(evt) => {
|
} all secrets on page`
|
||||||
if (evt.key === "Enter") handleSortToggle();
|
: ""
|
||||||
}}
|
}
|
||||||
>
|
>
|
||||||
Key
|
<div>
|
||||||
<FontAwesomeIcon
|
<Checkbox
|
||||||
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
|
isDisabled={totalCount === 0}
|
||||||
className="ml-2"
|
id="checkbox-select-all-rows"
|
||||||
/>
|
onClick={(e) => e.stopPropagation()}
|
||||||
</div>
|
isChecked={allRowsSelectedOnPage.isChecked}
|
||||||
<div className="flex-grow px-4 py-2">Value</div>
|
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
|
||||||
|
onCheckedChange={toggleSelectAllRows}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="mx-2 h-4 w-[1px] bg-mineshaft-400" />
|
||||||
|
Key
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
|
||||||
|
className="ml-2"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex-grow px-4 py-2">Value</div>
|
||||||
{canReadSecret && imports?.length && (
|
</div>
|
||||||
<SecretImportListView
|
)}
|
||||||
searchTerm={debouncedSearchFilter}
|
{canReadSecret && imports?.length && (
|
||||||
secretImports={imports}
|
<SecretImportListView
|
||||||
isFetching={isDetailsFetching}
|
searchTerm={debouncedSearchFilter}
|
||||||
environment={environment}
|
secretImports={imports}
|
||||||
workspaceId={workspaceId}
|
isFetching={isDetailsFetching}
|
||||||
secretPath={secretPath}
|
environment={environment}
|
||||||
importedSecrets={importedSecrets}
|
workspaceId={workspaceId}
|
||||||
/>
|
secretPath={secretPath}
|
||||||
)}
|
importedSecrets={importedSecrets}
|
||||||
{folders?.length && (
|
/>
|
||||||
<FolderListView
|
)}
|
||||||
folders={folders}
|
{folders?.length && (
|
||||||
environment={environment}
|
<FolderListView
|
||||||
workspaceId={workspaceId}
|
folders={folders}
|
||||||
secretPath={secretPath}
|
environment={environment}
|
||||||
onNavigateToFolder={handleResetFilter}
|
workspaceId={workspaceId}
|
||||||
/>
|
secretPath={secretPath}
|
||||||
)}
|
onNavigateToFolder={handleResetFilter}
|
||||||
{canReadSecret && dynamicSecrets?.length && (
|
/>
|
||||||
<DynamicSecretListView
|
)}
|
||||||
environment={environment}
|
{canReadSecret && dynamicSecrets?.length && (
|
||||||
projectSlug={projectSlug}
|
<DynamicSecretListView
|
||||||
secretPath={secretPath}
|
environment={environment}
|
||||||
dynamicSecrets={dynamicSecrets}
|
projectSlug={projectSlug}
|
||||||
/>
|
secretPath={secretPath}
|
||||||
)}
|
dynamicSecrets={dynamicSecrets}
|
||||||
{canReadSecret && secrets?.length && (
|
/>
|
||||||
<SecretListView
|
)}
|
||||||
secrets={secrets}
|
{canReadSecret && secrets?.length && (
|
||||||
tags={tags}
|
<SecretListView
|
||||||
isVisible={isVisible}
|
secrets={secrets}
|
||||||
environment={environment}
|
tags={tags}
|
||||||
workspaceId={workspaceId}
|
isVisible={isVisible}
|
||||||
secretPath={secretPath}
|
environment={environment}
|
||||||
isProtectedBranch={isProtectedBranch}
|
workspaceId={workspaceId}
|
||||||
/>
|
secretPath={secretPath}
|
||||||
)}
|
isProtectedBranch={isProtectedBranch}
|
||||||
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
|
/>
|
||||||
</div>
|
)}
|
||||||
|
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
|
||||||
</div>
|
</div>
|
||||||
{!isDetailsLoading && totalCount > 0 && (
|
</div>
|
||||||
<Pagination
|
{!isDetailsLoading && totalCount > 0 && (
|
||||||
startAdornment={
|
<Pagination
|
||||||
<SecretTableResourceCount
|
startAdornment={
|
||||||
dynamicSecretCount={totalDynamicSecretCount}
|
<SecretTableResourceCount
|
||||||
importCount={totalImportCount}
|
dynamicSecretCount={totalDynamicSecretCount}
|
||||||
secretCount={totalSecretCount}
|
importCount={totalImportCount}
|
||||||
folderCount={totalFolderCount}
|
secretCount={totalSecretCount}
|
||||||
/>
|
folderCount={totalFolderCount}
|
||||||
}
|
/>
|
||||||
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
|
}
|
||||||
count={totalCount}
|
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
|
||||||
page={page}
|
count={totalCount}
|
||||||
perPage={perPage}
|
page={page}
|
||||||
onChangePage={(newPage) => setPage(newPage)}
|
perPage={perPage}
|
||||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
onChangePage={(newPage) => setPage(newPage)}
|
||||||
/>
|
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||||
)}
|
|
||||||
<CreateSecretForm
|
|
||||||
environment={environment}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
secretPath={secretPath}
|
|
||||||
autoCapitalize={currentWorkspace?.autoCapitalization}
|
|
||||||
isProtectedBranch={isProtectedBranch}
|
|
||||||
/>
|
/>
|
||||||
<SecretDropzone
|
)}
|
||||||
secrets={secrets}
|
<CreateSecretForm
|
||||||
environment={environment}
|
|
||||||
workspaceId={workspaceId}
|
|
||||||
secretPath={secretPath}
|
|
||||||
isSmaller={isNotEmpty}
|
|
||||||
environments={currentWorkspace?.environments}
|
|
||||||
isProtectedBranch={isProtectedBranch}
|
|
||||||
/>
|
|
||||||
<PitDrawer
|
|
||||||
secretSnaphots={snapshotList}
|
|
||||||
snapshotId={snapshotId}
|
|
||||||
isDrawerOpen={popUp.snapshots.isOpen}
|
|
||||||
onOpenChange={(isOpen) => handlePopUpToggle("snapshots", isOpen)}
|
|
||||||
hasNextPage={hasNextSnapshotListPage}
|
|
||||||
fetchNextPage={fetchNextSnapshotList}
|
|
||||||
onSelectSnapshot={handleSelectSnapshot}
|
|
||||||
isFetchingNextPage={isFetchingNextSnapshotList}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<SnapshotView
|
|
||||||
snapshotId={snapshotId || ""}
|
|
||||||
environment={environment}
|
environment={environment}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
secrets={secrets}
|
autoCapitalize={currentWorkspace?.autoCapitalization}
|
||||||
folders={folders}
|
isProtectedBranch={isProtectedBranch}
|
||||||
snapshotCount={snapshotCount}
|
|
||||||
onGoBack={handleResetSnapshot}
|
|
||||||
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
<SecretDropzone
|
||||||
</div>
|
secrets={secrets}
|
||||||
</StoreProvider>
|
environment={environment}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
secretPath={secretPath}
|
||||||
|
isSmaller={isNotEmpty}
|
||||||
|
environments={currentWorkspace?.environments}
|
||||||
|
isProtectedBranch={isProtectedBranch}
|
||||||
|
/>
|
||||||
|
<PitDrawer
|
||||||
|
secretSnaphots={snapshotList}
|
||||||
|
snapshotId={snapshotId}
|
||||||
|
isDrawerOpen={popUp.snapshots.isOpen}
|
||||||
|
onOpenChange={(isOpen) => handlePopUpToggle("snapshots", isOpen)}
|
||||||
|
hasNextPage={hasNextSnapshotListPage}
|
||||||
|
fetchNextPage={fetchNextSnapshotList}
|
||||||
|
onSelectSnapshot={handleSelectSnapshot}
|
||||||
|
isFetchingNextPage={isFetchingNextSnapshotList}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<SnapshotView
|
||||||
|
snapshotId={snapshotId || ""}
|
||||||
|
environment={environment}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
secretPath={secretPath}
|
||||||
|
secrets={secrets}
|
||||||
|
folders={folders}
|
||||||
|
snapshotCount={snapshotCount}
|
||||||
|
onGoBack={handleResetSnapshot}
|
||||||
|
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const SecretMainPage = () => (
|
||||||
|
<StoreProvider>
|
||||||
|
<SecretMainPageContent />
|
||||||
|
</StoreProvider>
|
||||||
|
);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import {
|
|||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
|
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
|
||||||
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
|
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
|
||||||
import { SecretType, SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
|
import { SecretType, WsTag } from "@app/hooks/api/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PopUpNames,
|
PopUpNames,
|
||||||
@@ -69,8 +69,7 @@ import { FolderForm } from "./FolderForm";
|
|||||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
secrets?: SecretV3RawSanitized[];
|
// switch the secrets type as it gets decrypted after api call
|
||||||
// swtich the secrets type as it gets decrypted after api call
|
|
||||||
environment: string;
|
environment: string;
|
||||||
// @depreciated will be moving all these details to zustand
|
// @depreciated will be moving all these details to zustand
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -89,7 +88,6 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ActionBar = ({
|
export const ActionBar = ({
|
||||||
secrets = [],
|
|
||||||
environment,
|
environment,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
@@ -202,7 +200,7 @@ export const ActionBar = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSecretBulkDelete = async () => {
|
const handleSecretBulkDelete = async () => {
|
||||||
const bulkDeletedSecrets = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
|
const bulkDeletedSecrets = Object.values(selectedSecrets);
|
||||||
try {
|
try {
|
||||||
await deleteBatchSecretV3({
|
await deleteBatchSecretV3({
|
||||||
secretPath,
|
secretPath,
|
||||||
@@ -235,7 +233,7 @@ export const ActionBar = ({
|
|||||||
shouldOverwrite: boolean;
|
shouldOverwrite: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
|
const secretsToMove = Object.values(selectedSecrets);
|
||||||
const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({
|
const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
shouldOverwrite,
|
shouldOverwrite,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ type Props = {
|
|||||||
onDetailViewSecret: (sec: SecretV3RawSanitized) => void;
|
onDetailViewSecret: (sec: SecretV3RawSanitized) => void;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
onToggleSecretSelect: (id: string) => void;
|
onToggleSecretSelect: (secret: SecretV3RawSanitized) => void;
|
||||||
tags: WsTag[];
|
tags: WsTag[];
|
||||||
onCreateTag: () => void;
|
onCreateTag: () => void;
|
||||||
environment: string;
|
environment: string;
|
||||||
@@ -218,7 +218,7 @@ export const SecretItem = memo(
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
id={`checkbox-${secret.id}`}
|
id={`checkbox-${secret.id}`}
|
||||||
isChecked={isSelected}
|
isChecked={isSelected}
|
||||||
onCheckedChange={() => onToggleSecretSelect(secret.id)}
|
onCheckedChange={() => onToggleSecretSelect(secret)}
|
||||||
className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
|
className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
|
||||||
/>
|
/>
|
||||||
<FontAwesomeSymbol
|
<FontAwesomeSymbol
|
||||||
|
|||||||
@@ -304,7 +304,7 @@ export const SecretListView = ({
|
|||||||
environment={environment}
|
environment={environment}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
tags={wsTags}
|
tags={wsTags}
|
||||||
isSelected={selectedSecrets?.[secret.id]}
|
isSelected={Boolean(selectedSecrets?.[secret.id])}
|
||||||
onToggleSecretSelect={toggleSelectedSecret}
|
onToggleSecretSelect={toggleSelectedSecret}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
secret={secret}
|
secret={secret}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@@ -25,6 +25,7 @@ import { createNotification } from "@app/components/notifications";
|
|||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
@@ -67,7 +68,7 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
|||||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
|
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
|
||||||
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
|
import { TUpdateFolderBatchDTO } from "@app/hooks/api/secretFolders/types";
|
||||||
import { SecretType, TSecretFolder } from "@app/hooks/api/types";
|
import { SecretType, SecretV3RawSanitized, TSecretFolder } from "@app/hooks/api/types";
|
||||||
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
import { ProjectVersion } from "@app/hooks/api/workspace/types";
|
||||||
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
|
import { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
|
||||||
import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow";
|
import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow";
|
||||||
@@ -133,8 +134,9 @@ export const SecretOverviewPage = () => {
|
|||||||
>(new Map());
|
>(new Map());
|
||||||
|
|
||||||
const [selectedEntries, setSelectedEntries] = useState<{
|
const [selectedEntries, setSelectedEntries] = useState<{
|
||||||
[EntryType.FOLDER]: Record<string, boolean>;
|
// selectedEntries[name/key][envSlug][resource]
|
||||||
[EntryType.SECRET]: Record<string, boolean>;
|
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
|
||||||
|
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
|
||||||
}>({
|
}>({
|
||||||
[EntryType.FOLDER]: {},
|
[EntryType.FOLDER]: {},
|
||||||
[EntryType.SECRET]: {}
|
[EntryType.SECRET]: {}
|
||||||
@@ -152,23 +154,6 @@ export const SecretOverviewPage = () => {
|
|||||||
orderBy
|
orderBy
|
||||||
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
|
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
|
||||||
|
|
||||||
const toggleSelectedEntry = useCallback(
|
|
||||||
(type: EntryType, key: string) => {
|
|
||||||
const isChecked = Boolean(selectedEntries[type]?.[key]);
|
|
||||||
const newChecks = { ...selectedEntries };
|
|
||||||
|
|
||||||
// remove selection if its present else add it
|
|
||||||
if (isChecked) {
|
|
||||||
delete newChecks[type][key];
|
|
||||||
} else {
|
|
||||||
newChecks[type][key] = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedEntries(newChecks);
|
|
||||||
},
|
|
||||||
[selectedEntries]
|
|
||||||
);
|
|
||||||
|
|
||||||
const resetSelectedEntries = useCallback(() => {
|
const resetSelectedEntries = useCallback(() => {
|
||||||
setSelectedEntries({
|
setSelectedEntries({
|
||||||
[EntryType.FOLDER]: {},
|
[EntryType.FOLDER]: {},
|
||||||
@@ -551,6 +536,83 @@ export const SecretOverviewPage = () => {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const allRowsSelectedOnPage = useMemo(() => {
|
||||||
|
if (
|
||||||
|
(!secrets?.length ||
|
||||||
|
secrets?.every((secret) => selectedEntries[EntryType.SECRET][secret.key])) &&
|
||||||
|
(!folders?.length ||
|
||||||
|
folders?.every((folder) => selectedEntries[EntryType.FOLDER][folder.name]))
|
||||||
|
)
|
||||||
|
return { isChecked: true, isIndeterminate: false };
|
||||||
|
|
||||||
|
if (
|
||||||
|
secrets?.some((secret) => selectedEntries[EntryType.SECRET][secret.key]) ||
|
||||||
|
folders?.some((folder) => selectedEntries[EntryType.FOLDER][folder.name])
|
||||||
|
)
|
||||||
|
return { isChecked: true, isIndeterminate: true };
|
||||||
|
|
||||||
|
return { isChecked: false, isIndeterminate: false };
|
||||||
|
}, [selectedEntries, secrets, folders]);
|
||||||
|
|
||||||
|
const toggleSelectedEntry = useCallback(
|
||||||
|
(type: EntryType, key: string) => {
|
||||||
|
const isChecked = Boolean(selectedEntries[type]?.[key]);
|
||||||
|
const newChecks = { ...selectedEntries };
|
||||||
|
|
||||||
|
// remove selection if its present else add it
|
||||||
|
if (isChecked) {
|
||||||
|
delete newChecks[type][key];
|
||||||
|
} else {
|
||||||
|
newChecks[type][key] = {};
|
||||||
|
userAvailableEnvs.forEach((env) => {
|
||||||
|
const resource =
|
||||||
|
type === EntryType.SECRET
|
||||||
|
? getSecretByKey(env.slug, key)
|
||||||
|
: getFolderByNameAndEnv(key, env.slug);
|
||||||
|
|
||||||
|
if (resource) newChecks[type][key][env.slug] = resource;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedEntries(newChecks);
|
||||||
|
},
|
||||||
|
[selectedEntries, getFolderByNameAndEnv, getSecretByKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleSelectAllRows = () => {
|
||||||
|
const newChecks = { ...selectedEntries };
|
||||||
|
|
||||||
|
userAvailableEnvs.forEach((env) => {
|
||||||
|
secrets?.forEach((secret) => {
|
||||||
|
if (allRowsSelectedOnPage.isChecked) {
|
||||||
|
delete newChecks[EntryType.SECRET][secret.key];
|
||||||
|
} else {
|
||||||
|
if (!newChecks[EntryType.SECRET][secret.key])
|
||||||
|
newChecks[EntryType.SECRET][secret.key] = {};
|
||||||
|
|
||||||
|
const resource = getSecretByKey(env.slug, secret.key);
|
||||||
|
|
||||||
|
if (resource) newChecks[EntryType.SECRET][secret.key][env.slug] = resource;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
folders?.forEach((folder) => {
|
||||||
|
if (allRowsSelectedOnPage.isChecked) {
|
||||||
|
delete newChecks[EntryType.FOLDER][folder.name];
|
||||||
|
} else {
|
||||||
|
if (!newChecks[EntryType.FOLDER][folder.name])
|
||||||
|
newChecks[EntryType.FOLDER][folder.name] = {};
|
||||||
|
|
||||||
|
const resource = getFolderByNameAndEnv(folder.name, env.slug);
|
||||||
|
|
||||||
|
if (resource) newChecks[EntryType.FOLDER][folder.name][env.slug] = resource;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedEntries(newChecks);
|
||||||
|
};
|
||||||
|
|
||||||
if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) {
|
if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||||
@@ -799,8 +861,6 @@ export const SecretOverviewPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<SelectionPanel
|
<SelectionPanel
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
getSecretByKey={getSecretByKey}
|
|
||||||
getFolderByNameAndEnv={getFolderByNameAndEnv}
|
|
||||||
selectedEntries={selectedEntries}
|
selectedEntries={selectedEntries}
|
||||||
resetSelectedEntries={resetSelectedEntries}
|
resetSelectedEntries={resetSelectedEntries}
|
||||||
/>
|
/>
|
||||||
@@ -810,7 +870,28 @@ export const SecretOverviewPage = () => {
|
|||||||
<THead>
|
<THead>
|
||||||
<Tr className="sticky top-0 z-20 border-0">
|
<Tr className="sticky top-0 z-20 border-0">
|
||||||
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
|
<Th className="sticky left-0 z-20 min-w-[20rem] border-b-0 p-0">
|
||||||
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-3.5 pb-3">
|
<div className="flex items-center border-b border-r border-mineshaft-600 pr-5 pl-3 pt-3.5 pb-3">
|
||||||
|
<Tooltip
|
||||||
|
className="max-w-[20rem] whitespace-nowrap capitalize"
|
||||||
|
content={
|
||||||
|
totalCount > 0
|
||||||
|
? `${
|
||||||
|
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
|
||||||
|
} all folders and secrets on page`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Checkbox
|
||||||
|
isDisabled={totalCount === 0}
|
||||||
|
id="checkbox-select-all-rows"
|
||||||
|
isChecked={allRowsSelectedOnPage.isChecked}
|
||||||
|
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
|
||||||
|
onCheckedChange={toggleSelectAllRows}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="mx-2 h-4 w-[1px] bg-mineshaft-400" />
|
||||||
Name
|
Name
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="plain"
|
variant="plain"
|
||||||
@@ -938,7 +1019,7 @@ export const SecretOverviewPage = () => {
|
|||||||
<SecretOverviewFolderRow
|
<SecretOverviewFolderRow
|
||||||
folderName={folderName}
|
folderName={folderName}
|
||||||
isFolderPresentInEnv={isFolderPresentInEnv}
|
isFolderPresentInEnv={isFolderPresentInEnv}
|
||||||
isSelected={selectedEntries.folder[folderName]}
|
isSelected={Boolean(selectedEntries.folder[folderName])}
|
||||||
onToggleFolderSelect={() =>
|
onToggleFolderSelect={() =>
|
||||||
toggleSelectedEntry(EntryType.FOLDER, folderName)
|
toggleSelectedEntry(EntryType.FOLDER, folderName)
|
||||||
}
|
}
|
||||||
@@ -960,7 +1041,7 @@ export const SecretOverviewPage = () => {
|
|||||||
))}
|
))}
|
||||||
{secKeys.map((key, index) => (
|
{secKeys.map((key, index) => (
|
||||||
<SecretOverviewTableRow
|
<SecretOverviewTableRow
|
||||||
isSelected={selectedEntries.secret[key]}
|
isSelected={Boolean(selectedEntries.secret[key])}
|
||||||
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
|
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
getImportedSecretByKey={getImportedSecretByKey}
|
getImportedSecretByKey={getImportedSecretByKey}
|
||||||
|
|||||||
+12
-20
@@ -27,31 +27,23 @@ export enum EntryType {
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
secretPath: string;
|
secretPath: string;
|
||||||
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
|
|
||||||
getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined;
|
|
||||||
resetSelectedEntries: () => void;
|
resetSelectedEntries: () => void;
|
||||||
selectedEntries: {
|
selectedEntries: {
|
||||||
[EntryType.FOLDER]: Record<string, boolean>;
|
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
|
||||||
[EntryType.SECRET]: Record<string, boolean>;
|
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SelectionPanel = ({
|
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
|
||||||
getFolderByNameAndEnv,
|
|
||||||
getSecretByKey,
|
|
||||||
secretPath,
|
|
||||||
resetSelectedEntries,
|
|
||||||
selectedEntries
|
|
||||||
}: Props) => {
|
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
|
|
||||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||||
"bulkDeleteEntries"
|
"bulkDeleteEntries"
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
const selectedFolderCount = Object.keys(selectedEntries.folder).length
|
const selectedFolderCount = Object.keys(selectedEntries.folder).length;
|
||||||
const selectedKeysCount = Object.keys(selectedEntries.secret).length
|
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
|
||||||
const selectedCount = selectedFolderCount + selectedKeysCount
|
const selectedCount = selectedFolderCount + selectedKeysCount;
|
||||||
|
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
@@ -77,7 +69,7 @@ export const SelectionPanel = ({
|
|||||||
return "Do you want to delete the selected secrets across environments?";
|
return "Do you want to delete the selected secrets across environments?";
|
||||||
}
|
}
|
||||||
return "Do you want to delete the selected folders across environments?";
|
return "Do you want to delete the selected folders across environments?";
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
let processedEntries = 0;
|
let processedEntries = 0;
|
||||||
@@ -94,8 +86,8 @@ export const SelectionPanel = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
Object.keys(selectedEntries.folder).map(async (folderName) => {
|
Object.values(selectedEntries.folder).map(async (folderRecord) => {
|
||||||
const folder = getFolderByNameAndEnv(folderName, env.slug);
|
const folder = folderRecord[env.slug];
|
||||||
if (folder) {
|
if (folder) {
|
||||||
processedEntries += 1;
|
processedEntries += 1;
|
||||||
await deleteFolder({
|
await deleteFolder({
|
||||||
@@ -108,9 +100,9 @@ export const SelectionPanel = ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const secretsToDelete = Object.keys(selectedEntries.secret).reduce(
|
const secretsToDelete = Object.values(selectedEntries.secret).reduce(
|
||||||
(accum: TDeleteSecretBatchDTO["secrets"], secretName) => {
|
(accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => {
|
||||||
const entry = getSecretByKey(env.slug, secretName);
|
const entry = secretRecord[env.slug];
|
||||||
if (entry) {
|
if (entry) {
|
||||||
return [
|
return [
|
||||||
...accum,
|
...accum,
|
||||||
|
|||||||
Reference in New Issue
Block a user