mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge remote-tracking branch 'origin/main' into feat/moved-mfa-to-org-level
This commit is contained in:
@@ -115,7 +115,10 @@ export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => {
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createTagSchema)
|
||||
resolver: zodResolver(createTagSchema),
|
||||
defaultValues: {
|
||||
color: secretTagsColors[0].hex
|
||||
}
|
||||
});
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -15,6 +15,7 @@ export type CheckboxProps = Omit<
|
||||
isRequired?: boolean;
|
||||
checkIndicatorBg?: string | undefined;
|
||||
isError?: boolean;
|
||||
isIndeterminate?: boolean;
|
||||
};
|
||||
|
||||
export const Checkbox = ({
|
||||
@@ -26,6 +27,7 @@ export const Checkbox = ({
|
||||
isRequired,
|
||||
checkIndicatorBg,
|
||||
isError,
|
||||
isIndeterminate,
|
||||
...props
|
||||
}: CheckboxProps): JSX.Element => {
|
||||
return (
|
||||
@@ -45,7 +47,11 @@ export const Checkbox = ({
|
||||
id={id}
|
||||
>
|
||||
<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.Root>
|
||||
<label
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { HTMLAttributes, ReactNode, TdHTMLAttributes } from "react";
|
||||
import { DetailedHTMLProps, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Skeleton } from "../Skeleton";
|
||||
@@ -7,12 +7,13 @@ export type TableContainerProps = {
|
||||
children: ReactNode;
|
||||
isRounded?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
} & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
|
||||
|
||||
export const TableContainer = ({
|
||||
children,
|
||||
className,
|
||||
isRounded = true
|
||||
isRounded = true,
|
||||
...props
|
||||
}: TableContainerProps): JSX.Element => (
|
||||
<div
|
||||
className={twMerge(
|
||||
@@ -20,6 +21,7 @@ export const TableContainer = ({
|
||||
isRounded && "rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -10,13 +10,13 @@ export const groupKeys = {
|
||||
slug,
|
||||
offset,
|
||||
limit,
|
||||
username
|
||||
search
|
||||
}: {
|
||||
slug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
username: string;
|
||||
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, username }] as const
|
||||
search: string;
|
||||
}) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, search }] as const
|
||||
};
|
||||
|
||||
type TUser = {
|
||||
@@ -33,27 +33,28 @@ export const useListGroupUsers = ({
|
||||
groupSlug,
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
username
|
||||
search
|
||||
}: {
|
||||
id: string;
|
||||
groupSlug: string;
|
||||
offset: number;
|
||||
limit: number;
|
||||
username: string;
|
||||
search: string;
|
||||
}) => {
|
||||
return useQuery({
|
||||
queryKey: groupKeys.specificGroupUserMemberships({
|
||||
slug: groupSlug,
|
||||
offset,
|
||||
limit,
|
||||
username
|
||||
search
|
||||
}),
|
||||
enabled: Boolean(groupSlug),
|
||||
keepPreviousData: true,
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(limit),
|
||||
username
|
||||
search
|
||||
});
|
||||
|
||||
const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number }>(
|
||||
|
||||
@@ -48,7 +48,7 @@ export const fetchProjectSecrets = async ({
|
||||
};
|
||||
|
||||
export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
|
||||
const personalSecrets: Record<string, { id: string; value?: string }> = {};
|
||||
const personalSecrets: Record<string, { id: string; value?: string; env: string }> = {};
|
||||
const secrets: SecretV3RawSanitized[] = [];
|
||||
rawSecrets.forEach((el) => {
|
||||
const decryptedSecret: SecretV3RawSanitized = {
|
||||
@@ -69,7 +69,8 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
|
||||
if (el.type === SecretType.Personal) {
|
||||
personalSecrets[decryptedSecret.key] = {
|
||||
id: el.id,
|
||||
value: el.secretValue
|
||||
value: el.secretValue,
|
||||
env: el.environment
|
||||
};
|
||||
} else {
|
||||
secrets.push(decryptedSecret);
|
||||
@@ -77,9 +78,10 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
|
||||
});
|
||||
|
||||
secrets.forEach((sec) => {
|
||||
if (personalSecrets?.[sec.key]) {
|
||||
sec.idOverride = personalSecrets[sec.key].id;
|
||||
sec.valueOverride = personalSecrets[sec.key].value;
|
||||
const personalSecret = personalSecrets?.[sec.key];
|
||||
if (personalSecret && personalSecret.env === sec.env) {
|
||||
sec.idOverride = personalSecret.id;
|
||||
sec.valueOverride = personalSecret.value;
|
||||
sec.overrideAction = "modified";
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ export { useLeaveConfirm } from "./useLeaveConfirm";
|
||||
export { usePagination } from "./usePagination";
|
||||
export { usePersistentState } from "./usePersistentState";
|
||||
export { usePopUp } from "./usePopUp";
|
||||
export { useResetPageHelper } from "./useResetPageHelper";
|
||||
export { useSyntaxHighlight } from "./useSyntaxHighlight";
|
||||
export { useTimedReset } from "./useTimedReset";
|
||||
export { useToggle } from "./useToggle";
|
||||
|
||||
16
frontend/src/hooks/useResetPageHelper.ts
Normal file
16
frontend/src/hooks/useResetPageHelper.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Dispatch, SetStateAction, useEffect } from "react";
|
||||
|
||||
export const useResetPageHelper = ({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
}: {
|
||||
totalCount: number;
|
||||
offset: number;
|
||||
setPage: Dispatch<SetStateAction<number>>;
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
};
|
||||
@@ -241,6 +241,7 @@ export const AppLayout = ({ children }: LayoutProps) => {
|
||||
(!orgs?.map((org) => org.id)?.includes(router.query.id as string) &&
|
||||
!router.asPath.includes("project") &&
|
||||
!router.asPath.includes("personal") &&
|
||||
!router.asPath.includes("secret-scanning") &&
|
||||
!router.asPath.includes("integration")))
|
||||
) {
|
||||
router.push(`/org/${currentOrg?.id}/overview`);
|
||||
|
||||
@@ -72,7 +72,8 @@ const SecretScanning = withPermission(
|
||||
</div>
|
||||
{config.isSecretScanningDisabled && (
|
||||
<NoticeBanner title="Secret scanning is in maintenance" className="mb-4">
|
||||
We are working on improving the performance of secret scanning due to increased usage.
|
||||
We are working on improving the performance of secret scanning due to increased
|
||||
usage.
|
||||
</NoticeBanner>
|
||||
)}
|
||||
<div className="relative mb-6 flex justify-between rounded-md border border-mineshaft-600 bg-mineshaft-800 p-6">
|
||||
@@ -116,7 +117,7 @@ const SecretScanning = withPermission(
|
||||
colorSchema="primary"
|
||||
onClick={generateNewIntegrationSession}
|
||||
className="h-min py-2"
|
||||
isDisabled={!isAllowed || config.isSecretScanningDisabled}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Integrate with GitHub
|
||||
</Button>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
||||
import { useDebounce, useResetPageHelper } from "@app/hooks";
|
||||
import { useAddUserToGroup, useListGroupUsers, useRemoveUserFromGroup } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
@@ -33,18 +34,28 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchMemberFilter);
|
||||
|
||||
const popUpData = popUp?.groupMembers?.data as {
|
||||
groupId: string;
|
||||
slug: string;
|
||||
};
|
||||
|
||||
const offset = (page - 1) * perPage;
|
||||
const { data, isLoading } = useListGroupUsers({
|
||||
id: popUpData?.groupId,
|
||||
groupSlug: popUpData?.slug,
|
||||
offset: (page - 1) * perPage,
|
||||
offset,
|
||||
limit: perPage,
|
||||
username: searchMemberFilter
|
||||
search: debouncedSearch
|
||||
});
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: assignMutateAsync } = useAddUserToGroup();
|
||||
@@ -140,9 +151,9 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && data?.totalCount !== undefined && (
|
||||
{!isLoading && totalCount > 0 && (
|
||||
<Pagination
|
||||
count={data.totalCount}
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
@@ -150,7 +161,10 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
/>
|
||||
)}
|
||||
{!isLoading && !data?.users?.length && (
|
||||
<EmptyState title="No users found" icon={faUsers} />
|
||||
<EmptyState
|
||||
title={debouncedSearch ? "No users match search" : "No users found"}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</ModalContent>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { faEllipsis, faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
faArrowDown,
|
||||
faArrowUp,
|
||||
faEllipsis,
|
||||
faMagnifyingGlass,
|
||||
faUsers
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -11,6 +17,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Select,
|
||||
SelectItem,
|
||||
@@ -24,7 +31,9 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
@@ -43,12 +52,21 @@ type Props = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
enum GroupsOrderBy {
|
||||
Name = "name",
|
||||
Slug = "slug",
|
||||
Role = "role"
|
||||
}
|
||||
|
||||
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
const [searchGroupsFilter, setSearchGroupsFilter] = useState("");
|
||||
const [debouncedSearch] = useDebounce(searchGroupsFilter.trim());
|
||||
const { currentOrg } = useOrganization();
|
||||
const orgId = currentOrg?.id || "";
|
||||
const { isLoading, data: groups } = useGetOrganizationGroups(orgId);
|
||||
const { mutateAsync: updateMutateAsync } = useUpdateGroup();
|
||||
const [orderBy, setOrderBy] = useState(GroupsOrderBy.Name);
|
||||
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
|
||||
|
||||
const { data: roles } = useGetOrgRoles(orgId);
|
||||
|
||||
@@ -72,6 +90,43 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
const filtered = debouncedSearch
|
||||
? groups?.filter(
|
||||
({ name, slug }) =>
|
||||
name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
slug.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
)
|
||||
: groups;
|
||||
|
||||
const ordered = filtered?.sort((a, b) => {
|
||||
switch (orderBy) {
|
||||
case GroupsOrderBy.Role: {
|
||||
const aValue = a.role === "custom" ? (a.customRole?.name as string) : a.role;
|
||||
const bValue = b.role === "custom" ? (b.customRole?.name as string) : b.role;
|
||||
|
||||
return aValue.toLowerCase().localeCompare(bValue.toLowerCase());
|
||||
}
|
||||
default:
|
||||
return a[orderBy].toLowerCase().localeCompare(b[orderBy].toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
|
||||
}, [debouncedSearch, groups, orderBy, orderDirection]);
|
||||
|
||||
const handleSort = (column: GroupsOrderBy) => {
|
||||
if (column === orderBy) {
|
||||
setOrderDirection((prev) =>
|
||||
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderBy(column);
|
||||
setOrderDirection(OrderByDirection.ASC);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
@@ -84,16 +139,70 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr>
|
||||
<Th>Name</Th>
|
||||
<Th>Slug</Th>
|
||||
<Th>Role</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === GroupsOrderBy.Name ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(GroupsOrderBy.Name)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === GroupsOrderBy.Name
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Slug
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === GroupsOrderBy.Slug ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(GroupsOrderBy.Slug)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === GroupsOrderBy.Slug
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th>
|
||||
<div className="flex items-center">
|
||||
Role
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={`ml-2 ${orderBy === GroupsOrderBy.Role ? "" : "opacity-30"}`}
|
||||
ariaLabel="sort"
|
||||
onClick={() => handleSort(GroupsOrderBy.Role)}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={
|
||||
orderDirection === OrderByDirection.DESC && orderBy === GroupsOrderBy.Role
|
||||
? faArrowUp
|
||||
: faArrowDown
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
</Th>
|
||||
<Th className="w-5" />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isLoading && <TableSkeleton columns={4} innerKey="org-groups" />}
|
||||
{!isLoading &&
|
||||
groups?.map(({ id, name, slug, role, customRole }) => {
|
||||
filteredGroups?.map(({ id, name, slug, role, customRole }) => {
|
||||
return (
|
||||
<Tr className="h-10" key={`org-group-${id}`}>
|
||||
<Td>{name}</Td>
|
||||
@@ -226,7 +335,12 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||
})}
|
||||
</TBody>
|
||||
</Table>
|
||||
{groups?.length === 0 && <EmptyState title="No groups found" icon={faUsers} />}
|
||||
{filteredGroups?.length === 0 && (
|
||||
<EmptyState
|
||||
title={groups?.length === 0 ? "No groups found" : "No groups match search"}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -270,7 +270,7 @@ export const IdentityKubernetesAuthForm = ({
|
||||
label="Allowed Namespaces"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any namespaces."
|
||||
tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical."
|
||||
>
|
||||
<Input {...field} placeholder="namespaceA, namespaceB" type="text" />
|
||||
</FormControl>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faArrowDown,
|
||||
@@ -34,7 +33,7 @@ import {
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types";
|
||||
@@ -87,10 +86,11 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
|
||||
);
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { data: roles } = useGetOrgRoles(organizationId);
|
||||
|
||||
|
||||
@@ -377,7 +377,14 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
|
||||
</TBody>
|
||||
</Table>
|
||||
{!isLoading && filterdUser?.length === 0 && (
|
||||
<EmptyState title="No organization members found" icon={faUsers} />
|
||||
<EmptyState
|
||||
title={
|
||||
members?.length === 0
|
||||
? "No organization members found"
|
||||
: "No organization members match search"
|
||||
}
|
||||
icon={faUsers}
|
||||
/>
|
||||
)}
|
||||
</TableContainer>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { z } from "zod";
|
||||
|
||||
import { OrgPermissionSubjects } from "@app/context";
|
||||
import { TPermission } from "@app/hooks/api/roles/types";
|
||||
|
||||
const generalPermissionSchema = z
|
||||
@@ -46,7 +47,8 @@ export const formSchema = z.object({
|
||||
ldap: generalPermissionSchema,
|
||||
billing: generalPermissionSchema,
|
||||
identity: generalPermissionSchema,
|
||||
"organization-admin-console": adminConsolePermissionSchmea
|
||||
"organization-admin-console": adminConsolePermissionSchmea,
|
||||
[OrgPermissionSubjects.Kms]: generalPermissionSchema
|
||||
})
|
||||
.optional()
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Table, TableContainer, TBody, Th, THead, Tr } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||
import { useGetOrgRole, useUpdateOrgRole } from "@app/hooks/api";
|
||||
import {
|
||||
formRolePermission2API,
|
||||
@@ -64,6 +64,10 @@ const SIMPLE_PERMISSION_OPTIONS = [
|
||||
{
|
||||
title: "SCIM",
|
||||
formName: "scim"
|
||||
},
|
||||
{
|
||||
title: "External KMS",
|
||||
formName: OrgPermissionSubjects.Kms
|
||||
}
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
faArrowDown,
|
||||
@@ -51,7 +50,7 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePagination, usePopUp } from "@app/hooks";
|
||||
import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
|
||||
import { CmekOrderBy, TCmek } from "@app/hooks/api/cmeks/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
@@ -108,10 +107,11 @@ export const CmekTable = () => {
|
||||
});
|
||||
|
||||
const { keys = [], totalCount = 0 } = data ?? {};
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
|
||||
"upsertKey",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
faArrowDown,
|
||||
@@ -44,7 +43,7 @@ import {
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
import { usePagination } from "@app/hooks";
|
||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { IdentityMembership } from "@app/hooks/api/identities/types";
|
||||
@@ -99,10 +98,11 @@ export const IdentityTab = withProjectPermission(
|
||||
|
||||
const { totalCount = 0 } = data ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
|
||||
|
||||
|
||||
@@ -2,29 +2,33 @@ import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
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
|
||||
// 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
|
||||
type SelectedSecretState = {
|
||||
selectedSecret: Record<string, boolean>;
|
||||
selectedSecret: Record<string, SecretV3RawSanitized>;
|
||||
action: {
|
||||
toggle: (id: string) => void;
|
||||
toggle: (secret: SecretV3RawSanitized) => void;
|
||||
reset: () => void;
|
||||
set: (secrets: Record<string, SecretV3RawSanitized>) => void;
|
||||
};
|
||||
};
|
||||
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
|
||||
selectedSecret: {},
|
||||
action: {
|
||||
toggle: (id) =>
|
||||
toggle: (secret) =>
|
||||
set((state) => {
|
||||
const isChecked = Boolean(state.selectedSecret?.[id]);
|
||||
const isChecked = Boolean(state.selectedSecret?.[secret.id]);
|
||||
const newChecks = { ...state.selectedSecret };
|
||||
// remove selection if its present else add it
|
||||
if (isChecked) delete newChecks[id];
|
||||
else newChecks[id] = true;
|
||||
if (isChecked) delete newChecks[secret.id];
|
||||
else newChecks[secret.id] = secret;
|
||||
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 { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
@@ -9,14 +9,14 @@ import { twMerge } from "tailwind-merge";
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { PermissionDeniedBanner } from "@app/components/permissions";
|
||||
import { ContentLoader, Pagination } from "@app/components/v2";
|
||||
import { Checkbox, ContentLoader, Pagination, Tooltip } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useGetImportedSecretsSingleEnv,
|
||||
useGetSecretApprovalPolicyOfABoard,
|
||||
@@ -39,7 +39,11 @@ import { PitDrawer } from "./components/PitDrawer";
|
||||
import { SecretDropzone } from "./components/SecretDropzone";
|
||||
import { SecretListView } from "./components/SecretListView";
|
||||
import { SnapshotView } from "./components/SnapshotView";
|
||||
import { StoreProvider } from "./SecretMainPage.store";
|
||||
import {
|
||||
StoreProvider,
|
||||
useSelectedSecretActions,
|
||||
useSelectedSecrets
|
||||
} from "./SecretMainPage.store";
|
||||
import { Filter, RowType } from "./SecretMainPage.types";
|
||||
|
||||
const LOADER_TEXT = [
|
||||
@@ -48,7 +52,7 @@ const LOADER_TEXT = [
|
||||
"Getting secret import links..."
|
||||
];
|
||||
|
||||
export const SecretMainPage = () => {
|
||||
const SecretMainPageContent = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
|
||||
const router = useRouter();
|
||||
@@ -160,10 +164,11 @@ export const SecretMainPage = () => {
|
||||
totalCount = 0
|
||||
} = data ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
// fetch imported secrets to show user the overriden ones
|
||||
const { data: importedSecrets } = useGetImportedSecretsSingleEnv({
|
||||
@@ -283,6 +288,33 @@ export const SecretMainPage = () => {
|
||||
}
|
||||
}, [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) {
|
||||
return <ContentLoader text={LOADER_TEXT} />;
|
||||
}
|
||||
@@ -300,169 +332,192 @@ export const SecretMainPage = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<StoreProvider>
|
||||
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<SecretV2MigrationSection />
|
||||
<div className="relative right-6 -top-2 mb-2 ml-6">
|
||||
<NavHeader
|
||||
pageName={t("dashboard.title")}
|
||||
currentEnv={environment}
|
||||
userAvailableEnvs={currentWorkspace?.environments}
|
||||
isFolderMode
|
||||
<div className="container mx-auto flex flex-col px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<SecretV2MigrationSection />
|
||||
<div className="relative right-6 -top-2 mb-2 ml-6">
|
||||
<NavHeader
|
||||
pageName={t("dashboard.title")}
|
||||
currentEnv={environment}
|
||||
userAvailableEnvs={currentWorkspace?.environments}
|
||||
isFolderMode
|
||||
secretPath={secretPath}
|
||||
isProjectRelated
|
||||
onEnvChange={handleEnvChange}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
protectionPolicyName={boardPolicy?.name}
|
||||
/>
|
||||
</div>
|
||||
{!isRollbackMode ? (
|
||||
<>
|
||||
<ActionBar
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
isProjectRelated
|
||||
onEnvChange={handleEnvChange}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
protectionPolicyName={boardPolicy?.name}
|
||||
isVisible={isVisible}
|
||||
filter={filter}
|
||||
tags={tags}
|
||||
onVisibilityToggle={handleToggleVisibility}
|
||||
onSearchChange={handleSearchChange}
|
||||
onToggleTagFilter={handleTagToggle}
|
||||
snapshotCount={snapshotCount || 0}
|
||||
isSnapshotCountLoading={isSnapshotCountLoading}
|
||||
onToggleRowType={handleToggleRowType}
|
||||
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
||||
/>
|
||||
</div>
|
||||
{!isRollbackMode ? (
|
||||
<>
|
||||
<ActionBar
|
||||
secrets={secrets}
|
||||
environment={environment}
|
||||
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 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
|
||||
className={twMerge(
|
||||
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
|
||||
)}
|
||||
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2"
|
||||
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" />
|
||||
<div
|
||||
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSortToggle}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") handleSortToggle();
|
||||
}}
|
||||
<Tooltip
|
||||
className="max-w-[20rem] whitespace-nowrap"
|
||||
content={
|
||||
totalCount > 0
|
||||
? `${
|
||||
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
|
||||
} all secrets on page`
|
||||
: ""
|
||||
}
|
||||
>
|
||||
Key
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-2">Value</div>
|
||||
<div className="mr-6 ml-1">
|
||||
<Checkbox
|
||||
isDisabled={totalCount === 0}
|
||||
id="checkbox-select-all-rows"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
isChecked={allRowsSelectedOnPage.isChecked}
|
||||
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
|
||||
onCheckedChange={toggleSelectAllRows}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
Key
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{canReadSecret && imports?.length && (
|
||||
<SecretImportListView
|
||||
searchTerm={debouncedSearchFilter}
|
||||
secretImports={imports}
|
||||
isFetching={isDetailsFetching}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
importedSecrets={importedSecrets}
|
||||
/>
|
||||
)}
|
||||
{folders?.length && (
|
||||
<FolderListView
|
||||
folders={folders}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
onNavigateToFolder={handleResetFilter}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && dynamicSecrets?.length && (
|
||||
<DynamicSecretListView
|
||||
environment={environment}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecrets={dynamicSecrets}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && secrets?.length && (
|
||||
<SecretListView
|
||||
secrets={secrets}
|
||||
tags={tags}
|
||||
isVisible={isVisible}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
/>
|
||||
)}
|
||||
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-2">Value</div>
|
||||
</div>
|
||||
)}
|
||||
{canReadSecret && imports?.length && (
|
||||
<SecretImportListView
|
||||
searchTerm={debouncedSearchFilter}
|
||||
secretImports={imports}
|
||||
isFetching={isDetailsFetching}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
importedSecrets={importedSecrets}
|
||||
/>
|
||||
)}
|
||||
{folders?.length && (
|
||||
<FolderListView
|
||||
folders={folders}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
onNavigateToFolder={handleResetFilter}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && dynamicSecrets?.length && (
|
||||
<DynamicSecretListView
|
||||
environment={environment}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecrets={dynamicSecrets}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && secrets?.length && (
|
||||
<SecretListView
|
||||
secrets={secrets}
|
||||
tags={tags}
|
||||
isVisible={isVisible}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
/>
|
||||
)}
|
||||
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
|
||||
</div>
|
||||
{!isDetailsLoading && totalCount > 0 && (
|
||||
<Pagination
|
||||
startAdornment={
|
||||
<SecretTableResourceCount
|
||||
dynamicSecretCount={totalDynamicSecretCount}
|
||||
importCount={totalImportCount}
|
||||
secretCount={totalSecretCount}
|
||||
folderCount={totalFolderCount}
|
||||
/>
|
||||
}
|
||||
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
)}
|
||||
<CreateSecretForm
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
autoCapitalize={currentWorkspace?.autoCapitalization}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
</div>
|
||||
{!isDetailsLoading && totalCount > 0 && (
|
||||
<Pagination
|
||||
startAdornment={
|
||||
<SecretTableResourceCount
|
||||
dynamicSecretCount={totalDynamicSecretCount}
|
||||
importCount={totalImportCount}
|
||||
secretCount={totalSecretCount}
|
||||
folderCount={totalFolderCount}
|
||||
/>
|
||||
}
|
||||
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
|
||||
count={totalCount}
|
||||
page={page}
|
||||
perPage={perPage}
|
||||
onChangePage={(newPage) => setPage(newPage)}
|
||||
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
|
||||
/>
|
||||
<SecretDropzone
|
||||
secrets={secrets}
|
||||
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 || ""}
|
||||
)}
|
||||
<CreateSecretForm
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
secrets={secrets}
|
||||
folders={folders}
|
||||
snapshotCount={snapshotCount}
|
||||
onGoBack={handleResetSnapshot}
|
||||
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
|
||||
autoCapitalize={currentWorkspace?.autoCapitalization}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</StoreProvider>
|
||||
<SecretDropzone
|
||||
secrets={secrets}
|
||||
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 { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
|
||||
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 {
|
||||
PopUpNames,
|
||||
@@ -69,8 +69,7 @@ import { FolderForm } from "./FolderForm";
|
||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||
|
||||
type Props = {
|
||||
secrets?: SecretV3RawSanitized[];
|
||||
// swtich the secrets type as it gets decrypted after api call
|
||||
// switch the secrets type as it gets decrypted after api call
|
||||
environment: string;
|
||||
// @depreciated will be moving all these details to zustand
|
||||
workspaceId: string;
|
||||
@@ -89,7 +88,6 @@ type Props = {
|
||||
};
|
||||
|
||||
export const ActionBar = ({
|
||||
secrets = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
projectSlug,
|
||||
@@ -202,7 +200,7 @@ export const ActionBar = ({
|
||||
};
|
||||
|
||||
const handleSecretBulkDelete = async () => {
|
||||
const bulkDeletedSecrets = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
|
||||
const bulkDeletedSecrets = Object.values(selectedSecrets);
|
||||
try {
|
||||
await deleteBatchSecretV3({
|
||||
secretPath,
|
||||
@@ -235,7 +233,7 @@ export const ActionBar = ({
|
||||
shouldOverwrite: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const secretsToMove = secrets.filter(({ id }) => Boolean(selectedSecrets?.[id]));
|
||||
const secretsToMove = Object.values(selectedSecrets);
|
||||
const { isDestinationUpdated, isSourceUpdated } = await moveSecrets({
|
||||
projectSlug,
|
||||
shouldOverwrite,
|
||||
@@ -553,7 +551,7 @@ export const ActionBar = ({
|
||||
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="ml-4 flex-grow px-2 text-sm">
|
||||
<div className="ml-2 flex-grow px-2 text-sm">
|
||||
{Object.keys(selectedSecrets).length} Selected
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
|
||||
@@ -158,17 +158,17 @@ export const SecretImportItem = ({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex w-12 items-center px-4 py-2 text-green-700">
|
||||
<div className="flex w-11 items-center py-2 pl-5 text-green-700">
|
||||
<FontAwesomeIcon icon={faFileImport} />
|
||||
</div>
|
||||
<div className="flex flex-grow items-center px-4 py-2">
|
||||
<div className="flex flex-grow items-center py-2 pl-4 pr-2">
|
||||
<EnvFolderIcon
|
||||
env={importEnv.slug || ""}
|
||||
secretPath={secretImport?.importPath || ""}
|
||||
// isReplication={isReplication}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4 px-4 py-2">
|
||||
<div className="flex items-center space-x-4 py-2 pr-4">
|
||||
{lastReplicated && (
|
||||
<Tooltip
|
||||
position="left"
|
||||
|
||||
@@ -56,7 +56,7 @@ type Props = {
|
||||
onDetailViewSecret: (sec: SecretV3RawSanitized) => void;
|
||||
isVisible?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSecretSelect: (id: string) => void;
|
||||
onToggleSecretSelect: (secret: SecretV3RawSanitized) => void;
|
||||
tags: WsTag[];
|
||||
onCreateTag: () => void;
|
||||
environment: string;
|
||||
@@ -218,7 +218,7 @@ export const SecretItem = memo(
|
||||
<Checkbox
|
||||
id={`checkbox-${secret.id}`}
|
||||
isChecked={isSelected}
|
||||
onCheckedChange={() => onToggleSecretSelect(secret.id)}
|
||||
onCheckedChange={() => onToggleSecretSelect(secret)}
|
||||
className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
|
||||
/>
|
||||
<FontAwesomeSymbol
|
||||
|
||||
@@ -304,7 +304,7 @@ export const SecretListView = ({
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
tags={wsTags}
|
||||
isSelected={selectedSecrets?.[secret.id]}
|
||||
isSelected={Boolean(selectedSecrets?.[secret.id])}
|
||||
onToggleSecretSelect={toggleSelectedSecret}
|
||||
isVisible={isVisible}
|
||||
secret={secret}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
@@ -25,6 +25,7 @@ import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
@@ -54,7 +55,7 @@ import {
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
|
||||
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
|
||||
import {
|
||||
useCreateFolder,
|
||||
useCreateSecretV3,
|
||||
@@ -67,7 +68,7 @@ import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { useUpdateFolderBatch } from "@app/hooks/api/secretFolders/queries";
|
||||
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 { useDynamicSecretOverview, useFolderOverview, useSecretOverview } from "@app/hooks/utils";
|
||||
import { SecretOverviewDynamicSecretRow } from "@app/views/SecretOverviewPage/components/SecretOverviewDynamicSecretRow";
|
||||
@@ -106,18 +107,10 @@ export const SecretOverviewPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const router = useRouter();
|
||||
// this is to set expandable table width
|
||||
// coz when overflow the table goes to the right
|
||||
const parentTableRef = useRef<HTMLTableElement>(null);
|
||||
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [debouncedScrollOffset] = useDebounce(scrollOffset);
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
useEffect(() => {
|
||||
if (parentTableRef.current) {
|
||||
setExpandableTableWidth(parentTableRef.current.clientWidth);
|
||||
}
|
||||
}, [parentTableRef.current]);
|
||||
|
||||
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
|
||||
const isProjectV3 = currentWorkspace?.version === ProjectVersion.V3;
|
||||
const { currentOrg } = useOrganization();
|
||||
@@ -133,8 +126,9 @@ export const SecretOverviewPage = () => {
|
||||
>(new Map());
|
||||
|
||||
const [selectedEntries, setSelectedEntries] = useState<{
|
||||
[EntryType.FOLDER]: Record<string, boolean>;
|
||||
[EntryType.SECRET]: Record<string, boolean>;
|
||||
// selectedEntries[name/key][envSlug][resource]
|
||||
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
|
||||
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
|
||||
}>({
|
||||
[EntryType.FOLDER]: {},
|
||||
[EntryType.SECRET]: {}
|
||||
@@ -152,23 +146,6 @@ export const SecretOverviewPage = () => {
|
||||
orderBy
|
||||
} = 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(() => {
|
||||
setSelectedEntries({
|
||||
[EntryType.FOLDER]: {},
|
||||
@@ -177,19 +154,13 @@ export const SecretOverviewPage = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleParentTableWidthResize = () => {
|
||||
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
|
||||
};
|
||||
|
||||
const onRouteChangeStart = () => {
|
||||
resetSelectedEntries();
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", onRouteChangeStart);
|
||||
|
||||
window.addEventListener("resize", handleParentTableWidthResize);
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleParentTableWidthResize);
|
||||
router.events.off("routeChangeStart", onRouteChangeStart);
|
||||
};
|
||||
}, []);
|
||||
@@ -242,10 +213,11 @@ export const SecretOverviewPage = () => {
|
||||
totalCount = 0
|
||||
} = overview ?? {};
|
||||
|
||||
useEffect(() => {
|
||||
// reset page if no longer valid
|
||||
if (totalCount <= offset) setPage(1);
|
||||
}, [totalCount]);
|
||||
useResetPageHelper({
|
||||
totalCount,
|
||||
offset,
|
||||
setPage
|
||||
});
|
||||
|
||||
const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
|
||||
|
||||
@@ -551,6 +523,85 @@ export const SecretOverviewPage = () => {
|
||||
[]
|
||||
);
|
||||
|
||||
const allRowsSelectedOnPage = useMemo(() => {
|
||||
if (!secrets?.length && !folders?.length) return { isChecked: false, isIndeterminate: false };
|
||||
|
||||
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)) {
|
||||
return (
|
||||
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
@@ -799,18 +850,39 @@ export const SecretOverviewPage = () => {
|
||||
</div>
|
||||
<SelectionPanel
|
||||
secretPath={secretPath}
|
||||
getSecretByKey={getSecretByKey}
|
||||
getFolderByNameAndEnv={getFolderByNameAndEnv}
|
||||
selectedEntries={selectedEntries}
|
||||
resetSelectedEntries={resetSelectedEntries}
|
||||
/>
|
||||
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
|
||||
<TableContainer className="rounded-b-none">
|
||||
<div className="thin-scrollbar mt-4">
|
||||
<TableContainer
|
||||
onScroll={(e) => setScrollOffset(e.currentTarget.scrollLeft)}
|
||||
className="thin-scrollbar"
|
||||
>
|
||||
<Table>
|
||||
<THead>
|
||||
<Tr className="sticky top-0 z-20 border-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 className="mr-4 ml-2">
|
||||
<Checkbox
|
||||
isDisabled={totalCount === 0}
|
||||
id="checkbox-select-all-rows"
|
||||
isChecked={allRowsSelectedOnPage.isChecked}
|
||||
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
|
||||
onCheckedChange={toggleSelectAllRows}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
Name
|
||||
<IconButton
|
||||
variant="plain"
|
||||
@@ -938,7 +1010,7 @@ export const SecretOverviewPage = () => {
|
||||
<SecretOverviewFolderRow
|
||||
folderName={folderName}
|
||||
isFolderPresentInEnv={isFolderPresentInEnv}
|
||||
isSelected={selectedEntries.folder[folderName]}
|
||||
isSelected={Boolean(selectedEntries.folder[folderName])}
|
||||
onToggleFolderSelect={() =>
|
||||
toggleSelectedEntry(EntryType.FOLDER, folderName)
|
||||
}
|
||||
@@ -960,7 +1032,7 @@ export const SecretOverviewPage = () => {
|
||||
))}
|
||||
{secKeys.map((key, index) => (
|
||||
<SecretOverviewTableRow
|
||||
isSelected={selectedEntries.secret[key]}
|
||||
isSelected={Boolean(selectedEntries.secret[key])}
|
||||
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
|
||||
secretPath={secretPath}
|
||||
getImportedSecretByKey={getImportedSecretByKey}
|
||||
@@ -972,7 +1044,7 @@ export const SecretOverviewPage = () => {
|
||||
environments={visibleEnvs}
|
||||
secretKey={key}
|
||||
getSecretByKey={getSecretByKey}
|
||||
expandableColWidth={expandableTableWidth}
|
||||
scrollOffset={debouncedScrollOffset}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -23,7 +23,6 @@ type Props = {
|
||||
secretKey: string;
|
||||
secretPath: string;
|
||||
environments: { name: string; slug: string }[];
|
||||
expandableColWidth: number;
|
||||
isSelected: boolean;
|
||||
onToggleSecretSelect: (key: string) => void;
|
||||
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
|
||||
@@ -41,6 +40,7 @@ type Props = {
|
||||
env: string,
|
||||
secretName: string
|
||||
) => { secret?: SecretV3RawSanitized; environmentInfo?: WorkspaceEnv } | undefined;
|
||||
scrollOffset: number;
|
||||
};
|
||||
|
||||
export const SecretOverviewTableRow = ({
|
||||
@@ -53,9 +53,7 @@ export const SecretOverviewTableRow = ({
|
||||
onSecretDelete,
|
||||
isImportedSecretPresentInEnv,
|
||||
getImportedSecretByKey,
|
||||
// temporary until below todo is resolved
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
expandableColWidth,
|
||||
scrollOffset,
|
||||
onToggleSecretSelect,
|
||||
isSelected
|
||||
}: Props) => {
|
||||
@@ -152,11 +150,12 @@ export const SecretOverviewTableRow = ({
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="ml-2 w-[99%] p-2"
|
||||
// TODO: scott expandableColWidth sometimes 0 due to parent ref not mounting, opting for relative width until resolved
|
||||
// style={{
|
||||
// width: `calc(${expandableColWidth} - 1rem)`
|
||||
// }}
|
||||
className="ml-2 p-2"
|
||||
style={{
|
||||
marginLeft: scrollOffset,
|
||||
width: "calc(100vw - 300px)", // 300px accounts for sidebar and margin
|
||||
maxWidth: "calc(1536px - 50px)" // tw container max width minus padding for uw displays
|
||||
}}
|
||||
>
|
||||
<SecretRenameRow
|
||||
secretKey={secretKey}
|
||||
|
||||
@@ -27,31 +27,23 @@ export enum EntryType {
|
||||
|
||||
type Props = {
|
||||
secretPath: string;
|
||||
getSecretByKey: (slug: string, key: string) => SecretV3RawSanitized | undefined;
|
||||
getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined;
|
||||
resetSelectedEntries: () => void;
|
||||
selectedEntries: {
|
||||
[EntryType.FOLDER]: Record<string, boolean>;
|
||||
[EntryType.SECRET]: Record<string, boolean>;
|
||||
[EntryType.FOLDER]: Record<string, Record<string, TSecretFolder>>;
|
||||
[EntryType.SECRET]: Record<string, Record<string, SecretV3RawSanitized>>;
|
||||
};
|
||||
};
|
||||
|
||||
export const SelectionPanel = ({
|
||||
getFolderByNameAndEnv,
|
||||
getSecretByKey,
|
||||
secretPath,
|
||||
resetSelectedEntries,
|
||||
selectedEntries
|
||||
}: Props) => {
|
||||
export const SelectionPanel = ({ secretPath, resetSelectedEntries, selectedEntries }: Props) => {
|
||||
const { permission } = useProjectPermission();
|
||||
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"bulkDeleteEntries"
|
||||
] as const);
|
||||
|
||||
const selectedFolderCount = Object.keys(selectedEntries.folder).length
|
||||
const selectedKeysCount = Object.keys(selectedEntries.secret).length
|
||||
const selectedCount = selectedFolderCount + selectedKeysCount
|
||||
const selectedFolderCount = Object.keys(selectedEntries.folder).length;
|
||||
const selectedKeysCount = Object.keys(selectedEntries.secret).length;
|
||||
const selectedCount = selectedFolderCount + selectedKeysCount;
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
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 folders across environments?";
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
let processedEntries = 0;
|
||||
@@ -94,8 +86,8 @@ export const SelectionPanel = ({
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Object.keys(selectedEntries.folder).map(async (folderName) => {
|
||||
const folder = getFolderByNameAndEnv(folderName, env.slug);
|
||||
Object.values(selectedEntries.folder).map(async (folderRecord) => {
|
||||
const folder = folderRecord[env.slug];
|
||||
if (folder) {
|
||||
processedEntries += 1;
|
||||
await deleteFolder({
|
||||
@@ -108,9 +100,9 @@ export const SelectionPanel = ({
|
||||
})
|
||||
);
|
||||
|
||||
const secretsToDelete = Object.keys(selectedEntries.secret).reduce(
|
||||
(accum: TDeleteSecretBatchDTO["secrets"], secretName) => {
|
||||
const entry = getSecretByKey(env.slug, secretName);
|
||||
const secretsToDelete = Object.values(selectedEntries.secret).reduce(
|
||||
(accum: TDeleteSecretBatchDTO["secrets"], secretRecord) => {
|
||||
const entry = secretRecord[env.slug];
|
||||
if (entry) {
|
||||
return [
|
||||
...accum,
|
||||
@@ -173,7 +165,7 @@ export const SelectionPanel = ({
|
||||
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="ml-4 flex-grow px-2 text-sm">{selectedCount} Selected</div>
|
||||
<div className="ml-1 flex-grow px-2 text-sm">{selectedCount} Selected</div>
|
||||
{shouldShowDelete && (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
|
||||
Reference in New Issue
Block a user