Compare commits

...

5 Commits

Author SHA1 Message Date
Scott Wilson
61ca617616 improvement: address feedback 2025-07-16 16:20:10 -07:00
Scott Wilson
3fe41f81fe improvement: address feedback 2025-07-16 12:52:05 -07:00
Scott Wilson
621bfe3e60 chore: revert license 2025-07-16 12:17:43 -07:00
Scott Wilson
67ec00d46b feature: add access requests to single env view, with general UI improvements 2025-07-16 12:16:13 -07:00
Scott Wilson
d5043fdba4 Merge pull request #4109 from Infisical/navbar-org-name-truncation
improvement(frontend): prevent organization name wrap in header
2025-07-15 09:26:43 -07:00
7 changed files with 318 additions and 37 deletions

View File

@@ -17,8 +17,8 @@ export type SubscriptionPlan = {
rbac: boolean;
secretVersioning: boolean;
slug: string;
secretApproval: string;
secretRotation: string;
secretApproval: boolean;
secretRotation: boolean;
tier: number;
workspaceLimit: number;
workspacesUsed: number;

View File

@@ -0,0 +1,60 @@
import { useMemo } from "react";
import { useSubscription, useWorkspace } from "@app/context";
import { useGetAccessApprovalPolicies } from "@app/hooks/api";
const matchesPath = (folderPath: string, pattern: string) => {
const normalizedPath = folderPath === "/" ? "/" : folderPath.replace(/\/$/, "");
const normalizedPattern = pattern === "/" ? "/" : pattern.replace(/\/$/, "");
if (normalizedPath === normalizedPattern) {
return true;
}
if (normalizedPattern.endsWith("/**")) {
const basePattern = normalizedPattern.slice(0, -3); // Remove "/**"
// Handle root wildcard "/**"
if (basePattern === "") {
return true;
}
// Check if path starts with the base pattern
if (normalizedPath === basePattern) {
return true;
}
// Check if path is a subdirectory of the base pattern
return normalizedPath.startsWith(`${basePattern}/`);
}
return false;
};
type Params = {
secretPath: string;
environment: string;
};
export const usePathAccessPolicies = ({ secretPath, environment }: Params) => {
const { currentWorkspace } = useWorkspace();
const { subscription } = useSubscription();
const { data: policies } = useGetAccessApprovalPolicies({
projectSlug: currentWorkspace.slug,
options: {
enabled: subscription.secretApproval
}
});
return useMemo(() => {
const pathPolicies = policies?.filter(
(policy) =>
policy.environment.slug === environment && matchesPath(secretPath, policy.secretPath)
);
return {
hasPathPolicies: subscription.secretApproval && Boolean(pathPolicies?.length),
pathPolicies
};
}, [secretPath, environment, policies, subscription.secretApproval]);
};

View File

@@ -79,10 +79,14 @@ type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
export const SpecificPrivilegeSecretForm = ({
privilege,
policies,
onClose
onClose,
selectedActions = [],
secretPath: initialSecretPath
}: {
privilege?: TProjectUserPrivilege;
policies?: TAccessApprovalPolicy[];
selectedActions?: ProjectPermissionActions[];
secretPath?: string;
onClose?: () => void;
}) => {
const { currentWorkspace } = useWorkspace();
@@ -126,10 +130,11 @@ export const SpecificPrivilegeSecretForm = ({
}
: {
environmentSlug: currentWorkspace.environments?.[0]?.slug,
read: false,
edit: false,
create: false,
delete: false,
secretPath: initialSecretPath,
read: selectedActions.includes(ProjectPermissionActions.Read),
edit: selectedActions.includes(ProjectPermissionActions.Edit),
create: selectedActions.includes(ProjectPermissionActions.Create),
delete: selectedActions.includes(ProjectPermissionActions.Delete),
temporaryAccess: {
isTemporary: false
}
@@ -281,6 +286,8 @@ export const SpecificPrivilegeSecretForm = ({
isDisabled={isMemberEditDisabled}
className="w-full bg-mineshaft-900 hover:bg-mineshaft-800"
onValueChange={(e) => onChange(e)}
position="popper"
dropdownContainerClassName="max-w-none"
>
{currentWorkspace?.environments?.map(({ slug, id, name }) => (
<SelectItem value={slug} key={id}>
@@ -309,6 +316,8 @@ export const SpecificPrivilegeSecretForm = ({
className="w-full hover:bg-mineshaft-800"
placeholder="Select a secret path"
onValueChange={(e) => field.onChange(e)}
position="popper"
dropdownContainerClassName="max-w-none"
>
{selectablePaths.map((path) => (
<SelectItem value={path} key={path}>
@@ -636,6 +645,7 @@ export const SpecificPrivilegeSecretForm = ({
{!!policies && (
<Button
type="submit"
variant="outline_bg"
isLoading={privilegeForm.formState.isSubmitting || requestAccess.isPending}
isDisabled={
isMemberEditDisabled ||
@@ -647,7 +657,7 @@ export const SpecificPrivilegeSecretForm = ({
className="mt-4"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
>
Request access
Request Access
</Button>
)}
</form>

View File

@@ -1,15 +1,19 @@
import { Modal, ModalContent } from "@app/components/v2";
import { ProjectPermissionActions } from "@app/context";
import { TAccessApprovalPolicy } from "@app/hooks/api/types";
import { SpecificPrivilegeSecretForm } from "@app/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection";
export const RequestAccessModal = ({
isOpen,
onOpenChange,
policies
policies,
...props
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
policies: TAccessApprovalPolicy[];
selectedActions?: ProjectPermissionActions[];
secretPath?: string;
}) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -18,7 +22,11 @@ export const RequestAccessModal = ({
title="Request Access"
subTitle="Request access to any secrets and resources based on the predefined policies."
>
<SpecificPrivilegeSecretForm onClose={() => onOpenChange(false)} policies={policies} />
<SpecificPrivilegeSecretForm
onClose={() => onOpenChange(false)}
policies={policies}
{...props}
/>
</ModalContent>
</Modal>
);

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
import { faArrowDown, faArrowUp, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
@@ -10,10 +10,12 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner } from "@app/components/permissions";
import {
Button,
Checkbox,
ContentLoader,
Modal,
ModalContent,
PageHeader,
Pagination,
Tooltip
} from "@app/components/v2";
@@ -46,7 +48,9 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal";
import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView";
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
@@ -114,7 +118,10 @@ const Page = () => {
const [snapshotId, setSnapshotId] = useState<string | null>(null);
const isRollbackMode = Boolean(snapshotId);
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const);
const { popUp, handlePopUpClose, handlePopUpToggle, handlePopUpOpen } = usePopUp([
"snapshots",
"requestAccess"
] as const);
// env slug
const workspaceId = currentWorkspace?.id || "";
@@ -132,6 +139,26 @@ const Page = () => {
}
);
const canEditSecrets = permission.can(
ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})
);
const canDeleteSecrets = permission.can(
ProjectPermissionSecretActions.Delete,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})
);
const canReadSecretValue = hasSecretReadValueOrDescribePermission(
permission,
ProjectPermissionSecretActions.ReadValue,
@@ -257,6 +284,8 @@ const Page = () => {
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : ""
);
const { pathPolicies, hasPathPolicies } = usePathAccessPolicies({ secretPath, environment });
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
workspaceId,
environment,
@@ -476,8 +505,55 @@ const Page = () => {
setFilter(defaultFilterState);
setDebouncedSearchFilter("");
};
return (
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
<PageHeader
title={
currentWorkspace.environments.find((env) => env.slug === environment)?.name ?? environment
}
description={
<p className="text-md text-bunker-300">
Inject your secrets using
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical CLI
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/api"
target="_blank"
rel="noopener noreferrer"
>
Infisical API
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
, and
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
>
more
</a>
.
</p>
}
/>
<SecretV2MigrationSection />
{!isRollbackMode ? (
<>
@@ -500,8 +576,15 @@ const Page = () => {
importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs}
isPITEnabled={isPITEnabled}
hasPathPolicies={hasPathPolicies}
onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)}
/>
<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={twMerge(
"thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md bg-mineshaft-800 text-left text-sm text-bunker-300",
isNotEmpty && "rounded-b-none"
)}
>
<div className="flex flex-col" id="dashboard">
{isNotEmpty && (
<div
@@ -548,6 +631,62 @@ const Page = () => {
<div className="flex-grow px-4 py-2">Value</div>
</div>
)}
{hasPathPolicies &&
// eslint-disable-next-line no-nested-ternary
(!canReadSecret ? (
<div
className={twMerge(
"flex border-l-2 border-l-primary bg-mineshaft-700 px-4 py-2",
isNotEmpty ? "border-b border-b-mineshaft-600" : ""
)}
>
<div className="flex items-center text-sm">
<FontAwesomeIcon
icon={faInfoCircle}
className="ml-[0.15rem] mr-[1.65rem] text-primary"
/>
<span>You do not have permission to read secrets in this folder</span>
</div>
<Button
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() =>
handlePopUpOpen("requestAccess", [ProjectPermissionActions.Read])
}
>
Request Access
</Button>
</div>
) : !canEditSecrets || !canDeleteSecrets ? (
<div className="flex border-b border-l-2 border-b-mineshaft-600 border-l-primary bg-mineshaft-700 px-4 py-2">
<div className="flex items-center text-sm">
<FontAwesomeIcon
icon={faInfoCircle}
className="ml-[0.15rem] mr-[1.65rem] text-primary"
/>
<span>
You do not have permission to {!canEditSecrets ? "edit" : ""}
{!canEditSecrets && !canDeleteSecrets ? " or " : ""}
{!canDeleteSecrets ? "delete" : ""} secrets in this folder
</span>
</div>
<Button
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() =>
handlePopUpOpen("requestAccess", [
...(!canEditSecrets ? [ProjectPermissionActions.Edit] : []),
...(!canDeleteSecrets ? [ProjectPermissionActions.Delete] : [])
])
}
>
Request Access
</Button>
</div>
) : null)}
{canReadSecretImports && Boolean(imports?.length) && (
<SecretImportListView
searchTerm={debouncedSearchFilter}
@@ -636,6 +775,17 @@ const Page = () => {
/>
</ModalContent>
</Modal>
{!!pathPolicies && (
<RequestAccessModal
policies={pathPolicies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
handlePopUpClose("requestAccess");
}}
selectedActions={popUp.requestAccess.data}
secretPath={pathPolicies?.[0]?.secretPath}
/>
)}
<SecretDropzone
environment={environment}
workspaceId={workspaceId}

View File

@@ -46,6 +46,7 @@ import {
DropdownSubMenuTrigger,
IconButton,
Modal,
ModalClose,
ModalContent,
Tooltip
} from "@app/components/v2";
@@ -53,11 +54,13 @@ import {
ProjectPermissionActions,
ProjectPermissionDynamicSecretActions,
ProjectPermissionSub,
useProjectPermission,
useSubscription,
useWorkspace
} from "@app/context";
import {
ProjectPermissionCommitsActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions
} from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks";
@@ -127,6 +130,8 @@ type Props = {
}[];
}[];
isPITEnabled: boolean;
onRequestAccess: (actions: ProjectPermissionActions[]) => void;
hasPathPolicies: boolean;
};
export const ActionBar = ({
@@ -147,7 +152,9 @@ export const ActionBar = ({
protectedBranchPolicyName,
importedBy,
isPITEnabled = false,
usedBySecretSyncs
usedBySecretSyncs,
onRequestAccess,
hasPathPolicies
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
@@ -159,7 +166,8 @@ export const ActionBar = ({
"misc",
"upgradePlan",
"replicateFolder",
"confirmUpload"
"confirmUpload",
"requestAccess"
] as const);
const isProtectedBranch = Boolean(protectedBranchPolicyName);
const { subscription } = useSubscription();
@@ -180,6 +188,7 @@ export const ActionBar = ({
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const handleFolderCreate = async (folderName: string, description: string | null) => {
try {
@@ -807,27 +816,50 @@ export const ActionBar = ({
</ProjectPermissionCan>
</div>
<div className="flex items-center">
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
>
{(isAllowed) => (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => openPopUp(PopUpNames.CreateSecretForm)}
className="h-10 rounded-r-none"
isDisabled={!isAllowed}
>
Add Secret
</Button>
)}
</ProjectPermissionCan>
{hasPathPolicies ? (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() =>
permission.can(
ProjectPermissionSecretActions.Create,
subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})
)
? openPopUp(PopUpNames.CreateSecretForm)
: handlePopUpOpen("requestAccess", [ProjectPermissionActions.Create])
}
className="h-10 rounded-r-none"
>
Add Secret
</Button>
) : (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
>
{(isAllowed) => (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => openPopUp(PopUpNames.CreateSecretForm)}
className="h-10 rounded-r-none"
isDisabled={!isAllowed}
>
Add Secret
</Button>
)}
</ProjectPermissionCan>
)}
<DropdownMenu
open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
@@ -1166,6 +1198,27 @@ export const ActionBar = ({
)}
</ModalContent>
</Modal>
<Modal
isOpen={popUp?.requestAccess?.isOpen}
onOpenChange={(open) => handlePopUpToggle("requestAccess", open)}
>
<ModalContent title="Access Restricted">
<p className="mb-2 text-bunker-300">You do not have permission to perform this action.</p>
<p className="text-bunker-300">Request access to perform this action in this folder.</p>
<div className="mt-8 flex items-center gap-4">
<ModalClose asChild>
<Button onClick={() => onRequestAccess(popUp?.requestAccess.data)}>
Request Access
</Button>
</ModalClose>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</ModalContent>
</Modal>
</>
);
};

View File

@@ -262,7 +262,7 @@ export const SecretDropzone = ({
className={twMerge(
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 px-2 py-4 text-sm text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
isDragActive && "opacity-100",
!isSmaller && "mx-auto w-full max-w-3xl flex-col space-y-4 py-20",
!isSmaller && "mx-auto mt-40 w-full max-w-3xl flex-col space-y-4 py-20",
isLoading && "bg-bunker-800"
)}
>