feat(rbac): fixed merge conflicts and resolved some more issues with permission checks

This commit is contained in:
Akhil Mohan
2023-09-08 21:22:36 +05:30
parent bcfe1bda84
commit f1a291a52a
13 changed files with 120 additions and 87 deletions
@@ -201,14 +201,6 @@ export const logout = async (req: Request, res: Response) => {
}); });
}; };
export const getCommonPasswords = async (req: Request, res: Response) => {
const commonPasswords = fs
.readFileSync(path.resolve(__dirname, "../../data/" + "common_passwords.txt"), "utf8")
.split("\n");
return res.status(200).send(commonPasswords);
};
export const revokeAllSessions = async (req: Request, res: Response) => { export const revokeAllSessions = async (req: Request, res: Response) => {
await TokenVersion.updateMany( await TokenVersion.updateMany(
{ {
@@ -203,7 +203,6 @@ export const getUserWorkspacePermissions = async (req: Request, res: Response) =
params: { workspaceId } params: { workspaceId }
} = await validateRequest(GetUserProjectPermission, req); } = await validateRequest(GetUserProjectPermission, req);
const { permission } = await getUserProjectPermissions(req.user.id, workspaceId); const { permission } = await getUserProjectPermissions(req.user.id, workspaceId);
res.status(200).json({ res.status(200).json({
data: { data: {
permissions: packRules(permission.rules) permissions: packRules(permission.rules)
@@ -77,16 +77,6 @@ export const batchSecrets = async (req: Request, res: Response) => {
const folders = await Folder.findOne({ workspace: workspaceId, environment }); const folders = await Folder.findOne({ workspace: workspaceId, environment });
if (req.authData.authPayload instanceof ServiceTokenData) {
await validateServiceTokenDataClientForWorkspace({
serviceTokenData: req.authData.authPayload,
workspaceId: new Types.ObjectId(workspaceId),
environment,
secretPath,
requiredPermissions: [PERMISSION_WRITE_SECRETS]
});
}
if (secretPath) { if (secretPath) {
folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath); folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath);
} }
@@ -100,7 +90,15 @@ export const batchSecrets = async (req: Request, res: Response) => {
); );
} }
if (req.user?._id) { if (req.authData.authPayload instanceof ServiceTokenData) {
await validateServiceTokenDataClientForWorkspace({
serviceTokenData: req.authData.authPayload,
workspaceId: new Types.ObjectId(workspaceId),
environment,
secretPath,
requiredPermissions: [PERMISSION_WRITE_SECRETS]
});
} else {
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId); const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Create, ProjectPermissionActions.Create,
+1 -3
View File
@@ -44,6 +44,4 @@ const membershipOrgSchema = new Schema(
} }
); );
const MembershipOrg = model<IMembershipOrg>("MembershipOrg", membershipOrgSchema); export const MembershipOrg = model<IMembershipOrg>("MembershipOrg", membershipOrgSchema);
export default MembershipOrg;
-2
View File
@@ -38,8 +38,6 @@ router.post(
authController.checkAuth authController.checkAuth
); );
router.get("/common-passwords", authLimiter, authController.getCommonPasswords);
router.delete( router.delete(
// TODO endpoint: deprecate (moved to DELETE v2/users/me/sessions) // TODO endpoint: deprecate (moved to DELETE v2/users/me/sessions)
"/sessions", "/sessions",
+5 -5
View File
@@ -93,11 +93,11 @@ router.get(
); );
router.get( router.get(
"/:integrationAuthId/teamcity/build-configs", "/:integrationAuthId/teamcity/build-configs",
requireAuth({ requireAuth({
acceptedAuthModes: [AuthMode.JWT], acceptedAuthModes: [AuthMode.JWT]
}), }),
integrationAuthController.getIntegrationAuthTeamCityBuildConfigs integrationAuthController.getIntegrationAuthTeamCityBuildConfigs
); );
router.delete( router.delete(
@@ -219,7 +219,6 @@ export const getUserProjectPermissions = async (userId: string, workspaceId: str
}>("customRole") }>("customRole")
.exec(); .exec();
console.log(membership, userId, workspaceId);
if (!membership || (membership.role === "custom" && !membership.customRole)) { if (!membership || (membership.role === "custom" && !membership.customRole)) {
throw UnauthorizedRequestError({ message: "User doesn't belong to organization" }); throw UnauthorizedRequestError({ message: "User doesn't belong to organization" });
} }
+1 -2
View File
@@ -6,7 +6,6 @@ import { MembershipNotFoundError } from "../utils/errors";
import { AuthData } from "../interfaces/middleware"; import { AuthData } from "../interfaces/middleware";
import { ActorType } from "../ee/models"; import { ActorType } from "../ee/models";
import { z } from "zod"; import { z } from "zod";
import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables";
/** /**
* Validate authenticated clients for membership with id [membershipId] based * Validate authenticated clients for membership with id [membershipId] based
@@ -66,7 +65,7 @@ export const DeleteMembershipV1 = z.object({
export const ChangeMembershipRoleV1 = z.object({ export const ChangeMembershipRoleV1 = z.object({
body: z.object({ body: z.object({
role: z.enum([ADMIN, VIEWER, MEMBER, CUSTOM]) role: z.string().trim()
}), }),
params: z.object({ membershipId: z.string().trim() }) params: z.object({ membershipId: z.string().trim() })
}); });
+2 -2
View File
@@ -11,7 +11,7 @@ export const CreateRoleSchema = z.object({
.object({ .object({
subject: z.string(), subject: z.string(),
action: z.string(), action: z.string(),
condition: z.record(z.union([z.string(), z.number()])).optional() conditions: z.record(z.union([z.string(), z.number()])).optional()
}) })
.array() .array()
}) })
@@ -31,7 +31,7 @@ export const UpdateRoleSchema = z.object({
.object({ .object({
subject: z.string(), subject: z.string(),
action: z.string(), action: z.string(),
condition: z.record(z.union([z.string(), z.number()])).optional() conditions: z.record(z.union([z.string(), z.number()])).optional()
}) })
.array() .array()
.optional() .optional()
+4 -4
View File
@@ -18,13 +18,13 @@ export type TRole<T extends string | undefined> = {
export type TPermission = TWorkspacePermission | TGeneralPermission; export type TPermission = TWorkspacePermission | TGeneralPermission;
type TGeneralPermission = { type TGeneralPermission = {
condition?: Record<string, any>; conditions?: Record<string, any>;
action: "read" | "edit" | "create" | "delete"; action: "read" | "edit" | "create" | "delete";
subject: "member" | "role" | "incident-contact" | "sso" | "billing" | "settings"; subject: "member" | "role" | "incident-contact" | "sso" | "billing" | "settings";
}; };
type TWorkspacePermission = { type TWorkspacePermission = {
condition?: Record<string, any>; conditions?: Record<string, any>;
action: "read" | "create"; action: "read" | "create";
subject: "workspace"; subject: "workspace";
}; };
@@ -32,7 +32,7 @@ type TWorkspacePermission = {
export type TProjectPermission = TProjectGeneralPermission | TProjectWorkspacePermission; export type TProjectPermission = TProjectGeneralPermission | TProjectWorkspacePermission;
type TProjectGeneralPermission = { type TProjectGeneralPermission = {
condition?: Record<string, any>; conditions?: Record<string, any>;
action: "read" | "edit" | "create" | "delete"; action: "read" | "edit" | "create" | "delete";
subject: subject:
| "member" | "member"
@@ -46,7 +46,7 @@ type TProjectGeneralPermission = {
}; };
type TProjectWorkspacePermission = { type TProjectWorkspacePermission = {
condition?: Record<string, any>; conditions?: Record<string, any>;
action: "delete" | "edit"; action: "delete" | "edit";
subject: "workspace"; subject: "workspace";
}; };
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { FormProvider, useFieldArray, useForm } from "react-hook-form"; import { FormProvider, useFieldArray, useForm } from "react-hook-form";
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 { import {
closestCenter, closestCenter,
DndContext, DndContext,
@@ -25,6 +26,7 @@ import {
faEyeSlash, faEyeSlash,
faFileImport, faFileImport,
faFolderPlus, faFolderPlus,
faLock,
faMagnifyingGlass, faMagnifyingGlass,
faPlus faPlus
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
@@ -60,6 +62,7 @@ import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
useOrganization, useOrganization,
useProjectPermission,
useSubscription, useSubscription,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
@@ -79,7 +82,6 @@ import {
useGetSecretVersion, useGetSecretVersion,
useGetSnapshotSecrets, useGetSnapshotSecrets,
useGetUserAction, useGetUserAction,
useGetUserWsEnvironments,
useGetUserWsKey, useGetUserWsKey,
useGetWorkspaceSecretSnapshots, useGetWorkspaceSecretSnapshots,
useGetWsSnapshotCount, useGetWsSnapshotCount,
@@ -139,6 +141,7 @@ export const DashboardPage = withProjectPermission(
const { createNotification } = useNotificationContext(); const { createNotification } = useNotificationContext();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const envQuery = router.query.env as string; const envQuery = router.query.env as string;
const permission = useProjectPermission();
const secretContainer = useRef<HTMLDivElement | null>(null); const secretContainer = useRef<HTMLDivElement | null>(null);
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([ const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
@@ -181,17 +184,6 @@ export const DashboardPage = withProjectPermission(
const { data: userAction } = useGetUserAction(USER_ACTION_PUSH); const { data: userAction } = useGetUserAction(USER_ACTION_PUSH);
const hasUserPushed = Boolean(userAction); const hasUserPushed = Boolean(userAction);
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
workspaceId,
onSuccess: (data) => {
// get an env with one of the access available
const env = data.find(({ isReadDenied, isWriteDenied }) => !isWriteDenied || !isReadDenied);
if (env && data?.map((wsenv) => wsenv.slug).includes(envQuery)) {
setSelectedEnv(data?.filter((dp) => dp.slug === envQuery)[0]);
}
}
});
const { data: secretVersion } = useGetSecretVersion({ const { data: secretVersion } = useGetSecretVersion({
limit: 10, limit: 10,
offset: 0, offset: 0,
@@ -267,6 +259,24 @@ export const DashboardPage = withProjectPermission(
folderId folderId
}); });
const secretPath = `/${(folderData?.dir || [])
?.filter(({ name }) => name !== "root")
.join("/")}`;
const userAvailableEnvs = currentWorkspace?.environments?.filter(({ slug }) =>
permission.can(
ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath })
)
);
useEffect(() => {
if (!isLoading && currentWorkspace) {
const env = userAvailableEnvs?.find(({ slug }) => slug === envQuery);
if (env) setSelectedEnv(env);
}
}, [isLoading, workspaceId, userAvailableEnvs]);
// This is for dnd-kit. As react-query state mutation async // This is for dnd-kit. As react-query state mutation async
// This will act as a placeholder to avoid a glitching animation on dropping items // This will act as a placeholder to avoid a glitching animation on dropping items
const [items, setItems] = useState< const [items, setItems] = useState<
@@ -316,11 +326,19 @@ export const DashboardPage = withProjectPermission(
reset reset
} = method; } = method;
const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" }); const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" });
const isReadOnly = selectedEnv?.isWriteDenied;
const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied; const isReadOnly =
const canDoRollback = !isReadOnly && !isAddOnly; permission.can(
const isSubmitDisabled = ProjectPermissionActions.Read,
isReadOnly || (!isRollbackMode && !isDirty) || isAddOnly || isSubmitting; subject(ProjectPermissionSub.Secrets, { environment: selectedEnvSlug })
) &&
permission.cannot(
ProjectPermissionActions.Edit,
subject(ProjectPermissionSub.Secrets, { environment: selectedEnvSlug })
);
const canDoRollback = !isReadOnly;
const isSubmitDisabled = isReadOnly || (!isRollbackMode && !isDirty) || isSubmitting;
useEffect(() => { useEffect(() => {
if (!isSnapshotChanging && Boolean(snapshotId)) { if (!isSnapshotChanging && Boolean(snapshotId)) {
@@ -437,14 +455,12 @@ export const DashboardPage = withProjectPermission(
} }
// just closing this if save is triggered from drawer // just closing this if save is triggered from drawer
handlePopUpClose("secretDetails"); handlePopUpClose("secretDetails");
// when add only mode remove rest of things not created
const sec = isAddOnly ? userSec.filter(({ _id }) => !_id) : userSec;
// encrypt and format the secrets to batch api format // encrypt and format the secrets to batch api format
// requests = [ {method:"", secret:""} ] // requests = [ {method:"", secret:""} ]
const batchedSecret = transformSecretsToBatchSecretReq( const batchedSecret = transformSecretsToBatchSecretReq(
deletedSecretIds.current, deletedSecretIds.current,
latestFileKey, latestFileKey,
sec, userSec,
secrets?.secrets secrets?.secrets
); );
// type check // type check
@@ -486,7 +502,8 @@ export const DashboardPage = withProjectPermission(
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
if (!window.confirm(leaveConfirmDefaultMessage)) return; if (!window.confirm(leaveConfirmDefaultMessage)) return;
} }
const env = wsEnv?.find((el) => el.slug === slug);
const env = userAvailableEnvs?.find((el) => el.slug === slug);
if (env) setSelectedEnv(env); if (env) setSelectedEnv(env);
const query: Record<string, string> = { ...router.query, env: slug }; const query: Record<string, string> = { ...router.query, env: slug };
delete query.folderId; delete query.folderId;
@@ -748,7 +765,7 @@ export const DashboardPage = withProjectPermission(
const isSecretImportEmpty = !secretImportCfg?.imports?.length; const isSecretImportEmpty = !secretImportCfg?.imports?.length;
const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty; const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty;
if (isSecretsLoading || isEnvListLoading) { if (isSecretsLoading) {
return ( return (
<div className="container mx-auto flex h-1/2 w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex h-1/2 w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" /> <img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
@@ -756,9 +773,29 @@ export const DashboardPage = withProjectPermission(
); );
} }
const userAvailableEnvs = wsEnv?.filter( if (
({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied permission.cannot(
); ProjectPermissionActions.Read,
subject(ProjectPermissionSub.Secrets, { environment: envQuery, secretPath })
)
) {
return (
<div className="container h-full mx-auto flex justify-center items-center">
<div className="rounded-md bg-mineshaft-800 text-bunker-300 p-16 flex space-x-12 items-end">
<div>
<FontAwesomeIcon icon={faLock} size="6x" />
</div>
<div>
<div className="text-4xl font-medium mb-2">Permission Denied</div>
<div className="text-sm">
You do not have permission to this page. <br /> Kindly contact your organization
administrator
</div>
</div>
</div>
</div>
);
}
return ( return (
<div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
@@ -768,7 +805,7 @@ export const DashboardPage = withProjectPermission(
<NavHeader <NavHeader
pageName={t("dashboard.title")} pageName={t("dashboard.title")}
currentEnv={ currentEnv={
userAvailableEnvs?.filter((envir) => envir.slug === envQuery)[0].name || "" userAvailableEnvs?.filter((envir) => envir.slug === envQuery)?.[0]?.name || ""
} }
isFolderMode isFolderMode
folders={folderData?.dir} folders={folderData?.dir}
@@ -869,7 +906,7 @@ export const DashboardPage = withProjectPermission(
}} }}
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />} leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
isLoading={isLoadingSnapshotCount} isLoading={isLoadingSnapshotCount}
isDisabled={!canDoRollback && !isAllowed} isDisabled={!canDoRollback || !isAllowed}
className="h-10" className="h-10"
> >
{snapshotCount} Commits {snapshotCount} Commits
@@ -1037,7 +1074,6 @@ export const DashboardPage = withProjectPermission(
secUniqId={_id} secUniqId={_id}
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
isRollbackMode={isRollbackMode} isRollbackMode={isRollbackMode}
isAddOnly={isAddOnly}
index={index} index={index}
searchTerm={searchFilter} searchTerm={searchFilter}
onSecretDelete={onSecretDelete} onSecretDelete={onSecretDelete}
@@ -58,14 +58,14 @@ const multiEnvApi2Form = (
formVal: TFormSchema["permissions"]["secrets"], formVal: TFormSchema["permissions"]["secrets"],
permission: TProjectPermission permission: TProjectPermission
) => { ) => {
const isCustomRule = Boolean(permission?.condition?.slug); const isCustomRule = Boolean(permission?.conditions?.environment);
// full access // full access
if (isCustomRule && formVal && !formVal?.custom) { if (isCustomRule && formVal && !formVal?.custom) {
formVal.custom = { read: true, edit: true, delete: true, create: true }; formVal.custom = { read: true, edit: true, delete: true, create: true };
} }
const secretEnv = permission?.condition?.slug || "all"; const secretEnv = permission?.conditions?.environment || "all";
const secretPath = permission?.condition?.secretPath; const secretPath = permission?.conditions?.secretPath;
// initialize // initialize
if (formVal && !formVal?.[secretEnv]) { if (formVal && !formVal?.[secretEnv]) {
formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath }; formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath };
@@ -94,7 +94,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
permissions.forEach((permission) => { permissions.forEach((permission) => {
if (["secrets", "folders", "secret-imports"].includes(permission.subject)) { if (["secrets", "folders", "secret-imports"].includes(permission.subject)) {
multiEnvApi2Form(formVal?.secrets, permission); multiEnvApi2Form(formVal[permission.subject], permission);
} else { } else {
// everything else follows same pattern // everything else follows same pattern
// formVal[settings][read | write] = true // formVal[settings][read | write] = true
@@ -134,11 +134,10 @@ const multiEnvForm2Api = (
actions.forEach((action) => { actions.forEach((action) => {
// if not full access for an action // if not full access for an action
if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) { if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) {
permissions.push({ const conditions: Record<string, unknown> = { environment: slug };
action, if (formVal[slug]?.secretPath) conditions.secretPath = formVal[slug].secretPath;
subject,
condition: { slug, secretPath: formVal[slug]?.secretPath } permissions.push({ action, subject, conditions });
});
} }
}); });
}); });
@@ -2,7 +2,13 @@ import { useEffect, 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";
import { faArrowDown, faArrowUp, faFolderBlank, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { subject } from "@casl/ability";
import {
faArrowDown,
faArrowUp,
faFolderBlank,
faMagnifyingGlass
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider"; import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
@@ -27,6 +33,7 @@ import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionSub, ProjectPermissionSub,
useOrganization, useOrganization,
useProjectPermission,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { withProjectPermission } from "@app/hoc"; import { withProjectPermission } from "@app/hoc";
@@ -76,6 +83,7 @@ const SecretOverview = () => {
const { data: latestFileKey } = useGetUserWsKey(workspaceId); const { data: latestFileKey } = useGetUserWsKey(workspaceId);
const [searchFilter, setSearchFilter] = useState(""); const [searchFilter, setSearchFilter] = useState("");
const secretPath = router.query?.secretPath as string; const secretPath = router.query?.secretPath as string;
const permission = useProjectPermission();
useEffect(() => { useEffect(() => {
if (!isWorkspaceLoading && !workspaceId && router.isReady) { if (!isWorkspaceLoading && !workspaceId && router.isReady) {
@@ -83,11 +91,13 @@ const SecretOverview = () => {
} }
}, [isWorkspaceLoading, workspaceId, router.isReady]); }, [isWorkspaceLoading, workspaceId, router.isReady]);
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({ const userAvailableEnvs =
workspaceId currentWorkspace?.environments?.filter(({ slug }) =>
}); permission.can(
ProjectPermissionActions.Read,
const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied) || []; subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath: secretPath || "/" })
)
) || [];
const { const {
data: secrets, data: secrets,
@@ -213,7 +223,7 @@ const SecretOverview = () => {
} }
}; };
if (isEnvListLoading) { if (isWorkspaceLoading) {
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]">
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" /> <img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
@@ -225,9 +235,9 @@ const SecretOverview = () => {
folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading) folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading)
); );
const filteredSecretNames = secKeys?.filter((name) => const filteredSecretNames = secKeys
name.toUpperCase().includes(searchFilter.toUpperCase()) ?.filter((name) => name.toUpperCase().includes(searchFilter.toUpperCase()))
).sort((a, b) => sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a)); .sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a)));
const filteredFolderNames = folderNames?.filter((name) => const filteredFolderNames = folderNames?.filter((name) =>
name.toLowerCase().includes(searchFilter.toLowerCase()) name.toLowerCase().includes(searchFilter.toLowerCase())
); );
@@ -286,7 +296,12 @@ const SecretOverview = () => {
<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-4 pb-3.5"> <div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-4 pb-3.5">
Name Name
<IconButton variant="plain" className="ml-2" ariaLabel="sort" onClick={() => setSortDir(prev => prev === "asc" ? "desc" : "asc")}> <IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={() => setSortDir((prev) => (prev === "asc" ? "desc" : "asc"))}
>
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} /> <FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
</IconButton> </IconButton>
</div> </div>