mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(rbac): fixed merge conflicts and resolved some more issues with permission checks
This commit is contained in:
@@ -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) => {
|
||||
await TokenVersion.updateMany(
|
||||
{
|
||||
|
||||
@@ -203,7 +203,6 @@ export const getUserWorkspacePermissions = async (req: Request, res: Response) =
|
||||
params: { workspaceId }
|
||||
} = await validateRequest(GetUserProjectPermission, req);
|
||||
const { permission } = await getUserProjectPermissions(req.user.id, workspaceId);
|
||||
|
||||
res.status(200).json({
|
||||
data: {
|
||||
permissions: packRules(permission.rules)
|
||||
|
||||
@@ -77,16 +77,6 @@ export const batchSecrets = async (req: Request, res: Response) => {
|
||||
|
||||
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) {
|
||||
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);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
|
||||
@@ -44,6 +44,4 @@ const membershipOrgSchema = new Schema(
|
||||
}
|
||||
);
|
||||
|
||||
const MembershipOrg = model<IMembershipOrg>("MembershipOrg", membershipOrgSchema);
|
||||
|
||||
export default MembershipOrg;
|
||||
export const MembershipOrg = model<IMembershipOrg>("MembershipOrg", membershipOrgSchema);
|
||||
|
||||
@@ -38,8 +38,6 @@ router.post(
|
||||
authController.checkAuth
|
||||
);
|
||||
|
||||
router.get("/common-passwords", authLimiter, authController.getCommonPasswords);
|
||||
|
||||
router.delete(
|
||||
// TODO endpoint: deprecate (moved to DELETE v2/users/me/sessions)
|
||||
"/sessions",
|
||||
|
||||
@@ -93,11 +93,11 @@ router.get(
|
||||
);
|
||||
|
||||
router.get(
|
||||
"/:integrationAuthId/teamcity/build-configs",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT],
|
||||
}),
|
||||
integrationAuthController.getIntegrationAuthTeamCityBuildConfigs
|
||||
"/:integrationAuthId/teamcity/build-configs",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT]
|
||||
}),
|
||||
integrationAuthController.getIntegrationAuthTeamCityBuildConfigs
|
||||
);
|
||||
|
||||
router.delete(
|
||||
|
||||
@@ -219,7 +219,6 @@ export const getUserProjectPermissions = async (userId: string, workspaceId: str
|
||||
}>("customRole")
|
||||
.exec();
|
||||
|
||||
console.log(membership, userId, workspaceId);
|
||||
if (!membership || (membership.role === "custom" && !membership.customRole)) {
|
||||
throw UnauthorizedRequestError({ message: "User doesn't belong to organization" });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { MembershipNotFoundError } from "../utils/errors";
|
||||
import { AuthData } from "../interfaces/middleware";
|
||||
import { ActorType } from "../ee/models";
|
||||
import { z } from "zod";
|
||||
import { ADMIN, CUSTOM, MEMBER, VIEWER } from "../variables";
|
||||
|
||||
/**
|
||||
* Validate authenticated clients for membership with id [membershipId] based
|
||||
@@ -66,7 +65,7 @@ export const DeleteMembershipV1 = z.object({
|
||||
|
||||
export const ChangeMembershipRoleV1 = z.object({
|
||||
body: z.object({
|
||||
role: z.enum([ADMIN, VIEWER, MEMBER, CUSTOM])
|
||||
role: z.string().trim()
|
||||
}),
|
||||
params: z.object({ membershipId: z.string().trim() })
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export const CreateRoleSchema = z.object({
|
||||
.object({
|
||||
subject: 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()
|
||||
})
|
||||
@@ -31,7 +31,7 @@ export const UpdateRoleSchema = z.object({
|
||||
.object({
|
||||
subject: 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()
|
||||
.optional()
|
||||
|
||||
@@ -18,13 +18,13 @@ export type TRole<T extends string | undefined> = {
|
||||
export type TPermission = TWorkspacePermission | TGeneralPermission;
|
||||
|
||||
type TGeneralPermission = {
|
||||
condition?: Record<string, any>;
|
||||
conditions?: Record<string, any>;
|
||||
action: "read" | "edit" | "create" | "delete";
|
||||
subject: "member" | "role" | "incident-contact" | "sso" | "billing" | "settings";
|
||||
};
|
||||
|
||||
type TWorkspacePermission = {
|
||||
condition?: Record<string, any>;
|
||||
conditions?: Record<string, any>;
|
||||
action: "read" | "create";
|
||||
subject: "workspace";
|
||||
};
|
||||
@@ -32,7 +32,7 @@ type TWorkspacePermission = {
|
||||
export type TProjectPermission = TProjectGeneralPermission | TProjectWorkspacePermission;
|
||||
|
||||
type TProjectGeneralPermission = {
|
||||
condition?: Record<string, any>;
|
||||
conditions?: Record<string, any>;
|
||||
action: "read" | "edit" | "create" | "delete";
|
||||
subject:
|
||||
| "member"
|
||||
@@ -46,7 +46,7 @@ type TProjectGeneralPermission = {
|
||||
};
|
||||
|
||||
type TProjectWorkspacePermission = {
|
||||
condition?: Record<string, any>;
|
||||
conditions?: Record<string, any>;
|
||||
action: "delete" | "edit";
|
||||
subject: "workspace";
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
faEyeSlash,
|
||||
faFileImport,
|
||||
faFolderPlus,
|
||||
faLock,
|
||||
faMagnifyingGlass,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -60,6 +62,7 @@ import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useOrganization,
|
||||
useProjectPermission,
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
@@ -79,7 +82,6 @@ import {
|
||||
useGetSecretVersion,
|
||||
useGetSnapshotSecrets,
|
||||
useGetUserAction,
|
||||
useGetUserWsEnvironments,
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceSecretSnapshots,
|
||||
useGetWsSnapshotCount,
|
||||
@@ -139,6 +141,7 @@ export const DashboardPage = withProjectPermission(
|
||||
const { createNotification } = useNotificationContext();
|
||||
const queryClient = useQueryClient();
|
||||
const envQuery = router.query.env as string;
|
||||
const permission = useProjectPermission();
|
||||
|
||||
const secretContainer = useRef<HTMLDivElement | null>(null);
|
||||
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
||||
@@ -181,17 +184,6 @@ export const DashboardPage = withProjectPermission(
|
||||
const { data: userAction } = useGetUserAction(USER_ACTION_PUSH);
|
||||
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({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
@@ -267,6 +259,24 @@ export const DashboardPage = withProjectPermission(
|
||||
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 will act as a placeholder to avoid a glitching animation on dropping items
|
||||
const [items, setItems] = useState<
|
||||
@@ -316,11 +326,19 @@ export const DashboardPage = withProjectPermission(
|
||||
reset
|
||||
} = method;
|
||||
const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" });
|
||||
const isReadOnly = selectedEnv?.isWriteDenied;
|
||||
const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied;
|
||||
const canDoRollback = !isReadOnly && !isAddOnly;
|
||||
const isSubmitDisabled =
|
||||
isReadOnly || (!isRollbackMode && !isDirty) || isAddOnly || isSubmitting;
|
||||
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: selectedEnvSlug })
|
||||
) &&
|
||||
permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: selectedEnvSlug })
|
||||
);
|
||||
|
||||
const canDoRollback = !isReadOnly;
|
||||
const isSubmitDisabled = isReadOnly || (!isRollbackMode && !isDirty) || isSubmitting;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSnapshotChanging && Boolean(snapshotId)) {
|
||||
@@ -437,14 +455,12 @@ export const DashboardPage = withProjectPermission(
|
||||
}
|
||||
// just closing this if save is triggered from drawer
|
||||
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
|
||||
// requests = [ {method:"", secret:""} ]
|
||||
const batchedSecret = transformSecretsToBatchSecretReq(
|
||||
deletedSecretIds.current,
|
||||
latestFileKey,
|
||||
sec,
|
||||
userSec,
|
||||
secrets?.secrets
|
||||
);
|
||||
// type check
|
||||
@@ -486,7 +502,8 @@ export const DashboardPage = withProjectPermission(
|
||||
// eslint-disable-next-line no-alert
|
||||
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);
|
||||
const query: Record<string, string> = { ...router.query, env: slug };
|
||||
delete query.folderId;
|
||||
@@ -748,7 +765,7 @@ export const DashboardPage = withProjectPermission(
|
||||
const isSecretImportEmpty = !secretImportCfg?.imports?.length;
|
||||
const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty;
|
||||
|
||||
if (isSecretsLoading || isEnvListLoading) {
|
||||
if (isSecretsLoading) {
|
||||
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]">
|
||||
<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(
|
||||
({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied
|
||||
);
|
||||
if (
|
||||
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 (
|
||||
<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
|
||||
pageName={t("dashboard.title")}
|
||||
currentEnv={
|
||||
userAvailableEnvs?.filter((envir) => envir.slug === envQuery)[0].name || ""
|
||||
userAvailableEnvs?.filter((envir) => envir.slug === envQuery)?.[0]?.name || ""
|
||||
}
|
||||
isFolderMode
|
||||
folders={folderData?.dir}
|
||||
@@ -869,7 +906,7 @@ export const DashboardPage = withProjectPermission(
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
isLoading={isLoadingSnapshotCount}
|
||||
isDisabled={!canDoRollback && !isAllowed}
|
||||
isDisabled={!canDoRollback || !isAllowed}
|
||||
className="h-10"
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
@@ -1037,7 +1074,6 @@ export const DashboardPage = withProjectPermission(
|
||||
secUniqId={_id}
|
||||
isReadOnly={isReadOnly}
|
||||
isRollbackMode={isRollbackMode}
|
||||
isAddOnly={isAddOnly}
|
||||
index={index}
|
||||
searchTerm={searchFilter}
|
||||
onSecretDelete={onSecretDelete}
|
||||
|
||||
@@ -58,14 +58,14 @@ const multiEnvApi2Form = (
|
||||
formVal: TFormSchema["permissions"]["secrets"],
|
||||
permission: TProjectPermission
|
||||
) => {
|
||||
const isCustomRule = Boolean(permission?.condition?.slug);
|
||||
const isCustomRule = Boolean(permission?.conditions?.environment);
|
||||
// full access
|
||||
if (isCustomRule && formVal && !formVal?.custom) {
|
||||
formVal.custom = { read: true, edit: true, delete: true, create: true };
|
||||
}
|
||||
|
||||
const secretEnv = permission?.condition?.slug || "all";
|
||||
const secretPath = permission?.condition?.secretPath;
|
||||
const secretEnv = permission?.conditions?.environment || "all";
|
||||
const secretPath = permission?.conditions?.secretPath;
|
||||
// initialize
|
||||
if (formVal && !formVal?.[secretEnv]) {
|
||||
formVal[secretEnv] = { read: false, edit: false, create: false, delete: false, secretPath };
|
||||
@@ -94,7 +94,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
||||
|
||||
permissions.forEach((permission) => {
|
||||
if (["secrets", "folders", "secret-imports"].includes(permission.subject)) {
|
||||
multiEnvApi2Form(formVal?.secrets, permission);
|
||||
multiEnvApi2Form(formVal[permission.subject], permission);
|
||||
} else {
|
||||
// everything else follows same pattern
|
||||
// formVal[settings][read | write] = true
|
||||
@@ -134,11 +134,10 @@ const multiEnvForm2Api = (
|
||||
actions.forEach((action) => {
|
||||
// if not full access for an action
|
||||
if (!formVal?.all?.[action] && action !== "secretPath" && formVal?.[slug]?.[action]) {
|
||||
permissions.push({
|
||||
action,
|
||||
subject,
|
||||
condition: { slug, secretPath: formVal[slug]?.secretPath }
|
||||
});
|
||||
const conditions: Record<string, unknown> = { environment: slug };
|
||||
if (formVal[slug]?.secretPath) conditions.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 Link from "next/link";
|
||||
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 { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
@@ -27,6 +33,7 @@ import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useOrganization,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { withProjectPermission } from "@app/hoc";
|
||||
@@ -76,6 +83,7 @@ const SecretOverview = () => {
|
||||
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const secretPath = router.query?.secretPath as string;
|
||||
const permission = useProjectPermission();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWorkspaceLoading && !workspaceId && router.isReady) {
|
||||
@@ -83,11 +91,13 @@ const SecretOverview = () => {
|
||||
}
|
||||
}, [isWorkspaceLoading, workspaceId, router.isReady]);
|
||||
|
||||
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
|
||||
workspaceId
|
||||
});
|
||||
|
||||
const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied) || [];
|
||||
const userAvailableEnvs =
|
||||
currentWorkspace?.environments?.filter(({ slug }) =>
|
||||
permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment: slug, secretPath: secretPath || "/" })
|
||||
)
|
||||
) || [];
|
||||
|
||||
const {
|
||||
data: secrets,
|
||||
@@ -213,7 +223,7 @@ const SecretOverview = () => {
|
||||
}
|
||||
};
|
||||
|
||||
if (isEnvListLoading) {
|
||||
if (isWorkspaceLoading) {
|
||||
return (
|
||||
<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" />
|
||||
@@ -225,9 +235,9 @@ const SecretOverview = () => {
|
||||
folders?.some(({ isLoading }) => !isLoading) && secrets?.some(({ isLoading }) => !isLoading)
|
||||
);
|
||||
|
||||
const filteredSecretNames = secKeys?.filter((name) =>
|
||||
name.toUpperCase().includes(searchFilter.toUpperCase())
|
||||
).sort((a, b) => sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a));
|
||||
const filteredSecretNames = secKeys
|
||||
?.filter((name) => name.toUpperCase().includes(searchFilter.toUpperCase()))
|
||||
.sort((a, b) => (sortDir === "asc" ? a.localeCompare(b) : b.localeCompare(a)));
|
||||
const filteredFolderNames = folderNames?.filter((name) =>
|
||||
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">
|
||||
<div className="flex items-center border-b border-r border-mineshaft-600 px-5 pt-4 pb-3.5">
|
||||
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} />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user