feature: add access requests to single env view, with general UI improvements

This commit is contained in:
Scott Wilson
2025-07-16 12:16:13 -07:00
parent d5043fdba4
commit 67ec00d46b
8 changed files with 323 additions and 37 deletions

View File

@@ -40,7 +40,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
status: null,
trial_end: null,
has_used_trial: true,
secretApproval: false,
secretApproval: true,
secretRotation: false,
caCrl: false,
instanceUserManagement: false,

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,62 @@
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(/\/$/, "");
console.log(normalizedPath, normalizedPattern);
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,22 @@
import { Modal, ModalContent } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
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,
shouldShowBanner,
...props
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
policies: TAccessApprovalPolicy[];
selectedActions?: ProjectPermissionActions[];
secretPath?: string;
shouldShowBanner?: boolean;
}) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -18,7 +25,21 @@ 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} />
{shouldShowBanner && (
<NoticeBannerV2
className="mb-3"
title="You do not have permission to perform this action"
>
<p className="text-sm text-mineshaft-300">
Request access to gain access to this action.
</p>
</NoticeBannerV2>
)}
<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,68 @@ 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 permission to read secrets for this path</span>
</div>
<Button
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() =>
handlePopUpOpen("requestAccess", {
actions: [ProjectPermissionActions.Read],
shouldShowBanner: false
})
}
>
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 permission to {!canEditSecrets ? "edit" : ""}
{!canEditSecrets && !canDeleteSecrets ? " or " : ""}
{!canDeleteSecrets ? "delete" : ""} secrets for this path
</span>
</div>
<Button
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() =>
handlePopUpOpen("requestAccess", {
actions: [
...(!canEditSecrets ? [ProjectPermissionActions.Edit] : []),
...(!canDeleteSecrets ? [ProjectPermissionActions.Delete] : [])
],
shouldShowBanner: false
})
}
>
Request Access
</Button>
</div>
) : null)}
{canReadSecretImports && Boolean(imports?.length) && (
<SecretImportListView
searchTerm={debouncedSearchFilter}
@@ -636,6 +781,18 @@ const Page = () => {
/>
</ModalContent>
</Modal>
{!!pathPolicies && (
<RequestAccessModal
policies={pathPolicies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
handlePopUpClose("requestAccess");
}}
selectedActions={popUp.requestAccess.data?.actions}
shouldShowBanner={popUp.requestAccess.data?.shouldShowBanner}
secretPath={pathPolicies?.[0]?.secretPath}
/>
)}
<SecretDropzone
environment={environment}
workspaceId={workspaceId}

View File

@@ -53,11 +53,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 +129,11 @@ type Props = {
}[];
}[];
isPITEnabled: boolean;
onRequestAccess: (params: {
actions: ProjectPermissionActions[];
shouldShowBanner: boolean;
}) => void;
hasPathPolicies: boolean;
};
export const ActionBar = ({
@@ -147,7 +154,9 @@ export const ActionBar = ({
protectedBranchPolicyName,
importedBy,
isPITEnabled = false,
usedBySecretSyncs
usedBySecretSyncs,
onRequestAccess,
hasPathPolicies
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder",
@@ -180,6 +189,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 +817,53 @@ 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)
: onRequestAccess({
actions: [ProjectPermissionActions.Create],
shouldShowBanner: true
})
}
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)}

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"
)}
>